@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,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 } : {}) });
@@ -1094,6 +1095,7 @@ export class MemoryStore {
1094
1095
  for (const store of this.readStores()) store.syncSearchIndex();
1095
1096
  const now = this.now();
1096
1097
  const hits: SearchHit[] = [];
1098
+ let captureShard: string | undefined, captureNotBefore: string | undefined;
1097
1099
  // The project filter runs inside the FTS query (its rows plus shared ones), so a busy sibling project can never
1098
1100
  // starve this project's LIMIT.
1099
1101
  // Superseded, forgotten, expired and `since` rows are filtered after FTS, so a fixed candidate cap could be filled
@@ -1122,13 +1124,17 @@ export class MemoryStore {
1122
1124
  // #15 D4/T1: the capture tier is project-scoped; without a project key there is no shard to recall from.
1123
1125
  if (f.includeCapture !== false && f.project) {
1124
1126
  const shard = shardMachineDir(this.memoryBaseDir, resolveAlias(this.memoryBaseDir, f.project), this.machine.id);
1127
+ captureShard = shard;
1125
1128
  // The retention cutoff and `since` run in the capture query, so neither expired nor old rows take a candidate slot.
1126
1129
  // An out-of-range retention_days gives no cutoff rather than failing the whole search (eviction keeps its own check).
1127
1130
  const override = f.captureRetentionDays, days = typeof override === "number" && Number.isFinite(override) && override > 0 ? override : readCaptureConfig(shard, this.memoryBaseDir).retentionDays;
1128
1131
  const cutoff = new Date(now - days * 24 * 3600_000);
1129
1132
  const retained = Number.isFinite(cutoff.getTime()) ? cutoff.toISOString() : undefined;
1130
1133
  const since = f.since && Number.isFinite(Date.parse(f.since)) ? new Date(Date.parse(f.since)).toISOString() : undefined;
1131
- for (const { row, rank } of captureSearch(shard, q, Math.max(limit * 4, 32), since && (!retained || since > retained) ? since : retained)) {
1134
+ captureNotBefore = since && (!retained || since > retained) ? since : retained;
1135
+ // First reproduce the old unweighted BM25 candidate window. Only these baseline capture slots may change;
1136
+ // curated membership and positions are not affected by conversational weighting or session collapse.
1137
+ for (const { row, rank } of captureSearch(shard, q, Math.max(limit * 4, 32), captureNotBefore, { weightEvents: false, onePerSession: false })) {
1132
1138
  const selected: SearchHit = { source: "capture", score: rankedScore(rank, "capture", row.at, now), capture: row };
1133
1139
  if (f.explain) selected.scoreDetails = rankedScoreDetails("capture", rank, "capture", row.at, now, selected.score);
1134
1140
  hits.push(selected);
@@ -1144,7 +1150,23 @@ export class MemoryStore {
1144
1150
  }
1145
1151
  const ref = (h: SearchHit): string => h.view?.id ?? String(h.capture?.id ?? 0).padStart(12, "0");
1146
1152
  hits.sort((a, b) => b.score - a.score || ref(a).localeCompare(ref(b)));
1147
- return { engine: "fts5", hits: hits.slice(0, limit) };
1153
+ const baseline = hits.slice(0, limit), captureSlots = baseline.filter(hit => hit.source === "capture").length;
1154
+ if (!captureSlots || !captureShard) return { engine: "fts5", hits: baseline };
1155
+ // Stream session representatives in complete weighted-score order; the legacy
1156
+ // baseline above still owns noncapture slots and exposed scores remain unweighted.
1157
+ const weighted = captureSearch(captureShard, q, captureSlots, captureNotBefore,
1158
+ { weightEvents: true, onePerSession: true, now });
1159
+ const replacements = weighted.map(({ row, rank }) => {
1160
+ const score = rankedScore(rank, "capture", row.at, now), hit: SearchHit = { source: "capture", score, capture: row };
1161
+ if (f.explain) hit.scoreDetails = rankedScoreDetails("capture", rank, "capture", row.at, now, score);
1162
+ return hit;
1163
+ });
1164
+ let nextCapture = 0;
1165
+ const selected = baseline.flatMap(hit => {
1166
+ if (hit.source !== "capture") return [hit];
1167
+ const replacement = replacements[nextCapture++]; return replacement ? [replacement] : [];
1168
+ });
1169
+ return { engine: "fts5", hits: selected };
1148
1170
  }
1149
1171
 
1150
1172
  /** Bring the derived FTS copy level with the curated rows when a prune, a crash, a lock or a rebuild drifted them apart. */
@@ -1612,6 +1634,7 @@ export function openShardStore(opts: StoreOptions & { project?: string; user?: b
1612
1634
  if (opts.user === true && opts.project !== undefined || opts.user !== true && !opts.project) throw new Error("choose exactly one project or the user shard");
1613
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");
1614
1636
  const home = opts.memoryDir ?? (opts.home === undefined ? memoryHome() : join(opts.home, "memory"));
1637
+ assertFormatFloor(home, "curated");
1615
1638
  assertCuratedShardPaths(home, opts);
1616
1639
  // Lifecycle refusal must remain before admission creates any lease/fence file.
1617
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
  }
@@ -17,6 +17,8 @@ import type { MemoryStore, View } from "./memory/store.ts";
17
17
  import { WorkListStore, type WorkListFields, type WorkListView } from "./memory/work-lists.ts";
18
18
 
19
19
  export interface BriefOptions { cwd: string; base?: string; project?: string; maxTokens?: number; policy?: StandalonePolicyOptions;
20
+ /** Repository-less hosts use only machine policy and omit the repo section, even if their home lies inside a repository. */
21
+ repository?: false;
20
22
  /** Host-only monotonic budget probe. The native hook still owns the hard child deadline. */
21
23
  timeRemaining?: () => number;
22
24
  /** Host-only parent CLI path: precomputed owned process identity and zero-wait stores. */
@@ -26,7 +28,7 @@ export interface BriefOptions { cwd: string; base?: string; project?: string; ma
26
28
  /** Test seam for the pending-candidate line's clock. */
27
29
  now?: () => number;
28
30
  /**
29
- * `amem doctor` only (F15): size the brief without writing to the memory home. No reader lease is taken. The caller supplies the two sections that would otherwise
31
+ * Read-only diagnostic/console projection: build the brief without writing to the memory home. No reader lease is taken. The caller supplies the two sections that would otherwise
30
32
  * open a store read-write: `curated` is a store opened on a private copy of the logs (absent: the project has no curated notes, an empty complete section) and
31
33
  * `workBase` is a private base holding a copy of the project's and its aliases' work logs (absent: the work section is `unavailable`, never claimed complete).
32
34
  * The capture section opens this machine's capture database read-only, and only when it exists.
@@ -104,7 +106,7 @@ export async function sessionStartBrief(options: BriefOptions): Promise<BriefRes
104
106
  try {
105
107
  if (options.nativeSession && typeof options.timeRemaining !== "function") return result;
106
108
  const cwdForConfig = options.cwd, baseForConfig = canonicalPath(options.base ?? memoryHome());
107
- const configuredBrief = loadAmemConfig(baseForConfig, repoConfigRoot(cwdForConfig)).config.briefBytes;
109
+ const configuredBrief = loadAmemConfig(baseForConfig, options.repository === false ? undefined : repoConfigRoot(cwdForConfig)).config.briefBytes;
108
110
  // A caller's explicit budget wins over the default; the person's configured budget can only lower either.
109
111
  const maximum = Math.min(options.maxTokens ?? DEFAULT_TOKENS, configuredBrief ?? Number.MAX_SAFE_INTEGER);
110
112
  if (!Number.isSafeInteger(maximum) || maximum < 1) return result;
@@ -112,7 +114,7 @@ export async function sessionStartBrief(options: BriefOptions): Promise<BriefRes
112
114
  const requested = options.project ?? memoryProjectKey(cwd, { repositoryIsolation: true }), project = resolveAlias(base, requested);
113
115
  // #15 T5: a project the person archived or forgot yields no brief at all, before any lease, store or screen is opened.
114
116
  if (projectState(base, project) !== "active") return result;
115
- const policy = createStandalonePolicy(configuredPolicyOptions(base, cwd, options.policy)), release = options.measureOnly ? Object.assign(() => {}, { verify: () => {} }) : registerReaderLease(base, options.nativeSession ? { signature: options.nativeSession.signature, busyTimeoutMs: 0 } : undefined);
117
+ const policy = createStandalonePolicy(configuredPolicyOptions(base, options.repository === false ? undefined : cwd, options.policy)), release = options.measureOnly ? Object.assign(() => {}, { verify: () => {} }) : registerReaderLease(base, options.nativeSession ? { signature: options.nativeSession.signature, busyTimeoutMs: 0 } : undefined);
116
118
  closers.push(release);
117
119
  const selection = readSelector(base).revision;
118
120
  const sharded = !!lstatSync(join(base, "shards.json"), { throwIfNoEntry: false });
@@ -138,7 +140,7 @@ export async function sessionStartBrief(options: BriefOptions): Promise<BriefRes
138
140
  const coverage: BriefResult["coverage"] = { work: "complete", curated: "complete", capture: "complete" };
139
141
  // #15 T4: the bound repository's `.amem/log.jsonl`, only when the repository IS this project (AC3). The `lstat` probe comes FIRST and on every path (the SessionStart
140
142
  // worker builds this brief without `nativeSession`): a repository with no `.amem/log.jsonl` at or above the cwd costs a few syscalls and no git process.
141
- const repoRoot = !repoLogNearby(cwd) ? undefined : options.projectFromCwd ? nearestRepoRootWithLog(cwd) : boundRepoRoot(cwd, base, project);
143
+ const repoRoot = options.repository === false || !repoLogNearby(cwd) ? undefined : options.projectFromCwd ? nearestRepoRootWithLog(cwd) : boundRepoRoot(cwd, base, project);
142
144
  if (repoLogPresent(repoRoot)) coverage.repo = "complete";
143
145
  const lines: string[] = [];
144
146
  const reasons: Partial<Record<Section, string>> = {};
@@ -150,13 +152,13 @@ export async function sessionStartBrief(options: BriefOptions): Promise<BriefRes
150
152
  let candidateLine: string | undefined;
151
153
  const line = pendingLine(pendingSummary(base, [project, ...aliases], (options.now ?? Date.now)()));
152
154
  if (line !== undefined && Buffer.byteLength(line) + 1 <= remaining) { remaining -= Buffer.byteLength(line) + 1; candidateLine = line; }
153
- const add = async (section: Section, shown: { data: unknown; partial: boolean }, evidence: boolean) => {
154
- 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;
155
157
  coverage[section] = shown.partial ? "partial" : coverage[section] ?? "complete";
156
158
  const line = JSON.stringify({ section, evidence, data: shown.data });
157
159
  const size = Buffer.byteLength(line) + 1;
158
- if (Buffer.byteLength(line) > RECORD_BYTES || size > remaining) { coverage[section] = "partial"; return; }
159
- 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;
160
162
  };
161
163
  const run = async (section: Section, work: () => Promise<void>) => {
162
164
  const firstLine = lines.length, priorRemaining = remaining;
@@ -179,7 +181,7 @@ export async function sessionStartBrief(options: BriefOptions): Promise<BriefRes
179
181
  await run("work", async () => {
180
182
  const measured = options.measureOnly;
181
183
  if (measured && !measured.workBase) { coverage.work = "unavailable"; return; }
182
- if (measured && !lstatSync(join(shardDir(measured.workBase!, project), "work", "log"), { throwIfNoEntry: false })) return; // no work log: the real brief shows an empty list
184
+ if (measured && ![project, ...aliases].some(key => lstatSync(join(shardDir(measured.workBase!, key), "work", "log"), { throwIfNoEntry: false }))) return; // no work log: the real brief shows an empty list
183
185
  const work = new WorkListStore({ base: measured?.workBase ?? base, project, aliases, policy: screen, ...(options.nativeSession ? { busyTimeoutMs: 0, timeRemaining: options.timeRemaining } : {}) }); closers.push(() => work.close());
184
186
  // Complete operation rows AND the composed view are screened before closed-item filtering or display caps.
185
187
  const views = await work.read(undefined, true); guard();
@@ -191,14 +193,15 @@ export async function sessionStartBrief(options: BriefOptions): Promise<BriefRes
191
193
  }
192
194
  });
193
195
  const recent = async <T>(section: "curated" | "capture", read: (window: ReadWindow) => { rows: T[]; next?: ReadRef },
194
- display: (row: T) => { data: unknown; partial: boolean }) => {
195
- 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;
196
198
  for (let pageNumber = 0; pageNumber < MAX_PAGES; pageNumber++) {
197
199
  guard();
198
200
  if (options.nativeSession && options.timeRemaining && options.timeRemaining() <= 0) throw new Error("brief deadline");
199
201
  const page = read({ project, limit: PAGE_ROWS, after });
200
202
  for (const row of page.rows) {
201
203
  if ((row as { project?: unknown }).project !== project) { coverage[section] = "partial"; continue; }
204
+ if (section === "capture" && skipCaptureIds.has((row as { id: number }).id)) continue;
202
205
  // #173: subagent capture stays searchable but never enters the session brief.
203
206
  if (section === "capture" && (row as { subagent?: unknown }).subagent === true) continue;
204
207
  if (!await screen(row)) continue;
@@ -247,7 +250,18 @@ export async function sessionStartBrief(options: BriefOptions): Promise<BriefRes
247
250
  }
248
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());
249
252
  if (capture.recentReadTruncated) coverage.capture = "partial";
250
- 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);
251
265
  });
252
266
  guard();
253
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
+ }