@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
@@ -1,18 +1,22 @@
1
1
  import { Database } from "bun:sqlite";
2
- import { createHash, randomBytes } from "node:crypto";
3
- import { appendFileSync, chmodSync, closeSync, constants, existsSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { appendFileSync, chmodSync, closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
4
4
  import { isAbsolute, join, normalize } from "node:path";
5
- import { isDenied } from "./platform.ts";
5
+ import { isDenied, realPath } from "./platform.ts";
6
+ import { stripPrivateDeep, stripPrivateSpans } from "./private-spans.ts";
6
7
  import { looksSecret } from "./screen.ts";
7
8
  import { mutationDatabase } from "./mutation.ts";
8
9
  import { memoryHome } from "./home.ts";
10
+ import { machineId, type Machine } from "./machine.ts";
11
+ import { assertProjectActive } from "./lifecycle.ts";
12
+ export { machineId, type Machine } from "./machine.ts";
9
13
  import { readWindowSql, readRef, type ReadWindow, type ReadRef, SearchIndex, captureSearch } from "./search-fts.ts";
10
- import { assertCuratedShardPaths, ensureShardDescriptor, readShardDescriptor, readShardedSelection, resolveAlias, resolveShardProfile, shardDir, shardKeyHash, shardMachineDir, userShardDir } from "./shard.ts";
14
+ import { assertCuratedShardPaths, ensureShardDescriptor, readShardDescriptor, readShardedSelection, aliasedKeys, resolveAlias, resolveShardProfile, shardDir, shardKeyHash, shardMachineDir, userShardDir } from "./shard.ts";
11
15
  import { readCaptureConfig, type CaptureRow } from "./capture.ts";
12
16
  import { assertSharedV1Op } from "./ops-contract.ts";
13
- import { generationDescriptor, generationDir, fsyncDirectory, canonicalJSON, hashJSON, readProfileJSON, resolveProfile, writeProfileJSON, type BoundProfile, type ProfileInput, validateVaultLinkRow, vaultLinkId, type VaultLink, type VaultLinkRow } from "./generation.ts";
17
+ import { generationDescriptor, generationDir, fsyncDirectory, canonicalJSON, hashJSON, readProfileJSON, resolveProfile, writeProfileJSON, readSelector, type BoundProfile, type ProfileInput, validateVaultLinkRow, vaultLinkId, type VaultLink, type VaultLinkRow } from "./generation.ts";
14
18
  import { GenerationIndex, explicitExpirySnapshot, GENERATION_TABLES } from "./generation-index.ts";
15
- import { legacyEntries, type LegacyCheckpoint } from "./bridge.ts";
19
+ import { legacyEntries, readLegacySnapshot, SegmentReader, type LegacyCheckpoint } from "./bridge.ts";
16
20
  import { readMigrationSources } from "./migrate.ts";
17
21
  import type { PruneStorePort } from "./prune.ts";
18
22
  import { isDeepStrictEqual } from "node:util";
@@ -124,33 +128,6 @@ const oneLine = (text: string): string => text.replace(/\s+/g, " ").trim().slice
124
128
  /** A board task's key in hub memory: unique across `ahub reset --all`, which restarts the ids. */
125
129
  export const taskKey = (project: string, task: { id: number; created: number }): string => `${project}#${task.id}@${task.created}`;
126
130
 
127
- export interface Machine { id: string; label: string }
128
- /** Random per `~/.agenthub`, with a person-given label; created on first use. */
129
- export function machineId(dir: string): Machine {
130
- const file = join(dir, "machine.json");
131
- const read = (): Machine | undefined => {
132
- try {
133
- const m = JSON.parse(readFileSync(file, "utf8")) as Machine;
134
- if (/^[0-9a-f]{16}$/.test(m?.id)) return { id: m.id, label: typeof m.label === "string" ? m.label : m.id };
135
- } catch { /* none, or unreadable */ }
136
- return undefined;
137
- };
138
- const found = read();
139
- if (found) return found;
140
- // Hubs of several projects can start at once on a fresh home: each writes its own temp file and links it into place,
141
- // which fails if another got there first; then everyone reads the one identity that won.
142
- const id = randomBytes(8).toString("hex");
143
- mkdirSync(dir, { recursive: true, mode: 0o700 });
144
- const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
145
- writeFileSync(tmp, JSON.stringify({ id, label: id }) + "\n", { mode: 0o600 });
146
- try { linkSync(tmp, file); } catch { /* another process created it: its identity wins */ } finally { rmSync(tmp, { force: true }); }
147
- // A file that is there but holds no valid id is never overwritten (another process may be reading it): the store
148
- // fails to open, so the hub says hub memory is unavailable instead of filing notes under no machine.
149
- const won = read();
150
- if (!won) throw new Error(`${file} holds no valid machine id; remove it and a new one is made`);
151
- return won;
152
- }
153
-
154
131
  /** A person's name for this machine, shown beside its id. */
155
132
  export function setMachineLabel(dir: string, label: string): Machine {
156
133
  const m = machineId(dir);
@@ -167,6 +144,20 @@ export interface View {
167
144
  local: boolean;
168
145
  }
169
146
  export interface VaultEntry { view: View; /** Full authored hash in generation profiles; empty for read-only shared-v1. */ rowHash: string }
147
+ export interface RecallStateOptions { person?: boolean; asOf?: string; history?: boolean; maxRows?: number; maxBytes?: number }
148
+ export interface RecallRelation { from: string; to: string; at: string; kind: "supersede" | "promote" }
149
+ export interface RetainedRecallState {
150
+ at: string; views: View[]; forgotten: { id: string; at: string }[]; relations: RecallRelation[];
151
+ malformed: number; incomplete: boolean; notices: string[];
152
+ /** Present only when retained source diagnostics independently make the result incomplete. */
153
+ sourceIncomplete?: true;
154
+ }
155
+ interface RecallViewProjection {
156
+ now: number; rows: Record<string, string | null>[];
157
+ ops: { id: string; op: string; at: string; target: string; by: string | null }[];
158
+ realms?: Map<string, { id: string; own: number; legacy: number }>;
159
+ pruned: Set<string>; forgotten: Set<string>; includePromoted: boolean;
160
+ }
170
161
  export interface GoalView {
171
162
  id: string; at: string; machine: string; project: string; scope: Scope; title: string; milestones: string[]; dependsOn: string[];
172
163
  state: GoalState; links: { task: string; at: string; machine: string }[];
@@ -194,6 +185,8 @@ export type PromotionScreen = (v: View) => "promote" | "keep" | "skip";
194
185
  * went) wait for a process that has one; a person's explicit promote goes through.
195
186
  */
196
187
  export interface StoreOptions { home?: string; memoryDir?: string; ttlMs?: number; now?: () => number; log?: (line: string) => void; screen?: PromotionScreen; deferPromotions?: boolean; profile?: ProfileInput;
188
+ /** #15 T5: `openShardStore` refuses a project the person archived or forgot unless the caller says `ignore` (the catalog, `--include-archived` reads and export do, on purpose). */
189
+ lifecycle?: "enforce" | "ignore";
197
190
  /** #27: read the `aliases` keys' curated rows as part of the `canonical` project (CLI and MCP only); the library default reads the raw key. Rows are presented under `canonical`. */
198
191
  reconcile?: { canonical: string; aliases: readonly string[] };
199
192
  }
@@ -292,6 +285,11 @@ export class MemoryStore {
292
285
  return this.generationIndex.port(this.machine.id);
293
286
  }
294
287
 
288
+ /** Borrowed sync targets: the opened canonical shard plus its user overlay, never person-local aliases. Do not close these handles. */
289
+ openedSyncTargets(): readonly MemoryStore[] {
290
+ return [this, ...(this.userOverlay ? [this.userOverlay] : [])];
291
+ }
292
+
295
293
  /** Read-only project views, bounded before bodies or hashes; never promotes or authors a prune. */
296
294
  vaultEntries(project: string): VaultEntry[] {
297
295
  if (!project.trim() || this.profile.sharded && !this.profile.shardUser && this.profile.shardKey !== project) throw new Error("vault project does not match the bound store");
@@ -361,15 +359,38 @@ export class MemoryStore {
361
359
 
362
360
  /** A synchronous host-authorized mutation whose reads must not implicitly promote unrelated notes. */
363
361
  withDeferredPromotions<T>(work: () => T): T {
364
- const previous = this.promoting;
365
- this.promoting = true;
366
- try { return work(); } finally { this.promoting = previous; }
362
+ const previous = this.readStores().map(store => [store, store.promoting] as const);
363
+ for (const [store] of previous) store.promoting = true;
364
+ try { return work(); } finally { for (const [store, promoting] of previous) store.promoting = promoting; }
367
365
  }
368
366
 
369
367
  private withMutation<T>(work: () => T): T {
370
368
  return this.mutationDb.transaction(work).immediate();
371
369
  }
372
370
 
371
+ /** Synchronous exact-target work under every opened shard's existing writer fence. No read-time promotions. */
372
+ withCurrentEntries<T>(ids: readonly string[], project: string, work: (entries: readonly View[]) => T): T {
373
+ if (!ids.length || ids.length > MAX_TARGETS || new Set(ids).size !== ids.length || ids.some(id => !/^[a-f0-9]{32}$/.test(id)) || !project.trim()) throw new Error("invalid exact-entry batch");
374
+ return this.withStoreFences(stores => {
375
+ assertProjectActive(this.profile.home, project);
376
+ for (const store of stores) store.ensureFresh();
377
+ const entries = ids.map(id => {
378
+ const view = this.get(id, project);
379
+ if (!view || view.id !== id || view.supersededBy) throw new Error("exact entry changed or unavailable");
380
+ return view;
381
+ });
382
+ return work(entries);
383
+ });
384
+ }
385
+ private withStoreFences<T>(work: (stores: readonly MemoryStore[]) => T): T {
386
+ const unique = new Map<string, MemoryStore>();
387
+ for (const store of this.readStores()) { const dir = realPath(store.dir); if (!unique.has(dir)) unique.set(dir, store); }
388
+ const stores = [...unique.keys()].sort().map(dir => unique.get(dir)!);
389
+ const lock = (index: number): T => index === stores.length ? work(stores) : stores[index]!.withMutation(() => lock(index + 1));
390
+ // Older/raw writers that bypass mutation-lock.db retain the existing mutation.ts ceiling.
391
+ return this.withDeferredPromotions(() => lock(0));
392
+ }
393
+
373
394
  /** Serialize current-store writers, screen one normalized body, and expose a one-shot append of that body. */
374
395
  withAcceptedNote<T>(note: NoteInput, work: (append: () => string) => T): T {
375
396
  if (this.userOverlay && note.scope === "shared") return this.userOverlay.withAcceptedNote(note, work);
@@ -377,8 +398,8 @@ export class MemoryStore {
377
398
  // Ordinary writes may defer indexing, but accepting an exact target revision must have a current index.
378
399
  this.db.transaction(() => this.refresh()).immediate();
379
400
  const at = new Date(this.now()).toISOString();
380
- const normalized = structuredClone({ ...note, text: note.text.slice(0, MAX_TEXT),
381
- ...(note.title ? { title: note.title.slice(0, MAX_TITLE) } : {}),
401
+ const normalized = structuredClone({ ...note, text: stripPrivateSpans(note.text).slice(0, MAX_TEXT),
402
+ ...(note.title ? { title: stripPrivateSpans(note.title).slice(0, MAX_TITLE) } : {}),
382
403
  ...(note.tier === "short" && !note.expires ? { expires: new Date(Date.parse(at) + this.ttl).toISOString() } : {}) });
383
404
  const body = { schema: MEMORY_SCHEMA, at, machine: this.machine.id, ...normalized } as const;
384
405
  const view: View = { ...normalized, id: entryId(body), at, machine: body.machine, title: normalized.title ?? null,
@@ -404,7 +425,9 @@ export class MemoryStore {
404
425
 
405
426
  /** Append one entry to this machine's long-term (synced) or local segment and index it. Returns its id. */
406
427
  private append(entry: NoteInput | OpInput | GoalInput, local: boolean, at = new Date(this.now()).toISOString(), machine = this.machine.id): string {
407
- const capped = entry.op === "note" ? { ...entry, text: entry.text.slice(0, MAX_TEXT), ...(entry.title ? { title: entry.title.slice(0, MAX_TITLE) } : {}) } : entry;
428
+ // `<private>` spans are never stored, whoever writes (note, goal, promotion): dropped before the cap, the hash and the log line.
429
+ const capped = entry.op === "note" ? { ...entry, text: stripPrivateSpans(entry.text).slice(0, MAX_TEXT), ...(entry.title ? { title: stripPrivateSpans(entry.title).slice(0, MAX_TITLE) } : {}) }
430
+ : entry.op === "goal" ? stripPrivateDeep(entry) : entry;
408
431
  const body = { schema: MEMORY_SCHEMA, at, machine, ...capped } as Omit<Entry, "id">;
409
432
  return this.appendBody(body, local);
410
433
  }
@@ -444,12 +467,12 @@ export class MemoryStore {
444
467
  if (this.profile.sharded && (this.profile.shardUser ? n.scope !== "shared" : n.scope !== "project" || n.project !== this.profile.shardKey)) throw new Error("entry does not belong to this curated shard");
445
468
  assertSharedV1Op(n.op);
446
469
  // A fixed machine: the same observation imported on two machines is one entry once they sync.
447
- 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">;
470
+ const body = { schema: MEMORY_SCHEMA, at, machine: "claude-mem", ...n, text: stripPrivateSpans(n.text).slice(0, MAX_TEXT), ...(n.title ? { title: stripPrivateSpans(n.title).slice(0, MAX_TITLE) } : {}) } as Omit<Entry, "id">;
448
471
  const id = entryId(body);
449
472
  return this.withMutation(() => {
450
473
  this.generationIndex?.assertAppendable(n.tier === "short", this.machine.id);
451
474
  // The source tuple also recognizes legacy ids whose nodes depended on the checkout or extractor version.
452
- 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;
475
+ 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, stripPrivateSpans(n.text).slice(0, MAX_TEXT)) as { id: string } | null;
453
476
  if (known) return { id: known.id, added: false };
454
477
  appendFileSync(join(this.dir, n.tier === "short" ? "local" : "log", `${this.machine.id}.jsonl`), JSON.stringify({ ...body, id }) + "\n", { mode: 0o600 });
455
478
  return { id, added: true };
@@ -460,11 +483,19 @@ export class MemoryStore {
460
483
  private op(op: OpEntry["op"], target: View, actor: string, by?: string): string {
461
484
  return this.append({ op, target: target.id, actor, ...(by ? { by } : {}) }, target.local);
462
485
  }
463
- forget(id: string, actor: string, project?: string): View | undefined {
486
+ /** Complete existing forget expansion, including superseded legacy-import copies. Reads author no ops. */
487
+ forgetTargets(id: string, project?: string): View[] {
488
+ const v = this.get(id, project); return v ? this.forgetGroup(v) : [];
489
+ }
490
+ private forgetGroup(v: View): View[] {
491
+ return v.source === "claude-mem" ? this.live().filter(x => x.source === v.source && x.project === v.project && x.at === v.at && x.text === v.text) : [v];
492
+ }
493
+ forget(id: string, actor: string, project?: string, exactOnly = false): View | undefined {
464
494
  const v = this.get(id, project);
465
495
  if (!v) return undefined;
466
- // Legacy imports may have different node-derived ids. Forget every known copy, including hidden ones.
467
- 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];
496
+ const group = this.forgetGroup(v);
497
+ if (exactOnly && (group.length !== 1 || group[0]?.id !== id)) throw Error("forget expands beyond the reviewed exact entry");
498
+ // Ordinary person forget retains its legacy-import copy expansion; reviewed decay is exact-only.
468
499
  for (const member of group) this.op("forget", member, actor);
469
500
  return v;
470
501
  }
@@ -511,7 +542,17 @@ export class MemoryStore {
511
542
  }
512
543
  /** A short-term note kept short-term: it expires like any other and is never promoted (its demote entry stays local). */
513
544
  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
514
- 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; }
545
+ /** The short-tier deadline to display before a person confirms a demotion. */
546
+ shortExpiry(): string { return this.until(); }
547
+ /** A confirmed deadline may be older than a fresh one, but cannot extend the configured TTL beyond one hour. */
548
+ assertDemotionExpiry(expiresAt: string): void {
549
+ const at = Date.parse(expiresAt), now = this.now();
550
+ if (!Number.isFinite(at) || new Date(at).toISOString() !== expiresAt || at <= now || at > now + this.ttl + 3600_000) throw Error("demotion expiry must be future UTC within the short-tier deadline plus one hour");
551
+ }
552
+ demote(id: string, actor: string, project?: string, expiresAt?: string): View | undefined {
553
+ if (expiresAt !== undefined) this.assertDemotionExpiry(expiresAt);
554
+ const v = this.get(id, project); if (v?.tier === "long") this.op("demote", v, actor, expiresAt ?? this.until()); return v;
555
+ }
515
556
  supersede(oldId: string, newId: string, actor: string, project?: string): boolean {
516
557
  const a = this.get(oldId, project), b = this.get(newId, project);
517
558
  if (!a || !b || a.id === b.id) return false;
@@ -759,18 +800,18 @@ export class MemoryStore {
759
800
  return nest(this.readStores());
760
801
  }
761
802
 
762
- private viewRows(expired: boolean, selection: (Filter & { ids?: string[] }) | undefined, expiredOnly: boolean): View[] {
763
- const now = this.now(), selecting = selection !== undefined || expiredOnly;
764
- const pruned = this.generationIndex ? new Set((this.readRows("SELECT id FROM generation_targets") as { id: string }[]).map(row => row.id)) : undefined;
765
- if (this.generationIndex) this.generationIndex.pruned = new Set((this.db.query("SELECT id FROM generation_targets").all() as { id: string }[]).map(row => row.id));
803
+ private viewRows(expired: boolean, selection: (Filter & { ids?: string[] }) | undefined, expiredOnly: boolean, projection?: RecallViewProjection): View[] {
804
+ const now = projection?.now ?? this.now(), selecting = selection !== undefined || expiredOnly;
805
+ const pruned = projection?.pruned ?? (this.generationIndex ? new Set((this.readRows("SELECT id FROM generation_targets") as { id: string }[]).map(row => row.id)) : undefined);
806
+ if (!projection && this.generationIndex) this.generationIndex.pruned = new Set((this.db.query("SELECT id FROM generation_targets").all() as { id: string }[]).map(row => row.id));
766
807
  // Relations may cross projects and promoted copies: fold their small metadata globally, never filtered bodies.
767
- const rows = this.readRows(selecting
808
+ const rows = projection?.rows ?? this.readRows(selecting
768
809
  ? "SELECT id, at, tier, kind, task, expires, supersedes, promoted_from FROM entries"
769
810
  : "SELECT * FROM entries") as Record<string, string | null>[];
770
- const ops = this.readRows("SELECT id, op, at, target, by FROM ops ORDER BY at, id") as { id: string; op: string; at: string; target: string; by: string | null }[];
771
- const realms = this.generationIndex ? new Map((this.readRows("SELECT id,own,legacy FROM generation_rows") as { id: string; own: number; legacy: number }[]).map(row => [row.id, row])) : undefined;
811
+ const ops = projection?.ops ?? this.readRows("SELECT id, op, at, target, by FROM ops ORDER BY at, id") as { id: string; op: string; at: string; target: string; by: string | null }[];
812
+ const realms = projection ? projection.realms : this.generationIndex ? new Map((this.readRows("SELECT id,own,legacy FROM generation_rows") as { id: string; own: number; legacy: number }[]).map(row => [row.id, row])) : undefined;
772
813
  const born = realms ? new Set([...realms.values()].filter(row => row.own).map(row => row.id)) : undefined;
773
- const forgotten = new Set(ops.filter((o) => o.op === "forget").map((o) => o.target));
814
+ const forgotten = new Set([...ops.filter((o) => o.op === "forget").map((o) => o.target), ...(projection?.forgotten ?? [])]);
774
815
  // Deadlines were fixed when the entries were written, so every process (whatever its own TTL) prunes the same rows.
775
816
  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();
776
817
  const demoted = new Map(ops.filter((o) => o.op === "demote").map((o) => [o.target, deadline(o)]));
@@ -876,7 +917,7 @@ export class MemoryStore {
876
917
  for (const r of candidates) {
877
918
  const id = r.id!;
878
919
  // A forgotten entry is gone, and so is its promoted copy's source or copy: forgetting either forgets both.
879
- if (pruned?.has(id) || forgotten.has(id) || (r.promoted_from && forgotten.has(r.promoted_from)) || promoted.has(id)) continue;
920
+ if (pruned?.has(id) || forgotten.has(id) || (r.promoted_from && forgotten.has(r.promoted_from)) || promoted.has(id) && !projection?.includePromoted) continue;
880
921
  const tier: Tier = demoted.has(id) ? "short" : r.tier as Tier;
881
922
  const expiresAt = expiry(r);
882
923
  if (!expired && expiresAt !== null && Date.parse(expiresAt) <= now) continue;
@@ -1028,6 +1069,191 @@ export class MemoryStore {
1028
1069
  });
1029
1070
  }
1030
1071
 
1072
+ /** Metadata-only revision of the opened read set, never a note body or an unopened project shard. */
1073
+ recallRevision(): string { return this.recallFingerprint(true); }
1074
+ private recallFingerprint(databases: boolean): string {
1075
+ const entries: unknown[] = [];
1076
+ const stamp = (file: string, directory = false) => {
1077
+ try {
1078
+ const stat = lstatSync(file, { bigint: true });
1079
+ if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile())) throw Error("recall input path unavailable");
1080
+ entries.push([file, String(stat.dev), String(stat.ino), ...(directory ? [] : [String(stat.size), String(stat.mtimeNs), String(stat.ctimeNs)])]);
1081
+ return true;
1082
+ } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { entries.push([file, "absent"]); return false; } throw error; }
1083
+ };
1084
+ const roots = new Set<string>(), fixed = new Set<string>();
1085
+ for (const store of this.readStores()) {
1086
+ roots.add(store.dir);
1087
+ for (const name of ["profile.json", "shards.json", "aliases.jsonl", "lifecycle.jsonl", "machine.json"]) fixed.add(join(store.profile.home, name));
1088
+ for (const name of ["generation.json", "generation-seal.json", "migration-sources.json", "machine.json"]) fixed.add(join(store.dir, name));
1089
+ if (store.generationIndex) {
1090
+ const legacy = store.profile.legacyHome ?? store.profile.home; roots.add(legacy);
1091
+ if (store.profile.legacyMainHome) roots.add(store.profile.legacyMainHome);
1092
+ if (!store.profile.sharded && store.profile.generation) {
1093
+ const dir = generationDir(legacy, store.profile.generation), pointer = join(dir, "bridge-snapshot.json"); fixed.add(pointer);
1094
+ // Only the small sealed pointer is parsed to locate its active archive; JSONL bodies are never read here.
1095
+ const head = readProfileJSON(pointer, 4096) as { commit?: unknown } | undefined;
1096
+ if (head !== undefined) {
1097
+ if (typeof head.commit !== "string" || !/^[a-f0-9]{40,64}$/.test(head.commit)) throw Error("recall bridge pointer unavailable");
1098
+ const archive = join(dir, "bridge-snapshots", head.commit); roots.add(archive); fixed.add(join(archive, "snapshot.json"));
1099
+ }
1100
+ }
1101
+ }
1102
+ if (databases) entries.push([store.dir, store.db.query("PRAGMA data_version").get(), store.db.query("SELECT total_changes() AS n").get()]);
1103
+ }
1104
+ for (const root of [...roots].sort()) {
1105
+ if (!stamp(root, true)) continue;
1106
+ fixed.add(join(root, "machine.json"));
1107
+ for (const folder of ["log", "local"]) {
1108
+ const dir = join(root, folder); if (!stamp(dir, true)) continue;
1109
+ for (const name of readdirSync(dir).filter(name => name.endsWith(".jsonl")).sort()) stamp(join(dir, name));
1110
+ }
1111
+ }
1112
+ for (const file of [...fixed].sort()) stamp(file);
1113
+ return hashJSON(entries);
1114
+ }
1115
+ private prepareRecallRead(stores: readonly MemoryStore[]): void {
1116
+ for (const store of stores) {
1117
+ const active = readSelector(store.profile.home);
1118
+ if (active.revision !== store.profile.selectorRevision) throw Error("recall profile changed");
1119
+ if (store.profile.sharded) {
1120
+ const selected = readShardedSelection(store.profile.home);
1121
+ if (selected.generation !== store.profile.generation || selected.selectorRevision !== store.profile.selectorRevision) throw Error("recall shard binding changed");
1122
+ } else if ((readProfileJSON(join(store.profile.home, "shards.json")) as { phase?: unknown } | undefined)?.phase === "active"
1123
+ || (active.selector?.kind ?? "shared-v1") !== store.profile.kind || store.profile.kind === "generation" && active.selector?.generation !== store.profile.generation) throw Error("recall profile changed");
1124
+ if (store.reconcile && (resolveAlias(store.profile.home, store.reconcile.canonical) !== store.reconcile.canonical || canonicalJSON(aliasedKeys(store.profile.home, store.reconcile.canonical).sort()) !== canonicalJSON([...store.reconcile.aliases].sort()))) throw Error("recall alias binding changed");
1125
+ if (store.generationIndex) store.generationIndex.refresh();
1126
+ else if (store.stale || store.shape() !== store.seen) {
1127
+ store.stale = false; store.refresh();
1128
+ if (store.stale || store.shape() !== store.seen) throw Error("recall refresh unavailable");
1129
+ }
1130
+ }
1131
+ }
1132
+ /** Strict preparation once; callers release the fences before awaiting policy or optional overlays. */
1133
+ beginRecallRead(): () => void {
1134
+ return this.withStoreFences(stores => {
1135
+ const before = this.recallFingerprint(false);
1136
+ this.prepareRecallRead(stores);
1137
+ for (const store of stores) store.prune(); // readPage performs this too; settle its derived writes before the revision.
1138
+ const captured = this.recallRevision();
1139
+ if (this.recallFingerprint(false) !== before) throw Error("recall inputs changed during preparation");
1140
+ return () => { if (this.recallRevision() !== captured) throw Error("recall inputs changed"); };
1141
+ });
1142
+ }
1143
+ /** One lifecycle fold for a bounded exact-project set of useful intent-brief genres. */
1144
+ intentSnapshot(project: string, limit = 5000): { rows: View[]; truncated: boolean; assertCurrent(): void } {
1145
+ if (!project.trim() || project.length > 400 || !Number.isSafeInteger(limit) || limit < 1 || limit > 5000 || this.profile.sharded && !this.profile.shardUser && this.profile.shardKey !== project) throw Error("invalid intent snapshot scope or limit");
1146
+ return this.withStoreFences(stores => {
1147
+ const before = this.recallFingerprint(false);
1148
+ assertProjectActive(this.profile.home, project); this.prepareRecallRead(stores);
1149
+ const revision = this.recallRevision(), ids = new Map<string, { id: string; at: string }>();
1150
+ if (this.recallFingerprint(false) !== before) throw Error("recall inputs changed during preparation");
1151
+ for (const store of stores) {
1152
+ const rows = store.db.query("SELECT id,at FROM entries WHERE project IN (SELECT value FROM json_each(?)) AND kind IN ('fail','decision','contract') ORDER BY at DESC,id LIMIT ?").all(JSON.stringify(this.scopeKeys(project)), limit + 1) as { id: string; at: string }[];
1153
+ for (const row of rows) ids.set(row.id, row);
1154
+ }
1155
+ const refs = [...ids.values()].sort((a, b) => b.at.localeCompare(a.at) || a.id.localeCompare(b.id)), selected = refs.slice(0, limit);
1156
+ const rows = selected.length ? this.views(false, { project, ids: selected.map(row => row.id) }).filter(view => view.project === project && !view.supersededBy && Number.isFinite(Date.parse(view.at))) : [];
1157
+ const order = new Map(selected.map((row, index) => [row.id, index])); rows.sort((a, b) => order.get(a.id)! - order.get(b.id)!);
1158
+ const assertCurrent = () => { if (this.recallRevision() !== revision) throw Error("recall inputs changed"); };
1159
+ assertCurrent(); return { rows, truncated: refs.length > limit, assertCurrent };
1160
+ });
1161
+ }
1162
+
1163
+ /** The same injected clock used by validity, expiry and projection; callers must not substitute wall time. */
1164
+ readNow(): number { return this.now(); }
1165
+
1166
+ /** Person historical recall over verified retained rows. Current removals always override past state. */
1167
+ recallState(project: string, options: RecallStateOptions = {}): RetainedRecallState {
1168
+ if ((options.history || options.asOf !== undefined) && options.person !== true) throw Error("historical recall requires authenticated person intent from the host");
1169
+ if (!project.trim() || project.length > 400 || this.profile.sharded && !this.profile.shardUser && this.profile.shardKey !== project) throw Error("recall project does not match the bound store");
1170
+ const maximumRows = options.maxRows ?? 50_000, maximumBytes = options.maxBytes ?? 32 * 1024 * 1024, present = this.now();
1171
+ if (!Number.isSafeInteger(maximumRows) || maximumRows < 1 || maximumRows > 50_000 || !Number.isSafeInteger(maximumBytes) || maximumBytes < 1 || maximumBytes > 32 * 1024 * 1024) throw Error("invalid retained recall budget");
1172
+ let time = present;
1173
+ if (options.asOf !== undefined) {
1174
+ const value = /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(options.asOf) ? `${options.asOf}T00:00:00.000Z` : options.asOf;
1175
+ time = Date.parse(value);
1176
+ if (!Number.isFinite(time) || new Date(time).toISOString() !== value || time > present) throw Error("as-of must be a past canonical UTC date or timestamp");
1177
+ }
1178
+ const frontier = () => {
1179
+ const active = readSelector(this.profile.home);
1180
+ if (active.revision !== this.profile.selectorRevision) throw Error("recall profile changed or is not active");
1181
+ if (this.profile.sharded) {
1182
+ const shards = readShardedSelection(this.profile.home);
1183
+ if (shards.generation !== this.profile.generation || shards.selectorRevision !== this.profile.selectorRevision) throw Error("recall shard binding changed");
1184
+ } else if ((readProfileJSON(join(this.profile.home, "shards.json")) as { phase?: unknown } | undefined)?.phase === "active"
1185
+ || (active.selector?.kind ?? "shared-v1") !== this.profile.kind || this.profile.kind === "generation" && active.selector?.generation !== this.profile.generation) throw Error("recall profile changed or is not active");
1186
+ assertProjectActive(this.profile.home, project);
1187
+ };
1188
+ return this.withStoreFences(() => {
1189
+ frontier();
1190
+ const source = new Map<string, Entry>(), realm = new Map<string, { id: string; own: number; legacy: number }>(), pruned = new Set<string>();
1191
+ const reader = new SegmentReader(maximumBytes); let records = 0, bytes = 0, malformed = 0, incomplete = false;
1192
+ const add = (row: Entry, own: boolean, legacy: boolean) => {
1193
+ const { id, ...body } = row;
1194
+ if (row.schema !== MEMORY_SCHEMA || !/^[a-f0-9]{32}$/.test(id) || entryId(body as Omit<Entry, "id">) !== id) throw Error("retained recall row verification failed");
1195
+ const canonical = canonicalJSON(row); records++; bytes += Buffer.byteLength(canonical);
1196
+ if (records > maximumRows || bytes > maximumBytes) throw Error("retained recall budget exceeded");
1197
+ const previous = source.get(id); if (previous && canonicalJSON(previous) !== canonical) throw Error("retained recall identity conflict");
1198
+ source.set(id, row); realm.set(id, { id, own: Number(own), legacy: Number(legacy) });
1199
+ };
1200
+ // Same bound read handles as ordinary views. Never opens an unopened project's private shard.
1201
+ for (const store of this.readStores()) {
1202
+ if (store.generationIndex) {
1203
+ store.generationIndex.refresh(0, reader);
1204
+ store.db.transaction(() => {
1205
+ const size = store.db.query("SELECT COUNT(*) AS n, COALESCE(SUM(length(CAST(line AS BLOB))),0) AS bytes FROM generation_rows").get() as { n: number; bytes: number };
1206
+ if (size.n > maximumRows - records || size.bytes > maximumBytes - bytes) throw Error("retained recall budget exceeded");
1207
+ const diagnostics = store.generationIndex!.recallDiagnostics(); malformed += diagnostics.malformed; incomplete ||= diagnostics.incomplete;
1208
+ const state = store.generationIndex!.snapshot(false);
1209
+ for (const item of state.rows) add(item.row, item.own === true, item.legacy ?? item.own !== true);
1210
+ for (const id of state.pruned ?? []) pruned.add(id);
1211
+ }).deferred();
1212
+ } else {
1213
+ const segments = readLegacySnapshot(store.dir, { reader });
1214
+ incomplete ||= segments.some(segment => segment.tailBytes > 0);
1215
+ for (const row of legacyEntries(segments, { onMalformed: () => { malformed++; } })) add(row, false, true);
1216
+ }
1217
+ }
1218
+ frontier();
1219
+ const all = [...source.values()].sort((a, b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id));
1220
+ const notes = all.filter((row): row is NoteEntry => row.op === "note");
1221
+ const removed = new Set(pruned), forgottenAt = new Map<string, string>();
1222
+ for (const row of all) if (row.op === "forget") { removed.add(row.target); if (!forgottenAt.has(row.target)) forgottenAt.set(row.target, row.at); }
1223
+ // Forgetting either end removes known promotion ancestry, in linear bounded graph work.
1224
+ const ancestry = new Map<string, string[]>();
1225
+ for (const note of notes) if (note.promotedFrom) {
1226
+ ancestry.set(note.id, [...ancestry.get(note.id) ?? [], note.promotedFrom]);
1227
+ ancestry.set(note.promotedFrom, [...ancestry.get(note.promotedFrom) ?? [], note.id]);
1228
+ }
1229
+ const queue = [...removed];
1230
+ for (let index = 0; index < queue.length; index++) {
1231
+ const id = queue[index]!, at = forgottenAt.get(id);
1232
+ for (const next of ancestry.get(id) ?? []) {
1233
+ const fresh = !removed.has(next);
1234
+ if (fresh) { removed.add(next); queue.push(next); }
1235
+ if (at && !forgottenAt.has(next)) { forgottenAt.set(next, at); if (!fresh) queue.push(next); }
1236
+ }
1237
+ }
1238
+ const visible = (note: NoteEntry) => this.inScope(note.project, project) || note.scope === "shared";
1239
+ const dated = notes.filter(note => options.asOf === undefined || Date.parse(note.at) <= time), retained = new Map(dated.filter(visible).map(note => [note.id, note]));
1240
+ const rows = dated.map(note => ({ id: note.id, at: note.at, machine: note.machine, project: note.project, scope: note.scope, tier: note.tier, kind: note.kind, peer: note.peer, title: note.title ?? null, text: note.text,
1241
+ task: note.task ?? null, about: JSON.stringify(note.about), source: note.source ?? "hub", supersedes: note.supersedes ?? null, promoted_from: note.promotedFrom ?? null, expires: note.expires ?? null }));
1242
+ const ops = all.filter((row): row is OpEntry => row.op !== "note" && row.op !== "goal" && (options.asOf === undefined || Date.parse(row.at) <= time)).map(row => ({ id: row.id, op: row.op, at: row.at, target: row.target, by: row.by ?? null }));
1243
+ const views = this.viewRows(options.history === true, undefined, false, { now: time, rows, ops, ...(this.generationIndex ? { realms: realm } : {}), pruned, forgotten: removed, includePromoted: options.history === true })
1244
+ .filter(view => (this.inScope(view.project, project) || view.scope === "shared") && (options.history === true || !view.supersededBy));
1245
+ const forgotten = [...forgottenAt].flatMap(([id, at]) => retained.has(id) ? [{ id, at }] : []).sort((a, b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id));
1246
+ const relations: RecallRelation[] = [];
1247
+ const link = (from: string, to: string, at: string, kind: RecallRelation["kind"]) => { if (retained.has(from) && retained.has(to) && !pruned.has(from) && !pruned.has(to)) relations.push({ from, to, at, kind }); };
1248
+ for (const note of dated) { if (note.supersedes) link(note.supersedes, note.id, note.at, "supersede"); if (note.promotedFrom) link(note.promotedFrom, note.id, note.at, "promote"); }
1249
+ for (const op of ops) if (op.op === "supersede" && op.by) link(op.target, op.by, op.at, "supersede");
1250
+ const notices = (options.asOf !== undefined || options.history === true) && views.some(view => view.promotedFrom)
1251
+ ? ["promotion time unknown: tier before promotion cannot be recovered"] : [];
1252
+ frontier();
1253
+ return { at: new Date(time).toISOString(), views, forgotten, relations, malformed, notices, incomplete: incomplete || malformed > 0 || notices.length > 0, ...(incomplete || malformed > 0 ? { sourceIncomplete: true as const } : {}) };
1254
+ });
1255
+ }
1256
+
1031
1257
  /** One entry by id or unique id prefix; with `project`, only one that project may read (its own or shared). */
1032
1258
  get(id: string, project?: string): View | undefined {
1033
1259
  id = id.replace(/^mem:/, ""); // briefs and recall print `mem:<id>`
@@ -1073,10 +1299,10 @@ export class MemoryStore {
1073
1299
  }
1074
1300
 
1075
1301
  /** The neighbourhood of one node for the dashboard: up to `hops` (1 or 2) steps, at most `cap` nodes. */
1076
- graph(project: string, start: Node, hops = 1, cap = 200): Graph {
1302
+ graph(project: string, start: Node, hops = 1, cap = 200, projection?: readonly View[]): Graph {
1077
1303
  this.ensureFresh();
1078
1304
  if (start.startsWith("mem:") && this.generationIndex?.pruned.has(start.slice(4))) return { nodes: [], edges: [] };
1079
- const live = new Map(this.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true }).map((v) => [`mem:${v.id}`, v]));
1305
+ const live = new Map((projection ?? this.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true })).map((v) => [`mem:${v.id}`, v]));
1080
1306
  const nodes = new Map<Node, Graph["nodes"][number]>(), edges = new Map<string, Graph["edges"][number]>();
1081
1307
  const goals = new Map(this.goals(project).map((g) => [`goal:${g.id}`, g]));
1082
1308
  const suggestions = this.suggestions().filter((x) => x.status !== "rejected");
@@ -1123,6 +1349,8 @@ export class MemoryStore {
1123
1349
  return ids;
1124
1350
  }
1125
1351
  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));
1352
+ // Retained expired rows may no longer have derived edges; only their verified authored about nodes supply them.
1353
+ if (projection) for (const view of projection) if (view.about.includes(id) && !ids.includes(`mem:${view.id}`)) ids.push(`mem:${view.id}`);
1126
1354
  for (const m of ids) { const v2 = live.get(m)!; link(m, id, v2.kind === "fail" ? "rules_out" : "about"); }
1127
1355
  return [...ids, ...parts];
1128
1356
  };
@@ -1269,6 +1497,8 @@ export function openShardStore(opts: StoreOptions & { project?: string; user?: b
1269
1497
  if (opts.memoryDir !== undefined && (opts.home !== undefined || typeof opts.memoryDir !== "string" || !opts.memoryDir.trim())) throw new Error("choose a memory home or one explicit memory directory");
1270
1498
  const home = opts.memoryDir ?? (opts.home === undefined ? memoryHome() : join(opts.home, "memory"));
1271
1499
  assertCuratedShardPaths(home, opts);
1500
+ // #15 T5: the one gate every recall path shares, checked before a shard can be created. The user shard has no lifecycle.
1501
+ if (opts.user !== true && opts.lifecycle !== "ignore") assertProjectActive(home, opts.project!);
1272
1502
  const dir = opts.user === true ? userShardDir(home) : shardDir(home, opts.project!);
1273
1503
  const missing = !existsSync(join(dir, "generation.json"));
1274
1504
  if (missing) initializeShard(home, opts);
@@ -0,0 +1,139 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { generationDir, readSelector } from "./generation.ts";
4
+
5
+ /**
6
+ * F16, `amem sync --if-due`: the person's own shell (an EXIT trap) asks amem to sync when it is due; amem itself runs nothing between sessions and never
7
+ * registers a hook for it. Like every mutating command it needs a terminal, so an unattended scheduler (cron, launchd) is not a caller (a hook may not do network work: AGENTS.md, test/hooks-network.test.ts). The only state is one small stamp beside the logs.
8
+ * The stamp holds times and a failure count, never an error text (a git error can carry a remote URL).
9
+ */
10
+ export interface SyncState { lastAttemptAt: string | null; lastOkAt: string | null; failures: number }
11
+ export const SYNC_STATE_FILE = "sync-state.json";
12
+ /** After a success, the next attempt is due this long after the last one. */
13
+ export const SYNC_INTERVAL_MS = 60 * 60_000;
14
+ /** After a failure the wait starts here and doubles with each further failure. */
15
+ export const SYNC_BACKOFF_BASE_MS = 15 * 60_000;
16
+ export const SYNC_BACKOFF_CAP_MS = 24 * 60 * 60_000;
17
+ const MAX_FAILURES = 30, MAX_BYTES = 4096;
18
+ const EMPTY: SyncState = { lastAttemptAt: null, lastOkAt: null, failures: 0 };
19
+ const iso = (value: unknown): string | null => typeof value === "string" && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value ? value : null;
20
+
21
+ /** Never throws: a missing, oversized or malformed stamp is "no history", which makes a sync due. */
22
+ export function readSyncState(home: string): SyncState {
23
+ let fd: number | undefined;
24
+ try {
25
+ fd = openSync(join(home, SYNC_STATE_FILE), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
26
+ const stat = fstatSync(fd);
27
+ if (!stat.isFile() || stat.size > MAX_BYTES) return { ...EMPTY };
28
+ const buffer = Buffer.alloc(stat.size), size = readSync(fd, buffer, 0, buffer.length, 0);
29
+ const value = JSON.parse(buffer.subarray(0, size).toString("utf8")) as Record<string, unknown>;
30
+ const failures = Number.isSafeInteger(value.failures) && (value.failures as number) >= 0 ? Math.min(value.failures as number, MAX_FAILURES) : 0;
31
+ return { lastAttemptAt: iso(value.lastAttemptAt), lastOkAt: iso(value.lastOkAt), failures };
32
+ } catch { return { ...EMPTY }; }
33
+ finally { if (fd !== undefined) try { closeSync(fd); } catch { /* nothing to close */ } }
34
+ }
35
+
36
+ /** The wait after the last attempt: the plain interval after a success, 15 minutes doubling to a day after failures. */
37
+ export function syncWaitMs(failures: number): number {
38
+ return failures <= 0 ? SYNC_INTERVAL_MS : Math.min(SYNC_BACKOFF_BASE_MS * 2 ** (failures - 1), SYNC_BACKOFF_CAP_MS);
39
+ }
40
+
41
+ /** The earliest next attempt, or null when none was recorded (due now). */
42
+ export function nextSyncAt(state: SyncState): number | null {
43
+ return state.lastAttemptAt === null ? null : Date.parse(state.lastAttemptAt) + syncWaitMs(state.failures);
44
+ }
45
+
46
+ /** `force` (`--force`) ignores the schedule and the backoff. */
47
+ export function syncDue(state: SyncState, now: number, force = false): boolean {
48
+ const next = nextSyncAt(state);
49
+ return force || next === null || now >= next;
50
+ }
51
+
52
+ /** `--if-due` stops a running git step after this long, so a shell that runs it on exit is held for a bounded time. */
53
+ export const SYNC_IF_DUE_DEADLINE_MS = 30_000;
54
+
55
+ /**
56
+ * The stop signal of one `--if-due` run: the 30 second deadline, or any way the person ends it: Ctrl-C (SIGINT), SIGTERM, SIGHUP (the terminal closed, which is also how an
57
+ * EXIT trap's shell can go away) and SIGQUIT. git runs in its own process group while a stop signal is given (so the stop reaches its transport children), which also means a
58
+ * terminal's signals no longer reach git by themselves: amem turns each of them into the abort. `on`, not `once`: a second Ctrl-C must not fall back to the default action
59
+ * (which would end amem and strand git) while the first is still stopping git; the handlers stay until `dispose`. A process that dies some other way is covered by the exit
60
+ * handler in sync.ts, which signals every live git group. The emitter is a seam for tests; the CLI passes `process`.
61
+ */
62
+ export const STOP_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"] as const;
63
+ export type StopCause = "person" | "deadline";
64
+ export function stopSignal(ms: number, emitter: { on(name: string, fn: () => void): unknown; off(name: string, fn: () => void): unknown }, names: readonly string[] = STOP_SIGNALS): { signal: AbortSignal; dispose: () => void; cause: () => StopCause | undefined } {
65
+ const controller = new AbortController(), deadline = AbortSignal.timeout(ms);
66
+ let cause: StopCause | undefined;
67
+ // The first thing that ends the run is its cause: a person who closes the terminal after the deadline already fired did not stop it.
68
+ const stop = () => { cause ??= "person"; controller.abort(); };
69
+ deadline.addEventListener("abort", () => { cause ??= "deadline"; }, { once: true });
70
+ for (const name of names) emitter.on(name, stop);
71
+ return { signal: AbortSignal.any([controller.signal, deadline]), dispose: () => { for (const name of names) emitter.off(name, stop); }, cause: () => cause };
72
+ }
73
+
74
+ /**
75
+ * The repository the active profile syncs through: a curated split keeps a private cache in `shard-sync/objects.git`, a generation home keeps its own `.git` inside the
76
+ * generation directory (the memory home's own `.git` is then only the legacy input), and shared-v1 keeps `.git` in the memory home. Undefined when the profile cannot be
77
+ * resolved (an unreadable selector): no remote is then reported rather than a guess.
78
+ */
79
+ function syncRepository(home: string): string | undefined {
80
+ try {
81
+ if (lstatSync(join(home, "shards.json"), { throwIfNoEntry: false })) return join(home, "shard-sync", "objects.git", "config");
82
+ const { selector } = readSelector(home);
83
+ if (selector?.kind === "generation") return join(generationDir(home, selector.generation!), ".git", "config");
84
+ return join(home, ".git", "config");
85
+ } catch { return undefined; }
86
+ }
87
+
88
+ /**
89
+ * Whether the active profile has a sync remote configured, from its repository's own config file: no git process, and never the URL (it can carry a credential).
90
+ */
91
+ export function remoteConfigured(home: string): boolean {
92
+ const file = syncRepository(home);
93
+ if (file === undefined) return false;
94
+ let fd: number | undefined;
95
+ try {
96
+ fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
97
+ const stat = fstatSync(fd);
98
+ if (!stat.isFile() || stat.size > 65536) return false;
99
+ const buffer = Buffer.alloc(stat.size), size = readSync(fd, buffer, 0, buffer.length, 0);
100
+ return /^\s*\[remote "origin"\]/m.test(buffer.subarray(0, size).toString("utf8"));
101
+ } catch { return false; } // no such repository
102
+ finally { if (fd !== undefined) try { closeSync(fd); } catch { /* nothing to close */ } }
103
+ }
104
+
105
+ /** A stamp from the future (a clock set back, a hand edit) would block the sync until then plus the wait: it counts as no history, and so does a success it claims. */
106
+ export function usableState(state: SyncState, now: number): SyncState {
107
+ const future = (value: string | null) => value !== null && Date.parse(value) > now;
108
+ if (future(state.lastAttemptAt)) return { lastAttemptAt: null, lastOkAt: future(state.lastOkAt) ? null : state.lastOkAt, failures: 0 };
109
+ return future(state.lastOkAt) ? { ...state, lastOkAt: null } : state;
110
+ }
111
+
112
+ /**
113
+ * What a finished sync means for the schedule. `ok` clears the backoff. `local` is a refusal that the schedule cannot fix and that no remote ever saw, or a run its person ended (another sync or
114
+ * mutation holds the lock, no remote is configured, the configured policy declines a note, the remote is public, a usage error): it is not stamped, so it neither
115
+ * backs the schedule off nor makes a contended run look like a failed one. Everything else is a `failed` transport or git step, and an error this module does not
116
+ * recognise counts as failed (the safe side: it waits instead of retrying every minute).
117
+ */
118
+ export type SyncOutcome = "ok" | "failed" | "local";
119
+ const LOCAL = /^(usage:|another (sync|memory mutation|generation|shard sync)|no (generation )?sync remote|the configured privacy policy|the sync remote is public|this (machine's|store's) long-term|a line of this machine's long-term|more than \d+ long-term)/;
120
+ export function syncOutcome(error: string | undefined, cause?: StopCause): SyncOutcome {
121
+ if (error === undefined) return "ok";
122
+ // A run its person ended (Ctrl-C, a closing terminal) is not a failure of the remote: the same Ctrl-C reads "fetch failed" or "stopped" depending on which step it hit, so the cause,
123
+ // not the text, decides. Only the deadline (a remote that did not answer in time) stays a failure to back off from.
124
+ if (cause === "person") return "local";
125
+ return LOCAL.test(error.replace(/^amem sync: /, "")) ? "local" : "failed";
126
+ }
127
+
128
+ /** Records one attempt. A stamp that cannot be written never fails the sync it describes. */
129
+ export function recordSyncAttempt(home: string, ok: boolean, at: number): SyncState {
130
+ const prior = readSyncState(home), when = new Date(at).toISOString();
131
+ const next: SyncState = { lastAttemptAt: when, lastOkAt: ok ? when : prior.lastOkAt, failures: ok ? 0 : Math.min(prior.failures + 1, MAX_FAILURES) };
132
+ const file = join(home, SYNC_STATE_FILE), temp = `${file}.${process.pid}.tmp`;
133
+ try {
134
+ mkdirSync(home, { recursive: true, mode: 0o700 });
135
+ writeFileSync(temp, JSON.stringify(next) + "\n", { mode: 0o600 });
136
+ renameSync(temp, file);
137
+ } catch { try { rmSync(temp, { force: true }); } catch { /* best effort */ } }
138
+ return next;
139
+ }