@staix/agent-mem 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +12 -2
  3. package/README.md +135 -18
  4. package/package.json +1 -1
  5. package/src/cli.ts +476 -39
  6. package/src/commands-table.ts +53 -15
  7. package/src/hook-spool.ts +24 -2
  8. package/src/hooks.ts +9 -5
  9. package/src/index.ts +14 -0
  10. package/src/ingest.ts +3 -2
  11. package/src/mcp-memory.ts +141 -33
  12. package/src/mcp.ts +42 -7
  13. package/src/memory/aliases.ts +4 -0
  14. package/src/memory/bridge.ts +8 -2
  15. package/src/memory/candidate-pending.ts +119 -0
  16. package/src/memory/candidates.ts +144 -47
  17. package/src/memory/capacity-split.ts +76 -0
  18. package/src/memory/capacity.ts +139 -11
  19. package/src/memory/capture-input.ts +8 -3
  20. package/src/memory/capture.ts +30 -5
  21. package/src/memory/catalog.ts +43 -10
  22. package/src/memory/commands.ts +98 -44
  23. package/src/memory/config.ts +167 -0
  24. package/src/memory/cross-project.ts +75 -0
  25. package/src/memory/curated-read.ts +29 -0
  26. package/src/memory/decay-review.ts +136 -0
  27. package/src/memory/doctor.ts +412 -0
  28. package/src/memory/editor.ts +58 -0
  29. package/src/memory/eval-fixture.ts +137 -0
  30. package/src/memory/eval-set.json +443 -0
  31. package/src/memory/eval.ts +150 -0
  32. package/src/memory/gc.ts +97 -0
  33. package/src/memory/generation-index.ts +22 -17
  34. package/src/memory/generation-sync.ts +3 -0
  35. package/src/memory/goal-promotion.ts +71 -0
  36. package/src/memory/home.ts +15 -4
  37. package/src/memory/intent-brief.ts +89 -0
  38. package/src/memory/lifecycle-log.ts +54 -0
  39. package/src/memory/lifecycle.ts +61 -0
  40. package/src/memory/metrics.ts +2 -2
  41. package/src/memory/note-add.ts +100 -0
  42. package/src/memory/path-glob.ts +94 -0
  43. package/src/memory/plan-guard.ts +47 -0
  44. package/src/memory/policy.ts +44 -13
  45. package/src/memory/private-spans.ts +32 -0
  46. package/src/memory/project-export.ts +122 -0
  47. package/src/memory/recall-staleness.ts +56 -0
  48. package/src/memory/recall.ts +74 -0
  49. package/src/memory/repo-scope.ts +404 -0
  50. package/src/memory/shard-sync-format.ts +116 -0
  51. package/src/memory/shard-sync.ts +421 -0
  52. package/src/memory/store.ts +282 -27
  53. package/src/memory/sync-cadence.ts +139 -0
  54. package/src/memory/sync-screen.ts +86 -0
  55. package/src/memory/sync.ts +79 -11
  56. package/src/memory/usage.ts +35 -0
  57. package/src/memory/use-telemetry.ts +51 -0
  58. package/src/memory/work-lists.ts +10 -4
  59. package/src/session-brief-worker.ts +3 -2
  60. package/src/session-brief.ts +74 -15
  61. package/src/setup-owned.ts +120 -0
  62. package/src/setup-toml.ts +291 -0
  63. package/src/setup.ts +327 -0
