diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cbbc20..a7cd027 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/docs/contracts.md b/docs/contracts.md index 32e6543..1fa68c0 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -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 ` 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 --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//.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 done`) records the person's goal state, then offers live linked short notes via the goal's recorded task links or `about: goal:`. 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. diff --git a/scripts/measure-mcp-budget.ts b/scripts/measure-mcp-budget.ts index 44d8d1f..e9d7301 100644 --- a/scripts/measure-mcp-budget.ts +++ b/scripts/measure-mcp-budget.ts @@ -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) => { const result = await request("tools/call", { name, arguments: args }); const text = result.content?.find((item: { type: string; text?: string }) => item.type === "text")?.text; diff --git a/src/cli.ts b/src/cli.ts index c13acb5..c8ab038 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,3 +1,4 @@ +import { DecayApplyError } from "./memory/decay-review.ts"; import { createMemoryMcp } from "./mcp-memory.ts"; import { serveMcp } from "./mcp.ts"; import { createStandalonePolicy } from "./memory/policy.ts"; @@ -9,7 +10,7 @@ import { join } from "node:path"; import { MemoryStore, looksSecret, machineId, openShardStore, type StoreOptions } from "./memory/store.ts"; import { rawMemoryCommand, rawGoalCommand } from "./memory/commands.ts"; import { memoryProjectKey } from "./memory/project.ts"; -import { CandidateStore, type CandidateFilter } from "./memory/candidates.ts"; +import { CandidateStore, candidateTargetRevision, type CandidateFilter } from "./memory/candidates.ts"; import { aliasedKeys, appendAlias, appendUnalias, listAliases, readShardedSelection, resolveAlias } from "./memory/shard.ts"; import { capacityReport, capacityText } from "./memory/capacity.ts"; import { readCaptureConfigFile, writeCaptureConfigFile, type CaptureConfig } from "./memory/capture.ts"; @@ -17,7 +18,8 @@ import { readCaptureConfigFile, writeCaptureConfigFile, type CaptureConfig } fro import { commandHelp, parseCommand } from "./commands-table.ts"; import { completionScript } from "./completion.ts"; import { memoryHome, resolveMemoryHome } from "./memory/home.ts"; -import { resolveProfile } from "./memory/generation.ts"; +import { realPath } from "./memory/platform.ts"; +import { readSelector, resolveProfile } from "./memory/generation.ts"; import { migrate } from "./memory/migrate.ts"; import { migrateShards, shardMigrationStatus } from "./memory/shard-migration.ts"; import { applyPrune, planPrune, PRUNE_PLAN_MAX_BYTES } from "./memory/prune.ts"; @@ -195,6 +197,14 @@ export async function runCli(argv: readonly string[], context: CliContext = {}): const policyOf = () => configuredPolicy(memoryHome(), policyCwd); // the same home every other command here resolves let canonicalProject: string | undefined; const boundProject = () => canonicalProject ??= resolveAlias(memoryHome(), memoryProjectKey(context.cwd ?? process.cwd())); + const commandBinding = (home: string, project: string): (() => void) => { + const base = realPath(home), selector = readSelector(base).revision, aliases = aliasedKeys(base, project).join("\n"); + const split = shardStatePresent(), shards = split ? JSON.stringify(readShardedSelection(base)) : undefined; + return () => { + const currentSplit = shardStatePresent(); + if (realPath(memoryHome()) !== base || resolveAlias(base, memoryProjectKey(policyCwd)) !== project || readSelector(base).revision !== selector || aliasedKeys(base, project).join("\n") !== aliases || projectState(base, project) !== "active" || split !== currentSplit || split && JSON.stringify(readShardedSelection(base)) !== shards) throw new Error("memory command binding changed since preview"); + }; + }; if (sub === "migrate") { const modes = ["--dry-run", "--resume", "--deactivate"].filter(key => options[key] === true); if (modes.length > 1) throw new Error("migration modes are mutually exclusive"); @@ -248,6 +258,35 @@ export async function runCli(argv: readonly string[], context: CliContext = {}): return JSON.stringify(applying ? applyPrune(store.prunePort(), input, auth) : planPrune(store.prunePort(), { cutoff: String(options["--cutoff"]) }, auth), null, 2); } finally { store.close(); } } + if (sub === "decay") { + const applying = options["--apply"] === true; + if (applying && context.write === undefined && !(context.stdoutTTY ?? process.stdout.isTTY)) throw new Error("decay apply shows a revision preview before confirmation, so stdout must be a terminal"); + const { decayPreview, applyDecay, readDecayCounters, DECAY_DEFAULT_DAYS, assertExactDecayForget } = await import("./memory/decay-review.ts"); + const project = boundProject(), base = memoryHome(), aliases = aliasedKeys(base, project); + const store = curatedStore(project, { deferPromotions: true, screen: note => policyOf().screenStandalone(note) ? "promote" : "keep" }); + try { + const assertCurrent = commandBinding(store.profile.home, project); + const selected = parsed.positionals.length ? parsed.positionals : undefined; + if (options["--days"] !== undefined && !/^[1-9][0-9]*$/.test(String(options["--days"]))) throw new Error("decay days must be a positive decimal integer"); + const days = options["--days"] === undefined ? DECAY_DEFAULT_DAYS : Number(options["--days"]); + const settings = { days, ...(selected ? { ids: selected } : {}), aliases, counters: () => readDecayCounters(base, [project, ...aliases]) }; + const current = { ...settings, screen: (note: import("./memory/store.ts").View) => policyOf().screenStandalone(note), assertCurrent }; + const preview = decayPreview(store, project, current), action = options["--forget"] === true ? "FORGET" : "demote"; + const display = [`${action} preview: ${preview.entries.length} notes older than ${days} days; citation history unknown (${preview.citedExemptions ?? "unknown"} observed retained-positive exemptions)${preview.truncated ? "; truncated" : ""}`, + ...preview.entries.map(entry => `${entry.target.id} revision ${entry.target.revision} ${entry.view.at} ${entry.view.kind}: ${terminalSafe(entry.view.title ?? entry.view.text)}`)].join("\n"); + if (!applying) return options["--json"] ? JSON.stringify(preview, null, 2) : display; + if (!preview.entries.length) return options["--json"] ? JSON.stringify({ action, ids: [] }) : "no eligible notes to apply"; + if (options["--forget"] === true) assertExactDecayForget(store, preview.entries.map(entry => entry.target.id), project); + const write = context.write ?? ((text: string) => { process.stdout.write(`${text}\n`); }); write(display); + const ask = context.prompt ?? ((question: string) => prompt(question)); + const answer = await ask(`Type yes to ${action} these ${preview.entries.length} exact entries${options["--forget"] ? " permanently (hidden claude-mem-imported copies require separate review; expanded groups are refused)" : ` into the short tier, expires ${preview.demoteExpiresAt}`}: `); + if (answer?.trim() !== "yes") return options["--json"] ? JSON.stringify({ cancelled: true }) : "decay apply cancelled"; + assertCurrent(); + const result = applyDecay(store, preview, { ...current, actor: "person", confirmed: true, forget: options["--forget"] === true }); + return options["--json"] ? JSON.stringify(result, null, 2) : `${result.action}: ${result.ids.length} entries${result.expiresAt ? `; expires ${result.expiresAt}` : ""}`; + } catch (error) { if (error instanceof DecayApplyError) error.jsonOutput = options["--json"] === true; throw error; } + finally { store.close(); } + } if (sub === "note") { // F6: the person writes a curated note. Person-only (the agent-marker refusal and the terminal requirement above already ran, because `note add` mutates). const env = context.env ?? process.env, edit = context.edit ?? ((initial: string) => runEditor(env, initial)); @@ -272,9 +311,6 @@ export async function runCli(argv: readonly string[], context: CliContext = {}): } finally { store.close(); } } if (review) { - if (sub === "accept" && options["--kind"] !== "memory" && shardStatePresent()) throw new Error("candidate acceptance requires shard-aware review integration; use --kind memory only for a general memory candidate"); - if (sub === "accept" && options["--kind"] !== "memory" && resolveProfile(memoryHome()).kind === "generation") throw new Error("candidate review is not yet integrated with this generation; use --kind memory only for a general memory candidate"); - if (sub === "list" && options["--kind"] !== "memory" && resolveProfile(memoryHome()).kind === "generation") throw new Error("candidate review is not yet integrated with this generation; select --kind memory for the standalone memory candidate surface"); const id = parsed.positionals[0]; if (sub !== "list" && sub !== "walk" && (!id || !/^[a-f0-9]{64}$/.test(id))) throw new Error("review accept/reject requires a full 64-character candidate id"); const project = boundProject(), aliases = aliasedKeys(memoryHome(), project), candidates = new CandidateStore(aliases.length ? { reconcile: { canonical: project, aliases } } : {}); @@ -287,20 +323,32 @@ export async function runCli(argv: readonly string[], context: CliContext = {}): // would start the 7-day clock for something unread. Refused before anything is read or stamped. (A caller-supplied `write` is a fixture standing in for the terminal.) if (context.write === undefined && !(context.stdoutTTY ?? process.stdout.isTTY)) throw new Error("review walk prints each candidate and asks about it, so stdout must be a terminal (not redirected to a file or a pipe)"); const write = context.write ?? ((text: string) => { process.stdout.write(`${text}\n`); }); - const ask = context.prompt ?? ((question: string) => prompt(question)), scope = [project, ...aliases]; + const ask = context.prompt ?? ((question: string) => prompt(question)), scope = [project, ...aliases], base = realPath(candidates.dir), assertWalkCurrent = commandBinding(candidates.dir, project); // Oldest first: those are the ones nearest their expiry (`list` is newest first). const rows = candidates.list({ project, status: "pending", ...(options["--kind"] ? { kind: options["--kind"] as CandidateFilter["kind"] } : {}) }).reverse(); if (!rows.length) return "no candidates pending"; const done = { accepted: 0, edited: 0, rejected: 0, skipped: 0, failed: 0 }; const lines = { push: (...texts: string[]) => { for (const text of texts) write(text); return 0; } }; const body = (candidate: (typeof rows)[number]): string => { const input = candidate.input; + if (input.kind === "promotion") { + const viewStore = curatedStore(project, { deferPromotions: true, screen: () => "skip" }); + try { + const target = viewStore.get(input.target.id, project); + return target && target.id === input.target.id && candidateTargetRevision(target) === input.target.revision + ? `promotion. Goal goal:${input.goalId}, mem:${target.id}. ${target.title ? `${target.title}: ` : ""}${target.text}` + : `promotion. Goal goal:${input.goalId}, target mem:${input.target.id} changed or is unavailable; reject or skip`; + } finally { viewStore.close(); } + } return input.kind === "memory" ? `${input.title ? `${input.title}: ` : ""}${input.text}` : input.kind === "fail" ? `fail. Approach: ${input.approach} Symptom: ${input.symptom} Cause: ${input.cause} Retry when: ${input.retryWhen}` : input.kind === "correction" ? `correction. ${input.proposedText} Reason: ${input.reason}` : `decay. ${input.reason}`; }; const step = async (args: string[]) => { try { await runCli(args, context); return true; } catch (error) { lines.push(` ${(error as Error).message.split("\n")[0]}`); done.failed++; return false; } }; for (const [index, candidate] of rows.entries()) { + assertWalkCurrent(); lines.push(`${index + 1}/${rows.length} ${candidate.id.slice(0, 12)} ${candidate.input.kind} ${candidate.createdAt.slice(0, 10)}`, ` ${terminalSafe(body(candidate))}`); - markShown(memoryHome(), scope, Date.now(), [candidate.id]); // after the write: shown means the person could read it + assertWalkCurrent(); + markShown(base, scope, Date.now(), [candidate.id]); // after the write: shown means the person could read it const answer = ((await ask("[a]ccept [e]dit [r]eject [s]kip [q]uit: ")) ?? "q").trim().toLowerCase()[0] ?? "s"; + assertWalkCurrent(); if (answer === "q") break; if (answer === "a") { if (await step(["review", "accept", candidate.id, ...(candidate.input.kind === "memory" ? ["--kind", "memory"] : []), "--json"])) done.accepted++; } else if (answer === "r") { if (await step(["review", "reject", candidate.id])) done.rejected++; } @@ -312,7 +360,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}): const first = input.kind === "fail" ? `Approach: ${input.approach}\nSymptom: ${input.symptom}\nCause: ${input.cause}\nEvidence: ${input.evidenceRefs.join(", ")}\nRetry when: ${input.retryWhen}\n` : input.noteKind === "fail" && !hasFailLabel(input.text) ? `Approach: ${input.text}\nSymptom: \nCause: \nEvidence: \nRetry when: \n` : input.text; try { - const edited = await edit(first), standalone = policyOf(), store = curatedStore(project, { deferPromotions: true, screen: note => standalone.screenStandalone(note) ? "promote" : "keep" }); + const edited = await edit(first); assertWalkCurrent(); const standalone = policyOf(), store = curatedStore(project, { deferPromotions: true, screen: note => standalone.screenStandalone(note) ? "promote" : "keep" }); // A fail candidate, and a general memory candidate proposed as a fail note, are both written as a structured fail note from the labelled body the person edited. const asFail = input.kind === "fail" || input.noteKind === "fail"; try { noteAdd(store, project, asFail ? { kind: "fail", tier: "long", scope: "project", fail: parseFailTemplate(edited), ...(input.kind === "memory" && input.title ? { title: input.title } : {}) } : { kind: input.kind === "memory" ? input.noteKind ?? "finding" : "finding", tier: "long", scope: "project", text: edited, ...(input.kind === "memory" && input.title ? { title: input.title } : {}) }, standalone); } @@ -336,18 +384,17 @@ export async function runCli(argv: readonly string[], context: CliContext = {}): } const candidate = candidates.get(id!, project); if (!candidate) throw new Error("candidate unavailable"); - if (options["--kind"] === "memory" && candidate.input.kind !== "memory") throw new Error("--kind memory accepts only a general memory candidate"); + if (options["--kind"] !== undefined && candidate.input.kind !== options["--kind"]) throw new Error("candidate kind does not match --kind"); if (candidate.input.kind === "decay") throw new Error("decay candidates are listing-only; use `amem demote` or `amem forget` after review"); - if (candidate.input.kind !== "memory" && (shardStatePresent() || resolveProfile(memoryHome()).kind === "generation")) throw new Error("candidate acceptance requires shard-aware and profile-aware host integration for this candidate kind; the candidate remains pending"); - const standalone = policyOf(); - if (!await standalone.screen(candidate.input)) throw new Error("candidate refused by the current standalone privacy policy (secrets, personal data, deny paths and the patterns in amem.config.json)"); - // Candidate acceptance uses the actual secret screen; the core defers unrelated automatic promotions. - const store = curatedStore(project, { deferPromotions: true, screen: note => standalone.screenStandalone(note) ? "promote" : "keep" }); + const store = curatedStore(project, { deferPromotions: true, screen: note => policyOf().screenStandalone(note) ? "promote" : "keep" }); try { + const assertCurrent = commandBinding(store.profile.home, project); + if (!await policyOf().screen(candidate.input)) throw new Error("candidate refused by the current standalone privacy policy (secrets, personal data, deny paths and the patterns in amem.config.json)"); + assertCurrent(); const result = candidates.accept(id!, { project, actor: "person", verdict: "public", store, - policy: (proposal, _target, note) => standalone.screenStandalone(proposal.input) && standalone.screenStandalone(note), + policy: (proposal, target, note, goal) => { assertCurrent(); return policyOf().screenStandalone({ ...proposal.input, ...(target ? { targetView: target } : {}), note, ...(goal ? { goalView: goal } : {}) }); }, }); - return options["--json"] ? JSON.stringify(result, null, 2) : `accepted candidate ${id} as ordinary note ${result.noteId}`; + return options["--json"] ? JSON.stringify(result, null, 2) : `accepted candidate ${id} as ${candidate.input.kind === "promotion" ? "promoted" : "ordinary"} note ${result.noteId}`; } finally { store.close(); } } finally { candidates.close(); } } @@ -556,7 +603,17 @@ export async function runCli(argv: readonly string[], context: CliContext = {}): // Reads can trigger held-note reconciliation. Defer it unless this command is one that moves notes across the boundary (promote, sync, import, review accept), and then the configured policy decides each note. const open = () => curatedStore(boundProject(), { screen: note => !crosses ? "skip" : configured.screenStandalone(note) ? "promote" : "keep" }); // A person's reads are unscreened. A write is screened by the configured policy, which includes the built-in secret screen. - if (goal) return rawGoalCommand(args, cwd, { project: boundProject(), open, clean: (title, milestones) => !mutating || configured.screenStandalone({ title, milestones }) }); + if (goal) return rawGoalCommand(args, cwd, { project: boundProject(), open, clean: (title, milestones) => !mutating || configured.screenStandalone({ title, milestones }), + onDone: async (done, store) => { + const project = boundProject(), base = realPath(store.profile.home), assertCurrent = commandBinding(store.profile.home, project); + if (readSelector(base).revision !== store.profile.selectorRevision) throw new Error("goal promotion store selector changed"); + assertCurrent(); + const { offerGoalPromotions } = await import("./memory/goal-promotion.ts"); + assertCurrent(); + const candidates = new CandidateStore({ memoryDir: base, reconcile: { canonical: project, aliases: aliasedKeys(base, project) } }); + try { return await offerGoalPromotions(store, candidates, project, done.id, { policy: () => configuredPolicy(base, cwd), assertCurrent }); } + finally { candidates.close(); } + } }); const receipt: { bridgeStatus?: { skippedRows: number } } = {}; // F16: `sync --if-due` is the entry for the person's own shell (an EXIT trap; it needs a terminal like every mutating command). amem registers no sync hook and runs nothing between sessions. It does nothing without a // configured remote, nothing when the schedule or the failure backoff says it is not due (`--force` skips that), and it stops a running git step after 30 seconds. @@ -597,9 +654,11 @@ export async function runCli(argv: readonly string[], context: CliContext = {}): ? `${output}\n(legacy malformed lines skipped: ${receipt.bridgeStatus.skippedRows})` : output; } +export function formatCliError(error: unknown): string { return error instanceof DecayApplyError ? error.render() : `amem: ${(error as Error).message}`; } + export async function main(argv = process.argv.slice(2)): Promise { try { const output = await runCli(argv); if (output) console.log(output); } - catch (error) { console.error(`amem: ${(error as Error).message}`); process.exitCode = (error as { exitCode?: number }).exitCode ?? 1; } + catch (error) { console.error(formatCliError(error)); process.exitCode = (error as { exitCode?: number }).exitCode ?? 1; } } if (import.meta.main) await main(); diff --git a/src/commands-table.ts b/src/commands-table.ts index c283258..4fef437 100644 --- a/src/commands-table.ts +++ b/src/commands-table.ts @@ -85,12 +85,14 @@ export const COMMANDS: Record = { review: { description: "Review machine-local non-evidentiary candidates", options: json, defaultCommand: "list", commands: { list: { description: "List review candidates", options: { "--status": { description: "Candidate status", value: ["pending", "accepting", "accepted", "rejected", "all"] }, - "--kind": { description: "Candidate kind", value: ["correction", "fail", "decay", "memory"] }, "--limit": text("Maximum candidates (1..500)"), + "--kind": { description: "Candidate kind", value: ["correction", "fail", "decay", "memory", "promotion"] }, "--limit": text("Maximum candidates (1..500)"), } }, - accept: { description: "Accept one candidate through the current memory policy", options: { ...policy, "--kind": { description: "Explicit standalone candidate kind for selected profiles", value: ["memory"] } }, args: [{ name: "full-candidate-id" }], mutates: true, policyFree: true }, + accept: { description: "Accept one candidate through the current memory policy", options: { ...policy, "--kind": { description: "Require the selected candidate kind", value: ["correction", "fail", "decay", "memory", "promotion"] } }, args: [{ name: "full-candidate-id" }], mutates: true, policyFree: true }, reject: { description: "Reject one candidate", args: [{ name: "full-candidate-id" }], mutates: true }, - walk: { description: "Walk the pending candidates one by one: accept, edit (as a new note), reject, skip or quit", mutates: true, options: { "--kind": { description: "Candidate kind", value: ["correction", "fail", "decay", "memory"] } } }, + walk: { description: "Walk the pending candidates one by one: accept, edit (as a new note), reject, skip or quit", mutates: true, options: { "--kind": { description: "Candidate kind", value: ["correction", "fail", "decay", "memory", "promotion"] } } }, } }, + decay: { description: "Preview old live long-term notes unprotected by open goals; apply only after person review", mutates: "apply", options: { + ...json, "--days": text("Age in whole days (default 90)"), "--apply": flag("Show the exact revision preview and ask before applying"), "--forget": flag("Explicitly forget instead of demoting; confirmation names this action") }, args: [{ name: "id", variadic: true }] }, migrate: { description: "Select a person-approved generation or deactivate it", mutates: true, options: { ...json, "--dry-run": flag("Read-only migration receipt"), "--generation": text("Explicit 32-hex generation ID"), "--resume": flag("Resume the recorded migration"), "--deactivate": flag("Select shared v1 without deleting a generation"), diff --git a/src/index.ts b/src/index.ts index 94cf6dc..4a062bd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,3 +32,6 @@ export * from "./memory/vault.ts"; export * from "./hooks.ts"; export * from "./ingest.ts"; export * from "./session-brief.ts"; + +export * from "./memory/decay-review.ts"; +export * from "./memory/goal-promotion.ts"; diff --git a/src/mcp-memory.ts b/src/mcp-memory.ts index a5df295..63778ba 100644 --- a/src/mcp-memory.ts +++ b/src/mcp-memory.ts @@ -1,7 +1,7 @@ import { lstatSync } from "node:fs"; import { join } from "node:path"; import type { McpHandlers } from "./mcp.ts"; -import { CandidateStore } from "./memory/candidates.ts"; +import { CandidateStore, acceptedCorrectionText, candidateTargetRevision, type CandidateInput } from "./memory/candidates.ts"; import { capacityReport } from "./memory/capacity.ts"; import { CaptureStore } from "./memory/capture.ts"; import { readSelector } from "./memory/generation.ts"; @@ -104,6 +104,27 @@ export function createMemoryMcp(options: MemoryMcpOptions): MemoryMcp { const rows = await readPermitted(window => store.readPage({ ...window, includeSuperseded: true }), { prefix: id.replace(/^mem:/, "") }, 2); return rows.length === 1 ? rows[0]!.at : undefined; }; + const correctionTarget = (id: string): View => { + guard(); + const current = store.get(id, project); + // Proposal visibility matches MCP reads; shared rows from another project are not evidence here. + if (!current || current.id !== id || current.supersededBy || current.project !== project) return unavailable(); + return current; + }; + const propose = async (input: CandidateInput, target?: View): Promise<{ id: string }> => { + if (input.kind === "correction" && acceptedCorrectionText(input).length > 4000) return unavailable(); + const revision = target && candidateTargetRevision(target); + const currentPolicy = () => createStandalonePolicy(configuredPolicyOptions(base, cwd, options.policy)); + const check = () => { guard(); if (target && candidateTargetRevision(correctionTarget(target.id)) !== revision) unavailable(); }; + const complete = target ? { ...input, targetView: target } : input; + check(); + if (!await currentPolicy().screen(complete)) unavailable(); + check(); + // Config can tighten while the host screen awaits. Its synchronous check fences the final append. + if (!currentPolicy().screenStandalone(complete)) unavailable(); + check(); + return { id: candidates.put(input, "public") }; + }; const tools: McpHandlers = { async amem_search(args) { bound(args, ["query", "limit", "since", "includeCapture"]); const query = textOf(args, "query")!, limit = limitOf(args), since = sinceOf(args); @@ -181,10 +202,25 @@ export function createMemoryMcp(options: MemoryMcpOptions): MemoryMcp { }, async amem_work_state_write(args) { bound(args, ["list", "fields"]); guard(); const result = await work.write(textOf(args, "list")!, args.fields as WorkListFields); guard(); return result; }, async amem_remember(args) { - bound(args, ["text", "title", "kind"]); guard(); - const kind = args.kind ?? "finding"; if (!["decision", "finding", "contract", "fail"].includes(String(kind))) unavailable(); - const input = { project, kind: "memory" as const, text: textOf(args, "text")!, ...(args.title === undefined ? {} : { title: textOf(args, "title")! }), noteKind: kind as "decision" | "finding" | "contract" | "fail" }; - if (!await policy.screen(input)) unavailable(); guard(); return { id: candidates.put(input, "public") }; + const structured = ["approach", "symptom", "cause", "evidenceRefs", "retryWhen"]; + bound(args, ["text", "title", "kind", ...structured]); guard(); + const kind = args.kind ?? "finding"; + if (typeof kind !== "string" || !["decision", "finding", "contract", "fail"].includes(kind)) unavailable(); + if (structured.some(key => args[key] !== undefined)) { + if (kind !== "fail" || args.text !== undefined || args.title !== undefined || !Array.isArray(args.evidenceRefs) || args.evidenceRefs.length < 1 || args.evidenceRefs.length > 16) return unavailable(); + const evidenceRefs = args.evidenceRefs.map(value => typeof value === "string" && value.trim() && value.length <= 400 && !value.includes("\0") ? value : unavailable()); + return propose({ project, kind: "fail", approach: textOf(args, "approach")!, symptom: textOf(args, "symptom")!, cause: textOf(args, "cause")!, evidenceRefs, retryWhen: textOf(args, "retryWhen")! }); + } + return propose({ project, kind: "memory", text: textOf(args, "text")!, ...(args.title === undefined ? {} : { title: textOf(args, "title")! }), noteKind: kind as "decision" | "finding" | "contract" | "fail" }); + }, + async amem_correct(args) { + bound(args, ["id", "proposedText", "reason", "kind"]); guard(); + const id = textOf(args, "id")!.replace(/^mem:/, ""); + if (!/^[a-f0-9]{32}$/.test(id) || args.kind !== undefined && (typeof args.kind !== "string" || !["decision", "contract", "fail"].includes(args.kind))) unavailable(); + const target = correctionTarget(id); + const input: CandidateInput = { project, kind: "correction", target: { id, revision: candidateTargetRevision(target) }, proposedText: textOf(args, "proposedText")!, reason: textOf(args, "reason")!, + ...(args.kind === undefined ? {} : { noteKind: args.kind as "decision" | "contract" | "fail" }) }; + return propose(input, target); }, async amem_vault_candidates(args) { bound(args, ["limit"]); guard(); const result = await vaultCandidates(store, { project, policy, limit: limitOf(args) }); guard(); return result; }, async amem_capacity(args) { bound(args, []); guard(); const report = capacityReport({ memoryDir: base }); if (!await policy.screen(report)) unavailable(); guard(); return report; }, diff --git a/src/mcp.ts b/src/mcp.ts index 2085200..e7ebcea 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -7,7 +7,7 @@ const object = (properties: Record, required: string[] = []) => const tool = (name: Name, description: string, inputSchema: ReturnType, readOnlyHint = true) => ({ name, description, inputSchema, annotations: { readOnlyHint, destructiveHint: false, openWorldHint: false } }); -/** Frozen #13 P2 surface. Storage scope comes from startup, never tool arguments. */ +/** Storage scope comes from startup, never tool arguments; candidate proposals remain person-reviewed. */ export const MCP_TOOLS = [ tool("amem_search", "Search screened memory in this project. Capture results are recall, not curated evidence.", object({ query: string(1000), limit, since: string(40), includeCapture: { type: "boolean" } }, ["query"])), tool("amem_show", "Show a screened memory entry in this project by its exact id or unique prefix.", object({ id: string(80) }, ["id"])), @@ -16,7 +16,9 @@ export const MCP_TOOLS = [ tool("amem_work_state_read", "Read project work lists; closed lists/items can be included explicitly.", object({ list: string(200), includeClosed: { type: "boolean" } })), tool("amem_work_state_write", "Append scalar project work-list fields; null clears, task selects an item, list status done closes.", object({ list: string(200), fields: { type: "object", minProperties: 1, maxProperties: 64, additionalProperties: { type: ["string", "number", "boolean", "null"], maxLength: 4000 } } }, ["list", "fields"]), false), - tool("amem_remember", "Propose a general memory candidate for person acceptance. Never writes directly to curated notes.", object({ text: string(3000), title: string(200), kind: { type: "string", enum: ["decision", "finding", "contract", "fail"] } }, ["text"]), false), + tool("amem_remember", "Propose a memory candidate (text, optional kind/title), or kind=fail with approach, symptom, cause, evidenceRefs and retryWhen. Person acceptance only; never writes curated notes.", object({ text: string(3000), title: string(200), kind: { type: "string", enum: ["decision", "finding", "contract", "fail"] }, + approach: string(4000), symptom: string(4000), cause: string(4000), evidenceRefs: { type: "array", minItems: 1, maxItems: 16, items: string(400) }, retryWhen: string(4000) }), false), + tool("amem_correct", "Propose a correction to an exact curated entry id visible in this project. The host binds its current revision; the full accepted body including reason and provenance headers must fit 4000 characters. A person must accept before any note or supersession is written.", object({ id: string(80), proposedText: string(4000), reason: string(4000), kind: { type: "string", enum: ["decision", "contract", "fail"] } }, ["id", "proposedText", "reason"]), false), tool("amem_vault_candidates", "List screened durable curated notes eligible for a person to move to the vault. Read only.", object({ limit })), tool("amem_capacity", "Read this machine's bounded capture capacity proposal; applying it is person-only.", object({})), ] as const; @@ -33,7 +35,7 @@ const FRAME_BYTES = 65_536, RESULT_BYTES = 262_144; const protocols = ["2025-06-18", "2025-03-26", "2024-11-05"]; const plain = (v: unknown): v is Record => !!v && typeof v === "object" && !Array.isArray(v) && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null); -/** The deliberately small schema vocabulary of the nine tools, shared with runtime validation. */ +/** The deliberately small schema vocabulary of the tools, shared with runtime validation. */ function valid(value: unknown, raw: unknown): boolean { const s = raw as Record, type = value === null ? "null" : Array.isArray(value) ? "array" : typeof value; const types = Array.isArray(s.type) ? s.type : [s.type]; @@ -41,6 +43,10 @@ function valid(value: unknown, raw: unknown): boolean { if (s.enum && !s.enum.includes(value)) return false; if (typeof value === "string" && (value.length < (s.minLength ?? 0) || value.length > (s.maxLength ?? FRAME_BYTES) || value.includes("\0"))) return false; if (typeof value === "number" && (!Number.isFinite(value) || value < (s.minimum ?? -Infinity) || value > (s.maximum ?? Infinity))) return false; + if (Array.isArray(value)) { + if (value.length < (s.minItems ?? 0) || value.length > (s.maxItems ?? 64) || !plain(s.items)) return false; + return value.every(item => valid(item, s.items)); + } if (plain(value)) { const keys = Object.keys(value); if (keys.length < (s.minProperties ?? 0) || keys.length > (s.maxProperties ?? 64)) return false; diff --git a/src/memory/candidates.ts b/src/memory/candidates.ts index b1c55cd..dea5f2e 100644 --- a/src/memory/candidates.ts +++ b/src/memory/candidates.ts @@ -7,7 +7,7 @@ import { oneLine } from "./note-add.ts"; import { memoryHome } from "./home.ts"; import { realPath } from "./platform.ts"; import { stripPrivateDeep } from "./private-spans.ts"; -import { MemoryStore, type NoteInput, type View } from "./store.ts"; +import { MemoryStore, type GoalView, type NoteInput, type View } from "./store.ts"; export const CANDIDATE_DB_BUSY_TIMEOUT_MS = 3000; export const CANDIDATE_RETENTION = { count: 500, ageMs: 30 * 86400_000 } as const; @@ -19,6 +19,7 @@ export type CandidateInput = { project: string } & ( | { kind: "fail"; target?: CandidateTarget; approach: string; symptom: string; cause: string; evidenceRefs: string[]; retryWhen: string } | { kind: "memory"; text: string; title?: string; noteKind?: "decision" | "finding" | "contract" | "fail" } | { kind: "decay"; target: CandidateTarget; reason: string } + | { kind: "promotion"; target: CandidateTarget; goalId: string } ); export type CandidateStatus = "pending" | "accepting" | "accepted" | "rejected"; export interface Candidate { id: string; createdAt: string; status: CandidateStatus; input: CandidateInput; acceptedNoteId: string | null } @@ -32,7 +33,7 @@ export interface CandidateAcceptance { actor: "person" | "agent"; verdict: CandidateVerdict; store: MemoryStore; - policy: (candidate: Candidate, target: View | undefined, note: NoteInput) => boolean; + policy: (candidate: Candidate, target: View | undefined, note: NoteInput, goal?: GoalView) => boolean; } export interface CandidateAcceptanceResult { candidateId: string; noteId: string; alreadyAccepted: boolean } @@ -56,12 +57,28 @@ function validate(input: CandidateInput): void { if (input.target !== undefined && !validTarget(input.target) || ![input.approach, input.symptom, input.cause, input.retryWhen].every(x => text(x)) || !Array.isArray(input.evidenceRefs) || input.evidenceRefs.length < 1 || input.evidenceRefs.length > 16 || !input.evidenceRefs.every(x => text(x, 400))) throw new Error("invalid fail candidate"); } else if (input.kind === "memory") { if (!text(input.text, 3000) || input.title !== undefined && !text(input.title, 200) || input.noteKind !== undefined && !["decision", "finding", "contract", "fail"].includes(input.noteKind)) throw new Error("invalid memory candidate"); + } else if (input.kind === "promotion") { + if (!validTarget(input.target) || !entryId(input.goalId)) throw new Error("invalid promotion candidate"); } else if (input.kind === "decay") { if (!validTarget(input.target) || !text(input.reason)) throw new Error("invalid decay candidate"); } else throw new Error("invalid candidate kind"); if (Buffer.byteLength(canonical(input), "utf8") > 16_384) throw new Error("candidate payload exceeds 16 KiB"); } +/** The same normalized identity used by put(), without a sidecar write or a capped listing. */ +export function candidateInputId(rawInput: CandidateInput): string { + const input = stripPrivateDeep(rawInput); validate(input); + return hash({ schema: "agent-mem.candidate/v1", input }); +} +/** Exact correction body shared by proposal validation and person acceptance. */ +export function acceptedCorrectionText(input: Extract, id = candidateInputId(input)): string { + const data = stripPrivateDeep(input); + return `Accepted candidate: ${id}\nTarget revision: ${data.target.revision}\nTarget entry: ${data.target.id}\n\n${data.proposedText}\n\nReason: ${data.reason}`; +} +export function goalPromotionLinked(view: Pick, goal: Pick): boolean { + const tasks = new Set(goal.links.map(link => link.task)); + return !!view.task && tasks.has(view.task) || view.about.some(node => node === `goal:${goal.id}` || node.startsWith("task:") && tasks.has(node.slice(5))); +} /** Includes current lifecycle state, so a changed promotion, supersession or demotion makes a proposal stale. */ export function candidateTargetRevision(target: View): string { return hash({ ...target, about: [...target.about].sort(), supersedes: [...target.supersedes].sort() }); @@ -140,7 +157,7 @@ export class CandidateStore { // `` spans are never stored: dropped before the input is validated, hashed (so the id) or written. const input = stripPrivateDeep(rawInput); publicOnly(verdict); validate(input); - const payload = canonical(input), id = hash({ schema: "agent-mem.candidate/v1", input }); + const payload = canonical(input), id = candidateInputId(input); // Prune, count, insert and prune again are ONE immediate transaction: concurrent proposers (several agents) cannot all pass the count and then let the cap evict an unseen candidate. return this.db.transaction((): string => { this.prune(); @@ -160,7 +177,7 @@ export class CandidateStore { return row ? this.decode(row) : undefined; } list(filter: CandidateFilter): Candidate[] { - if (!text(filter.project, 400) || filter.status !== undefined && !["pending", "accepting", "accepted", "rejected", "all"].includes(filter.status) || filter.kind !== undefined && !["correction", "fail", "decay", "memory"].includes(filter.kind)) throw new Error("invalid candidate filter"); + if (!text(filter.project, 400) || filter.status !== undefined && !["pending", "accepting", "accepted", "rejected", "all"].includes(filter.status) || filter.kind !== undefined && !["correction", "fail", "decay", "memory", "promotion"].includes(filter.kind)) throw new Error("invalid candidate filter"); const limit = filter.limit ?? CANDIDATE_RETENTION.count; if (!Number.isSafeInteger(limit) || limit < 1 || limit > CANDIDATE_RETENTION.count) throw new Error("candidate limit must be 1..500"); this.prune(); @@ -203,43 +220,82 @@ export class CandidateStore { if (!candidate) throw new Error("candidate unavailable"); if (candidate.status === "accepted") return { candidateId: id, noteId: candidate.acceptedNoteId!, alreadyAccepted: true }; if (candidate.status !== "pending") throw new Error("candidate is rejected or acceptance was interrupted; no automatic replay"); - if (candidate.input.kind === "decay") throw new Error("decay candidates are listing-only; no automatic mutation is available"); + const data = candidate.input; + if (data.kind === "decay") throw new Error("decay candidates are listing-only; no automatic mutation is available"); + if (data.kind === "promotion") return this.acceptPromotion(candidate, data, context); + const execute = () => { + const resolve = () => { + const target = data.kind === "memory" ? undefined : data.target; + if (!target) return undefined; + const current = context.store.get(target.id, context.project); + if (!current || current.id !== target.id || candidateTargetRevision(current) !== target.revision) throw new Error("candidate target revision is stale"); + return current; + }; + const target = resolve(); + const body = data.kind === "memory" ? data.text : data.kind === "correction" ? `${data.proposedText}\n\nReason: ${data.reason}` + : `Approach: ${oneLine(data.approach)}\nSymptom: ${oneLine(data.symptom)}\nCause: ${oneLine(data.cause)}\nEvidence: ${data.evidenceRefs.map(oneLine).join(", ")}\nRetry when: ${oneLine(data.retryWhen)}`; + const note: NoteInput = { op: "note", ...(data.kind === "memory" && data.title ? { title: data.title } : {}), project: data.kind === "correction" ? target!.project : context.project, + scope: data.kind === "correction" ? target!.scope : "project", + // A demoted synced target is logically short, but replacing it locally would narrow its reach (X04). + tier: data.kind === "correction" && target!.local ? "short" : "long", + kind: data.kind === "fail" ? "fail" : data.noteKind ?? (target?.kind === "contract" || target?.kind === "fail" ? target.kind : "decision"), peer: "person", source: "person", about: [...target?.about ?? []], + text: data.kind === "correction" ? acceptedCorrectionText(data, id) : `Accepted candidate: ${id}\nTarget revision: ${(data.kind === "memory" ? undefined : data.target)?.revision ?? "none"}\nTarget entry: ${(data.kind === "memory" ? undefined : data.target)?.id ?? "none"}\n\n${body}` }; + if (note.text.length > 4000) throw new Error("accepted candidate text exceeds the v1 note limit"); + if (context.policy(structuredClone(candidate), target && structuredClone(target), structuredClone(note)) !== true) throw new Error("candidate refused by current host policy"); + return context.store.withAcceptedNote(note, append => { + resolve(); // Recheck under the same mutation lock as the note and supersede appends. + const started = this.beginAcceptance(id, context.project); + if (started) return { candidateId: id, noteId: started, alreadyAccepted: true }; + // ponytail: the sidecar receipt and v1 append are not one transaction. An interrupted 'accepting' row refuses + // automatic replay; add explicit person-audited recovery before offering background acceptance retries. + const noteId = append(); + if (data.kind === "correction" && !context.store.supersede(target!.id, noteId, "person", context.project)) throw new Error("candidate replacement refused; inspect the accepted note before recovery"); + this.db.query("UPDATE candidates SET status = 'accepted', note_id = ? WHERE id = ? AND project IN (SELECT value FROM json_each(?))").run(noteId, id, this.scope(context.project)); + return { candidateId: id, noteId, alreadyAccepted: false }; + }); + }; + const target = data.kind === "memory" ? undefined : data.target; + if (!target) return execute(); + const current = context.store.get(target.id, context.project); + if (!current || current.id !== target.id || candidateTargetRevision(current) !== target.revision) throw new Error("candidate target revision is stale"); + return context.store.withCurrentEntries([target.id], context.project, execute); + } + private acceptPromotion(candidate: Candidate, data: Extract, context: CandidateAcceptance): CandidateAcceptanceResult { const resolve = () => { - const target = candidate.input.kind === "memory" ? undefined : candidate.input.target; - if (!target) return undefined; - const current = context.store.get(target.id, context.project); - if (!current || current.id !== target.id || candidateTargetRevision(current) !== target.revision) throw new Error("candidate target revision is stale"); - return current; + const target = context.store.get(data.target.id, context.project); + if (!target || target.id !== data.target.id || candidateTargetRevision(target) !== data.target.revision || target.tier !== "short" || target.supersededBy) throw Error("candidate target revision is stale"); + const goal = context.store.goalOf(data.goalId, context.project); + if (!goal || goal.id !== data.goalId || goal.state !== "done" || !goalPromotionLinked(target, goal)) throw Error("promotion requires a current done goal and a linked short note"); + return { target, goal }; }; - const target = resolve(), data = candidate.input; - const body = data.kind === "memory" ? data.text : data.kind === "correction" ? `${data.proposedText}\n\nReason: ${data.reason}` - // Each field is one line (every line-break character collapses): a candidate from an agent cannot forge a labelled line in the note the person accepts. - : `Approach: ${oneLine(data.approach)}\nSymptom: ${oneLine(data.symptom)}\nCause: ${oneLine(data.cause)}\nEvidence: ${data.evidenceRefs.map(oneLine).join(", ")}\nRetry when: ${oneLine(data.retryWhen)}`; - const note: NoteInput = { op: "note", ...(data.kind === "memory" && data.title ? { title: data.title } : {}), project: data.kind === "correction" ? target!.project : context.project, - scope: data.kind === "correction" ? target!.scope : "project", - // A demoted synced target is logically short, but replacing it locally would narrow its reach (X04). - tier: data.kind === "correction" && target!.local ? "short" : "long", - kind: data.kind === "fail" ? "fail" : data.noteKind ?? (target?.kind === "contract" || target?.kind === "fail" ? target.kind : "decision"), peer: "person", source: "person", about: [...target?.about ?? []], - text: `Accepted candidate: ${id}\nTarget revision: ${(data.kind === "memory" ? undefined : data.target)?.revision ?? "none"}\nTarget entry: ${(data.kind === "memory" ? undefined : data.target)?.id ?? "none"}\n\n${body}` }; - if (note.text.length > 4000) throw new Error("accepted candidate text exceeds the v1 note limit"); - if (context.policy(structuredClone(candidate), target && structuredClone(target), structuredClone(note)) !== true) throw new Error("candidate refused by current host policy"); - return context.store.withAcceptedNote(note, append => { - resolve(); // Recheck under the same mutation lock as the note and supersede appends. - const started = this.db.transaction(() => { - const current = this.get(id, context.project); - if (!current) throw new Error("candidate unavailable"); - if (current.status === "accepted") return current.acceptedNoteId!; - if (current.status !== "pending") throw new Error("candidate is rejected or acceptance was interrupted; no automatic replay"); - this.db.query("UPDATE candidates SET status = 'accepting' WHERE id = ? AND project IN (SELECT value FROM json_each(?))").run(id, this.scope(context.project)); - return undefined; - }).immediate(); - if (started) return { candidateId: id, noteId: started, alreadyAccepted: true }; - // ponytail: the sidecar receipt and v1 append are not one transaction. An interrupted 'accepting' row refuses - // automatic replay; add explicit person-audited recovery before offering background acceptance retries. - const noteId = append(); - if (data.kind === "correction" && !context.store.supersede(target!.id, noteId, "person", context.project)) throw new Error("candidate replacement refused; inspect the accepted note before recovery"); - this.db.query("UPDATE candidates SET status = 'accepted', note_id = ? WHERE id = ? AND project IN (SELECT value FROM json_each(?))").run(noteId, id, this.scope(context.project)); - return { candidateId: id, noteId, alreadyAccepted: false }; + resolve(); + return context.store.withCurrentEntries([data.target.id], context.project, () => { + const { target, goal } = resolve(); + const note: NoteInput = { op: "note", project: target.project, scope: target.scope, tier: "long", kind: target.kind, peer: target.peer, source: target.source as NoteInput["source"], text: target.text, about: [...target.about], + ...(target.title ? { title: target.title } : {}), ...(target.task ? { task: target.task } : {}), promotedFrom: target.id }; + if (context.policy(structuredClone(candidate), structuredClone(target), structuredClone(note), structuredClone(goal)) !== true) throw Error("candidate refused by current host policy"); + resolve(); + const replay = this.beginAcceptance(candidate.id, context.project); if (replay) return { candidateId: candidate.id, noteId: replay, alreadyAccepted: true }; + // A thrown append remains accepting: its authored-row outcome needs person-audited recovery. + const noteId = context.store.promote(target.id, "person", context.project); + if (noteId === undefined) { + this.db.query("UPDATE candidates SET status = 'pending' WHERE id = ? AND project IN (SELECT value FROM json_each(?)) AND status = 'accepting'").run(candidate.id, this.scope(context.project)); + throw Error("promotion unavailable or declined; candidate remains pending"); + } + const promoted = context.store.get(noteId, context.project); + if (noteId === target.id || !promoted || promoted.id !== noteId || promoted.tier !== "long" || promoted.promotedFrom !== target.id) throw Error("promotion outcome unconfirmed; inspect the accepting candidate"); + this.db.query("UPDATE candidates SET status = 'accepted', note_id = ? WHERE id = ? AND project IN (SELECT value FROM json_each(?))").run(noteId, candidate.id, this.scope(context.project)); + return { candidateId: candidate.id, noteId, alreadyAccepted: false }; }); } + private beginAcceptance(id: string, project: string): string | undefined { + return this.db.transaction(() => { + const current = this.get(id, project); + if (!current) throw Error("candidate unavailable"); + if (current.status === "accepted") return current.acceptedNoteId!; + if (current.status !== "pending") throw Error("candidate is rejected or acceptance was interrupted; no automatic replay"); + this.db.query("UPDATE candidates SET status = 'accepting' WHERE id = ? AND project IN (SELECT value FROM json_each(?))").run(id, this.scope(project)); + return undefined; + }).immediate(); + } } diff --git a/src/memory/commands.ts b/src/memory/commands.ts index 655ed91..77b67f3 100644 --- a/src/memory/commands.ts +++ b/src/memory/commands.ts @@ -2,6 +2,7 @@ import { extractNodes, GOAL_STATES, KINDS, MemoryStore, setMachineLabel, type Go import { syncMemory, type SyncOptions } from "./sync.ts"; import { importClaudeMem } from "./import.ts"; import { memoryProjectKey, projectChain } from "./project.ts"; +import type { GoalPromotionReport } from "./goal-promotion.ts"; /** * What the commands call themselves in their own messages. The default is amem's own surface; a host that exposes the same commands under its own @@ -29,6 +30,8 @@ export interface GoalCommandOptions { project?: string; store?: MemoryStore; link?: (goal: string, task: string) => Promise; + /** Optional host proposal hook; the goal state is saved before it runs. It never grants mutation authority. */ + onDone?: (goal: GoalView, store: MemoryStore) => Promise; open: () => MemoryStore; clean: (title: string, milestones: string[], project: string) => boolean; } @@ -220,6 +223,13 @@ export async function rawGoalCommand(args: string[], cwd: string, opts: GoalComm const state = (sub === "done" ? "done" : rest[1]) as GoalState; if (!GOAL_STATES.includes(state)) fail(`the state is one of ${GOAL_STATES.join(", ")}`); store.goalState(g.id, state, "user"); + if (state === "done" && opts.onDone) { + const current = store.goalOf(g.id, project); + if (!current || current.id !== g.id || current.state !== "done") return fail("goal state changed before promotion offering"); + const promotion = await opts.onDone(current, store); + if (json) return JSON.stringify({ goalId: current.id, state: current.state, promotion }, null, 2); + return `long-term task ${current.id.slice(0, 8)} is done\npromotion candidates: ${promotion.offered} offered, ${promotion.alreadyOffered} already offered, ${promotion.withheld} withheld${promotion.truncated ? "; more linked notes remain (truncated)" : ""}`; + } return `long-term task ${g.id.slice(0, 8)} is ${state}`; } case "take": { diff --git a/src/memory/decay-review.ts b/src/memory/decay-review.ts new file mode 100644 index 0000000..e9cc8cb --- /dev/null +++ b/src/memory/decay-review.ts @@ -0,0 +1,136 @@ +import { Database } from "bun:sqlite"; +import { lstatSync } from "node:fs"; +import { join } from "node:path"; +import { hashJSON } from "./generation.ts"; +import { candidateTargetRevision, type CandidateTarget, type Counter } from "./candidates.ts"; +import { MemoryStore, type View } from "./store.ts"; + +export const DECAY_DEFAULT_DAYS = 90, DECAY_PREVIEW_LIMIT = 64, DECAY_SCAN_LIMIT = 5000; +export interface DecayEntry { target: CandidateTarget; view: View } +export interface DecayPlan { + project: string; profile: string; days: number; at: string; cutoff: string; demoteExpiresAt: string; + entries: DecayEntry[]; withheld: number; citedExemptions: number | null; truncated: boolean; + /** Missing/zero observations do not prove the note was never cited. */ + citationHistory: "unknown"; +} +export interface DecayOptions { + now?: () => number; days?: number; ids?: readonly string[]; aliases?: readonly string[]; counters?: () => readonly Counter[] | undefined; + screen?: (view: View) => boolean; assertCurrent?: () => void; +} +function idsOf(ids: readonly string[]): string[] { + const normalized = ids.map(id => id.replace(/^mem:/, "")); + if (normalized.length > DECAY_PREVIEW_LIMIT || new Set(normalized).size !== normalized.length || normalized.some(id => !/^[a-f0-9]{32}$/.test(id))) throw Error("name up to 64 distinct exact entry ids"); + return normalized; +} +/** Advisory telemetry without opening/pruning the candidate writer. Unavailable is unknown, never zero. */ +export function readDecayCounters(base: string, projects: readonly string[]): Counter[] | undefined { + const file = join(base, "candidates.db"); + let db: Database | undefined; + try { + const stat = lstatSync(file, { throwIfNoEntry: false }); + if (!stat || !stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) return undefined; + db = new Database(file, { readonly: true }); db.run("PRAGMA busy_timeout = 0"); + const rows = db.query("SELECT id,payload,at,rendered,delivered,cited FROM counters WHERE project IN (SELECT value FROM json_each(?)) AND cited > 0 ORDER BY at DESC").all(JSON.stringify(projects)) as { id: string; payload: string; at: number; rendered: number | null; delivered: number | null; cited: number | null }[]; + return rows.map(row => ({ id: row.id, key: JSON.parse(row.payload), createdAt: new Date(row.at).toISOString(), rendered: row.rendered, delivered: row.delivered, cited: row.cited })); + } catch { return undefined; } + finally { try { db?.close(); } catch { /* An unavailable telemetry close cannot authorize a mutation. */ } } +} +function citedIds(project: string, aliases: readonly string[], counters: readonly Counter[] | undefined): Set { + const ids = new Set(); + for (const row of counters ?? []) { + if (!row?.key || ![project, ...aliases].includes(row.key.project) || !Number.isSafeInteger(row.cited) || !(row.cited! > 0)) continue; + // Counter timestamps describe delivery, not a later citation update. Any retained positive exempts. + ids.add(row.key.entryId); + } + return ids; +} +/** Age/goal selection is independent of telemetry; retained positive citations may only exempt entries. */ +export function decayPreview(store: MemoryStore, project: string, options: DecayOptions = {}): DecayPlan { + options.assertCurrent?.(); + if (!project.trim() || store.profile.sharded && !store.profile.shardUser && store.profile.shardKey !== project) throw Error("decay project does not match the bound store"); + const now = (options.now ?? Date.now)(), days = options.days ?? DECAY_DEFAULT_DAYS, cutoff = now - days * 86400_000; + if (!Number.isSafeInteger(days) || days < 1 || !Number.isFinite(now) || !Number.isFinite(cutoff) || Math.abs(cutoff) > 8_640_000_000_000_000) throw Error("decay days are outside the supported range"); + const requested = options.ids === undefined ? undefined : idsOf(options.ids); + return store.withDeferredPromotions(() => { + const goals = store.goals(project).filter(goal => ["open", "active", "blocked"].includes(goal.state)); + const tasks = new Set(goals.flatMap(goal => goal.links.map(link => link.task))), goalNodes = new Set(goals.map(goal => `goal:${goal.id}`)); + let counters: readonly Counter[] | undefined; + try { counters = options.counters?.(); } catch { /* Telemetry is advisory; an unreadable snapshot is unknown. */ } + const cited = citedIds(project, options.aliases ?? [], counters); + // ponytail: list materializes the existing core corpus before slicing; a scoped pager is the upgrade for large homes. + const rows = requested ? requested.map(id => store.get(id, project)) : store.list({ project, tier: "long", limit: DECAY_SCAN_LIMIT + 1 }); + if (rows.length > DECAY_SCAN_LIMIT) throw Error("decay preview scan bound exceeded; name exact ids to narrow it"); + const entries: DecayEntry[] = []; let withheld = 0, citedExemptions = 0; + for (let index = 0; index < rows.length; index++) { + const view = rows[index]; + const eligible = view && (!requested || view.id === requested[index]) && view.project === project && view.tier === "long" && !view.supersededBy && Number.isFinite(Date.parse(view.at)) && Date.parse(view.at) < cutoff + && !(view.task && tasks.has(view.task)) && !view.about.some(node => goalNodes.has(node) || node.startsWith("task:") && tasks.has(node.slice(5))); + if (!eligible) { if (requested) throw Error("requested entry is not eligible for decay"); continue; } + if (cited.has(view.id)) { citedExemptions++; if (requested) throw Error("requested entry has a retained recorded citation"); continue; } + if (options.screen && !options.screen(structuredClone(view))) { withheld++; if (requested) throw Error("requested entry is withheld by current policy"); continue; } + entries.push({ target: { id: view.id, revision: candidateTargetRevision(view) }, view: structuredClone(view) }); + } + options.assertCurrent?.(); + return { project, profile: hashJSON(store.profile), days, at: new Date(now).toISOString(), cutoff: new Date(cutoff).toISOString(), demoteExpiresAt: store.shortExpiry(), entries: entries.slice(0, DECAY_PREVIEW_LIMIT), withheld, citedExemptions: counters === undefined ? null : citedExemptions, truncated: entries.length > DECAY_PREVIEW_LIMIT, citationHistory: "unknown" }; + }); +} +/** Duplicate-group forgetting requires a separate person group review, never an implicit decay expansion. */ +export function assertExactDecayForget(store: MemoryStore, ids: readonly string[], project: string): void { + store.withDeferredPromotions(() => { + for (const id of ids) { + const targets = store.forgetTargets(id, project); + if (targets.length !== 1 || targets[0]?.id !== id) throw Error("decay forget expands beyond reviewed exact entries; review the imported copy group separately"); + } + }); +} + +export interface DecayApplyOptions extends Omit { + actor: "person" | "agent"; confirmed?: boolean; forget?: boolean; +} +export interface DecayApplyReport { action: "demote" | "forget"; applied: string[]; failed: string; remaining: string[]; expiresAt?: string } +export class DecayApplyError extends Error { + jsonOutput = false; + readonly failedMayHaveChanged = true; + constructor(readonly report: DecayApplyReport, cause: unknown) { + super("decay apply stopped; the failed target may have changed, inspect before retrying", { cause }); this.name = "DecayApplyError"; + } + get applied(): readonly string[] { return this.report.applied; } + get failed(): string { return this.report.failed; } + get remaining(): readonly string[] { return this.report.remaining; } + render(): string { + if (this.jsonOutput) return JSON.stringify({ ok: false, ...this.report, failedMayHaveChanged: this.failedMayHaveChanged }); + return `${this.message}; applied: ${this.report.applied.join(", ") || "none"}; failed: ${this.report.failed}; remaining: ${this.report.remaining.join(", ") || "none"}${this.report.expiresAt ? `; completed demotions expire: ${this.report.expiresAt}` : ""}`; + } +} +/** The host shows this exact plan; every selected revision/eligibility is rechecked before any append. */ +export function applyDecay(store: MemoryStore, plan: DecayPlan, options: DecayApplyOptions): { action: "demote" | "forget"; ids: string[]; expiresAt?: string } { + if (options.actor !== "person") throw Error("decay apply requires person authority"); + options.assertCurrent?.(); + const ids = options.ids === undefined ? plan.entries.map(entry => entry.target.id) : idsOf(options.ids); + if ((options.ids === undefined || options.forget) && options.confirmed !== true) throw Error("decay bulk/forget apply requires explicit person confirmation"); + if (hashJSON(store.profile) !== plan.profile || ids.some(id => !plan.entries.some(entry => entry.target.id === id))) throw Error("decay preview binding changed or target was not previewed"); + const action = options.forget ? "forget" : "demote"; + if (!ids.length) return { action, ids }; + if (!options.forget && (!Number.isFinite(Date.parse(plan.demoteExpiresAt)) || new Date(plan.demoteExpiresAt).toISOString() !== plan.demoteExpiresAt || Date.parse(plan.demoteExpiresAt) <= (options.now ?? Date.now)())) throw Error("decay demotion expiry is spent; preview again"); + if (!options.forget) store.assertDemotionExpiry(plan.demoteExpiresAt); + return store.withCurrentEntries(ids, plan.project, current => { + const fresh = decayPreview(store, plan.project, { ...options, days: plan.days, ids }); + for (const view of current) { + const expected = plan.entries.find(entry => entry.target.id === view.id)?.target.revision; + if (!expected || candidateTargetRevision(view) !== expected || !fresh.entries.some(entry => entry.target.id === view.id && entry.target.revision === expected)) throw Error("decay target changed since preview"); + } + if (options.forget) assertExactDecayForget(store, ids, plan.project); + options.assertCurrent?.(); + // All checks precede effects. The failed target may itself have appended before an I/O error. + const applied: string[] = [], expiry = options.forget ? {} : { expiresAt: plan.demoteExpiresAt }; + for (let index = 0; index < ids.length; index++) { + const id = ids[index]!; + try { + const result = options.forget ? store.forget(id, "person", plan.project, true) : store.demote(id, "person", plan.project, plan.demoteExpiresAt); + if (!result) throw Error("decay target outcome unavailable"); + applied.push(id); + } catch (error) { throw new DecayApplyError({ action, applied: [...applied], failed: id, remaining: ids.slice(index + 1), ...expiry }, error); } + } + return { action, ids, ...expiry }; + }); +} diff --git a/src/memory/goal-promotion.ts b/src/memory/goal-promotion.ts new file mode 100644 index 0000000..5d33dfc --- /dev/null +++ b/src/memory/goal-promotion.ts @@ -0,0 +1,71 @@ +import { lstatSync } from "node:fs"; +import { join } from "node:path"; +import { CandidateStore, candidateInputId, candidateTargetRevision, goalPromotionLinked, type CandidateInput } from "./candidates.ts"; +import { readSelector } from "./generation.ts"; +import { projectState } from "./lifecycle.ts"; +import { realPath } from "./platform.ts"; +import type { StandalonePolicy } from "./policy.ts"; +import { aliasedKeys, readShardedSelection, resolveAlias } from "./shard.ts"; +import { MemoryStore, type GoalView } from "./store.ts"; + +export const GOAL_PROMOTION_SCAN_LIMIT = 5000, GOAL_PROMOTION_LIMIT = 64; +export interface GoalPromotionOptions { + /** Recreated before/after awaits so a changed built-in configuration is never a cached public verdict. */ + policy(): StandalonePolicy; + assertCurrent?(): void; + limit?: number; +} +/** ids contains only newly created proposals; known states are counted without disclosure or replay. */ +export interface GoalPromotionReport { ids: string[]; offered: number; alreadyOffered: number; withheld: number; truncated: boolean } +const goalRevision = (goal: GoalView) => JSON.stringify([goal.id, goal.project, goal.scope, goal.title, goal.milestones, goal.dependsOn, goal.state, goal.links]); +/** Offer only machine-local metadata proposals. Goal state is set by the person caller, never by this helper. */ +export async function offerGoalPromotions(store: MemoryStore, candidates: CandidateStore, project: string, goalId: string, options: GoalPromotionOptions): Promise { + goalId = goalId.replace(/^goal:/, ""); + const limit = options.limit ?? GOAL_PROMOTION_LIMIT; + if (!/^[a-f0-9]{32}$/.test(goalId) || !project.trim() || !Number.isSafeInteger(limit) || limit < 1 || limit > GOAL_PROMOTION_LIMIT) throw Error("goal promotion requires an exact goal id and a bounded limit"); + const base = realPath(store.profile.home); + if (realPath(candidates.dir) !== base) throw Error("goal promotion sidecar does not match its store"); + project = resolveAlias(base, project); + const selector = readSelector(base).revision, aliases = aliasedKeys(base, project).join("\n"); + if (selector !== store.profile.selectorRevision) throw Error("goal promotion store selector changed"); + const split = !!lstatSync(join(base, "shards.json"), { throwIfNoEntry: false }), shards = split ? JSON.stringify(readShardedSelection(base)) : undefined; + const read = (work: () => T) => store.withDeferredPromotions(work); + const guard = () => { + options.assertCurrent?.(); + const currentSplit = !!lstatSync(join(base, "shards.json"), { throwIfNoEntry: false }); + if (readSelector(base).revision !== selector || resolveAlias(base, project) !== project || aliasedKeys(base, project).join("\n") !== aliases || projectState(base, project) !== "active" || currentSplit !== split || split && JSON.stringify(readShardedSelection(base)) !== shards) throw Error("goal promotion binding changed"); + }; + const goal = read(() => store.goalOf(goalId, project)); + if (!goal || goal.id !== goalId || goal.state !== "done") throw Error("goal promotion requires a current done goal"); + const revision = goalRevision(goal), currentGoal = () => { + guard(); const value = read(() => store.goalOf(goalId, project)); + if (!value || value.id !== goalId || value.state !== "done" || goalRevision(value) !== revision) throw Error("goal changed during promotion offering"); + return value; + }; + const result: GoalPromotionReport = { ids: [], offered: 0, alreadyOffered: 0, withheld: 0, truncated: false }; + guard(); + const goalAllowed = await options.policy().screen(goal); currentGoal(); + if (!goalAllowed || !options.policy().screenStandalone(goal)) { result.withheld++; return result; } + // ponytail: list() retains full-corpus materialization; this bounds selected candidates/async policy work only. + // Upgrade: scope-aware pages including foreign-source shared rows (readPage is exact-project only). + const rows = read(() => store.list({ project, tier: "short", limit: GOAL_PROMOTION_SCAN_LIMIT + 1 })); + result.truncated = rows.length > GOAL_PROMOTION_SCAN_LIMIT; + let selected = 0; + for (const row of rows.slice(0, GOAL_PROMOTION_SCAN_LIMIT)) { + if (row.tier !== "short" || row.supersededBy || row.project !== project && row.scope !== "shared" || !goalPromotionLinked(row, goal)) continue; + const input: CandidateInput = { project, kind: "promotion", goalId, target: { id: row.id, revision: candidateTargetRevision(row) } }; + currentGoal(); + const id = candidateInputId(input); + if (candidates.get(id, project)) { result.alreadyOffered++; continue; } + if (selected++ >= limit) { result.truncated = true; break; } + const complete = { ...input, targetView: row, goalView: goal }; + currentGoal(); const permitted = await options.policy().screen(complete); const freshGoal = currentGoal(); + const fresh = read(() => store.get(row.id, project)); + if (!permitted || !fresh || fresh.id !== row.id || candidateTargetRevision(fresh) !== input.target.revision || fresh.tier !== "short" || fresh.supersededBy || !goalPromotionLinked(fresh, freshGoal) + || !options.policy().screenStandalone({ ...input, targetView: fresh, goalView: freshGoal })) { result.withheld++; continue; } + guard(); + if (candidates.get(id, project)) { result.alreadyOffered++; continue; } + candidates.put(input, "public"); result.offered++; result.ids.push(id); + } + guard(); return result; +} diff --git a/src/memory/store.ts b/src/memory/store.ts index 7f06e44..5d9da4d 100644 --- a/src/memory/store.ts +++ b/src/memory/store.ts @@ -2,7 +2,7 @@ import { Database } from "bun:sqlite"; import { createHash } from "node:crypto"; import { appendFileSync, chmodSync, closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; import { isAbsolute, join, normalize } from "node:path"; -import { isDenied } from "./platform.ts"; +import { isDenied, realPath } from "./platform.ts"; import { stripPrivateDeep, stripPrivateSpans } from "./private-spans.ts"; import { looksSecret } from "./screen.ts"; import { mutationDatabase } from "./mutation.ts"; @@ -345,15 +345,36 @@ export class MemoryStore { /** A synchronous host-authorized mutation whose reads must not implicitly promote unrelated notes. */ withDeferredPromotions(work: () => T): T { - const previous = this.promoting; - this.promoting = true; - try { return work(); } finally { this.promoting = previous; } + const previous = this.readStores().map(store => [store, store.promoting] as const); + for (const [store] of previous) store.promoting = true; + try { return work(); } finally { for (const [store, promoting] of previous) store.promoting = promoting; } } private withMutation(work: () => T): T { return this.mutationDb.transaction(work).immediate(); } + /** Synchronous exact-target work under every opened shard's existing writer fence. No read-time promotions. */ + withCurrentEntries(ids: readonly string[], project: string, work: (entries: readonly View[]) => T): T { + if (!ids.length || ids.length > MAX_TARGETS || new Set(ids).size !== ids.length || ids.some(id => !/^[a-f0-9]{32}$/.test(id)) || !project.trim()) throw new Error("invalid exact-entry batch"); + const unique = new Map(); + for (const store of this.readStores()) { const dir = realPath(store.dir); if (!unique.has(dir)) unique.set(dir, store); } + const stores = [...unique.keys()].sort().map(dir => unique.get(dir)!); + const execute = (): T => { + assertProjectActive(this.profile.home, project); + for (const store of stores) store.ensureFresh(); + const entries = ids.map(id => { + const view = this.get(id, project); + if (!view || view.id !== id || view.supersededBy) throw new Error("exact entry changed or unavailable"); + return view; + }); + return work(entries); + }; + const lock = (index: number): T => index === stores.length ? execute() : stores[index]!.withMutation(() => lock(index + 1)); + // Older/raw writers that bypass mutation-lock.db retain the existing mutation.ts ceiling. + return this.withDeferredPromotions(() => lock(0)); + } + /** Serialize current-store writers, screen one normalized body, and expose a one-shot append of that body. */ withAcceptedNote(note: NoteInput, work: (append: () => string) => T): T { if (this.userOverlay && note.scope === "shared") return this.userOverlay.withAcceptedNote(note, work); @@ -446,11 +467,19 @@ export class MemoryStore { private op(op: OpEntry["op"], target: View, actor: string, by?: string): string { return this.append({ op, target: target.id, actor, ...(by ? { by } : {}) }, target.local); } - forget(id: string, actor: string, project?: string): View | undefined { + /** Complete existing forget expansion, including superseded legacy-import copies. Reads author no ops. */ + forgetTargets(id: string, project?: string): View[] { + const v = this.get(id, project); return v ? this.forgetGroup(v) : []; + } + private forgetGroup(v: View): View[] { + return v.source === "claude-mem" ? this.live().filter(x => x.source === v.source && x.project === v.project && x.at === v.at && x.text === v.text) : [v]; + } + forget(id: string, actor: string, project?: string, exactOnly = false): View | undefined { const v = this.get(id, project); if (!v) return undefined; - // Legacy imports may have different node-derived ids. Forget every known copy, including hidden ones. - const group = v.source === "claude-mem" ? this.live().filter(x => x.source === v.source && x.project === v.project && x.at === v.at && x.text === v.text) : [v]; + const group = this.forgetGroup(v); + if (exactOnly && (group.length !== 1 || group[0]?.id !== id)) throw Error("forget expands beyond the reviewed exact entry"); + // Ordinary person forget retains its legacy-import copy expansion; reviewed decay is exact-only. for (const member of group) this.op("forget", member, actor); return v; } @@ -497,7 +526,17 @@ export class MemoryStore { } /** A short-term note kept short-term: it expires like any other and is never promoted (its demote entry stays local). */ keepShort(id: string, actor: string): void { const v = this.get(id); if (v?.tier === "short" && v.local && v.expiresAt === null) this.op("demote", v, actor, this.until()); } // once: a deadline is never pushed out - demote(id: string, actor: string, project?: string): View | undefined { const v = this.get(id, project); if (v?.tier === "long") this.op("demote", v, actor, this.until()); return v; } + /** The short-tier deadline to display before a person confirms a demotion. */ + shortExpiry(): string { return this.until(); } + /** A confirmed deadline may be older than a fresh one, but cannot extend the configured TTL beyond one hour. */ + assertDemotionExpiry(expiresAt: string): void { + const at = Date.parse(expiresAt), now = this.now(); + if (!Number.isFinite(at) || new Date(at).toISOString() !== expiresAt || at <= now || at > now + this.ttl + 3600_000) throw Error("demotion expiry must be future UTC within the short-tier deadline plus one hour"); + } + demote(id: string, actor: string, project?: string, expiresAt?: string): View | undefined { + if (expiresAt !== undefined) this.assertDemotionExpiry(expiresAt); + const v = this.get(id, project); if (v?.tier === "long") this.op("demote", v, actor, expiresAt ?? this.until()); return v; + } supersede(oldId: string, newId: string, actor: string, project?: string): boolean { const a = this.get(oldId, project), b = this.get(newId, project); if (!a || !b || a.id === b.id) return false; diff --git a/test/candidate-decay-cli.test.ts b/test/candidate-decay-cli.test.ts new file mode 100644 index 0000000..9134158 --- /dev/null +++ b/test/candidate-decay-cli.test.ts @@ -0,0 +1,106 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runCli, type CliContext } from "../src/cli.ts"; +import { createMemoryMcp } from "../src/mcp-memory.ts"; +import { CandidateStore } from "../src/memory/candidates.ts"; +import { pendingSummary } from "../src/memory/candidate-pending.ts"; +import { migrate } from "../src/memory/migrate.ts"; +import { migrateShards } from "../src/memory/shard-migration.ts"; +import { realPath } from "../src/memory/platform.ts"; +import { memoryProjectKey } from "../src/memory/project.ts"; +import { MemoryStore, MEMORY_SCHEMA, entryId, openShardStore, type NoteEntry, type View } from "../src/memory/store.ts"; +import { sessionStartBrief } from "../src/session-brief.ts"; +const cleanup: Array<() => void> = []; +afterEach(() => { for (const close of cleanup.splice(0).reverse()) close(); }); +type Mode = "legacy" | "generation" | "shards"; +const structuredFail = { kind: "fail", approach: "hold one bounded lease", symptom: "the second writer stalls", cause: "the lease spans unrelated work", evidenceRefs: ["test:bounded-lease"], retryWhen: "the contention regression passes" }; +async function fixture(mode: Mode = "legacy", secondTarget = false) { + const root = realPath(mkdtempSync(join(tmpdir(), "amem-candidate-decay-cli-"))), base = join(root, "memory"), project = memoryProjectKey(root); + cleanup.push(() => rmSync(root, { recursive: true, force: true })); + const initial = new MemoryStore({ memoryDir: base, now: () => Date.now() - 120 * 86400_000, deferPromotions: true }); + const target = initial.note({ op: "note", project, scope: "project", tier: "long", kind: "contract", text: "original public writer boundary", peer: "original-peer", source: "person", task: `${project}#99@1`, about: [] }); + const second = secondTarget ? initial.note({ op: "note", project, scope: "project", tier: "long", kind: "contract", text: "another old public boundary", peer: "person", source: "person", about: [] }) : undefined; initial.close(); + if (mode === "generation") await migrate({ memoryDir: base, generation: "d".repeat(32), mode: "apply", authorizedPerson: true, tty: true }); + if (mode === "shards") await migrateShards({ memoryDir: base, mode: "apply", authorizedPerson: true, tty: true }); + const open = () => mode === "shards" ? openShardStore({ memoryDir: base, project, deferPromotions: true }) : new MemoryStore({ memoryDir: base, deferPromotions: true }); + const use = (work: (store: MemoryStore) => T): T => { const store = open(); try { return work(store); } finally { store.close(); } }; + const candidates = (work: (store: CandidateStore) => T): T => { const store = new CandidateStore({ memoryDir: base }); try { return work(store); } finally { store.close(); } }; + const bytes = () => Object.fromEntries(readdirSync(base, { recursive: true, withFileTypes: true }).filter(row => row.isFile() && row.name.endsWith(".jsonl")).map(row => { const file = join(row.parentPath, row.name); return [file.slice(base.length), readFileSync(file, "utf8")]; })); + const cli = async (args: string[], extra: Partial = {}) => { + const previousHome = process.env.HOME, previousBase = process.env.AMEM_HOME; process.env.HOME = root; process.env.AMEM_HOME = base; + try { return await runCli(args, { cwd: root, env: {}, stdinTTY: true, ...extra }); } + finally { if (previousHome === undefined) delete process.env.HOME; else process.env.HOME = previousHome; if (previousBase === undefined) delete process.env.AMEM_HOME; else process.env.AMEM_HOME = previousBase; } + }; + const propose = async () => { const mcp = createMemoryMcp({ cwd: root, base, project }); try { const fail = (await mcp.tools.amem_remember(structuredFail) as { id: string }).id; const correction = (await mcp.tools.amem_correct({ id: target, proposedText: "use a bounded writer lease", reason: "the public regression establishes the boundary", kind: "contract" }) as { id: string }).id; return { fail, correction }; } finally { mcp.close(); } }; + return { root, base, project, target, second, open, use, candidates, bytes, cli, propose }; +} +function canonicalPromotion(view: View): string { + const body: Omit = { schema: MEMORY_SCHEMA, op: "note", at: view.at, machine: view.machine, project: view.project, scope: view.scope, tier: "long", kind: view.kind, peer: view.peer, text: view.text, about: [...view.about], promotedFrom: view.id, source: view.source as NoteEntry["source"], ...(view.title ? { title: view.title } : {}), ...(view.task ? { task: view.task } : {}) }; + return entryId(body); +} +for (const mode of ["legacy", "generation", "shards"] as const) { + test(`${mode}: MCP fail/correction remain pending until person CLI accepts into the bound profile`, async () => { + const f = await fixture(mode), before = f.bytes(), ids = await f.propose(); expect(f.bytes()).toEqual(before); + const listed = JSON.parse(await f.cli(["review", "list", "--json"])) as Array<{ id: string; status: string }>; + expect(listed.map(row => row.id).sort()).toEqual(Object.values(ids).sort()); expect(listed.every(row => row.status === "pending")).toBe(true); + const fail = JSON.parse(await f.cli(["review", "accept", ids.fail, "--json"])), correction = JSON.parse(await f.cli(["review", "accept", ids.correction, "--json"])); + f.use(store => { expect(store.get(fail.noteId, f.project)).toMatchObject({ kind: "fail", tier: "long", source: "person", peer: "person" }); expect(store.get(fail.noteId, f.project)?.text).toContain(structuredFail.retryWhen); expect(store.get(correction.noteId, f.project)).toMatchObject({ kind: "contract", project: f.project }); expect(store.get(correction.noteId, f.project)?.text).toContain("use a bounded writer lease"); expect(store.get(correction.noteId, f.project)?.text).toContain("Target revision:"); expect(store.get(f.target, f.project)?.supersededBy).toBe(correction.noteId); const dump = store.dump(); store.rebuild(); expect(store.dump()).toBe(dump); }); + const accepted = f.bytes(); expect(JSON.parse(await f.cli(["review", "accept", ids.correction, "--json"]))).toMatchObject({ noteId: correction.noteId, alreadyAccepted: true }); expect(f.bytes()).toEqual(accepted); + }); + test(`${mode}: goal done offers pending promotion, SessionStart does not mark shown, person walk preserves canonical attribution`, async () => { + const f = await fixture(mode); let goal = "", note = ""; + const original = f.use(store => { goal = store.goal({ project: f.project, scope: "project", title: "ship public boundary", milestones: [], dependsOn: [], actor: "person" }); const task = `${f.project}#8@10`; store.goalLink(goal, task, "person"); note = store.note({ op: "note", project: f.project, scope: "project", tier: "short", kind: "decision", peer: "native-author", source: "person", text: "actual linked short note body", task, about: [] }); return store.get(note, f.project)!; }); + const before = f.bytes(), report = JSON.parse(await f.cli(["goal", "done", goal, "--json"])); expect(report.promotion.offered).toBe(1); const id = report.promotion.ids[0]; + expect(f.use(store => store.get(note, f.project)?.tier)).toBe("short"); expect(f.candidates(store => store.get(id, f.project)?.status)).toBe("pending"); + expect(Object.values(f.bytes()).join("\n").split('"op":"note"').length).toBe(Object.values(before).join("\n").split('"op":"note"').length); + const brief = await sessionStartBrief({ cwd: f.root, base: f.base, project: f.project, maxTokens: 1200 }); expect(brief.context).toContain("1 candidate"); expect(pendingSummary(f.base, [f.project], Date.now())).toMatchObject({ count: 1, unshown: 1 }); + expect(JSON.parse(await f.cli(["goal", "done", goal, "--json"])).promotion).toMatchObject({ offered: 0, alreadyOffered: 1 }); + const printed: string[] = []; expect(await f.cli(["review", "walk", "--kind", "promotion"], { write: line => printed.push(line), prompt: () => "a" })).toContain("accepted 1"); expect(printed.join("\n")).toContain(original.text); + const expected = canonicalPromotion(original); expect(f.candidates(store => store.get(id, f.project))).toMatchObject({ status: "accepted", acceptedNoteId: expected }); + f.use(store => expect(store.get(expected, f.project)).toMatchObject({ promotedFrom: note, peer: original.peer, source: original.source, at: original.at, machine: original.machine, tier: "long" })); + expect(JSON.parse(await f.cli(["goal", "done", goal, "--json"])).promotion.offered).toBe(0); expect(f.candidates(store => store.list({ project: f.project, status: "all" }))).toHaveLength(1); + }); +} +test("candidate acceptance refuses agents, nonTTY, stale target and current privacy without curated effects", async () => { + const f = await fixture(), ids = await f.propose(), before = f.bytes(); + await expect(f.cli(["review", "accept", ids.fail], { env: { CLAUDECODE: "" } })).rejects.toThrow("person"); + await expect(f.cli(["review", "accept", ids.fail], { stdinTTY: false })).rejects.toThrow(); expect(f.bytes()).toEqual(before); + f.use(store => store.demote(f.target, "person", f.project)); const changed = f.bytes(); await expect(f.cli(["review", "accept", ids.correction])).rejects.toThrow(); expect(f.bytes()).toEqual(changed); + writeFileSync(join(f.base, "amem.config.json"), JSON.stringify({ piiPatterns: [structuredFail.cause] })); + await expect(f.cli(["review", "accept", ids.fail])).rejects.toThrow("privacy"); expect(f.bytes()).toEqual(changed); + expect(f.candidates(store => store.get(ids.fail, f.project)?.status)).toBe("pending"); expect(f.candidates(store => store.get(ids.correction, f.project)?.status)).toBe("pending"); +}); +test("rejected MCP proposal identity remains rejected on repeat and does not author notes", async () => { + const f = await fixture(), ids = await f.propose(), before = f.bytes(); await f.cli(["review", "reject", ids.fail]); const repeated = await f.propose(); expect(repeated).toEqual(ids); expect(f.candidates(store => store.get(ids.fail, f.project)?.status)).toBe("rejected"); expect(f.candidates(store => store.get(ids.correction, f.project)?.status)).toBe("pending"); expect(f.bytes()).toEqual(before); +}); +test("decay defaults to a read-only revision preview, then named subset demotes only after printed preview and yes", async () => { + const f = await fixture("legacy", true), before = f.bytes(), preview = JSON.parse(await f.cli(["decay", "--json"])); expect(preview.entries.map((row: any) => row.target.id).sort()).toEqual([f.target, f.second].sort()); expect(preview.citationHistory).toBe("unknown"); expect(f.bytes()).toEqual(before); + const printed: string[] = [], questions: string[] = []; + const applied = JSON.parse(await f.cli(["decay", `mem:${f.target}`, "--apply", "--json"], { write: text => printed.push(text), prompt: question => { questions.push(question); expect(printed.join("\n")).toContain(preview.entries.find((row: any) => row.target.id === f.target).target.revision); expect(f.bytes()).toEqual(before); return "yes"; } })); + expect(applied).toMatchObject({ action: "demote", ids: [f.target] }); expect(applied.expiresAt).toBe(f.use(store => store.get(f.target, f.project)?.expiresAt)); expect(questions).toHaveLength(1); expect(f.use(store => store.get(f.target, f.project)?.tier)).toBe("short"); expect(f.use(store => store.get(f.second!, f.project)?.tier)).toBe("long"); +}); +test("bulk decay requires confirmation and forget explicitly names FORGET before removing exact entries", async () => { + const f = await fixture(), before = f.bytes(); let question = ""; + expect(await f.cli(["decay", "--apply"], { write: () => {}, prompt: q => { question = q; return "no"; } })).toContain("cancelled"); expect(question).toContain("demote"); expect(f.bytes()).toEqual(before); + const forgotten = JSON.parse(await f.cli(["decay", "--apply", "--forget", "--json"], { write: () => {}, prompt: q => { expect(q).toContain("FORGET"); expect(q).toContain("permanently"); return "yes"; } })); expect(forgotten).toEqual({ action: "forget", ids: [f.target] }); expect(f.use(store => store.get(f.target, f.project))).toBeUndefined(); +}); +test("decay apply refuses redirected output, nonTTY, agent and cancellation without target writes", async () => { + const f = await fixture(), before = f.bytes(); let prompts = 0; + for (const extra of [{ stdinTTY: false, write: () => {} }, { stdoutTTY: false }, { env: { CODEX_THREAD_ID: "" }, write: () => {} }]) await expect(f.cli(["decay", "--apply"], { ...extra, prompt: () => { prompts++; return "yes"; } })).rejects.toThrow(); + expect(prompts).toBe(0); expect(f.bytes()).toEqual(before); + expect(await f.cli(["decay", "--apply"], { write: () => {}, prompt: () => null })).toContain("cancelled"); expect(f.bytes()).toEqual(before); +}); +for (const drift of ["target", "goal", "citation", "policy"] as const) test(`decay ${drift} drift during confirmation refuses the complete batch before effects`, async () => { + const f = await fixture("legacy", true); let changed: ReturnType | undefined; + await expect(f.cli(["decay", "--apply"], { write: () => {}, prompt: () => { + if (drift === "target") f.use(store => store.demote(f.target, "person", f.project)); + if (drift === "goal") f.use(store => { const goal = store.goal({ project: f.project, scope: "project", title: "new protection", milestones: [], dependsOn: [], actor: "person" }); store.goalLink(goal, `${f.project}#99@1`, "person"); }); + if (drift === "citation") f.candidates(store => store.recordCounter({ project: f.project, entryId: f.target, deliveryId: "confirmed-citation" }, { cited: 1 }, "public")); + if (drift === "policy") writeFileSync(join(f.base, "amem.config.json"), JSON.stringify({ piiPatterns: ["original public writer boundary"] })); + changed = f.bytes(); return "yes"; + } })).rejects.toThrow(); expect(f.bytes()).toEqual(changed!); + expect(f.use(store => store.get(f.second!, f.project)?.tier)).toBe("long"); + if (drift !== "target") expect(f.use(store => store.get(f.target, f.project)?.tier)).toBe("long"); +}); diff --git a/test/candidate-tools.test.ts b/test/candidate-tools.test.ts new file mode 100644 index 0000000..90703ab --- /dev/null +++ b/test/candidate-tools.test.ts @@ -0,0 +1,138 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough } from "node:stream"; +import { createMemoryMcp, type MemoryMcp } from "../src/mcp-memory.ts"; +import { MCP_TOOLS, serveMcp } from "../src/mcp.ts"; +import { CandidateStore, candidateTargetRevision } from "../src/memory/candidates.ts"; +import { MemoryStore, type NoteInput } from "../src/memory/store.ts"; +import type { StandalonePolicyOptions } from "../src/memory/policy.ts"; +import { realPath } from "../src/memory/platform.ts"; +const cleanup: Array<() => void> = []; +afterEach(() => { for (const close of cleanup.splice(0).reverse()) close(); }); +const fail = { kind: "fail", approach: "use a single transaction", symptom: "the second writer waits", cause: "a long held writer lease", evidenceRefs: ["test:writer-lease", "path:src/writer.ts"], retryWhen: "the bounded contention regression passes" }; +function fixture() { + const root = realPath(mkdtempSync(join(tmpdir(), "amem-candidate-tools-"))), base = join(root, "memory"), project = "fixture-project"; + cleanup.push(() => rmSync(root, { recursive: true, force: true })); + const store = new MemoryStore({ memoryDir: base, deferPromotions: true, screen: () => "promote" }); cleanup.push(() => store.close()); + const sidecar = new CandidateStore({ memoryDir: base }); cleanup.push(() => sidecar.close()); + const note = (extra: Partial = {}) => store.note({ op: "note", project, scope: "project", tier: "long", kind: "contract", peer: "person", source: "person", text: "the original public boundary", about: ["path:src/writer.ts"], ...extra }); + const open = (policy?: StandalonePolicyOptions) => { const mcp = createMemoryMcp({ cwd: root, base, project, ...(policy ? { policy } : {}) }); cleanup.push(() => mcp.close()); return mcp; }; + const bytes = () => readdirSync(base, { recursive: true, withFileTypes: true }).filter(file => file.isFile() && file.name.endsWith(".jsonl")).map(file => { const full = join(file.parentPath, file.name); return [full, readFileSync(full, "utf8")]; }).sort((a, b) => a[0]!.localeCompare(b[0]!)); + return { root, base, project, store, sidecar, note, open, bytes }; +} +function deferred() { let resolve!: () => void; const promise = new Promise(done => { resolve = done; }); return { resolve, promise }; } +const idOf = (result: unknown) => (result as { id: string }).id; + +test("structured fail proposals are pending machine-local candidates and never author curated rows", async () => { + const f = fixture(); f.note(); const mcp = f.open(), before = f.bytes(); + const id = idOf(await mcp.tools.amem_remember(fail)); + expect(f.sidecar.path).toBe(join(f.base, "candidates.db")); + expect(f.sidecar.get(id, f.project)).toMatchObject({ status: "pending", acceptedNoteId: null, input: { project: f.project, ...fail } }); + expect(f.bytes()).toEqual(before); expect(f.store.list({ project: f.project })).toHaveLength(1); + expect(await mcp.tools.amem_search({ query: "single transaction", includeCapture: false })).toMatchObject({ hits: [] }); +}); +test("legacy remember defaults and explicit memory kinds remain compatible", async () => { + const f = fixture(), mcp = f.open(); + const fallback = idOf(await mcp.tools.amem_remember({ text: "legacy public choice" })); + expect(f.sidecar.get(fallback, f.project)?.input).toEqual({ project: f.project, kind: "memory", text: "legacy public choice", noteKind: "finding" }); + for (const kind of ["decision", "finding", "contract", "fail"] as const) { + const id = idOf(await mcp.tools.amem_remember({ text: "public explicit choice", title: "Choice", kind })); + expect(f.sidecar.get(id, f.project)?.input).toEqual({ project: f.project, kind: "memory", text: "public explicit choice", title: "Choice", noteKind: kind }); + } +}); +test("malformed structured fails and model-selected authority/scope refuse without pending candidates", async () => { + const f = fixture(), mcp = f.open(), before = f.bytes(); + for (const args of [ + { ...fail, cause: undefined }, { ...fail, evidenceRefs: [] }, { ...fail, evidenceRefs: Array(17).fill("test:x") }, { ...fail, evidenceRefs: ["x".repeat(401)] }, { ...fail, evidenceRefs: [1] }, + { ...fail, kind: "decision" }, { ...fail, text: "do not silently ignore" }, { ...fail, title: "do not silently ignore" }, { ...fail, project: "other" }, { ...fail, status: "accepted" }, + ]) await expect(mcp.tools.amem_remember(args)).rejects.toThrow(); + expect(f.sidecar.list({ project: f.project, status: "all" })).toEqual([]); expect(f.bytes()).toEqual(before); +}); +test("correction binds an exact current public target revision without writing a note or supersession", async () => { + const f = fixture(), target = f.note(), mcp = f.open(), before = f.bytes(); + const id = idOf(await mcp.tools.amem_correct({ id: `mem:${target}`, proposedText: "use the bounded writer lease", reason: "the regression establishes the corrected boundary", kind: "contract" })); + expect(f.sidecar.get(id, f.project)).toMatchObject({ status: "pending", acceptedNoteId: null, input: { project: f.project, kind: "correction", target: { id: target, revision: candidateTargetRevision(f.store.get(target, f.project)!) }, proposedText: "use the bounded writer lease", noteKind: "contract" } }); + expect(f.sidecar.get(id, f.project)?.input).not.toHaveProperty("targetView"); expect(f.store.get(target, f.project)?.supersededBy).toBeNull(); expect(f.bytes()).toEqual(before); +}); +test("correction rejects foreign local, superseded, missing or ambiguous-prefix targets and injected revisions", async () => { + const f = fixture(), target = f.note(), foreign = f.note({ project: "other-project" }), old = f.note({ text: "old contract" }), replacement = f.note({ text: "replacement contract" }); f.store.supersede(old, replacement, "person", f.project); + let screens = 0; const mcp = f.open({ host: { screen: async () => { screens++; return true; } } }), before = f.bytes(); + for (const id of [foreign, old, "f".repeat(32), target.slice(0, 8), `repo:${target}`, `capture:${target}`]) await expect(mcp.tools.amem_correct({ id, proposedText: "public correction", reason: "public evidence" })).rejects.toThrow(); + await expect(mcp.tools.amem_correct({ id: target, proposedText: "public correction", reason: "public evidence", revision: "a".repeat(64) })).rejects.toThrow(); + expect(screens).toBe(0); expect(f.sidecar.list({ project: f.project })).toEqual([]); expect(f.bytes()).toEqual(before); +}); +test("foreign shared-source correction refuses generically like show and search without candidate or curated writes", async () => { + const f = fixture(), shared = f.note({ project: "source-project", scope: "shared", text: "foreignsharedpublicneedle" }), mcp = f.open(), before = f.bytes(); + expect(await mcp.tools.amem_show({ id: shared })).toBeNull(); + expect(await mcp.tools.amem_search({ query: "foreignsharedpublicneedle", includeCapture: false })).toMatchObject({ hits: [] }); + for (const id of [shared, "f".repeat(32)]) await expect(mcp.tools.amem_correct({ id, proposedText: "public replacement", reason: "public evidence" })).rejects.toThrow("memory operation unavailable"); + expect(f.sidecar.list({ project: f.project, status: "all" })).toEqual([]); expect(f.bytes()).toEqual(before); +}); +test("correction proposal bounds the exact persisted body including headers and reason at 4000 characters", async () => { + const f = fixture(), target = f.note(), mcp = f.open(), before = f.bytes(), reason = "documented public evidence", revision = candidateTargetRevision(f.store.get(target, f.project)!); + const headers = `Accepted candidate: ${"0".repeat(64)}\nTarget revision: ${"0".repeat(64)}\nTarget entry: ${target}\n\n`; + const available = 4000 - headers.length - `\n\nReason: ${reason}`.length; + const proposedText = "public boundary ".repeat(300).slice(0, available); + const tooLong = { id: target, proposedText: proposedText + "x", reason }; + expect(tooLong.proposedText.length).toBeLessThan(4000); expect(reason.length).toBeLessThan(4000); + await expect(mcp.tools.amem_correct(tooLong)).rejects.toThrow("memory operation unavailable"); + expect(f.sidecar.list({ project: f.project })).toEqual([]); expect(f.bytes()).toEqual(before); + const id = idOf(await mcp.tools.amem_correct({ id: target, proposedText, reason })); expect(f.bytes()).toEqual(before); + expect(() => f.sidecar.accept(id, { project: f.project, actor: "agent", verdict: "public", store: f.store, policy: () => true })).toThrow("person"); + const accepted = f.sidecar.accept(id, { project: f.project, actor: "person", verdict: "public", store: f.store, policy: () => true }); + const note = f.store.get(accepted.noteId, f.project)!; expect(note.text.length).toBe(4000); expect(note.text).toBe(`Accepted candidate: ${id}\nTarget revision: ${revision}\nTarget entry: ${target}\n\n${proposedText}\n\nReason: ${reason}`); +}); +test("structured fail acceptance stays person-only in the existing core", async () => { + const f = fixture(), mcp = f.open(), id = idOf(await mcp.tools.amem_remember(fail)), before = f.bytes(); + expect(() => f.sidecar.accept(id, { project: f.project, actor: "agent", verdict: "public", store: f.store, policy: () => true })).toThrow("person"); expect(f.bytes()).toEqual(before); + const accepted = f.sidecar.accept(id, { project: f.project, actor: "person", verdict: "public", store: f.store, policy: () => true }); + const note = f.store.get(accepted.noteId, f.project)!; expect(note).toMatchObject({ kind: "fail", project: f.project, peer: "person", source: "person" }); expect(note.text).toContain("Approach: " + fail.approach); expect(note.text).toContain("Retry when: " + fail.retryWhen); +}); +test("current standalone/host privacy and complete correction target metadata are enforced", async () => { + const f = fixture(), hidden = f.note({ about: ["path:private/contract.ts"] }), mcp = f.open({ denyPaths: ["private/"], host: { screen: async value => !JSON.stringify(value).includes("HOST_DENIED") } }); + for (const args of [{ ...fail, cause: "Contact fixture.person@example.com" }, { ...fail, cause: "HOST_DENIED" }]) await expect(mcp.tools.amem_remember(args)).rejects.toThrow(); + await expect(mcp.tools.amem_correct({ id: hidden, proposedText: "public replacement", reason: "public reason" })).rejects.toThrow(); + expect(f.sidecar.list({ project: f.project })).toEqual([]); +}); +test("host tightening during an awaited complete correction screen refuses without exposing targetView in storage", async () => { + const f = fixture(), target = f.note(), entered = deferred(), release = deferred(); let permitted = true, observed: Record | undefined; + const mcp = f.open({ host: { screen: async value => { observed = value as Record; entered.resolve(); await release.promise; return permitted; } } }), before = f.bytes(); + const proposal = mcp.tools.amem_correct({ id: target, proposedText: "public replacement", reason: "public reason" }); + await entered.promise; permitted = false; release.resolve(); await expect(proposal).rejects.toThrow(); + expect(observed).toMatchObject({ project: f.project, kind: "correction", targetView: { id: target, text: "the original public boundary" } }); expect(f.sidecar.list({ project: f.project })).toEqual([]); expect(f.bytes()).toEqual(before); +}); +test("configured policy tightening while a host screen awaits is re-read before put", async () => { + for (const correction of [false, true]) { + const f = fixture(), target = f.note(), mcp = f.open({ host: { screen: async () => { await Promise.resolve(); writeFileSync(join(f.base, "amem.config.json"), JSON.stringify({ piiPatterns: ["LATE_POLICY"] })); return true; } } }), before = f.bytes(); + const result = correction ? mcp.tools.amem_correct({ id: target, proposedText: "LATE_POLICY", reason: "public reason" }) : mcp.tools.amem_remember({ ...fail, cause: "LATE_POLICY" }); + await expect(result).rejects.toThrow(); expect(f.sidecar.list({ project: f.project })).toEqual([]); expect(f.bytes()).toEqual(before); + } +}); +test("target revision drift across await and before person acceptance never authors a stale correction", async () => { + const f = fixture(), target = f.note(); let mutated: ReturnType = []; + const mcp = f.open({ host: { screen: async () => { await Promise.resolve(); f.store.demote(target, "person", f.project); mutated = f.bytes(); return true; } } }); + await expect(mcp.tools.amem_correct({ id: target, proposedText: "public replacement", reason: "public evidence" })).rejects.toThrow(); expect(f.sidecar.list({ project: f.project })).toEqual([]); expect(f.bytes()).toEqual(mutated); + const fresh = f.open(), id = idOf(await fresh.tools.amem_correct({ id: target, proposedText: "another correction", reason: "public evidence" })); + const replacement = f.note({ text: "a newer public boundary" }); f.store.supersede(target, replacement, "person", f.project); const beforeAccept = f.bytes(); + expect(() => f.sidecar.accept(id, { project: f.project, actor: "person", verdict: "public", store: f.store, policy: () => true })).toThrow("stale"); expect(f.bytes()).toEqual(beforeAccept); expect(f.sidecar.get(id, f.project)?.status).toBe("pending"); +}); +test("closing a bound MCP server during privacy await refuses the pending candidate", async () => { + const f = fixture(); let mcp: MemoryMcp; + mcp = f.open({ host: { screen: async () => { await Promise.resolve(); mcp.close(); return true; } } }); const before = f.bytes(); + await expect(mcp.tools.amem_remember(fail)).rejects.toThrow(); expect(f.sidecar.list({ project: f.project })).toEqual([]); expect(f.bytes()).toEqual(before); +}); +test("stdio exposes ten project-bound tools and validates structured evidence arrays before handler effects", async () => { + const f = fixture(), mcp = f.open(), input = new PassThrough(), output = new PassThrough(); let encoded = ""; + output.on("data", chunk => { encoded += chunk.toString(); }); + const request = (id: number, method: string, params: unknown) => JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"; + const run = serveMcp({ input, output, tools: mcp.tools }); + input.end(request(1, "initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "fixture", version: "1" } }) + JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n" + request(2, "tools/list", {}) + + request(3, "tools/call", { name: "amem_remember", arguments: { ...fail, evidenceRefs: [1] } }) + request(4, "tools/call", { name: "amem_remember", arguments: { ...fail, evidenceRefs: Array(17).fill("test:x") } }) + + request(5, "tools/call", { name: "amem_remember", arguments: fail })); + await run; + const replies = encoded.trim().split("\n").map(line => JSON.parse(line)); expect(replies.find(row => row.id === 2).result.tools).toHaveLength(10); expect(MCP_TOOLS.find(tool => tool.name === "amem_correct")?.annotations.readOnlyHint).toBe(false); + for (const id of [3, 4]) expect(replies.find(row => row.id === id).error.code).toBe(-32602); + expect(JSON.parse(replies.find(row => row.id === 5).result.content[0].text).id).toMatch(/^[a-f0-9]{64}$/); expect(f.sidecar.list({ project: f.project })).toHaveLength(1); +}); diff --git a/test/decay-failure-cli.test.ts b/test/decay-failure-cli.test.ts new file mode 100644 index 0000000..d7d7388 --- /dev/null +++ b/test/decay-failure-cli.test.ts @@ -0,0 +1,58 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { formatCliError, runCli, type CliContext } from "../src/cli.ts"; +import { DecayApplyError } from "../src/memory/decay-review.ts"; +import { realPath } from "../src/memory/platform.ts"; +import { memoryProjectKey } from "../src/memory/project.ts"; +import { MemoryStore } from "../src/memory/store.ts"; +const cleanup: Array<() => void> = []; +afterEach(() => { for (const close of cleanup.splice(0).reverse()) close(); }); +function fixture() { + const root = realPath(mkdtempSync(join(tmpdir(), "amem-decay-failure-cli-"))), base = join(root, "memory"), project = memoryProjectKey(root); + cleanup.push(() => rmSync(root, { recursive: true, force: true })); + const seed = new MemoryStore({ memoryDir: base, deferPromotions: true, now: () => Date.now() - 120 * 86400_000 }); + const ids = ["first", "second", "third"].map(name => seed.note({ op: "note", project, scope: "project", tier: "long", kind: "contract", peer: "person", source: "person", text: `${name} old public boundary`, about: [] })); seed.close(); + const use = (work: (store: MemoryStore) => T): T => { const store = new MemoryStore({ memoryDir: base, deferPromotions: true }); try { return work(store); } finally { store.close(); } }; + const bytes = () => Object.fromEntries(readdirSync(base, { recursive: true, withFileTypes: true }).filter(row => row.isFile() && row.name.endsWith(".jsonl")).map(row => { const file = join(row.parentPath, row.name); return [file.slice(base.length), readFileSync(file, "utf8")]; })); + const cli = async (args: string[], extra: Partial = {}) => { + const previousHome = process.env.HOME, previousBase = process.env.AMEM_HOME; process.env.HOME = root; process.env.AMEM_HOME = base; + try { return await runCli(args, { cwd: root, env: {}, stdinTTY: true, ...extra }); } + finally { if (previousHome === undefined) delete process.env.HOME; else process.env.HOME = previousHome; if (previousBase === undefined) delete process.env.AMEM_HOME; else process.env.AMEM_HOME = previousBase; } + }; + return { ids, project, use, bytes, cli }; +} +for (const action of ["demote", "forget"] as const) for (const json of [false, true]) test(`${action} second-target I/O failure retains the applied prefix and renders a ${json ? "standalone JSON" : "human"} partial receipt`, async () => { + const f = fixture(), originalDemote = MemoryStore.prototype.demote, originalForget = MemoryStore.prototype.forget, calls: string[] = []; + const before = f.use(store => f.ids.map(id => store.get(id, f.project))), printed: string[] = []; let question = "", caught: unknown; + try { + if (action === "demote") MemoryStore.prototype.demote = function (...args: Parameters) { calls.push(args[0]); if (calls.length === 2) throw Error("private raw filesystem failure"); return originalDemote.apply(this, args); }; + else MemoryStore.prototype.forget = function (...args: Parameters) { calls.push(args[0]); if (calls.length === 2) throw Error("private raw filesystem failure"); return originalForget.apply(this, args); }; + try { await f.cli(["decay", ...f.ids, "--apply", ...(action === "forget" ? ["--forget"] : []), ...(json ? ["--json"] : [])], { write: line => printed.push(line), prompt: text => { question = text; return "yes"; } }); } + catch (error) { caught = error; } + } finally { MemoryStore.prototype.demote = originalDemote; MemoryStore.prototype.forget = originalForget; } + expect(caught).toBeInstanceOf(DecayApplyError); const error = caught as DecayApplyError; + expect(calls).toEqual(f.ids.slice(0, 2)); expect(error.report).toMatchObject({ action, applied: [f.ids[0]], failed: f.ids[1], remaining: [f.ids[2]] }); + expect(printed.join("\n")).toContain(f.ids[0]!); const rendered = formatCliError(error); expect(rendered).not.toContain("private raw filesystem failure"); + if (json) expect(JSON.parse(rendered)).toEqual({ ok: false, ...error.report, failedMayHaveChanged: true }); + else { expect(rendered).toContain(`applied: ${f.ids[0]}`); expect(rendered).toContain(`failed: ${f.ids[1]}`); expect(rendered).toContain(`remaining: ${f.ids[2]}`); expect(rendered).toContain("failed target may have changed"); } + f.use(store => { + const prefix = store.get(f.ids[0]!, f.project); + if (action === "forget") { expect(prefix).toBeUndefined(); expect(question).toContain("hidden claude-mem-imported copies"); expect(error.report.expiresAt).toBeUndefined(); } + else { expect(prefix?.tier).toBe("short"); expect(prefix?.expiresAt).toBe(error.report.expiresAt); expect(question).toContain(error.report.expiresAt!); } + expect(store.get(f.ids[1]!, f.project)).toEqual(before[1]); expect(store.get(f.ids[2]!, f.project)).toEqual(before[2]); + }); +}); +test("decay days accept only positive decimal syntax before any confirmation or authoritative writes", async () => { + const f = fixture(), before = f.bytes(); let prompts = 0; + for (const days of ["1e2", "0x5a", " 90", "+90", "0"]) await expect(f.cli(["decay", "--days", days, "--apply"], { write: () => {}, prompt: () => { prompts++; return "yes"; } })).rejects.toThrow("positive decimal integer"); + expect(prompts).toBe(0); expect(f.bytes()).toEqual(before); + const preview = JSON.parse(await f.cli(["decay", "--days", "90", "--json"])); expect(preview.days).toBe(90); expect(preview.entries).toHaveLength(3); expect(f.bytes()).toEqual(before); +}); +test("successful demotion receipt and confirmation pin the exact expiry written to the note", async () => { + const f = fixture(); let question = ""; const printed: string[] = []; + const result = JSON.parse(await f.cli(["decay", f.ids[0]!, "--apply", "--json"], { write: text => printed.push(text), prompt: text => { question = text; return "yes"; } })); + expect(result).toMatchObject({ action: "demote", ids: [f.ids[0]] }); expect(typeof result.expiresAt).toBe("string"); expect(Date.parse(result.expiresAt)).toBeGreaterThan(Date.now()); expect(question).toContain(result.expiresAt); expect(printed.join("\n")).toContain("revision"); + f.use(store => { expect(store.get(f.ids[0]!, f.project)).toMatchObject({ tier: "short", expiresAt: result.expiresAt }); expect(store.get(f.ids[1]!, f.project)?.tier).toBe("long"); expect(store.get(f.ids[2]!, f.project)?.tier).toBe("long"); }); +}); diff --git a/test/decay-review.test.ts b/test/decay-review.test.ts new file mode 100644 index 0000000..6ddb669 --- /dev/null +++ b/test/decay-review.test.ts @@ -0,0 +1,137 @@ +import { afterEach, expect, test } from "bun:test"; +import { copyFileSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { MemoryStore, type NoteInput } from "../src/memory/store.ts"; +import { applyDecay, decayPreview, DecayApplyError } from "../src/memory/decay-review.ts"; +import type { Counter } from "../src/memory/candidates.ts"; +import { realPath } from "../src/memory/platform.ts"; +const closes: Array<() => void> = []; +afterEach(() => { for (const close of closes.splice(0).reverse()) close(); }); +function fixture(ttlMs?: number) { + const root = realPath(mkdtempSync(join(tmpdir(), "amem-decay-"))), base = join(root, "memory"), project = "decay-project"; + let now = Date.parse("2026-01-01T00:00:00.000Z"); const clock = () => now; + closes.push(() => rmSync(root, { recursive: true, force: true })); + const store = new MemoryStore({ memoryDir: base, now: clock, ...(ttlMs === undefined ? {} : { ttlMs }), deferPromotions: true, screen: () => "promote" }); closes.push(() => store.close()); + const note = (extra: Partial = {}) => store.note({ op: "note", project, scope: "project", tier: "long", kind: "contract", peer: "person", source: "person", text: "old public decision", about: [], ...extra }); + const bytes = () => readdirSync(base, { recursive: true, withFileTypes: true }).filter(row => row.isFile() && row.name.endsWith(".jsonl")).map(row => [join(row.parentPath, row.name), readFileSync(join(row.parentPath, row.name), "utf8")]).sort((a, b) => a[0]!.localeCompare(b[0]!)); + return { store, project, note, clock, bytes, advance: (days: number) => { now += days * 86400_000; } }; +} +test("decay preview keeps exact age/project/goal selection and authors no rows", () => { + const f = fixture(), old = f.note(), foreign = f.note({ project: "other", scope: "shared" }); + const goal = f.store.goal({ project: f.project, scope: "project", title: "protected goal", milestones: [], dependsOn: [], actor: "person" }); + f.store.goalLink(goal, "project#1@1", "person"); + const task = f.note({ task: "project#1@1" }), about = f.note({ about: [`goal:${goal}`] }), viaTask = f.note({ about: ["task:project#1@1"] }); + f.advance(90); expect(decayPreview(f.store, f.project, { now: f.clock }).entries).toHaveLength(0); f.advance(1); + const recent = f.note(), before = f.bytes(), plan = decayPreview(f.store, f.project, { now: f.clock }); + expect(plan.entries.map(row => row.target.id)).toEqual([old]); expect(f.bytes()).toEqual(before); expect(plan.citationHistory).toBe("unknown"); expect(plan.citedExemptions).toBeNull(); + for (const id of [foreign, task, about, viaTask, recent]) expect(plan.entries.some(row => row.target.id === id)).toBe(false); + f.store.goalState(goal, "done", "person"); expect(decayPreview(f.store, f.project, { now: f.clock }).entries.map(row => row.target.id)).toEqual(expect.arrayContaining([old, task, about, viaTask])); +}); +test("recent positive citations only exempt; unknown or zero observations never add an entry", () => { + const f = fixture(), cited = f.note(), unknown = f.note({ text: "unknown citation history" }); f.advance(91); const recent = f.note(); + const counter = (entryId: string, count: number | null, at = new Date(f.clock()).toISOString()): Counter => ({ id: "counter", key: { entryId, project: f.project, deliveryId: "delivery" }, createdAt: at, rendered: null, delivered: null, cited: count }); + const counters = [counter(cited, 1), counter(unknown, null), counter(recent, 0)]; + const plan = decayPreview(f.store, f.project, { now: f.clock, counters: () => counters }); expect(plan.entries.map(row => row.target.id)).toEqual([unknown]); expect(plan.citedExemptions).toBe(1); + expect(decayPreview(f.store, f.project, { now: f.clock, counters: () => { throw Error("unavailable"); } }).entries.map(row => row.target.id)).toEqual(expect.arrayContaining([cited, unknown])); + expect(decayPreview(f.store, f.project, { now: f.clock, counters: () => [] }).entries.some(row => row.target.id === recent)).toBe(false); +}); +test("default apply demotes only named previewed ids; bulk and forget require confirmation/person", () => { + const f = fixture(), first = f.note({ text: "first" }), second = f.note({ text: "second" }); f.advance(91); + const plan = decayPreview(f.store, f.project, { now: f.clock }), before = f.bytes(); + expect(() => applyDecay(f.store, plan, { actor: "agent", confirmed: true })).toThrow("person"); + expect(() => applyDecay(f.store, plan, { actor: "person" })).toThrow("confirmation"); + expect(() => applyDecay(f.store, plan, { actor: "person", ids: [first], forget: true })).toThrow("confirmation"); expect(f.bytes()).toEqual(before); + expect(applyDecay(f.store, plan, { actor: "person", ids: [first], now: f.clock })).toEqual({ action: "demote", ids: [first], expiresAt: plan.demoteExpiresAt }); + expect(f.store.get(first, f.project)?.tier).toBe("short"); expect(f.store.get(second, f.project)?.tier).toBe("long"); + const next = decayPreview(f.store, f.project, { now: f.clock }); expect(applyDecay(f.store, next, { actor: "person", confirmed: true, forget: true, now: f.clock }).ids).toEqual([second]); expect(f.store.get(second, f.project)).toBeUndefined(); +}); +test("a changed member refuses the entire selected batch before any earlier target effect", () => { + const f = fixture(), first = f.note({ text: "first" }), second = f.note({ text: "second" }); f.advance(91); + const plan = decayPreview(f.store, f.project, { now: f.clock }); f.store.demote(second, "person", f.project); const before = f.bytes(); + expect(() => applyDecay(f.store, plan, { actor: "person", confirmed: true, now: f.clock })).toThrow(); expect(f.bytes()).toEqual(before); expect(f.store.get(first, f.project)?.tier).toBe("long"); +}); +test("new goal protection, citation or privacy denial after preview refuses without target writes", () => { + for (const changed of ["goal", "citation", "policy"] as const) { + const f = fixture(), goal = f.store.goal({ project: f.project, scope: "project", title: "goal", milestones: [], dependsOn: [], actor: "person" }); + f.store.goalState(goal, "done", "person"); const id = f.note({ about: [`goal:${goal}`] }); f.advance(91); + const plan = decayPreview(f.store, f.project, { now: f.clock }); if (changed === "goal") f.store.goalState(goal, "active", "person"); + const before = f.bytes(), counters: Counter[] = changed === "citation" ? [{ id: "counter", key: { project: f.project, entryId: id, deliveryId: "new" }, createdAt: new Date(f.clock()).toISOString(), cited: 1, delivered: null, rendered: null }] : []; + expect(() => applyDecay(f.store, plan, { actor: "person", confirmed: true, now: f.clock, counters: () => counters, screen: () => changed !== "policy" })).toThrow(); expect(f.bytes()).toEqual(before); + } +}); + +test("retained positive citations exempt old deliveries even with a shorter decay age", () => { + const f = fixture(), id = f.note(); f.advance(60); + const counters: Counter[] = [{ id: "old-delivery", key: { project: f.project, entryId: id, deliveryId: "old" }, createdAt: new Date(f.clock() - 59 * 86400_000).toISOString(), rendered: 1, delivered: 1, cited: 1 }]; + expect(decayPreview(f.store, f.project, { now: f.clock, days: 5, counters: () => counters })).toMatchObject({ entries: [], citedExemptions: 1, citationHistory: "unknown" }); + counters[0]!.cited = 0; expect(decayPreview(f.store, f.project, { now: f.clock, days: 5, counters: () => counters }).entries.map(row => row.target.id)).toEqual([id]); +}); +test("an I/O failure reports completed, uncertain failed and untouched remaining ids", () => { + const f = fixture(); for (let n = 0; n < 3; n++) f.note({ text: `partial target ${n}` }); f.advance(91); + const plan = decayPreview(f.store, f.project, { now: f.clock }), ids = plan.entries.map(row => row.target.id), demote = f.store.demote.bind(f.store); let calls = 0; + f.store.demote = (...args) => { if (++calls === 2) throw Error("EIO simulated"); return demote(...args); }; + let failure: unknown; try { applyDecay(f.store, plan, { actor: "person", confirmed: true, now: f.clock }); } catch (error) { failure = error; } + expect(failure).toBeInstanceOf(DecayApplyError); + expect(failure).toMatchObject({ applied: [ids[0]!], failed: ids[1]!, remaining: [ids[2]!] }); + expect((failure as DecayApplyError).report).toEqual({ action: "demote", applied: [ids[0]!], failed: ids[1]!, remaining: [ids[2]!], expiresAt: plan.demoteExpiresAt }); + expect(f.store.get(ids[0]!, f.project)).toMatchObject({ tier: "short", expiresAt: plan.demoteExpiresAt }); + expect(f.store.get(ids[1]!, f.project)?.tier).toBe("long"); expect(f.store.get(ids[2]!, f.project)?.tier).toBe("long"); expect(calls).toBe(2); +}); +test("expired displayed demotion deadline refuses the entire batch before effects", () => { + const f = fixture(), id = f.note(); f.advance(91); const plan = decayPreview(f.store, f.project, { now: f.clock }); f.advance(15); const before = f.bytes(); + expect(() => applyDecay(f.store, plan, { actor: "person", confirmed: true, now: f.clock })).toThrow("expiry is spent"); expect(f.bytes()).toEqual(before); expect(f.store.get(id, f.project)?.tier).toBe("long"); +}); + +test("the failed target is reported as uncertain even when its append preceded the exception", () => { + const f = fixture(); for (let n = 0; n < 3; n++) f.note({ text: `uncertain target ${n}` }); f.advance(91); + const plan = decayPreview(f.store, f.project, { now: f.clock }), ids = plan.entries.map(row => row.target.id), demote = f.store.demote.bind(f.store); let calls = 0; + f.store.demote = (...args) => { const result = demote(...args); if (++calls === 2) throw Error("EIO after append"); return result; }; + let failure: unknown; try { applyDecay(f.store, plan, { actor: "person", confirmed: true, now: f.clock }); } catch (error) { failure = error; } + expect(failure).toMatchObject({ applied: [ids[0]!], failed: ids[1]!, remaining: [ids[2]!] }); + expect((failure as DecayApplyError).render()).toContain("failed target may have changed"); + (failure as DecayApplyError).jsonOutput = true; expect(JSON.parse((failure as DecayApplyError).render()).failedMayHaveChanged).toBe(true); + expect(f.store.get(ids[1]!, f.project)?.tier).toBe("short"); expect(f.store.get(ids[2]!, f.project)?.tier).toBe("long"); expect(calls).toBe(2); +}); + +test("forget preflights every expansion and refuses a protected hidden import copy before any prefix effect", () => { + const f = fixture(), prefix = f.note({ text: "unrelated prefix stays long" }), at = new Date(f.clock()).toISOString(); + const first = f.store.importNote({ op: "note", project: f.project, scope: "project", tier: "long", kind: "contract", peer: "original", source: "claude-mem", text: "same imported tuple", task: "import-task-0", about: ["path:copy0.ts"] }, at).id; + const other = new MemoryStore({ memoryDir: join(f.store.profile.home, "separate-import-fixture"), now: f.clock, deferPromotions: true }); + let second: string; + try { + second = other.importNote({ op: "note", project: f.project, scope: "project", tier: "long", kind: "contract", peer: "original", source: "claude-mem", text: "same imported tuple", task: "import-task-1", about: ["path:copy1.ts"] }, at).id; + copyFileSync(join(other.dir, "log", `${other.machine.id}.jsonl`), join(f.store.dir, "log", `${other.machine.id}.jsonl`)); + } finally { other.close(); } + f.store.refresh(); expect(f.store.dedupe(f.project, true).applied).toBe(1); + const [visible, hidden] = [first, second!].sort(); + const goal = f.store.goal({ project: f.project, scope: "project", title: "protected imported copy", milestones: [], dependsOn: [], actor: "person" }); + f.store.goalLink(goal, hidden === first ? "import-task-0" : "import-task-1", "person"); f.advance(91); + const plan = decayPreview(f.store, f.project, { now: f.clock }); expect(plan.entries.map(row => row.target.id).sort()).toEqual([prefix, visible!].sort()); + const before = f.bytes(); let refusal: unknown; + try { applyDecay(f.store, plan, { actor: "person", confirmed: true, forget: true, ids: [prefix, visible!], now: f.clock }); } catch (error) { refusal = error; } + expect(f.bytes()).toEqual(before); expect(f.store.get(prefix, f.project)?.tier).toBe("long"); + expect(refusal).toBeInstanceOf(Error); expect((refusal as Error).message).toContain("expands beyond reviewed exact entries"); + expect(f.store.list({ project: f.project, includeSuperseded: true }).map(row => row.id)).toEqual(expect.arrayContaining([visible!, hidden!])); + expect(() => f.store.forget(visible!, "person", f.project, true)).toThrow("expands beyond"); expect(f.bytes()).toEqual(before); + // Existing direct person forget remains a group operation after its separate review. + f.store.forget(visible!, "person", f.project); expect(f.store.list({ project: f.project, includeSuperseded: true }).map(row => row.id)).not.toContain(hidden!); +}); + +test("explicit demotion expiry is canonical future UTC and never exceeds configured TTL plus one hour", () => { + for (const ttlMs of [undefined, 2 * 86400_000]) { + const f = fixture(ttlMs), id = f.note(), deadline = f.store.shortExpiry(), upper = Date.parse(deadline) + 3600_000, before = f.bytes(); + for (const expiry of [new Date(f.clock()).toISOString(), new Date(f.clock() - 1).toISOString(), new Date(upper + 1).toISOString(), "not-a-date", deadline.replace("Z", "+00:00")]) { + expect(() => f.store.demote(id, "person", f.project, expiry)).toThrow("expiry"); expect(f.bytes()).toEqual(before); + } + const allowed = new Date(upper).toISOString(); f.store.demote(id, "person", f.project, allowed); expect(f.store.get(id, f.project)?.expiresAt).toBe(allowed); + } +}); +test("an overlong displayed expiry refuses the entire decay batch before any demote attempt", () => { + const f = fixture(); f.note({ text: "expiry prefix one" }); f.note({ text: "expiry prefix two" }); f.advance(91); + const plan = decayPreview(f.store, f.project, { now: f.clock }), before = f.bytes(), demote = f.store.demote.bind(f.store); let calls = 0; + f.store.demote = (...args) => { calls++; return demote(...args); }; + const forged = { ...plan, demoteExpiresAt: new Date(Date.parse(plan.demoteExpiresAt) + 3600_001).toISOString() }; + let refusal: unknown; try { applyDecay(f.store, forged, { actor: "person", confirmed: true, now: f.clock }); } catch (error) { refusal = error; } + expect(calls).toBe(0); expect(f.bytes()).toEqual(before); expect(refusal).toBeInstanceOf(Error); expect((refusal as Error).message).toContain("expiry"); +}); diff --git a/test/generation-cli.test.ts b/test/generation-cli.test.ts index 90ec3ab..ed11110 100644 --- a/test/generation-cli.test.ts +++ b/test/generation-cli.test.ts @@ -51,7 +51,9 @@ test("generation CLI dry-run is readonly; migrate/prune apply/resume/deactivate const applied = JSON.parse(await f.command(["prune", "--apply", "--plan", planFile])); expect(applied.completed).toHaveLength(1); const gen = new MemoryStore(); try { expect(gen.get(f.id)).toBeUndefined(); } finally { gen.close(); } expect(JSON.parse(await f.command(["migrate", "--resume"])).profile.generation).toBe(g); - await expect(f.command(["review", "--json"])).rejects.toThrow("not yet integrated"); + const beforeReview = bytes(f.memory); + expect(JSON.parse(await f.command(["review", "--json"]))).toEqual([]); + for (const [file, value] of Object.entries(beforeReview).filter(([name]) => name.endsWith(".jsonl"))) expect(bytes(f.memory)[file]).toBe(value); const deactivated = JSON.parse(await f.command(["migrate", "--deactivate"])); expect(deactivated.profile.kind).toBe("shared-v1"); expect(JSON.parse(await f.command(["migrate", "--generation", g])).profile.generation).toBe(g); }); diff --git a/test/goal-command-candidates.test.ts b/test/goal-command-candidates.test.ts new file mode 100644 index 0000000..2d2a0f0 --- /dev/null +++ b/test/goal-command-candidates.test.ts @@ -0,0 +1,66 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { rawGoalCommand, type GoalCommandOptions } from "../src/memory/commands.ts"; +import { offerGoalPromotions, type GoalPromotionReport } from "../src/memory/goal-promotion.ts"; +import { CandidateStore } from "../src/memory/candidates.ts"; +import { MemoryStore, type Entry } from "../src/memory/store.ts"; +import { createStandalonePolicy } from "../src/memory/policy.ts"; +import { realPath } from "../src/memory/platform.ts"; +const cleanup: Array<() => void> = []; +afterEach(() => { for (const close of cleanup.splice(0).reverse()) close(); }); +function deferred() { let resolve!: () => void; const promise = new Promise(done => { resolve = done; }); return { promise, resolve }; } +function fixture() { + const cwd = realPath(mkdtempSync(join(tmpdir(), "amem-goal-command-"))), base = join(cwd, "memory"), project = "goal-command-project"; let now = Date.parse("2026-10-20T00:00:00.000Z"), closed = false, closes = 0, opens = 0; + cleanup.push(() => rmSync(cwd, { recursive: true, force: true })); + const store = new MemoryStore({ memoryDir: base, now: () => now, deferPromotions: true, screen: () => "promote" }), close = store.close.bind(store); + store.close = () => { closes++; closed = true; close(); }; cleanup.push(() => { if (!closed) close(); }); + const candidates = new CandidateStore({ memoryDir: base, now: () => now }); cleanup.push(() => candidates.close()); + const goal = store.goal({ project, scope: "project", title: "public goal", milestones: [], dependsOn: [], actor: "person" }); + now++; const task = `${project}#1@100`; store.goalLink(goal, task, "person"); now++; + const note = store.note({ op: "note", project, scope: "project", tier: "short", kind: "contract", peer: "original-agent", source: "person", text: "linked short boundary", task, about: [] }); + const options: GoalCommandOptions = { store, project, open: () => { opens++; return store; }, clean: () => true }; + const authored = () => ["log", "local"].flatMap(dir => readdirSync(join(store.dir, dir)).sort().flatMap(name => readFileSync(join(store.dir, dir, name), "utf8").split("\n").filter(Boolean).map(line => JSON.parse(line) as Entry))); + const onDone: NonNullable = async (fresh, source) => { expect(fresh).toMatchObject({ id: goal, state: "done" }); expect(source.goalOf(goal, project)?.state).toBe("done"); return offerGoalPromotions(source, candidates, project, fresh.id, { policy: () => createStandalonePolicy() }); }; + return { cwd, base, project, store, candidates, goal, note, options, authored, onDone, tick: () => now++, closes: () => closes, opens: () => opens }; +} +for (const command of ["done", "state"] as const) test(`${command} done saves state then offers only pending promotion candidates through the optional host callback`, async () => { + const f = fixture(), before = f.authored().filter(row => row.op === "note"); f.tick(); + const args = command === "done" ? ["done", f.goal, "--json"] : ["state", `goal:${f.goal}`, "done", "--json"]; + const output = JSON.parse(await rawGoalCommand(args, f.cwd, { ...f.options, onDone: f.onDone })); + expect(output).toMatchObject({ goalId: f.goal, state: "done", promotion: { offered: 1, alreadyOffered: 0, withheld: 0, truncated: false } }); expect(output.promotion.ids).toHaveLength(1); + expect(f.candidates.get(output.promotion.ids[0], f.project)).toMatchObject({ status: "pending", acceptedNoteId: null, input: { kind: "promotion", goalId: f.goal, target: { id: f.note } } }); + expect(f.authored().filter(row => row.op === "note")).toEqual(before); expect(f.store.get(f.note, f.project)?.tier).toBe("short"); expect(f.store.goalOf(f.goal, f.project)?.state).toBe("done"); expect(f.closes()).toBe(0); expect(f.opens()).toBe(0); +}); +test("without a done callback legacy outputs remain byte-identical even with json/custom host names", async () => { + const f = fixture(), options = { ...f.options, names: { memory: "custom memory", goal: "custom goal" } }; + expect(await rawGoalCommand(["done", f.goal], f.cwd, options)).toBe(`long-term task ${f.goal.slice(0, 8)} is done`); f.tick(); + expect(await rawGoalCommand(["state", f.goal, "done", "--json"], f.cwd, options)).toBe(`long-term task ${f.goal.slice(0, 8)} is done`); + expect(await rawGoalCommand(["list"], f.cwd, options)).toBe(`no open long-term tasks for ${f.project} (custom goal new )`); + await expect(rawGoalCommand(["state"], f.cwd, options)).rejects.toThrow("usage: custom goal"); expect(f.candidates.list({ project: f.project })).toEqual([]); expect(f.closes()).toBe(0); +}); +test("only done invokes the callback and text counts include truncation without baked host commands", async () => { + const f = fixture(); let callbacks = 0; + const report: GoalPromotionReport = { ids: [], offered: 2, alreadyOffered: 1, withheld: 3, truncated: true }, options = { ...f.options, names: { memory: "custom memory", goal: "custom goal" }, onDone: async () => { callbacks++; return report; } }; + f.tick(); expect(await rawGoalCommand(["state", f.goal, "active"], f.cwd, options)).toBe(`long-term task ${f.goal.slice(0, 8)} is active`); expect(callbacks).toBe(0); + f.tick(); const text = await rawGoalCommand(["done", f.goal], f.cwd, options); + expect(callbacks).toBe(1); expect(text).toContain("2 offered, 1 already offered, 3 withheld"); expect(text).toContain("truncated"); expect(text).not.toMatch(/\bahub\b|\bamem\b/); +}); +test("owned store remains open throughout the awaited callback and closes exactly once afterward", async () => { + const f = fixture(), entered = deferred(), release = deferred(), { store: _borrowed, ...owned } = f.options; + const pending = rawGoalCommand(["done", f.goal, "--json"], f.cwd, { ...owned, onDone: async (goal, store) => { expect(store).toBe(f.store); expect(f.closes()).toBe(0); entered.resolve(); await release.promise; expect(store.goalOf(goal.id, f.project)?.state).toBe("done"); return f.onDone(goal, store); } }); + await entered.promise; expect(f.opens()).toBe(1); expect(f.closes()).toBe(0); release.resolve(); expect(JSON.parse(await pending).promotion.offered).toBe(1); expect(f.closes()).toBe(1); +}); +test("a failed offer leaves the saved goal done and retry reuses its already-created candidate identity", async () => { + const f = fixture(); let offered: string[] = []; const before = f.authored().filter(row => row.op === "note"); + await expect(rawGoalCommand(["done", f.goal], f.cwd, { ...f.options, onDone: async (goal, store) => { offered = (await f.onDone(goal, store)).ids; throw Error("fixture lost offer response"); } })).rejects.toThrow("lost offer response"); + expect(f.store.goalOf(f.goal, f.project)?.state).toBe("done"); expect(offered).toHaveLength(1); f.tick(); + const retry = JSON.parse(await rawGoalCommand(["done", f.goal, "--json"], f.cwd, { ...f.options, onDone: f.onDone })); + expect(retry.promotion).toMatchObject({ ids: [], offered: 0, alreadyOffered: 1 }); expect(f.candidates.list({ project: f.project })).toHaveLength(1); expect(f.candidates.get(offered[0]!, f.project)?.status).toBe("pending"); expect(f.authored().filter(row => row.op === "note")).toEqual(before); expect(f.closes()).toBe(0); +}); +test("owned store closes after a rejected callback while preserving the authored done state", async () => { + const f = fixture(), { store: _borrowed, ...owned } = f.options; + await expect(rawGoalCommand(["done", f.goal], f.cwd, { ...owned, onDone: async (_goal, store) => { expect(store.goalOf(f.goal, f.project)?.state).toBe("done"); throw Error("fixture offer refused"); } })).rejects.toThrow("offer refused"); + expect(f.opens()).toBe(1); expect(f.closes()).toBe(1); expect(f.authored().some(row => row.op === "goal_state" && row.target === f.goal && row.by === "done")).toBe(true); +}); diff --git a/test/goal-promotion.test.ts b/test/goal-promotion.test.ts new file mode 100644 index 0000000..5378e1a --- /dev/null +++ b/test/goal-promotion.test.ts @@ -0,0 +1,149 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CandidateStore, candidateInputId, candidateTargetRevision, type CandidateInput } from "../src/memory/candidates.ts"; +import { GOAL_PROMOTION_LIMIT, offerGoalPromotions } from "../src/memory/goal-promotion.ts"; +import { createStandalonePolicy, type StandalonePolicyOptions } from "../src/memory/policy.ts"; +import { MEMORY_SCHEMA, MemoryStore, entryId, openShardStore, type NoteEntry, type NoteInput, type PromotionScreen, type View } from "../src/memory/store.ts"; +import { migrate } from "../src/memory/migrate.ts"; +import { migrateShards } from "../src/memory/shard-migration.ts"; +import { realPath } from "../src/memory/platform.ts"; +const cleanup: Array<() => void> = []; +afterEach(() => { for (const close of cleanup.splice(0).reverse()) close(); }); +async function fixture(mode: "legacy" | "generation" | "shards" = "legacy", screen: PromotionScreen = () => "promote", deferPromotions = true) { + const root = realPath(mkdtempSync(join(tmpdir(), "amem-goal-promotion-"))), base = join(root, "memory"), project = "goal-project"; let now = Date.parse("2026-10-20T00:00:00.000Z"); + cleanup.push(() => rmSync(root, { recursive: true, force: true })); + if (mode !== "legacy") { const seed = new MemoryStore({ memoryDir: base, deferPromotions: true }); seed.close(); + if (mode === "generation") await migrate({ memoryDir: base, generation: "d".repeat(32), mode: "apply", authorizedPerson: true, tty: true }); + else await migrateShards({ memoryDir: base, generation: "e".repeat(32), mode: "apply", authorizedPerson: true, tty: true }); + } + const store = mode === "shards" ? openShardStore({ memoryDir: base, project, now: () => now, screen, deferPromotions }) : new MemoryStore({ memoryDir: base, now: () => now, screen, deferPromotions }); cleanup.push(() => store.close()); + const candidates = new CandidateStore({ memoryDir: base, now: () => now }); cleanup.push(() => candidates.close()); + const note = (extra: Partial<NoteInput> = {}) => { now++; return store.note({ op: "note", project, scope: "project", tier: "short", kind: "contract", peer: "original-agent", source: "hub", text: "a linked public boundary", about: [], ...extra }); }; + const goal = (task = `${project}#1@100`) => { now++; const id = store.goal({ project, scope: "project", title: "public goal", milestones: [], dependsOn: [], actor: "person" }); now++; store.goalLink(id, task, "person"); now++; store.goalState(id, "done", "person"); return { id, task }; }; + const policy = (options: StandalonePolicyOptions = {}) => () => createStandalonePolicy(options); + const offer = (id: string, options: StandalonePolicyOptions = {}) => offerGoalPromotions(store, candidates, project, id, { policy: policy(options) }); + const bytes = () => readdirSync(base, { recursive: true, withFileTypes: true }).filter(row => row.isFile() && row.name.endsWith(".jsonl")).map(row => [join(row.parentPath, row.name), readFileSync(join(row.parentPath, row.name), "utf8")]).sort((a, b) => a[0]!.localeCompare(b[0]!)); + const accept = (id: string, actor: "person" | "agent" = "person", permitted = true) => candidates.accept(id, { project, actor, verdict: "public", store, policy: () => permitted }); + return { root, base, project, store, candidates, note, goal, offer, policy, bytes, accept, clock: () => now, tick: () => now++ }; +} +function promotedId(view: View): string { + const body: Omit<NoteEntry, "id"> = { schema: MEMORY_SCHEMA, op: "note", at: view.at, machine: view.machine, project: view.project, scope: view.scope, tier: "long", kind: view.kind, peer: view.peer, text: view.text, about: [...view.about], promotedFrom: view.id, source: view.source as NoteEntry["source"], ...(view.title ? { title: view.title } : {}), ...(view.task ? { task: view.task } : {}) }; + return entryId(body); +} +for (const mode of ["legacy", "generation", "shards"] as const) test(`goal ${mode} offer stays local; person acceptance uses canonical existing promotion identity`, async () => { + const f = await fixture(mode), goal = f.goal(), target = f.note({ task: goal.task }), original = f.store.get(target, f.project)!, before = f.bytes(); + const report = await f.offer(goal.id); expect(report).toMatchObject({ offered: 1, alreadyOffered: 0, withheld: 0, truncated: false }); expect(f.bytes()).toEqual(before); + const id = report.ids[0]!, candidate = f.candidates.get(id, f.project)!; expect(candidate.input).toEqual({ project: f.project, kind: "promotion", goalId: goal.id, target: { id: target, revision: candidateTargetRevision(original) } }); + expect(() => f.accept(id, "agent")).toThrow("person"); expect(f.bytes()).toEqual(before); + const accepted = f.accept(id), expected = promotedId(original); expect(accepted.noteId).toBe(expected); + expect(f.store.get(expected, f.project)).toMatchObject({ id: expected, tier: "long", at: original.at, machine: original.machine, peer: original.peer, source: original.source, text: original.text, promotedFrom: target }); + expect(f.store.get(expected, f.project)?.text).not.toContain("Accepted candidate:"); + const authored = f.bytes(); const get = f.store.get.bind(f.store); f.store.get = () => { throw Error("accepted replay must not resolve a stale target"); }; + try { expect(f.accept(id)).toEqual({ ...accepted, alreadyAccepted: true }); } finally { f.store.get = get; } + expect(f.bytes()).toEqual(authored); +}); +test("goal offers task, about-task and about-goal shorts, excluding unlinked, long and foreign-local entries", async () => { + const f = await fixture(), goal = f.goal(); + const task = f.note({ task: goal.task }), aboutTask = f.note({ text: "via task", about: [`task:${goal.task}`] }), aboutGoal = f.note({ text: "via goal", about: [`goal:${goal.id}`] }); + f.note({ text: "unlinked" }); f.note({ text: "long linked", task: goal.task, tier: "long" }); f.note({ text: "foreign local", task: goal.task, project: "foreign-project" }); + const report = await f.offer(goal.id), ids = report.ids.map(id => (f.candidates.get(id, f.project)!.input as Extract<CandidateInput, { kind: "promotion" }>).target.id); + expect(ids.sort()).toEqual([task, aboutTask, aboutGoal].sort()); +}); +test("exact proposal identity handles existing rejected/pending candidates without resurrecting them", async () => { + const f = await fixture(), goal = f.goal(); f.note({ task: goal.task }); + const first = await f.offer(goal.id), id = first.ids[0]!; f.candidates.reject(id, { project: f.project, actor: "person", verdict: "public" }); + const get = f.candidates.list.bind(f.candidates); f.candidates.list = () => { throw Error("offering must not depend on capped/status-filtered candidate lists"); }; + try { expect(await f.offer(goal.id)).toMatchObject({ ids: [], offered: 0, alreadyOffered: 1 }); } finally { f.candidates.list = get; } + expect(f.candidates.get(id, f.project)?.status).toBe("rejected"); +}); +test("private goal/target and current policy tightening withhold offers without curated mutation", async () => { + const f = await fixture(), goal = f.goal(), target = f.note({ task: goal.task }), before = f.bytes(); let deny = false; + const policy = () => createStandalonePolicy({ piiPatterns: deny ? ["linked public"] : [], host: { screen: async value => { if ((value as { kind?: string }).kind === "promotion") { await Promise.resolve(); deny = true; } return true; } } }); + expect(await offerGoalPromotions(f.store, f.candidates, f.project, goal.id, { policy })).toMatchObject({ ids: [], offered: 0, withheld: 1 }); expect(f.bytes()).toEqual(before); expect(f.store.get(target, f.project)?.tier).toBe("short"); + expect(await f.offer(goal.id, { piiPatterns: ["public goal"] })).toMatchObject({ ids: [], offered: 0, withheld: 1 }); +}); +test("goal and target changes after awaited screening refuse before candidate put", async () => { + for (const changed of ["target", "goal", "binding"] as const) { + const f = await fixture(), goal = f.goal(), target = f.note({ task: goal.task }); let drifted = false; + const policy = () => createStandalonePolicy({ host: { screen: async value => { if ((value as { kind?: string }).kind === "promotion") { await Promise.resolve(); drifted = true; if (changed === "target") f.store.forget(target, "person", f.project); if (changed === "goal") { f.tick(); f.store.goalState(goal.id, "active", "person"); } } return true; } } }); + const run = offerGoalPromotions(f.store, f.candidates, f.project, goal.id, { policy, assertCurrent: () => { if (changed === "binding" && drifted) throw Error("binding moved"); } }); + if (changed === "target") expect(await run).toMatchObject({ ids: [], withheld: 1 }); else await expect(run).rejects.toThrow(); + expect(f.candidates.list({ project: f.project, kind: "promotion" })).toEqual([]); + } +}); +test("promotion acceptance rejects stale revisions, reopened goals and denied policy while staying pending", async () => { + for (const changed of ["revision", "goal", "policy"] as const) { + const f = await fixture(), goal = f.goal(), target = f.note({ task: goal.task }), id = (await f.offer(goal.id)).ids[0]!; + if (changed === "revision") f.store.forget(target, "person", f.project); + if (changed === "goal") { f.tick(); f.store.goalState(goal.id, "active", "person"); } + const before = f.bytes(); expect(() => f.accept(id, "person", changed !== "policy")).toThrow(); expect(f.bytes()).toEqual(before); expect(f.candidates.get(id, f.project)?.status).toBe("pending"); + } +}); +test("ordinary unsupported/declined promote result stays pending and can be accepted after policy permits", async () => { + let permit = false; const f = await fixture("legacy", () => permit ? "promote" : "skip"), goal = f.goal(), target = f.note({ task: goal.task }), id = (await f.offer(goal.id)).ids[0]!, before = f.bytes(), expected = promotedId(f.store.get(target, f.project)!); + expect(() => f.accept(id)).toThrow("remains pending"); expect(f.candidates.get(id, f.project)?.status).toBe("pending"); expect(f.bytes()).toEqual(before); + permit = true; expect(f.accept(id).noteId).toBe(expected); +}); +test("a thrown promotion after append remains accepting and never automatically replays", async () => { + const f = await fixture(), goal = f.goal(); f.note({ task: goal.task }); const id = (await f.offer(goal.id)).ids[0]!, promote = f.store.promote.bind(f.store); let calls = 0; + f.store.promote = (...args) => { calls++; promote(...args); throw Error("fixture append interrupted"); }; + expect(() => f.accept(id)).toThrow("interrupted"); expect(f.candidates.get(id, f.project)?.status).toBe("accepting"); const before = f.bytes(); + expect(() => f.accept(id)).toThrow("interrupted"); expect(calls).toBe(1); expect(f.bytes()).toEqual(before); f.store.promote = promote; +}); +test("shared-source goal-linked promotion preserves foreign source reach", async () => { + const f = await fixture("shards"), goal = f.goal(), target = f.note({ project: "foreign-source", scope: "shared", task: goal.task }), original = f.store.get(target, f.project)!; + const offered = await f.offer(goal.id); expect(offered.offered).toBe(1); const accepted = f.accept(offered.ids[0]!); + expect(f.store.get(accepted.noteId, f.project)).toMatchObject({ project: "foreign-source", scope: "shared", peer: original.peer, source: original.source, tier: "long" }); +}); +test("goal offer limit is explicit and proposal identity lookup is independent of listing caps", async () => { + const f = await fixture(), goal = f.goal(); for (let i = 0; i < 3; i++) f.note({ text: `linked ${i}`, task: goal.task }); + expect(await offerGoalPromotions(f.store, f.candidates, f.project, goal.id, { policy: f.policy(), limit: 2 })).toMatchObject({ offered: 2, truncated: true }); + expect(() => candidateInputId({ project: f.project, kind: "promotion", goalId: "bad", target: { id: "a".repeat(32), revision: "b".repeat(64) } })).toThrow(); + await expect(offerGoalPromotions(f.store, f.candidates, f.project, goal.id, { policy: f.policy(), limit: GOAL_PROMOTION_LIMIT + 1 })).rejects.toThrow(); +}); +test("a reconciled alias handle promotes the raw source canonically while proposals use the canonical project", async () => { + const f = await fixture(), goal = f.goal(), target = f.note({ task: goal.task }), original = f.store.get(target, f.project)!, canonical = "canonical-project"; + const store = new MemoryStore({ memoryDir: f.base, now: f.clock, deferPromotions: true, screen: () => "promote", reconcile: { canonical, aliases: [f.project] } }); cleanup.push(() => store.close()); + const candidates = new CandidateStore({ memoryDir: f.base, now: f.clock, reconcile: { canonical, aliases: [f.project] } }); cleanup.push(() => candidates.close()); + const offered = await offerGoalPromotions(store, candidates, canonical, goal.id, { policy: f.policy() }); expect(offered.offered).toBe(1); + expect(candidates.get(offered.ids[0]!, canonical)?.input.project).toBe(canonical); + const result = candidates.accept(offered.ids[0]!, { project: canonical, actor: "person", verdict: "public", store, policy: () => true }); + expect(result.noteId).toBe(promotedId(original)); expect(f.store.get(result.noteId, f.project)?.project).toBe(f.project); +}); +test("offer reads defer an otherwise automatic held-note promotion", async () => { + let permit = false; const f = await fixture("legacy", () => permit ? "promote" : "skip", false), goal = f.goal(), target = f.note({ task: goal.task }); + f.tick(); expect(f.store.settle(goal.task, "person")).toBe(0); + const held = f.store.withDeferredPromotions(() => f.store.get(target, f.project)!); expect(held).toMatchObject({ tier: "short", expiresAt: null }); + permit = true; const before = f.bytes(); expect((await f.offer(goal.id)).offered).toBe(1); expect(f.bytes()).toEqual(before); expect(f.store.withDeferredPromotions(() => f.store.get(target, f.project)?.tier)).toBe("short"); +}); +test("repeated bounded offers advance past known proposals to the rest of the linked notes", async () => { + const f = await fixture(), goal = f.goal(), targets = Array.from({ length: 5 }, (_, i) => f.note({ text: `progress linked ${i}`, task: goal.task })), before = f.bytes(); + const batches = []; + for (const count of [2, 2, 1]) { + const result = await offerGoalPromotions(f.store, f.candidates, f.project, goal.id, { policy: f.policy(), limit: 2 }); + expect(result.offered).toBe(count); expect(result.ids).toHaveLength(count); batches.push(result); + } + expect(batches.map(row => row.alreadyOffered)).toEqual([0, 2, 4]); expect(batches.map(row => row.truncated)).toEqual([true, true, false]); + const ids = batches.flatMap(row => row.ids); expect(new Set(ids).size).toBe(5); + expect(ids.map(id => (f.candidates.get(id, f.project)!.input as Extract<CandidateInput, { kind: "promotion" }>).target.id).sort()).toEqual(targets.sort()); + expect(await offerGoalPromotions(f.store, f.candidates, f.project, goal.id, { policy: f.policy(), limit: 2 })).toMatchObject({ ids: [], offered: 0, alreadyOffered: 5, truncated: false }); expect(f.bytes()).toEqual(before); +}); +test("promotion acceptance receives current goal metadata so newly private goal policy refuses before effects", async () => { + const f = await fixture(), goal = f.goal(), target = f.note({ task: goal.task }), id = (await f.offer(goal.id)).ids[0]!, before = f.bytes(); let checked = false; + const tightened = createStandalonePolicy({ piiPatterns: ["public goal"] }); + expect(() => f.candidates.accept(id, { project: f.project, actor: "person", verdict: "public", store: f.store, policy: (_candidate, currentTarget, _note, currentGoal) => { + checked = true; expect(currentTarget?.id).toBe(target); expect(currentGoal).toMatchObject({ id: goal.id, title: "public goal", state: "done" }); + return tightened.screenStandalone(currentGoal); + } })).toThrow("policy"); + expect(checked).toBe(true); expect(f.bytes()).toEqual(before); expect(f.candidates.get(id, f.project)?.status).toBe("pending"); expect(f.store.get(target, f.project)?.tier).toBe("short"); +}); + +test("a previously opened generation cannot offer after its selector changed before invocation", async () => { + const f = await fixture("generation"), goal = f.goal(); f.note({ task: goal.task }); + await migrate({ memoryDir: f.base, mode: "deactivate", authorizedPerson: true, tty: true }); + const before = f.bytes(); let screens = 0; + await expect(offerGoalPromotions(f.store, f.candidates, f.project, goal.id, { policy: () => { screens++; return createStandalonePolicy(); } })).rejects.toThrow("selector changed"); + expect(screens).toBe(0); expect(f.candidates.list({ project: f.project })).toEqual([]); expect(f.bytes()).toEqual(before); +}); diff --git a/test/mcp-cli.test.ts b/test/mcp-cli.test.ts index f5ff4ed..8a4e884 100644 --- a/test/mcp-cli.test.ts +++ b/test/mcp-cli.test.ts @@ -21,7 +21,8 @@ test("agent-marked CLI exposes only the MCP session path and exits on EOF", asyn expect(exit).toBe(0); expect(stderr).toBe(""); const replies = stdout.trim().split("\n").map(line => JSON.parse(line)); expect(replies.map(row => row.id)).toEqual([1, 2, 3]); - expect(replies[1].result.tools).toHaveLength(9); + expect(replies[1].result.tools).toHaveLength(10); + expect(replies[1].result.tools.map((tool: { name: string }) => tool.name).sort()).toEqual(["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"]); expect(replies[2].result.isError).toBeUndefined(); const person = Bun.spawn([process.execPath, join(import.meta.dir, "../bin/amem.js"), "review", "list"], { cwd: root, env, stdout: "pipe", stderr: "pipe" }); expect(await person.exited).toBe(1); diff --git a/test/mcp.test.ts b/test/mcp.test.ts index 8d53db5..f20160d 100644 --- a/test/mcp.test.ts +++ b/test/mcp.test.ts @@ -2,6 +2,8 @@ import { expect, test } from "bun:test"; import { PassThrough } from "node:stream"; import { MCP_TOOLS, serveMcp, type McpHandlers } from "../src/mcp.ts"; +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"]; + const request = (id: number, method: string, params?: unknown) => JSON.stringify({ jsonrpc: "2.0", id, method, ...(params === undefined ? {} : { params }) }) + "\n"; const initialize = request(1, "initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "fake-client", version: "1" } }) + JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n"; async function session(lines: string, handlers: Partial<McpHandlers> = {}) { @@ -15,11 +17,12 @@ async function session(lines: string, handlers: Partial<McpHandlers> = {}) { return { replies: text.trim().split("\n").filter(Boolean).map(line => JSON.parse(line)), closed, calls }; } -test("stdio client initializes, lists the nine startup-bound tools, calls one and closes on EOF", async () => { +test("stdio client initializes, lists the ten startup-bound tools, calls one and closes on EOF", async () => { const s = await session(initialize + request(2, "tools/list", {}) + request(3, "tools/call", { name: "amem_search", arguments: { query: "policy", limit: 1 } })); expect(s.replies.map(r => r.id)).toEqual([1, 2, 3]); expect(s.replies[0].result.protocolVersion).toBe("2025-06-18"); - expect(s.replies[1].result.tools).toHaveLength(9); + expect(s.replies[1].result.tools).toHaveLength(10); + expect(s.replies[1].result.tools.map((tool: { name: string }) => tool.name).sort()).toEqual(expectedTools); expect(s.replies[1].result.tools.every((t: any) => t.inputSchema.additionalProperties === false && !t.inputSchema.properties.project && !t.inputSchema.properties.home && !t.inputSchema.properties.cwd)).toBe(true); expect(s.replies[2].result.content).toEqual([{ type: "text", text: '{"ok":true}' }]); expect(s.closed).toBe(1); @@ -73,7 +76,8 @@ test("a fake client uses a real stdio child, whose EOF closes once and exits wit expect(exit).toBe(0); expect(stderr).toBe("closed\n"); const replies = stdout.trim().split("\n").map(line => JSON.parse(line)); expect(replies.map(row => row.id)).toEqual([1, 2, 3]); - expect(replies[1].result.tools).toHaveLength(9); + expect(replies[1].result.tools).toHaveLength(10); + expect(replies[1].result.tools.map((tool: { name: string }) => tool.name).sort()).toEqual(expectedTools); expect(JSON.parse(replies[2].result.content[0].text)).toEqual({ args: { id: "abcdef123456" } }); } finally { clearTimeout(timeout); if (child.exitCode === null) { child.kill(); await child.exited; } } }); @@ -82,7 +86,8 @@ test("tools/list accepts MCP null cursor and metadata forms without widening too const accepted = [null, { cursor: null }, { _meta: {} }]; for (const params of accepted) { const s = await session(initialize + request(2, "tools/list", params)); - expect(s.replies.find(r => r.id === 2)?.result.tools).toHaveLength(9); + expect(s.replies.find(r => r.id === 2)?.result.tools).toHaveLength(10); + expect(s.replies.find(r => r.id === 2)?.result.tools.map((tool: { name: string }) => tool.name).sort()).toEqual(expectedTools); expect(s.calls).toBe(0); } for (const params of [{ cursor: "next" }, { _meta: { unexpected: true } }, { extra: true }, [], 1]) { diff --git a/test/shard-review-cli.test.ts b/test/shard-review-cli.test.ts index 5f509e7..c880d67 100644 --- a/test/shard-review-cli.test.ts +++ b/test/shard-review-cli.test.ts @@ -50,15 +50,17 @@ test("#15 CLI routes explicit shard modes and resumes while refusing ordinary ge expect(JSON.parse(await runCli(["migrate", "--shards", "--resume"], f.context)).alreadyActive).toBe(true); }); -test("#15 split-home candidate acceptance refuses before touching the sidecar or legacy logs", async () => { +test("#40 split-home person acceptance writes the bound shard and preserves legacy logs", async () => { const f = fixture(), candidates = new CandidateStore({ home: f.dir }); const id = candidates.put({ project: f.project, kind: "fail", approach: "fixture", symptom: "fixture", cause: "retained candidate", evidenceRefs: ["test:shards"], retryWhen: "shard-aware review is available" }, "public"); candidates.close(); await f.split(); const before = bytes(f.memory); - await expect(runCli(["review", "accept", id, "--policy-free"], f.context)).rejects.toThrow("shard-aware"); - expect(bytes(f.memory)).toEqual(before); + const receipt = JSON.parse(await runCli(["review", "accept", id, "--policy-free", "--json"], f.context)) as { noteId: string }; + for (const [file, value] of Object.entries(before).filter(([name]) => /^\/(log|local)\//.test(name))) expect(bytes(f.memory)[file]).toBe(value); + const store = openShardStore({ memoryDir: f.memory, project: f.project, deferPromotions: true }); + try { expect(store.get(receipt.noteId, f.project)).toMatchObject({ project: f.project, kind: "fail", tier: "long", source: "person" }); } finally { store.close(); } const after = new CandidateStore({ home: f.dir }); - try { expect(after.get(id, f.project)?.status).toBe("pending"); } finally { after.close(); } + try { expect(after.get(id, f.project)?.status).toBe("accepted"); } finally { after.close(); } }); test("#15 prune --user is explicit, current-project authorized, and preserves the default project-only port", async () => {