@staix/agent-mem 0.17.0 → 0.19.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,8 +1,11 @@
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";
5
- import { sessionStartBrief } from "../session-brief.ts";
7
+ import { sessionStartBrief, type BriefOptions } from "../session-brief.ts";
8
+ import { openCuratedForRead } from "./curated-read.ts";
6
9
  import { pendingSummary } from "./candidate-pending.ts";
7
10
  import { readCaptureDrops } from "./metrics.ts";
8
11
  import { walkShards } from "./catalog.ts";
@@ -11,10 +14,11 @@ import { readSelector } from "./generation.ts";
11
14
  import { processLiveness, withGitTimeout } from "./platform.ts";
12
15
  import { endLikeSignal, STOP_SIGNALS } from "./sync-cadence.ts";
13
16
  import { boundRepoRoot } from "./repo-scope.ts";
14
- import { facts, label, PLAIN, type Style } from "./style.ts";
15
- import { shardDir } from "./shard.ts";
17
+ import { facts, label, PLAIN, singleLine, type Style } from "./style.ts";
18
+ import { aliasedKeys, shardDir } from "./shard.ts";
16
19
  import type { MemoryStore, View } from "./store.ts";
17
20
  import { nextSyncAt, readSyncState, remoteConfigured, syncDue, usableState } from "./sync-cadence.ts";
21
+ import { readSetupDrift } from "../setup-drift.ts";
18
22
 
19
23
  /**
20
24
  * 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
@@ -41,6 +45,8 @@ const MAX_IDS = 10;
41
45
 
42
46
  export interface DoctorOptions {
43
47
  home: string; homeSource: string; cwd: string; project: string; aliases: readonly string[]; now: number;
48
+ /** Test seam for setup path resolution; the CLI uses the process environment. */
49
+ env?: Readonly<Record<string, string | undefined>>;
44
50
  /** 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. */
45
51
  openCurated: (base: string) => MemoryStore;
46
52
  /** The cadence command the person runs from their own shell; shown in the report, never installed. */
@@ -59,6 +65,8 @@ export interface DoctorReport {
59
65
  drops: Section;
60
66
  /** #188, additive: present only when the project's curated logs hold forget or prune operations. Informational; status is always ok. */
61
67
  masked?: Section;
68
+ /** #202, additive: applied native setup registrations checked against the shared path resolver. */
69
+ setup?: Section;
62
70
  };
63
71
  }
64
72
 
@@ -93,7 +101,22 @@ function curatedPresent(home: string, project: string, layout: Layout): boolean
93
101
  /** What a scratch copy leaves out: databases, locks, repositories, the machine-local capture and work trees, spools and ingest cursors. */
94
102
  const SKIP = /\.db(-wal|-shm)?$|\.lock$|\.tmp$|^\.git$|\.git$|^spool$|^ingest$|^readers$/;
95
103
  class TooBig extends Error {}