@@ -0,0 +1,412 @@
1
+ import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { Database } from "bun:sqlite";
5
+ import { sessionStartBrief } from "../session-brief.ts";
6
+ import { pendingSummary } from "./candidate-pending.ts";
7
+ import { walkShards } from "./catalog.ts";
8
+ import { loadAmemConfig, repoConfigRoot } from "./config.ts";
9
+ import { readSelector } from "./generation.ts";
10
+ import { processLiveness } from "./platform.ts";
11
+ import { STOP_SIGNALS } from "./sync-cadence.ts";
12
+ import { boundRepoRoot } from "./repo-scope.ts";
13
+ import { shardDir } from "./shard.ts";
14
+ import type { MemoryStore, View } from "./store.ts";
15
+ import { nextSyncAt, readSyncState, remoteConfigured, syncDue, usableState } from "./sync-cadence.ts";
16
+
17
+ /**
18
+ * F15, `amem doctor`: one report over the memory home. It writes nothing into the home. Every check that needs a store (the notes, the brief, the index proof) runs on a
19
+ * private scratch copy of the logs in a temporary directory that is removed afterwards: opening the live store would take a reader lease, refresh and checkpoint the live
20
+ * index, and the live work list would run its migration. The live index is only snapshotted (a read-only connection and `VACUUM INTO`), so "index equals rebuild" compares the
21
+ * index the way a reader would refresh it with a full rebuild, without anyone's index being touched. A home nobody used, and a project the home has not seen, stay absent.
22
+ * Every section is `ok`, `attention` (something to look at), `skipped` (nothing to check here, with the reason: not a problem) or `unavailable` (it could not be
23
+ * checked, with the reason), never a silent zero.
24
+ */
25
+ export type SectionStatus = "ok" | "attention" | "skipped" | "unavailable";
26
+ export type Section<T extends object = Record<string, unknown>> = { status: SectionStatus } & T;
27
+ export const DOCTOR_SCHEMA = "amem.doctor/v1";
28
+ export const CLIS = ["claude", "codex", "kimi", "pi"] as const;
29
+ export type Cli = typeof CLIS[number];
30
+ /** A CLI whose last capture is older than this is called stale. */
31
+ export const CAPTURE_STALE_MS = 7 * 86400_000;
32
+ /** A sync remote whose last success is older than this needs attention. */
33
+ export const SYNC_STALE_MS = 7 * 86400_000;
34
+ const DEFAULT_BRIEF_TOKENS = 1200;
35
+ const COPY_LIMIT_BYTES = 64 * 1024 * 1024;
36
+ /** Notes read per project for the duplicate and stale checks; a report says when a project has more. */
37
+ export const LIST_ALL = 100_000;
38
+ const MAX_IDS = 10;
39
+
40
+ export interface DoctorOptions {
41
+ home: string; homeSource: string; cwd: string; project: string; aliases: readonly string[]; now: number;
42
+ /** Opens this project's curated store on `base`, the way the other person reads do (a split home through its project shard). Doctor passes a private scratch base, never the live home. */
43
+ openCurated: (base: string) => MemoryStore;
44
+ /** The cadence command the person runs from their own shell; shown in the report, never installed. */
45
+ syncCommand?: string;
46
+ /** Test seam: called with the scratch directory right after it is made, before anything else runs. The CLI never sets it. */
47
+ afterScratch?: (dir: string) => void;
48
+ }
49
+ export interface DoctorReport {
50
+ schema: typeof DOCTOR_SCHEMA;
51
+ status: SectionStatus;
52
+ home: { path: string; source: string };
53
+ project: string;
54
+ sections: {
55
+ duplicates: Section; stale: Section; candidates: Section; capture: Section; brief: Section; index: Section; readers: Section; policy: Section; cadence: Section;
56
+ };
57
+ }
58
+
59
+ const worst = (values: SectionStatus[]): SectionStatus => values.includes("attention") ? "attention" : values.includes("unavailable") ? "unavailable" : "ok";
60
+ const message = (error: unknown): string => (error instanceof Error ? error.message : String(error)).split("\n")[0]!.slice(0, 200);
61
+ const unavailable = (reason: string): Section => ({ status: "unavailable", reason });
62
+ const skipped = (reason: string): Section => ({ status: "skipped", reason });
63
+ const ids = (list: string[]): string[] => list.slice(0, MAX_IDS).map(id => id.slice(0, 8));
64
+ const regularDir = (path: string): boolean => { try { const stat = lstatSync(path); return stat.isDirectory() && !stat.isSymbolicLink(); } catch { return false; } };
65
+ const regularFile = (path: string): boolean => { try { const stat = lstatSync(path); return stat.isFile() && !stat.isSymbolicLink(); } catch { return false; } };
66
+
67
+ type Layout = "shared-v1" | "generation" | "split";
68
+ function layoutOf(home: string): Layout {
69
+ if (existsSync(join(home, "shards.json"))) return "split";
70
+ return readSelector(home).selector?.kind === "generation" ? "generation" : "shared-v1";
71
+ }
72
+
73
+ /** Whether this project's curated notes already exist, so there is something to copy. */
74
+ function curatedPresent(home: string, project: string, layout: Layout): boolean {
75
+ if (layout === "split") return existsSync(join(shardDir(home, project), "generation.json"));
76
+ if (layout === "generation") return true;
77
+ return regularDir(join(home, "log"));
78
+ }
79
+
80
+ /** What a scratch copy leaves out: databases, locks, repositories, the machine-local capture and work trees, spools and ingest cursors. */
81
+ const SKIP = /\.db(-wal|-shm)?$|\.lock$|\.tmp$|^\.git$|\.git$|^spool$|^ingest$|^readers$/;
82
+ class TooBig extends Error {}
83
+ function copyTree(from: string, to: string, budget: { bytes: number }, skipNames: ReadonlySet<string> = new Set()): void {
84
+ mkdirSync(to, { recursive: true, mode: 0o700 });
85
+ for (const name of readdirSync(from)) {
86
+ if (SKIP.test(name) || skipNames.has(name)) continue;
87
+ const source = join(from, name), stat = lstatSync(source);
88
+ if (stat.isSymbolicLink()) throw new Error("the memory home holds a link where a plain file or directory is expected");
89
+ if (stat.isDirectory()) copyTree(source, join(to, name), budget, skipNames);
90
+ else if (stat.isFile()) { budget.bytes += stat.size; if (budget.bytes > COPY_LIMIT_BYTES) throw new TooBig(); copyFileSync(source, join(to, name)); }
91
+ }
92
+ }
93
+
94
+ /** The curated side of the home, without any database: the logs of this project (and its aliases), and the small files that bind them. */
95
+ function copyCurated(options: DoctorOptions, layout: Layout, scratch: string, budget: { bytes: number }): void {
96
+ const home = options.home;
97
+ for (const name of readdirSync(home)) { // top-level plain files: machine.json, shards.json, profile.json, aliases.jsonl, lifecycle.jsonl, the configuration
98
+ if (SKIP.test(name) || !regularFile(join(home, name))) continue;
99
+ const stat = statSync(join(home, name)); budget.bytes += stat.size; if (budget.bytes > COPY_LIMIT_BYTES) throw new TooBig();
100
+ copyFileSync(join(home, name), join(scratch, name));
101
+ }
102
+ if (layout === "split") {
103
+ for (const key of [options.project, ...options.aliases]) {
104
+ const dir = shardDir(home, key);
105
+ if (existsSync(join(dir, "generation.json"))) copyTree(dir, join(scratch, "projects", dir.split("/").pop()!), budget, new Set(["machine", "work"]));
106
+ }
107
+ if (regularDir(join(home, "user"))) copyTree(join(home, "user"), join(scratch, "user"), budget);
108
+ } else {
109
+ if (layout === "generation") copyTree(join(home, "generations"), join(scratch, "generations"), budget);
110
+ for (const folder of ["log", "local"]) if (regularDir(join(home, folder))) copyTree(join(home, folder), join(scratch, folder), budget); // shared-v1's logs; a generation's legacy input
111
+ }
112
+ }
113
+
114
+ /** The work logs of this project and its aliases, in a private base of their own: the brief opens a work list read-write and migrates its index. */
115
+ function copyWork(options: DoctorOptions, base: string, budget: { bytes: number }): boolean {
116
+ let any = false;
117
+ for (const name of ["machine.json", "aliases.jsonl", "lifecycle.jsonl"]) if (regularFile(join(options.home, name))) { mkdirSync(base, { recursive: true, mode: 0o700 }); copyFileSync(join(options.home, name), join(base, name)); }
118
+ for (const key of [options.project, ...options.aliases]) {
119
+ const dir = shardDir(options.home, key), log = join(dir, "work", "log");
120
+ if (!regularDir(log)) continue;
121
+ copyTree(log, join(base, "projects", dir.split("/").pop()!, "work", "log"), budget);
122
+ if (regularFile(join(dir, "project.json"))) copyFileSync(join(dir, "project.json"), join(base, "projects", dir.split("/").pop()!, "project.json"));
123
+ any = true;
124
+ }
125
+ return any;
126
+ }
127
+
128
+ /** A snapshot of the live shared-v1 index (a read-only connection, so no checkpoint, no lease), or false when there is none. */
129
+ function snapshotIndex(live: string, to: string): boolean {
130
+ const file = join(live, "index.db");
131
+ if (!regularFile(file)) return false;
132
+ const forms: (() => Database)[] = [
133
+ () => new Database(file, { readonly: true }),
134
+ () => new Database(`file:${encodeURI(file).replace(/\?/g, "%3F").replace(/#/g, "%23")}?immutable=1`, { readonly: true } as ConstructorParameters<typeof Database>[1]),
135
+ ];
136
+ for (const open of forms) {
137
+ let db: Database | undefined;
138
+ try { db = open(); db.run("PRAGMA busy_timeout = 1000"); db.run(`VACUUM INTO '${to.replaceAll("'", "''")}'`); return true; }
139
+ catch { /* the next form */ } finally { try { db?.close(); } catch { /* nothing to close */ } }
140
+ }
141
+ throw new Error("the live index could not be snapshotted");
142
+ }
143
+
144
+ function duplicatesAndStale(options: DoctorOptions, rows: View[], store: MemoryStore): { duplicates: Section; stale: Section } {
145
+ const groups = new Map<string, string[]>();
146
+ for (const row of rows) {
147
+ const key = JSON.stringify([row.kind, (row.title ?? "").trim().toLowerCase(), row.text.replace(/\s+/g, " ").trim().toLowerCase()]);
148
+ groups.set(key, [...(groups.get(key) ?? []), row.id]);
149
+ }
150
+ const same = [...groups.values()].filter(group => group.length > 1).map(group => group.sort());
151
+ let legacy = 0;
152
+ try { legacy = store.dedupe(options.project, false).groups.length; } catch { /* the legacy preview is optional */ }
153
+ const truncated = rows.length >= LIST_ALL;
154
+ const duplicates: Section = { status: same.length || legacy || truncated ? "attention" : "ok", notes: rows.length, identicalGroups: same.length,
155
+ ...(same.length ? { examples: same.slice(0, MAX_IDS).map(group => group.map(id => id.slice(0, 8))) } : {}), importedGroups: legacy,
156
+ ...(truncated ? { truncated: `only the newest ${LIST_ALL} notes were read` } : {}),
157
+ ...(legacy ? { hint: "amem dedupe previews the imported duplicates; amem dedupe --apply reconciles them" } : {}) };
158
+ const root = boundRepoRoot(options.cwd, options.home, options.project);
159
+ let stale: Section;
160
+ if (!root) stale = skipped("run doctor from inside this project's repository to check its path references");
161
+ else {
162
+ const missing = rows.filter(row => { const paths = row.about.filter(node => node.startsWith("path:")).map(node => node.slice(5)); return paths.length > 0 && paths.every(path => !existsSync(join(root, path))); });
163
+ stale = { status: missing.length ? "attention" : "ok", checked: rows.length, missingReferences: missing.length, ...(missing.length ? { examples: ids(missing.map(row => row.id)) } : {}) };
164
+ }
165
+ return { duplicates, stale };
166
+ }
167
+
168
+ /** The incremental index, as a reader would refresh it from the snapshot of the live one, against a full rebuild of the same logs. Both live in the scratch copy. */
169
+ function indexSection(store: MemoryStore, layout: Layout, snapshotted: boolean): Section {
170
+ if (layout !== "shared-v1") return skipped("a curated split or generation home builds and seals its index when it is created; amem rebuild repeats it");
171
+ try {
172
+ store.list({ limit: 1 }); // brings the snapshot level with the logs, as the next reader would
173
+ const incremental = store.dump(); store.rebuild();
174
+ return incremental === store.dump() ? { status: "ok", liveIndex: snapshotted ? "snapshotted" : "absent" }
175
+ : { status: "attention", liveIndex: snapshotted ? "snapshotted" : "absent", hint: "the index differs from a rebuild of the logs: run amem rebuild" };
176
+ } catch (error) { return unavailable(message(error)); }
177
+ }
178
+
179
+ function receiptPhase(home: string, cli: Cli): "applied" | "prepared" | null {
180
+ try {
181
+ const file = join(home, "setup", `${cli}.json`), stat = lstatSync(file);
182
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4 * 1024 * 1024) return null;
183
+ const value = JSON.parse(readFileSync(file, "utf8")) as { target?: unknown; phase?: unknown };
184
+ return value.target === cli && (value.phase === "applied" || value.phase === "prepared") ? value.phase : null;
185
+ } catch { return null; }
186
+ }
187
+
188
+ /**
189
+ * Per CLI: is a capture hook registered, and when did it last capture. "Registered" is what the setup receipt says (`hookEvidence`): a hook the person removed by hand
190
+ * afterwards still shows as registered until `amem setup <cli> --apply` or `--undo` is run again. A CLI without a receipt is `manual`.
191
+ */
192
+ function captureSection(options: DoctorOptions): Section {
193
+ const keys = [options.project, ...options.aliases];
194
+ const seen = new Map<string, { rows: number; last: string | null }>();
195
+ let readable = true;
196
+ for (const shard of walkShards(options.home)) {
197
+ if (!keys.includes(shard.key)) continue;
198
+ for (const dir of shard.machineDirs) {
199
+ const file = join(dir, "capture.db");
200
+ if (!existsSync(file)) continue;
201
+ let db: Database | undefined;
202
+ try {
203
+ db = new Database(file, { readonly: true }); db.run("PRAGMA busy_timeout = 1000");
204
+ for (const row of db.query("SELECT agent, COUNT(*) AS n, MAX(at) AS last FROM rows GROUP BY agent").all() as { agent: string; n: number; last: string | null }[]) {
205
+ const prior = seen.get(row.agent);
206
+ seen.set(row.agent, { rows: (prior?.rows ?? 0) + row.n, last: prior?.last && prior.last > (row.last ?? "") ? prior.last : row.last });
207
+ }
208
+ } catch { readable = false; } finally { db?.close(); }
209
+ }
210
+ }
211
+ const clis: Record<string, unknown> = {};
212
+ const attention: string[] = [];
213
+ for (const cli of CLIS) {
214
+ const phase = receiptPhase(options.home, cli), registered = phase === "applied", entry = seen.get(cli);
215
+ const last = entry?.last ?? null, ageMs = last === null ? null : options.now - Date.parse(last);
216
+ const state = last === null ? "never" : ageMs! > CAPTURE_STALE_MS ? "stale" : "fresh";
217
+ // A registered hook that is not fresh, or any CLI whose capture went stale, needs a look. A CLI without a hook that never captured is just not used here: reported, not flagged.
218
+ const needs = registered ? state !== "fresh" : state === "stale";
219
+ if (needs) attention.push(cli);
220
+ clis[cli] = { hook: registered ? "registered" : "manual", ...(registered ? { hookEvidence: "setup receipt" } : {}), ...(phase === "prepared" ? { setup: "prepared, not applied" } : {}), capture: state, lastCaptureAt: last, rows: entry?.rows ?? 0,
221
+ ...(needs || (!registered && state === "never") ? { hint: registered ? `the ${cli} hook is registered but its capture is ${state}: open a session${cli === "claude" ? "" : `, or run amem ingest ${cli}`}` : cli === "claude" ? "run amem setup claude --apply to register the capture hooks" : `run amem setup ${cli} --apply, or amem ingest ${cli} by hand` } : {}) };
222
+ }
223
+ return { status: !readable ? "unavailable" : attention.length ? "attention" : "ok", ...(readable ? {} : { reason: "a capture database could not be read" }), clis };
224
+ }
225
+
226
+ /**
227
+ * The SessionStart brief's size for this project: the brief the SessionStart worker builds (the native parent's deferred brief is smaller and is not what is measured).
228
+ * It is built from the scratch copy of the curated logs and of the work logs; the capture section reads this machine's capture database read-only and only when it exists.
229
+ */
230
+ async function briefSection(options: DoctorOptions, curated: MemoryStore | undefined, workBase: string | undefined, copyFailed: string | undefined): Promise<Section> {
231
+ if (copyFailed) return unavailable(copyFailed);
232
+ let configured: number | undefined;
233
+ try { configured = loadAmemConfig(options.home, repoConfigRoot(options.cwd)).config.briefBytes; } catch { /* the policy section reports it */ }
234
+ const budget = Math.min(DEFAULT_BRIEF_TOKENS, configured ?? Number.MAX_SAFE_INTEGER);
235
+ try {
236
+ const result = await sessionStartBrief({ cwd: options.cwd, base: options.home, project: options.project, now: () => options.now,
237
+ measureOnly: { ...(curated ? { curated } : {}), ...(workBase ? { workBase } : {}) } });
238
+ if (result.context === "") return unavailable("the brief is empty for this project (archived, forgotten or its binding changed)");
239
+ const tight = result.tokens > budget * 0.9;
240
+ return { status: result.partial || tight ? "attention" : "ok", tokens: result.tokens, budget, coverage: result.coverage,
241
+ ...(result.partial ? { hint: "a section is partial or unavailable in the brief: see coverage" } : tight ? { hint: "the brief uses over 90% of its budget" } : {}) };
242
+ } catch (error) { return unavailable(message(error)); }
243
+ }
244
+
245
+ function readersSection(options: DoctorOptions): Section {
246
+ const dir = join(options.home, "readers");
247
+ if (!existsSync(dir)) return { status: "ok", live: 0, stale: 0, unreadable: 0 };
248
+ if (!regularDir(dir)) return unavailable("the readers directory is not a plain directory");
249
+ let live = 0, stale = 0, unreadable = 0;
250
+ try {
251
+ for (const name of readdirSync(dir).filter(file => file.endsWith(".json")).sort()) {
252
+ try {
253
+ const stat = lstatSync(join(dir, name));
254
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096) throw new Error();
255
+ const value = JSON.parse(readFileSync(join(dir, name), "utf8")) as { pid?: unknown; signature?: unknown };
256
+ const liveness = processLiveness(value.pid, typeof value.signature === "string" ? value.signature : undefined);
257
+ if (liveness === "gone") stale++; else live++;
258
+ } catch { unreadable++; }
259
+ }
260
+ } catch (error) { return unavailable(message(error)); }
261
+ return { status: stale || unreadable ? "attention" : "ok", live, stale, unreadable, ...(stale || unreadable ? { hint: "a reader lease of a process that is gone is cleared by the next reader; an unreadable one needs a look" } : {}) };
262
+ }
263
+
264
+ function policySection(options: DoctorOptions): Section {
265
+ try {
266
+ const { config, source } = loadAmemConfig(options.home, repoConfigRoot(options.cwd));
267
+ return { status: "ok", machine: source.machine, repo: source.repo, denyPaths: config.denyPaths.length, piiPatterns: config.piiPatterns.length,
268
+ ...(config.briefBytes !== undefined ? { briefBytes: config.briefBytes } : {}), ...(config.excerptBytes !== undefined ? { excerptBytes: config.excerptBytes } : {}),
269
+ ...(config.capturePaths !== undefined ? { capturePaths: config.capturePaths } : {}) };
270
+ } catch (error) { return unavailable(message(error)); }
271
+ }
272
+
273
+ /** F16: which CLIs ingest at SessionEnd (setup receipts), and the person-owned sync cadence. amem registers no sync hook (a hook may not do network work). */
274
+ function cadenceSection(options: DoctorOptions): Section {
275
+ const ingest: Record<string, string> = {};
276
+ for (const cli of CLIS) ingest[cli] = receiptPhase(options.home, cli) === "applied" ? (cli === "claude" ? "hooks" : "session end") : "manual";
277
+ const remote = remoteConfigured(options.home), state = usableState(readSyncState(options.home), options.now), next = nextSyncAt(state);
278
+ const command = options.syncCommand ?? "amem sync --if-due";
279
+ const stale = remote && (state.lastOkAt === null || options.now - Date.parse(state.lastOkAt) > SYNC_STALE_MS || state.failures >= 3);
280
+ const sync = { remote: remote ? "configured" : "none", lastAttemptAt: state.lastAttemptAt, lastOkAt: state.lastOkAt, failures: state.failures,
281
+ nextAttemptAt: next === null ? null : new Date(next).toISOString(), due: remote ? syncDue(state, options.now) : false,
282
+ ...(remote ? { schedule: `run \`${command}\` from your own shell (for example a zshexit or bash EXIT trap): it needs a terminal like every mutating command; amem registers no sync hook and runs nothing between sessions` } : {}) };
283
+ const manual = CLIS.filter(cli => ingest[cli] === "manual");
284
+ return { status: stale ? "attention" : "ok", ingest, ...(manual.length ? { manual } : {}), sync,
285
+ ...(stale ? { hint: state.failures >= 3 ? "sync has failed repeatedly; amem sync --force shows why" : "no recent successful sync: run amem sync" } : {}) };
286
+ }
287
+
288
+ /**
289
+ * Let the event loop run once. Doctor's stages are mostly synchronous (a git lookup is a `spawnSync`), and Bun runs a signal handler only when the loop turns: a signal that arrives
290
+ * during a stalled call would otherwise wait for the end of doctor, where the handler is already removed and the signal is lost. Between stages this lets the handler act at once.
291
+ * (Measured: with every tick removed a stalled git call loses the signal; removing any ONE tick does not, because other `await`s between stages turn the loop too. They are
292
+ * redundant on purpose, so a later stage that stops awaiting cannot silently reopen the gap: `test/doctor.test.ts` stalls every git call, one at a time.)
293
+ */
294
+ const tick = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 0));
295
+
296
+ /** `amem-doctor-<pid>-<random>`: the owner's pid is in the name so a later run can tell a leftover from a live run. */
297
+ const SCRATCH = /^amem-doctor-(\d+)-[A-Za-z0-9]{6}$/, OLD_SCRATCH = /^amem-doctor-[A-Za-z0-9]{6}$/;
298
+ const OLD_SCRATCH_AGE_MS = 24 * 3600_000;
299
+
300
+ /**
301
+ * Remove scratch directories an earlier doctor left behind (it was killed by something no handler sees, or by a version that had none). A directory goes only when it is a plain
302
+ * directory this user owns, named exactly as doctor names them, and its owner pid is gone: a live pid, or one that cannot be told, keeps its directory (a reused pid costs
303
+ * a leftover, never a live run's data). A directory from before the pid was in the name has no owner to ask, so it goes only after a day. Returns how many were removed. Never throws.
304
+ */
305
+ export function sweepStaleScratch(tmp: string = tmpdir(), alive: (pid: number) => boolean = pid => processLiveness(pid) !== "gone", now: number = Date.now(), uid: number | undefined = typeof process.getuid === "function" ? process.getuid() : undefined): number {
306
+ let removed = 0;
307
+ let names: string[] = [];
308
+ try { names = readdirSync(tmp); } catch { return 0; }
309
+ for (const name of names) {
310
+ const owner = SCRATCH.exec(name), old = !owner && OLD_SCRATCH.test(name);
311
+ if (!owner && !old) continue;
312
+ try {
313
+ const path = join(tmp, name), stat = lstatSync(path);
314
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (uid !== undefined && stat.uid !== uid)) continue;
315
+ if (owner ? alive(Number(owner[1])) : now - stat.mtimeMs < OLD_SCRATCH_AGE_MS) continue;
316
+ rmSync(path, { recursive: true, force: true }); removed++;
317
+ } catch { /* gone already, or not ours to remove */ }
318
+ }
319
+ return removed;
320
+ }
321
+
322
+ /**
323
+ * Keep doctor's scratch directory removed however this process ends while doctor runs. Install it first, then `adopt` the directory once `mkdtemp` has made it: a signal that
324
+ * arrives in between meets a handler, which has nothing to remove yet but still ends the process with the signal. A handler cannot be left out of the picture: with one installed the
325
+ * default action is suppressed, so after removing the directory it removes itself and re-raises the signal, which ends the process with the status the shell expects (and only when
326
+ * no other handler of the host remains, so it never double-fires). An `exit` handler covers a throw. Everything is removed again by `release` once doctor finished normally.
327
+ * SIGKILL cannot be handled by anyone: the next doctor's `sweepStaleScratch` removes that directory.
328
+ */
329
+ function guardScratch(): { adopt: (dir: string) => void; release: () => void } {
330
+ let dir: string | undefined;
331
+ const remove = () => { if (dir) try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ } };
332
+ const handlers = STOP_SIGNALS.map(name => {
333
+ const handler = () => {
334
+ remove();
335
+ process.off(name, handler);
336
+ if (process.listenerCount(name) !== 0) return; // another handler of the host remains: it decides how the process ends
337
+ // SIGQUIT's default action is a core dump, and Bun honours it: a re-raised SIGQUIT on Linux writes a core file the size of the process image (measured 5.9 GB, taking
338
+ // seconds, in a container with `ulimit -c unlimited`) while the shell waits for the exit. Exit with the status that death would have had instead (128 + 3), at once. The other
339
+ // three are re-raised, so a parent sees the signal itself (`signalCode`).
340
+ if (name === "SIGQUIT") process.exit(128 + 3);
341
+ process.kill(process.pid, name);
342
+ };
343
+ process.on(name, handler);
344
+ return [name, handler] as const;
345
+ });
346
+ process.on("exit", remove);
347
+ return { adopt: made => { dir = made; }, release: () => { for (const [name, handler] of handlers) process.off(name, handler); process.off("exit", remove); } };
348
+ }
349
+
350
+ export async function doctorReport(options: DoctorOptions): Promise<DoctorReport> {
351
+ let scratch: string | undefined, unguard: (() => void) | undefined;
352
+ let duplicates: Section, stale: Section, index: Section;
353
+ let curated: MemoryStore | undefined, workBase: string | undefined, copyFailed: string | undefined;
354
+ try {
355
+ let layout: Layout | undefined;
356
+ try { layout = layoutOf(options.home); } catch (error) { throw new Error(`the memory profile cannot be read: ${message(error)}`); }
357
+ sweepStaleScratch();
358
+ // The guard is installed BEFORE the directory exists and told its name afterwards: a signal in the few microseconds between `mkdtemp` and a guard installed after it would
359
+ // meet no handler, take the default action and strand the directory (measured: 4 of 60 runs when a signal lands the instant it appears).
360
+ const guard = guardScratch(); unguard = guard.release;
361
+ scratch = mkdtempSync(join(tmpdir(), `amem-doctor-${process.pid}-`)); // a scratch directory, never inside the home: a home nobody used stays absent
362
+ guard.adopt(scratch);
363
+ options.afterScratch?.(scratch);
364
+ const budget = { bytes: 0 };
365
+ try {
366
+ // The work logs go to a private base of their own, whether or not the project has curated notes: the brief's work section opens a list read-write.
367
+ workBase = join(scratch, "work-base"); copyWork(options, workBase, budget);
368
+ if (!curatedPresent(options.home, options.project, layout)) {
369
+ duplicates = stale = index = skipped("no curated notes for this project yet");
370
+ } else {
371
+ const home = join(scratch, "home"); mkdirSync(home, { mode: 0o700 });
372
+ copyCurated(options, layout, home, budget);
373
+ const snapshotted = layout === "shared-v1" && snapshotIndex(options.home, join(home, "index.db"));
374
+ curated = options.openCurated(home);
375
+ const rows = curated.list({ project: options.project, limit: LIST_ALL });
376
+ await tick();
377
+ ({ duplicates, stale } = duplicatesAndStale(options, rows, curated));
378
+ await tick();
379
+ // The brief is measured after this and before the index proof's rebuild only because the brief reads rows, not the index tables.
380
+ index = indexSection(curated, layout, snapshotted);
381
+ }
382
+ } catch (error) {
383
+ copyFailed = error instanceof TooBig ? "the logs are over 64 MiB: the brief and the checks that need a copy were not run" : message(error);
384
+ workBase = undefined; duplicates = stale = index = unavailable(copyFailed);
385
+ }
386
+ } catch (error) { copyFailed = message(error); duplicates = stale = index = unavailable(copyFailed); }
387
+ await tick();
388
+ let candidates: Section;
389
+ try {
390
+ const summary = pendingSummary(options.home, [options.project, ...options.aliases], options.now, true);
391
+ candidates = summary === null ? unavailable("candidates.db exists but cannot be read")
392
+ : { status: summary.dueWithin7Days ? "attention" : "ok", ...summary, ...(summary.dueWithin7Days ? { hint: "run amem review walk" } : {}) };
393
+ } catch (error) { candidates = unavailable(message(error)); }
394
+ let sections: DoctorReport["sections"];
395
+ try {
396
+ await tick();
397
+ sections = { duplicates, stale, candidates, capture: captureSection(options), brief: await briefSection(options, curated, workBase, copyFailed), index, readers: readersSection(options), policy: policySection(options), cadence: cadenceSection(options) };
398
+ await tick(); // a signal that arrived during the last synchronous stage is handled while the guard is still installed
399
+ } finally { try { curated?.close(); } finally { try { if (scratch) rmSync(scratch, { recursive: true, force: true }); } finally { unguard?.(); } } }
400
+ return { schema: DOCTOR_SCHEMA, status: worst(Object.values(sections).map(section => section.status)), home: { path: options.home, source: options.homeSource }, project: options.project, sections };
401
+ }
402
+
403
+ export function doctorText(report: DoctorReport): string {
404
+ const mark = (status: SectionStatus) => status === "ok" ? "ok " : status === "attention" ? "ATTENTION " : status === "skipped" ? "skipped " : "unavailable";
405
+ const lines = [`amem doctor: ${report.status}`, `home ${report.home.path} (${report.home.source}), project ${report.project}`];
406
+ for (const [name, section] of Object.entries(report.sections)) {
407
+ const { status, hint, reason, ...detail } = section as Section & { hint?: string; reason?: string };
408
+ lines.push(`${mark(status)} ${name}: ${JSON.stringify(detail)}${reason ? ` (${reason})` : ""}`);
409
+ if (hint) lines.push(` -> ${hint}`);
410
+ }
411
+ return lines.join("\n");
412
+ }
@@ -0,0 +1,58 @@
1
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ /**
6
+ * Launch the person's editor on a private temporary file and return what they wrote. Import-light on purpose (node built-ins only).
7
+ *
8
+ * - `$VISUAL` then `$EDITOR`; an EMPTY `VISUAL` does not hide a set `EDITOR` (`||`, not `??`).
9
+ * - The command is run without a shell. A value that names an existing file is one command (so an editor path with a space works unquoted); otherwise it is split like a shell
10
+ * would for quotes and backslashes only (`"/path with space/code" --wait`), with no expansion of variables, globs or substitutions.
11
+ * - SIGINT is ignored in this process while the editor runs (as git does), so Ctrl-C reaches the editor and not us: we are still here afterwards to remove the note text. A SIGKILL or
12
+ * a power cut can still leave it, in a 0700 directory as a 0600 file, under the person's own temporary directory.
13
+ * - An editor that is killed by a signal, or exits non-zero, writes nothing.
14
+ */
15
+ export interface EditorSpawn { (argv: string[]): { exitCode: number | null; signalCode?: string | number | null } }
16
+ const spawnReal: EditorSpawn = argv => { const done = Bun.spawnSync(argv, { stdin: "inherit", stdout: "inherit", stderr: "inherit" }); return { exitCode: done.exitCode, signalCode: done.signalCode ?? null }; };
17
+
18
+ export function splitCommand(command: string): string[] {
19
+ const out: string[] = [];
20
+ let current = "", started = false, quote: "'" | '"' | undefined;
21
+ for (let i = 0; i < command.length; i++) {
22
+ const c = command[i]!;
23
+ if (quote) {
24
+ if (c === quote) quote = undefined;
25
+ else if (c === "\\" && quote === '"' && (command[i + 1] === '"' || command[i + 1] === "\\")) current += command[++i];
26
+ else current += c;
27
+ } else if (c === "'" || c === '"') { quote = c; started = true; }
28
+ else if (c === "\\" && i + 1 < command.length) { current += command[++i]; started = true; }
29
+ else if (/\s/.test(c)) { if (started || current) { out.push(current); current = ""; started = false; } }
30
+ else { current += c; started = true; }
31
+ }
32
+ if (quote) throw new Error("the editor command has an unclosed quote");
33
+ if (started || current) out.push(current);
34
+ return out;
35
+ }
36
+
37
+ export function editorArgv(env: Readonly<Record<string, string | undefined>>, file: string): string[] {
38
+ const editor = (env.VISUAL?.trim() || env.EDITOR?.trim() || "");
39
+ if (!editor) throw new Error("no editor: set $VISUAL or $EDITOR, or pass the text as an argument");
40
+ const argv = existsSync(editor) ? [editor] : splitCommand(editor);
41
+ if (!argv.length) throw new Error("no editor: $VISUAL or $EDITOR names no command");
42
+ return [...argv, file];
43
+ }
44
+
45
+ export function runEditor(env: Readonly<Record<string, string | undefined>>, initial: string, spawn: EditorSpawn = spawnReal): string {
46
+ const dir = mkdtempSync(join(tmpdir(), "amem-note-")), file = join(dir, "note.txt");
47
+ const ignore = () => { /* Ctrl-C belongs to the editor while it runs */ };
48
+ try {
49
+ const argv = editorArgv(env, file);
50
+ writeFileSync(file, initial, { mode: 0o600 });
51
+ process.on("SIGINT", ignore);
52
+ let done: ReturnType<EditorSpawn>;
53
+ try { done = spawn(argv); } finally { process.off("SIGINT", ignore); }
54
+ if (done.signalCode !== null && done.signalCode !== undefined) throw new Error(`the editor was stopped by ${done.signalCode}; nothing was written`);
55
+ if (done.exitCode !== 0) throw new Error(`the editor exited with status ${done.exitCode ?? "unknown"}; nothing was written`);
56
+ return readFileSync(file, "utf8");
57
+ } finally { rmSync(dir, { recursive: true, force: true }); }
58
+ }
@@ -0,0 +1,137 @@
1
+ import { closeSync, constants, fstatSync, mkdirSync, mkdtempSync, openSync, readSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { parseEvalSet, runEval, type EvalQuestion, type EvalReport, type EvalSet, MAX_SET_BYTES } from "./eval.ts";
6
+ import { MemoryStore, type View } from "./store.ts";
7
+ import { realPath } from "./platform.ts";
8
+
9
+ /**
10
+ * The committed synthetic question set: no person's memory, only invented entries about a made-up project. It lives under `src/` because the package ships
11
+ * `src/` and not `test/`, so a plain `amem eval` works from an installed copy. A file URL, converted with `fileURLToPath` wherever a path is needed: a plain
12
+ * `.pathname` stays percent-encoded and breaks under an install path with a space or non-ASCII characters.
13
+ */
14
+ export const FIXTURE_PATH = new URL("./eval-set.json", import.meta.url);
15
+ const PROJECT = "example.test/eval/fixture";
16
+ const DAY = 86400_000;
17
+ /** A fixed clock: entry ids hash their `at`, and recency weights the ranking, so a clock that only moves when the seeder moves it makes every run identical. */
18
+ const EPOCH = Date.parse("2026-01-01T00:00:00.000Z");
19
+
20
+ /**
21
+ * Read and parse a set file. One descriptor does everything: it must be a regular file, its size is bounded before and after the read, and the bytes read are bounded
22
+ * too, so a file that grows or is swapped between a size check and a read cannot slip a larger set through. A symlink is followed (the person named the path).
23
+ */
24
+ export function readEvalSet(path: string | URL): EvalSet {
25
+ const file = typeof path === "string" ? path : fileURLToPath(path), label = typeof path === "string" ? path : "the built-in fixture";
26
+ const tooBig = () => new Error(`invalid eval set: ${label} is not a regular file within ${MAX_SET_BYTES} bytes`);
27
+ const fd = openSync(file, constants.O_RDONLY | constants.O_NONBLOCK);
28
+ let bytes: Buffer;
29
+ try {
30
+ const before = fstatSync(fd);
31
+ if (!before.isFile() || before.size > MAX_SET_BYTES) throw tooBig();
32
+ const buffer = Buffer.alloc(MAX_SET_BYTES + 1);
33
+ let length = 0;
34
+ while (length < buffer.length) {
35
+ const n = readSync(fd, buffer, length, buffer.length - length, length);
36
+ if (n === 0) break;
37
+ length += n;
38
+ }
39
+ const after = fstatSync(fd);
40
+ if (length > MAX_SET_BYTES || after.size !== before.size || length !== before.size) throw tooBig();
41
+ bytes = buffer.subarray(0, length);
42
+ } finally { closeSync(fd); }
43
+ let raw: unknown;
44
+ try { raw = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); } catch { throw new Error("invalid eval set: not valid JSON"); }
45
+ return parseEvalSet(raw);
46
+ }
47
+
48
+ /** The ranked ids of `store.search` for one question, curated tier only (a question set names notes, and the capture tier is machine-local recall, never an answer key). */
49
+ function ranker(store: MemoryStore, project: string, window: number): { rank: (question: string) => string[]; engine: () => string } {
50
+ let engine = "unknown";
51
+ return {
52
+ rank: question => {
53
+ const result = store.search(question, { project, limit: window, includeCapture: false });
54
+ engine = result.engine;
55
+ return result.hits.flatMap(hit => hit.view ? [hit.view.id] : []);
56
+ },
57
+ engine: () => engine,
58
+ };
59
+ }
60
+
61
+ /** A ranking under test: the ids it returns for a question, best first, up to `window` of them. `amem eval` scores `store.search`; F14 candidates are scored by passing another one. */
62
+ export type RankFn = (store: MemoryStore, question: string, window: number) => string[];
63
+
64
+ /** Score a self-contained set: seed its synthetic entries into a throwaway store, search it, and delete it. Touches no memory home. `rank` replaces `store.search` (an experiment, never the command). */
65
+ export function evalSeeded(set: EvalSet, ks?: readonly number[], rank?: RankFn): EvalReport {
66
+ if (!set.entries?.length) throw new Error("this set has no entries; it must be run against a store");
67
+ const dir = realPath(mkdtempSync(join(tmpdir(), "amem-eval-")));
68
+ // The cleanup is armed the moment the directory exists, so a failure while building the store cannot leak it.
69
+ let store: MemoryStore | undefined;
70
+ try {
71
+ const base = join(dir, "memory");
72
+ mkdirSync(base, { recursive: true });
73
+ // One entry per day, so the recency weight (a 30-day scale) is exercised, long-tier entries first and short-tier ones last: a short-term note leaves the index 14 days after it is
74
+ // written, and the clock stops a day after the last entry, so the short ones written in that window are live when the set is searched (a set with more short entries than that
75
+ // window holds loses its oldest, and a question naming one is then reported as not in the store, like any other missing entry).
76
+ let current = EPOCH;
77
+ store = new MemoryStore({ memoryDir: base, now: () => current, screen: () => "keep" });
78
+ const ids = new Map<string, string>();
79
+ const ordered = [...set.entries.filter(entry => entry.tier !== "short"), ...set.entries.filter(entry => entry.tier === "short")];
80
+ for (const [index, entry] of ordered.entries()) {
81
+ current = EPOCH + index * DAY;
82
+ ids.set(entry.key, store.note({ op: "note", project: PROJECT, scope: "project", tier: entry.tier ?? "long", kind: entry.kind ?? "finding", peer: "eval", source: "person",
83
+ ...(entry.title ? { title: entry.title } : {}), text: entry.text, about: entry.about ?? [] }, entry.tier === "short"));
84
+ }
85
+ current = EPOCH + ordered.length * DAY;
86
+ const window = Math.max(...(ks ?? [10]));
87
+ const search = rank ? { rank: (question: string) => rank(store!, question, window), engine: () => "custom" } : ranker(store, PROJECT, window);
88
+ // Resolved through `store.get`, so a note that has already expired is absent, not silently present: coverage stays separate from ranking.
89
+ const live = (name: string): string | undefined => { const id = ids.get(name); return id !== undefined && store!.get(id, PROJECT) ? id : undefined; };
90
+ const report = runEval(search.rank, question => question.expect.map(live), set, "pending", ks);
91
+ return { ...report, engine: search.engine() };
92
+ } finally { store?.close(); rmSync(dir, { recursive: true, force: true }); }
93
+ }
94
+
95
+ /**
96
+ * Score a set of real entry ids against the bound project's store. Each expected name is resolved through `store.get` (so a promoted short-term id, a `mem:` prefix and an id older
97
+ * than any list cap resolve like they do for `amem show`), then followed to the entry that is LIVE: a superseded entry points at its replacement, and `store.search` only ever returns the
98
+ * replacement, so an expected id that was superseded stands for the head of its chain (a broken or cyclic chain is "not in the store"). A prefix that `get` cannot place is checked against
99
+ * every visible id: more than one distinct live entry is refused with an error, because crediting whichever entry matched would let the ranking pick its own answer key.
100
+ *
101
+ * ponytail: `store.get` rebuilds the live view on every call, so the cost is names x store size. Names repeated across questions are resolved once (memoized for the run); the ceiling is
102
+ * 500 questions x 20 names on a very large store, tens of seconds. Upgrade: one snapshot of the visible views (`list` with `includeSuperseded`) indexed by id, `promotedFrom` and
103
+ * `supersededBy`, if a real set ever meets that.
104
+ */
105
+ export function evalStore(store: MemoryStore, project: string, set: EvalSet, ks?: readonly number[]): EvalReport {
106
+ let visible: View[] | undefined;
107
+ const all = () => visible ??= store.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true });
108
+ /** The id of the live entry `view` stands for: itself, or the head of its supersede chain. */
109
+ const live = (view: View): string | undefined => {
110
+ const seen = new Set<string>();
111
+ for (let current: View | undefined = view; current; current = store.get(current.supersededBy, project)) {
112
+ if (!current.supersededBy) return current.id;
113
+ if (seen.has(current.id)) return undefined;
114
+ seen.add(current.id);
115
+ }
116
+ return undefined;
117
+ };
118
+ const known = new Map<string, string | undefined>();
119
+ const resolve = (name: string): string | undefined => {
120
+ if (known.has(name)) return known.get(name);
121
+ let id: string | undefined;
122
+ const found = store.get(name, project);
123
+ if (found) id = live(found);
124
+ else {
125
+ const bare = name.replace(/^mem:/, "");
126
+ const heads = new Set<string>(), seenAny = all().filter(view => view.id.startsWith(bare));
127
+ for (const view of seenAny) { const head = live(view); if (head !== undefined) heads.add(head); }
128
+ if (heads.size > 1) throw new Error(`invalid eval set: "${name.slice(0, 40)}" is the start of ${heads.size} entries; use more characters`);
129
+ id = [...heads][0];
130
+ }
131
+ known.set(name, id);
132
+ return id;
133
+ };
134
+ const search = ranker(store, project, Math.max(...(ks ?? [10])));
135
+ const report = runEval(search.rank, (question: EvalQuestion) => question.expect.map(resolve), set, "pending", ks);
136
+ return { ...report, engine: search.engine() };
137
+ }