context-mode 1.0.131 → 1.0.133

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/build/server.js CHANGED
@@ -78,6 +78,20 @@ const CM_FS_PRELOAD = join(tmpdir(), `cm-fs-preload-${process.pid}.js`);
78
78
  writeFileSync(CM_FS_PRELOAD, `(function(){var __cm_fs=0;process.on('exit',function(){if(__cm_fs>0)try{process.stderr.write('__CM_FS__:'+__cm_fs+'\\n')}catch(e){}});try{var f=require('fs');var ors=f.readFileSync;f.readFileSync=function(){var r=ors.apply(this,arguments);if(Buffer.isBuffer(r))__cm_fs+=r.length;else if(typeof r==='string')__cm_fs+=Buffer.byteLength(r);return r;};}catch(e){}})();\n`);
79
79
  // Lazy singleton — no DB overhead unless index/search is used
80
80
  let _store = null;
81
+ /**
82
+ * Build the FK-attribution object passed to every ContentStore.index*() call
83
+ * in this process. CLAUDE_SESSION_ID is the only MCP-side handle we have on
84
+ * the current session — eventId stays undefined because MCP tool invocations
85
+ * are not paired with PostToolUse event rows at index time (the hook fires
86
+ * AFTER the tool returns). Empty-string fallback inside #insertChunks keeps
87
+ * legacy unattributed rows readable.
88
+ */
89
+ function currentAttribution() {
90
+ const sessionId = process.env.CLAUDE_SESSION_ID;
91
+ if (!sessionId)
92
+ return undefined;
93
+ return { sessionId };
94
+ }
81
95
  /**
82
96
  * Auto-index session events files written by SessionStart hook.
83
97
  * Scans ~/.claude/context-mode/sessions/ for *-events.md files.
@@ -95,7 +109,7 @@ function maybeIndexSessionEvents(store) {
95
109
  for (const file of files) {
96
110
  const filePath = join(sessionsDir, file);
97
111
  try {
98
- store.index({ path: filePath, source: "session-events" });
112
+ store.index({ path: filePath, source: "session-events", attribution: currentAttribution() });
99
113
  unlinkSync(filePath);
100
114
  }
101
115
  catch { /* best-effort per file */ }
