Repository navigation
feat(memory): add the person-owned preference kind (#370) - #377
Merged
Merged
Conversation
Codex Review Summary: ❌ FailedThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
entelecheia
force-pushed
the
feat/370-preference-kind
branch
from
November 11, 2026 10:58
ec1b37b to
3b7aae2
Compare
A preference is a standing instruction ("always answer in Korean") that no
path or intent ranking can surface, so it gets its own kind, its own brief
slot and its own prebuilt SessionStart sidecar.
The kind is not additive on the wire: a released reader passes the row's
entryId hash but rejects the payload enum, skips the row, and then refuses
its own machine's whole sync publish over it. Measured on the published
0.24.0: 0 rows listed and syncMemory returning ok:false, pushed:false,
entries:0 with "this machine's long-term segment is not readable as
complete valid rows". So the two own-segment writers (appendBody and
importNote) plus candidate acceptance fail closed until a person records a
curated or home floor of 0.25.0 in formats.json, where an older reader
refuses the home at store open instead. Reads, briefs and queueing need no
floor; a refused acceptance leaves the candidate pending.
Briefs show up to five current preferences (project plus shared, newest
first): amem_brief reserves at most half its budget for whole 240-byte
lines, and the hook reads brief-preferences.json, written beside
brief-curated.json and excluded from that section, so no row is shown
twice. INDEX_VERSION 4 names its own index file.
Refs #370.
entelecheia
force-pushed
the
feat/370-preference-kind
branch
from
November 11, 2026 11:26
3b7aae2 to
60f7023
Compare
…west rows readPreferences took `ORDER BY at DESC LIMIT limit+1` over every preference row and filtered superseded, forgotten, expired and promoted-source rows only after slicing. A run of newer dead rules could therefore fill the whole slice: with five live rules and a six-deep supersede chain written after them, the slot returned one row while six live rules existed. Liveness cannot be decided in SQL here: supersession folds from a newer row's `supersedes`, and a forgotten successor releases its target again. The read now pages the kind predicate by `at` across every store, resolves liveness with the same live views `list` uses, and only then takes the newest `limit`, so `truncated` counts live rules rather than raw rows. Refs #370
…erence kind AC4 asks for the new kind to be tested against a released reader, and the suite's own N-1 block did not mention it. Three proofs now run against the published previous release through AMEM_PREV_ROOT: (a) one preference row in this machine's own long-term segment makes the previous release's syncMemory refuse the whole publish (ok/pushed false, entries 0, "not readable as complete valid rows"), while a control home carrying only an ordinary note publishes; (b) the person-recorded curated floor at the introducing release makes that release refuse at store open with code format_floor, not with a sync error; (c) its incremental index equals a rebuild and a rebuild from a deleted index, all three drop the preference row and keep the ordinary notes, and the segment offset equals the segment size. The row is written by a copy of this build's own writer whose manifest sits at PREFERENCE_MIN_READER, which is the shipping configuration: this build's own opener refuses a floor above its own version by design. Every arm proves the row reached the segment, so none passes vacuously. Refs #370
…he floor I2: buildPreferenceBriefCache stored noteDisplay's 240-byte clip and only set `partial`, so an over-long standing instruction reached SessionStart as half a rule that reads as the whole one. amem_brief already drops such a line whole; the sidecar now applies the same rule and reports truncation instead. I3: readPreferenceBriefCache served brief-preferences.json without the gate readCuratedBriefCache applies, so a home whose person-recorded floor is above this reader — or whose floor does not admit the kind at all — still got prebuilt preference rows. It now asserts the curated floor and requires the floor to reach PREFERENCE_MIN_READER before it serves anything. Refs #370
… drop rule The review's two nits were both about the kind's lifetime. `amem note add --kind preference --tier short` wrote an expiring local preference, although a preference is a standing instruction: the command now refuses a short tier for that kind and leaves every other kind's tiers alone. `amem decay --apply` demoted preferences older than 90 days. A preference never expires automatically, so decay leaves it out of the demotion plan, and naming one by id is refused as an ineligible target like any other, rather than silently skipped. contracts.md now states the accepted wording drift: a rule is shown whole or not at all in both brief slots, so a rule that does not fit its slot line or the remaining fixed share is dropped and the section reports partial instead of carrying half a standing instruction. It also records the lifetime rules above. Refs #370
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
preferenceis a person-owned standing instruction ("always answer in Korean"). It is the fifth curated note kind: long-term, project orsharedscope, superseded and forgotten like any note, and never inferred: consolidation skips it at both the row and the group level.Why the write is floor-gated
The kind is not additive on the wire. A released reader whose
KINDSpredates it accepts the row'sentryIdhash but rejects the payload enum, soMemoryStore.apply()skips the row andcheckedEntryinsidescreenOwnSegmentfails on the machine's ownlog/<machineId>.jsonl, which makes that host's wholesyncMemoryrefuse.Measured against the published
@staix/agent-mem@0.24.0(installed in a temp dir; the row is written with 0.24.0's ownentryId, so only the payload enum differs):The same 0.24.0 on the same home once a person records the floor:
So one guard (
assertPreferenceWritable) is called from both own-segment writers (appendBody, which every note goes through,amem note addincluded, andimportNote) and fromwithAcceptedNotebeforebeginAcceptance, so a refused acceptance leaves the candidatependingand replayable. It refuses unless the home'scuratedorhomefloor is at leastPREFERENCE_MIN_READER(0.25.0), names the cause, the exact record and the contract section, and never writesformats.json. Reads, briefs andamem_rememberqueueing need no floor.Boundary, out of scope by design: a pulled segment is another machine's file and stays inert on an older host, and shard migration preserves the source path, so the
ponytail:note atshard-migration.ts:427still points to a migration-side floor check. No strict migration refusal.Lifetime: long-term, and out of decay
A standing instruction does not expire, so two paths hold that:
amem note add --kind preference --tier shortis refused (--kind preference is long-term; --tier takes long only). Every other kind keeps both tiers.amem decayleaves preferences out of the demotion plan, and naming one by id is refused as an ineligible target like any other rather than silently skipped.Briefs
A standing instruction has no anchor to rank by, so it gets its own slot:
amem_brief(intent-brief.ts): up to 5 current preferences, this project's plus everysharedone, newest first; at most half the budget; whole 240-byte lines only, so an over-long rule is dropped and the brief reportstruncatedinstead of clipping half a sentence.session-brief.ts): the same rows from its own prebuilt sidecarbrief-preferences.json, written byprepareCuratedBriefsbesidebrief-curated.jsonunder the same profile binding, 24-hour freshness and 32 KiB cap; at most 480 bytes, emitted before the curated rows. The curated section excludes preference rows, so no row is shown twice or takes a curated slot. A missing sidecar means no slot and no coverage reason, so a home without preferences keeps a byte-identical brief.readCuratedBriefCache: it asserts the curated format floor and serves nothing unless the floor reachesPREFERENCE_MIN_READER, so a home whose recorded floor is above this reader, or whose floor does not admit the kind, gets no prebuilt preference rows.Storage:
KINDSgainspreference(PROMOTED_KINDSunchanged), the note payload enum gains it,INDEX_VERSION3 to 4 (index-v4.db,index-v2-i4.db) withSHARED_INDEX_VERSIONleft at 3.Review round 1
readPreferencesslicedORDER BY at DESC LIMIT limit+1over every preference row and filtered superseded, forgotten and promoted-source rows only afterwards, so a run of newer dead rules filled the slotatacross every store, resolves liveness with the same live viewslistuses, and only then takes the newestlimit;truncatedcounts live rules. Liveness cannot be decided in SQL:supersededByfolds from a newer row'ssupersedes, and a forgotten successor releases its target againbuildPreferenceBriefCachestorednoteDisplay's 240-byte clip, so SessionStart received half a rule that reads as the whole oneamem_briefalready appliedreadPreferenceBriefCacheserved the sidecar without the floor gatereadCuratedBriefCacheappliesassertFormatFloor(base, "curated")plus aPREFERENCE_MIN_READERcheck before anything is readtest/previous-release.test.tsformat_floorat store open, (c) index equality plus the above--tier shortwrote an expiring preferenceamem decay --applydemoted preferences older than 90 daysThe accepted wording drift is now stated in
docs/contracts.md§Preference kind (#370): a rule is always shown whole or not at all in both slots, so the fixed byte share is spent on lines, and a rule that does not fit its slot line or the remaining share is dropped while the section reportspartial(truncatedinamem_brief).Why the N-1 row needs a writer pinned at the floor
PREFERENCE_MIN_READER(0.25.0) is above this build's ownREADER_VERSION(0.24.0) until the release PR ships the kind, andassertFormatFloorrefuses a home whose floor exceeds the reader. So the N-1 fixture writes its row with an isolated copy of this build'ssrcbeside a manifest atPREFERENCE_MIN_READER, which is the shipping configuration. Every arm asserts the segment contains"kind":"preference", so none passes vacuously, and (a)'s control home (same flow, no preference row) publishesok: true, which is the counterfactual for the refusal.Tests
test/preference-kind.test.ts(8): CLI refusal content and nothing written (not even a segment directory); refusals at curated 0.24.0 and home 0.23.1 withdecisionstill allowed;importNoterefusal;amem_rememberqueues without a floor, acceptance is refused and the candidate stayspending; a floor at the introducing release givescode: "format_floor"at store open with no authored bytes; a reader at 99.0.0 writes both own-segment paths, supersedes, imports and accepts a queued preference, then proves the incremental index equals a full rebuild; the slot behind a longer supersede chain than its own width; the short-tier refusal; decay exclusion and the ineligible-id refusal.test/preference-brief.test.ts(1): theamem_briefslot (ordered, superseded and forgotten excluded, shared kept, over-long dropped whole, empty at a 200-byte budget), the sidecar (whole rules newest first,partialfalse on both, the curated section beside it empty), and a home at a curated floor of 100.0.0 serving no prebuilt rows.kindenum, no op or field moved), andindex-per-versionnow provesindex-v4.dbsits beside 0.18.0'sindex.dbwithout either dropping the other.Red proofs at
1577fd2, one revert per fix, every other file at this head (bun test --timeout 20000, focused file per revert):60f7023src/memory/store.ts(I1)Expected - 4 / Received + 0)src/brief-curated.ts(I2)preference-brief.test.ts:98:sidecar.partialis[true, ...], the clipped rule stored as partialsrc/brief-curated-native.ts(I3)preference-brief.test.ts:105:aboveFloor.kindsis["preference","preference"]instead of[]src/memory/note-add.ts(N1)src/memory/decay-review.ts(decay)Each revert fails exactly the test written for it and nothing else, which is what attributes the guard to the change. Restored,
test/preference-kind.test.tsandtest/preference-brief.test.tsare 9 pass, 0 fail at this head.Previous-release proofs, run remotely (
remote_dev.py verifyon the mirrored1577fd2dd6ad, treebdf3d1c0e71a,AMEM_PREV_ROOTfromscripts/previous-release.ts,AMEM_PREV_REQUIRED=1,bun test --timeout 20000 test/previous-release.test.ts): exit 0 in 27 s, 22 pass, 0 fail, 180 expect() calls, including#370 (a)322 ms,#370 (b)141 ms,#370 (c)253 ms.Gates and CI at this head
In this worktree at
1577fd2with a clean tree:bun x tsc --noEmitexit 0,bun scripts/check-boundary.ts→boundary: OK (175 source files),bun scripts/check-docs.tsexit 0 (docs: OK (6 stale documents), the same stale set asmain; the tag workflow's--require-freshis a release step),npm pack --dry-run→ 185 files, andbun test --timeout 20000→ 3762 pass, 24 skip, 2 fail, 3788 tests across 344 files (1222 s).Both failures come from this shell, not from the change: the session that ran the suite carries
AGENTHUB_PEER_ID, whichsrc/cli.ts:142counts as an agent marker, and two tests spawn the CLI with{ ...process.env }.test/mcp-cli.test.ts:55then records the hub peer where it expectscodex, andtest/format-floor.test.ts:95getsamem doctor is not available to an agent-marked sessionwhere it expectsformat floor 99.0.0. Neither file is touched by this branch, and rerunning both with the marker stripped is 7 pass, 0 fail. Hosted CI carries no marker.The hosted
measure (macos-latest)leg failed the previous head (60f7023) on its Stop-hook wall gate (transcript-hooks: wall-miss: p95 153 ms, then 162 ms on the retry, against 150 ms) whilemeasure (ubuntu-latest)and all fourtestshards passed. Two measurements say that is the hosted-macOS wall flake, not this change:mainat224b2bcfails the identicaltranscript-hooks: wall-miss, and the only work this change adds to the drain and publication path that leg times is the preference sidecar, measured on a 40-note home at 0.24 ms median / 0.42 ms p95, beside the curated sidecar's 0.43 ms. At1577fd2the matrix is still running (decisionpassed;measure×2 andtest×4 pending).Compatibility with N-1 (0.24.0)
Storage: existing rows, hashes, schemas, scopes and paths are unchanged; the new kind is refused below the floor rather than written, and the three proofs above show the previous release meeting it: sync refuses and names its own segment, a recorded floor refuses at open with
format_floor, and its index drops the row while incremental, rebuilt and rebuilt-from-deleted agree. Indexes: version 4 names its own file, so each release keeps a warm index on one home and neither drops the other's; an older index skips preference rows, so sharing one file would be wrong. The cost is one refresh for a reader whose own file this release no longer advances, and 0.24.0 rebuilds from the log and answers. Brief cache:brief-curated.jsonkeeps its shape; the preference sidecar is a new file name and metadata key, so 0.24.0 neither parses nor invalidates it, and the curated file it does read no longer carries preference rows, which only this release writes.Refs #370.