@staix/agent-mem 0.5.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 (76) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +15 -0
  3. package/README.md +186 -21
  4. package/bin/amem.js +26 -3
  5. package/hooks/hooks.json +81 -0
  6. package/package.json +3 -1
  7. package/src/cli.ts +492 -39
  8. package/src/commands-table.ts +56 -15
  9. package/src/hook-contract.ts +3 -0
  10. package/src/hook-git.ts +42 -0
  11. package/src/hook-native.ts +92 -0
  12. package/src/hook-project.ts +206 -0
  13. package/src/hook-resources.ts +15 -0
  14. package/src/hook-spool.ts +566 -0
  15. package/src/hooks.ts +253 -0
  16. package/src/index.ts +18 -0
  17. package/src/ingest.ts +351 -0
  18. package/src/mcp-memory.ts +141 -33
  19. package/src/mcp.ts +42 -7
  20. package/src/memory/aliases.ts +87 -0
  21. package/src/memory/bridge.ts +8 -2
  22. package/src/memory/candidate-pending.ts +119 -0
  23. package/src/memory/candidates.ts +144 -47
  24. package/src/memory/capacity-split.ts +76 -0
  25. package/src/memory/capacity.ts +162 -59
  26. package/src/memory/capture-input.ts +36 -0
  27. package/src/memory/capture.ts +153 -39
  28. package/src/memory/catalog.ts +43 -10
  29. package/src/memory/commands.ts +98 -44
  30. package/src/memory/config.ts +167 -0
  31. package/src/memory/cross-project.ts +75 -0
  32. package/src/memory/curated-read.ts +29 -0
  33. package/src/memory/decay-review.ts +136 -0
  34. package/src/memory/doctor.ts +412 -0
  35. package/src/memory/editor.ts +58 -0
  36. package/src/memory/eval-fixture.ts +137 -0
  37. package/src/memory/eval-set.json +443 -0
  38. package/src/memory/eval.ts +150 -0
  39. package/src/memory/gc.ts +97 -0
  40. package/src/memory/generation-index.ts +22 -17
  41. package/src/memory/generation-sync.ts +3 -0
  42. package/src/memory/goal-promotion.ts +71 -0
  43. package/src/memory/home.ts +15 -4
  44. package/src/memory/intent-brief.ts +89 -0
  45. package/src/memory/lifecycle-log.ts +54 -0
  46. package/src/memory/lifecycle.ts +61 -0
  47. package/src/memory/machine.ts +31 -0
  48. package/src/memory/metrics.ts +63 -0
  49. package/src/memory/mutation.ts +6 -4
  50. package/src/memory/note-add.ts +100 -0
  51. package/src/memory/path-glob.ts +94 -0
  52. package/src/memory/plan-guard.ts +47 -0
  53. package/src/memory/platform.ts +9 -4
  54. package/src/memory/policy.ts +44 -13
  55. package/src/memory/private-spans.ts +32 -0
  56. package/src/memory/project-export.ts +122 -0
  57. package/src/memory/project.ts +129 -20
  58. package/src/memory/reader-lease.ts +22 -10
  59. package/src/memory/recall-staleness.ts +56 -0
  60. package/src/memory/recall.ts +74 -0
  61. package/src/memory/repo-scope.ts +404 -0
  62. package/src/memory/shard-sync-format.ts +116 -0
  63. package/src/memory/shard-sync.ts +421 -0
  64. package/src/memory/shard.ts +3 -80
  65. package/src/memory/store.ts +286 -56
  66. package/src/memory/sync-cadence.ts +139 -0
  67. package/src/memory/sync-screen.ts +86 -0
  68. package/src/memory/sync.ts +79 -11
  69. package/src/memory/usage.ts +35 -0
  70. package/src/memory/use-telemetry.ts +51 -0
  71. package/src/memory/work-lists.ts +115 -38
  72. package/src/session-brief-worker.ts +82 -0
  73. package/src/session-brief.ts +261 -0
  74. package/src/setup-owned.ts +120 -0
  75. package/src/setup-toml.ts +291 -0
  76. package/src/setup.ts +327 -0
