@staix/agent-mem 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,805 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { appendFileSync, chmodSync, closeSync, existsSync, fstatSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
4
+ import { isAbsolute, join, normalize } from "node:path";
5
+ import { isDenied } from "./platform.ts";
6
+ import { memoryHome } from "./home.ts";
7
+
8
+ /**
9
+ * #258: the hub's own memory. Append-only JSONL logs are the truth: `log/<machine>.jsonl` holds long-term entries and
10
+ * is what machines sync (each machine writes only its own segment), `local/<machine>.jsonl` holds short-term entries
11
+ * and never leaves the machine. `index.db` is derived from the logs: rows are content-addressed and inserted with
12
+ * INSERT OR IGNORE, and every state (superseded, forgotten, promoted, demoted, expired) is computed from the rows, so
13
+ * applying the segments in any order gives the same index and `rebuild()` equals the incremental index.
14
+ */
15
+ export const MEMORY_SCHEMA = "agent-hub.memory/v1";
16
+ export const MEMORY_DB_BUSY_TIMEOUT_MS = 3000;
17
+ export const KINDS = ["decision", "finding", "contract", "fail"] as const;
18
+ export type Kind = (typeof KINDS)[number];
19
+ /** Kinds that move to long-term when their task is approved; findings stay short-term unless a person promotes them. */
20
+ export const PROMOTED_KINDS: readonly Kind[] = ["decision", "contract", "fail"];
21
+ export type Tier = "short" | "long";
22
+ export type Scope = "project" | "shared";
23
+ export const DEFAULT_TTL_MS = 14 * 24 * 3600_000;
24
+ /** A note on a task that is never approved (abandoned, or lost to `ahub reset --all`) leaves after this, the same for every reader. */
25
+ export const UNSETTLED_MS = 180 * 24 * 3600_000;
26
+ const MAX_TEXT = 4000, MAX_TITLE = 200, MAX_TARGETS = 64;
27
+
28
+ /** A graph node another entry can be about: `path:src/a.ts`, `symbol:parse@src/a.ts`, `issue:owner/repo#12`, `task:<project>#3`. */
29
+ export type Node = string;
30
+ export interface NoteEntry {
31
+ schema: typeof MEMORY_SCHEMA; op: "note"; id: string; at: string; machine: string;
32
+ project: string; scope: Scope; tier: Tier; kind: Kind; peer: string; title?: string; text: string;
33
+ /** The board task it was saved against: `<project>#<id>@<created ms>`, unique across `ahub reset --all`. */
34
+ task?: string;
35
+ about: Node[];
36
+ supersedes?: string; promotedFrom?: string;
37
+ source: "hub" | "person" | "claude-mem";
38
+ /** A short-term note with no task: when it leaves the index, fixed when it was written so every reader agrees. */
39
+ expires?: string;
40
+ }
41
+ export interface OpEntry {
42
+ schema: typeof MEMORY_SCHEMA; op: "forget" | "supersede" | "demote" | "settle" | "goal_state" | "goal_link" | "lease" | "lease_force" | "suggest" | "confirm" | "reject"; id: string; at: string; machine: string;
43
+ /** An entry id, a goal id, or for `settle` a task key. */
44
+ target: string;
45
+ /** `supersede`: the newer entry; `goal_state`: the state; `goal_link`: the task key; `lease`, `lease_force`: until (ISO); `settle`, `demote`: when the short-term entries leave the index (ISO), fixed when written; `suggest`: `<rel>:<entry id>`. */
46
+ by?: string;
47
+ actor: string;
48
+ }
49
+ /** #258 phase 2: a long-term task, a goal that spans sessions, board tasks, projects and machines. */
50
+ export interface GoalEntry {
51
+ schema: typeof MEMORY_SCHEMA; op: "goal"; id: string; at: string; machine: string;
52
+ project: string; scope: Scope; title: string; milestones: string[]; dependsOn: string[]; actor: string;
53
+ }
54
+ export type Entry = NoteEntry | OpEntry | GoalEntry;
55
+ export const GOAL_STATES = ["open", "active", "blocked", "done", "dropped"] as const;
56
+ export type GoalState = (typeof GOAL_STATES)[number];
57
+ export const DEFAULT_LEASE_MS = 4 * 3600_000;
58
+ /** Bumped when the index's tables change: an index with another version is dropped and rebuilt from the logs. */
59
+ const INDEX_VERSION = 3; // 3: suggest, confirm and reject ops (an older index skipped them)
60
+ export const SUGGESTED = ["contradicts", "same_topic"] as const;
61
+ export type Suggested = (typeof SUGGESTED)[number];
62
+ export interface Suggestion { id: string; at: string; from: string; to: string; rel: Suggested; status: "suggested" | "confirmed" | "rejected" }
63
+ export type NoteInput = Omit<NoteEntry, "schema" | "id" | "at" | "machine">;
64
+ export type OpInput = Omit<OpEntry, "schema" | "id" | "at" | "machine">;
65
+ export type GoalInput = Omit<GoalEntry, "schema" | "id" | "at" | "machine">;
66
+
67
+ /** Key order fixed, so the same content always hashes to the same id. */
68
+ function canonical(value: unknown): string {
69
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
70
+ if (value && typeof value === "object") return `{${Object.keys(value).sort().filter((k) => (value as Record<string, unknown>)[k] !== undefined).map((k) => `${JSON.stringify(k)}:${canonical((value as Record<string, unknown>)[k])}`).join(",")}}`;
71
+ return JSON.stringify(value);
72
+ }
73
+ export const entryId = (e: Omit<Entry, "id">): string => createHash("sha256").update(canonical(e)).digest("hex").slice(0, 32);
74
+
75
+ const SECRETS = [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, /\bAKIA[0-9A-Z]{16}\b/, /\bgh[pousr]_[A-Za-z0-9]{30,}/, /\bgithub_pat_[A-Za-z0-9_]{40,}/, /\bsk-[A-Za-z0-9_-]{20,}/,
76
+ /\bxox[abprs]-[A-Za-z0-9-]{10,}/, /\bAIza[0-9A-Za-z_-]{35}\b/, /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/, /\b(?:password|passwd|secret|api[_-]?key|token)\s*[:=]\s*\S{8,}/i];
77
+ /** A secret-looking string: refused before anything is written, since long-term entries leave the machine. */
78
+ export const looksSecret = (text: string): boolean => SECRETS.some((re) => re.test(text));
79
+
80
+ /** `task`: the board task's id and proposal time; its node carries both, so a task id reused after `ahub reset --all` is another node. */
81
+ export interface Extract { cwd: string; project: string; repo: string; deny?: string[]; paths?: string[]; symbols?: string[]; task?: { id: number; created: number }; checkExists?: boolean }
82
+ /**
83
+ * Deterministic edges, fixed at write time so a rebuild never depends on the file system: the task's refs and plan
84
+ * paths, plan symbols (with the path when the plan names exactly one), paths in the text that exist in the project then (or text-only paths when checkExists is false),
85
+ * `#N` as an issue of `repo`, and `task #N` as a board task. Paths stay relative to the project root; denylisted ones
86
+ * are left out.
87
+ */
88
+ export function extractNodes(text: string, x: Extract): Node[] {
89
+ const out = new Set<Node>();
90
+ const path = (p: string): string | undefined => {
91
+ const n = normalize(p.trim()).replace(/^\.\/+/, "").replace(/\/+$/, "");
92
+ if (!n || n === "." || isAbsolute(n) || n.startsWith("..") || isDenied(n, x.deny)) return undefined;
93
+ out.add(`path:${n}`);
94
+ return n;
95
+ };
96
+ const declared = [...new Set((x.paths ?? []).flatMap((p) => path(p) ?? []))];
97
+ const only = declared.length === 1 ? declared[0] : undefined;
98
+ for (const s of x.symbols ?? []) { const name = s.trim().slice(0, 120); if (name) out.add(`symbol:${name}${only && !name.includes("@") ? `@${only}` : ""}`); }
99
+ for (const m of text.matchAll(/(?:^|[\s(`'"])((?:[\w.-]+\/)*[\w.-]+\.[A-Za-z0-9]{1,8})(?=$|[\s)`'",:;])/g)) {
100
+ const p = m[1]!;
101
+ if (!p.includes("/") && !/\.(ts|js|tsx|jsx|py|rs|go|md|json|toml|yaml|yml|sh|html|css)$/.test(p)) continue;
102
+ try { if (!isAbsolute(p) && !p.startsWith("..") && (x.checkExists === false || existsSync(join(x.cwd, p)))) path(p); } catch { /* unreadable: not an edge */ }
103
+ }
104
+ for (const m of text.matchAll(/\btask #(\d{1,9})\b/gi)) out.add(`task:${x.project}#${m[1]}`);
105
+ for (const m of text.matchAll(/(?<![\w/]|task )#(\d{1,9})\b/gi)) out.add(`issue:${x.repo}#${m[1]}`);
106
+ if (x.task !== undefined) out.add(`task:${x.project}#${x.task.id}@${x.task.created}`);
107
+ return [...out].sort().slice(0, MAX_TARGETS);
108
+ }
109
+
110
+ /** Text that is rendered as one line: whitespace runs (newlines included) collapse, and it is capped. */
111
+ const oneLine = (text: string): string => text.replace(/\s+/g, " ").trim().slice(0, MAX_TITLE);
112
+
113
+ /** A board task's key in hub memory: unique across `ahub reset --all`, which restarts the ids. */
114
+ export const taskKey = (project: string, task: { id: number; created: number }): string => `${project}#${task.id}@${task.created}`;
115
+
116
+ export interface Machine { id: string; label: string }
117
+ /** Random per `~/.agenthub`, with a person-given label; created on first use. */
118
+ export function machineId(dir: string): Machine {
119
+ const file = join(dir, "machine.json");
120
+ const read = (): Machine | undefined => {
121
+ try {
122
+ const m = JSON.parse(readFileSync(file, "utf8")) as Machine;
123
+ if (/^[0-9a-f]{16}$/.test(m?.id)) return { id: m.id, label: typeof m.label === "string" ? m.label : m.id };
124
+ } catch { /* none, or unreadable */ }
125
+ return undefined;
126
+ };
127
+ const found = read();
128
+ if (found) return found;
129
+ // Hubs of several projects can start at once on a fresh home: each writes its own temp file and links it into place,
130
+ // which fails if another got there first; then everyone reads the one identity that won.
131
+ const id = randomBytes(8).toString("hex");
132
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
133
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
134
+ writeFileSync(tmp, JSON.stringify({ id, label: id }) + "\n", { mode: 0o600 });
135
+ try { linkSync(tmp, file); } catch { /* another process created it: its identity wins */ } finally { rmSync(tmp, { force: true }); }
136
+ // A file that is there but holds no valid id is never overwritten (another process may be reading it): the store
137
+ // fails to open, so the hub says hub memory is unavailable instead of filing notes under no machine.
138
+ const won = read();
139
+ if (!won) throw new Error(`${file} holds no valid machine id; remove it and a new one is made`);
140
+ return won;
141
+ }
142
+
143
+ /** A person's name for this machine, shown beside its id. */
144
+ export function setMachineLabel(dir: string, label: string): Machine {
145
+ const m = machineId(dir);
146
+ const clean = label.trim().replace(/[^\w .@-]/g, "").slice(0, 40) || m.id;
147
+ writeFileSync(join(dir, "machine.json.tmp"), JSON.stringify({ id: m.id, label: clean }) + "\n", { mode: 0o600 });
148
+ renameSync(join(dir, "machine.json.tmp"), join(dir, "machine.json"));
149
+ return { id: m.id, label: clean };
150
+ }
151
+
152
+ export interface View {
153
+ id: string; at: string; machine: string; project: string; scope: Scope; tier: Tier; kind: Kind; peer: string; title: string | null; text: string;
154
+ task: string | null; about: Node[]; source: string; supersededBy: string | null; supersedes: string[]; promotedFrom: string | null; expiresAt: string | null;
155
+ /** Written as short-term, so it lives in this machine's local segment and never syncs. */
156
+ local: boolean;
157
+ }
158
+ export interface GoalView {
159
+ id: string; at: string; machine: string; project: string; scope: Scope; title: string; milestones: string[]; dependsOn: string[];
160
+ state: GoalState; links: { task: string; at: string; machine: string }[];
161
+ /** The machine holding it now; other active claims are a conflict. Without an active claim: the last one, expired. */
162
+ lease: { holder: string | null; until: string | null; conflict: string[]; expired: boolean; lastHolder?: string | null };
163
+ }
164
+ export interface Filter { project?: string; tier?: Tier; kind?: Kind; peer?: string; machine?: string; since?: string; q?: string; limit?: number; includeSuperseded?: boolean }
165
+ export interface Graph { nodes: { id: Node; type: string; label: string; kind?: string; tier?: Tier }[]; edges: { from: Node; to: Node; rel: string }[] }
166
+
167
+ /**
168
+ * Whether a short-term note may move to the synced long-term log, under the reading project's current policy: `skip` for
169
+ * a note this process cannot judge (another project's), `keep` for one that must stay on this machine.
170
+ */
171
+ export type PromotionScreen = (v: View) => "promote" | "keep" | "skip";
172
+ /**
173
+ * `screen`: applied to every promotion. Without one, automatic promotions (a settlement, a held note whose replacement
174
+ * went) wait for a process that has one; a person's explicit promote goes through.
175
+ */
176
+ export interface StoreOptions { home?: string; ttlMs?: number; now?: () => number; log?: (line: string) => void; screen?: PromotionScreen }
177
+
178
+ export class MemoryStore {
179
+ readonly dir: string;
180
+ readonly machine: Machine;
181
+ private readonly db: Database;
182
+ private readonly ttl: number;
183
+ private readonly now: () => number;
184
+ private readonly log: (line: string) => void;
185
+
186
+ constructor(opts: StoreOptions = {}) {
187
+ this.dir = opts.home === undefined ? memoryHome() : join(opts.home, "memory");
188
+ mkdirSync(join(this.dir, "log"), { recursive: true, mode: 0o700 });
189
+ mkdirSync(join(this.dir, "local"), { recursive: true, mode: 0o700 });
190
+ chmodSync(this.dir, 0o700); // mkdir's mode applies only when it creates the directory
191
+ this.machine = machineId(this.dir);
192
+ this.ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
193
+ this.now = opts.now ?? Date.now;
194
+ this.log = opts.log ?? (() => {});
195
+ this.screen = opts.screen;
196
+ const db = new Database(join(this.dir, "index.db"), { create: true });
197
+ db.run(`PRAGMA busy_timeout = ${MEMORY_DB_BUSY_TIMEOUT_MS}`);
198
+ db.run("PRAGMA journal_mode = WAL");
199
+ if ((db.query("PRAGMA user_version").get() as { user_version: number }).user_version !== INDEX_VERSION) {
200
+ for (const t of ["segments", "heads", "entries", "ops", "edges", "goals"]) db.run(`DROP TABLE IF EXISTS ${t}`);
201
+ db.run(`PRAGMA user_version = ${INDEX_VERSION}`);
202
+ }
203
+ db.run("CREATE TABLE IF NOT EXISTS segments (file TEXT PRIMARY KEY, offset INTEGER NOT NULL)");
204
+ // The hash of each segment's whole indexed prefix: a segment rewritten in place (same size or larger) is caught.
205
+ db.run("CREATE TABLE IF NOT EXISTS heads (file TEXT PRIMARY KEY, hash TEXT NOT NULL)");
206
+ db.run("CREATE TABLE IF NOT EXISTS goals (id TEXT PRIMARY KEY, at TEXT NOT NULL, machine TEXT NOT NULL, project TEXT NOT NULL, scope TEXT NOT NULL, title TEXT NOT NULL, milestones TEXT NOT NULL, depends TEXT NOT NULL, actor TEXT NOT NULL)");
207
+ db.run(`CREATE TABLE IF NOT EXISTS entries (id TEXT PRIMARY KEY, at TEXT NOT NULL, machine TEXT NOT NULL, project TEXT NOT NULL, scope TEXT NOT NULL, tier TEXT NOT NULL,
208
+ kind TEXT NOT NULL, peer TEXT NOT NULL, title TEXT, text TEXT NOT NULL, task TEXT, about TEXT NOT NULL, supersedes TEXT, promoted_from TEXT, source TEXT NOT NULL, expires TEXT)`);
209
+ db.run("CREATE TABLE IF NOT EXISTS ops (id TEXT PRIMARY KEY, op TEXT NOT NULL, at TEXT NOT NULL, machine TEXT NOT NULL, target TEXT NOT NULL, by TEXT, actor TEXT NOT NULL)");
210
+ db.run("CREATE TABLE IF NOT EXISTS edges (src TEXT NOT NULL, dst TEXT NOT NULL, PRIMARY KEY (src, dst))");
211
+ db.run("CREATE INDEX IF NOT EXISTS edges_dst ON edges (dst)");
212
+ this.db = db;
213
+ try { chmodSync(join(this.dir, "index.db"), 0o600); } catch { /* best effort */ }
214
+ this.refresh();
215
+ }
216
+
217
+ close(): void { this.db.close(); }
218
+
219
+ /** When a short-term entry written now leaves the index. */
220
+ private until(): string { return new Date(this.now() + this.ttl).toISOString(); }
221
+
222
+ /** Append one entry to this machine's long-term (synced) or local segment and index it. Returns its id. */
223
+ private append(entry: NoteInput | OpInput | GoalInput, local: boolean, at = new Date(this.now()).toISOString(), machine = this.machine.id): string {
224
+ const capped = entry.op === "note" ? { ...entry, text: entry.text.slice(0, MAX_TEXT), ...(entry.title ? { title: entry.title.slice(0, MAX_TITLE) } : {}) } : entry;
225
+ const body = { schema: MEMORY_SCHEMA, at, machine, ...capped } as Omit<Entry, "id">;
226
+ const id = entryId(body);
227
+ appendFileSync(join(this.dir, local ? "local" : "log", `${this.machine.id}.jsonl`), JSON.stringify({ ...body, id }) + "\n", { mode: 0o600 });
228
+ // The line is written, so the entry is saved: an index that cannot be updated now (a busy database) catches up on the
229
+ // next read, and the caller is never told it failed.
230
+ try { this.refresh(); } catch (e) { this.stale = true; this.log(`hub memory index not updated yet: ${(e as Error).message}`); }
231
+ return id;
232
+ }
233
+
234
+ /** A note: short-term ones stay on this machine, long-term ones go to the synced log. The caller has screened it. */
235
+ // A short-term note on a task that was settled already gets its own deadline: the task's has passed or is close.
236
+ // `ownClock`: a note asked for short-term on purpose expires on its own clock, open task or not, and is never promoted.
237
+ note(n: NoteInput, ownClock = false): string { return this.append(n.tier === "short" && !n.expires && (ownClock || !n.task || this.settled(n.task)) ? { ...n, expires: this.until() } : n, n.tier === "short"); }
238
+
239
+ /**
240
+ * An entry from another store with its own time (`ahub memory import claude-mem`): the same source row always gives
241
+ * the same id, so a second import writes nothing. Returns the id and whether it was new.
242
+ */
243
+ importNote(n: NoteInput, at: string): { id: string; added: boolean } {
244
+ // A fixed machine: the same observation imported on two machines is one entry once they sync.
245
+ const body = { schema: MEMORY_SCHEMA, at, machine: "claude-mem", ...n, text: n.text.slice(0, MAX_TEXT), ...(n.title ? { title: n.title.slice(0, MAX_TITLE) } : {}) } as Omit<Entry, "id">;
246
+ const id = entryId(body);
247
+ // The source tuple also recognizes legacy ids whose nodes depended on the checkout or extractor version.
248
+ const known = this.db.query("SELECT id FROM entries WHERE id = ? OR (source = 'claude-mem' AND project = ? AND at = ? AND text = ?)").get(id, n.project, at, n.text.slice(0, MAX_TEXT)) as { id: string } | null;
249
+ if (known) return { id: known.id, added: false };
250
+ appendFileSync(join(this.dir, n.tier === "short" ? "local" : "log", `${this.machine.id}.jsonl`), JSON.stringify({ ...body, id }) + "\n", { mode: 0o600 });
251
+ return { id, added: true };
252
+ }
253
+
254
+ /** A person's or the hub's op on an entry; it goes where the entry lives, so forgetting a long-term entry syncs. */
255
+ private op(op: OpEntry["op"], target: View, actor: string, by?: string): string {
256
+ return this.append({ op, target: target.id, actor, ...(by ? { by } : {}) }, target.local);
257
+ }
258
+ forget(id: string, actor: string, project?: string): View | undefined {
259
+ const v = this.get(id, project);
260
+ if (!v) return undefined;
261
+ // Legacy imports may have different node-derived ids. Forget every known copy, including hidden ones.
262
+ const group = v.source === "claude-mem" ? this.live().filter(x => x.source === v.source && x.project === v.project && x.at === v.at && x.text === v.text) : [v];
263
+ for (const member of group) this.op("forget", member, actor);
264
+ return v;
265
+ }
266
+
267
+ /** Plan or append reconciliation of legacy imports, preserving every existing replacement relation. */
268
+ dedupe(project: string, apply = false): { groups: { members: View[]; heads: string[]; target: string }[]; applied: number } {
269
+ const rows = this.live(), byId = new Map(rows.map(v => [v.id, v]));
270
+ const tuples = new Map<string, View[]>();
271
+ for (const v of rows) {
272
+ if (v.project !== project || v.source !== "claude-mem") continue;
273
+ const key = JSON.stringify([v.at, v.text]);
274
+ const group = tuples.get(key) ?? []; group.push(v); tuples.set(key, group);
275
+ }
276
+ const groups: { members: View[]; heads: string[]; target: string }[] = [];
277
+ for (const members of tuples.values()) {
278
+ if (members.length < 2) continue;
279
+ members.sort((a, b) => a.id.localeCompare(b.id));
280
+ const heads = members.filter(v => !v.supersededBy);
281
+ if (!heads.length) continue;
282
+ const ids = new Set(members.map(v => v.id));
283
+ const terminals = new Map<string, View>();
284
+ for (const v of members) {
285
+ let terminal = v;
286
+ const seen = new Set<string>();
287
+ while (terminal.supersededBy && !seen.has(terminal.id)) {
288
+ seen.add(terminal.id);
289
+ const next = byId.get(terminal.supersededBy);
290
+ if (!next) break;
291
+ terminal = next;
292
+ }
293
+ if (!terminal.supersededBy && !ids.has(terminal.id) && !terminal.local && (terminal.project === project || terminal.scope === "shared") && heads.every(h => h.scope !== "shared" || terminal.scope === "shared")) terminals.set(terminal.id, terminal);
294
+ }
295
+ const outside = [...terminals.values()].sort((a, b) => b.at.localeCompare(a.at) || b.id.localeCompare(a.id))[0];
296
+ if (!outside && heads.length === 1) continue;
297
+ groups.push({ members, heads: heads.map(v => v.id), target: outside?.id ?? heads[0]!.id });
298
+ }
299
+ let applied = 0;
300
+ if (apply) for (const group of groups) for (const head of group.heads) {
301
+ if (head === group.target) continue;
302
+ if (!this.supersede(head, group.target, "user", project)) throw new Error("memory dedupe replacement refused; rerun to inspect current relations");
303
+ applied++;
304
+ }
305
+ return { groups, applied };
306
+ }
307
+ /** A short-term note kept short-term: it expires like any other and is never promoted (its demote entry stays local). */
308
+ keepShort(id: string, actor: string): void { const v = this.get(id); if (v?.tier === "short" && v.local && v.expiresAt === null) this.op("demote", v, actor, this.until()); } // once: a deadline is never pushed out
309
+ demote(id: string, actor: string, project?: string): View | undefined { const v = this.get(id, project); if (v?.tier === "long") this.op("demote", v, actor, this.until()); return v; }
310
+ supersede(oldId: string, newId: string, actor: string, project?: string): boolean {
311
+ const a = this.get(oldId, project), b = this.get(newId, project);
312
+ if (!a || !b || a.id === b.id) return false;
313
+ // A replacement must reach everyone the old entry reached: never a project entry for a shared one, nor a
314
+ // local (unsynced) entry for a synced one, a demoted long-term entry included.
315
+ if ((a.scope === "shared" && b.scope !== "shared") || (!a.local && b.local)) return false;
316
+ // Never a cycle: if the new entry is already (through others) replaced by the old one, both would vanish from reads.
317
+ const replaced = new Map(this.views().filter((v) => v.supersededBy).map((v) => [v.id, v.supersededBy!]));
318
+ for (let at: string | undefined = b.id, hops = 0; at && hops <= replaced.size; at = replaced.get(at), hops++) if (at === a.id) return false;
319
+ this.op("supersede", a, actor, b.id);
320
+ return true;
321
+ }
322
+ /**
323
+ * A short-term entry's long-term copy; the short one is then hidden. Returns the copy's id, or nothing for a replaced
324
+ * entry: its replacement may live only on this machine, so the copy could reach other machines without it (promote the
325
+ * replacement instead).
326
+ */
327
+ promote(id: string, actor: string, project?: string, auto = false): string | undefined {
328
+ const v = this.get(id, project);
329
+ if (!v || v.tier !== "short") return v?.id;
330
+ if (v.supersededBy) return undefined;
331
+ // Screened here, whatever asked: a note saved under an older policy must not reach the synced log under a newer one.
332
+ const verdict = this.screen ? this.screen(v) : auto ? "skip" : "promote";
333
+ if (verdict === "skip") return undefined;
334
+ if (verdict === "keep") { this.keepShort(v.id, actor); return undefined; }
335
+ // ponytail: the check and the append are not one step across processes: a person forgetting or replacing this entry
336
+ // at the very moment another process promotes it can leave the copy current on other machines. A per-machine lock
337
+ // around local mutations (like sync's) would close it.
338
+ return this.append({ op: "note", project: v.project, scope: v.scope, tier: "long", kind: v.kind, peer: v.peer, ...(v.title ? { title: v.title } : {}), text: v.text,
339
+ ...(v.task ? { task: v.task } : {}), about: v.about, promotedFrom: v.id, source: v.source as NoteEntry["source"] }, false,
340
+ // Dated as its source, from its source's machine and with its fields, whoever promotes and wherever: two processes or
341
+ // two machines promoting the same entry write one id, which the index keeps once.
342
+ v.at, v.machine);
343
+ }
344
+ /**
345
+ * The task was approved: its short-term entries start to expire, and its decision, contract and fail notes move to
346
+ * long-term. Returns how many moved.
347
+ */
348
+ settle(task: string, actor: string): number {
349
+ this.promoting = true; // promoted here, in the settling actor's name, not after each read
350
+ try {
351
+ this.append({ op: "settle", target: task, actor, by: this.until() }, true);
352
+ return this.promoteSettled(task, actor);
353
+ } finally { this.promoting = false; }
354
+ }
355
+ private promoteSettled(task: string, actor: string): number {
356
+ // Only the notes held for promotion (no deadline): one saved short-term after the settlement stays short-term.
357
+ const moving = this.views().filter((v) => v.task === task && v.tier === "short" && v.local && !v.supersededBy && v.expiresAt === null && PROMOTED_KINDS.includes(v.kind));
358
+ return moving.filter((v) => this.promote(v.id, actor, undefined, true) !== undefined).length;
359
+ }
360
+ /** Whether a task was ever settled, for the hub's reconciliation of approvals a crash cut off before their settle entry. */
361
+ settled(task: string): boolean { return !!this.db.query("SELECT 1 FROM ops WHERE op = 'settle' AND target = ?").get(task); }
362
+ /** Whether any entry was saved against the task. */
363
+ hasTask(task: string): boolean { return !!this.db.query("SELECT 1 FROM entries WHERE task = ?").get(task); }
364
+ private promoting = false;
365
+ private readonly screen?: PromotionScreen;
366
+ /**
367
+ * After every read: a settled task's held decision, contract or fail moves to long-term once nothing replaces it (a
368
+ * settlement a crash cut short, or a replacement that was forgotten or expired).
369
+ */
370
+ private promoteHeld(): void {
371
+ if (this.promoting) return;
372
+ this.promoting = true;
373
+ try {
374
+ const settled = new Set((this.db.query("SELECT target FROM ops WHERE op = 'settle'").all() as { target: string }[]).map((r) => r.target));
375
+ if (!settled.size) return;
376
+ for (const v of this.views()) {
377
+ if (v.task && settled.has(v.task) && v.tier === "short" && v.local && !v.supersededBy && v.expiresAt === null && PROMOTED_KINDS.includes(v.kind)) {
378
+ try { this.promote(v.id, "hub", undefined, true); } catch { /* fail-open: tried again at the next read */ }
379
+ }
380
+ }
381
+ } finally { this.promoting = false; }
382
+ }
383
+
384
+ /** Index what the segments gained since the last read. A line still being written (no newline yet) waits for the next read. */
385
+ refresh(attempt = 0): void {
386
+ const files = [...this.segments("log"), ...this.segments("local")];
387
+ const seen = this.shape(files); // taken before reading: a line written meanwhile changes it again
388
+ // One statement, one snapshot: an offset and a head from two different commits would look like a rewrite.
389
+ const read = this.db.query("SELECT s.file, s.offset, h.hash FROM segments s LEFT JOIN heads h ON h.file = s.file").all() as { file: string; offset: number; hash: string | null }[];
390
+ const offsets = new Map(read.map((r) => [r.file, r.offset]));
391
+ const heads = new Map(read.filter((r) => r.hash !== null).map((r) => [r.file, r.hash!]));
392
+ // ponytail: hashes the whole indexed prefix on every read (O(segment size) per write); keep a per-block hash chain if
393
+ // segments grow large enough for that to show.
394
+ const head = (b: Buffer, upTo: number) => createHash("sha256").update(b.subarray(0, upTo)).digest("hex");
395
+ // A segment that shrank or went away (a sync reset) leaves rows no incremental read can remove: rebuild instead.
396
+ let shrunk = [...offsets.keys()].some((rel) => !files.includes(rel)), raced = false;
397
+ for (const rel of shrunk ? [] : files) {
398
+ const abs = join(this.dir, rel);
399
+ let start = offsets.get(rel) ?? 0, text = "", fingerprint: string | undefined;
400
+ try {
401
+ const fd = openSync(abs, "r");
402
+ try {
403
+ // One read of the whole segment: the prefix check, the new lines and the new head all come from the same bytes,
404
+ // so a rewrite between two reads can never index old lines under a new head.
405
+ const all = Buffer.alloc(fstatSync(fd).size);
406
+ const size = readSync(fd, all, 0, all.length, 0);
407
+ // Replaced by a shorter one, or rewritten in place (a sync reset, a history rewrite): rebuilt below, from every segment.
408
+ if (size < start || (start > 0 && heads.has(rel) && head(all, start) !== heads.get(rel))) { shrunk = true; break; }
409
+ text = all.subarray(start, size).toString("utf8");
410
+ const done = start + Buffer.byteLength(text.slice(0, text.lastIndexOf("\n") + 1));
411
+ if (done > 0) fingerprint = head(all, done);
412
+ } finally { closeSync(fd); }
413
+ } catch (e) { this.stale = true; this.log(`memory segment ${rel} unreadable: ${(e as Error).message}`); continue; } // tried again on the next read
414
+ const end = text.lastIndexOf("\n");
415
+ if (end < 0) continue;
416
+ const lines = text.slice(0, end).split("\n");
417
+ this.db.transaction(() => {
418
+ // Another process moved this segment's offset or head since it was read (it indexed the same lines, or a rebuild
419
+ // emptied the index or read a rewritten segment): committing ours would skip lines the index no longer has, or
420
+ // index lines the segment no longer has. Read again from what that process committed.
421
+ const now = this.db.query("SELECT s.offset, h.hash FROM segments s LEFT JOIN heads h ON h.file = s.file WHERE s.file = ?").get(rel) as { offset: number; hash: string | null } | null;
422
+ if (now?.offset !== offsets.get(rel) || (now?.hash ?? undefined) !== heads.get(rel)) { raced = true; return; }
423
+ for (const line of lines) this.apply(line, rel);
424
+ const offset = start + Buffer.byteLength(text.slice(0, end + 1));
425
+ this.db.query("INSERT INTO segments (file, offset) VALUES (?, ?) ON CONFLICT(file) DO UPDATE SET offset = excluded.offset").run(rel, offset);
426
+ if (fingerprint !== undefined) this.db.query("INSERT INTO heads (file, hash) VALUES (?, ?) ON CONFLICT(file) DO UPDATE SET hash = excluded.hash").run(rel, fingerprint);
427
+ }).immediate(); // a writer from the start: a read lock upgraded under another writer fails at once, past busy_timeout
428
+ }
429
+ if (shrunk) return this.rebuild();
430
+ // ponytail: a few retries; a store with more writers racing on one segment would need a lock around the read.
431
+ if (raced && attempt < 5) return this.refresh(attempt + 1);
432
+ this.seen = seen;
433
+ this.prune();
434
+ this.promoteHeld();
435
+ }
436
+ /**
437
+ * Each segment's size, times and inode: what a read compares to know whether anything was written since (ctime and
438
+ * inode change on any rewrite, even one that keeps size and mtime).
439
+ */
440
+ private shape(files = [...this.segments("log"), ...this.segments("local")]): string {
441
+ return files.map((rel) => { try { const st = statSync(join(this.dir, rel)); return `${rel}:${st.size}:${st.mtimeMs}:${st.ctimeMs}:${st.ino}`; } catch { return rel; } }).join("\n");
442
+ }
443
+ private seen = "";
444
+
445
+ /** Drop the index and read every segment again: the same rows as the incremental index. */
446
+ rebuild(): void {
447
+ // One transaction: no reader sees the index empty, and no other process commits an offset in between.
448
+ this.db.transaction(() => { for (const t of ["segments", "heads", "entries", "ops", "edges", "goals"]) this.db.run(`DELETE FROM ${t}`); this.refresh(); }).immediate();
449
+ }
450
+
451
+ private segments(sub: "log" | "local"): string[] {
452
+ try { return readdirSync(join(this.dir, sub)).filter((n) => n.endsWith(".jsonl")).sort().map((n) => `${sub}/${n}`); } catch { return []; }
453
+ }
454
+
455
+ /** One log line. A malformed line, or one whose id does not match its content, is skipped. */
456
+ private apply(line: string, segment: string): void {
457
+ if (!line.trim()) return;
458
+ let e: Entry;
459
+ try { e = JSON.parse(line); } catch { this.log(`memory segment ${segment}: an unreadable line was skipped`); return; }
460
+ if (e?.schema !== MEMORY_SCHEMA || typeof e.id !== "string") return;
461
+ // Every column the index requires, checked before the insert: a malformed synced line is skipped, never fatal.
462
+ const str = (v: unknown, max = 4096) => typeof v === "string" && v.length > 0 && v.length <= max;
463
+ const opt = (v: unknown) => v === undefined || v === null || typeof v === "string";
464
+ // `at` is compared as text (order, ranges, ties): only the UTC form the store writes is accepted.
465
+ const utc = (v: string) => { const t = Date.parse(v); return !Number.isNaN(t) && new Date(t).toISOString() === v; };
466
+ if (!str(e.at, 64) || !utc(e.at) || !str(e.machine, 64)) { this.log(`memory segment ${segment}: a malformed entry was skipped`); return; }
467
+ const { id, ...rest } = e;
468
+ if (entryId(rest) !== id) { this.log(`memory segment ${segment}: an entry whose id does not match its content was skipped`); return; }
469
+ if (e.op === "note") {
470
+ if (!KINDS.includes(e.kind) || typeof e.text !== "string" || !Array.isArray(e.about) || !str(e.project, 400) || !str(e.peer, 64)
471
+ || !opt(e.title) || !opt(e.task) || !opt(e.supersedes) || !opt(e.promotedFrom) || !opt(e.expires) || (typeof e.expires === "string" && Number.isNaN(Date.parse(e.expires))) || !opt(e.source) || !["project", "shared"].includes(e.scope as string) || !["short", "long"].includes(e.tier as string)) { this.log(`memory segment ${segment}: a malformed entry was skipped`); return; }
472
+ this.db.query(`INSERT OR IGNORE INTO entries (id, at, machine, project, scope, tier, kind, peer, title, text, task, about, supersedes, promoted_from, source, expires)
473
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, e.at, e.machine, e.project, e.scope === "shared" ? "shared" : "project", e.tier === "long" ? "long" : "short",
474
+ e.kind, e.peer, e.title ?? null, e.text, e.task ?? null, JSON.stringify(e.about.map(String)), e.supersedes ?? null, e.promotedFrom ?? null, e.source ?? "hub", typeof e.expires === "string" ? e.expires : null);
475
+ for (const node of e.about) this.db.query("INSERT OR IGNORE INTO edges (src, dst) VALUES (?, ?)").run(id, String(node));
476
+ } else if (e.op === "goal") {
477
+ if (!str(e.title, 400) || !Array.isArray(e.milestones) || !Array.isArray(e.dependsOn) || !str(e.project, 400) || !opt(e.actor) || !opt(e.scope)) { this.log(`memory segment ${segment}: a malformed entry was skipped`); return; }
478
+ this.db.query("INSERT OR IGNORE INTO goals (id, at, machine, project, scope, title, milestones, depends, actor) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
479
+ .run(id, e.at, e.machine, e.project, e.scope === "shared" ? "shared" : "project", e.title, JSON.stringify(e.milestones.map(String)), JSON.stringify(e.dependsOn.map(String)), e.actor ?? "");
480
+ } else if (["forget", "supersede", "demote", "settle", "goal_state", "goal_link", "lease", "lease_force", "suggest", "confirm", "reject"].includes(e.op)) {
481
+ // A deadline is a date: a reader's own TTL must never stand in for an unreadable one.
482
+ const dated = !["demote", "settle", "lease", "lease_force"].includes(e.op) || (typeof e.by === "string" && !Number.isNaN(Date.parse(e.by)));
483
+ if (!str(e.target, 400) || !opt(e.by) || !opt(e.actor) || !dated) { this.log(`memory segment ${segment}: a malformed entry was skipped`); return; }
484
+ this.db.query("INSERT OR IGNORE INTO ops (id, op, at, machine, target, by, actor) VALUES (?, ?, ?, ?, ?, ?, ?)").run(id, e.op, e.at, e.machine, e.target, e.by ?? null, e.actor ?? "");
485
+ }
486
+ }
487
+
488
+ /**
489
+ * Every live note with its derived state; an expired one is left out as soon as its deadline passes, before any
490
+ * prune. ponytail: computed in memory over all rows; push the filters into SQL when the store grows.
491
+ */
492
+ private views(expired = false): View[] {
493
+ const now = this.now();
494
+ const rows = this.db.query("SELECT * FROM entries").all() as Record<string, string | null>[];
495
+ const ops = this.db.query("SELECT op, at, target, by FROM ops ORDER BY at, id").all() as { op: string; at: string; target: string; by: string | null }[];
496
+ const forgotten = new Set(ops.filter((o) => o.op === "forget").map((o) => o.target));
497
+ // Deadlines were fixed when the entries were written, so every process (whatever its own TTL) prunes the same rows.
498
+ const deadline = (o: { at: string; by: string | null }) => o.by && !Number.isNaN(Date.parse(o.by)) ? o.by : new Date(Date.parse(o.at) + this.ttl).toISOString();
499
+ const demoted = new Map(ops.filter((o) => o.op === "demote").map((o) => [o.target, deadline(o)]));
500
+ const settled = new Map<string, string>();
501
+ for (const o of ops) if (o.op === "settle" && !settled.has(o.target)) settled.set(o.target, deadline(o));
502
+ const superseded = new Map<string, string>(), supersededAt = new Map<string, string>();
503
+ // Relations from ops and from notes' own `supersedes` fold together by write time (then id): the latest intent wins.
504
+ const relate = (old: string, newer: string, when: string) => { if (!(when < (supersededAt.get(old) ?? ""))) { superseded.set(old, newer); supersededAt.set(old, when); } };
505
+ for (const o of ops) if (o.op === "supersede" && o.by) relate(o.target, o.by, `${o.at} ${o.by}`);
506
+ const promoted = new Set<string>();
507
+ for (const r of rows) { if (r.supersedes) relate(r.supersedes, r.id!, `${r.at} ${r.id}`); if (r.promoted_from) promoted.add(r.promoted_from); }
508
+ const expiry = (r: Record<string, string | null>): string | null => {
509
+ const id = r.id!;
510
+ if ((demoted.has(id) ? "short" : r.tier) !== "short") return null;
511
+ // A decision, contract or fail of a settled task not yet copied to long-term waits for its promotion, never expires
512
+ // first; a note saved short-term after the settlement carries its own deadline and stays short-term.
513
+ // A superseded one is held too: if its replacement goes, it shows again, and a rebuild must find it the same way.
514
+ if (r.tier === "short" && !demoted.has(id) && !r.expires && r.task && settled.has(r.task) && PROMOTED_KINDS.includes(r.kind as Kind)) return null;
515
+ // A deadline written into the note (no task, a late note, an explicit short horizon) is its own, whatever the task does.
516
+ return demoted.get(id) ?? r.expires ?? (r.task ? settled.get(r.task) ?? new Date(Date.parse(r.at!) + UNSETTLED_MS).toISOString() : new Date(Date.parse(r.at!) + this.ttl).toISOString());
517
+ };
518
+ // A newer entry that was forgotten, expired or pruned no longer replaces anything: the older one shows again. A
519
+ // promoted source lives on as its copy.
520
+ const byId = new Map(rows.map((r) => [r.id!, r]));
521
+ const copyOf = new Map(rows.filter((r) => r.promoted_from).map((r) => [r.promoted_from!, r.id!]));
522
+ const gone = (id: string): boolean => {
523
+ const r = byId.get(id);
524
+ if (!r || forgotten.has(id) || (r.promoted_from && forgotten.has(r.promoted_from))) return true;
525
+ const copy = copyOf.get(id);
526
+ if (copy) return gone(copy);
527
+ const e = expiry(r);
528
+ return e !== null && Date.parse(e) <= now;
529
+ };
530
+ // A promoted entry lives on as its copy, on either side of a relation: a promoted replacement still replaces, and a
531
+ // promoted entry that was replaced stays replaced.
532
+ const last = (id: string) => { let to = id; for (let c = copyOf.get(to); c; c = copyOf.get(to)) to = c; return to; };
533
+ for (const [old, newer] of [...superseded]) {
534
+ superseded.set(old, last(newer));
535
+ const copy = last(old);
536
+ if (copy !== old && !superseded.has(copy)) { superseded.set(copy, last(newer)); supersededAt.set(copy, supersededAt.get(old)!); }
537
+ }
538
+ for (const [old, newer] of superseded) if (gone(newer)) superseded.delete(old);
539
+ // Supersessions made apart (two processes, two machines) can close a cycle that would hide every entry in it: the
540
+ // earliest relation in the cycle gives way, so the latest intent stands, the same in every process. Each entry has
541
+ // one replacement, so a chain holds at most one cycle.
542
+ const when = (id: string) => `${supersededAt.get(id) ?? ""} ${id}`; // write time, then ids: the same in every process
543
+ for (const start of [...superseded.keys()]) {
544
+ const seen: string[] = [];
545
+ for (let at: string | undefined = start; at !== undefined && superseded.has(at); at = superseded.get(at)) {
546
+ const i = seen.indexOf(at);
547
+ if (i >= 0) { superseded.delete(seen.slice(i).reduce((x, y) => (when(y) < when(x) ? y : x))); break; }
548
+ seen.push(at);
549
+ }
550
+ }
551
+ const supersedes = new Map<string, string[]>(); // the reverse: what each newer entry replaced
552
+ for (const [old, newer] of superseded) supersedes.set(newer, [...(supersedes.get(newer) ?? []), old]);
553
+ const out: View[] = [];
554
+ for (const r of rows) {
555
+ const id = r.id!;
556
+ // A forgotten entry is gone, and so is its promoted copy's source or copy: forgetting either forgets both.
557
+ if (forgotten.has(id) || (r.promoted_from && forgotten.has(r.promoted_from)) || promoted.has(id)) continue;
558
+ const tier: Tier = demoted.has(id) ? "short" : r.tier as Tier;
559
+ const expiresAt = expiry(r);
560
+ if (!expired && expiresAt !== null && Date.parse(expiresAt) <= now) continue;
561
+ out.push({ id, at: r.at!, machine: r.machine!, project: r.project!, scope: r.scope as Scope, tier, kind: r.kind as Kind, peer: r.peer!, title: r.title ?? null, text: r.text!,
562
+ task: r.task ?? null, about: JSON.parse(r.about!), source: r.source!, supersededBy: superseded.get(id) ?? null, supersedes: supersedes.get(id) ?? [], promotedFrom: r.promoted_from ?? null, expiresAt, local: r.tier === "short" });
563
+ }
564
+ return out;
565
+ }
566
+
567
+ /**
568
+ * Short-term entries whose time is up leave the index (their log lines stay on this machine); a rebuild prunes again.
569
+ * ponytail: pruning trusts the wall clock; a clock set back past a deadline lets a rebuild show the entry again until
570
+ * the clock passes it once more. A durable prune entry in the log would close that gap.
571
+ */
572
+ prune(): number {
573
+ const now = this.now();
574
+ const expired = this.views(true).filter((v) => v.expiresAt !== null && Date.parse(v.expiresAt) <= now);
575
+ // A copy that expires (it was demoted) takes its whole hidden promotion ancestry with it, or an ancestor would reappear.
576
+ const parent = new Map((this.db.query("SELECT id, promoted_from FROM entries WHERE promoted_from IS NOT NULL").all() as { id: string; promoted_from: string }[]).map((r) => [r.id, r.promoted_from]));
577
+ const gone = new Set<string>();
578
+ for (const v of expired) for (let id: string | undefined = v.id; id && !gone.has(id); id = parent.get(id)) gone.add(id);
579
+ if (!gone.size) return 0;
580
+ this.db.transaction(() => { for (const id of gone) { this.db.query("DELETE FROM entries WHERE id = ?").run(id); this.db.query("DELETE FROM edges WHERE src = ?").run(id); } }).immediate();
581
+ return gone.size;
582
+ }
583
+
584
+ /** Index deferred writes before any note, goal or suggestion read. */
585
+ private ensureFresh(): void {
586
+ // Another process (a CLI, another project's hub, a sync) may have written since this one last read the segments.
587
+ if (this.stale || this.shape() !== this.seen) try { this.stale = false; this.refresh(); } catch { this.stale = true; /* still busy: the next read tries again */ }
588
+ }
589
+
590
+ /**
591
+ * What a read sees: rows that expired and promotions that became due while the store sat idle are settled first, so a
592
+ * read, and the index after it, never differ from a rebuild.
593
+ */
594
+ private live(): View[] {
595
+ this.ensureFresh();
596
+ this.prune(); this.promoteHeld(); return this.views();
597
+ }
598
+ /** A write whose index update failed: the next read refreshes first. */
599
+ private stale = false;
600
+
601
+ /** Entries visible from `project` (its own, and `shared` ones of any project), newest first, filtered. */
602
+ list(f: Filter = {}): View[] {
603
+ const q = f.q?.toLowerCase().split(/\s+/).filter(Boolean) ?? [];
604
+ return this.live().filter((v) => (!f.project || v.project === f.project || v.scope === "shared") && (!f.tier || v.tier === f.tier) && (!f.kind || v.kind === f.kind)
605
+ && (!f.peer || v.peer === f.peer) && (!f.machine || v.machine === f.machine) && (!f.since || Date.parse(v.at) >= Date.parse(f.since)) && (f.includeSuperseded || !v.supersededBy)
606
+ && q.every((w) => `${v.title ?? ""}\n${v.text}\n${v.about.join(" ")}`.toLowerCase().includes(w)))
607
+ .sort((a, b) => b.at.localeCompare(a.at) || a.id.localeCompare(b.id)).slice(0, f.limit ?? 200);
608
+ }
609
+
610
+ /** One entry by id or unique id prefix; with `project`, only one that project may read (its own or shared). */
611
+ get(id: string, project?: string): View | undefined {
612
+ id = id.replace(/^mem:/, ""); // briefs and recall print `mem:<id>`
613
+ const all = this.live().filter((v) => !project || v.project === project || v.scope === "shared");
614
+ const pick = <T extends { id: string }>(rows: T[]) => rows.find((v) => v.id === id) ?? (id.length >= 6 ? (() => { const m = rows.filter((v) => v.id.startsWith(id)); return m.length === 1 ? m[0] : undefined; })() : undefined);
615
+ const found = pick(all);
616
+ if (found) return found;
617
+ // A short-term id an agent kept after its task was approved names the promoted copy now, here or on another machine
618
+ // that has only the synced copy (which names its source).
619
+ let at = pick(this.db.query("SELECT id FROM entries UNION SELECT promoted_from FROM entries WHERE promoted_from IS NOT NULL").all() as { id: string }[])?.id;
620
+ for (let copy = at; copy; copy = (this.db.query("SELECT id FROM entries WHERE promoted_from = ?").get(copy) as { id: string } | null)?.id) at = copy;
621
+ return at ? all.find((v) => v.id === at) : undefined;
622
+ }
623
+
624
+ /**
625
+ * Entries one or two hops from the given nodes (hop one: about one of them; hop two: about a node a hop-one entry is
626
+ * about), visible from `project`. Fails first, then nearer, then newer; superseded entries are dropped.
627
+ */
628
+ related(project: string, nodes: Node[], limit: number): (View & { hops: 1 | 2 })[] {
629
+ if (!nodes.length) return [];
630
+ // A task that is part of a long-term task brings the goal's other tasks along, so its decisions and fails are near.
631
+ const taskNode = (key: string) => `task:${key}`; // a link's task key is the task node's own (id and proposal time)
632
+ const goals = this.goals(project).filter((g) => g.links.some((l) => nodes.includes(taskNode(l.task))));
633
+ nodes = [...new Set([...nodes, ...goals.flatMap((g) => g.links.map((l) => taskNode(l.task)))])];
634
+ const live = new Map(this.list({ project, limit: Number.MAX_SAFE_INTEGER }).map((v) => [v.id, v]));
635
+ const near = new Map<string, 1 | 2>();
636
+ const about = (ns: Iterable<Node>) => {
637
+ const ids = new Set<string>();
638
+ for (const n of ns) for (const r of this.db.query("SELECT src FROM edges WHERE dst = ?").all(n) as { src: string }[]) if (live.has(r.src)) ids.add(r.src);
639
+ return ids;
640
+ };
641
+ const first = about(nodes);
642
+ for (const id of first) near.set(id, 1);
643
+ for (const id of about(new Set([...first].flatMap((id) => live.get(id)!.about)))) if (!near.has(id)) near.set(id, 2);
644
+ // Only a person-confirmed suggestion links two entries for retrieval; an unconfirmed one is never evidence.
645
+ for (const s of this.suggestions().filter((x) => x.status === "confirmed")) {
646
+ if (first.has(s.from) && live.has(s.to) && !near.has(s.to)) near.set(s.to, 2);
647
+ if (first.has(s.to) && live.has(s.from) && !near.has(s.from)) near.set(s.from, 2);
648
+ }
649
+ return [...near].map(([id, hops]) => ({ ...live.get(id)!, hops }))
650
+ .sort((a, b) => Number(b.kind === "fail") - Number(a.kind === "fail") || a.hops - b.hops || b.at.localeCompare(a.at)).slice(0, limit);
651
+ }
652
+
653
+ /** The neighbourhood of one node for the dashboard: up to `hops` (1 or 2) steps, at most `cap` nodes. */
654
+ graph(project: string, start: Node, hops = 1, cap = 200): Graph {
655
+ const live = new Map(this.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true }).map((v) => [`mem:${v.id}`, v]));
656
+ const nodes = new Map<Node, Graph["nodes"][number]>(), edges = new Map<string, Graph["edges"][number]>();
657
+ const goals = new Map(this.goals(project).map((g) => [`goal:${g.id}`, g]));
658
+ const suggestions = this.suggestions().filter((x) => x.status !== "rejected");
659
+ const taskNode = (key: string) => `task:${key}`; // a link's task key is the task node's own (id and proposal time)
660
+ const node = (id: Node) => {
661
+ if (nodes.has(id) || nodes.size >= cap) return nodes.has(id);
662
+ const v = live.get(id), g = goals.get(id);
663
+ nodes.set(id, v ? { id, type: "memory", label: (v.title ?? v.text).slice(0, 60), kind: v.kind, tier: v.tier } : g ? { id, type: "goal", label: g.title.slice(0, 60), kind: g.state } : { id, type: id.slice(0, id.indexOf(":")), label: id.slice(id.indexOf(":") + 1) });
664
+ return true;
665
+ };
666
+ const link = (from: Node, to: Node, rel: string) => { if (node(from) && node(to)) edges.set(`${from}\0${to}\0${rel}`, { from, to, rel }); };
667
+ /** A memory node's own edges: what it is about, who wrote it, where, and what it supersedes. */
668
+ const expand = (id: Node): Node[] => {
669
+ const v = live.get(id);
670
+ if (v) {
671
+ const out: Node[] = [];
672
+ for (const n of v.about) { link(id, n, v.kind === "fail" ? "rules_out" : "about"); out.push(n); }
673
+ for (const s of suggestions) {
674
+ const other = s.from === v.id ? s.to : s.to === v.id ? s.from : undefined;
675
+ if (!other || !live.has(`mem:${other}`)) continue;
676
+ link(`mem:${s.from}`, `mem:${s.to}`, s.status === "confirmed" ? s.rel : `suggested ${s.rel}`); out.push(`mem:${other}`);
677
+ }
678
+ link(id, `peer:${v.peer}`, "by"); link(id, `machine:${v.machine}`, "by"); link(id, `project:${v.project}`, "in");
679
+ out.push(`peer:${v.peer}`, `machine:${v.machine}`, `project:${v.project}`); // a second hop reaches their other entries
680
+ if (v.supersededBy && live.has(`mem:${v.supersededBy}`)) { link(`mem:${v.supersededBy}`, id, "supersedes"); out.push(`mem:${v.supersededBy}`); }
681
+ for (const old of v.supersedes) if (live.has(`mem:${old}`)) { link(id, `mem:${old}`, "supersedes"); out.push(`mem:${old}`); }
682
+ return out;
683
+ }
684
+ const g = goals.get(id);
685
+ if (g) {
686
+ const out = g.links.map((l) => taskNode(l.task));
687
+ for (const t of out) link(t, id, "part_of");
688
+ for (const d of g.dependsOn) if (goals.has(`goal:${d}`)) { link(id, `goal:${d}`, "depends_on"); out.push(`goal:${d}`); }
689
+ return out;
690
+ }
691
+ const parts = id.startsWith("task:") ? [...goals.values()].filter((x) => x.links.some((l) => taskNode(l.task) === id)).map((x) => `goal:${x.id}`) : [];
692
+ for (const p of parts) link(id, p, "part_of");
693
+ // Provenance nodes keep their own relation: who wrote it and on which machine (`by`), in which project (`in`).
694
+ const field = id.startsWith("peer:") ? (x: View) => `peer:${x.peer}` : id.startsWith("machine:") ? (x: View) => `machine:${x.machine}` : id.startsWith("project:") ? (x: View) => `project:${x.project}` : undefined;
695
+ if (field) {
696
+ const ids = [...live.values()].filter((x) => field(x) === id).map((x) => `mem:${x.id}`);
697
+ for (const m of ids) link(m, id, id.startsWith("project:") ? "in" : "by");
698
+ return ids;
699
+ }
700
+ const ids = (this.db.query("SELECT src FROM edges WHERE dst = ?").all(id) as { src: string }[]).map((r) => `mem:${r.src}`).filter((m) => live.has(m));
701
+ for (const m of ids) { const v2 = live.get(m)!; link(m, id, v2.kind === "fail" ? "rules_out" : "about"); }
702
+ return [...ids, ...parts];
703
+ };
704
+ node(start);
705
+ let frontier: Node[] = [start];
706
+ for (let h = 0; h < Math.min(2, Math.max(1, hops)); h++) frontier = [...new Set(frontier.flatMap(expand))].filter((n) => n !== start);
707
+ return { nodes: [...nodes.values()], edges: [...edges.values()] };
708
+ }
709
+
710
+ /** A new long-term task; it lives in the synced log. Returns its id. */
711
+ goal(g: Omit<GoalInput, "op">): string { return this.append({ op: "goal", ...g, title: oneLine(g.title), milestones: g.milestones.map(oneLine).slice(0, 32) }, false); }
712
+ goalState(id: string, state: GoalState, actor: string): void { this.append({ op: "goal_state", target: id, by: state, actor }, false); }
713
+ /** A board task (`<project>#<id>@<created>`) becomes part of the goal. */
714
+ goalLink(id: string, task: string, actor: string): void { if (!this.goals().find((g) => g.id === id)?.links.some((l) => l.task === task)) this.append({ op: "goal_link", target: id, by: task, actor }, false); }
715
+
716
+ /**
717
+ * Claim the goal's lease for this machine until now + `ms`. Refused while another machine's lease is active, unless
718
+ * `force` (a person's takeover), which voids every earlier claim. ponytail: advisory, eventually consistent through
719
+ * sync, no consensus: two claims inside one sync window both stand and show as a conflict, the earlier one holding
720
+ * until a person settles it; a broker would make it a real lock if concurrent cross-machine work needs one.
721
+ */
722
+ take(id: string, actor: string, opts: { force?: boolean; ms?: number } = {}): { ok: true; until: string } | { ok: false; holder: string; until: string } {
723
+ const g = this.goals().find((x) => x.id === id);
724
+ if (!g) throw new Error(`no long-term task ${id}`);
725
+ if (!opts.force && g.lease.holder && g.lease.holder !== this.machine.id) return { ok: false, holder: g.lease.holder, until: g.lease.until! };
726
+ const until = new Date(this.now() + (opts.ms ?? DEFAULT_LEASE_MS)).toISOString();
727
+ this.append({ op: opts.force ? "lease_force" : "lease", target: g.id, by: until, actor }, false);
728
+ return { ok: true, until };
729
+ }
730
+
731
+ /** Every long-term task with its derived state, links and lease, newest first; `project` keeps its own and shared ones. */
732
+ goals(project?: string): GoalView[] {
733
+ this.ensureFresh();
734
+ const now = this.now();
735
+ const rows = this.db.query("SELECT * FROM goals").all() as { id: string; at: string; machine: string; project: string; scope: string; title: string; milestones: string; depends: string }[];
736
+ const ops = this.db.query("SELECT op, at, machine, target, by FROM ops WHERE op IN ('goal_state', 'goal_link', 'lease', 'lease_force') ORDER BY at, id").all() as { op: string; at: string; machine: string; target: string; by: string | null }[];
737
+ return rows.filter((r) => !project || r.project === project || r.scope === "shared").map((r): GoalView => {
738
+ const mine = ops.filter((o) => o.target === r.id);
739
+ const states = mine.filter((o) => o.op === "goal_state" && (GOAL_STATES as readonly string[]).includes(o.by ?? ""));
740
+ const links = mine.filter((o) => o.op === "goal_link" && o.by).map((o) => ({ task: o.by!, at: o.at, machine: o.machine }));
741
+ // Claims after the last forced one, latest per machine; active ones hold.
742
+ const claims = mine.filter((o) => o.op === "lease" || o.op === "lease_force");
743
+ const fresh = claims.slice(Math.max(0, claims.map((o) => o.op).lastIndexOf("lease_force")));
744
+ // Precedence goes by when each machine's current lease began: a claim made after its own previous one expired
745
+ // starts a new lease, so an old, lapsed claim never puts a machine ahead of one that took the task over since.
746
+ const first = new Map<string, string>(), latest = new Map<string, string>();
747
+ for (const o of fresh) {
748
+ const until = latest.get(o.machine);
749
+ if (until === undefined || Date.parse(until) <= Date.parse(o.at)) first.set(o.machine, o.at);
750
+ latest.set(o.machine, o.by ?? o.at);
751
+ }
752
+ const active = [...latest].filter(([, until]) => Date.parse(until) > now).sort((a, b) => first.get(a[0])!.localeCompare(first.get(b[0])!) || a[0].localeCompare(b[0]));
753
+ const last = fresh.at(-1);
754
+ // One line whatever another machine wrote: titles are rendered into agents' context.
755
+ return { id: r.id, at: r.at, machine: r.machine, project: r.project, scope: r.scope as Scope, title: oneLine(r.title), milestones: (JSON.parse(r.milestones) as string[]).map(oneLine), dependsOn: JSON.parse(r.depends),
756
+ state: (states.at(-1)?.by as GoalState | undefined) ?? "open", links,
757
+ lease: active.length ? { holder: active[0]![0], until: active[0]![1], conflict: active.slice(1).map(([m]) => m), expired: false }
758
+ : { holder: null, until: last?.by ?? null, conflict: [], expired: !!last, lastHolder: last?.machine ?? null } };
759
+ }).sort((a, b) => b.at.localeCompare(a.at));
760
+ }
761
+ goalOf(id: string, project?: string): GoalView | undefined {
762
+ const all = this.goals(project);
763
+ return all.find((g) => g.id === id) ?? (id.length >= 6 ? (() => { const m = all.filter((g) => g.id.startsWith(id)); return m.length === 1 ? m[0] : undefined; })() : undefined);
764
+ }
765
+ /** The goals a board task is part of. */
766
+ goalsOfTask(task: string): GoalView[] { return this.goals().filter((g) => g.links.some((l) => l.task === task)); }
767
+
768
+ /**
769
+ * A model-suggested edge between two entries (#258 phase 3): stored as the hub model's suggestion, drawn dashed, and
770
+ * never evidence (briefs, recall, `ahub ask`) until a person confirms it.
771
+ */
772
+ suggest(from: string, to: string, rel: Suggested): string | undefined {
773
+ if (from === to || !SUGGESTED.includes(rel) || this.suggestions().some((x) => x.from === from && x.to === to && x.rel === rel)) return undefined;
774
+ return this.append({ op: "suggest", target: from, by: `${rel}:${to}`, actor: "hub-model" }, false);
775
+ }
776
+ /** A person's verdict on a suggestion; with `project`, only one whose two entries that project may read. */
777
+ judge(id: string, verdict: "confirm" | "reject", actor: string, project?: string): Suggestion | undefined {
778
+ const s = this.suggestions(project).find((x) => x.id === id || (id.length >= 6 && x.id.startsWith(id)));
779
+ if (s) this.append({ op: verdict, target: s.id, actor }, false);
780
+ return s;
781
+ }
782
+ suggestions(project?: string): Suggestion[] {
783
+ this.ensureFresh();
784
+ const visible = project ? new Set(this.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true }).map((v) => v.id)) : undefined;
785
+ const ops = this.db.query("SELECT id, op, at, target, by FROM ops WHERE op IN ('suggest', 'confirm', 'reject') ORDER BY at, id").all() as { id: string; op: string; at: string; target: string; by: string | null }[];
786
+ const verdict = new Map<string, "confirmed" | "rejected">();
787
+ for (const o of ops) if (o.op !== "suggest") verdict.set(o.target, o.op === "confirm" ? "confirmed" : "rejected");
788
+ return ops.filter((o) => o.op === "suggest" && o.by).flatMap((o) => {
789
+ const [rel, to] = [o.by!.slice(0, o.by!.indexOf(":")), o.by!.slice(o.by!.indexOf(":") + 1)];
790
+ if (visible && (!visible.has(o.target) || !visible.has(to))) return [];
791
+ return SUGGESTED.includes(rel as Suggested) ? [{ id: o.id, at: o.at, from: o.target, to, rel: rel as Suggested, status: verdict.get(o.id) ?? "suggested" as const }] : [];
792
+ });
793
+ }
794
+
795
+ /** What the segments hold, for `ahub memory` and the dashboard header. */
796
+ counts(project?: string): { short: number; long: number; segments: number; machine: Machine } {
797
+ const all = this.list({ ...(project ? { project } : {}), limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true });
798
+ return { short: all.filter((v) => v.tier === "short").length, long: all.filter((v) => v.tier === "long").length, segments: this.segments("log").length + this.segments("local").length, machine: this.machine };
799
+ }
800
+
801
+ /** The index's rows in a fixed order: what a rebuild must reproduce. */
802
+ dump(): string {
803
+ return JSON.stringify(["entries", "ops", "edges", "goals"].map((t) => this.db.query(`SELECT * FROM ${t} ORDER BY 1, 2`).all()));
804
+ }
805
+ }