@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +26 -5
- package/package.json +4 -2
- package/src/cli.ts +27 -4
- package/src/commands-table.ts +3 -1
- package/src/hook-contract.ts +9 -0
- package/src/hook-jcode.ts +56 -0
- package/src/hook-native.ts +66 -13
- package/src/hook-project.ts +27 -4
- package/src/hook-resources.ts +54 -12
- package/src/hook-rss-wire.ts +85 -0
- package/src/hook-spool.ts +80 -20
- package/src/hook-transcript.ts +189 -0
- package/src/hooks.ts +132 -61
- package/src/mcp-memory.ts +13 -3
- package/src/memory/candidates.ts +13 -4
- package/src/memory/capture-input.ts +34 -0
- package/src/memory/capture.ts +46 -0
- package/src/memory/catalog.ts +2 -1
- package/src/memory/cross-project.ts +2 -1
- package/src/memory/doctor.ts +88 -17
- package/src/memory/formats.ts +65 -0
- package/src/memory/index-file.ts +15 -0
- package/src/memory/metrics.ts +4 -3
- package/src/memory/public-prune-activation.ts +2 -1
- package/src/memory/search-fts.ts +63 -15
- package/src/memory/search-score.ts +14 -0
- package/src/memory/shard.ts +2 -1
- package/src/memory/store.ts +29 -6
- package/src/memory/sync.ts +2 -0
- package/src/memory/work-lists.ts +2 -0
- package/src/session-brief-worker.ts +38 -37
- package/src/session-brief.ts +26 -12
- package/src/setup-drift.ts +217 -0
- package/src/setup-jcode.ts +65 -0
- package/src/setup-owned.ts +18 -1
- package/src/setup-paths.ts +76 -0
- package/src/setup.ts +92 -47
- package/src/ui/panel.js +25 -0
- package/src/ui/plugin.ts +17 -5
package/src/memory/store.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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!);
|
package/src/memory/sync.ts
CHANGED
|
@@ -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.
|
package/src/memory/work-lists.ts
CHANGED
|
@@ -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 {
|
|
4
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
|
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()
|
|
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 (
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
//
|
|
64
|
-
|
|
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
|
|
70
|
-
store ??= new CaptureStore({ memoryDir: options.base!, project
|
|
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
|
-
|
|
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
|
}
|
package/src/session-brief.ts
CHANGED
|
@@ -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
|
-
*
|
|
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!,
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|