@staix/agent-mem 0.18.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +20 -4
- package/package.json +3 -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 +45 -12
- package/src/hook-transcript.ts +189 -0
- package/src/hooks.ts +132 -61
- package/src/memory/candidates.ts +2 -0
- package/src/memory/capture.ts +46 -0
- package/src/memory/catalog.ts +2 -1
- package/src/memory/doctor.ts +24 -5
- 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-score.ts +3 -2
- package/src/memory/shard.ts +2 -1
- package/src/memory/store.ts +6 -4
- 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 +19 -7
- 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/hooks.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { fileURLToPath } from "node:url";
|
|
2
|
-
import { resolveHookProjectAsync } from "./hook-project.ts";
|
|
3
|
-
import { hookProcessSignature } from "./hook-native.ts";
|
|
2
|
+
import { resolveHookProjectAsync, type HookProjectReceipt } from "./hook-project.ts";
|
|
3
|
+
import { hookProcessSignature, type HookNativeUsage } from "./hook-native.ts";
|
|
4
4
|
import { resolveAlias } from "./memory/aliases.ts";
|
|
5
|
-
import {
|
|
5
|
+
import { combineRssSources, countedRssSources, emptyRssSources, hookRssReading, rssSource, type HookRssSources } from "./hook-resources.ts";
|
|
6
|
+
import { HookWorkerRss } from "./hook-rss-wire.ts";
|
|
6
7
|
import { appendMetrics, captureDropSummary } from "./memory/metrics.ts";
|
|
7
|
-
import { stageHookCapture, hookCaptureScope, HookSpoolFull, type PendingHookCapture } from "./hook-spool.ts";
|
|
8
|
+
import { stageHookCapture, drainHookCaptures, hookCaptureScope, quarantineHookScope, HookSpoolFull, type HookAgent, type PendingHookCapture } from "./hook-spool.ts";
|
|
9
|
+
import { jcodeHookCapture } from "./hook-jcode.ts";
|
|
8
10
|
import { memoryHome } from "./memory/home.ts";
|
|
9
11
|
import { canonicalPath, realPath } from "./memory/platform.ts";
|
|
10
12
|
import { createStandalonePolicy, type MemoryPolicy, type StandalonePolicyOptions } from "./memory/policy.ts";
|
|
11
13
|
import { configuredPolicyOptions, loadAmemConfig, repoConfigRoot } from "./memory/config.ts";
|
|
12
14
|
|
|
13
|
-
import { HOOK_EVENTS, HOOK_INPUT_BYTES, HOOK_CONTEXT_BYTES, HOOK_OUTPUT_BYTES } from "./hook-contract.ts";
|
|
15
|
+
import { HOOK_EVENTS, HOOK_INPUT_BYTES, HOOK_CONTEXT_BYTES, HOOK_OUTPUT_BYTES, isHookTranscriptProgress, type HookTranscriptProgress } from "./hook-contract.ts";
|
|
14
16
|
export { HOOK_EVENTS, HOOK_INPUT_BYTES, HOOK_CONTEXT_BYTES, HOOK_OUTPUT_BYTES, type HookEvent } from "./hook-contract.ts";
|
|
15
17
|
// Leave 10ms of the external 150ms R2 budget for teardown and the metrics write.
|
|
16
18
|
const HANDLER_MS = 140;
|
|
@@ -25,7 +27,7 @@ function handlerMs(host?: HookHostPorts): number {
|
|
|
25
27
|
if (!Number.isSafeInteger(value) || value < 1 || value > 10_000) throw new TypeError("invalid hook host deadline");
|
|
26
28
|
return value;
|
|
27
29
|
}
|
|
28
|
-
export interface HookOptions { cwd: string; base?: string; policy?: StandalonePolicyOptions; maxTokens?: number; startedAt?: number }
|
|
30
|
+
export interface HookOptions { cwd: string; base?: string; policy?: StandalonePolicyOptions; maxTokens?: number; startedAt?: number; agent?: HookAgent }
|
|
29
31
|
export interface HookReceipt {
|
|
30
32
|
captured: boolean; skipped: boolean;
|
|
31
33
|
/** Screened capture is durable but still awaits project-bound replay. */
|
|
@@ -66,62 +68,111 @@ async function before<T>(work: Promise<T>, deadline: number): Promise<T | undefi
|
|
|
66
68
|
}
|
|
67
69
|
const publicBefore = async (policy: MemoryPolicy, value: unknown, deadline: number) => await before(policy.screen(value), deadline) === true;
|
|
68
70
|
|
|
69
|
-
async function boundedOutput(stream: ReadableStream<Uint8Array
|
|
70
|
-
const reader = stream.getReader(),
|
|
71
|
+
async function boundedOutput(stream: ReadableStream<Uint8Array>, onRss?: (value: unknown) => void, onTranscript?: (value: unknown) => void): Promise<string | undefined> {
|
|
72
|
+
const reader = stream.getReader(), decoder = new TextDecoder("utf-8", { fatal: true });
|
|
73
|
+
let length = 0, pending = "", body = "";
|
|
74
|
+
const line = (text: string) => {
|
|
75
|
+
if (!text) return;
|
|
76
|
+
const value = JSON.parse(text) as { rss?: unknown; transcript?: unknown };
|
|
77
|
+
if (onRss && Object.hasOwn(value, "rss")) onRss(value.rss);
|
|
78
|
+
else if (onTranscript && Object.hasOwn(value, "transcript")) onTranscript(value.transcript);
|
|
79
|
+
else { if (body) throw new Error("worker output bound"); body = text; }
|
|
80
|
+
};
|
|
71
81
|
try {
|
|
72
82
|
for (;;) {
|
|
73
83
|
const next = await reader.read(); if (next.done) break;
|
|
74
84
|
if (length + next.value.byteLength > HOOK_OUTPUT_BYTES) return undefined;
|
|
75
|
-
|
|
85
|
+
length += next.value.byteLength; pending += decoder.decode(next.value, { stream: true });
|
|
86
|
+
for (;;) { const at = pending.indexOf("\n"); if (at < 0) break; line(pending.slice(0, at)); pending = pending.slice(at + 1); }
|
|
76
87
|
}
|
|
77
|
-
|
|
78
|
-
|
|
88
|
+
pending += decoder.decode(); if (pending) line(pending);
|
|
89
|
+
return body;
|
|
90
|
+
} catch { return undefined; }
|
|
91
|
+
finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Native handles always belong to the CLI that writes the metric, never a killable worker. */
|
|
95
|
+
async function parentProject(options: HookOptions, base: string, deadline: number) {
|
|
96
|
+
const cwd = realPath(options.cwd);
|
|
97
|
+
const scope = hookCaptureScope(cwd, base);
|
|
98
|
+
const usage: HookNativeUsage = { rssKB: 0, rawRssKB: 0, rssSources: emptyRssSources() };
|
|
99
|
+
let project: string | undefined, receipt: HookProjectReceipt | undefined;
|
|
100
|
+
try {
|
|
101
|
+
project = await resolveHookProjectAsync({ cwd, base, scope, timeRemaining: () => deadline - performance.now(), maintainCache: true, prefetch: true,
|
|
102
|
+
onResolved: value => { receipt = value; } }, usage);
|
|
103
|
+
} catch { /* All native finally/reap barriers have completed before this returns. */ }
|
|
104
|
+
if (project === undefined && scope !== undefined) try { quarantineHookScope({ cwd, base, scope }); }
|
|
105
|
+
catch { /* Failed maintenance cannot change native fail-open behavior or its accounting. */ }
|
|
106
|
+
return { cwd, scope, project, receipt, usage };
|
|
79
107
|
}
|
|
80
108
|
|
|
81
|
-
/**
|
|
109
|
+
/** Only capture work uses a short-lived owned child; native handles remain in this CLI.
|
|
82
110
|
* Host callbacks cannot cross this boundary; a host needing them calls sessionStartBrief in-process instead. */
|
|
83
|
-
async function hookWorker(options: HookOptions, base: string, deadline: number, pending: PendingHookCapture, event: string): Promise<{ context: string; rssKB: number; rawRssKB: number; captured: boolean; valid: boolean }> {
|
|
111
|
+
async function hookWorker(options: HookOptions, base: string, deadline: number, pending: PendingHookCapture, event: string, transcript?: unknown): Promise<{ context: string; rssKB: number; rawRssKB: number; captured: boolean; valid: boolean; rssSources?: HookRssSources; transcript?: HookTranscriptProgress }> {
|
|
84
112
|
const workDeadline = deadline - WORKER_CLEANUP_MS;
|
|
85
113
|
if (options.policy?.host || workDeadline - performance.now() < 5) return { context: unavailable, rssKB: 0, rawRssKB: 0, captured: false, valid: false };
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const child = Bun.spawn([process.execPath, "--smol", fileURLToPath(new URL("./session-brief-worker.ts", import.meta.url))],
|
|
92
|
-
{ cwd: options.cwd, env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", AMEM_HOME: base }, detached: true, stdin: Buffer.from(payload), stdout: "pipe", stderr: "ignore" });
|
|
93
|
-
let exited = false;
|
|
94
|
-
const result = { context: unavailable, rssKB: 0, rawRssKB: 0, captured: false, valid: false };
|
|
95
|
-
const exit = child.exited.then(code => { exited = true; return code; });
|
|
96
|
-
const stop = () => {
|
|
97
|
-
if (exited) return;
|
|
98
|
-
// The still-live leader is the detached process we just spawned, so its group is ours.
|
|
99
|
-
// Kill its native metadata children too; never signal a group after observing leader exit.
|
|
100
|
-
try { process.kill(-child.pid, "SIGKILL"); } catch { try { child.kill("SIGKILL"); } catch { /* Already exited. */ } }
|
|
101
|
-
};
|
|
102
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
114
|
+
const metadata = await parentProject(options, base, workDeadline);
|
|
115
|
+
if (!metadata.receipt && transcript === undefined || workDeadline - performance.now() < 5) return { context: unavailable, ...metadata.usage, captured: false, valid: false, ...(transcript !== undefined ? { transcript: { stopReason: "deadline" as const } } : {}) };
|
|
116
|
+
const result = { context: unavailable, ...metadata.usage, captured: false, valid: false,
|
|
117
|
+
rssSources: combineRssSources(metadata.usage.rssSources), transcript: undefined as HookTranscriptProgress | undefined };
|
|
118
|
+
let owned: ReturnType<typeof Bun.spawn> | undefined, exited = false;
|
|
103
119
|
try {
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
120
|
+
const configuredBrief = loadAmemConfig(base, repoConfigRoot(options.cwd)).config.briefBytes;
|
|
121
|
+
const maxTokens = Math.min(HOOK_CONTEXT_BYTES, configuredBrief ?? HOOK_CONTEXT_BYTES, Math.max(1, options.maxTokens ?? HOOK_CONTEXT_BYTES));
|
|
122
|
+
// The worker reads the configuration itself (same base, same cwd), so it screens with exactly the policy the parent did.
|
|
123
|
+
const payload = JSON.stringify({ cwd: options.cwd, base, current: pending.name, event, identity: metadata.receipt, transcript, maxTokens, ownedGroup: true, deadlineAt: performance.timeOrigin + workDeadline,
|
|
124
|
+
policy: { denyPaths: options.policy?.denyPaths, piiPatterns: options.policy?.piiPatterns } });
|
|
125
|
+
const child = Bun.spawn([process.execPath, "--smol", fileURLToPath(new URL("./session-brief-worker.ts", import.meta.url))],
|
|
126
|
+
{ cwd: options.cwd, env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", AMEM_HOME: base }, detached: true, stdin: Buffer.from(payload), stdout: "pipe", stderr: "ignore" });
|
|
127
|
+
owned = child;
|
|
128
|
+
result.rssSources.unavailable++;
|
|
129
|
+
const exit = child.exited.then(code => { exited = true; return code; });
|
|
130
|
+
const tracker = new HookWorkerRss(child.pid);
|
|
131
|
+
const output = boundedOutput(child.stdout, value => tracker.frame(value), value => {
|
|
132
|
+
if (!isHookTranscriptProgress(value)) throw new Error("worker transcript progress bound");
|
|
133
|
+
if (value.offset !== undefined && result.transcript?.offset !== undefined && value.offset < result.transcript.offset) throw new Error("worker transcript progress regressed");
|
|
134
|
+
result.transcript = { ...result.transcript, ...value };
|
|
135
|
+
});
|
|
136
|
+
const stop = () => {
|
|
137
|
+
if (exited) return;
|
|
138
|
+
tracker.captureLive();
|
|
139
|
+
// The still-live leader is the detached process we just spawned, so its group is ours.
|
|
140
|
+
// The capture-only worker owns no native helper; those handles were reaped in this parent.
|
|
141
|
+
try { process.kill(-child.pid, "SIGKILL"); } catch { try { child.kill("SIGKILL"); } catch { /* Already exited. */ } }
|
|
142
|
+
};
|
|
143
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
144
|
+
try {
|
|
145
|
+
const answer = await Promise.race([
|
|
146
|
+
Promise.all([exit, output]),
|
|
147
|
+
new Promise<undefined>(resolve => { timer = setTimeout(() => { if (transcript !== undefined) result.transcript ??= { stopReason: "deadline" }; stop(); resolve(undefined); }, Math.max(1, workDeadline - performance.now())); }),
|
|
148
|
+
]);
|
|
149
|
+
if (!answer || !answer[1]) return result;
|
|
150
|
+
const parsed = JSON.parse(answer[1]) as { context?: unknown; captured?: unknown; valid?: unknown; complete?: unknown; rssKB?: unknown; rssTelemetryValid?: unknown };
|
|
151
|
+
if (parsed.complete !== true) return result;
|
|
152
|
+
if (parsed.rssTelemetryValid !== true) tracker.invalidate();
|
|
153
|
+
if (typeof parsed.rssKB === "number" && Number.isSafeInteger(parsed.rssKB) && parsed.rssKB > 0) result.rssKB = parsed.rssKB;
|
|
154
|
+
if (typeof parsed.context === "string" && Buffer.byteLength(parsed.context) <= maxTokens) result.context = parsed.context;
|
|
155
|
+
result.captured = parsed.captured === true; result.valid = parsed.valid === true;
|
|
156
|
+
return result;
|
|
157
|
+
} catch { return result; }
|
|
158
|
+
finally {
|
|
159
|
+
if (timer) clearTimeout(timer);
|
|
160
|
+
stop();
|
|
161
|
+
await exit;
|
|
162
|
+
// Bun subprocess usage is bytes; process.resourceUsage below is KiB.
|
|
163
|
+
const workerRawKB = (child.resourceUsage()?.maxRSS ?? 0) / 1024;
|
|
164
|
+
result.rawRssKB = metadata.usage.rawRssKB + workerRawKB;
|
|
165
|
+
if (await output === undefined) tracker.invalidate();
|
|
166
|
+
const accounting = tracker.finish(workerRawKB);
|
|
167
|
+
result.rssKB = metadata.usage.rssKB + accounting.rssKB; result.rssSources = combineRssSources(metadata.usage.rssSources, accounting.rssSources);
|
|
168
|
+
}
|
|
169
|
+
} catch { result.valid = false; return result; }
|
|
116
170
|
finally {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
// Darwin self samples can precede final residency; use the reaped peak as a floor.
|
|
123
|
-
// Linux wait4 inherits launcher high-water, so retain the proven per-image sampler there.
|
|
124
|
-
result.rssKB = process.platform === "darwin" ? Math.max(result.rssKB, result.rawRssKB) : result.rssKB || result.rawRssKB;
|
|
171
|
+
// Even setup or accounting failure after spawn cannot leave an owned image live.
|
|
172
|
+
if (owned) {
|
|
173
|
+
if (!exited) try { process.kill(-owned.pid, "SIGKILL"); } catch { try { owned.kill("SIGKILL"); } catch { /* Already gone. */ } }
|
|
174
|
+
await owned.exited;
|
|
175
|
+
}
|
|
125
176
|
}
|
|
126
177
|
}
|
|
127
178
|
|
|
@@ -143,7 +194,7 @@ async function sessionInParent(input: unknown, options: HookOptions, base: strin
|
|
|
143
194
|
catch (error) { if (!(error instanceof HookSpoolFull)) throw error; full = true; }
|
|
144
195
|
if (!pending && !full) return undefined;
|
|
145
196
|
const cwd = realPath(options.cwd), scope = hookCaptureScope(cwd, base);
|
|
146
|
-
const remaining = () => deadline - performance.now(), usage = { rssKB: 0, rawRssKB: 0 };
|
|
197
|
+
const remaining = () => deadline - performance.now(), usage: HookNativeUsage = { rssKB: 0, rawRssKB: 0, rssSources: emptyRssSources() };
|
|
147
198
|
const result = { context: unavailable, captured: false, valid: false, pending: !!pending, dropped: false, ...usage };
|
|
148
199
|
if (remaining() <= 0) { result.dropped = !pending; return result; }
|
|
149
200
|
if (sessionSource(input) === "resume") {
|
|
@@ -158,7 +209,7 @@ async function sessionInParent(input: unknown, options: HookOptions, base: strin
|
|
|
158
209
|
try {
|
|
159
210
|
// Independent native reads and module loading share the same deadline, not three serial cold starts.
|
|
160
211
|
// Separate usage ports conservatively sum concurrent helper peaks instead of hiding overlap.
|
|
161
|
-
const projectUsage = { rssKB: 0, rawRssKB: 0 }, signatureUsage = { rssKB: 0, rawRssKB: 0 };
|
|
212
|
+
const projectUsage: HookNativeUsage = { rssKB: 0, rawRssKB: 0 }, signatureUsage: HookNativeUsage = { rssKB: 0, rawRssKB: 0 };
|
|
162
213
|
// Reaping native groups and serializing the fail-open result stay inside the handler deadline.
|
|
163
214
|
const nativeRemaining = () => remaining() - 15;
|
|
164
215
|
const metadata = await Promise.allSettled([
|
|
@@ -168,6 +219,7 @@ async function sessionInParent(input: unknown, options: HookOptions, base: strin
|
|
|
168
219
|
] as const);
|
|
169
220
|
usage.rssKB = projectUsage.rssKB + signatureUsage.rssKB;
|
|
170
221
|
usage.rawRssKB = projectUsage.rawRssKB + signatureUsage.rawRssKB;
|
|
222
|
+
usage.rssSources = combineRssSources(projectUsage.rssSources, signatureUsage.rssSources);
|
|
171
223
|
const [identity, owner, briefModule] = metadata;
|
|
172
224
|
if (identity.status !== "fulfilled" || owner.status !== "fulfilled" || briefModule.status !== "fulfilled") throw new Error("hook metadata unavailable");
|
|
173
225
|
const project = resolveAlias(base, identity.value);
|
|
@@ -185,20 +237,22 @@ async function sessionInParent(input: unknown, options: HookOptions, base: strin
|
|
|
185
237
|
result.valid = result.context.length > 0;
|
|
186
238
|
return result;
|
|
187
239
|
} catch { result.dropped = !pending; return result; }
|
|
188
|
-
finally { result.rssKB = usage.rssKB; result.rawRssKB = usage.rawRssKB; }
|
|
240
|
+
finally { result.rssKB = usage.rssKB; result.rawRssKB = usage.rawRssKB; result.rssSources = usage.rssSources; }
|
|
189
241
|
}
|
|
190
242
|
|
|
191
243
|
/** A full pending tier must still be drainable. Recover bounded old records before retrying
|
|
192
244
|
* publication; this path uses one helper too, and the current normalized event stays pending. */
|
|
193
245
|
async function captureAndWork(event: string, input: unknown, options: HookOptions, base: string, deadline: number) {
|
|
194
246
|
if (event === "SessionStart") return sessionInParent(input, options, base, deadline);
|
|
195
|
-
|
|
247
|
+
const transcript = (event === "Stop" || event === "SessionEnd") && input !== null && typeof input === "object" && "transcript_path" in input && typeof input.transcript_path === "string" ? input : undefined;
|
|
248
|
+
const work = (pending: PendingHookCapture) => hookWorker({ ...options, cwd: pending.cwd }, base, deadline, pending, event, transcript);
|
|
249
|
+
let result: Awaited<ReturnType<typeof work>> | undefined;
|
|
196
250
|
let pending: PendingHookCapture | undefined;
|
|
197
251
|
try { pending = await stageHookCapture(event, input, { ...options, base, deferAtCapacity: true }); }
|
|
198
252
|
catch (error) {
|
|
199
253
|
if (!(error instanceof HookSpoolFull)) throw error;
|
|
200
254
|
const cwd = realPath(options.cwd);
|
|
201
|
-
result = await
|
|
255
|
+
result = await work({ name: "", receipt: "", cwd });
|
|
202
256
|
try { pending = await stageHookCapture(event, input, { ...options, base, deferAtCapacity: true }); }
|
|
203
257
|
catch (retryError) {
|
|
204
258
|
// A concurrent publisher can refill the soft tier. Capture admission cannot discard
|
|
@@ -208,7 +262,7 @@ async function captureAndWork(event: string, input: unknown, options: HookOption
|
|
|
208
262
|
}
|
|
209
263
|
}
|
|
210
264
|
if (!pending) return result ? { ...result, pending: false } : undefined;
|
|
211
|
-
result ??= await
|
|
265
|
+
result ??= await work(pending);
|
|
212
266
|
return { ...result, pending: !result.captured };
|
|
213
267
|
}
|
|
214
268
|
|
|
@@ -217,6 +271,8 @@ export async function runHook(event: string, input: unknown, options: HookOption
|
|
|
217
271
|
const now = performance.now(), started = options.startedAt !== undefined && Number.isFinite(options.startedAt) && options.startedAt <= now ? options.startedAt : now;
|
|
218
272
|
const deadline = started + handlerMs(host);
|
|
219
273
|
let base: string | undefined, captured = false, childRssKB = 0, childRawRssKB = 0, pending = false, dropped = false;
|
|
274
|
+
let childSources = emptyRssSources();
|
|
275
|
+
let transcriptProgress: HookTranscriptProgress | undefined;
|
|
220
276
|
try {
|
|
221
277
|
base = canonicalPath(options.base ?? memoryHome());
|
|
222
278
|
if (!(HOOK_EVENTS as readonly string[]).includes(event)) return { captured, ...(pending ? { pending: true as const } : {}), ...(dropped ? { dropped: true as const, dropReason: "spool_full" as const, dropCount: 1 } : {}), skipped: true };
|
|
@@ -225,15 +281,17 @@ export async function runHook(event: string, input: unknown, options: HookOption
|
|
|
225
281
|
const result = await captureAndWork(event, input, options, base, deadline - 3);
|
|
226
282
|
if (!result) return { captured, ...(pending ? { pending: true as const } : {}), ...(dropped ? { dropped: true as const, dropReason: "spool_full" as const, dropCount: 1 } : {}), skipped: true };
|
|
227
283
|
captured = result.captured; pending = result.pending; dropped = "dropped" in result && result.dropped === true;
|
|
228
|
-
childRssKB = result.rssKB; childRawRssKB = result.rawRssKB;
|
|
284
|
+
childRssKB = result.rssKB; childRawRssKB = result.rawRssKB; childSources = result.rssSources ?? emptyRssSources();
|
|
285
|
+
transcriptProgress = "transcript" in result ? result.transcript : undefined;
|
|
229
286
|
if (!result.valid) return { captured, ...(pending ? { pending: true as const } : {}), ...(dropped ? { dropped: true as const, dropReason: "spool_full" as const, dropCount: 1 } : {}), skipped: true };
|
|
230
287
|
if (event !== "SessionStart") return { captured, ...(pending ? { pending: true as const } : {}), ...(dropped ? { dropped: true as const, dropReason: "spool_full" as const, dropCount: 1 } : {}), skipped: false };
|
|
231
288
|
if (!await publicBefore(policy, { context: result.context }, deadline)) return { captured, ...(pending ? { pending: true as const } : {}), ...(dropped ? { dropped: true as const, dropReason: "spool_full" as const, dropCount: 1 } : {}), skipped: false };
|
|
232
289
|
return { captured, ...(pending ? { pending: true as const } : {}), ...(dropped ? { dropped: true as const, dropReason: "spool_full" as const, dropCount: 1 } : {}), skipped: false, output: { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: result.context } } };
|
|
233
290
|
} catch { return { captured, ...(pending ? { pending: true as const } : {}), ...(dropped ? { dropped: true as const, dropReason: "spool_full" as const, dropCount: 1 } : {}), skipped: true }; }
|
|
234
291
|
finally {
|
|
235
|
-
if (base) try { appendMetrics(base, [{ at: new Date().toISOString(), kind: "hook", wallMs: performance.now() - started,
|
|
236
|
-
rssMB: (
|
|
292
|
+
if (base) try { const self = hookRssReading(); appendMetrics(base, [{ at: new Date().toISOString(), kind: "hook", wallMs: performance.now() - started,
|
|
293
|
+
rssMB: (self.kb + childRssKB) / 1024, rssSources: combineRssSources(rssSource(self), countedRssSources(childRssKB, childSources)), rssScope: "owned-images", rawRssMB: (process.resourceUsage().maxRSS + childRawRssKB) / 1024,
|
|
294
|
+
...(transcriptProgress ? { transcriptOffset: transcriptProgress.offset, transcriptStopReason: transcriptProgress.stopReason } : {}), ...(dropped ? { captureDrops: 1, captureDropReason: "spool_full" as const } : {}) }]); } catch { /* Metrics never change the native tool outcome. */ }
|
|
237
295
|
}
|
|
238
296
|
}
|
|
239
297
|
|
|
@@ -242,8 +300,18 @@ export async function hookMain(args: readonly string[], startedAt = performance.
|
|
|
242
300
|
const deadlineMs = handlerMs(host);
|
|
243
301
|
let cleanup: (() => Promise<void>) | undefined;
|
|
244
302
|
let childRssKB = 0, childRawRssKB = 0, dropped = false;
|
|
303
|
+
let childSources = emptyRssSources();
|
|
304
|
+
let transcriptProgress: HookTranscriptProgress | undefined;
|
|
245
305
|
try {
|
|
246
306
|
if (args.length !== 1) return "";
|
|
307
|
+
if (args[0] === "jcode") {
|
|
308
|
+
// #203: jcode observers have no stdin and discard stdout; the event and its cwd arrive in the environment.
|
|
309
|
+
const jcode = jcodeHookCapture(process.env);
|
|
310
|
+
if (!jcode) return "";
|
|
311
|
+
const result = await captureAndWork(jcode.event, jcode.input, { cwd: jcode.cwd, agent: "jcode" }, canonicalPath(memoryHome()), startedAt + deadlineMs - 3);
|
|
312
|
+
if (result) { childRssKB = result.rssKB; childRawRssKB = result.rawRssKB; childSources = result.rssSources ?? emptyRssSources(); dropped = "dropped" in result && result.dropped === true; }
|
|
313
|
+
return "";
|
|
314
|
+
}
|
|
247
315
|
const reader = Bun.stdin.stream().getReader();
|
|
248
316
|
cleanup = async () => { await reader.cancel().catch(() => {}); reader.releaseLock(); };
|
|
249
317
|
const chunks: Uint8Array[] = []; let length = 0;
|
|
@@ -260,7 +328,8 @@ export async function hookMain(args: readonly string[], startedAt = performance.
|
|
|
260
328
|
const base = canonicalPath(memoryHome());
|
|
261
329
|
const result = await captureAndWork(args[0]!, input, { cwd: process.cwd() }, base, startedAt + deadlineMs - 3);
|
|
262
330
|
if (!result) return "";
|
|
263
|
-
childRssKB = result.rssKB; childRawRssKB = result.rawRssKB; dropped = "dropped" in result && result.dropped === true;
|
|
331
|
+
childRssKB = result.rssKB; childRawRssKB = result.rawRssKB; childSources = result.rssSources ?? emptyRssSources(); dropped = "dropped" in result && result.dropped === true;
|
|
332
|
+
transcriptProgress = "transcript" in result ? result.transcript : undefined;
|
|
264
333
|
if (!result.valid || args[0] !== "SessionStart") return "";
|
|
265
334
|
return JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: result.context } });
|
|
266
335
|
}
|
|
@@ -269,8 +338,10 @@ export async function hookMain(args: readonly string[], startedAt = performance.
|
|
|
269
338
|
finally {
|
|
270
339
|
await cleanup?.();
|
|
271
340
|
try {
|
|
341
|
+
const self = hookRssReading();
|
|
272
342
|
appendMetrics(canonicalPath(memoryHome()), [{ at: new Date().toISOString(), kind: "hook", wallMs: performance.now() - startedAt,
|
|
273
|
-
rssMB: (
|
|
343
|
+
rssMB: (self.kb + childRssKB) / 1024, rssSources: combineRssSources(rssSource(self), countedRssSources(childRssKB, childSources)), rssScope: "owned-images", rawRssMB: (process.resourceUsage().maxRSS + childRawRssKB) / 1024,
|
|
344
|
+
...(transcriptProgress ? { transcriptOffset: transcriptProgress.offset, transcriptStopReason: transcriptProgress.stopReason } : {}), ...(dropped ? { captureDrops: 1, captureDropReason: "spool_full" as const } : {}) }]);
|
|
274
345
|
} catch { /* Invalid input and unavailable homes still fail open. */ }
|
|
275
346
|
}
|
|
276
347
|
}
|
package/src/memory/candidates.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertFormatFloor } from "./formats.ts";
|
|
1
2
|
import { Database } from "bun:sqlite";
|
|
2
3
|
import { createHash } from "node:crypto";
|
|
3
4
|
import { chmodSync, mkdirSync } from "node:fs";
|
|
@@ -146,6 +147,7 @@ export class CandidateStore {
|
|
|
146
147
|
if (options.reconcile?.aliases.length) this.reconcile = { canonical: options.reconcile.canonical, aliases: [...options.reconcile.aliases] };
|
|
147
148
|
if (options.memoryDir !== undefined && options.home !== undefined) throw new Error("choose a home or an exact memory directory");
|
|
148
149
|
this.dir = options.memoryDir ?? (options.home === undefined ? memoryHome() : join(options.home, "memory"));
|
|
150
|
+
assertFormatFloor(this.dir, "candidates");
|
|
149
151
|
this.now = options.now ?? Date.now;
|
|
150
152
|
mkdirSync(this.dir, { recursive: true, mode: 0o700 }); chmodSync(this.dir, 0o700);
|
|
151
153
|
this.path = join(this.dir, "candidates.db");
|
package/src/memory/capture.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 { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, opendirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
@@ -49,6 +50,7 @@ export const CAPTURE_EVICT_MAX_BATCHES = 4;
|
|
|
49
50
|
/** Wall-time bound for one invocation's eviction: with the batch bound, an append stays inside R2 after a cap shrink. */
|
|
50
51
|
export const CAPTURE_EVICT_MAX_MS = 25;
|
|
51
52
|
export const CAPTURE_DRAIN_MAX_LINES = 1000;
|
|
53
|
+
const BRIEF_SUMMARY_CANDIDATES = 32;
|
|
52
54
|
const SPOOL = "spool.jsonl";
|
|
53
55
|
const DRAINING = "spool.jsonl.draining";
|
|
54
56
|
const SPOOL_DIR = "spool";
|
|
@@ -210,6 +212,7 @@ export class CaptureStore {
|
|
|
210
212
|
const maxReadRows = opts.maxReadRows ?? 128;
|
|
211
213
|
if (this.readOnly && (!Number.isSafeInteger(maxReadRows) || maxReadRows < 1 || maxReadRows >= Number.MAX_SAFE_INTEGER)) throw new Error("invalid capture read row bound");
|
|
212
214
|
this.base = opts.memoryDir ?? memoryBase(opts.home);
|
|
215
|
+
assertFormatFloor(this.base, "capture");
|
|
213
216
|
const machine = opts.machine ?? (this.readOnly ? this.existingMachine() : machineId(this.base).id);
|
|
214
217
|
if (this.readOnly && !/^[a-f0-9]{16}$/.test(machine)) throw new Error("capture machine identity unavailable");
|
|
215
218
|
this.requestedKey = opts.project;
|
|
@@ -288,6 +291,7 @@ export class CaptureStore {
|
|
|
288
291
|
db.run("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
|
|
289
292
|
// #173: additive nullable columns. Older readers use explicit or `SELECT *` column names and ignore extras.
|
|
290
293
|
addAttributionColumns(db);
|
|
294
|
+
this.ensureBriefSummaryIndex(true);
|
|
291
295
|
this.ensureFts();
|
|
292
296
|
} catch (error) {
|
|
293
297
|
if (!lockError(error)) { db.close(); throw error; }
|
|
@@ -383,6 +387,12 @@ export class CaptureStore {
|
|
|
383
387
|
}
|
|
384
388
|
|
|
385
389
|
private mcpReady = false;
|
|
390
|
+
private briefReady = false;
|
|
391
|
+
private prepareBriefSummaryIndex(): void {
|
|
392
|
+
if (this.readOnly || this.briefReady) return;
|
|
393
|
+
try { this.ensureBriefSummaryIndex(false); this.briefReady = true; }
|
|
394
|
+
catch { /* Optional summary coverage remains partial; query engines remain independent. */ }
|
|
395
|
+
}
|
|
386
396
|
/** One-time MCP read cost only: hooks neither create this derived index nor scan history to fill it. */
|
|
387
397
|
private ensureMcpFts(): boolean {
|
|
388
398
|
if (this.readOnly) return !!this.db.query("SELECT 1 FROM sqlite_master WHERE name = 'mcp_fts' AND type = 'table'").get();
|
|
@@ -637,6 +647,41 @@ export class CaptureStore {
|
|
|
637
647
|
return rows.map(toRow);
|
|
638
648
|
}
|
|
639
649
|
|
|
650
|
+
/** Generic SQLite index maintenance also covers older writers sharing this file. */
|
|
651
|
+
private ensureBriefSummaryIndex(bounded: boolean): void {
|
|
652
|
+
if (this.db.query("SELECT 1 FROM sqlite_master WHERE name='rows_brief_summaries_v1' AND type='index'").get()) return;
|
|
653
|
+
const build = () => {
|
|
654
|
+
if (bounded) {
|
|
655
|
+
const count = (this.db.query("SELECT COUNT(*) AS n FROM (SELECT 1 FROM rows LIMIT 129)").get() as { n: number }).n;
|
|
656
|
+
if (count > 128) return;
|
|
657
|
+
}
|
|
658
|
+
this.db.run("CREATE INDEX IF NOT EXISTS rows_brief_summaries_v1 ON rows(project,at,id) WHERE event IN ('Recap','Compact') AND (subagent IS NULL OR subagent != 1)");
|
|
659
|
+
};
|
|
660
|
+
// Keep the small-store decision and build atomic against a concurrent older writer.
|
|
661
|
+
if (bounded) this.db.transaction(build).immediate(); else build();
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** Index reads return at most33 bodies. Legacy fallback reads at most128 recent rows. */
|
|
665
|
+
readBriefSummaries(limit = BRIEF_SUMMARY_CANDIDATES): { rows: CaptureRow[]; partial: boolean } {
|
|
666
|
+
this.checkReadBinding();
|
|
667
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > BRIEF_SUMMARY_CANDIDATES) throw new Error("invalid brief summary limit");
|
|
668
|
+
const indexed = !!this.db.query("SELECT 1 FROM sqlite_master WHERE name='rows_brief_summaries_v1' AND type='index'").get();
|
|
669
|
+
let rows: RawRow[], partial = false;
|
|
670
|
+
if (indexed) {
|
|
671
|
+
rows = this.db.query("SELECT * FROM rows INDEXED BY rows_brief_summaries_v1 WHERE project=? AND event IN ('Recap','Compact') AND (subagent IS NULL OR subagent != 1) ORDER BY at DESC,id DESC LIMIT ?")
|
|
672
|
+
.all(this.key, limit + 1) as RawRow[];
|
|
673
|
+
} else {
|
|
674
|
+
// ponytail: a large legacy store stays partial within128 rows until an off-hook
|
|
675
|
+
// query builds the partial index; never scan or build the history on a hook.
|
|
676
|
+
const hasSubagent = (this.db.query("PRAGMA table_info(rows)").all() as { name: string }[]).some(column => column.name === "subagent");
|
|
677
|
+
const subagent = hasSubagent ? " AND (subagent IS NULL OR subagent != 1)" : "";
|
|
678
|
+
rows = this.db.query(`SELECT * FROM (SELECT * FROM rows INDEXED BY rows_at ORDER BY at DESC,id DESC LIMIT 128) WHERE project=? AND event IN ('Recap','Compact')${subagent} ORDER BY at DESC,id DESC LIMIT ?`)
|
|
679
|
+
.all(this.key, limit + 1) as RawRow[];
|
|
680
|
+
partial = this.db.query("SELECT id FROM rows INDEXED BY rows_at ORDER BY at DESC,id DESC LIMIT 129").all().length > 128;
|
|
681
|
+
}
|
|
682
|
+
return { rows: rows.slice(0, limit).map(toRow), partial: partial || rows.length > limit };
|
|
683
|
+
}
|
|
684
|
+
|
|
640
685
|
/** Exact-project, bounded body pages; MCP screens before filling its public candidate window. */
|
|
641
686
|
readPage(window: ReadWindow & { query?: string; match?: "substring" }): { rows: CaptureRow[]; next?: ReadRef; engine?: "fts5" | "substring" } {
|
|
642
687
|
this.checkReadBinding();
|
|
@@ -645,6 +690,7 @@ export class CaptureStore {
|
|
|
645
690
|
if (window.query !== undefined) throw new Error("recent capture brief does not support query indexes");
|
|
646
691
|
where.push("id IN (SELECT value FROM json_each(?))"); bind.push(JSON.stringify(this.recentReadIds));
|
|
647
692
|
}
|
|
693
|
+
if (window.query !== undefined) this.prepareBriefSummaryIndex();
|
|
648
694
|
const engine = window.query === undefined ? undefined : window.match !== "substring" && this.ensureMcpFts() ? "fts5" : "substring";
|
|
649
695
|
const plan = captureReadPlan(window, engine, where, bind);
|
|
650
696
|
if (!plan) return { rows: [], engine };
|
package/src/memory/catalog.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
+
import { indexFileName } from "./index-file.ts";
|
|
2
3
|
import { chmodSync, existsSync, lstatSync, readdirSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { memoryBase } from "./home.ts";
|
|
@@ -48,7 +49,7 @@ function curatedStats(base: string, hash: string, key: string, captured: boolean
|
|
|
48
49
|
if (!existsSync(join(dir, "generation.json")) && !existsSync(join(dir, "generation-seal.json"))) return { entries: 0, lastAt: null };
|
|
49
50
|
const store = openShardStore({ memoryDir: base, project: key, deferPromotions: true, lifecycle: "ignore" });
|
|
50
51
|
try {
|
|
51
|
-
const db = new Database(join(dir, store.profile.foreignPruneFormat
|
|
52
|
+
const db = new Database(join(dir, indexFileName(!!store.profile.foreignPruneFormat)), { readonly: true });
|
|
52
53
|
try {
|
|
53
54
|
db.run("PRAGMA busy_timeout = 0");
|
|
54
55
|
const row = db.query("SELECT COUNT(*) AS n, MAX(at) AS last FROM entries WHERE scope = 'project' AND project = ?" + (store.profile.foreignPruneFormat ? " AND id NOT IN (SELECT id FROM generation_targets)" : "")).get(key) as { n: number; last: string | null };
|
package/src/memory/doctor.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
2
|
+
import { indexFileName } from "./index-file.ts";
|
|
3
|
+
import { assertFormatFloor } from "./formats.ts";
|
|
2
4
|
import { tmpdir } from "node:os";
|
|
3
5
|
import { basename, join } from "node:path";
|
|
4
6
|
import { Database } from "bun:sqlite";
|
|
@@ -12,10 +14,11 @@ import { readSelector } from "./generation.ts";
|
|
|
12
14
|
import { processLiveness, withGitTimeout } from "./platform.ts";
|
|
13
15
|
import { endLikeSignal, STOP_SIGNALS } from "./sync-cadence.ts";
|
|
14
16
|
import { boundRepoRoot } from "./repo-scope.ts";
|
|
15
|
-
import { facts, label, PLAIN, type Style } from "./style.ts";
|
|
17
|
+
import { facts, label, PLAIN, singleLine, type Style } from "./style.ts";
|
|
16
18
|
import { aliasedKeys, shardDir } from "./shard.ts";
|
|
17
19
|
import type { MemoryStore, View } from "./store.ts";
|
|
18
20
|
import { nextSyncAt, readSyncState, remoteConfigured, syncDue, usableState } from "./sync-cadence.ts";
|
|
21
|
+
import { readSetupDrift } from "../setup-drift.ts";
|
|
19
22
|
|
|
20
23
|
/**
|
|
21
24
|
* F15, `amem doctor`: one report over the memory home. It writes nothing into the home. Every check that needs a store (the notes, the brief, the index proof) runs on a
|
|
@@ -42,6 +45,8 @@ const MAX_IDS = 10;
|
|
|
42
45
|
|
|
43
46
|
export interface DoctorOptions {
|
|
44
47
|
home: string; homeSource: string; cwd: string; project: string; aliases: readonly string[]; now: number;
|
|
48
|
+
/** Test seam for setup path resolution; the CLI uses the process environment. */
|
|
49
|
+
env?: Readonly<Record<string, string | undefined>>;
|
|
45
50
|
/** Opens this project's curated store on `base`, the way the other person reads do (a split home through its project shard). Doctor passes a private scratch base, never the live home. */
|
|
46
51
|
openCurated: (base: string) => MemoryStore;
|
|
47
52
|
/** The cadence command the person runs from their own shell; shown in the report, never installed. */
|
|
@@ -60,6 +65,8 @@ export interface DoctorReport {
|
|
|
60
65
|
drops: Section;
|
|
61
66
|
/** #188, additive: present only when the project's curated logs hold forget or prune operations. Informational; status is always ok. */
|
|
62
67
|
masked?: Section;
|
|
68
|
+
/** #202, additive: applied native setup registrations checked against the shared path resolver. */
|
|
69
|
+
setup?: Section;
|
|
63
70
|
};
|
|
64
71
|
}
|
|
65
72
|
|
|
@@ -202,7 +209,7 @@ export async function withBriefSnapshot<T>(home: string, project: string, read:
|
|
|
202
209
|
|
|
203
210
|
/** A snapshot of the live shared-v1 index (a read-only connection, so no checkpoint, no lease), or false when there is none. */
|
|
204
211
|
function snapshotIndex(live: string, to: string): boolean {
|
|
205
|
-
const file = join(live,
|
|
212
|
+
const file = join(live, indexFileName(false));
|
|
206
213
|
if (!regularFile(file)) return false;
|
|
207
214
|
const forms: (() => Database)[] = [
|
|
208
215
|
() => new Database(file, { readonly: true }),
|
|
@@ -435,7 +442,11 @@ function guardScratch(): { adopt: (dir: string) => void; release: () => void } {
|
|
|
435
442
|
* a lone signal waits at most one call for the signal handler, which then removes the scratch copy and ends doctor.
|
|
436
443
|
*/
|
|
437
444
|
export const DOCTOR_GIT_TIMEOUT_MS = 5_000;
|
|
438
|
-
export function doctorReport(options: DoctorOptions): Promise<DoctorReport> {
|
|
445
|
+
export function doctorReport(options: DoctorOptions): Promise<DoctorReport> {
|
|
446
|
+
// Doctor reads every surface below, live or through a scratch copy: a floor on any of them refuses the whole report before anything is read (#199 AC5).
|
|
447
|
+
for (const surface of ["curated", "capture", "candidates", "work-lists"] as const) assertFormatFloor(options.home, surface);
|
|
448
|
+
return withGitTimeout(DOCTOR_GIT_TIMEOUT_MS, () => runDoctor(options));
|
|
449
|
+
}
|
|
439
450
|
async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
|
|
440
451
|
let scratch: string | undefined, unguard: (() => void) | undefined;
|
|
441
452
|
let duplicates: Section, stale: Section, index: Section;
|
|
@@ -459,7 +470,7 @@ async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
|
|
|
459
470
|
} else {
|
|
460
471
|
const home = join(scratch, "home"); mkdirSync(home, { mode: 0o700 });
|
|
461
472
|
copyCurated(options, layout, home, budget);
|
|
462
|
-
const snapshotted = layout === "shared-v1" && snapshotIndex(options.home, join(home,
|
|
473
|
+
const snapshotted = layout === "shared-v1" && snapshotIndex(options.home, join(home, indexFileName(false)));
|
|
463
474
|
curated = options.openCurated(home);
|
|
464
475
|
const rows = curated.list({ project: options.project, limit: LIST_ALL });
|
|
465
476
|
await tick();
|
|
@@ -480,10 +491,13 @@ async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
|
|
|
480
491
|
candidates = summary === null ? unavailable("candidates.db exists but cannot be read")
|
|
481
492
|
: { status: summary.dueWithin7Days ? "attention" : "ok", ...summary, ...(summary.dueWithin7Days ? { hint: "run amem review walk" } : {}) };
|
|
482
493
|
} catch (error) { candidates = unavailable(message(error)); }
|
|
494
|
+
let setup: Section;
|
|
495
|
+
try { setup = { ...readSetupDrift({ home: options.home, ...(options.env ? { env: options.env } : {}), cwd: options.cwd }) }; }
|
|
496
|
+
catch { setup = unavailable("setup registrations could not be checked"); }
|
|
483
497
|
let sections: DoctorReport["sections"];
|
|
484
498
|
try {
|
|
485
499
|
await tick();
|
|
486
|
-
sections = { duplicates, stale, candidates, capture: captureSection(options), brief: await briefSection(options, curated, workBase, copyFailed), index, readers: readersSection(options), policy: policySection(options), cadence: cadenceSection(options), drops: dropsSection(options), ...maskedSection(curated) };
|
|
500
|
+
sections = { duplicates, stale, candidates, capture: captureSection(options), brief: await briefSection(options, curated, workBase, copyFailed), index, readers: readersSection(options), policy: policySection(options), cadence: cadenceSection(options), drops: dropsSection(options), setup, ...maskedSection(curated) };
|
|
487
501
|
await tick(); // a signal that arrived during the last synchronous stage is handled while the guard is still installed
|
|
488
502
|
} finally { try { curated?.close(); } finally { try { if (scratch) rmSync(scratch, { recursive: true, force: true }); } finally { unguard?.(); } } }
|
|
489
503
|
return { schema: DOCTOR_SCHEMA, status: worst(Object.values(sections).map(section => section.status)), home: { path: options.home, source: options.homeSource }, project: options.project, sections };
|
|
@@ -493,6 +507,7 @@ async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
|
|
|
493
507
|
const SECTION_TITLE: Record<keyof DoctorReport["sections"], string> = {
|
|
494
508
|
duplicates: "duplicate notes", stale: "stale path references", candidates: "review candidates", capture: "capture per CLI", brief: "session brief",
|
|
495
509
|
index: "index equals rebuild", readers: "reader leases", policy: "policy in effect", cadence: "ingest and sync cadence", drops: "recent capture drops",
|
|
510
|
+
setup: "native setup registrations",
|
|
496
511
|
masked: "forgotten or pruned entries (informational)",
|
|
497
512
|
};
|
|
498
513
|
|
|
@@ -513,6 +528,10 @@ function sectionDetail(name: string, detail: Record<string, unknown>, s: Style):
|
|
|
513
528
|
const counts = Object.entries((detail.counts ?? {}) as Record<string, number>);
|
|
514
529
|
return [counts.length ? `${counts.map(([reason, n]) => `${reason.replaceAll("_", " ")} ${n}`).join(", ")} (recent metrics tail)` : "none in the recent metrics tail"].map(line => detail.malformedLines ? `${line}; ${detail.malformedLines} malformed lines skipped` : line);
|
|
515
530
|
}
|
|
531
|
+
if (name === "setup" && Array.isArray(detail.entries)) {
|
|
532
|
+
return (detail.entries as { target?: unknown; file?: unknown; entry?: unknown; reason?: unknown; expectedFile?: unknown }[]).map(item =>
|
|
533
|
+
singleLine(`${item.target} ${item.file}${item.expectedFile ? ` -> ${item.expectedFile}` : ""} ${item.entry}: ${item.reason}`));
|
|
534
|
+
}
|
|
516
535
|
if (name === "brief" && typeof detail.tokens === "number") return [`${detail.tokens} of ${detail.budget} tokens${detail.coverage ? `, coverage ${facts(detail.coverage as Record<string, unknown>) || "complete"}` : ""}`];
|
|
517
536
|
const { examples, ...rest } = detail;
|
|
518
537
|
const line = facts(rest);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, openSync, readFileSync, readSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Format floors (#199 AC5). `formats.json` in the memory home is an append-only JSON array of `{ surface, minReader, since }` records: a
|
|
6
|
+
* person appends one to stop releases older than `minReader` from reading that surface (`home` covers every surface). This package never
|
|
7
|
+
* writes the file, so no writer raises a floor implicitly. Each surface checks its floor before it creates, opens or writes anything, and a
|
|
8
|
+
* reader below it refuses with `code: "format_floor"`; a file this version cannot read refuses with `code: "format_floor_unreadable"`.
|
|
9
|
+
* A record naming a surface this version does not know is not its concern. Releases before this one do not read the file.
|
|
10
|
+
*/
|
|
11
|
+
export const FORMATS_FILE = "formats.json";
|
|
12
|
+
export const FORMAT_SURFACES = ["curated", "capture", "hook-spool", "candidates", "work-lists", "sync"] as const;
|
|
13
|
+
export type FormatSurface = typeof FORMAT_SURFACES[number];
|
|
14
|
+
export interface FormatFloor { surface: string; minReader: string; since: string }
|
|
15
|
+
const MAX_BYTES = 64 * 1024, SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/;
|
|
16
|
+
|
|
17
|
+
export class FormatFloorError extends Error {
|
|
18
|
+
constructor(readonly code: "format_floor" | "format_floor_unreadable", message: string, readonly floor?: FormatFloor & { reader: string }) { super(message); this.name = "FormatFloorError"; }
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* This release's version, read once when the module loads, so a long-running process whose install is replaced in place keeps comparing its own
|
|
22
|
+
* version. Undefined when it cannot be read: then every applicable floor refuses.
|
|
23
|
+
*/
|
|
24
|
+
const READER_VERSION: string | undefined = (() => {
|
|
25
|
+
try { const { version } = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as { version?: unknown }; return typeof version === "string" && SEMVER.test(version) ? version : undefined; }
|
|
26
|
+
catch { return undefined; }
|
|
27
|
+
})();
|
|
28
|
+
const parts = (version: string) => SEMVER.exec(version)!.slice(1, 4).map(Number);
|
|
29
|
+
function below(reader: string, floor: string): boolean {
|
|
30
|
+
const a = parts(reader), b = parts(floor);
|
|
31
|
+
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i]! < b[i]!;
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Bounded read of the floor records: undefined when there is no file; anything that is not a regular file of valid records refuses. */
|
|
36
|
+
export function readFormatFloors(home: string): FormatFloor[] | undefined {
|
|
37
|
+
const file = join(home, FORMATS_FILE), unreadable = (why: string) => new FormatFloorError("format_floor_unreadable", `${file}: ${why}; nothing was read or written. Fix or restore the file (it is append-only and written by a person).`);
|
|
38
|
+
let fd: number;
|
|
39
|
+
try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
|
40
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT" || (error as NodeJS.ErrnoException).code === "ENOTDIR") return undefined; throw unreadable("not a readable regular file"); }
|
|
41
|
+
let text: string;
|
|
42
|
+
try {
|
|
43
|
+
const stat = fstatSync(fd);
|
|
44
|
+
if (!stat.isFile() || stat.size > MAX_BYTES) throw unreadable("not a regular file of at most 64 KiB");
|
|
45
|
+
const data = Buffer.alloc(MAX_BYTES + 1); let size = 0, n: number;
|
|
46
|
+
while (size < data.length && (n = readSync(fd, data, size, data.length - size, null)) > 0) size += n;
|
|
47
|
+
if (size > MAX_BYTES) throw unreadable("not a regular file of at most 64 KiB");
|
|
48
|
+
text = data.subarray(0, size).toString("utf8");
|
|
49
|
+
} finally { closeSync(fd); }
|
|
50
|
+
let value: unknown;
|
|
51
|
+
try { value = JSON.parse(text); } catch { throw unreadable("not JSON"); }
|
|
52
|
+
if (!Array.isArray(value) || !value.every(record => record && typeof record === "object" && !Array.isArray(record)
|
|
53
|
+
&& typeof record.surface === "string" && typeof record.minReader === "string" && SEMVER.test(record.minReader) && typeof record.since === "string")) throw unreadable("not an array of { surface, minReader, since } records");
|
|
54
|
+
return value.map(({ surface, minReader, since }) => ({ surface, minReader, since }));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Refuse before touching `surface` when the home records a floor above this release. */
|
|
58
|
+
export function assertFormatFloor(home: string, surface: FormatSurface): void {
|
|
59
|
+
const floors = readFormatFloors(home);
|
|
60
|
+
if (!floors) return;
|
|
61
|
+
const reader = READER_VERSION ?? "unknown";
|
|
62
|
+
for (const floor of floors) if ((floor.surface === surface || floor.surface === "home") && (READER_VERSION === undefined || below(READER_VERSION, floor.minReader))) {
|
|
63
|
+
throw new FormatFloorError("format_floor", `amem ${reader} is below the ${surface} format floor ${floor.minReader} (since ${floor.since}) recorded in ${join(home, FORMATS_FILE)}; nothing was read or written. Upgrade amem to ${floor.minReader} or later.`, { ...floor, reader });
|
|
64
|
+
}
|
|
65
|
+
}
|