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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.openclaw-plugin/openclaw.plugin.json +1 -1
- package/.openclaw-plugin/package.json +1 -1
- package/README.md +26 -15
- package/build/cli.js +32 -0
- package/build/lifecycle.d.ts +51 -2
- package/build/lifecycle.js +67 -3
- package/build/server.js +118 -16
- package/build/session/analytics.d.ts +38 -0
- package/build/session/analytics.js +58 -1
- package/build/session/extract.d.ts +7 -0
- package/build/session/extract.js +22 -6
- package/build/store.d.ts +17 -2
- package/build/store.js +17 -13
- package/build/util/sibling-mcp.d.ts +40 -0
- package/build/util/sibling-mcp.js +116 -11
- package/cli.bundle.mjs +174 -165
- package/configs/jetbrains-copilot/mcp.json +1 -2
- package/configs/vscode-copilot/mcp.json +1 -2
- package/hooks/session-extract.bundle.mjs +2 -2
- package/hooks/session-loaders.mjs +15 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +5 -2
- package/scripts/heal-better-sqlite3.mjs +99 -2
- package/scripts/postinstall.mjs +58 -0
- package/server.bundle.mjs +106 -104
- package/skills/context-mode/SKILL.md +1 -0
- package/skills/context-mode/references/anti-patterns.md +26 -0
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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;
|
package/build/session/extract.js
CHANGED
|
@@ -678,12 +678,28 @@ function extractExternalRef(input) {
|
|
|
678
678
|
}
|
|
679
679
|
if (refs.size === 0)
|
|
680
680
|
return [];
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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
|
|
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
|
|
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,
|
|
823
|
-
this.#stmtInsertChunkTrigram.run(chunk.title, chunk.content, sourceId, ct, null,
|
|
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>;
|