@@ -1153,7 +1167,7 @@ __cm_main().catch(e=>{console.error(e);process.exitCode=1});${background ? '\nse
1153
1167
  function indexStdout(stdout, source) {
1154
1168
  const store = getStore();
1155
1169
  trackIndexed(Buffer.byteLength(stdout));
1156
- const indexed = store.index({ content: stdout, source });
1170
+ const indexed = store.index({ content: stdout, source, attribution: currentAttribution() });
1157
1171
  return {
1158
1172
  content: [
1159
1173
  {
@@ -1173,7 +1187,7 @@ function intentSearch(stdout, intent, source, maxResults = 5) {
1173
1187
  const totalBytes = Buffer.byteLength(stdout);
1174
1188
  // Index into the PERSISTENT store so user can ctx_search() later
1175
1189
  const persistent = getStore();
1176
- const indexed = persistent.indexPlainText(stdout, source);
1190
+ const indexed = persistent.indexPlainText(stdout, source, undefined, currentAttribution());
1177
1191
  // Search the persistent store directly (porter → trigram → fuzzy)
1178
1192
  let results = persistent.searchWithFallback(intent, maxResults, source);
1179
1193
  // Extract distinctive terms as vocabulary hints for the LLM
@@ -1407,7 +1421,7 @@ server.registerTool("ctx_index", {
1407
1421
  catch { /* ignore — file read errors handled by store */ }
1408
1422
  }
1409
1423
  const store = getStore();
1410
- const result = store.index({ content, path: resolvedPath, source: source ?? resolvedPath });
1424
+ const result = store.index({ content, path: resolvedPath, source: source ?? resolvedPath, attribution: currentAttribution() });
1411
1425
  return trackResponse("ctx_index", {
1412
1426
  content: [
1413
1427
  {
@@ -1687,7 +1701,24 @@ export function buildFetchCode(url, outputPath) {
1687
1701
  // can serve a public IP for the parent's pre-flight ssrfGuard lookup and
1688
1702
  // then a blocked IP (e.g. 169.254.169.254 IMDS) for the subprocess fetch's
1689
1703
  // own lookup — classic DNS rebinding across the parent/child boundary.
1690
- const classifyIpSrc = classifyIp.toString();
1704
+ //
1705
+ // CRITICAL: bundlers (esbuild) rename top-level identifiers — `classifyIp`
1706
+ // becomes e.g. `_h` in server.bundle.mjs. `classifyIp.toString()` returns
1707
+ // the renamed source `function _h(t){...}`, but the embedded subprocess
1708
+ // template references the literal name `classifyIp` (and the function's
1709
+ // own internal recursion is also `_h(...)`). Result: the subprocess sees
1710
+ // `function _h(t){...; return _h(...)}` injected, then references to
1711
+ // `classifyIp` blow up with `ReferenceError: classifyIp is not defined`.
1712
+ //
1713
+ // Fix: emit `var <fnName> = <fn-expr>; var classifyIp = <fnName>;`. The
1714
+ // named function expression preserves recursion under whatever name the
1715
+ // bundler chose, and the alias re-exposes the canonical `classifyIp`
1716
+ // identifier the rest of the embedded script depends on.
1717
+ const classifyIpInner = classifyIp.toString();
1718
+ const classifyIpFnName = classifyIp.name || "classifyIp";
1719
+ const classifyIpSrc = classifyIpFnName === "classifyIp"
1720
+ ? `var classifyIp = ${classifyIpInner};`
1721
+ : `var ${classifyIpFnName} = ${classifyIpInner};\nvar classifyIp = ${classifyIpFnName};`;
1691
1722
  const strictMode = process.env.CTX_FETCH_STRICT === "1";
1692
1723
  return `
1693
1724
  const TurndownService = require(${turndownPath});
@@ -2145,15 +2176,16 @@ function indexFetched(f) {
2145
2176
  // `source` label do not overwrite each other (commit 1f1243e). ctx_search()
2146
2177
  // still finds both via LIKE-mode source filter on the `source` substring.
2147
2178
  const storageLabel = composeFetchCacheKey(f.source, f.url);
2179
+ const attribution = currentAttribution();
2148
2180
  let indexed;
2149
2181
  if (f.header === "__CM_CT__:json") {
2150
- indexed = store.indexJSON(f.markdown, storageLabel);
2182
+ indexed = store.indexJSON(f.markdown, storageLabel, undefined, attribution);
2151
2183
  }
2152
2184
  else if (f.header === "__CM_CT__:text") {
2153
- indexed = store.indexPlainText(f.markdown, storageLabel);
2185
+ indexed = store.indexPlainText(f.markdown, storageLabel, undefined, attribution);
2154
2186
  }
2155
2187
  else {
2156
- indexed = store.index({ content: f.markdown, source: storageLabel });
2188
+ indexed = store.index({ content: f.markdown, source: storageLabel, attribution });
2157
2189
  }
2158
2190
  // Track AFTER the FTS5 write succeeds — failed indexes shouldn't inflate the counter.
2159
2191
  trackIndexed(Buffer.byteLength(f.markdown));
@@ -2460,7 +2492,7 @@ server.registerTool("ctx_batch_execute", {
2460
2492
  .map((c) => c.label)
2461
2493
  .join(",")
2462
2494
  .slice(0, 80)}`;
2463
- const indexed = store.index({ content: stdout, source });
2495
+ const indexed = store.index({ content: stdout, source, attribution: currentAttribution() });
2464
2496
  // Build section inventory — direct query by source_id (no FTS5 MATCH needed)
2465
2497
  const allSections = store.getChunksBySource(indexed.sourceId);
2466
2498
  const inventory = ["## Indexed Sections", ""];
@@ -2582,7 +2614,14 @@ server.registerTool("ctx_stats", {
2582
2614
  }
2583
2615
  if (sid) {
2584
2616
  conversation = getConversationStats({ sessionId: sid, sessionsDir: getSessionDir(), worktreeHash: dbHash });
2585
- const convReal = getRealBytesStats({ sessionId: sid, sessionsDir: getSessionDir(), worktreeHash: dbHash });
2617
+ // v1.0.133 Slice 3: pass contentDbPath so getRealBytesStats can
2618
+ // join chunks WHERE session_id = sid and fold the indexed
2619
+ // content bytes into the per-conversation bar. Without this,
2620
+ // Mert's session showed ~200B (event metadata only) even with
2621
+ // 49 MB of indexed content sitting in the content DB.
2622
+ // Render-time read-only — no DB mutation, no backfill.
2623
+ const contentDbPath = getStorePath();
2624
+ const convReal = getRealBytesStats({ sessionId: sid, sessionsDir: getSessionDir(), worktreeHash: dbHash, contentDbPath });
2586
2625
  const lifeReal = getRealBytesStats({ sessionsDir: getSessionDir() });
2587
2626
  realBytes = { conversation: convReal, lifetime: lifeReal };
2588
2627
  }
@@ -2865,7 +2904,12 @@ server.registerTool("ctx_upgrade", {
2865
2904
  // files (events.md, FTS5 store file, stats file) are preserved.
2866
2905
  // Passing both sessionId AND scope:"project" is ambiguous (does the
2867
2906
  // caller want a per-session wipe or a project-wide one?) and is
2868
- // rejected by the schema's refine().
2907
+ // rejected by an explicit check in the handler body — NOT a schema-level
2908
+ // .refine(). MCP SDK's normalizeObjectSchema() reads `.shape` to project
2909
+ // inputSchema → JSON Schema for tools/list; a ZodEffects (refine wrapper)
2910
+ // has no `.shape`, so the SDK silently emits `properties: {}`, and Claude
2911
+ // Code's strict-input-validation gate then rejects EVERY call to this
2912
+ // tool with "input_schema does not support fields". Issue #563.
2869
2913
  server.registerTool("ctx_purge", {
2870
2914
  title: "Purge Knowledge Base",
2871
2915
  description: "DESTRUCTIVE — permanently delete indexed content. CANNOT be undone.\n\n" +
@@ -2886,6 +2930,9 @@ server.registerTool("ctx_purge", {
2886
2930
  "Use sessionId when the user asks to clear a specific conversation's data.\n" +
2887
2931
  "Use scope:'project' ONLY when the user explicitly asks to reset everything.\n" +
2888
2932
  "NEVER call with bare {confirm:true} — always specify the scope.",
2933
+ // NOTE: schema MUST be a plain z.object — no .refine()/.transform()/
2934
+ // .superRefine() wrapper. See block comment above & issue #563. The
2935
+ // cross-field ambiguity check lives in the handler body below.
2889
2936
  inputSchema: z.object({
2890
2937
  confirm: z.boolean().describe("MUST be true. Destructive operation; false returns 'purge cancelled'."),
2891
2938
  sessionId: z.string().optional().describe("UUID of a single session. Pairs with confirm:true to wipe only that " +
@@ -2894,12 +2941,22 @@ server.registerTool("ctx_purge", {
2894
2941
  scope: z.enum(["session", "project"]).optional().describe("Explicit scope selector. 'session' REQUIRES sessionId. 'project' wipes " +
2895
2942
  "the entire project (FTS5 + every session + stats). Omit only for the " +
2896
2943
  "deprecated bare-{confirm:true} back-compat path."),
2897
- }).refine((v) => !(v.sessionId && v.scope === "project"), {
2898
- message: "Ambiguous purge: sessionId implies scope:'session', cannot combine with scope:'project'. " +
2899
- "Use scope:'project' WITHOUT sessionId for the legacy whole-project wipe.",
2900
- path: ["scope"],
2901
2944
  }),
2902
2945
  }, async ({ confirm, sessionId, scope }) => {
2946
+ // Cross-field ambiguity check — formerly a schema .refine(), moved
2947
+ // into the handler so the inputSchema stays a plain ZodObject and
2948
+ // the MCP SDK can serialize `.shape` into JSON Schema (issue #563).
2949
+ // Same human-readable message as the original refine() preserved.
2950
+ if (sessionId && scope === "project") {
2951
+ return trackResponse("ctx_purge", {
2952
+ content: [{
2953
+ type: "text",
2954
+ text: "Ambiguous purge: sessionId implies scope:'session', cannot combine with scope:'project'. " +
2955
+ "Use scope:'project' WITHOUT sessionId for the legacy whole-project wipe.",
2956
+ }],
2957
+ isError: true,
2958
+ });
2959
+ }
2903
2960
  if (!confirm) {
2904
2961
  return trackResponse("ctx_purge", {
2905
2962
  content: [{
@@ -3365,6 +3422,20 @@ server.registerTool("ctx_insight", {
3365
3422
  // Server startup
3366
3423
  // ─────────────────────────────────────────────────────────
3367
3424
  async function main() {
3425
+ // Startup sibling sweep (#565). OpenCode/KiloCode spawn one MCP child
3426
+ // per session/subagent and never reap them. When a new MCP child boots
3427
+ // under a host that already has N stale idle siblings (sharing OUR
3428
+ // ppid), reclaim them before opening our own DB / sentinel / stdio.
3429
+ // Best effort — never blocks startup.
3430
+ try {
3431
+ const { startupSiblingSweep } = await import("./util/sibling-mcp.js");
3432
+ const report = await startupSiblingSweep();
3433
+ if (report.totalKilled > 0) {
3434
+ console.error(`Reaped ${report.totalKilled} stale sibling MCP server(s) ` +
3435
+ `(SIGTERM: ${report.terminatedBySigterm}, SIGKILL: ${report.terminatedBySigkill})`);
3436
+ }
3437
+ }
3438
+ catch { /* best effort */ }
3368
3439
  // Clean up stale DB files from previous sessions
3369
3440
  const cleaned = cleanupStaleDBs();
3370
3441
  if (cleaned > 0) {
@@ -3414,7 +3485,38 @@ async function main() {
3414
3485
  process.on("SIGINT", () => { gracefulShutdown(); });
3415
3486
  process.on("SIGTERM", () => { gracefulShutdown(); });
3416
3487
  // Lifecycle guard: detect parent death + stdin close to prevent orphaned processes (#103)
3417
- startLifecycleGuard({ onShutdown: () => gracefulShutdown() });
3488
+ // Also: idle self-shutdown (#565) — OpenCode/KiloCode open one MCP child per
3489
+ // session AND per subagent and never tear them down for the host's lifetime,
3490
+ // accumulating one stdio child per session (observed: 26 children / 1.6 GB
3491
+ // RSS under a single `opencode serve` parent). Idle timeout reaps quiescent
3492
+ // servers; live ones bump `recordActivity()` on every JSON-RPC request via
3493
+ // the MCP SDK's `_onrequest` hook wrapped below.
3494
+ const lifecycle = startLifecycleGuard({ onShutdown: () => gracefulShutdown() });
3495
+ // Wrap the SDK's internal request entry so every JSON-RPC `tools/call`,
3496
+ // `tools/list`, etc. resets the idle timer. We intercept at this layer
3497
+ // rather than per-tool because (a) it covers ALL requests, including
3498
+ // listTools / listPrompts / listResources / ping, and (b) it survives
3499
+ // future tool additions without each handler needing to remember to opt in.
3500
+ //
3501
+ // The cast is necessary because `_onrequest` is intentionally undocumented
3502
+ // in the SDK's public types. Best effort — if the field shape changes in
3503
+ // a future SDK release the lifecycle still works, idle reset just degrades
3504
+ // to "untriggered" which simply means the server lives until the next
3505
+ // ppid/signal-based exit path fires. We never block the request path.
3506
+ try {
3507
+ const inner = server.server;
3508
+ const origOnRequest = inner._onrequest;
3509
+ if (typeof origOnRequest === "function") {
3510
+ inner._onrequest = function (...args) {
3511
+ try {
3512
+ lifecycle.recordActivity();
3513
+ }
3514
+ catch { /* never break request path */ }
3515
+ return origOnRequest.apply(this, args);
3516
+ };
3517
+ }
3518
+ }
3519
+ catch { /* best effort — see comment above */ }
3418
3520
  const transport = new StdioServerTransport();
3419
3521
  await server.connect(transport);
3420
3522
  // Write MCP readiness sentinel (#230)
@@ -358,8 +358,39 @@ export interface RealBytesStats {
358
358
  bytesAvoided: number;
359
359
  bytesReturned: number;
360
360
  snapshotBytes: number;
361
+ /**
362
+ * v1.0.133 Slice 3: bytes attributed to this session in the FTS5 content
363
+ * DB — `SUM(LENGTH(title) + LENGTH(content)) FROM chunks WHERE session_id = ?`.
364
+ *
365
+ * Read-only, render-time computation. Populated only when
366
+ * `getRealBytesStats` is called with both `sessionId` AND `contentDbPath`
367
+ * (i.e. the conversation tier from ctx_stats). Lifetime / project tiers
368
+ * leave this at 0 — aggregating across every adapter's content DB is a
369
+ * separate concern.
370
+ *
371
+ * Legacy chunks with empty `session_id` (pre-Slice-1) are NOT backfilled:
372
+ * the architect rejected the time-window join as unsafe. Old conversations
373
+ * stay low; new conversations populate honestly.
374
+ */
375
+ contentBytes: number;
361
376
  totalSavedTokens: number;
362
377
  }
378
+ /**
379
+ * v1.0.133 Slice 3: Sum the bytes attributed to one session in the FTS5
380
+ * content DB.
381
+ *
382
+ * Returns `LENGTH(title) + LENGTH(content)` summed across every chunk
383
+ * whose `session_id` column matches `sessionId`. Best-effort — returns 0
384
+ * when the DB file is missing, the schema lacks the `session_id` column
385
+ * (pre-Slice-1 content DBs), or the query fails. Never throws.
386
+ *
387
+ * Render-time only. Does NOT mutate the content DB. Architect-approved
388
+ * because the read-only join carries no risk of cross-session attribution
389
+ * (the FK was set at chunk insert time by Slice 1).
390
+ */
391
+ export declare function getContentBytesForSession(sessionId: string, contentDbPath: string, opts?: {
392
+ loadDatabase?: () => unknown;
393
+ }): number;
363
394
  /**
364
395
  * Compute real-bytes stats across one session, one project (worktree
365
396
  * filter), or every session on disk (lifetime).
@@ -378,6 +409,13 @@ export declare function getRealBytesStats(opts: {
378
409
  sessionId?: string;
379
410
  sessionsDir?: string;
380
411
  worktreeHash?: string;
412
+ /**
413
+ * v1.0.133 Slice 3: when set alongside `sessionId`, the function joins
414
+ * the FTS5 content DB at this path and folds chunk bytes into
415
+ * `bytesAvoided` + `totalSavedTokens` + `contentBytes`. Render-time
416
+ * only — no DB writes.
417
+ */
418
+ contentDbPath?: string;
381
419
  loadDatabase?: () => unknown;
382
420
  }): RealBytesStats;
383
421
  /**
@@ -706,6 +706,50 @@ export function getConversationStats(opts) {
706
706
  byDay,
707
707
  };
708
708
  }
709
+ /**
710
+ * v1.0.133 Slice 3: Sum the bytes attributed to one session in the FTS5
711
+ * content DB.
712
+ *
713
+ * Returns `LENGTH(title) + LENGTH(content)` summed across every chunk
714
+ * whose `session_id` column matches `sessionId`. Best-effort — returns 0
715
+ * when the DB file is missing, the schema lacks the `session_id` column
716
+ * (pre-Slice-1 content DBs), or the query fails. Never throws.
717
+ *
718
+ * Render-time only. Does NOT mutate the content DB. Architect-approved
719
+ * because the read-only join carries no risk of cross-session attribution
720
+ * (the FK was set at chunk insert time by Slice 1).
721
+ */
722
+ export function getContentBytesForSession(sessionId, contentDbPath, opts) {
723
+ if (!sessionId || !contentDbPath)
724
+ return 0;
725
+ if (!existsSync(contentDbPath))
726
+ return 0;
727
+ let DatabaseCtor = null;
728
+ try {
729
+ DatabaseCtor = opts?.loadDatabase
730
+ ? opts.loadDatabase()
731
+ : loadDatabaseImpl();
732
+ }
733
+ catch {
734
+ return 0;
735
+ }
736
+ if (!DatabaseCtor)
737
+ return 0;
738
+ try {
739
+ const db = new DatabaseCtor(contentDbPath, { readonly: true });
740
+ try {
741
+ const row = db.prepare(`SELECT COALESCE(SUM(LENGTH(content) + LENGTH(title)), 0) AS bytes
742
+ FROM chunks WHERE session_id = ?`).get(sessionId);
743
+ return Number(row?.bytes ?? 0);
744
+ }
745
+ finally {
746
+ db.close();
747
+ }
748
+ }
749
+ catch {
750
+ return 0;
751
+ }
752
+ }
709
753
  /**
710
754
  * Compute real-bytes stats across one session, one project (worktree
711
755
  * filter), or every session on disk (lifetime).
@@ -726,6 +770,7 @@ export function getRealBytesStats(opts) {
726
770
  bytesAvoided: 0,
727
771
  bytesReturned: 0,
728
772
  snapshotBytes: 0,
773
+ contentBytes: 0,
729
774
  totalSavedTokens: 0,
730
775
  };
731
776
  const sessionsDir = opts.sessionsDir
@@ -812,8 +857,19 @@ export function getRealBytesStats(opts) {
812
857
  }
813
858
  catch { /* missing tables / corrupt — skip */ }
814
859
  }
860
+ // v1.0.133 Slice 3: fold content DB chunk bytes for this session into
861
+ // bytesAvoided. Skipped silently when caller didn't pass contentDbPath
862
+ // (lifetime / project tiers, or pre-Slice-3 callers). Treated as
863
+ // "avoided" because indexed chunks are bytes that would have been
864
+ // re-inflated into context on every search if the model had to
865
+ // re-read raw files.
866
+ let contentBytes = 0;
867
+ if (opts.sessionId && opts.contentDbPath) {
868
+ contentBytes = getContentBytesForSession(opts.sessionId, opts.contentDbPath, { loadDatabase: opts.loadDatabase });
869
+ bytesAvoided += contentBytes;
870
+ }
815
871
  const totalSavedTokens = Math.floor((eventDataBytes + bytesAvoided + snapshotBytes) / 4);
816
- return { eventDataBytes, bytesAvoided, bytesReturned, snapshotBytes, totalSavedTokens };
872
+ return { eventDataBytes, bytesAvoided, bytesReturned, snapshotBytes, contentBytes, totalSavedTokens };
817
873
  }
818
874
  const DEFAULT_REAL_USAGE_FILTER = {
819
875
  minEvents: 100,
@@ -975,6 +1031,7 @@ export function getMultiAdapterRealBytesStats(opts) {
975
1031
  bytesAvoided: 0,
976
1032
  bytesReturned: 0,
977
1033
  snapshotBytes: 0,
1034
+ contentBytes: 0,
978
1035
  totalSavedTokens: 0,
979
1036
  };
980
1037
  const perAdapter = [];
@@ -15,6 +15,13 @@ export interface SessionEvent {
15
15
  data: string;
16
16
  /** 1=critical (rules, files, tasks) … 5=low */
17
17
  priority: number;
18
+ /**
19
+ * Optional — bytes context-mode prevented from entering the model context
20
+ * window for this event. Currently populated by external_ref when a
21
+ * ctx_fetch_and_index tool_response carries the
22
+ * `Fetched and indexed N sections (XKB)` preamble.
23
+ */
24
+ bytes_avoided?: number;
18
25
  }
19
26
  export interface ToolCall {
20
27
  toolName: string;
@@ -678,12 +678,28 @@ function extractExternalRef(input) {
678
678
  }
679
679
  if (refs.size === 0)
680
680
  return [];
681
- return [{
682
- type: "external_ref",
683
- category: "external-ref",
684
- data: safeString(Array.from(refs).join(", ")),
685
- priority: 3,
686
- }];
681
+ // ctx_fetch_and_index returns a preamble like
682
+ // "Fetched and indexed **5 sections** (47.50KB) from: <label>"
683
+ // Parse the size to credit bytes_avoided on the event so per-session
684
+ // honest-savings stats reflect what was kept out of the context window.
685
+ // KB literal in the preamble is decimal (KB = 1024 bytes per the formatter).
686
+ let bytesAvoided;
687
+ const preambleMatch = safeString(input.tool_response).match(/Fetched and indexed[^\(]*\(([\d.]+)\s*KB\)/i);
688
+ if (preambleMatch) {
689
+ const kb = Number(preambleMatch[1]);
690
+ if (Number.isFinite(kb) && kb > 0) {
691
+ bytesAvoided = Math.round(kb * 1024);
692
+ }
693
+ }
694
+ const event = {
695
+ type: "external_ref",
696
+ category: "external-ref",
697
+ data: safeString(Array.from(refs).join(", ")),
698
+ priority: 3,
699
+ };
700
+ if (bytesAvoided !== undefined)
701
+ event.bytes_avoided = bytesAvoided;
702
+ return [event];
687
703
  }
688
704
  /**
689
705
  * Category 8: env (worktree)
package/build/store.d.ts CHANGED
@@ -41,13 +41,25 @@ export declare class ContentStore {
41
41
  content?: string;
42
42
  path?: string;
43
43
  source?: string;
44
+ /**
45
+ * Optional FK metadata recorded on each indexed chunk so per-session
46
+ * honest-savings stats can join chunks → session_events. When omitted,
47
+ * chunks fall back to empty-string columns (legacy behaviour).
48
+ */
49
+ attribution?: {
50
+ sessionId?: string;
51
+ eventId?: string;
52
+ };
44
53
  }): IndexResult;
45
54
  /**
46
55
  * Index plain-text output (logs, build output, test results) by splitting
47
56
  * into fixed-size line groups. Unlike markdown indexing, this does not
48
57
  * look for headings — it chunks by line count with overlap.
49
58
  */
50
- indexPlainText(content: string, source: string, linesPerChunk?: number): IndexResult;
59
+ indexPlainText(content: string, source: string, linesPerChunk?: number, attribution?: {
60
+ sessionId?: string;
61
+ eventId?: string;
62
+ }): IndexResult;
51
63
  /**
52
64
  * Index JSON content by walking the object tree and using key paths
53
65
  * as chunk titles (analogous to heading hierarchy in markdown). Objects
@@ -55,7 +67,10 @@ export declare class ContentStore {
55
67
  *
56
68
  * Falls back to `indexPlainText` if the content is not valid JSON.
57
69
  */
58
- indexJSON(content: string, source: string, maxChunkBytes?: number): IndexResult;
70
+ indexJSON(content: string, source: string, maxChunkBytes?: number, attribution?: {
71
+ sessionId?: string;
72
+ eventId?: string;
73
+ }): IndexResult;
59
74
  search(query: string, limit?: number, source?: string, mode?: "AND" | "OR", contentType?: "code" | "prose", sourceMatchMode?: SourceMatchMode): SearchResult[];
60
75
  searchTrigram(query: string, limit?: number, source?: string, mode?: "AND" | "OR", contentType?: "code" | "prose", sourceMatchMode?: SourceMatchMode): SearchResult[];
61
76
  fuzzyCorrect(query: string): string | null;
package/build/store.js CHANGED
@@ -714,7 +714,7 @@ export class ContentStore {
714
714
  }
715
715
  // ── Index ──
716
716
  index(options) {
717
- const { content, path, source } = options;
717
+ const { content, path, source, attribution } = options;
718
718
  // Treat empty string as "no content" so an empty `content` paired with a
719
719
  // valid `path` falls back to reading the file. Some MCP clients
720
720
  // materialize optional string fields as `""` and the previous
@@ -754,7 +754,7 @@ export class ContentStore {
754
754
  // Stale detection: store file_path + SHA-256 for file-backed sources
755
755
  const filePath = path ?? undefined;
756
756
  const contentHash = filePath ? createHash("sha256").update(text).digest("hex") : undefined;
757
- return withRetry(() => this.#insertChunks(chunks, label, text, filePath, contentHash));
757
+ return withRetry(() => this.#insertChunks(chunks, label, text, filePath, contentHash, attribution));
758
758
  }
759
759
  // ── Index Plain Text ──
760
760
  /**
@@ -762,12 +762,12 @@ export class ContentStore {
762
762
  * into fixed-size line groups. Unlike markdown indexing, this does not
763
763
  * look for headings — it chunks by line count with overlap.
764
764
  */
765
- indexPlainText(content, source, linesPerChunk = 20) {
765
+ indexPlainText(content, source, linesPerChunk = 20, attribution) {
766
766
  if (!content || content.trim().length === 0) {
767
- return this.#insertChunks([], source, "");
767
+ return this.#insertChunks([], source, "", undefined, undefined, attribution);
768
768
  }
769
769
  const chunks = this.#chunkPlainText(content, linesPerChunk);
770
- return withRetry(() => this.#insertChunks(chunks.map((c) => ({ ...c, hasCode: false })), source, content));
770
+ return withRetry(() => this.#insertChunks(chunks.map((c) => ({ ...c, hasCode: false })), source, content, undefined, undefined, attribution));
771
771
  }
772
772
  // ── Index JSON ──
773
773
  /**
@@ -777,23 +777,23 @@ export class ContentStore {
777
777
  *
778
778
  * Falls back to `indexPlainText` if the content is not valid JSON.
779
779
  */
780
- indexJSON(content, source, maxChunkBytes = MAX_CHUNK_BYTES) {
780
+ indexJSON(content, source, maxChunkBytes = MAX_CHUNK_BYTES, attribution) {
781
781
  if (!content || content.trim().length === 0) {
782
- return this.indexPlainText("", source);
782
+ return this.indexPlainText("", source, undefined, attribution);
783
783
  }
784
784
  let parsed;
785
785
  try {
786
786
  parsed = JSON.parse(content);
787
787
  }
788
788
  catch {
789
- return this.indexPlainText(content, source);
789
+ return this.indexPlainText(content, source, undefined, attribution);
790
790
  }
791
791
  const chunks = [];
792
792
  this.#walkJSON(parsed, [], chunks, maxChunkBytes);
793
793
  if (chunks.length === 0) {
794
- return this.indexPlainText(content, source);
794
+ return this.indexPlainText(content, source, undefined, attribution);
795
795
  }
796
- return withRetry(() => this.#insertChunks(chunks, source, content));
796
+ return withRetry(() => this.#insertChunks(chunks, source, content, undefined, undefined, attribution));
797
797
  }
798
798
  // ── Shared DB Insertion ──
799
799
  /**
@@ -801,8 +801,12 @@ export class ContentStore {
801
801
  * into both FTS5 tables within a transaction and extracts vocabulary.
802
802
  * Uses cached prepared statements from #prepareStatements().
803
803
  */
804
- #insertChunks(chunks, label, text, filePath, contentHash) {
804
+ #insertChunks(chunks, label, text, filePath, contentHash, attribution) {
805
805
  const codeChunks = chunks.filter((c) => c.hasCode).length;
806
+ // FK columns on chunks. Empty-string fallback preserves the FTS5-friendly
807
+ // "not-null but unattributed" sentinel used by legacy rows.
808
+ const sessionIdCol = attribution?.sessionId ?? "";
809
+ const eventIdCol = attribution?.eventId ?? "";
806
810
  // Atomic dedup + insert: delete previous source with same label,
807
811
  // then insert new content — all within a single transaction.
808
812
  // Prevents stale results in iterative workflows. (See: GitHub issue #67)
@@ -819,8 +823,8 @@ export class ContentStore {
819
823
  const now = new Date().toISOString();
820
824
  for (const chunk of chunks) {
821
825
  const ct = chunk.hasCode ? "code" : "prose";
822
- this.#stmtInsertChunk.run(chunk.title, chunk.content, sourceId, ct, null, null, null, now);
823
- this.#stmtInsertChunkTrigram.run(chunk.title, chunk.content, sourceId, ct, null, null, null, now);
826
+ this.#stmtInsertChunk.run(chunk.title, chunk.content, sourceId, ct, null, sessionIdCol, eventIdCol, now);
827
+ this.#stmtInsertChunkTrigram.run(chunk.title, chunk.content, sourceId, ct, null, sessionIdCol, eventIdCol, now);
824
828
  }
825
829
  return sourceId;
826
830
  });
@@ -38,6 +38,21 @@ export interface DiscoverOptions {
38
38
  platform?: NodeJS.Platform;
39
39
  /** Test injection point — defaults to `child_process.execFileSync`. */
40
40
  runCommand?: RunCommand;
41
+ /**
42
+ * When true, only return pids whose parent (ppid) is the SAME as the
43
+ * caller's own ppid (i.e. siblings under the same host process).
44
+ *
45
+ * Used by the startup sweep (#565) so an opencode-spawned MCP child
46
+ * only reaps OTHER opencode-spawned MCP children, never the children
47
+ * of a different opencode/Claude host running in parallel.
48
+ *
49
+ * Requires a way to read each pid's ppid. Defaults to a `ps -o ppid=`
50
+ * probe on POSIX and PowerShell `Get-CimInstance` on Windows. Set
51
+ * `readPpid` to inject in tests.
52
+ */
53
+ sameParentOnly?: boolean;
54
+ /** Test injection — read ppid for a given pid. Defaults to platform probe. */
55
+ readPpid?: (pid: number) => number;
41
56
  }
42
57
  export interface KillOptions {
43
58
  pids: readonly number[];
@@ -77,3 +92,28 @@ export declare function discoverSiblingMcpPids(opts: DiscoverOptions): number[];
77
92
  * counted — they were not ours to kill.
78
93
  */
79
94
  export declare function killSiblingMcpServers(opts: KillOptions): Promise<KillReport>;
95
+ /**
96
+ * Startup-time sibling sweep (#565).
97
+ *
98
+ * Discovers any context-mode MCP server pids that share OUR parent process
99
+ * (i.e. other MCP children of the same host like `opencode serve`) and
100
+ * terminates them. The intent is "exactly one MCP child per host" — when a
101
+ * new MCP client spawns inside an opencode host that already has 25 stale
102
+ * idle siblings, this sweep reclaims them at boot rather than waiting for
103
+ * the idle timeout to fire on each one independently.
104
+ *
105
+ * Gated by env (default-on but easy to disable):
106
+ *
107
+ * CONTEXT_MODE_STARTUP_SWEEP=0 → disabled
108
+ * CONTEXT_MODE_STARTUP_SWEEP=1 → enabled (default)
109
+ *
110
+ * Safety:
111
+ * - `sameParentOnly: true` — never touches MCP children of a different host.
112
+ * - Best-effort throughout: failures never block server startup.
113
+ * - Composes with the idle-timeout path: if a sibling is actively in use
114
+ * by another session, the parent process will simply spawn a new MCP
115
+ * child on its next request. The cost is one cold-start (~1–3 s) for
116
+ * that session, which is identical to opencode's existing behaviour
117
+ * of spawning a fresh MCP child per session anyway.
118
+ */
119
+ export declare function startupSiblingSweep(env?: NodeJS.ProcessEnv): Promise<KillReport>;