96
- function copyTree(from: string, to: string, budget: { bytes: number }, skipNames: ReadonlySet<string> = new Set()): void {
104
+ interface CopyBudget { bytes: number; verify?: (() => void)[] }
105
+ function observedCopy(from: string, to: string, budget: CopyBudget): void {
106
+ budget.bytes += statSync(from).size; if (budget.bytes > COPY_LIMIT_BYTES) throw new TooBig();
107
+ if (budget.verify) {
108
+ const stamp = () => { const s = lstatSync(from, { bigint: true }); if (!s.isFile()) throw Error("recap source is not a plain file"); return `${s.dev}:${s.ino}:${s.size}:${s.mtimeNs}:${s.ctimeNs}`; };
109
+ const before = stamp(); budget.verify.push(() => { if (stamp() !== before) throw Error("recap source changed"); });
110
+ }
111
+ copyFileSync(from, to);
112
+ }
113
+ function observeDirectory(from: string, budget: CopyBudget, keep: (name: string) => boolean): void {
114
+ if (!budget.verify) return;
115
+ const stamp = () => { const s = lstatSync(from, { bigint: true }); if (!s.isDirectory()) throw Error("recap source directory changed"); return JSON.stringify([String(s.dev), String(s.ino), readdirSync(from).filter(keep).sort()]); };
116
+ const before = stamp(); budget.verify.push(() => { if (stamp() !== before) throw Error("recap source directory changed"); });
117
+ }
118
+ function copyTree(from: string, to: string, budget: CopyBudget, skipNames: ReadonlySet<string> = new Set()): void {
119
+ observeDirectory(from, budget, name => !(SKIP.test(name) && !(basename(from) === "public-prune-objects" && /^[a-f0-9]{40}\.git$/.test(name))) && !skipNames.has(name));
97
120
  mkdirSync(to, { recursive: true, mode: 0o700 });
98
121
  for (const name of readdirSync(from)) {
99
122
  const proofObject = basename(from) === "public-prune-objects" && /^[a-f0-9]{40}\.git$/.exec(name)?.[0] === name;
@@ -102,25 +125,24 @@ function copyTree(from: string, to: string, budget: { bytes: number }, skipNames
102
125
  if (proofObject && !stat.isFile()) throw new Error("a public prune object is not a plain file");
103
126
  if (stat.isSymbolicLink()) throw new Error("the memory home holds a link where a plain file or directory is expected");
104
127
  if (stat.isDirectory()) copyTree(source, join(to, name), budget, skipNames);
105
- else if (stat.isFile()) { budget.bytes += stat.size; if (budget.bytes > COPY_LIMIT_BYTES) throw new TooBig(); copyFileSync(source, join(to, name)); }
128
+ else if (stat.isFile()) observedCopy(source, join(to, name), budget);
106
129
  }
107
130
  }
108
131
 
109
132
  /** The curated side of the home, without any database: the logs of this project (and its aliases), and the small files that bind them. */
110
- function copyCurated(options: DoctorOptions, layout: Layout, scratch: string, budget: { bytes: number }): void {
133
+ function copyCurated(options: Pick<DoctorOptions, "home" | "project" | "aliases">, layout: Layout, scratch: string, budget: CopyBudget): void {
111
134
  const home = options.home;
135
+ observeDirectory(home, budget, name => !SKIP.test(name) && regularFile(join(home, name)));
112
136
  for (const name of readdirSync(home)) { // top-level plain files: machine.json, shards.json, profile.json, aliases.jsonl, lifecycle.jsonl, the configuration
113
137
  if (SKIP.test(name) || !regularFile(join(home, name))) continue;
114
- const stat = statSync(join(home, name)); budget.bytes += stat.size; if (budget.bytes > COPY_LIMIT_BYTES) throw new TooBig();
115
- copyFileSync(join(home, name), join(scratch, name));
138
+ observedCopy(join(home, name), join(scratch, name), budget);
116
139
  }
117
140
  const selected = readSelector(home).selector;
118
141
  if (selected?.schema === "agent-mem.profile/v2") {
119
142
  const name = `${selected.pruneInputSetHash}.json`, source = join(home, "public-prune-input-sets", name), stat = lstatSync(source);
120
143
  if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error("the committed public prune view is not a plain file");
121
- budget.bytes += stat.size; if (budget.bytes > COPY_LIMIT_BYTES) throw new TooBig();
122
144
  const directory = join(scratch, "public-prune-input-sets"); mkdirSync(directory, { mode: 0o700 });
123
- copyFileSync(source, join(directory, name)); chmodSync(join(directory, name), 0o600);
145
+ observedCopy(source, join(directory, name), budget); chmodSync(join(directory, name), 0o600);
124
146
  }
125
147
  if (layout === "split") {
126
148
  for (const key of [options.project, ...options.aliases]) {
@@ -135,22 +157,59 @@ function copyCurated(options: DoctorOptions, layout: Layout, scratch: string, bu
135
157
  }
136
158
 
137
159
  /** The work logs of this project and its aliases, in a private base of their own: the brief opens a work list read-write and migrates its index. */
138
- function copyWork(options: DoctorOptions, base: string, budget: { bytes: number }): boolean {
160
+ function copyWork(options: Pick<DoctorOptions, "home" | "project" | "aliases">, base: string, budget: CopyBudget): boolean {
139
161
  let any = false;
140
- for (const name of ["machine.json", "aliases.jsonl", "lifecycle.jsonl"]) if (regularFile(join(options.home, name))) { mkdirSync(base, { recursive: true, mode: 0o700 }); copyFileSync(join(options.home, name), join(base, name)); }
162
+ for (const name of ["machine.json", "aliases.jsonl", "lifecycle.jsonl"]) if (regularFile(join(options.home, name))) { mkdirSync(base, { recursive: true, mode: 0o700 }); observedCopy(join(options.home, name), join(base, name), budget); }
141
163
  for (const key of [options.project, ...options.aliases]) {
142
164
  const dir = shardDir(options.home, key), log = join(dir, "work", "log");
143
165
  if (!regularDir(log)) continue;
144
166
  copyTree(log, join(base, "projects", dir.split("/").pop()!, "work", "log"), budget);
145
- if (regularFile(join(dir, "project.json"))) copyFileSync(join(dir, "project.json"), join(base, "projects", dir.split("/").pop()!, "project.json"));
167
+ if (regularFile(join(dir, "project.json"))) observedCopy(join(dir, "project.json"), join(base, "projects", dir.split("/").pop()!, "project.json"), budget);
146
168
  any = true;
147
169
  }
148
170
  return any;
149
171
  }
150
172
 
173
+ /** Reuse doctor's bounded private inputs for an on-demand brief, never a live store or reader lease. */
174
+ export async function withBriefSnapshot<T>(home: string, project: string, read: (inputs: NonNullable<BriefOptions["measureOnly"]>) => Promise<T>): Promise<T> {
175
+ const aliases = aliasedKeys(home, project), selection = readSelector(home).revision, layout = layoutOf(home), guard = guardScratch();
176
+ let scratch: string | undefined, curated: MemoryStore | undefined;
177
+ const budget: CopyBudget = { bytes: 0, verify: [] };
178
+ const inputDirectories = [join(home, "projects"), ...(layout === "split" ? [join(home, "user")] : [join(home, "log"), join(home, "local")]),
179
+ ...(layout === "generation" ? [join(home, "generations")] : []),
180
+ ...[project, ...aliases].flatMap(key => { const dir = shardDir(home, key); return [dir, join(dir, "work"), join(dir, "work", "log")]; })];
181
+ const checkDirectories = () => {
182
+ for (const dir of inputDirectories) { const stat = lstatSync(dir, { throwIfNoEntry: false }); if (stat && !stat.isDirectory()) throw Error("recap input is not a plain directory"); }
183
+ };
184
+ const check = () => {
185
+ checkDirectories();
186
+ if (readSelector(home).revision !== selection || layoutOf(home) !== layout || JSON.stringify(aliasedKeys(home, project)) !== JSON.stringify(aliases)) throw Error("recap binding changed");
187
+ for (const verify of budget.verify!) verify();
188
+ };
189
+ try {
190
+ checkDirectories();
191
+ scratch = mkdtempSync(join(tmpdir(), `amem-doctor-${process.pid}-`)); chmodSync(scratch, 0o700); guard.adopt(scratch);
192
+ const options = { home, project, aliases }, workBase = join(scratch, "work-base"); mkdirSync(workBase, { mode: 0o700 });
193
+ copyWork(options, workBase, budget);
194
+ if (curatedPresent(home, project, layout)) {
195
+ const copy = join(scratch, "home"); mkdirSync(copy, { mode: 0o700 }); copyCurated(options, layout, copy, budget);
196
+ curated = openCuratedForRead(copy, project);
197
+ if (!curated) throw Error("recap curated snapshot unavailable");
198
+ }
199
+ check();
200
+ const result = await read({ ...(curated ? { curated } : {}), workBase });
201
+ check(); return result;
202
+ } catch (error) {
203
+ if (error instanceof TooBig) throw Error("recap snapshot exceeds the 64 MiB copy limit");
204
+ throw error;
205
+ } finally {
206
+ try { curated?.close(); } finally { try { if (scratch) rmSync(scratch, { recursive: true, force: true }); } finally { guard.release(); } }
207
+ }
208
+ }
209
+
151
210
  /** A snapshot of the live shared-v1 index (a read-only connection, so no checkpoint, no lease), or false when there is none. */
152
211
  function snapshotIndex(live: string, to: string): boolean {
153
- const file = join(live, "index.db");
212
+ const file = join(live, indexFileName(false));
154
213
  if (!regularFile(file)) return false;
155
214
  const forms: (() => Database)[] = [
156
215
  () => new Database(file, { readonly: true }),
@@ -383,7 +442,11 @@ function guardScratch(): { adopt: (dir: string) => void; release: () => void } {
383
442
  * a lone signal waits at most one call for the signal handler, which then removes the scratch copy and ends doctor.
384
443
  */
385
444
  export const DOCTOR_GIT_TIMEOUT_MS = 5_000;
386
- export function doctorReport(options: DoctorOptions): Promise<DoctorReport> { return withGitTimeout(DOCTOR_GIT_TIMEOUT_MS, () => runDoctor(options)); }
445
+ export function doctorReport(options: DoctorOptions): Promise<DoctorReport> {
446
+ // 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).
447
+ for (const surface of ["curated", "capture", "candidates", "work-lists"] as const) assertFormatFloor(options.home, surface);
448
+ return withGitTimeout(DOCTOR_GIT_TIMEOUT_MS, () => runDoctor(options));
449
+ }
387
450
  async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
388
451
  let scratch: string | undefined, unguard: (() => void) | undefined;
389
452
  let duplicates: Section, stale: Section, index: Section;
@@ -407,7 +470,7 @@ async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
407
470
  } else {
408
471
  const home = join(scratch, "home"); mkdirSync(home, { mode: 0o700 });
409
472
  copyCurated(options, layout, home, budget);
410
- const snapshotted = layout === "shared-v1" && snapshotIndex(options.home, join(home, "index.db"));
473
+ const snapshotted = layout === "shared-v1" && snapshotIndex(options.home, join(home, indexFileName(false)));
411
474
  curated = options.openCurated(home);
412
475
  const rows = curated.list({ project: options.project, limit: LIST_ALL });
413
476
  await tick();
@@ -428,10 +491,13 @@ async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
428
491
  candidates = summary === null ? unavailable("candidates.db exists but cannot be read")
429
492
  : { status: summary.dueWithin7Days ? "attention" : "ok", ...summary, ...(summary.dueWithin7Days ? { hint: "run amem review walk" } : {}) };
430
493
  } catch (error) { candidates = unavailable(message(error)); }
494
+ let setup: Section;
495
+ try { setup = { ...readSetupDrift({ home: options.home, ...(options.env ? { env: options.env } : {}), cwd: options.cwd }) }; }
496
+ catch { setup = unavailable("setup registrations could not be checked"); }
431
497
  let sections: DoctorReport["sections"];
432
498
  try {
433
499
  await tick();
434
- 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) };
500
+ 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), setup, ...maskedSection(curated) };
435
501
  await tick(); // a signal that arrived during the last synchronous stage is handled while the guard is still installed
436
502
  } finally { try { curated?.close(); } finally { try { if (scratch) rmSync(scratch, { recursive: true, force: true }); } finally { unguard?.(); } } }
437
503
  return { schema: DOCTOR_SCHEMA, status: worst(Object.values(sections).map(section => section.status)), home: { path: options.home, source: options.homeSource }, project: options.project, sections };
@@ -441,6 +507,7 @@ async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
441
507
  const SECTION_TITLE: Record<keyof DoctorReport["sections"], string> = {
442
508
  duplicates: "duplicate notes", stale: "stale path references", candidates: "review candidates", capture: "capture per CLI", brief: "session brief",
443
509
  index: "index equals rebuild", readers: "reader leases", policy: "policy in effect", cadence: "ingest and sync cadence", drops: "recent capture drops",
510
+ setup: "native setup registrations",
444
511
  masked: "forgotten or pruned entries (informational)",
445
512
  };
446
513
 
@@ -461,6 +528,10 @@ function sectionDetail(name: string, detail: Record<string, unknown>, s: Style):
461
528
  const counts = Object.entries((detail.counts ?? {}) as Record<string, number>);
462
529
  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);
463
530
  }
531
+ if (name === "setup" && Array.isArray(detail.entries)) {
532
+ return (detail.entries as { target?: unknown; file?: unknown; entry?: unknown; reason?: unknown; expectedFile?: unknown }[]).map(item =>
533
+ singleLine(`${item.target} ${item.file}${item.expectedFile ? ` -> ${item.expectedFile}` : ""} ${item.entry}: ${item.reason}`));
534
+ }
464
535
  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"}` : ""}`];
465
536
  const { examples, ...rest } = detail;
466
537
  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,9 @@
1
1
  import { closeSync, constants, fstatSync, openSync, readSync, appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import type { HookRssSources } from "../hook-resources.ts";
2
3
  import { join } from "node:path";
3
4
 
4
5
  /** 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 }
6
+ export interface MetricSample { at: string; kind: "hook" | "search"; wallMs?: number; rssMB?: number; rawRssMB?: number; rssSources?: HookRssSources; rssScope?: "owned-images"; transcriptOffset?: number; transcriptStopReason?: "cap" | "deadline" | "spool_full"; idleMB?: number; captureDrops?: number; captureDropReason?: CaptureDropReason }
6
7
  const METRICS_MAX_BYTES = 64 * 1024, METRICS_KEEP = 200;
7
8
 
8
9
  export function metricsPath(base: string): string {
@@ -36,8 +37,8 @@ export function readMetrics(base: string, limit = METRICS_KEEP): MetricSample[]
36
37
  }
37
38
 
38
39
 
39
- export type CaptureDropReason = "spool_full" | "cwd_removed" | "undrainable_expired" | "scope_unknown" | "project_archived" | "project_forgotten";
40
- const DROP_REASONS: readonly CaptureDropReason[] = ["spool_full", "cwd_removed", "undrainable_expired", "scope_unknown", "project_archived", "project_forgotten"];
40
+ export type CaptureDropReason = "spool_full" | "cwd_removed" | "undrainable_expired" | "scope_unknown" | "project_archived" | "project_forgotten" | "unknown_schema_expired" | "transcript_line_bound";
41
+ const DROP_REASONS: readonly CaptureDropReason[] = ["spool_full", "cwd_removed", "undrainable_expired", "scope_unknown", "project_archived", "project_forgotten", "unknown_schema_expired", "transcript_line_bound"];
41
42
  /** 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
43
  export function captureDropSummary(base: string): Partial<Record<CaptureDropReason, number>> {
43
44
  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 {
@@ -1,4 +1,4 @@
1
- import { rankedScoreSql, rankedScoreSqlDetails, type ScoreFactors } from "./search-score.ts";
1
+ import { captureEventWeightSql, rankedScoreSql, rankedScoreSqlDetails, type ScoreFactors } from "./search-score.ts";
2
2
  import { Database } from "bun:sqlite";
3
3
  import { chmodSync, existsSync } from "node:fs";
4
4
  import { join } from "node:path";
@@ -204,8 +204,8 @@ export class ScreenedSearchIndex<T> {
204
204
  if ((db.query("SELECT sqlite_compileoption_used('TEMP_STORE=3') AS memoryOnly").get() as { memoryOnly: number }).memoryOnly) throw Error("disk-backed search unavailable");
205
205
  db.run("PRAGMA temp_store = FILE");
206
206
  db.run(this.available
207
- ? "CREATE VIRTUAL TABLE temp.docs USING fts5(body, ref UNINDEXED, tier UNINDEXED, at UNINDEXED, payload UNINDEXED, expires UNINDEXED)"
208
- : "CREATE TEMP TABLE docs (body TEXT, ref TEXT, tier TEXT, at TEXT, payload TEXT, expires REAL)");
207
+ ? "CREATE VIRTUAL TABLE temp.docs USING fts5(body, ref UNINDEXED, tier UNINDEXED, at UNINDEXED, payload UNINDEXED, expires UNINDEXED, event UNINDEXED, session UNINDEXED)"
208
+ : "CREATE TEMP TABLE docs (body TEXT, ref TEXT, tier TEXT, at TEXT, payload TEXT, expires REAL, event TEXT, session TEXT)");
209
209
  db.run("PRAGMA temp.cache_size = -2048");
210
210
  // A platform spill minimum can exceed cache_size (macOS: 20,000 pages).
211
211
  // Spill at the cache target so dirty TEMP pages cannot accumulate behind that minimum.
@@ -214,13 +214,13 @@ export class ScreenedSearchIndex<T> {
214
214
  } catch (error) { db?.close(); throw error; }
215
215
  }
216
216
 
217
- append(rows: readonly { body: string; ref: string; tier: string; at: string; value: T; expiresAt?: string | null }[]): void {
218
- const insert = this.db.query("INSERT INTO docs (body, ref, tier, at, payload, expires) VALUES (?, ?, ?, ?, ?, ?)");
217
+ append(rows: readonly { body: string; ref: string; tier: string; at: string; value: T; expiresAt?: string | null; event?: string; session?: string }[]): void {
218
+ const insert = this.db.query("INSERT INTO docs (body, ref, tier, at, payload, expires, event, session) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
219
219
  this.db.transaction(() => {
220
220
  for (const row of rows) {
221
221
  const deadline = row.expiresAt == null ? null : Date.parse(row.expiresAt);
222
222
  // Invalid deadlines fail closed; this sentinel predates every representable Date.
223
- insert.run(row.body, row.ref, row.tier, row.at, JSON.stringify(row.value), deadline !== null && !Number.isFinite(deadline) ? Number.MIN_SAFE_INTEGER : deadline);
223
+ insert.run(row.body, row.ref, row.tier, row.at, JSON.stringify(row.value), deadline !== null && !Number.isFinite(deadline) ? Number.MIN_SAFE_INTEGER : deadline, row.event ?? null, row.session ?? null);
224
224
  }
225
225
  })();
226
226
  }
@@ -242,6 +242,31 @@ export class ScreenedSearchIndex<T> {
242
242
  });
243
243
  }
244
244
 
245
+ /** Weighted capture-only fill after the caller fixes the legacy noncapture slots. The caller has already screened every row before append. */
246
+ bestCaptureGroups(query: string, limit: number, now: number, useFts = this.available, explain = false): { value: T; score: number; scoreDetails?: ScoreFactors }[] {
247
+ if (!Number.isSafeInteger(limit) || limit < 1 || useFts && !ftsTerms(query)) return [];
248
+ const instant = new Date(now).toISOString(), score = useFts ? SCREENED_SCORE : SCREENED_SUBSTRING_SCORE;
249
+ const weight = captureEventWeightSql("event");
250
+ const factors = explain ? rankedScoreSqlDetails(useFts ? "bm25(docs)" : "-1.0", useFts) : undefined;
251
+ const columns = factors ? `, ${factors.rawBm25Rank} AS rawBm25Rank, ${factors.tierFactor} AS tierFactor, ${factors.recencyFactor} AS recencyFactor` : "";
252
+ const where = "WHERE tier = 'capture' AND (expires IS NULL OR expires > ?)" + (useFts ? " AND docs MATCH ?" : "");
253
+ const selected: { value: T; score: number; scoreDetails?: ScoreFactors }[] = [], groups = new Set<string>();
254
+ // Sort once in SQLite-owned TEMP storage, then stream past duplicate sessions without growing JS pages or re-sorting OFFSET windows.
255
+ const statement = this.db.query(`SELECT payload, ref, event, session, ${score} AS score${columns} FROM docs ${where} ORDER BY score * ${weight} DESC, ref`);
256
+ const orderedArgs = [instant, ...(explain ? [instant] : []), now, ...(useFts ? [ftsTerms(query)] : [])];
257
+ for (const row of statement.iterate(...orderedArgs) as Iterable<{ payload: string; ref: string; event: string | null; session: string | null; score: number; rawBm25Rank?: number | null; tierFactor?: number | null; recencyFactor?: number | null }>) {
258
+ const session = typeof row.session === "string" && /^[a-f0-9]{16}$/.test(row.session) ? row.session : undefined;
259
+ const group = session ? `session:${session}` : `row:${row.ref}`;
260
+ if (groups.has(group)) continue;
261
+ groups.add(group);
262
+ const hit: { value: T; score: number; scoreDetails?: ScoreFactors } = { value: JSON.parse(row.payload) as T, score: row.score };
263
+ if (explain) hit.scoreDetails = { rawBm25Rank: row.rawBm25Rank ?? null, tierFactor: row.tierFactor ?? null, recencyFactor: row.recencyFactor ?? null, finalScore: row.score, matchMode: useFts ? "fts5" : "substring" };
264
+ selected.push(hit);
265
+ if (selected.length === limit) break;
266
+ }
267
+ return selected;
268
+ }
269
+
245
270
  close(): void {
246
271
  if (this.closed) return;
247
272
  this.closed = true;
@@ -250,6 +275,7 @@ export class ScreenedSearchIndex<T> {
250
275
  }
251
276
 
252
277
  interface RawCaptureRow { id: number; at: string; project: string; agent: string; event: string; tool: string | null; paths: string; status: string | null; excerpt: string; session?: string | null; subagent?: number | null }
278
+ export interface CaptureSearchOptions { weightEvents?: boolean; onePerSession?: boolean; now?: number }
253
279
 
254
280
  /**
255
281
  * The capture leg of #13 search: BM25 over the excerpts of one capture shard (`machine/<id>` of a project shard,
@@ -257,21 +283,43 @@ interface RawCaptureRow { id: number; at: string; project: string; agent: string
257
283
  * Rows older than `notBefore` (an ISO time) are left out; the store passes the shard's retention cutoff, so an expired
258
284
  * row is not recalled before a later append evicts it.
259
285
  */
260
- export function captureSearch(dir: string, q: string, limit: number, notBefore?: string): { row: CaptureRow; rank: number }[] {
286
+ export function captureSearch(dir: string, q: string, limit: number, notBefore?: string, options: CaptureSearchOptions = {}): { row: CaptureRow; rank: number }[] {
261
287
  const match = ftsTerms(q);
262
- if (!match || !fts5Available() || !existsSync(join(dir, "capture.db"))) return [];
288
+ if (!match || !Number.isSafeInteger(limit) || limit < 1 || !fts5Available() || !existsSync(join(dir, "capture.db"))) return [];
289
+ const weightEvents = options.weightEvents !== false, onePerSession = options.onePerSession ?? weightEvents;
290
+ const eventWeight = captureEventWeightSql("r.event");
291
+ const weightedOrder = `bm25(fts) * ${eventWeight}, r.id`;
292
+ const selected: { row: CaptureRow; rank: number }[] = [], groups = new Set<string>();
263
293
  let db: Database | undefined;
264
294
  try {
265
295
  db = new Database(join(dir, "capture.db"), { readonly: true });
266
296
  db.run("PRAGMA busy_timeout = 3000");
267
297
  if (!db.query("SELECT 1 FROM sqlite_master WHERE name = 'fts' AND type = 'table'").get()) return [];
268
- // `notBefore` (retention cutoff, `since`) runs inside the query: rows past it never take a LIMIT slot.
269
- const rows = (notBefore === undefined
270
- ? db.query(`SELECT r.*, bm25(fts) AS rank FROM fts JOIN rows r ON r.id = fts.rowid WHERE fts MATCH ? ORDER BY rank LIMIT ?`).all(match, limit)
271
- : db.query(`SELECT r.*, bm25(fts) AS rank FROM fts JOIN rows r ON r.id = fts.rowid WHERE fts MATCH ? AND r.at >= ? ORDER BY rank LIMIT ?`).all(match, notBefore, limit)) as (RawCaptureRow & { rank: number })[];
272
- return rows.map((r) => ({ row: { id: r.id, at: r.at, project: r.project, agent: r.agent, event: r.event, tool: r.tool, paths: JSON.parse(r.paths) as string[], status: r.status, excerpt: r.excerpt,
273
- // #173 attribution, mirrored from capture-input.ts captureAttribution (this module stays import-light for the ablation harness).
274
- ...(typeof r.session === "string" && /^[a-f0-9]{16}$/.test(r.session) ? { session: r.session } : {}), ...(r.subagent === 1 ? { subagent: true as const } : {}) }, rank: r.rank }));
298
+ // Filter first, then sort once. In the grouped path the first row of each session is
299
+ // its best complete-score match, so stopping at limit cannot hide a better unread row.
300
+ const matchWhere = notBefore === undefined ? "fts MATCH ?" : "fts MATCH ? AND r.at >= ?";
301
+ const args: (string | number)[] = notBefore === undefined ? [match] : [match, notBefore];
302
+ const order = onePerSession
303
+ ? `${rankedScoreSql("bm25(fts)")} * ${weightEvents ? eventWeight : "1.0"} DESC, r.id`
304
+ : weightEvents ? weightedOrder : "rank";
305
+ // The capture tier is an existing score input, projected only for the shared SQL recipe.
306
+ const sql = `SELECT r.*, ${onePerSession ? "'capture' AS tier, " : ""}bm25(fts) AS rank FROM fts JOIN rows r ON r.id = fts.rowid WHERE ${matchWhere} ORDER BY ${order}${onePerSession ? "" : " LIMIT ?"}`;
307
+ args.push(onePerSession ? new Date(options.now ?? Date.now()).toISOString() : limit);
308
+ const statement = db.query(sql);
309
+ const rows = (onePerSession ? statement.iterate(...args) : statement.all(...args)) as Iterable<RawCaptureRow & { rank: number }>;
310
+ for (const r of rows) {
311
+ const session = typeof r.session === "string" && /^[a-f0-9]{16}$/.test(r.session) ? r.session : undefined;
312
+ if (onePerSession && session) {
313
+ if (groups.has(session)) continue;
314
+ groups.add(session);
315
+ }
316
+ const row: CaptureRow = { id: r.id, at: r.at, project: r.project, agent: r.agent, event: r.event, tool: r.tool, paths: JSON.parse(r.paths) as string[], status: r.status, excerpt: r.excerpt,
317
+ // #173 attribution, mirrored from capture-input.ts captureAttribution (this module stays import-light for the ablation harness).
318
+ ...(session ? { session } : {}), ...(r.subagent === 1 ? { subagent: true as const } : {}) };
319
+ selected.push({ row, rank: r.rank });
320
+ if (selected.length === limit) break;
321
+ }
322
+ return selected;
275
323
  } catch { return []; }
276
324
  finally { db?.close(); }
277
325
  }
@@ -14,6 +14,17 @@ type ScoreExpr = { op: "rank" | "tier" | "age" } | { op: "constant"; value: numb
14
14
  const MONTH_DAYS = 30;
15
15
  const TIER_WEIGHTS: Readonly<Record<string, number>> = { long: 1, capture: 0.5 };
16
16
  const DEFAULT_TIER_WEIGHT = 0.9;
17
+ // ponytail: fixed 1.25x ceiling for conversational capture, not a learned relevance model.
18
+ // Raise it only with fresh S/M no-loss and private-capture evidence; curated tier factors stay unchanged.
19
+ export const CAPTURE_CONVERSATION_WEIGHT = 1.25;
20
+ const CONVERSATIONAL_CAPTURE_EVENTS = ["Stop", "UserPromptSubmit", "Title", "Recap", "Compact"] as const;
21
+ export function captureEventWeight(event: string): number {
22
+ return (CONVERSATIONAL_CAPTURE_EVENTS as readonly string[]).includes(event) ? CAPTURE_CONVERSATION_WEIGHT : 1;
23
+ }
24
+ /** Only fixed internal SQL identifiers are supplied by callers; event values remain stored data. */
25
+ export function captureEventWeightSql(eventColumn: string): string {
26
+ return `(CASE WHEN ${eventColumn} IN (${CONVERSATIONAL_CAPTURE_EVENTS.map(event => `'${event}'`).join(", ")}) THEN ${CAPTURE_CONVERSATION_WEIGHT} ELSE 1.0 END)`;
27
+ }
17
28
  const TIER_FACTOR: ScoreExpr = { op: "tier" };
18
29
  const DENOMINATOR: ScoreExpr = { op: "add", left: { op: "constant", value: 1 }, right: { op: "max", left: { op: "constant", value: 0 }, right: { op: "age" } } };
19
30
  const RECENCY_FACTOR: ScoreExpr = { op: "reciprocal", value: DENOMINATOR };
@@ -53,6 +64,9 @@ function sql(expr: ScoreExpr, rank: string): string {
53
64
  export function rankedScore(rank: number, tier: string, at: string, now: number): number {
54
65
  return numeric(SCORE, rank, tier, at, now);
55
66
  }
67
+ export function captureWeightedScore(rank: number, event: string, at: string, now: number): number {
68
+ return rankedScore(rank, "capture", at, now) * captureEventWeight(event);
69
+ }
56
70
  /** SQL uses fixed internal identifiers and bound clock values, never interpolated row/query input. */
57
71
  export function rankedScoreSql(rank = "bm25(docs)"): string { return sql(SCORE, rank); }
58
72
  export function rankedScoreSqlDetails(rank = "bm25(docs)", useFts = true): { rawBm25Rank: string; tierFactor: string; recencyFactor: string } {
@@ -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. */