@staix/agent-mem 0.18.0 → 0.20.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.
@@ -1,3 +1,4 @@
1
+ import { assertFormatFloor } from "./formats.ts";
1
2
  import { Database } from "bun:sqlite";
2
3
  import { createHash, randomBytes } from "node:crypto";
3
4
  import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, opendirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
@@ -49,6 +50,7 @@ export const CAPTURE_EVICT_MAX_BATCHES = 4;
49
50
  /** Wall-time bound for one invocation's eviction: with the batch bound, an append stays inside R2 after a cap shrink. */
50
51
  export const CAPTURE_EVICT_MAX_MS = 25;
51
52
  export const CAPTURE_DRAIN_MAX_LINES = 1000;
53
+ const BRIEF_SUMMARY_CANDIDATES = 32;
52
54
  const SPOOL = "spool.jsonl";
53
55
  const DRAINING = "spool.jsonl.draining";
54
56
  const SPOOL_DIR = "spool";
@@ -210,6 +212,7 @@ export class CaptureStore {
210
212
  const maxReadRows = opts.maxReadRows ?? 128;
211
213
  if (this.readOnly && (!Number.isSafeInteger(maxReadRows) || maxReadRows < 1 || maxReadRows >= Number.MAX_SAFE_INTEGER)) throw new Error("invalid capture read row bound");
212
214
  this.base = opts.memoryDir ?? memoryBase(opts.home);
215
+ assertFormatFloor(this.base, "capture");
213
216
  const machine = opts.machine ?? (this.readOnly ? this.existingMachine() : machineId(this.base).id);
214
217
  if (this.readOnly && !/^[a-f0-9]{16}$/.test(machine)) throw new Error("capture machine identity unavailable");
215
218
  this.requestedKey = opts.project;
@@ -288,6 +291,7 @@ export class CaptureStore {
288
291
  db.run("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
289
292
  // #173: additive nullable columns. Older readers use explicit or `SELECT *` column names and ignore extras.
290
293
  addAttributionColumns(db);
294
+ this.ensureBriefSummaryIndex(true);
291
295
  this.ensureFts();
292
296
  } catch (error) {
293
297
  if (!lockError(error)) { db.close(); throw error; }
@@ -383,6 +387,12 @@ export class CaptureStore {
383
387
  }
384
388
 
385
389
  private mcpReady = false;
390
+ private briefReady = false;
391
+ private prepareBriefSummaryIndex(): void {
392
+ if (this.readOnly || this.briefReady) return;
393
+ try { this.ensureBriefSummaryIndex(false); this.briefReady = true; }
394
+ catch { /* Optional summary coverage remains partial; query engines remain independent. */ }
395
+ }
386
396
  /** One-time MCP read cost only: hooks neither create this derived index nor scan history to fill it. */
387
397
  private ensureMcpFts(): boolean {
388
398
  if (this.readOnly) return !!this.db.query("SELECT 1 FROM sqlite_master WHERE name = 'mcp_fts' AND type = 'table'").get();
@@ -637,6 +647,41 @@ export class CaptureStore {
637
647
  return rows.map(toRow);
638
648
  }
639
649
 
650
+ /** Generic SQLite index maintenance also covers older writers sharing this file. */
651
+ private ensureBriefSummaryIndex(bounded: boolean): void {
652
+ if (this.db.query("SELECT 1 FROM sqlite_master WHERE name='rows_brief_summaries_v1' AND type='index'").get()) return;
653
+ const build = () => {
654
+ if (bounded) {
655
+ const count = (this.db.query("SELECT COUNT(*) AS n FROM (SELECT 1 FROM rows LIMIT 129)").get() as { n: number }).n;
656
+ if (count > 128) return;
657
+ }
658
+ this.db.run("CREATE INDEX IF NOT EXISTS rows_brief_summaries_v1 ON rows(project,at,id) WHERE event IN ('Recap','Compact') AND (subagent IS NULL OR subagent != 1)");
659
+ };
660
+ // Keep the small-store decision and build atomic against a concurrent older writer.
661
+ if (bounded) this.db.transaction(build).immediate(); else build();
662
+ }
663
+
664
+ /** Index reads return at most33 bodies. Legacy fallback reads at most128 recent rows. */
665
+ readBriefSummaries(limit = BRIEF_SUMMARY_CANDIDATES): { rows: CaptureRow[]; partial: boolean } {
666
+ this.checkReadBinding();
667
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > BRIEF_SUMMARY_CANDIDATES) throw new Error("invalid brief summary limit");
668
+ const indexed = !!this.db.query("SELECT 1 FROM sqlite_master WHERE name='rows_brief_summaries_v1' AND type='index'").get();
669
+ let rows: RawRow[], partial = false;
670
+ if (indexed) {
671
+ rows = this.db.query("SELECT * FROM rows INDEXED BY rows_brief_summaries_v1 WHERE project=? AND event IN ('Recap','Compact') AND (subagent IS NULL OR subagent != 1) ORDER BY at DESC,id DESC LIMIT ?")
672
+ .all(this.key, limit + 1) as RawRow[];
673
+ } else {
674
+ // ponytail: a large legacy store stays partial within128 rows until an off-hook
675
+ // query builds the partial index; never scan or build the history on a hook.
676
+ const hasSubagent = (this.db.query("PRAGMA table_info(rows)").all() as { name: string }[]).some(column => column.name === "subagent");
677
+ const subagent = hasSubagent ? " AND (subagent IS NULL OR subagent != 1)" : "";
678
+ rows = this.db.query(`SELECT * FROM (SELECT * FROM rows INDEXED BY rows_at ORDER BY at DESC,id DESC LIMIT 128) WHERE project=? AND event IN ('Recap','Compact')${subagent} ORDER BY at DESC,id DESC LIMIT ?`)
679
+ .all(this.key, limit + 1) as RawRow[];
680
+ partial = this.db.query("SELECT id FROM rows INDEXED BY rows_at ORDER BY at DESC,id DESC LIMIT 129").all().length > 128;
681
+ }
682
+ return { rows: rows.slice(0, limit).map(toRow), partial: partial || rows.length > limit };
683
+ }
684
+
640
685
  /** Exact-project, bounded body pages; MCP screens before filling its public candidate window. */
641
686
  readPage(window: ReadWindow & { query?: string; match?: "substring" }): { rows: CaptureRow[]; next?: ReadRef; engine?: "fts5" | "substring" } {
642
687
  this.checkReadBinding();
@@ -645,6 +690,7 @@ export class CaptureStore {
645
690
  if (window.query !== undefined) throw new Error("recent capture brief does not support query indexes");
646
691
  where.push("id IN (SELECT value FROM json_each(?))"); bind.push(JSON.stringify(this.recentReadIds));
647
692
  }
693
+ if (window.query !== undefined) this.prepareBriefSummaryIndex();
648
694
  const engine = window.query === undefined ? undefined : window.match !== "substring" && this.ensureMcpFts() ? "fts5" : "substring";
649
695
  const plan = captureReadPlan(window, engine, where, bind);
650
696
  if (!plan) return { rows: [], engine };
@@ -1,4 +1,5 @@
1
1
  import { Database } from "bun:sqlite";
2
+ import { indexFileName } from "./index-file.ts";
2
3
  import { chmodSync, existsSync, lstatSync, readdirSync } from "node:fs";
3
4
  import { join } from "node:path";
4
5
  import { memoryBase } from "./home.ts";
@@ -48,7 +49,7 @@ function curatedStats(base: string, hash: string, key: string, captured: boolean
48
49
  if (!existsSync(join(dir, "generation.json")) && !existsSync(join(dir, "generation-seal.json"))) return { entries: 0, lastAt: null };
49
50
  const store = openShardStore({ memoryDir: base, project: key, deferPromotions: true, lifecycle: "ignore" });
50
51
  try {
51
- const db = new Database(join(dir, store.profile.foreignPruneFormat ? "index-v2.db" : "index.db"), { readonly: true });
52
+ const db = new Database(join(dir, indexFileName(!!store.profile.foreignPruneFormat)), { readonly: true });
52
53
  try {
53
54
  db.run("PRAGMA busy_timeout = 0");
54
55
  const row = db.query("SELECT COUNT(*) AS n, MAX(at) AS last FROM entries WHERE scope = 'project' AND project = ?" + (store.profile.foreignPruneFormat ? " AND id NOT IN (SELECT id FROM generation_targets)" : "")).get(key) as { n: number; last: string | null };
@@ -1,4 +1,6 @@
1
1
  import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
2
+ import { indexFileName } from "./index-file.ts";
3
+ import { assertFormatFloor } from "./formats.ts";
2
4
  import { tmpdir } from "node:os";
3
5
  import { basename, join } from "node:path";
4
6
  import { Database } from "bun:sqlite";
@@ -12,10 +14,12 @@ import { readSelector } from "./generation.ts";
12
14
  import { processLiveness, withGitTimeout } from "./platform.ts";
13
15
  import { endLikeSignal, STOP_SIGNALS } from "./sync-cadence.ts";
14
16
  import { boundRepoRoot } from "./repo-scope.ts";
15
- import { facts, label, PLAIN, type Style } from "./style.ts";
17
+ import { facts, label, PLAIN, singleLine, type Style } from "./style.ts";
16
18
  import { aliasedKeys, shardDir } from "./shard.ts";
17
19
  import type { MemoryStore, View } from "./store.ts";
18
20
  import { nextSyncAt, readSyncState, remoteConfigured, syncDue, usableState } from "./sync-cadence.ts";
21
+ import { readSetupDrift } from "../setup-drift.ts";
22
+ import { readHookSpoolStatus } from "../hook-spool.ts";
19
23
 
20
24
  /**
21
25
  * F15, `amem doctor`: one report over the memory home. It writes nothing into the home. Every check that needs a store (the notes, the brief, the index proof) runs on a
@@ -42,6 +46,8 @@ const MAX_IDS = 10;
42
46
 
43
47
  export interface DoctorOptions {
44
48
  home: string; homeSource: string; cwd: string; project: string; aliases: readonly string[]; now: number;
49
+ /** Test seam for setup path resolution; the CLI uses the process environment. */
50
+ env?: Readonly<Record<string, string | undefined>>;
45
51
  /** Opens this project's curated store on `base`, the way the other person reads do (a split home through its project shard). Doctor passes a private scratch base, never the live home. */
46
52
  openCurated: (base: string) => MemoryStore;
47
53
  /** The cadence command the person runs from their own shell; shown in the report, never installed. */
@@ -58,8 +64,12 @@ export interface DoctorReport {
58
64
  duplicates: Section; stale: Section; candidates: Section; capture: Section; brief: Section; index: Section; readers: Section; policy: Section; cadence: Section;
59
65
  /** Added after v1 shipped (#132); additive, so the schema stays amem.doctor/v1. */
60
66
  drops: Section;
67
+ /** #217, additive: bounded read-only home-wide hook backlog inventory. */
68
+ spool?: Section;
61
69
  /** #188, additive: present only when the project's curated logs hold forget or prune operations. Informational; status is always ok. */
62
70
  masked?: Section;
71
+ /** #202, additive: applied native setup registrations checked against the shared path resolver. */
72
+ setup?: Section;
63
73
  };
64
74
  }
65
75
 
@@ -202,7 +212,7 @@ export async function withBriefSnapshot<T>(home: string, project: string, read:
202
212
 
203
213
  /** A snapshot of the live shared-v1 index (a read-only connection, so no checkpoint, no lease), or false when there is none. */
204
214
  function snapshotIndex(live: string, to: string): boolean {
205
- const file = join(live, "index.db");
215
+ const file = join(live, indexFileName(false));
206
216
  if (!regularFile(file)) return false;
207
217
  const forms: (() => Database)[] = [
208
218
  () => new Database(file, { readonly: true }),
@@ -356,6 +366,22 @@ function dropsSection(options: DoctorOptions): Section {
356
366
  ...(total ? { hint: "capture rows were dropped recently (see counts by reason); amem doctor --json keeps the numbers" } : {}) };
357
367
  }
358
368
 
369
+ /** Bounded read-only inventory and classification; no replay, claim or retirement. */
370
+ function spoolSection(options: DoctorOptions): Section {
371
+ try {
372
+ const inventory = readHookSpoolStatus(options.home);
373
+ const tiers = [inventory.active, inventory.reserve];
374
+ const full = tiers.some(tier => tier.files >= tier.maxFiles || tier.bytes >= tier.maxBytes);
375
+ const near = tiers.some(tier => tier.files >= tier.maxFiles * 0.9 || tier.bytes >= tier.maxBytes * 0.9);
376
+ return { ...inventory, status: !inventory.complete ? "unavailable" : full || near ? "attention" : "ok",
377
+ coverage: inventory.complete ? "complete" : "partial",
378
+ classificationCoverage: inventory.kinds.complete ? "complete" : "partial",
379
+ fill: full ? "full" : near ? "nearly_full" : !inventory.complete ? "unknown" : tiers.some(tier => tier.files > 0) ? "pending" : "empty",
380
+ ...(!inventory.complete ? { reason: "hook spool inventory is partial; reported file and byte counts are lower bounds" } : {}),
381
+ ...(full || near ? { hint: "pending hook spool needs attention: capture can be dropped when both tiers fill; supported orphan recovery is pending" } : {}) };
382
+ } catch { return unavailable("hook spool could not be read safely"); }
383
+ }
384
+
359
385
  /** F16: which CLIs ingest at SessionEnd (setup receipts), and the person-owned sync cadence. amem registers no sync hook (a hook may not do network work). */
360
386
  function cadenceSection(options: DoctorOptions): Section {
361
387
  const ingest: Record<string, string> = {};
@@ -435,7 +461,11 @@ function guardScratch(): { adopt: (dir: string) => void; release: () => void } {
435
461
  * a lone signal waits at most one call for the signal handler, which then removes the scratch copy and ends doctor.
436
462
  */
437
463
  export const DOCTOR_GIT_TIMEOUT_MS = 5_000;
438
- export function doctorReport(options: DoctorOptions): Promise<DoctorReport> { return withGitTimeout(DOCTOR_GIT_TIMEOUT_MS, () => runDoctor(options)); }
464
+ export function doctorReport(options: DoctorOptions): Promise<DoctorReport> {
465
+ // Doctor reads every surface below, live or through a scratch copy: a floor on any of them refuses the whole report before anything is read (#199 AC5).
466
+ for (const surface of ["curated", "capture", "candidates", "work-lists", "hook-spool"] as const) assertFormatFloor(options.home, surface);
467
+ return withGitTimeout(DOCTOR_GIT_TIMEOUT_MS, () => runDoctor(options));
468
+ }
439
469
  async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
440
470
  let scratch: string | undefined, unguard: (() => void) | undefined;
441
471
  let duplicates: Section, stale: Section, index: Section;
@@ -459,7 +489,7 @@ async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
459
489
  } else {
460
490
  const home = join(scratch, "home"); mkdirSync(home, { mode: 0o700 });
461
491
  copyCurated(options, layout, home, budget);
462
- const snapshotted = layout === "shared-v1" && snapshotIndex(options.home, join(home, "index.db"));
492
+ const snapshotted = layout === "shared-v1" && snapshotIndex(options.home, join(home, indexFileName(false)));
463
493
  curated = options.openCurated(home);
464
494
  const rows = curated.list({ project: options.project, limit: LIST_ALL });
465
495
  await tick();
@@ -480,10 +510,13 @@ async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
480
510
  candidates = summary === null ? unavailable("candidates.db exists but cannot be read")
481
511
  : { status: summary.dueWithin7Days ? "attention" : "ok", ...summary, ...(summary.dueWithin7Days ? { hint: "run amem review walk" } : {}) };
482
512
  } catch (error) { candidates = unavailable(message(error)); }
513
+ let setup: Section;
514
+ try { setup = { ...readSetupDrift({ home: options.home, ...(options.env ? { env: options.env } : {}), cwd: options.cwd }) }; }
515
+ catch { setup = unavailable("setup registrations could not be checked"); }
483
516
  let sections: DoctorReport["sections"];
484
517
  try {
485
518
  await tick();
486
- sections = { duplicates, stale, candidates, capture: captureSection(options), brief: await briefSection(options, curated, workBase, copyFailed), index, readers: readersSection(options), policy: policySection(options), cadence: cadenceSection(options), drops: dropsSection(options), ...maskedSection(curated) };
519
+ sections = { duplicates, stale, candidates, capture: captureSection(options), brief: await briefSection(options, curated, workBase, copyFailed), index, readers: readersSection(options), policy: policySection(options), cadence: cadenceSection(options), drops: dropsSection(options), spool: spoolSection(options), setup, ...maskedSection(curated) };
487
520
  await tick(); // a signal that arrived during the last synchronous stage is handled while the guard is still installed
488
521
  } finally { try { curated?.close(); } finally { try { if (scratch) rmSync(scratch, { recursive: true, force: true }); } finally { unguard?.(); } } }
489
522
  return { schema: DOCTOR_SCHEMA, status: worst(Object.values(sections).map(section => section.status)), home: { path: options.home, source: options.homeSource }, project: options.project, sections };
@@ -493,6 +526,8 @@ async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
493
526
  const SECTION_TITLE: Record<keyof DoctorReport["sections"], string> = {
494
527
  duplicates: "duplicate notes", stale: "stale path references", candidates: "review candidates", capture: "capture per CLI", brief: "session brief",
495
528
  index: "index equals rebuild", readers: "reader leases", policy: "policy in effect", cadence: "ingest and sync cadence", drops: "recent capture drops",
529
+ spool: "pending hook spool",
530
+ setup: "native setup registrations",
496
531
  masked: "forgotten or pruned entries (informational)",
497
532
  };
498
533
 
@@ -513,6 +548,20 @@ function sectionDetail(name: string, detail: Record<string, unknown>, s: Style):
513
548
  const counts = Object.entries((detail.counts ?? {}) as Record<string, number>);
514
549
  return [counts.length ? `${counts.map(([reason, n]) => `${reason.replaceAll("_", " ")} ${n}`).join(", ")} (recent metrics tail)` : "none in the recent metrics tail"].map(line => detail.malformedLines ? `${line}; ${detail.malformedLines} malformed lines skipped` : line);
515
550
  }
551
+ if (name === "spool") {
552
+ if (!detail.active || !detail.reserve) return [];
553
+ const tiers = ["active", "reserve"].map(name => {
554
+ const tier = detail[name] as { files: number; bytes: number; maxFiles: number; maxBytes: number };
555
+ return `${name} ${tier.files}/${tier.maxFiles} files, ${tier.bytes}/${tier.maxBytes} bytes`;
556
+ });
557
+ const kinds = detail.kinds as { native: number; summaries: number; unclassified: number; complete: boolean };
558
+ return [`${detail.fill}; ${tiers.join("; ")}; claimed ${detail.claimed}; oldest ${detail.oldestAt ?? "none observed"}; coverage ${detail.coverage}${detail.complete ? "" : " (counts are lower bounds)"}`,
559
+ `native ${kinds.native}, summaries ${kinds.summaries}, unclassified ${kinds.unclassified}; classification ${detail.classificationCoverage}${kinds.complete ? "" : " (native and summary counts are lower bounds)"}`];
560
+ }
561
+ if (name === "setup" && Array.isArray(detail.entries)) {
562
+ return (detail.entries as { target?: unknown; file?: unknown; entry?: unknown; reason?: unknown; expectedFile?: unknown }[]).map(item =>
563
+ singleLine(`${item.target} ${item.file}${item.expectedFile ? ` -> ${item.expectedFile}` : ""} ${item.entry}: ${item.reason}`));
564
+ }
516
565
  if (name === "brief" && typeof detail.tokens === "number") return [`${detail.tokens} of ${detail.budget} tokens${detail.coverage ? `, coverage ${facts(detail.coverage as Record<string, unknown>) || "complete"}` : ""}`];
517
566
  const { examples, ...rest } = detail;
518
567
  const line = facts(rest);
@@ -0,0 +1,65 @@
1
+ import { closeSync, constants, fstatSync, openSync, readFileSync, readSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * Format floors (#199 AC5). `formats.json` in the memory home is an append-only JSON array of `{ surface, minReader, since }` records: a
6
+ * person appends one to stop releases older than `minReader` from reading that surface (`home` covers every surface). This package never
7
+ * writes the file, so no writer raises a floor implicitly. Each surface checks its floor before it creates, opens or writes anything, and a
8
+ * reader below it refuses with `code: "format_floor"`; a file this version cannot read refuses with `code: "format_floor_unreadable"`.
9
+ * A record naming a surface this version does not know is not its concern. Releases before this one do not read the file.
10
+ */
11
+ export const FORMATS_FILE = "formats.json";
12
+ export const FORMAT_SURFACES = ["curated", "capture", "hook-spool", "candidates", "work-lists", "sync"] as const;
13
+ export type FormatSurface = typeof FORMAT_SURFACES[number];
14
+ export interface FormatFloor { surface: string; minReader: string; since: string }
15
+ const MAX_BYTES = 64 * 1024, SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/;
16
+
17
+ export class FormatFloorError extends Error {
18
+ constructor(readonly code: "format_floor" | "format_floor_unreadable", message: string, readonly floor?: FormatFloor & { reader: string }) { super(message); this.name = "FormatFloorError"; }
19
+ }
20
+ /**
21
+ * This release's version, read once when the module loads, so a long-running process whose install is replaced in place keeps comparing its own
22
+ * version. Undefined when it cannot be read: then every applicable floor refuses.
23
+ */
24
+ const READER_VERSION: string | undefined = (() => {
25
+ try { const { version } = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as { version?: unknown }; return typeof version === "string" && SEMVER.test(version) ? version : undefined; }
26
+ catch { return undefined; }
27
+ })();
28
+ const parts = (version: string) => SEMVER.exec(version)!.slice(1, 4).map(Number);
29
+ function below(reader: string, floor: string): boolean {
30
+ const a = parts(reader), b = parts(floor);
31
+ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i]! < b[i]!;
32
+ return false;
33
+ }
34
+
35
+ /** Bounded read of the floor records: undefined when there is no file; anything that is not a regular file of valid records refuses. */
36
+ export function readFormatFloors(home: string): FormatFloor[] | undefined {
37
+ const file = join(home, FORMATS_FILE), unreadable = (why: string) => new FormatFloorError("format_floor_unreadable", `${file}: ${why}; nothing was read or written. Fix or restore the file (it is append-only and written by a person).`);
38
+ let fd: number;
39
+ try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
40
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT" || (error as NodeJS.ErrnoException).code === "ENOTDIR") return undefined; throw unreadable("not a readable regular file"); }
41
+ let text: string;
42
+ try {
43
+ const stat = fstatSync(fd);
44
+ if (!stat.isFile() || stat.size > MAX_BYTES) throw unreadable("not a regular file of at most 64 KiB");
45
+ const data = Buffer.alloc(MAX_BYTES + 1); let size = 0, n: number;
46
+ while (size < data.length && (n = readSync(fd, data, size, data.length - size, null)) > 0) size += n;
47
+ if (size > MAX_BYTES) throw unreadable("not a regular file of at most 64 KiB");
48
+ text = data.subarray(0, size).toString("utf8");
49
+ } finally { closeSync(fd); }
50
+ let value: unknown;
51
+ try { value = JSON.parse(text); } catch { throw unreadable("not JSON"); }
52
+ if (!Array.isArray(value) || !value.every(record => record && typeof record === "object" && !Array.isArray(record)
53
+ && typeof record.surface === "string" && typeof record.minReader === "string" && SEMVER.test(record.minReader) && typeof record.since === "string")) throw unreadable("not an array of { surface, minReader, since } records");
54
+ return value.map(({ surface, minReader, since }) => ({ surface, minReader, since }));
55
+ }
56
+
57
+ /** Refuse before touching `surface` when the home records a floor above this release. */
58
+ export function assertFormatFloor(home: string, surface: FormatSurface): void {
59
+ const floors = readFormatFloors(home);
60
+ if (!floors) return;
61
+ const reader = READER_VERSION ?? "unknown";
62
+ for (const floor of floors) if ((floor.surface === surface || floor.surface === "home") && (READER_VERSION === undefined || below(READER_VERSION, floor.minReader))) {
63
+ throw new FormatFloorError("format_floor", `amem ${reader} is below the ${surface} format floor ${floor.minReader} (since ${floor.since}) recorded in ${join(home, FORMATS_FILE)}; nothing was read or written. Upgrade amem to ${floor.minReader} or later.`, { ...floor, reader });
64
+ }
65
+ }
@@ -0,0 +1,15 @@
1
+ /** Bumped when the index's tables change: an index with another version is dropped and rebuilt from the logs. */
2
+ export const INDEX_VERSION = 3; // 3: suggest, confirm and reject ops (an older index skipped them)
3
+ /** The version that released 0.14.0 through 0.18.0 build into the unversioned names below; it keeps them so this release shares their warm index. */
4
+ const SHARED_INDEX_VERSION = 3;
5
+
6
+ /**
7
+ * The derived curated index file of a profile directory, one per `INDEX_VERSION` (#199 AC4): two releases with different index versions on
8
+ * one home each keep their own file instead of dropping and rebuilding the other's. Version 3 keeps the released names (`index.db`, and
9
+ * `index-v2.db` for the v2 public-prune view, whose `v2` is that view's format, not the index version); every later version names its own,
10
+ * `index-v<N>.db` or `index-v2-i<N>.db`. An older version's file is left as it is.
11
+ */
12
+ export function indexFileName(foreignPruneFormat: boolean, version: number = INDEX_VERSION): string {
13
+ const base = foreignPruneFormat ? "index-v2" : "index";
14
+ return version === SHARED_INDEX_VERSION ? `${base}.db` : foreignPruneFormat ? `${base}-i${version}.db` : `${base}-v${version}.db`;
15
+ }
@@ -1,8 +1,10 @@
1
1
  import { closeSync, constants, fstatSync, openSync, readSync, appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import type { HookRssSources } from "../hook-resources.ts";
3
+ import type { HookTranscriptStopReason } from "../hook-contract.ts";
2
4
  import { join } from "node:path";
3
5
 
4
6
  /** Rolling resource samples; importing this leaf never loads a memory store or capacity probe. */
5
- export interface MetricSample { at: string; kind: "hook" | "search"; wallMs?: number; rssMB?: number; rawRssMB?: number; idleMB?: number; captureDrops?: number; captureDropReason?: CaptureDropReason }
7
+ export interface MetricSample { at: string; kind: "hook" | "search"; wallMs?: number; rssMB?: number; rawRssMB?: number; rssSources?: HookRssSources; rssScope?: "owned-images"; transcriptOffset?: number; transcriptStopReason?: HookTranscriptStopReason; /** #226: owned children killed but still alive when the hook returned (bounded reap). */ abandonedChildren?: number; /** #226: a killed worker skipped this call's drain; staged records stay pending (not a capture drop). */ drainSkipped?: number; /** #226: when the worker drain started (ms after hook start) and how many records it committed. */ drainStartMs?: number; drained?: number; idleMB?: number; captureDrops?: number; captureDropReason?: CaptureDropReason }
6
8
  const METRICS_MAX_BYTES = 64 * 1024, METRICS_KEEP = 200;
7
9
 
8
10
  export function metricsPath(base: string): string {
@@ -36,8 +38,8 @@ export function readMetrics(base: string, limit = METRICS_KEEP): MetricSample[]
36
38
  }
37
39
 
38
40
 
39
- export type CaptureDropReason = "spool_full" | "cwd_removed" | "undrainable_expired" | "scope_unknown" | "project_archived" | "project_forgotten" | "unknown_schema_expired";
40
- const DROP_REASONS: readonly CaptureDropReason[] = ["spool_full", "cwd_removed", "undrainable_expired", "scope_unknown", "project_archived", "project_forgotten", "unknown_schema_expired"];
41
+ export type CaptureDropReason = "summary_capacity" | "scope_capacity" | "spool_full" | "cwd_removed" | "undrainable_expired" | "scope_unknown" | "project_archived" | "project_forgotten" | "unknown_schema_expired" | "transcript_line_bound";
42
+ const DROP_REASONS: readonly CaptureDropReason[] = ["summary_capacity", "scope_capacity", "spool_full", "cwd_removed", "undrainable_expired", "scope_unknown", "project_archived", "project_forgotten", "unknown_schema_expired", "transcript_line_bound"];
41
43
  /** Numeric notice only, bounded to the rolling metric tail; never an all-time counter. Fails open to no counts: hooks never deny over metrics. */
42
44
  export function captureDropSummary(base: string): Partial<Record<CaptureDropReason, number>> {
43
45
  const read = readCaptureDrops(base);
@@ -2,6 +2,7 @@
2
2
  * Checkpoints are fault seams after intent, shard marker, selector and intent removal.
3
3
  * A caller resumes an interrupted operation from its immutable direct-profile journal. */
4
4
  import { createHash } from "node:crypto";
5
+ import { indexFileName } from "./index-file.ts";
5
6
  import { closeSync, constants, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
6
7
  import { dirname, join } from "node:path";
7
8
  import { canonicalJSON, fsyncDirectory, hashJSON, readSelector, withProfileLock, writeProfileJSON, type BoundProfile } from "./generation.ts";
@@ -105,7 +106,7 @@ function immutable(file: string, text: string): void {
105
106
  linkSync(temp, file); fsyncDirectory(base); unlinkSync(temp); fsyncDirectory(base);
106
107
  }
107
108
  function syncIndex(profile: BoundProfile): void {
108
- const file = join(profile.dir, "index-v2.db"); owned(file); const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
109
+ const file = join(profile.dir, indexFileName(true)); owned(file); const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
109
110
  try { fsyncSync(fd); } finally { closeSync(fd); } fsyncDirectory(profile.dir);
110
111
  }
111
112
  function nextProfile(realm: Realm, intent: Intent): BoundProfile {
@@ -49,14 +49,18 @@ export function waitForPublicPruneWriter(home: string, absoluteDeadline?: number
49
49
  const deadline = Math.min(absoluteDeadline ?? Infinity, Date.now() + PROFILE_LOCK_TIMEOUT_MS);
50
50
  const intentFile = join(home, PUBLIC_PRUNE_INTENT), lockFile = join(home, "profile.lock");
51
51
  if (!lstatSync(intentFile, { throwIfNoEntry: false }) && !lstatSync(lockFile, { throwIfNoEntry: false })) return;
52
+ // #225: a cooperating writer that finishes unlinks its intent, then its lock. If that lands between this reader's
53
+ // open and fstat, the fd's link count is 0. Only that is the normal disappearance (ENOENT): bytes read through the fd
54
+ // are discarded and the bounded loop re-resolves the path from scratch. Any other link count still refuses.
55
+ const unlinked = (stat: Stats): void => { if (stat.nlink === 0) throw Object.assign(new Error("publication unlinked while open"), { code: "ENOENT" }); };
52
56
  const small = (file: string, limit: number): Record<string, unknown> => {
53
57
  const before = lstatSync(file); privateStat(before); guard(before.size > 0 && before.size <= limit);
54
58
  const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
55
59
  try {
56
- const opened = fstatSync(fd); privateStat(opened); guard(opened.size > 0 && opened.size <= limit);
60
+ const opened = fstatSync(fd); unlinked(opened); privateStat(opened); guard(opened.size > 0 && opened.size <= limit);
57
61
  const bytes = Buffer.alloc(opened.size + 1); let count = 0;
58
62
  while (count < bytes.length) { const read = readSync(fd, bytes, count, bytes.length - count, count); if (!read) break; count += read; }
59
- const after = fstatSync(fd); privateStat(after); guard(count === opened.size && same(opened, after));
63
+ const after = fstatSync(fd); unlinked(after); privateStat(after); guard(count === opened.size && same(opened, after));
60
64
  const parsed: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes.subarray(0, count))); guard(object(parsed)); return parsed;
61
65
  } finally { closeSync(fd); }
62
66
  };
@@ -17,12 +17,13 @@ const DEFAULT_TIER_WEIGHT = 0.9;
17
17
  // ponytail: fixed 1.25x ceiling for conversational capture, not a learned relevance model.
18
18
  // Raise it only with fresh S/M no-loss and private-capture evidence; curated tier factors stay unchanged.
19
19
  export const CAPTURE_CONVERSATION_WEIGHT = 1.25;
20
+ const CONVERSATIONAL_CAPTURE_EVENTS = ["Stop", "UserPromptSubmit", "Title", "Recap", "Compact"] as const;
20
21
  export function captureEventWeight(event: string): number {
21
- return event === "Stop" || event === "UserPromptSubmit" ? CAPTURE_CONVERSATION_WEIGHT : 1;
22
+ return (CONVERSATIONAL_CAPTURE_EVENTS as readonly string[]).includes(event) ? CAPTURE_CONVERSATION_WEIGHT : 1;
22
23
  }
23
24
  /** Only fixed internal SQL identifiers are supplied by callers; event values remain stored data. */
24
25
  export function captureEventWeightSql(eventColumn: string): string {
25
- return `(CASE WHEN ${eventColumn} IN ('Stop', 'UserPromptSubmit') THEN ${CAPTURE_CONVERSATION_WEIGHT} ELSE 1.0 END)`;
26
+ return `(CASE WHEN ${eventColumn} IN (${CONVERSATIONAL_CAPTURE_EVENTS.map(event => `'${event}'`).join(", ")}) THEN ${CAPTURE_CONVERSATION_WEIGHT} ELSE 1.0 END)`;
26
27
  }
27
28
  const TIER_FACTOR: ScoreExpr = { op: "tier" };
28
29
  const DENOMINATOR: ScoreExpr = { op: "add", left: { op: "constant", value: 1 }, right: { op: "max", left: { op: "constant", value: 0 }, right: { op: "age" } } };
@@ -1,4 +1,5 @@
1
1
  import { assertNoPublicPruneIntent, readPublicPruneView } from "./public-prune-view.ts";
2
+ import { indexFileName } from "./index-file.ts";
2
3
  import { createHash, randomBytes } from "node:crypto";
3
4
  import { existsSync, linkSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
5
  import { join } from "node:path";
@@ -145,7 +146,7 @@ export function assertCuratedShardPaths(memoryDir: string, opts: { project?: str
145
146
  check(dir, true);
146
147
  for (const folder of ["log", "local", "legacy", "legacy/log", "legacy/local", "legacy-main", "legacy-main/log", "legacy-main/local"]) check(join(dir, folder), true);
147
148
  for (const name of ["project.json", "generation.json", "generation-seal.json", "migration-sources.json", "machine.json", "legacy/machine.json", "legacy-main/machine.json", "prune-translations.jsonl"]) check(join(dir, name), false);
148
- for (const database of ["index.db", "index-v2.db", "search.db", "mutation-lock.db"]) for (const suffix of ["", "-wal", "-shm", "-journal"]) check(join(dir, database + suffix), false);
149
+ for (const database of new Set(["index.db", "index-v2.db", indexFileName(false), indexFileName(true), "search.db", "mutation-lock.db"])) for (const suffix of ["", "-wal", "-shm", "-journal"]) check(join(dir, database + suffix), false);
149
150
  }
150
151
 
151
152
  /** Open one already-sealed shard without consulting the legacy home or the catalog. */
@@ -1,4 +1,6 @@
1
+ import { assertFormatFloor } from "./formats.ts";
1
2
  import { readPublicPruneView } from "./public-prune-view.ts";
3
+ import { INDEX_VERSION, indexFileName } from "./index-file.ts";
2
4
  import { readShardLegacySnapshot } from "./shard-bridge.ts";
3
5
  import { cliAttribution } from "./cli-attribution.ts";
4
6
  import { Database } from "bun:sqlite";
@@ -77,8 +79,6 @@ export type Entry = NoteEntry | OpEntry | GoalEntry;
77
79
  export const GOAL_STATES = ["open", "active", "blocked", "done", "dropped"] as const;
78
80
  export type GoalState = (typeof GOAL_STATES)[number];
79
81
  export const DEFAULT_LEASE_MS = 4 * 3600_000;
80
- /** Bumped when the index's tables change: an index with another version is dropped and rebuilt from the logs. */
81
- const INDEX_VERSION = 3; // 3: suggest, confirm and reject ops (an older index skipped them)
82
82
  export const SUGGESTED = ["contradicts", "same_topic"] as const;
83
83
  export type Suggested = (typeof SUGGESTED)[number];
84
84
  export interface Suggestion { id: string; at: string; from: string; to: string; rel: Suggested; status: "suggested" | "confirmed" | "rejected" }
@@ -233,6 +233,7 @@ export class MemoryStore {
233
233
  this.reconcile = opts.reconcile && opts.reconcile.aliases.length ? { canonical: opts.reconcile.canonical, aliases: [...opts.reconcile.aliases] } : undefined;
234
234
  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");
235
235
  const home = bootstrap?.profile.home ?? opts.memoryDir ?? (opts.home === undefined ? memoryHome() : join(opts.home, "memory"));
236
+ assertFormatFloor(home, "curated");
236
237
  if (!bootstrap && lstatSync(join(home, "shards.json"), { throwIfNoEntry: false })) throw new Error("memory home has a curated split; hosts must use openShardStore({ project }) or openShardStore({ user: true }); finish an incomplete split with amem migrate --shards --resume");
237
238
  this.profile = bootstrap?.profile ?? resolveProfile(home, opts.profile);
238
239
  if (bootstrap && (this.profile.kind !== "generation" || !this.profile.generation
@@ -253,7 +254,7 @@ export class MemoryStore {
253
254
  if (!bootstrap || bootstrap.authoring === true) this.releaseReaderLease = registerReaderLease(this.profile.home, this.profile.sharded ? {scope:"shard"} : {});
254
255
  let openedDb: Database | undefined, openedMutation: Database | undefined, openedSearch: SearchIndex | undefined;
255
256
  try {
256
- const indexFile = join(this.dir, this.profile.foreignPruneFormat ? "index-v2.db" : "index.db");
257
+ const indexFile = join(this.dir, indexFileName(!!this.profile.foreignPruneFormat));
257
258
  if (this.profile.foreignPruneFormat) for (const suffix of ["", "-wal", "-shm", "-journal"]) {
258
259
  const stat = lstatSync(indexFile + suffix, {throwIfNoEntry:false});
259
260
  if (stat && (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1 || stat.uid !== process.getuid?.())) throw new Error("v2 index path unavailable");
@@ -280,7 +281,7 @@ export class MemoryStore {
280
281
  // One derived FTS copy per profile: a generation handle and a shared-v1 handle on one home never resync-fight.
281
282
  this.searchIndex = openedSearch = new SearchIndex(this.profile.sharded ? join(this.dir, "search.db") : join(home, this.profile.kind === "generation" ? `search-${this.profile.generation}.db` : "search.db"));
282
283
  this.memoryBaseDir = home;
283
- try { chmodSync(join(this.dir, this.profile.foreignPruneFormat ? "index-v2.db" : "index.db"), 0o600); } catch { /* best effort */ }
284
+ try { chmodSync(indexFile, 0o600); } catch { /* best effort */ }
284
285
  if (this.profile.kind === "generation") this.generationIndex = new GenerationIndex({ profile: this.profile, db, now: this.now,
285
286
  apply: (line, source) => this.apply(line, source), withMutation: work => this.withMutation(work),
286
287
  ...(bootstrap ? { limits: bootstrap.limits } : {}), ...(bootstrap?.commit ? { commit: bootstrap.commit } : {}) });
@@ -1633,6 +1634,7 @@ export function openShardStore(opts: StoreOptions & { project?: string; user?: b
1633
1634
  if (opts.user === true && opts.project !== undefined || opts.user !== true && !opts.project) throw new Error("choose exactly one project or the user shard");
1634
1635
  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");
1635
1636
  const home = opts.memoryDir ?? (opts.home === undefined ? memoryHome() : join(opts.home, "memory"));
1637
+ assertFormatFloor(home, "curated");
1636
1638
  assertCuratedShardPaths(home, opts);
1637
1639
  // Lifecycle refusal must remain before admission creates any lease/fence file.
1638
1640
  if (opts.user !== true && opts.lifecycle !== "ignore") assertProjectActive(home, opts.project!);
@@ -1,3 +1,4 @@
1
+ import { assertFormatFloor } from "./formats.ts";
1
2
  import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, readSync, rmSync, writeFileSync, writeSync, type BigIntStats } from "node:fs";
2
3
  import { createHash } from "node:crypto";
3
4
  import { dirname, join, relative, sep } from "node:path";
@@ -284,6 +285,7 @@ function captureShardSyncBinding(store: MemoryStore, selection: boolean): string
284
285
  }
285
286
 
286
287
  export async function syncMemory(store: MemoryStore, opts: SyncOptions = {}): Promise<SyncResult> {
288
+ assertFormatFloor(store.profile.home, "sync");
287
289
  if (store.profile.sharded) {
288
290
  try {
289
291
  // Refresh only through the existing proved monotone profile rebind before pinning this attempt.
@@ -1,3 +1,4 @@
1
+ import { assertFormatFloor } from "./formats.ts";
1
2
  import { Database } from "bun:sqlite";
2
3
  import { createHash, randomBytes } from "node:crypto";
3
4
  import { appendFileSync, chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readSync, readdirSync } from "node:fs";
@@ -131,6 +132,7 @@ export class WorkListStore {
131
132
  || !Number.isSafeInteger(this.busyTimeoutMs) || this.busyTimeoutMs < 0 || this.busyTimeoutMs > 3000) throw new Error("invalid work-list budget");
132
133
  this.checkBudget();
133
134
  const base = canonicalPath(options.base);
135
+ assertFormatFloor(base, "work-lists");
134
136
  this.project = options.exact === true ? options.project : resolveAlias(base, options.project);
135
137
  regular(join(base, "projects"), true); regular(shardDir(base, this.project), true);
136
138
  this.checkBudget();