Skip to content
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Agent candidate tools and person-reviewed lifecycle (agent-mem#40 F8/F9): explicit and structured fail proposals, revision-bound `amem_correct`, goal-linked promotion candidates accepted with the existing canonical promotion primitive, and `amem decay` preview/confirmed demote or explicit forget. Current-profile review supports shared-v1, generation and split homes; agent authority and shared-v1 schema/hash remain unchanged. MCP correction targets only its bound project and rejects an oversized complete accepted body at proposal. Decay receipts show exact expiry and partial failures report completed/uncertain/remaining ids in human or JSON output. Decay forget refuses unreviewed legacy-copy expansion before any batch change; ordinary person forget is unchanged. Compatibility for the next version: amem 0.6.0 and older candidate listings throw invalid candidate kind after a promotion row is offered; upgrade all readers/writers of that machine-local candidates.db before using this feature.

- `amem doctor [--json]` and the sync cadence (agent-mem#40 F15, F16, AC8, task #424): one read-only report of duplicates and stale notes, pending candidates, capture freshness and hook registration per CLI, the brief's token cost, index equals rebuild, reader leases, the policy in effect and the ingest and sync cadence, with a stable `amem.doctor/v1` JSON shape pinned by a snapshot. `amem sync --if-due [--force]` syncs only when an hourly schedule and a failure backoff (15 minutes doubling to a day) allow it, only with a configured remote, bounded at 30 seconds, and every sync stamps `sync-state.json` (times and a count, never an error text). amem registers no sync hook and no resident process (owner decision O3). Doctor writes nothing into the home: every check runs on a scratch copy of the logs (the live index is only snapshotted read-only), and `sessionStartBrief` gains `measureOnly` for it. `sync --if-due` also stops git's transport children, stamps only real transport failures (also under `--json`) and ignores a future stamp. See `docs/contracts.md`.

- Lexical recall gate (agent-mem#40 F14, AC7, task #423): the F14 candidates (query expansion from `about`-node words, 1-hop graph neighbours by append and by score blend, an `about`-node boost, pseudo-relevance feedback) were each scored with `amem eval` on the built-in set and on a new held-out set (`test/fixtures/eval-heldout.json`, written before any candidate ran). None raised MRR or hit@1 on both, so none is adopted and `store.search` is unchanged. Added the gate (`scripts/f14-gate.ts`, the pure `adopts()` rule, `test/f14-gate.test.ts` pinning the record) so a future candidate is judged the same way, and an optional `rank` on `evalSeeded` to score one. Recorded in `docs/contracts.md`.
Expand Down
10 changes: 10 additions & 0 deletions docs/contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -388,3 +388,13 @@ agent-mem#40 F14 asks for query expansion from aliases and tags and for BM25 plu
- *Ingest.* Already done by `amem setup` (#417): the SessionEnd hook of Codex and Kimi, and Pi's `session_shutdown` extension, run `amem ingest <cli>` within the hook budget (3 s, 2.5 s for Pi) as a short-lived process with no watcher. Claude captures through its seven bounded hooks. Doctor reads the setup receipts and reports a CLI with no registered hook as `manual`, with the command to register it. "Registered" is evidence from the receipt (`hookEvidence: "setup receipt"`): a hook the person removed by hand afterwards still shows as registered until `amem setup <cli> --apply` or `--undo` runs again.
- *Sync (owner decision O3: off by default).* amem registers **no** sync hook and starts **no** resident process. `amem sync --if-due` is the opt-in entry the person calls from their own shell (for example an EXIT trap); it needs a terminal like every mutating command, so an unattended scheduler is not a caller. **A CLI's SessionEnd hook cannot run it, deliberately (claude's F16 ruling, final):** a hook has no terminal and may carry an agent marker, and the person-only gate on a network and git write is not weakened for one flag. The person who wants sync at the end of a session puts the command in their own shell, not in a hook amem registers. It does nothing without a configured remote (no `.git` is created), nothing when it is not due, and it stops a running git step after 30 seconds: git runs in its own process group for that call, so the stop reaches the transport children git started (ssh, a credential helper) and not git's pid alone. Because that group no longer receives what the terminal sends to amem, amem turns Ctrl-C (SIGINT), SIGTERM, SIGHUP (the terminal closed, which is also how an EXIT trap's shell goes away) and SIGQUIT into the stop (`on`, not `once`, so a second Ctrl-C is not the default action that would end amem before git), and a process that exits for any other reason signals every live git group first (an exit handler, installed when the first deadline git starts). Tested with the real binary under a pty for each of the four signals, three Ctrl-Cs in a row and a group SIGHUP, and with a process that dies by an uncaught throw. The one thing nobody can handle is SIGKILL of amem itself (or a power loss): the step timer dies with amem, so a git already running is left with nothing to stop it and runs until it finishes or its transport gives up. The next sync takes over `sync.lock` once amem's pid is gone and removes `index.lock`, which can then land under that live git. This is the existing hazard of any killed sync, and `--if-due` does not widen it; it is not closed here (`ponytail`: a group-aware stale-lock check in `sync.ts`, if a killed sync is ever seen to corrupt the index). The 30 seconds bound the git steps. It does not bound the `gh` visibility lookup (its own 15 s limit) or the cleanup `git merge --abort` after an interrupted merge, which deliberately ignore the stop so the repository is left clean (up to 60 s). After a success the next attempt is due an hour later; after a failure the wait is 15 minutes and doubles with each further failure up to a day. `--force` (only with `--if-due`) ignores the schedule and the backoff. The only state is `sync-state.json` beside the logs: two times and a failure count, never an error text (a git error can carry a remote URL), mode 0600, written by every `amem sync`, plain or scheduled. A damaged or hand-edited stamp, and one dated in the future, read as no history. Only a transport failure counts: the real result is read (also under `--json`, which returns a failed sync instead of throwing), and a refusal no remote ever saw (another sync or mutation holds the lock, no remote is configured, the policy declines a note, the remote is public, a usage error) is not stamped, so it neither backs the schedule off nor makes a contended run look like a failed one. The read-modify-write of the stamp is not locked: two syncs finishing together can lose one count, which at worst delays a backoff by one step (`ponytail`: a lock if a scheduler ever runs several at once). Doctor reports the remote as `configured` or `none` (never the URL; read from the active profile's own repository: the shard cache of a split home, `generations/<G>/.git` of a generation home, `.git` of shared-v1), the last attempt and success, the failure count and the next attempt, and is `attention` when a remote has had no success for 7 days or has failed 3 times in a row.
- *Option A, rejected.* A detached bounded `amem sync` child started from the SessionEnd hook would hand network and git work to a hook. `AGENTS.md` forbids a model or network call on the capture path and a resident process, and `test/hooks-network.test.ts` runs the executable hooks, the SessionStart brief, native ingest and the capture append under network tripwires and observes no attempt, so a sync wired into them would have to change that contract. A detached child outlives the hook's own budget and cannot be accounted to it. The choice stays with the person: a hook is not asked to do it. `test/sync-cadence.test.ts` greps the setup, hook and ingest sources for any sync wiring and the cadence module for any process or timer.

## Agent proposals and person-reviewed lifecycle (F8, F9, AC4)

- `amem_remember` keeps its text form and optional explicit note kind. Its structured fail form requires `kind: fail`, approach, symptom, cause, evidenceRefs and retryWhen together, with no mixed text/title. `amem_correct` takes an exact visible entry id, proposedText, reason and optional decision/contract/fail kind. Both write only machine-local pending candidates. The host binds the project and target revision; model arguments cannot choose a home, project, actor or revision. Current configured policy screens the complete proposal and visible target before and after asynchronous checks. A stale, superseded, private or inaccessible target refuses without a curated append. MCP correction is restricted to the bound project, matching its show/search visibility; another project's shared entry also refuses generically. The complete accepted correction body, including provenance headers and reason, must fit the v1 4000-character limit already at proposal time.
- Person review accepts memory, correction, fail and promotion candidates in shared-v1, generation and split homes. Acceptance rechecks the current home, selector, aliases, realm, project lifecycle, exact target revision and policy. A split store writes to its bound shard and leaves legacy logs alone. Existing actor, current-policy and stale-target refusals remain. This replaces the earlier #15 split-home and generation CLI review deferrals; it does not add agent authority.
- A `promotion` candidate is a new machine-local candidates.db variant, not a correction or a shared-v1 operation. `goal done` (also `goal state <id> done`) records the person's goal state, then offers live linked short notes via the goal's recorded task links or `about: goal:<id>`. No live board is required. Offers are revision-bound and idempotent across pending, accepted and rejected states while their sidecar receipts are retained; repeated bounded runs advance through new offers. Existing candidate retention still applies, so a rejected note can be offered again after its receipt expires. Current goal, note and configured policy are rechecked after awaits. Unknown or withheld notes are never promoted implicitly. Person acceptance calls the existing `store.promote`, retaining its canonical id, original attribution and promotedFrom provenance. Earlier notes and shared-v1 hashes are unchanged. A host using rawGoalCommand without the optional onDone callback keeps its previous behavior.
- `amem decay [ids...] [--days N] [--json]` previews by default. The default is live, non-superseded long notes strictly older than 90 days. Open, active or blocked goals protect their linked tasks and goal/task about nodes. Any retained positive citation telemetry can only exempt otherwise eligible notes (the counter timestamp is the original delivery time, not a later citation update); missing, unreadable or zero observations never establish an absence of citations. The report labels citation history unknown, and an unavailable exemption count is null.
- `--apply` is person-only, with terminal input and a visible fresh revision preview before explicit confirmation. It acts on the named ids, or the displayed bounded set when none are named. Every selected exact revision, current policy, goal protection, citation exemption and profile binding is checked before the first effect. Default action is demotion to the short tier. The exact expiry is displayed before confirmation, written into every completed demotion and returned in the receipt; the default is 14 days, so reversal is available only until that expiry. A confirmation left open beyond the displayed deadline refuses before effects. Explicit deadlines must be canonical future UTC and no later than the store's configured short-tier TTL plus one hour from now; core demote and whole-batch preflight use the same validator. `--forget` must be supplied for each forget run and its confirmation explicitly names forgetting, that hidden claude-mem-imported copies need separate review; decay refuses expanded groups. There is no automatic deletion. The synchronous library apply additionally requires confirmed=true for bulk or forget; the CLI confirms even a named subset.
- Decay forget checks every actual forget group before confirmation and again during whole-batch preflight; any expansion beyond one exact reviewed id refuses. A core exact-only guard repeats this check immediately before its append. Thus an unseen, policy-withheld, stale or open-goal-protected imported copy is never removed by decay. Ordinary person `forget` keeps its existing legacy group operation after separate review. `ponytail:` decay does not yet offer a group-aware preview with per-copy revisions and policy/goal checks; that is the upgrade path for duplicate imported groups.
- `ponytail:` previews/offers cap selected notes at 64 and scans at 5000, but existing list/get still materialize the core corpus first; a scope-aware pager including shared rows is the upgrade. Exact-target checks use the existing sorted shard writer fences; older/raw writers that bypass them retain the mutation-lock ceiling. All selected checks precede all effects, but a later filesystem failure can leave an append-only partial batch. DecayApplyError carries applied (completed ids), failed (the uncertain target) and remaining (unattempted ids), plus the completed demotion expiry. CLI failure output prints them, or emits standalone JSON under --json (including failedMayHaveChanged: true), exits unsuccessfully and warns that the failed target may also have changed. There is no automatic retry. Goal state remains done if offering subsequently fails; rerunning done offers only missing candidates. A thrown promotion append keeps the existing accepting/manual-inspection recovery state, while a clean refusal returns the candidate to pending. Release compatibility: amem 0.6.0 and older CandidateStore.list() throws invalid candidate kind on a promotion row. Upgrade every reader/writer of this machine-local candidates.db before using goal-done promotion offers; the next package release must carry this compatibility note. This does not change curated shared-v1 rows.
3 changes: 2 additions & 1 deletion scripts/measure-mcp-budget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,8 @@ try {
if (initialized.serverInfo?.name !== "amem") throw new Error("expected the amem MCP server");
send({ jsonrpc: "2.0", method: "notifications/initialized" });
const listing = await request("tools/list");
if (listing.tools?.length !== 9 || !listing.tools.some((tool: { name: string }) => tool.name === "amem_search")) throw new Error("frozen MCP tool surface missing");
const expectedTools = ["amem_capacity", "amem_correct", "amem_graph", "amem_remember", "amem_search", "amem_show", "amem_timeline", "amem_vault_candidates", "amem_work_state_read", "amem_work_state_write"];
if (listing.tools?.length !== 10 || JSON.stringify(listing.tools.map((tool: { name: string }) => tool.name).sort()) !== JSON.stringify(expectedTools)) throw new Error("frozen MCP tool surface missing");
const call = async (name: string, args: Record<string, unknown>) => {
const result = await request("tools/call", { name, arguments: args });
const text = result.content?.find((item: { type: string; text?: string }) => item.type === "text")?.text;
Expand Down
Loading
Loading