@@ -0,0 +1,136 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { lstatSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { hashJSON } from "./generation.ts";
5
+ import { candidateTargetRevision, type CandidateTarget, type Counter } from "./candidates.ts";
6
+ import { MemoryStore, type View } from "./store.ts";
7
+
8
+ export const DECAY_DEFAULT_DAYS = 90, DECAY_PREVIEW_LIMIT = 64, DECAY_SCAN_LIMIT = 5000;
9
+ export interface DecayEntry { target: CandidateTarget; view: View }
10
+ export interface DecayPlan {
11
+ project: string; profile: string; days: number; at: string; cutoff: string; demoteExpiresAt: string;
12
+ entries: DecayEntry[]; withheld: number; citedExemptions: number | null; truncated: boolean;
13
+ /** Missing/zero observations do not prove the note was never cited. */
14
+ citationHistory: "unknown";
15
+ }
16
+ export interface DecayOptions {
17
+ now?: () => number; days?: number; ids?: readonly string[]; aliases?: readonly string[]; counters?: () => readonly Counter[] | undefined;
18
+ screen?: (view: View) => boolean; assertCurrent?: () => void;
19
+ }
20
+ function idsOf(ids: readonly string[]): string[] {
21
+ const normalized = ids.map(id => id.replace(/^mem:/, ""));
22
+ 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");
23
+ return normalized;
24
+ }
25
+ /** Advisory telemetry without opening/pruning the candidate writer. Unavailable is unknown, never zero. */
26
+ export function readDecayCounters(base: string, projects: readonly string[]): Counter[] | undefined {
27
+ const file = join(base, "candidates.db");
28
+ let db: Database | undefined;
29
+ try {
30
+ const stat = lstatSync(file, { throwIfNoEntry: false });
31
+ if (!stat || !stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) return undefined;
32
+ db = new Database(file, { readonly: true }); db.run("PRAGMA busy_timeout = 0");
33
+ 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 }[];
34
+ 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 }));
35
+ } catch { return undefined; }
36
+ finally { try { db?.close(); } catch { /* An unavailable telemetry close cannot authorize a mutation. */ } }
37
+ }
38
+ function citedIds(project: string, aliases: readonly string[], counters: readonly Counter[] | undefined): Set<string> {
39
+ const ids = new Set<string>();
40
+ for (const row of counters ?? []) {
41
+ if (!row?.key || ![project, ...aliases].includes(row.key.project) || !Number.isSafeInteger(row.cited) || !(row.cited! > 0)) continue;
42
+ // Counter timestamps describe delivery, not a later citation update. Any retained positive exempts.
43
+ ids.add(row.key.entryId);
44
+ }
45
+ return ids;
46
+ }
47
+ /** Age/goal selection is independent of telemetry; retained positive citations may only exempt entries. */
48
+ export function decayPreview(store: MemoryStore, project: string, options: DecayOptions = {}): DecayPlan {
49
+ options.assertCurrent?.();
50
+ if (!project.trim() || store.profile.sharded && !store.profile.shardUser && store.profile.shardKey !== project) throw Error("decay project does not match the bound store");
51
+ const now = (options.now ?? Date.now)(), days = options.days ?? DECAY_DEFAULT_DAYS, cutoff = now - days * 86400_000;
52
+ 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");
53
+ const requested = options.ids === undefined ? undefined : idsOf(options.ids);
54
+ return store.withDeferredPromotions(() => {
55
+ const goals = store.goals(project).filter(goal => ["open", "active", "blocked"].includes(goal.state));
56
+ const tasks = new Set(goals.flatMap(goal => goal.links.map(link => link.task))), goalNodes = new Set(goals.map(goal => `goal:${goal.id}`));
57
+ let counters: readonly Counter[] | undefined;
58
+ try { counters = options.counters?.(); } catch { /* Telemetry is advisory; an unreadable snapshot is unknown. */ }
59
+ const cited = citedIds(project, options.aliases ?? [], counters);
60
+ // ponytail: list materializes the existing core corpus before slicing; a scoped pager is the upgrade for large homes.
61
+ const rows = requested ? requested.map(id => store.get(id, project)) : store.list({ project, tier: "long", limit: DECAY_SCAN_LIMIT + 1 });
62
+ if (rows.length > DECAY_SCAN_LIMIT) throw Error("decay preview scan bound exceeded; name exact ids to narrow it");
63
+ const entries: DecayEntry[] = []; let withheld = 0, citedExemptions = 0;
64
+ for (let index = 0; index < rows.length; index++) {
65
+ const view = rows[index];
66
+ 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
67
+ && !(view.task && tasks.has(view.task)) && !view.about.some(node => goalNodes.has(node) || node.startsWith("task:") && tasks.has(node.slice(5)));
68
+ if (!eligible) { if (requested) throw Error("requested entry is not eligible for decay"); continue; }
69
+ if (cited.has(view.id)) { citedExemptions++; if (requested) throw Error("requested entry has a retained recorded citation"); continue; }
70
+ if (options.screen && !options.screen(structuredClone(view))) { withheld++; if (requested) throw Error("requested entry is withheld by current policy"); continue; }
71
+ entries.push({ target: { id: view.id, revision: candidateTargetRevision(view) }, view: structuredClone(view) });
72
+ }
73
+ options.assertCurrent?.();
74
+ 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" };
75
+ });
76
+ }
77
+ /** Duplicate-group forgetting requires a separate person group review, never an implicit decay expansion. */
78
+ export function assertExactDecayForget(store: MemoryStore, ids: readonly string[], project: string): void {
79
+ store.withDeferredPromotions(() => {
80
+ for (const id of ids) {
81
+ const targets = store.forgetTargets(id, project);
82
+ if (targets.length !== 1 || targets[0]?.id !== id) throw Error("decay forget expands beyond reviewed exact entries; review the imported copy group separately");
83
+ }
84
+ });
85
+ }
86
+
87
+ export interface DecayApplyOptions extends Omit<DecayOptions, "days"> {
88
+ actor: "person" | "agent"; confirmed?: boolean; forget?: boolean;
89
+ }
90
+ export interface DecayApplyReport { action: "demote" | "forget"; applied: string[]; failed: string; remaining: string[]; expiresAt?: string }
91
+ export class DecayApplyError extends Error {
92
+ jsonOutput = false;
93
+ readonly failedMayHaveChanged = true;
94
+ constructor(readonly report: DecayApplyReport, cause: unknown) {
95
+ super("decay apply stopped; the failed target may have changed, inspect before retrying", { cause }); this.name = "DecayApplyError";
96
+ }
97
+ get applied(): readonly string[] { return this.report.applied; }
98
+ get failed(): string { return this.report.failed; }
99
+ get remaining(): readonly string[] { return this.report.remaining; }
100
+ render(): string {
101
+ if (this.jsonOutput) return JSON.stringify({ ok: false, ...this.report, failedMayHaveChanged: this.failedMayHaveChanged });
102
+ 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}` : ""}`;
103
+ }
104
+ }
105
+ /** The host shows this exact plan; every selected revision/eligibility is rechecked before any append. */
106
+ export function applyDecay(store: MemoryStore, plan: DecayPlan, options: DecayApplyOptions): { action: "demote" | "forget"; ids: string[]; expiresAt?: string } {
107
+ if (options.actor !== "person") throw Error("decay apply requires person authority");
108
+ options.assertCurrent?.();
109
+ const ids = options.ids === undefined ? plan.entries.map(entry => entry.target.id) : idsOf(options.ids);
110
+ if ((options.ids === undefined || options.forget) && options.confirmed !== true) throw Error("decay bulk/forget apply requires explicit person confirmation");
111
+ 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");
112
+ const action = options.forget ? "forget" : "demote";
113
+ if (!ids.length) return { action, ids };
114
+ 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");
115
+ if (!options.forget) store.assertDemotionExpiry(plan.demoteExpiresAt);
116
+ return store.withCurrentEntries(ids, plan.project, current => {
117
+ const fresh = decayPreview(store, plan.project, { ...options, days: plan.days, ids });
118
+ for (const view of current) {
119
+ const expected = plan.entries.find(entry => entry.target.id === view.id)?.target.revision;
120
+ 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");
121
+ }
122
+ if (options.forget) assertExactDecayForget(store, ids, plan.project);
123
+ options.assertCurrent?.();
124
+ // All checks precede effects. The failed target may itself have appended before an I/O error.
125
+ const applied: string[] = [], expiry = options.forget ? {} : { expiresAt: plan.demoteExpiresAt };
126
+ for (let index = 0; index < ids.length; index++) {
127
+ const id = ids[index]!;
128
+ try {
129
+ const result = options.forget ? store.forget(id, "person", plan.project, true) : store.demote(id, "person", plan.project, plan.demoteExpiresAt);
130
+ if (!result) throw Error("decay target outcome unavailable");
131
+ applied.push(id);
132
+ } catch (error) { throw new DecayApplyError({ action, applied: [...applied], failed: id, remaining: ids.slice(index + 1), ...expiry }, error); }
133
+ }
134
+ return { action, ids, ...expiry };
135
+ });
136
+ }
@@ -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
+ }