@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/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 { hookPeakRssKB } from "./hook-resources.ts";
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>): Promise<string | undefined> {
70
- const reader = stream.getReader(), chunks: Uint8Array[] = []; let length = 0;
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
- chunks.push(next.value); length += next.value.byteLength;
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
- return new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks));
78
- } finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
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
- /** Native metadata and cold indexes are bounded in a short-lived owned child, never a resident worker.
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 configuredBrief = loadAmemConfig(base, repoConfigRoot(options.cwd)).config.briefBytes;
87
- const maxTokens = Math.min(HOOK_CONTEXT_BYTES, configuredBrief ?? HOOK_CONTEXT_BYTES, Math.max(1, options.maxTokens ?? HOOK_CONTEXT_BYTES));
88
- // The worker reads the configuration itself (same base, same cwd), so it screens with exactly the policy the parent did.
89
- const payload = JSON.stringify({ cwd: options.cwd, base, current: pending.name, event, maxTokens, ownedGroup: true, deadlineAt: performance.timeOrigin + workDeadline,
90
- policy: { denyPaths: options.policy?.denyPaths, piiPatterns: options.policy?.piiPatterns } });
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 answer = await Promise.race([
105
- Promise.all([exit, boundedOutput(child.stdout)]),
106
- new Promise<undefined>(resolve => { timer = setTimeout(() => { stop(); resolve(undefined); }, Math.max(1, workDeadline - performance.now())); }),
107
- ]);
108
- if (!answer || !answer[1]) return result;
109
- const parsed = JSON.parse(answer[1]) as { context?: unknown; captured?: unknown; valid?: unknown; complete?: unknown; rssKB?: unknown };
110
- if (parsed.complete !== true) return result;
111
- if (typeof parsed.rssKB === "number" && Number.isSafeInteger(parsed.rssKB) && parsed.rssKB > 0) result.rssKB = parsed.rssKB;
112
- if (typeof parsed.context === "string" && Buffer.byteLength(parsed.context) <= maxTokens) result.context = parsed.context;
113
- result.captured = parsed.captured === true; result.valid = parsed.valid === true;
114
- return result;
115
- } catch { return result; }
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
- if (timer) clearTimeout(timer);
118
- stop();
119
- await exit;
120
- // Bun subprocess usage is bytes; process.resourceUsage below is KiB.
121
- result.rawRssKB = (child.resourceUsage()?.maxRSS ?? 0) / 1024;
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
- let result: Awaited<ReturnType<typeof hookWorker>> | undefined;
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 hookWorker({ ...options, cwd }, base, deadline, { name: "", receipt: "", cwd }, event);
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 hookWorker({ ...options, cwd: pending.cwd }, base, deadline, pending, event);
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: (hookPeakRssKB() + childRssKB) / 1024, rawRssMB: (process.resourceUsage().maxRSS + childRawRssKB) / 1024, ...(dropped ? { captureDrops: 1, captureDropReason: "spool_full" as const } : {}) }]); } catch { /* Metrics never change the native tool outcome. */ }
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: (hookPeakRssKB() + childRssKB) / 1024, rawRssMB: (process.resourceUsage().maxRSS + childRawRssKB) / 1024, ...(dropped ? { captureDrops: 1, captureDropReason: "spool_full" as const } : {}) }]);
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 selected = index.best(query, limit, store.readNow(), useFts, args.explain === true).map(({ value, score, scoreDetails }) => {
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
  });
@@ -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
- private decode(row: { id: string; payload: string; at: number; status: CandidateStatus; note_id: string | null }): Candidate {
191
- const input = JSON.parse(row.payload) as CandidateInput; validate(input);
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 && !["correction", "fail", "decay", "memory", "promotion", "consolidation", "model-answer"].includes(filter.kind)) throw new Error("invalid candidate filter");
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
- return (this.db.query(`SELECT * FROM candidates WHERE ${where.join(" AND ")} ORDER BY at DESC, rowid DESC LIMIT ?`).all(...bind, limit) as Parameters<CandidateStore["decode"]>[0][]).map(row => this.decode(row));
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[] }
@@ -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 };
@@ -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 ? "index-v2.db" : "index.db"), { readonly: true });
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
  }