@staix/agent-mem 0.18.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.
@@ -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" | "unknown_schema_expired";
40
- const DROP_REASONS: readonly CaptureDropReason[] = ["spool_full", "cwd_removed", "undrainable_expired", "scope_unknown", "project_archived", "project_forgotten", "unknown_schema_expired"];
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 {
@@ -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();
@@ -1,12 +1,12 @@
1
1
  import type { BriefOptions } from "./session-brief.ts";
2
2
  import type { CaptureStore } from "./memory/capture.ts";
3
- import { resolveHookProjectAsync } from "./hook-project.ts";
4
- import { hookPeakRssKB } from "./hook-resources.ts";
3
+ import { acceptHookProjectReceipt } from "./hook-project.ts";
4
+ import { writeSync } from "node:fs";
5
+ import { hookRssReading, hookPeakRssKB } from "./hook-resources.ts";
6
+ import { rssSourceCode } from "./hook-rss-wire.ts";
5
7
  import { resolveAlias } from "./memory/aliases.ts";
6
- import { HOOK_EVENTS, HOOK_INPUT_BYTES } from "./hook-contract.ts";
7
- import { drainHookCaptures, hookCaptureScope, quarantineHookScope } from "./hook-spool.ts";
8
- import { createStandalonePolicy } from "./memory/policy.ts";
9
- import { configuredPolicyOptions } from "./memory/config.ts";
8
+ import { HOOK_EVENTS, HOOK_INPUT_BYTES, type HookTranscriptProgress } from "./hook-contract.ts";
9
+ import { drainHookCaptures, hookCaptureScope } from "./hook-spool.ts";
10
10
 
11
11
  async function readOptions(): Promise<string> {
12
12
  const reader = Bun.stdin.stream().getReader(), chunks: Uint8Array[] = []; let bytes = 0;
@@ -25,62 +25,63 @@ async function readOptions(): Promise<string> {
25
25
  // Fixed package helper, launched and reaped by each native hook; never registered or resident.
26
26
  if (import.meta.main) {
27
27
  let owned = false;
28
- const nativeUsage = { rssKB: 0, rawRssKB: 0 };
28
+ let rssTelemetryValid = true;
29
+ const frame = (rss: number[]) => { try { writeSync(1, JSON.stringify({ rss }) + "\n"); } catch { rssTelemetryValid = false; } };
30
+ const progress = (transcript: HookTranscriptProgress) => { try { writeSync(1, JSON.stringify({ transcript }) + "\n"); } catch { rssTelemetryValid = false; } };
31
+ frame([0]);
29
32
  const stop = (): never => {
33
+ if (!rssTelemetryValid) frame([4]);
34
+ const self = hookRssReading(); frame([3, self.kb, self.rawKB, rssSourceCode(self)]);
30
35
  // This leader is still alive in the detached group created by hookWorker.
31
- // Native Git uses a separate owned async supervisor with its own native watchdog;
32
- // a blocked Git cannot prevent this worker's JS deadline from firing.
36
+ // Native Git is owned and reaped by the CLI parent, never by this worker.
33
37
  if (owned) try { process.kill(-process.pid, "SIGKILL"); } catch { /* No group exists in a direct, non-owned invocation. */ }
34
38
  process.exit(0);
35
39
  };
36
40
  const emit = async (result: unknown) => {
37
- const body = JSON.stringify({ ...(result as object), complete: true });
38
- const rssKB = hookPeakRssKB() + nativeUsage.rssKB;
41
+ const body = JSON.stringify({ ...(result as object), complete: true, rssTelemetryValid });
42
+ const rssKB = hookPeakRssKB();
39
43
  await Bun.stdout.write(body.slice(0, -1) + `,"rssKB":${rssKB}}\n`);
40
44
  stop();
41
45
  };
42
46
  try {
43
- const options = JSON.parse(await readOptions()) as BriefOptions & { current: string; event: string; deadlineAt: number; ownedGroup: boolean };
47
+ const options = JSON.parse(await readOptions()) as BriefOptions & { current: string; event: string; identity?: unknown; transcript?: unknown; deadlineAt: number; ownedGroup: boolean };
44
48
  if (!Number.isFinite(options.deadlineAt) || options.ownedGroup !== true) process.exit(0);
45
49
  owned = true;
46
50
  const remaining = () => options.deadlineAt - (performance.timeOrigin + performance.now());
47
- if (remaining() <= 0) stop();
48
- setTimeout(stop, remaining()).unref();
49
- const policy = createStandalonePolicy(configuredPolicyOptions(options.base!, options.cwd, options.policy)), event = options.event;
51
+ const deadlineStop = () => { if (options.transcript !== undefined) progress({ stopReason: "deadline" }); stop(); };
52
+ if (remaining() <= 0) deadlineStop();
53
+ setTimeout(deadlineStop, remaining()).unref();
54
+ const event = options.event;
50
55
  if (!(HOOK_EVENTS as readonly string[]).includes(event)) stop();
51
56
  const expectedScope = hookCaptureScope(options.cwd, options.base!);
52
- let project: string;
53
- // Overlap the shared resolver's existing origin/metadata reads on a cold cache;
54
- // serial native launches can exhaust every hook before any binding or replay is published.
55
- // ponytail: cold identity must still fit one hook deadline. Revalidated durable
56
- // identity progress is the upgrade if slower native reads keep exhausting that slice.
57
- try { project = resolveAlias(options.base!, await resolveHookProjectAsync({ cwd: options.cwd, base: options.base!, scope: expectedScope, timeRemaining: remaining, maintainCache: event !== "SessionStart", prefetch: true }, nativeUsage)); }
58
- catch {
59
- if (expectedScope !== undefined) quarantineHookScope({ cwd: options.cwd, base: options.base!, scope: expectedScope });
60
- project = stop();
57
+ // A receipt is single-use parent IPC. This worker must never launch a native helper.
58
+ if (event === "SessionStart") stop();
59
+ let project: string | undefined;
60
+ try { project = resolveAlias(options.base!, acceptHookProjectReceipt(options.identity, { cwd: options.cwd, base: options.base!, scope: expectedScope, timeRemaining: remaining })); } catch { /* Independently bound transcript intake can still proceed. */ }
61
+ if ((event === "Stop" || event === "SessionEnd") && options.transcript !== undefined
62
+ && (project !== undefined || expectedScope !== undefined && hookCaptureScope(options.cwd, options.base!) === expectedScope)) {
63
+ try {
64
+ const { scanHookTranscriptTail } = await import("./hook-transcript.ts");
65
+ const scanned = await scanHookTranscriptTail(options.transcript, { cwd: options.cwd, base: options.base!, policy: options.policy,
66
+ deadline: Math.min(performance.now() + remaining() - 20, performance.now() + 60) });
67
+ if (scanned) progress(scanned);
68
+ } catch { /* Ordinary durable capture is independent. */ }
61
69
  }
70
+ if (project === undefined) stop();
62
71
  let captured = false;
63
- // Normal SessionStart keeps the parent's durable stage for later replay and spends
64
- // its short-lived worker on context. An empty current name is mandatory full-tier recovery.
65
- if (expectedScope !== undefined && (event !== "SessionStart" || !options.current)) {
72
+ // An empty current name selects bounded full-tier recovery; SessionStart stays in the parent.
73
+ if (expectedScope !== undefined) {
66
74
  const { CaptureStore } = await import("./memory/capture.ts");
67
75
  let store: CaptureStore | undefined;
68
76
  try {
69
- const drained = await drainHookCaptures({ ...options, base: options.base!, project, expectedScope, timeRemaining: remaining }, (input, receipt) => {
70
- store ??= new CaptureStore({ memoryDir: options.base!, project, busyTimeoutMs: 0, cwd: options.cwd });
77
+ const drained = await drainHookCaptures({ ...options, base: options.base!, project: project!, expectedScope, timeRemaining: remaining }, (input, receipt) => {
78
+ store ??= new CaptureStore({ memoryDir: options.base!, project: project!, busyTimeoutMs: 0, cwd: options.cwd });
71
79
  return store.append(input, { receipt });
72
80
  });
73
81
  captured = drained.captured.includes(options.current);
74
82
  } finally { store?.close(); }
75
83
  }
76
84
  if (hookCaptureScope(options.cwd, options.base!) !== expectedScope) stop();
77
- if (event === "SessionStart") {
78
- const { sessionStartBrief } = await import("./session-brief.ts");
79
- const result = await sessionStartBrief({ ...options, project,
80
- timeRemaining: () => options.deadlineAt - (performance.timeOrigin + performance.now()) });
81
- const valid = await policy.screen({ context: result.context });
82
- if (hookCaptureScope(options.cwd, options.base!) !== expectedScope) stop();
83
- await emit({ ...result, captured, valid });
84
- } else await emit({ context: "", captured, valid: true });
85
+ await emit({ context: "", captured, valid: true });
85
86
  } catch { if (owned) stop(); process.exitCode = 1; }
86
87
  }
@@ -152,13 +152,13 @@ export async function sessionStartBrief(options: BriefOptions): Promise<BriefRes
152
152
  let candidateLine: string | undefined;
153
153
  const line = pendingLine(pendingSummary(base, [project, ...aliases], (options.now ?? Date.now)()));
154
154
  if (line !== undefined && Buffer.byteLength(line) + 1 <= remaining) { remaining -= Buffer.byteLength(line) + 1; candidateLine = line; }
155
- const add = async (section: Section, shown: { data: unknown; partial: boolean }, evidence: boolean) => {
156
- if (!await screen(shown.data)) return;
155
+ const add = async (section: Section, shown: { data: unknown; partial: boolean }, evidence: boolean): Promise<boolean> => {
156
+ if (!await screen(shown.data)) return false;
157
157
  coverage[section] = shown.partial ? "partial" : coverage[section] ?? "complete";
158
158
  const line = JSON.stringify({ section, evidence, data: shown.data });
159
159
  const size = Buffer.byteLength(line) + 1;
160
- if (Buffer.byteLength(line) > RECORD_BYTES || size > remaining) { coverage[section] = "partial"; return; }
161
- lines.push(line); remaining -= size;
160
+ if (Buffer.byteLength(line) > RECORD_BYTES || size > remaining) { coverage[section] = "partial"; return false; }
161
+ lines.push(line); remaining -= size; return true;
162
162
  };
163
163
  const run = async (section: Section, work: () => Promise<void>) => {
164
164
  const firstLine = lines.length, priorRemaining = remaining;
@@ -193,14 +193,15 @@ export async function sessionStartBrief(options: BriefOptions): Promise<BriefRes
193
193
  }
194
194
  });
195
195
  const recent = async <T>(section: "curated" | "capture", read: (window: ReadWindow) => { rows: T[]; next?: ReadRef },
196
- display: (row: T) => { data: unknown; partial: boolean }) => {
197
- let after: ReadWindow["after"], selected = 0, complete = false;
196
+ display: (row: T) => { data: unknown; partial: boolean }, selectedBefore = 0, skipCaptureIds: ReadonlySet<number> = new Set()) => {
197
+ let after: ReadWindow["after"], selected = selectedBefore, complete = false;
198
198
  for (let pageNumber = 0; pageNumber < MAX_PAGES; pageNumber++) {
199
199
  guard();
200
200
  if (options.nativeSession && options.timeRemaining && options.timeRemaining() <= 0) throw new Error("brief deadline");
201
201
  const page = read({ project, limit: PAGE_ROWS, after });
202
202
  for (const row of page.rows) {
203
203
  if ((row as { project?: unknown }).project !== project) { coverage[section] = "partial"; continue; }
204
+ if (section === "capture" && skipCaptureIds.has((row as { id: number }).id)) continue;
204
205
  // #173: subagent capture stays searchable but never enters the session brief.
205
206
  if (section === "capture" && (row as { subagent?: unknown }).subagent === true) continue;
206
207
  if (!await screen(row)) continue;
@@ -249,7 +250,18 @@ export async function sessionStartBrief(options: BriefOptions): Promise<BriefRes
249
250
  }
250
251
  const capture = new CaptureStore({ memoryDir: base, project, cwd, ...(options.nativeSession || options.measureOnly ? { readOnly: true, busyTimeoutMs: options.measureOnly ? 1000 : 0, maxReadRows: 128, recentReadRows: true } : {}) }); closers.push(() => capture.close());
251
252
  if (capture.recentReadTruncated) coverage.capture = "partial";
252
- await recent("capture", window => capture.readPage(window), captureDisplay);
253
+ const summaries = capture.readBriefSummaries(), summaryIds = new Set<number>();
254
+ if (summaries.partial) coverage.capture = "partial";
255
+ let selectedSummaries = 0;
256
+ for (const row of summaries.rows) {
257
+ if (row.project !== project) continue;
258
+ if (row.subagent === true) { summaryIds.add(row.id); continue; }
259
+ if (selectedSummaries >= 2) break;
260
+ if (!await screen(row)) { summaryIds.add(row.id); continue; }
261
+ summaryIds.add(row.id);
262
+ if (await add("capture", captureDisplay(row), false)) selectedSummaries++;
263
+ }
264
+ await recent("capture", window => capture.readPage(window), captureDisplay, selectedSummaries, summaryIds);
253
265
  });
254
266
  guard();
255
267
  const context = [header(coverage, reasons), ...(candidateLine ? [candidateLine] : []), ...lines].join("\n");
@@ -0,0 +1,217 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, resolve } from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { isDeepStrictEqual } from "node:util";
5
+ import type { SetupTarget } from "./setup.ts";
6
+ import { SETUP_CONFIG_BYTES } from "./setup-toml.ts";
7
+ import { managedAfter, managedHookEntries, mcpRecord, type ConfigImage } from "./setup-owned.ts";
8
+ import { resolveSetupPaths, type SetupEnvironment } from "./setup-paths.ts";
9
+ import { HOOK_EVENTS } from "./hook-contract.ts";
10
+ import { realPath } from "./memory/platform.ts";
11
+
12
+ export type SetupDriftStatus = "ok" | "attention" | "skipped" | "unavailable";
13
+ export interface SetupDriftEntry {
14
+ target: SetupTarget;
15
+ file: string;
16
+ entry: string;
17
+ reason: string;
18
+ kind: "drift" | "unavailable" | "review-needed";
19
+ expectedFile?: string;
20
+ }
21
+ export interface SetupDriftReport {
22
+ status: SetupDriftStatus;
23
+ checked: number;
24
+ entries: SetupDriftEntry[];
25
+ reason?: string;
26
+ }
27
+
28
+ interface ReceiptChange { key: string; path: string; before: ConfigImage; after: ConfigImage }
29
+ interface Receipt { version: 1; target: SetupTarget; phase: "prepared" | "applied"; changes: ReceiptChange[] }
30
+ const JOURNAL_BYTES = 12 * SETUP_CONFIG_BYTES;
31
+ // ponytail: at most 32 MiB of receipt/config input per doctor call; use metadata-only receipts before widening.
32
+ const READ_BYTES = 32 * 1024 * 1024;
33
+ const hash = (value: unknown) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
34
+ const plain = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
35
+
36
+ function safeImage(path: string, max = SETUP_CONFIG_BYTES, budget?: { remaining: number }): ConfigImage {
37
+ let cursor = resolve(path);
38
+ while (true) {
39
+ try { if (lstatSync(cursor).isSymbolicLink()) throw Error("unsafe"); }
40
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
41
+ const parent = dirname(cursor); if (parent === cursor) break; cursor = parent;
42
+ }
43
+ let fd: number;
44
+ try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
45
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { bytes: null, mode: 0o600 }; throw error; }
46
+ try {
47
+ const before = fstatSync(fd);
48
+ if (!before.isFile() || before.nlink !== 1 || before.size > max || budget && before.size > budget.remaining) throw Error("unsafe");
49
+ if (budget) budget.remaining -= before.size;
50
+ const data = Buffer.alloc(before.size + 1), size = readSync(fd, data, 0, data.length, 0), after = fstatSync(fd);
51
+ if (size !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ino !== before.ino || after.dev !== before.dev) throw Error("changed");
52
+ return { bytes: data.subarray(0, size).toString("base64"), mode: before.mode & 0o777 };
53
+ } finally { closeSync(fd); }
54
+ }
55
+
56
+ function parseImage(value: unknown): ConfigImage {
57
+ if (!plain(value) || !Number.isInteger(value.mode) || Number(value.mode) < 0 || Number(value.mode) > 0o777) throw Error("invalid receipt");
58
+ if (value.bytes !== null && (typeof value.bytes !== "string" || value.bytes.length > 2 * SETUP_CONFIG_BYTES || Buffer.from(value.bytes, "base64").toString("base64") !== value.bytes)) throw Error("invalid receipt");
59
+ return { bytes: value.bytes as string | null, mode: Number(value.mode) };
60
+ }
61
+
62
+ function parseReceipt(value: unknown, target: SetupTarget, expectedPaths: readonly string[]): Receipt {
63
+ if (!plain(value) || value.version !== 1 || value.target !== target || !/^[a-f0-9]{64}$/.test(String(value.intent)) || !["applied", "prepared"].includes(String(value.phase)) || !Array.isArray(value.changes) || value.changes.length !== expectedPaths.length && !(target === "jcode" && value.changes.length === 1)) throw Error("invalid receipt");
64
+ const changes: ReceiptChange[] = [];
65
+ for (let index = 0; index < value.changes.length; index++) {
66
+ const item: unknown = value.changes[index];
67
+ if (!plain(item) || item.key !== `${target}:${index}` || typeof item.path !== "string" || !isAbsolute(item.path) || item.path.length > 4096 || item.path.includes("\0")) throw Error("invalid receipt");
68
+ changes.push({ key: `${target}:${index}`, path: item.path, before: parseImage(item.before), after: parseImage(item.after) });
69
+ }
70
+ return { version: 1, target, phase: value.phase as Receipt["phase"], changes };
71
+ }
72
+
73
+ function workspaceRoot(cwd: string): string | undefined {
74
+ let current = realPath(resolve(cwd));
75
+ while (true) {
76
+ try {
77
+ const marker = lstatSync(join(current, ".git"));
78
+ if (marker.isDirectory() || marker.isFile()) return realPath(current);
79
+ } catch { /* Continue toward the filesystem root. */ }
80
+ const parent = dirname(current); if (parent === current) return undefined; current = parent;
81
+ }
82
+ }
83
+
84
+ const shellQuote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
85
+ function launch(record: unknown): string | undefined {
86
+ if (!plain(record) || typeof record.command !== "string" || !Array.isArray(record.args) || !record.args.every(arg => typeof arg === "string")) return undefined;
87
+ const args = [...record.args as string[]];
88
+ if (args.at(-1) === "mcp") args.pop();
89
+ return [shellQuote(record.command), ...args.map(arg => arg === "--smol" ? arg : shellQuote(arg))].join(" ");
90
+ }
91
+
92
+ function expectedManagedHookCommands(target: SetupTarget, after: ConfigImage): { event: string; command: string }[] {
93
+ if (["jcode", "opencode", "gemini", "agy"].includes(target)) return [];
94
+ const base = launch(mcpRecord(target, after));
95
+ if (!base) return [];
96
+ if (target === "claude") return HOOK_EVENTS.map(event => ({ event, command: `${base} hook ${event}` }));
97
+ if (target === "pi") return [];
98
+ return [{ event: "SessionEnd", command: `${base} ingest ${target}${target === "codex" || target === "kimi" || target === "qwen" ? " --json" : ""}` }];
99
+ }
100
+
101
+ /** Codex stores trust in config.toml under hooks.state, keyed by hooks.json path and handler position. */
102
+ function codexMissingTrust(hooksPath: string, hooksImage: ConfigImage, configImage: ConfigImage, expected: { event: string; command: string }[]): string[] {
103
+ if (hooksImage.bytes === null || configImage.bytes === null) return [];
104
+ const hooksDocument: unknown = JSON.parse(Buffer.from(hooksImage.bytes, "base64").toString("utf8"));
105
+ const configDocument: unknown = Bun.TOML.parse(Buffer.from(configImage.bytes, "base64").toString("utf8"));
106
+ if (!plain(hooksDocument) || !plain(configDocument)) throw Error("invalid Codex configuration");
107
+ const groups: unknown[] = plain(hooksDocument.hooks) && Array.isArray(hooksDocument.hooks.SessionEnd) ? hooksDocument.hooks.SessionEnd : [];
108
+ const stateRoot: Record<string, unknown> = plain(configDocument.hooks) && plain(configDocument.hooks.state) ? configDocument.hooks.state : Object.create(null);
109
+ const wanted = new Set(expected.filter(row => row.event === "SessionEnd").map(row => row.command));
110
+ const missing: string[] = [];
111
+ for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
112
+ const group = groups[groupIndex]; if (!plain(group) || !Array.isArray(group.hooks)) continue;
113
+ for (let handlerIndex = 0; handlerIndex < group.hooks.length; handlerIndex++) {
114
+ const handler = group.hooks[handlerIndex];
115
+ if (!plain(handler) || typeof handler.command !== "string" || !wanted.has(handler.command)) continue;
116
+ const key = `${hooksPath}:session_end:${groupIndex}:${handlerIndex}`;
117
+ const state = stateRoot[key];
118
+ // Presence is not proof that the stored digest still matches; amem only detects a missing record.
119
+ if (!plain(state) || typeof state.trusted_hash !== "string" || !state.trusted_hash || state.enabled === false) missing.push(key);
120
+ }
121
+ }
122
+ return missing;
123
+ }
124
+
125
+ function entry(target: SetupTarget, file: string, name: string, reason: string, kind: SetupDriftEntry["kind"]): SetupDriftEntry {
126
+ return { target, file, entry: name, reason, kind };
127
+ }
128
+
129
+ /** Read setup receipts and only the resolver-selected native config files. This function never writes. */
130
+ export function readSetupDrift(options: { home: string; env?: SetupEnvironment; cwd?: string }): SetupDriftReport {
131
+ const env = options.env ?? process.env;
132
+ const root = workspaceRoot(options.cwd ?? process.cwd());
133
+ let resolved: ReturnType<typeof resolveSetupPaths>;
134
+ try { resolved = resolveSetupPaths(env, undefined, root); }
135
+ catch { return { status: "unavailable", checked: 0, entries: [], reason: "setup paths could not be resolved" }; }
136
+ const entries: SetupDriftEntry[] = [], budget = { remaining: READ_BYTES };
137
+ let checked = 0, unavailable = false, anyReceipt = false;
138
+ for (const target of Object.keys(resolved.paths) as SetupTarget[]) {
139
+ const expectedPaths = resolved.paths[target];
140
+ if (!expectedPaths.length) continue;
141
+ const receiptPath = join(options.home, "setup", target === "agy" ? `agy-${hash(root)}.json` : `${target}.json`);
142
+ let raw: ConfigImage;
143
+ try { raw = safeImage(receiptPath, JOURNAL_BYTES, budget); }
144
+ catch {
145
+ entries.push(entry(target, receiptPath, "receipt", "receipt cannot be read safely or exceeds the read bound", "unavailable")); unavailable = true;
146
+ continue;
147
+ }
148
+ if (raw.bytes === null) continue;
149
+ let receipt: Receipt;
150
+ try {
151
+ const value: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(raw.bytes, "base64")));
152
+ if (plain(value) && value.target === target && value.phase === "prepared") continue;
153
+ anyReceipt = true; checked++;
154
+ receipt = parseReceipt(value, target, expectedPaths);
155
+ } catch {
156
+ entries.push(entry(target, receiptPath, "receipt", "receipt is invalid or unavailable", "unavailable")); unavailable = true; continue;
157
+ }
158
+ // Never open a manifest-selected file before the full set of paths matches the shared resolver.
159
+ const mismatches = receipt.changes.filter((change, index) => change.path !== expectedPaths[index]);
160
+ if (mismatches.length) {
161
+ for (const change of mismatches) entries.push({ ...entry(target, change.path, change.key,
162
+ "manifest path no longer matches the resolved target; referenced files were not opened", "drift"),
163
+ expectedFile: expectedPaths[receipt.changes.indexOf(change)] });
164
+ continue;
165
+ }
166
+ const liveImages: (ConfigImage | undefined)[] = [];
167
+ for (const change of receipt.changes) {
168
+ let current: ConfigImage;
169
+ try { current = safeImage(change.path, SETUP_CONFIG_BYTES, budget); }
170
+ catch {
171
+ liveImages.push(undefined);
172
+ entries.push(entry(target, change.path, change.key, "resolved configuration cannot be read safely", "unavailable")); unavailable = true; continue;
173
+ }
174
+ liveImages.push(current);
175
+ const isMcp = change.key === `${target}:0`;
176
+ if (isMcp) {
177
+ const name = target === "codex" ? "mcp_servers.amem" : target === "jcode" ? "servers.amem" : target === "opencode" ? "mcp.amem" : "mcpServers.amem";
178
+ try {
179
+ const expected = mcpRecord(target, change.after);
180
+ if (expected === undefined) throw Error("receipt has no MCP registration");
181
+ if (!isDeepStrictEqual(expected, mcpRecord(target, current))) entries.push(entry(target, change.path, name, "managed MCP registration is missing or changed", "drift"));
182
+ } catch { entries.push(entry(target, change.path, name, "managed MCP registration could not be checked", "unavailable")); unavailable = true; }
183
+ }
184
+ const hasHookSurface = target === "qwen" && change.key === `${target}:0` || target === "claude" && change.key === `${target}:1` || target === "codex" && change.key === `${target}:1` || target === "kimi" && change.key === `${target}:1`;
185
+ if (hasHookSurface) {
186
+ try {
187
+ const expected = expectedManagedHookCommands(target, receipt.changes[0]!.after);
188
+ for (const row of managedHookEntries(target, change.before, change.after, current, expected)) {
189
+ if (!row.present) entries.push(entry(target, change.path, row.entry, "managed hook is missing or changed", "drift"));
190
+ }
191
+ } catch { entries.push(entry(target, change.path, "hooks", "managed hooks could not be checked", "unavailable")); unavailable = true; }
192
+ }
193
+ if (target === "pi" && change.key === "pi:1" && !managedAfter(target, 1, change.before, change.after, current)) entries.push(entry(target, change.path, "session_shutdown extension", "managed hook extension is missing or changed", "drift"));
194
+ // #203: jcode's capture hooks are command elements in config.toml [hooks]; a pre-#203 receipt has no such change.
195
+ if (target === "jcode" && change.key === "jcode:1" && !managedAfter(target, 1, change.before, change.after, current)) entries.push(entry(target, change.path, "hooks", "managed hook is missing or changed", "drift"));
196
+ if (target === "codex" && change.key === "codex:1") {
197
+ // Codex owns trust state. Doctor checks only whether an entry exists; it never writes or verifies trusted_hash.
198
+ const runtimePaths = resolved.redirects.some(row => row.target === "codex") ? resolved.legacyPaths.codex : expectedPaths;
199
+ const configPath = runtimePaths[0]!, hooksPath = runtimePaths[1]!;
200
+ try {
201
+ const configImage = configPath === receipt.changes[0]!.path ? liveImages[0] : safeImage(configPath, SETUP_CONFIG_BYTES, budget);
202
+ if (!configImage) throw Error("Codex config is unavailable");
203
+ const hookImage = hooksPath === change.path ? current : safeImage(hooksPath, SETUP_CONFIG_BYTES, budget);
204
+ const expected = expectedManagedHookCommands(target, receipt.changes[0]!.after);
205
+ if (hooksPath !== change.path && (hookImage.bytes === null || configImage.bytes === null || managedHookEntries(target, change.before, change.after, hookImage, expected).some(row => !row.present))) {
206
+ entries.push(entry(target, hooksPath, "hooks.SessionEnd", "personal hook source is waiting for the managed runtime copy; start a new Orca session, then review the hook", "review-needed"));
207
+ } else {
208
+ for (const key of codexMissingTrust(hooksPath, hookImage, configImage, expected)) entries.push(entry(target, configPath, `hooks.state.${key}`, "Codex hook has no enabled trust record; treat it as untrusted and review it in Codex", "review-needed"));
209
+ }
210
+ } catch { entries.push(entry(target, configPath, "hooks.state", "Codex hook trust could not be checked safely", "unavailable")); unavailable = true; }
211
+ }
212
+ }
213
+ }
214
+ const status: SetupDriftStatus = entries.some(value => value.kind === "drift" || value.kind === "review-needed") ? "attention"
215
+ : unavailable ? "unavailable" : anyReceipt ? "ok" : "skipped";
216
+ return { status, checked, entries, ...(!anyReceipt && !entries.length ? { reason: "no applied setup receipts found" } : {}) };
217
+ }