@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/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/mcp-memory.ts
CHANGED
|
@@ -201,11 +201,12 @@ export function createMemoryMcp(options: MemoryMcpOptions): MemoryMcp {
|
|
|
201
201
|
try {
|
|
202
202
|
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
203
203
|
const captureState: { engine?: "fts5" | "substring" } = {};
|
|
204
|
-
let batch: { body: string; ref: string; tier: string; at: string; value: SearchValue; expiresAt?: string | null }[] = [];
|
|
204
|
+
let batch: { body: string; ref: string; tier: string; at: string; value: SearchValue; expiresAt?: string | null; event?: string; session?: string }[] = [];
|
|
205
205
|
const append = (value: SearchValue) => {
|
|
206
206
|
const row = value.source === "capture" ? value.capture : value.view;
|
|
207
207
|
batch.push(value.source === "capture"
|
|
208
|
-
? { value, ref: `capture:${value.capture.id}`, at: row.at, tier: "capture", body: `${value.capture.excerpt}\n${value.capture.paths.join(" ")}\n${value.capture.event}
|
|
208
|
+
? { value, ref: `capture:${value.capture.id}`, at: row.at, tier: "capture", body: `${value.capture.excerpt}\n${value.capture.paths.join(" ")}\n${value.capture.event}`,
|
|
209
|
+
event: value.capture.event, ...(typeof value.capture.session === "string" && /^[a-f0-9]{16}$/.test(value.capture.session) ? { session: value.capture.session } : {}) }
|
|
209
210
|
: { value, ref: value.source === "repo" ? `repo:${value.view.id}` : value.view.id, at: row.at, tier: value.source === "repo" ? "long" : value.view.tier, body: `${value.view.title ?? ""}\n${value.view.text}\n${value.view.about.join(" ")}`, expiresAt: value.source === "curated" ? value.view.expiresAt : null });
|
|
210
211
|
if (batch.length === READ_PAGE) { index.append(batch); batch = []; }
|
|
211
212
|
};
|
|
@@ -223,7 +224,16 @@ export function createMemoryMcp(options: MemoryMcpOptions): MemoryMcp {
|
|
|
223
224
|
const repoNotes = (await repoRows()).filter(view => (!since || Date.parse(view.at) >= Date.parse(since)) && (useFts || words.every(word => `${view.title ?? ""}\n${view.text}\n${view.about.join(" ")}`.toLowerCase().includes(word))));
|
|
224
225
|
for (const view of repoNotes) append({ source: "repo", evidence: false, view });
|
|
225
226
|
if (batch.length) index.append(batch);
|
|
226
|
-
const
|
|
227
|
+
const now = store.readNow(), baseline = index.best(query, limit, now, useFts, args.explain === true);
|
|
228
|
+
const captureSlots = baseline.filter(hit => hit.value.source === "capture").length;
|
|
229
|
+
const weightedCapture = captureSlots ? index.bestCaptureGroups(query, captureSlots, now, useFts, args.explain === true) : [];
|
|
230
|
+
let captureIndex = 0;
|
|
231
|
+
// Keep every baseline noncapture slot and position. Only its capture slots receive weighted,
|
|
232
|
+
// one-per-session rows from the separately screened TEMP corpus; exhausted slots are omitted.
|
|
233
|
+
const selected = baseline.flatMap(hit => {
|
|
234
|
+
if (hit.value.source !== "capture") return [hit];
|
|
235
|
+
const replacement = weightedCapture[captureIndex++]; return replacement ? [replacement] : [];
|
|
236
|
+
}).map(({ value, score, scoreDetails }) => {
|
|
227
237
|
const hit = { ...value, score };
|
|
228
238
|
return scoreDetails ? { ...hit, scoreDetails: { source: value.source, ...scoreDetails } } : hit;
|
|
229
239
|
});
|
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";
|
|
@@ -38,6 +39,7 @@ export type CandidateInput = { project: string } & (
|
|
|
38
39
|
export type CandidateStatus = "pending" | "accepting" | "accepted" | "rejected";
|
|
39
40
|
export interface Candidate { id: string; createdAt: string; status: CandidateStatus; input: CandidateInput; acceptedNoteId: string | null }
|
|
40
41
|
export interface ConsolidationReview { status: Exclude<CandidateStatus, "pending">; acceptedNoteId: string | null }
|
|
42
|
+
const KINDS: readonly string[] = ["correction", "fail", "decay", "memory", "promotion", "consolidation", "model-answer"] satisfies CandidateInput["kind"][];
|
|
41
43
|
export interface CandidateFilter { project: string; status?: CandidateStatus | "all"; kind?: CandidateInput["kind"]; limit?: number }
|
|
42
44
|
export interface CounterKey { entryId: string; project: string; task?: string | null; turn?: string | null; session?: string | null; deliveryId: string }
|
|
43
45
|
export interface CounterValues { rendered?: number | null; delivered?: number | null; cited?: number | null }
|
|
@@ -145,6 +147,7 @@ export class CandidateStore {
|
|
|
145
147
|
if (options.reconcile?.aliases.length) this.reconcile = { canonical: options.reconcile.canonical, aliases: [...options.reconcile.aliases] };
|
|
146
148
|
if (options.memoryDir !== undefined && options.home !== undefined) throw new Error("choose a home or an exact memory directory");
|
|
147
149
|
this.dir = options.memoryDir ?? (options.home === undefined ? memoryHome() : join(options.home, "memory"));
|
|
150
|
+
assertFormatFloor(this.dir, "candidates");
|
|
148
151
|
this.now = options.now ?? Date.now;
|
|
149
152
|
mkdirSync(this.dir, { recursive: true, mode: 0o700 }); chmodSync(this.dir, 0o700);
|
|
150
153
|
this.path = join(this.dir, "candidates.db");
|
|
@@ -187,8 +190,12 @@ export class CandidateStore {
|
|
|
187
190
|
this.db.query("DELETE FROM counters WHERE rowid IN (SELECT rowid FROM (SELECT rowid, row_number() OVER (PARTITION BY project ORDER BY at DESC, rowid DESC) AS rank FROM counters) WHERE rank > ?)").run(COUNTER_RETENTION.count);
|
|
188
191
|
}).immediate();
|
|
189
192
|
}
|
|
190
|
-
|
|
191
|
-
|
|
193
|
+
/** #199: rows of a kind this version does not know (a newer writer's) are skipped and counted here, never thrown. */
|
|
194
|
+
skippedUnknownKinds = 0;
|
|
195
|
+
private decode(row: { id: string; payload: string; at: number; status: CandidateStatus; note_id: string | null }): Candidate | undefined {
|
|
196
|
+
const input = JSON.parse(row.payload) as CandidateInput;
|
|
197
|
+
if (input && typeof input === "object" && !KINDS.includes(input.kind)) { this.skippedUnknownKinds++; return undefined; }
|
|
198
|
+
validate(input);
|
|
192
199
|
if (hash({ schema: "agent-mem.candidate/v1", input }) !== row.id) throw new Error("candidate content hash mismatch");
|
|
193
200
|
return { id: row.id, createdAt: new Date(row.at).toISOString(), status: row.status, input, acceptedNoteId: row.note_id };
|
|
194
201
|
}
|
|
@@ -230,14 +237,16 @@ export class CandidateStore {
|
|
|
230
237
|
return row ? { status: row.status, acceptedNoteId: row.note_id } : undefined;
|
|
231
238
|
}
|
|
232
239
|
list(filter: CandidateFilter): Candidate[] {
|
|
233
|
-
if (!text(filter.project, 400) || filter.status !== undefined && !["pending", "accepting", "accepted", "rejected", "all"].includes(filter.status) || filter.kind !== undefined && !
|
|
240
|
+
if (!text(filter.project, 400) || filter.status !== undefined && !["pending", "accepting", "accepted", "rejected", "all"].includes(filter.status) || filter.kind !== undefined && !KINDS.includes(filter.kind)) throw new Error("invalid candidate filter");
|
|
234
241
|
const limit = filter.limit ?? CANDIDATE_RETENTION.count;
|
|
235
242
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > CANDIDATE_RETENTION.count) throw new Error("candidate limit must be 1..500");
|
|
236
243
|
this.prune();
|
|
237
244
|
const where = ["project IN (SELECT value FROM json_each(?))"], bind: (string | number)[] = [this.scope(filter.project)];
|
|
238
245
|
if (filter.status === undefined) where.push("status IN ('pending', 'accepting')"); else if (filter.status !== "all") { where.push("status = ?"); bind.push(filter.status); }
|
|
239
246
|
if (filter.kind) { where.push("kind = ?"); bind.push(filter.kind); }
|
|
240
|
-
|
|
247
|
+
// #199: unknown kinds are skipped before LIMIT, so a newer writer's rows never displace a supported candidate; they are still counted.
|
|
248
|
+
this.skippedUnknownKinds += (this.db.query(`SELECT count(*) AS n FROM candidates WHERE ${where.join(" AND ")} AND kind NOT IN (SELECT value FROM json_each(?))`).get(...bind, JSON.stringify(KINDS)) as { n: number }).n;
|
|
249
|
+
return (this.db.query(`SELECT * FROM candidates WHERE ${where.join(" AND ")} AND kind IN (SELECT value FROM json_each(?)) ORDER BY at DESC, rowid DESC LIMIT ?`).all(...bind, JSON.stringify(KINDS), limit) as Parameters<CandidateStore["decode"]>[0][]).flatMap(row => this.decode(row) ?? []);
|
|
241
250
|
}
|
|
242
251
|
recordCounter(key: CounterKey, values: CounterValues, verdict: CandidateVerdict): string {
|
|
243
252
|
publicOnly(verdict); const normalized = counterKey(key);
|
|
@@ -4,6 +4,9 @@ import { stripPrivateSpans } from "./private-spans.ts";
|
|
|
4
4
|
|
|
5
5
|
export const CAPTURE_MAX_EXCERPT = 2000;
|
|
6
6
|
export const CAPTURE_MAX_PATHS = 32;
|
|
7
|
+
export const TOOL_INPUT_LINE_BYTES = 512;
|
|
8
|
+
const TOOL_INPUT_ORDER = ["command", "pattern", "query", "url", "description", "prompt", "skill", "subject"] as const;
|
|
9
|
+
const TOOL_INPUT_OMIT = new Set(["content", "old_string", "new_string", "edits"]);
|
|
7
10
|
const ROW_OVERHEAD_BYTES = 96;
|
|
8
11
|
/** The session key is a fixed-width hash prefix, so it adds a bounded amount covered by the row overhead. */
|
|
9
12
|
export const SESSION_KEY = /^[a-f0-9]{16}$/;
|
|
@@ -22,6 +25,37 @@ export interface NormalizedCaptureFields {
|
|
|
22
25
|
session?: string; subagent?: true;
|
|
23
26
|
}
|
|
24
27
|
|
|
28
|
+
/** Clip a string at a UTF-8 boundary without splitting a code point. */
|
|
29
|
+
export function clipCaptureUtf8(text: string, maximumBytes: number): string {
|
|
30
|
+
let out = "", bytes = 0;
|
|
31
|
+
for (const character of text) {
|
|
32
|
+
const size = Buffer.byteLength(character);
|
|
33
|
+
if (bytes + size > maximumBytes) break;
|
|
34
|
+
out += character; bytes += size;
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Deterministic, value-only summary of Claude tool_input. Strip private spans before clipping. */
|
|
40
|
+
export function toolInputLine(input: unknown): string {
|
|
41
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) return "";
|
|
42
|
+
const fields = Object.entries(input as Record<string, unknown>)
|
|
43
|
+
.filter(([key, value]) => typeof value === "string" && !TOOL_INPUT_OMIT.has(key.toLowerCase()) && value.length > 0);
|
|
44
|
+
const byKey = new Map(fields);
|
|
45
|
+
const known = TOOL_INPUT_ORDER.filter(key => byKey.has(key));
|
|
46
|
+
const other = fields.map(([key]) => key).filter(key => !(TOOL_INPUT_ORDER as readonly string[]).includes(key)).sort();
|
|
47
|
+
const values = [...known, ...other].map(key => clipCaptureUtf8(
|
|
48
|
+
stripPrivateSpans(byKey.get(key) as string).replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, " "), TOOL_INPUT_LINE_BYTES,
|
|
49
|
+
)).filter(Boolean);
|
|
50
|
+
return clipCaptureUtf8(values.join(" | "), TOOL_INPUT_LINE_BYTES).trimEnd();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Remove private content before joining tool input and content into the persisted excerpt. */
|
|
54
|
+
export function toolCaptureExcerpt(inputLine: string, content: string): string {
|
|
55
|
+
const visibleContent = stripPrivateSpans(content);
|
|
56
|
+
return inputLine ? visibleContent ? `${inputLine}\n${visibleContent}` : inputLine : visibleContent;
|
|
57
|
+
}
|
|
58
|
+
|
|
25
59
|
/** Pure capture shaping; no store, project lookup or filesystem operation is loaded or invoked here. */
|
|
26
60
|
/** The caps a person's configuration may lower (F4); the built-in caps are the ceiling, so a larger value is ignored. */
|
|
27
61
|
export interface CaptureCaps { excerptBytes?: number; paths?: number; denyPaths?: readonly string[] }
|
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 };
|
|
@@ -44,9 +44,10 @@ export function searchAllProjects(base: string, query: string, opts: { limit?: n
|
|
|
44
44
|
}
|
|
45
45
|
} finally { store?.close(); }
|
|
46
46
|
if (opts.includeCapture !== false) {
|
|
47
|
+
// #196 fixes selected-project store/MCP capture slots; this separate cross-project merge keeps its legacy cohort.
|
|
47
48
|
for (const dir of shard.machineDirs) {
|
|
48
49
|
const days = readCaptureConfig(dir, base).retentionDays, cutoff = new Date(now - days * 24 * 3600_000);
|
|
49
|
-
for (const { row, rank } of captureSearch(dir, query, Math.max(limit * 4, 32), Number.isFinite(cutoff.getTime()) ? cutoff.toISOString() : undefined)) {
|
|
50
|
+
for (const { row, rank } of captureSearch(dir, query, Math.max(limit * 4, 32), Number.isFinite(cutoff.getTime()) ? cutoff.toISOString() : undefined, { weightEvents: false, onePerSession: false })) {
|
|
50
51
|
hits.push({ source: "capture", score: -rank * 0.5, capture: row as CaptureRow, project: shard.key });
|
|
51
52
|
}
|
|
52
53
|
}
|