Skip to content

feat(memory): add the person-owned preference kind (#370) - #377

Merged
entelecheia merged 5 commits into
mainfrom
feat/370-preference-kind
Nov 11, 2026
Merged

entelecheia merged 5 commits into
mainfrom
feat/370-preference-kind

Conversation

@entelecheia

@entelecheia entelecheia commented Nov 11, 2026 •

Copy link
Copy Markdown
Member

What

preference is a person-owned standing instruction ("always answer in Korean"). It is the fifth curated note kind: long-term, project or shared scope, 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 KINDS predates it accepts the row's entryId hash but rejects the payload enum, so MemoryStore.apply() skips the row and checkedEntry inside screenOwnSegment fails on the machine's own log/<machineId>.jsonl, which makes that host's whole syncMemory refuse.

Measured against the published @staix/agent-mem@0.24.0 (installed in a temp dir; the row is written with 0.24.0's own entryId, so only the payload enum differs):

{ "rows": 0, "kinds": [],
  "sync": { "ok": false, "pushed": false, "entries": 0,
    "error": "this machine's long-term segment is not readable as complete valid rows, so it was not screened; inspect the local input and reopen the store before retrying" } }

The same 0.24.0 on the same home once a person records the floor:

{ "code": "format_floor",
  "message": "amem 0.24.0 is below the curated format floor 0.25.0 (since 2026-11-11) recorded in .../memory/formats.json; nothing was read or written. Upgrade amem to 0.25.0 or later." }

So one guard (assertPreferenceWritable) is called from both own-segment writers (appendBody, which every note goes through, amem note add included, and importNote) and from withAcceptedNote before beginAcceptance, so a refused acceptance leaves the candidate pending and replayable. It refuses unless the home's curated or home floor is at least PREFERENCE_MIN_READER (0.25.0), names the cause, the exact record and the contract section, and never writes formats.json. Reads, briefs and amem_remember queueing 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 at shard-migration.ts:427 still 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 short is refused (--kind preference is long-term; --tier takes long only). Every other kind keeps both tiers.
  • amem decay leaves 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 every shared one, newest first; at most half the budget; whole 240-byte lines only, so an over-long rule is dropped and the brief reports truncated instead of clipping half a sentence.
  • SessionStart (session-brief.ts): the same rows from its own prebuilt sidecar brief-preferences.json, written by prepareCuratedBriefs beside brief-curated.json under 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.
  • The reader of that sidecar applies the same gates as readCuratedBriefCache: it asserts the curated format floor and serves nothing unless the floor reaches PREFERENCE_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: KINDS gains preference (PROMOTED_KINDS unchanged), the note payload enum gains it, INDEX_VERSION 3 to 4 (index-v4.db, index-v2-i4.db) with SHARED_INDEX_VERSION left at 3.

Review round 1

Finding Fix
I1 readPreferences sliced ORDER BY at DESC LIMIT limit+1 over every preference row and filtered superseded, forgotten and promoted-source rows only afterwards, so a run of newer dead rules filled the slot the read 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; truncated counts live rules. Liveness cannot be decided in SQL: supersededBy folds from a newer row's supersedes, and a forgotten successor releases its target again
I2 buildPreferenceBriefCache stored noteDisplay's 240-byte clip, so SessionStart received half a rule that reads as the whole one the sidecar drops a clipped rule whole and reports truncation, the rule amem_brief already applied
I3 readPreferenceBriefCache served the sidecar without the floor gate readCuratedBriefCache applies assertFormatFloor(base, "curated") plus a PREFERENCE_MIN_READER check before anything is read
I4 AC4 and the agreed previous-release proofs were missing from test/previous-release.test.ts three N-1 proofs, (a) sync refusal with a publishing control, (b) format_floor at store open, (c) index equality plus the above
N1 --tier short wrote an expiring preference refused for that kind
decay amem decay --apply demoted preferences older than 90 days kind excluded, and an id naming one is refused as ineligible

The 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 reports partial (truncated in amem_brief).

Why the N-1 row needs a writer pinned at the floor

PREFERENCE_MIN_READER (0.25.0) is above this build's own READER_VERSION (0.24.0) until the release PR ships the kind, and assertFormatFloor refuses a home whose floor exceeds the reader. So the N-1 fixture writes its row with an isolated copy of this build's src beside a manifest at PREFERENCE_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) publishes ok: 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 with decision still allowed; importNote refusal; amem_remember queues without a floor, acceptance is refused and the candidate stays pending; a floor at the introducing release gives code: "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): the amem_brief slot (ordered, superseded and forgotten excluded, shared kept, over-long dropped whole, empty at a 200-byte budget), the sidecar (whole rules newest first, partial false on both, the curated section beside it empty), and a home at a curated floor of 100.0.0 serving no prebuilt rows.
  • Two compatibility tests moved with the design, as facts rather than accommodations: the frozen shared-v1 registry comparison now names feat(memory): person-owned preference kind with a bounded brief slot #370 as the single deliberate widening (the note kind enum, no op or field moved), and index-per-version now proves index-v4.db sits beside 0.18.0's index.db without 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):

Reverted to 60f7023 Result
src/memory/store.ts (I1) 7 pass, 1 fail: the chain test's slot returns one rule instead of five (Expected - 4 / Received + 0)
src/brief-curated.ts (I2) 0 pass, 1 fail at preference-brief.test.ts:98: sidecar.partial is [true, ...], the clipped rule stored as partial
src/brief-curated-native.ts (I3) 0 pass, 1 fail at preference-brief.test.ts:105: aboveFloor.kinds is ["preference","preference"] instead of []
src/memory/note-add.ts (N1) 7 pass, 1 fail: the short-tier write reaches the floor refusal instead of the long-term refusal
src/memory/decay-review.ts (decay) 7 pass, 1 fail: the plan carries one extra kind, the preference

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.ts and test/preference-brief.test.ts are 9 pass, 0 fail at this head.

Previous-release proofs, run remotely (remote_dev.py verify on the mirrored 1577fd2dd6ad, tree bdf3d1c0e71a, AMEM_PREV_ROOT from scripts/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 1577fd2 with a clean tree: bun x tsc --noEmit exit 0, bun scripts/check-boundary.ts → boundary: OK (175 source files), bun scripts/check-docs.ts exit 0 (docs: OK (6 stale documents), the same stale set as main; the tag workflow's --require-fresh is a release step), npm pack --dry-run → 185 files, and bun 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, which src/cli.ts:142 counts as an agent marker, and two tests spawn the CLI with { ...process.env }. test/mcp-cli.test.ts:55 then records the hub peer where it expects codex, and test/format-floor.test.ts:95 gets amem doctor is not available to an agent-marked session where it expects format 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) while measure (ubuntu-latest) and all four test shards passed. Two measurements say that is the hosted-macOS wall flake, not this change: main at 224b2bc fails the identical transcript-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. At 1577fd2 the matrix is still running (decision passed; measure ×2 and test ×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.json keeps 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.

Copilot AI balanced review requested due to automatic review settings November 11, 2026 10:43
@chatgpt-codex-connector

Copy link
Copy Markdown
Codex Review Summary: ❌ Failed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ❌ Failed 2026-11-11T10:43:07.409254Z
We couldn’t complete this review. Comment @codex review to try again.
ec1b37b PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@entelecheia
entelecheia force-pushed the feat/370-preference-kind branch from ec1b37b to 3b7aae2 Compare November 11, 2026 10:58
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
entelecheia force-pushed the feat/370-preference-kind branch from 3b7aae2 to 60f7023 Compare November 11, 2026 11:26
…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
@entelecheia
entelecheia merged commit c4e4cb2 into main Nov 11, 2026
15 of 18 checks passed
@entelecheia
entelecheia deleted the feat/370-preference-kind branch November 11, 2026 16:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants