context-mode 1.0.146 → 1.0.148
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 +2 -2
- package/.codex-plugin/plugin.json +1 -1
- package/.openclaw-plugin/openclaw.plugin.json +1 -1
- package/.openclaw-plugin/package.json +1 -1
- package/README.md +26 -23
- package/bin/statusline.mjs +22 -9
- package/build/adapters/base.d.ts +9 -4
- package/build/adapters/base.js +16 -7
- package/build/adapters/codex/index.d.ts +8 -1
- package/build/adapters/codex/index.js +43 -6
- package/build/adapters/openclaw/index.d.ts +11 -2
- package/build/adapters/openclaw/index.js +12 -3
- package/build/adapters/pi/mcp-bridge.d.ts +8 -0
- package/build/adapters/pi/mcp-bridge.js +118 -15
- package/build/adapters/types.d.ts +11 -2
- package/build/cli.d.ts +2 -0
- package/build/cli.js +87 -20
- package/build/search/auto-memory.d.ts +6 -1
- package/build/search/auto-memory.js +11 -2
- package/build/server.js +346 -106
- package/build/session/analytics.d.ts +19 -0
- package/build/session/analytics.js +71 -21
- package/build/session/db.d.ts +81 -0
- package/build/session/db.js +282 -20
- package/build/session/extract.js +16 -0
- package/build/truncate.d.ts +15 -0
- package/build/truncate.js +28 -0
- package/cli.bundle.mjs +435 -350
- package/hooks/core/routing.mjs +4 -4
- package/hooks/routing-block.mjs +18 -23
- package/hooks/session-db.bundle.mjs +21 -19
- package/hooks/session-extract.bundle.mjs +2 -2
- package/hooks/session-helpers.mjs +13 -2
- package/hooks/session-snapshot.bundle.mjs +7 -7
- package/openclaw.plugin.json +1 -1
- package/package.json +4 -2
- package/server.bundle.mjs +383 -300
package/build/server.js
CHANGED
|
@@ -18,14 +18,14 @@ import { readBashPolicies, evaluateCommandDenyOnly, extractShellCommands, readTo
|
|
|
18
18
|
import { detectRuntimes, getRuntimeSummary, getAvailableLanguages, hasBunRuntime, } from "./runtime.js";
|
|
19
19
|
import { classifyNonZeroExit } from "./exit-classify.js";
|
|
20
20
|
import { startLifecycleGuard } from "./lifecycle.js";
|
|
21
|
-
import {
|
|
21
|
+
import { charSafePrefix } from "./truncate.js";
|
|
22
|
+
import { describeStorageDirectorySource, ensureWritableStorageDir, formatStorageDirectoryError, hashProjectDirCanonical, hashProjectDirLegacy, resolveContentStorePath, resolveContentStorageDir, resolveDefaultSessionDir, resolveSessionDbPath, resolveSessionStorageDir, resolveStatsStorageDir, SessionDB, StorageDirectoryError, } from "./session/db.js";
|
|
22
23
|
import { purgeSession } from "./session/purge.js";
|
|
23
24
|
import { emitCacheHitEvent, emitIndexWriteEvent, emitSandboxExecuteEvent, } from "./session/event-emit.js";
|
|
24
25
|
import { persistToolCallCounter, restoreSessionStats } from "./session/persist-tool-calls.js";
|
|
25
26
|
import { searchAllSources } from "./search/unified.js";
|
|
26
27
|
import { buildNodeCommand } from "./adapters/types.js";
|
|
27
28
|
import { detectPlatform, getSessionDirSegments } from "./adapters/detect.js";
|
|
28
|
-
import { resolveCodexConfigDir } from "./adapters/codex/paths.js";
|
|
29
29
|
import { getHookScriptPaths } from "./util/hook-config.js";
|
|
30
30
|
import { resolveClaudeConfigDir } from "./util/claude-config.js";
|
|
31
31
|
import { resolveProjectDir } from "./util/project-dir.js";
|
|
@@ -231,9 +231,32 @@ server.registerTool = (...args) => {
|
|
|
231
231
|
emitSuppressionDiagnostic();
|
|
232
232
|
return undefined;
|
|
233
233
|
}
|
|
234
|
-
|
|
234
|
+
const wrappedHandler = wrapToolHandler(name, handler);
|
|
235
|
+
REGISTERED_CTX_TOOLS.push({ name, config, handler: wrappedHandler });
|
|
236
|
+
args[2] = wrappedHandler;
|
|
235
237
|
return originalRegisterTool(...args);
|
|
236
238
|
};
|
|
239
|
+
function wrapToolHandler(name, handler) {
|
|
240
|
+
return async (toolArgs) => {
|
|
241
|
+
try {
|
|
242
|
+
return await handler(toolArgs);
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
const result = storageErrorResult(err);
|
|
246
|
+
if (result) {
|
|
247
|
+
try {
|
|
248
|
+
return trackResponse(name, result);
|
|
249
|
+
}
|
|
250
|
+
catch (trackErr) {
|
|
251
|
+
if (trackErr instanceof StorageDirectoryError)
|
|
252
|
+
return result;
|
|
253
|
+
throw trackErr;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
throw err;
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
}
|
|
237
260
|
// Issue #637 — when suppression is active, install the empty tools/list handler
|
|
238
261
|
// once at module-init time so the suppressed MCP child responds with
|
|
239
262
|
// `{tools: []}` instead of JSON-RPC `-32601 Method not found`. Pair with the
|
|
@@ -383,9 +406,6 @@ let _insightChild = null;
|
|
|
383
406
|
* Issue #460 round-3: delegates to the canonical util so empty/whitespace
|
|
384
407
|
* env values fall back instead of poisoning downstream `join()` calls.
|
|
385
408
|
*/
|
|
386
|
-
function resolveClaudeConfigRoot() {
|
|
387
|
-
return resolveClaudeConfigDir();
|
|
388
|
-
}
|
|
389
409
|
async function getDiagnosticAdapter() {
|
|
390
410
|
if (_detectedAdapter)
|
|
391
411
|
return _detectedAdapter;
|
|
@@ -402,7 +422,7 @@ async function getDiagnosticAdapter() {
|
|
|
402
422
|
* Get the platform-specific sessions directory from the detected adapter.
|
|
403
423
|
* Falls back to the detected platform config root before adapter detection.
|
|
404
424
|
*/
|
|
405
|
-
function
|
|
425
|
+
function getDefaultSessionDir() {
|
|
406
426
|
if (_detectedAdapter)
|
|
407
427
|
return _detectedAdapter.getSessionDir();
|
|
408
428
|
// Pre-detection path (race window before MCP `initialize` completes):
|
|
@@ -415,22 +435,24 @@ function getSessionDir() {
|
|
|
415
435
|
const signal = detectPlatform();
|
|
416
436
|
const segments = getSessionDirSegments(signal.platform);
|
|
417
437
|
if (segments) {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
}
|
|
422
|
-
else if (segments.length === 1 && segments[0] === ".codex") {
|
|
423
|
-
root = resolveCodexConfigDir();
|
|
424
|
-
}
|
|
425
|
-
const dir = join(root, "context-mode", "sessions");
|
|
426
|
-
mkdirSync(dir, { recursive: true });
|
|
427
|
-
return dir;
|
|
438
|
+
return resolveDefaultSessionDir({
|
|
439
|
+
configDir: join(...segments),
|
|
440
|
+
configDirEnv: configDirEnvForSessionSegments(segments),
|
|
441
|
+
});
|
|
428
442
|
}
|
|
429
443
|
}
|
|
430
444
|
catch { /* fall through to claude fallback */ }
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
445
|
+
return resolveDefaultSessionDir({ configDir: ".claude", configDirEnv: "CLAUDE_CONFIG_DIR" });
|
|
446
|
+
}
|
|
447
|
+
function configDirEnvForSessionSegments(segments) {
|
|
448
|
+
if (segments.length === 1 && segments[0] === ".claude")
|
|
449
|
+
return "CLAUDE_CONFIG_DIR";
|
|
450
|
+
if (segments.length === 1 && segments[0] === ".codex")
|
|
451
|
+
return "CODEX_HOME";
|
|
452
|
+
return undefined;
|
|
453
|
+
}
|
|
454
|
+
function getSessionDir() {
|
|
455
|
+
return ensureWritableStorageDir(resolveSessionStorageDir(getDefaultSessionDir));
|
|
434
456
|
}
|
|
435
457
|
/**
|
|
436
458
|
* Project directory detection across supported platforms.
|
|
@@ -526,9 +548,7 @@ function getSessionDbPath() {
|
|
|
526
548
|
* ~/.cursor/context-mode/content/87c28c41ddb64d38.db
|
|
527
549
|
*/
|
|
528
550
|
function getStorePath() {
|
|
529
|
-
|
|
530
|
-
const dir = join(dirname(getSessionDir()), "content");
|
|
531
|
-
mkdirSync(dir, { recursive: true });
|
|
551
|
+
const dir = ensureWritableStorageDir(resolveContentStorageDir(getDefaultSessionDir));
|
|
532
552
|
// Delegate to resolveContentStorePath: same case-fold + one-shot legacy
|
|
533
553
|
// rename behavior as resolveSessionDbPath. On macOS / Windows, an
|
|
534
554
|
// existing legacy raw-casing FTS5 db (with -wal/-shm sidecars) is
|
|
@@ -585,6 +605,14 @@ const sessionStats = {
|
|
|
585
605
|
cacheBytesSaved: 0, // bytes avoided by TTL cache hits
|
|
586
606
|
sessionStart: Date.now(),
|
|
587
607
|
};
|
|
608
|
+
function storageErrorResult(err) {
|
|
609
|
+
if (!(err instanceof StorageDirectoryError))
|
|
610
|
+
return null;
|
|
611
|
+
return {
|
|
612
|
+
content: [{ type: "text", text: formatStorageDirectoryError(err) }],
|
|
613
|
+
isError: true,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
588
616
|
// ── Version outdated warning ──────────────────────────────────────────────
|
|
589
617
|
// Non-blocking npm check at startup. trackResponse prepends warning
|
|
590
618
|
// using a burst cadence: 3 warnings → 1h silent → 3 warnings → repeat.
|
|
@@ -785,7 +813,8 @@ let _lifetimeCache;
|
|
|
785
813
|
*/
|
|
786
814
|
function getStatsFilePath() {
|
|
787
815
|
const sessionId = process.env.CLAUDE_SESSION_ID || `pid-${process.ppid}`;
|
|
788
|
-
|
|
816
|
+
const statsDir = ensureWritableStorageDir(resolveStatsStorageDir(getDefaultSessionDir));
|
|
817
|
+
return join(statsDir, `stats-${sessionId}.json`);
|
|
789
818
|
}
|
|
790
819
|
function persistStats() {
|
|
791
820
|
const now = Date.now();
|
|
@@ -1099,6 +1128,15 @@ function formatCommandOutput(label, raw, onFsBytes) {
|
|
|
1099
1128
|
}
|
|
1100
1129
|
return `# ${label}\n\n${output}\n`;
|
|
1101
1130
|
}
|
|
1131
|
+
function combineExecOutput(result) {
|
|
1132
|
+
const stdout = result.stdout || "";
|
|
1133
|
+
const stderr = result.stderr || "";
|
|
1134
|
+
if (!stderr)
|
|
1135
|
+
return stdout;
|
|
1136
|
+
if (!stdout)
|
|
1137
|
+
return stderr;
|
|
1138
|
+
return `${stdout}${stdout.endsWith("\n") ? "" : "\n"}${stderr}`;
|
|
1139
|
+
}
|
|
1102
1140
|
/**
|
|
1103
1141
|
* Execute batch commands. concurrency=1 preserves the legacy serial path
|
|
1104
1142
|
* (shared timeout budget + cascading skip-on-timeout). concurrency>1 runs
|
|
@@ -1130,10 +1168,10 @@ export async function runBatchCommands(commands, opts, executor) {
|
|
|
1130
1168
|
}
|
|
1131
1169
|
const result = await executor.execute({
|
|
1132
1170
|
language: "shell",
|
|
1133
|
-
code: `${nodeOptsPrefix}${cmd.command}
|
|
1171
|
+
code: `${nodeOptsPrefix}${cmd.command}`,
|
|
1134
1172
|
timeout: perCmdTimeout,
|
|
1135
1173
|
});
|
|
1136
|
-
outputs.push(formatCommandOutput(cmd.label, result
|
|
1174
|
+
outputs.push(formatCommandOutput(cmd.label, combineExecOutput(result), onFsBytes));
|
|
1137
1175
|
if (result.timedOut) {
|
|
1138
1176
|
timedOut = true;
|
|
1139
1177
|
for (let j = i + 1; j < commands.length; j++) {
|
|
@@ -1151,12 +1189,12 @@ export async function runBatchCommands(commands, opts, executor) {
|
|
|
1151
1189
|
run: async () => {
|
|
1152
1190
|
const result = await executor.execute({
|
|
1153
1191
|
language: "shell",
|
|
1154
|
-
code: `${nodeOptsPrefix}${cmd.command}
|
|
1192
|
+
code: `${nodeOptsPrefix}${cmd.command}`,
|
|
1155
1193
|
timeout,
|
|
1156
1194
|
});
|
|
1157
|
-
// Always route partial
|
|
1195
|
+
// Always route partial output through formatCommandOutput so __CM_FS__
|
|
1158
1196
|
// markers are stripped + counted, even when the command timed out.
|
|
1159
|
-
const formatted = formatCommandOutput(cmd.label, result
|
|
1197
|
+
const formatted = formatCommandOutput(cmd.label, combineExecOutput(result), onFsBytes);
|
|
1160
1198
|
const output = result.timedOut
|
|
1161
1199
|
? formatted.replace(/\n$/, "") + `\n(timed out after ${timeout ?? "?"}ms)\n`
|
|
1162
1200
|
: formatted;
|
|
@@ -1186,7 +1224,38 @@ export async function runBatchCommands(commands, opts, executor) {
|
|
|
1186
1224
|
// ─────────────────────────────────────────────────────────
|
|
1187
1225
|
server.registerTool("ctx_execute", {
|
|
1188
1226
|
title: "Execute Code",
|
|
1189
|
-
description: `
|
|
1227
|
+
description: `Run code in a sandboxed subprocess.${bunNote} Languages: ${langList}.
|
|
1228
|
+
|
|
1229
|
+
Think-in-Code — the core philosophy: the bytes your code processes never enter your conversation memory; only what you console.log() does. Reading a 700 KB log directly means 700 KB of your remaining reasoning capacity gets spent on raw bytes. Running code over that same log in this sandbox and printing a 3 KB summary leaves you with 697 KB of capacity for the actual work.
|
|
1230
|
+
|
|
1231
|
+
Concrete shape — analyze 47 source files without reading any of them:
|
|
1232
|
+
ctx_execute(language: "javascript", code: \`
|
|
1233
|
+
const fs = require('fs');
|
|
1234
|
+
const files = fs.readdirSync('src').filter(f => f.endsWith('.ts'));
|
|
1235
|
+
files.forEach(f => {
|
|
1236
|
+
const lines = fs.readFileSync('src/'+f,'utf8').split('\\\\n').length;
|
|
1237
|
+
console.log(f + ': ' + lines + ' lines');
|
|
1238
|
+
});
|
|
1239
|
+
\`)
|
|
1240
|
+
// 47 files analyzed, 15,314 LoC summarized — output ~3.6 KB instead of 47 Read() calls = ~700 KB.
|
|
1241
|
+
|
|
1242
|
+
WHEN:
|
|
1243
|
+
- You intend to derive an answer FROM data (filter, count, aggregate, parse, compare, transform) — do the derivation in code and print only the answer
|
|
1244
|
+
- Output shape or size cannot be predicted before execution (recursive finds, repo-wide greps, list endpoints, query results, log scans)
|
|
1245
|
+
- You would otherwise read raw output and then mentally compute — that compute belongs here, in code, where its inputs stay out of your conversation
|
|
1246
|
+
- You need to keep a long-running process alive (dev server, watcher, daemon) — pass \`background: true\` to detach on timeout instead of killing the process
|
|
1247
|
+
- The output may legitimately be large but you only want recall-by-topic later — pass an \`intent\` string; outputs over ~5KB are auto-indexed into the knowledge base and only the section titles + previews come back, retrievable via ctx_search
|
|
1248
|
+
|
|
1249
|
+
WHEN NOT:
|
|
1250
|
+
- Single observational command whose entire short output you intend to consume verbatim (whoami, pwd, git status on a clean tree) — Bash is simpler
|
|
1251
|
+
- File mutations (Edit/Write) or navigation (cd/ls) — Bash is the right surface
|
|
1252
|
+
- You already know the output is one short fixed line and you want to read it as-is
|
|
1253
|
+
|
|
1254
|
+
RETURNS:
|
|
1255
|
+
Only what your code prints. Wrap risky calls in try/catch — uncaught errors go to stderr and may leak more than intended. When \`intent\` is set and output exceeds the auto-index threshold, the response carries searchable section titles + previews instead of the raw stdout; use ctx_search(queries: [...]) to drill into specific sections.
|
|
1256
|
+
|
|
1257
|
+
EXAMPLE: ctx_execute(language: "shell", code: "npm test 2>&1 | grep -E '(FAIL|✗|×|Error:|Tests +.*(failed|passed))' | head -60")
|
|
1258
|
+
EXAMPLE: ctx_execute(language: "javascript", code: "const out = require('child_process').execSync('gh issue list --json number,title --limit 100', {encoding:'utf8'}); const hooks = JSON.parse(out).filter(i => /hook|routing/i.test(i.title)); console.log(\`\${hooks.length} hook-related issues\`)")`,
|
|
1190
1259
|
inputSchema: z.object({
|
|
1191
1260
|
language: z
|
|
1192
1261
|
.enum([
|
|
@@ -1484,7 +1553,26 @@ function intentSearch(stdout, intent, source, maxResults = 5) {
|
|
|
1484
1553
|
// ─────────────────────────────────────────────────────────
|
|
1485
1554
|
server.registerTool("ctx_execute_file", {
|
|
1486
1555
|
title: "Execute File Processing",
|
|
1487
|
-
description:
|
|
1556
|
+
description: `Read a file into a sandboxed FILE_CONTENT variable and run code over it. Only what you console.log() enters your conversation — the file bytes stay in the sandbox.
|
|
1557
|
+
|
|
1558
|
+
Think-in-Code applied to file-level analysis: Reading the whole file means every byte enters your conversation memory and costs reasoning capacity for the rest of the session. Running code over it here lets you keep the raw bytes out and only the derived answer in. Same principle as ctx_execute, scoped to one named file via the FILE_CONTENT variable.
|
|
1559
|
+
|
|
1560
|
+
WHEN:
|
|
1561
|
+
- You want to KNOW SOMETHING ABOUT a file (line count, matches of a pattern, parsed structure, statistical aggregate) without needing to SEE all of it
|
|
1562
|
+
- The file is structured (CSV, JSON, log, code) and a code-level derivation is cheaper than reading verbatim
|
|
1563
|
+
- The file is large enough that reading the full content would burn meaningful conversation memory you need for the actual work
|
|
1564
|
+
- The derivation may itself produce a large output you want recall-by-topic on later — pass an \`intent\` string; outputs over ~5KB are auto-indexed and only matching sections come back, retrievable via ctx_search
|
|
1565
|
+
|
|
1566
|
+
WHEN NOT:
|
|
1567
|
+
- You intend to EDIT the file — use Read so the subsequent Edit can match the exact text
|
|
1568
|
+
- You only need one specific line and you know its offset — Read with offset/limit is the simplest path
|
|
1569
|
+
- The file is small AND you will consume all of it for understanding/editing — Read directly
|
|
1570
|
+
|
|
1571
|
+
RETURNS:
|
|
1572
|
+
Only what your code prints. The FILE_CONTENT variable holds the raw bytes inside the sandbox; nothing else leaves. When \`intent\` is set and output exceeds the auto-index threshold, the response carries searchable section titles + previews instead of the raw stdout.
|
|
1573
|
+
|
|
1574
|
+
EXAMPLE: ctx_execute_file(path: "huge.log", language: "javascript", code: "const errs = FILE_CONTENT.split('\\\\n').filter(l => /ERROR|FATAL/.test(l)); console.log(\`\${errs.length} error lines\`); console.log(errs.slice(-5).join('\\\\n'))")
|
|
1575
|
+
EXAMPLE: ctx_execute_file(path: "data.csv", language: "javascript", code: "const rows = FILE_CONTENT.split('\\\\n'); console.log(\`rows: \${rows.length - 1}, header: \${rows[0]}\`)")`,
|
|
1488
1576
|
inputSchema: z.object({
|
|
1489
1577
|
path: z
|
|
1490
1578
|
.string()
|
|
@@ -1616,19 +1704,25 @@ server.registerTool("ctx_execute_file", {
|
|
|
1616
1704
|
// ─────────────────────────────────────────────────────────
|
|
1617
1705
|
server.registerTool("ctx_index", {
|
|
1618
1706
|
title: "Index Content",
|
|
1619
|
-
description:
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1707
|
+
description: `Store content in a searchable knowledge base (BM25 over FTS5). Splits markdown by headings, keeps code blocks intact, and persists the raw chunks. The full content stays in storage — retrieve any section on-demand via ctx_search; nothing is summarized or truncated.
|
|
1708
|
+
|
|
1709
|
+
WHEN:
|
|
1710
|
+
- Documentation from Context7, Skills, or MCP tools (API docs, framework guides, code examples)
|
|
1711
|
+
- API references (endpoint details, parameter specs, response schemas)
|
|
1712
|
+
- MCP tools/list output (exact tool signatures and descriptions)
|
|
1713
|
+
- Skill prompts and instructions that are too large to keep verbatim in conversation
|
|
1714
|
+
- README files, migration guides, changelog entries
|
|
1715
|
+
- Any content with code examples you may need to reference precisely later
|
|
1716
|
+
|
|
1717
|
+
WHEN NOT:
|
|
1718
|
+
- Log files, test output, CSV, or build output — use ctx_execute_file, which processes in-sandbox without persisting bytes
|
|
1719
|
+
- Single-use ephemeral content you will not query later — keep it inline if it fits, or ctx_execute_file it
|
|
1720
|
+
|
|
1721
|
+
RETURNS:
|
|
1722
|
+
Indexing metadata: chunk counts (total, code-bearing), source label, and the exact ctx_search call shape to query the indexed content. Raw content is NOT echoed back — it lives in storage, retrievable via ctx_search(source: "<label>"). When \`path\` is provided, a content hash is stored so ctx_search results auto-flag staleness on future calls.
|
|
1723
|
+
|
|
1724
|
+
EXAMPLE: ctx_index(content: "# React useEffect\\n\\nThe Effect Hook lets you ...", source: "react-useeffect-docs")
|
|
1725
|
+
EXAMPLE: ctx_index(path: "/path/to/large-spec.md", source: "openapi-v2-spec")`,
|
|
1632
1726
|
inputSchema: z.object({
|
|
1633
1727
|
content: z
|
|
1634
1728
|
.string()
|
|
@@ -1774,11 +1868,28 @@ function coerceCommandsArray(val) {
|
|
|
1774
1868
|
}
|
|
1775
1869
|
server.registerTool("ctx_search", {
|
|
1776
1870
|
title: "Search Indexed Content",
|
|
1777
|
-
description:
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1871
|
+
description: `Search a unified knowledge base with a multi-strategy ranking pipeline. Two parallel matchers run on every query: a Porter-stemming matcher ("caching" finds "cached", "caches", "cach") and a trigram-substring matcher ("useEff" finds "useEffect"). Their ranked lists are merged via Reciprocal Rank Fusion, so a document that ranks well in both surfaces above one that wins only on a single strategy. Multi-term queries get an additional proximity-rerank pass that boosts passages where the query terms appear close together. Typos are corrected via Levenshtein distance and re-searched. Result snippets are window-extracted around the matched terms, not blindly truncated.
|
|
1872
|
+
|
|
1873
|
+
The knowledge base is unified: queries reach indexed content you stored (ctx_index, ctx_fetch_and_index, ctx_batch_execute output) AND auto-captured session memory written by hooks (decisions, errors, blockers, plans, user prompts, rejected approaches, tool failures, compaction guides — 26 event categories). File-backed sources carry a content hash and auto-flag staleness when the source file changes.
|
|
1874
|
+
|
|
1875
|
+
WHEN:
|
|
1876
|
+
- You want to recall something that exists in storage (recently indexed content, prior session events, auto-memory) instead of re-reading raw sources
|
|
1877
|
+
- You have multiple related questions about the same body of knowledge — batch every question into one call (the ranking pipeline runs per-query but the round-trip cost is paid once)
|
|
1878
|
+
- You want to scope the query to one labelled source (pass \`source\` — partial match is fine)
|
|
1879
|
+
- You want a chronological view across current session + prior sessions + persistent auto-memory (pass \`sort: "timeline"\` — the default \`relevance\` mode only ranks within the current session)
|
|
1880
|
+
- You want to filter ranked results by content shape (pass \`contentType: "code"\` to surface implementation snippets or \`contentType: "prose"\` to surface explanations)
|
|
1881
|
+
|
|
1882
|
+
WHEN NOT:
|
|
1883
|
+
- The data you want to query has never been stored in the knowledge base AND no session memory has accumulated around it — capture first (run a gather-and-index call), then come back here to query
|
|
1884
|
+
- You have one ad-hoc question against data that is not in the knowledge base — answer it inline by running code in the sandbox tool; one round-trip instead of capture-then-query
|
|
1885
|
+
|
|
1886
|
+
RETURNS:
|
|
1887
|
+
Per-query ranked sections with window-extracted snippets. Use 2-4 specific technical terms per query. Common session-memory source labels: \`decision\` (user corrections / preferences), \`error\` and \`error-resolution\` (past failures + their fixes), \`blocker\`, \`plan\`, \`user-prompt\`, \`rejected-approach\`, \`compaction\` (post-compact session guide). See ctx_stats for live category counts.
|
|
1888
|
+
|
|
1889
|
+
EXAMPLE: ctx_search(queries: ["root cause", "proposed fix", "test coverage"], source: "issue-#683")
|
|
1890
|
+
EXAMPLE: ctx_search(queries: ["what did we decide about caching"], source: "decision", sort: "timeline")
|
|
1891
|
+
EXAMPLE: ctx_search(queries: ["useEffect cleanup pattern"], source: "react-docs", contentType: "code", limit: 5)
|
|
1892
|
+
EXAMPLE: ctx_search(queries: ["last user prompt", "active skills", "open blockers"], sort: "timeline")`,
|
|
1782
1893
|
inputSchema: z.object({
|
|
1783
1894
|
queries: z.preprocess(coerceJsonArray, z
|
|
1784
1895
|
.array(z.string())
|
|
@@ -1888,7 +1999,7 @@ server.registerTool("ctx_search", {
|
|
|
1888
1999
|
}
|
|
1889
2000
|
catch { /* SessionDB unavailable — search ContentStore + auto-memory only */ }
|
|
1890
2001
|
}
|
|
1891
|
-
const configDir = _detectedAdapter?.getConfigDir() ??
|
|
2002
|
+
const configDir = _detectedAdapter?.getConfigDir() ?? resolveClaudeConfigDir();
|
|
1892
2003
|
try {
|
|
1893
2004
|
for (const q of queryList) {
|
|
1894
2005
|
if (totalSize > MAX_TOTAL) {
|
|
@@ -2254,6 +2365,20 @@ main();
|
|
|
2254
2365
|
// ─────────────────────────────────────────────────────────
|
|
2255
2366
|
const FETCH_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
2256
2367
|
const FETCH_PREVIEW_LIMIT = 3072;
|
|
2368
|
+
function formatFetchTtl(ttlMs) {
|
|
2369
|
+
if (ttlMs === 0)
|
|
2370
|
+
return "0ms";
|
|
2371
|
+
const day = 24 * 60 * 60 * 1000;
|
|
2372
|
+
const hour = 60 * 60 * 1000;
|
|
2373
|
+
const minute = 60 * 1000;
|
|
2374
|
+
if (ttlMs % day === 0)
|
|
2375
|
+
return `${ttlMs / day}d`;
|
|
2376
|
+
if (ttlMs % hour === 0)
|
|
2377
|
+
return `${ttlMs / hour}h`;
|
|
2378
|
+
if (ttlMs % minute === 0)
|
|
2379
|
+
return `${ttlMs / minute}m`;
|
|
2380
|
+
return `${ttlMs}ms`;
|
|
2381
|
+
}
|
|
2257
2382
|
/**
|
|
2258
2383
|
* Pure fetch step — TTL cache check + subprocess fetch. SAFE TO RUN IN PARALLEL.
|
|
2259
2384
|
* Performs zero SQLite writes (only reads source meta). Caller must funnel
|
|
@@ -2328,10 +2453,23 @@ async function ssrfGuard(rawUrl) {
|
|
|
2328
2453
|
}
|
|
2329
2454
|
}
|
|
2330
2455
|
catch (err) {
|
|
2456
|
+
// libuv DNS error codes that typically indicate the resolver itself can't
|
|
2457
|
+
// reach a nameserver — common when the MCP host process is running under
|
|
2458
|
+
// a sandbox that blocks outbound network, OR a transient upstream DNS
|
|
2459
|
+
// hiccup. Append an imperative retry hint so the agent does not capitulate
|
|
2460
|
+
// to training data on the FIRST transient failure (PR #654 substitute —
|
|
2461
|
+
// sibling-tool consistency with hooks/core/routing.mjs WebFetch wording).
|
|
2462
|
+
const errCode = err?.code ?? "";
|
|
2463
|
+
const isTransientDns = errCode === "ETIMEOUT" || errCode === "ETIMEDOUT" ||
|
|
2464
|
+
errCode === "EAI_AGAIN" || errCode === "ENETUNREACH" || errCode === "EPERM";
|
|
2465
|
+
const baseMsg = err instanceof Error ? err.message : String(err);
|
|
2466
|
+
const hint = isTransientDns
|
|
2467
|
+
? " — transient DNS error; retry once before falling back. If it keeps failing, the MCP host may be running under a network sandbox; restart the host with network access enabled."
|
|
2468
|
+
: "";
|
|
2331
2469
|
return {
|
|
2332
2470
|
kind: "fetch_error",
|
|
2333
2471
|
url: rawUrl,
|
|
2334
|
-
error: `DNS lookup failed for "${parsed.hostname}": ${
|
|
2472
|
+
error: `DNS lookup failed for "${parsed.hostname}": ${baseMsg}${hint}`,
|
|
2335
2473
|
reason: "exit",
|
|
2336
2474
|
};
|
|
2337
2475
|
}
|
|
@@ -2401,14 +2539,14 @@ export function classifyIp(rawIp) {
|
|
|
2401
2539
|
return "private"; // 192.168.0.0/16
|
|
2402
2540
|
return "public";
|
|
2403
2541
|
}
|
|
2404
|
-
async function fetchOneUrl(url, source, force) {
|
|
2542
|
+
async function fetchOneUrl(url, source, force, ttl) {
|
|
2405
2543
|
// SSRF guard — reject file://, javascript:, loopback, RFC1918, IMDS, link-local
|
|
2406
2544
|
// BEFORE any cache lookup or subprocess spawn. Even cached entries shouldn't
|
|
2407
2545
|
// serve a previously-poisoned source label.
|
|
2408
2546
|
const ssrfBlock = await ssrfGuard(url);
|
|
2409
2547
|
if (ssrfBlock)
|
|
2410
2548
|
return ssrfBlock;
|
|
2411
|
-
if (!force) {
|
|
2549
|
+
if (!force && ttl !== 0) {
|
|
2412
2550
|
const store = getStore();
|
|
2413
2551
|
// Cache key composes (source, url) so two distinct URLs sharing the same
|
|
2414
2552
|
// `source` label do not collide — they each get their own cache slot
|
|
@@ -2418,12 +2556,13 @@ async function fetchOneUrl(url, source, force) {
|
|
|
2418
2556
|
if (meta) {
|
|
2419
2557
|
const indexedAt = new Date(meta.indexedAt + "Z"); // SQLite datetime is UTC without Z
|
|
2420
2558
|
const ageMs = Date.now() - indexedAt.getTime();
|
|
2421
|
-
|
|
2559
|
+
const cacheTtlMs = ttl ?? FETCH_TTL_MS;
|
|
2560
|
+
if (ageMs < cacheTtlMs) {
|
|
2422
2561
|
const ageHours = Math.floor(ageMs / (60 * 60 * 1000));
|
|
2423
2562
|
const ageMin = Math.floor(ageMs / (60 * 1000));
|
|
2424
2563
|
const ageStr = ageHours > 0 ? `${ageHours}h ago` : ageMin > 0 ? `${ageMin}m ago` : "just now";
|
|
2425
2564
|
const estimatedBytes = meta.chunkCount * 1600; // ~1.6KB/chunk avg
|
|
2426
|
-
return { kind: "cached", label: meta.label, chunkCount: meta.chunkCount, estimatedBytes, ageStr };
|
|
2565
|
+
return { kind: "cached", label: meta.label, chunkCount: meta.chunkCount, estimatedBytes, ageStr, ttlStr: formatFetchTtl(cacheTtlMs) };
|
|
2427
2566
|
}
|
|
2428
2567
|
// Stale — fall through to re-fetch silently
|
|
2429
2568
|
}
|
|
@@ -2437,7 +2576,18 @@ async function fetchOneUrl(url, source, force) {
|
|
|
2437
2576
|
timeout: 30_000,
|
|
2438
2577
|
});
|
|
2439
2578
|
if (result.exitCode !== 0) {
|
|
2440
|
-
|
|
2579
|
+
// Subprocess fetch failure — undici / fetch can surface EAI_AGAIN /
|
|
2580
|
+
// ETIMEDOUT / ENETUNREACH in stderr when the resolver is overloaded
|
|
2581
|
+
// or the network is briefly unavailable. Append the same retry hint
|
|
2582
|
+
// ssrfGuard's pre-flight DNS path emits so the agent doesn't capitulate
|
|
2583
|
+
// to training data on the first transient failure (PR #654 substitute —
|
|
2584
|
+
// sibling-tool consistency with hooks/core/routing.mjs WebFetch wording).
|
|
2585
|
+
const raw = result.stderr || result.stdout || "unknown error";
|
|
2586
|
+
const isTransientDns = /\b(EAI_AGAIN|ETIMEDOUT|ETIMEOUT|ENETUNREACH|EPERM|getaddrinfo)\b/.test(raw);
|
|
2587
|
+
const hint = isTransientDns
|
|
2588
|
+
? " — transient DNS error; retry once before falling back. If it keeps failing, the MCP host may be running under a network sandbox; restart the host with network access enabled."
|
|
2589
|
+
: "";
|
|
2590
|
+
return { kind: "fetch_error", url, error: `${raw}${hint}`, reason: "exit" };
|
|
2441
2591
|
}
|
|
2442
2592
|
const header = (result.stdout || "").trim();
|
|
2443
2593
|
let markdown;
|
|
@@ -2492,7 +2642,7 @@ function indexFetched(f) {
|
|
|
2492
2642
|
// Track AFTER the FTS5 write succeeds — failed indexes shouldn't inflate the counter.
|
|
2493
2643
|
trackIndexed(Buffer.byteLength(f.markdown));
|
|
2494
2644
|
const preview = f.markdown.length > FETCH_PREVIEW_LIMIT
|
|
2495
|
-
? f.markdown
|
|
2645
|
+
? charSafePrefix(f.markdown, FETCH_PREVIEW_LIMIT) + "\n\n…[truncated — use ctx_search() for full content]"
|
|
2496
2646
|
: f.markdown;
|
|
2497
2647
|
return {
|
|
2498
2648
|
label: indexed.label,
|
|
@@ -2503,15 +2653,27 @@ function indexFetched(f) {
|
|
|
2503
2653
|
}
|
|
2504
2654
|
server.registerTool("ctx_fetch_and_index", {
|
|
2505
2655
|
title: "Fetch & Index URL(s)",
|
|
2506
|
-
description:
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2656
|
+
description: `Fetches URL content, converts HTML to markdown (JSON is chunked by key paths, plain text indexed directly), persists it in a searchable knowledge base, and returns a small preview window per source. The raw page bytes never enter your conversation — they live in storage and you retrieve any section on-demand via ctx_search.
|
|
2657
|
+
|
|
2658
|
+
Caching: every fetch is cached on disk and reused for repeat calls within the TTL window. The default TTL is 24 hours; override per-call with the \`ttl\` parameter (milliseconds, \`ttl: 0\` bypasses cache like \`force: true\`). Stored content older than 14 days is cleaned up on startup.
|
|
2659
|
+
|
|
2660
|
+
WHEN:
|
|
2661
|
+
- You need web content (docs, changelogs, API references, spec pages) and the raw page bytes should NOT enter your conversation
|
|
2662
|
+
- Multi-URL research (library evaluation, migration scans, doc comparisons): pass the \`requests\` array and a \`concurrency\` value 2-8 for parallel I/O
|
|
2663
|
+
- You want repeat lookups against the same URL to be cheap (TTL cache hits return only a hint, no re-fetch)
|
|
2664
|
+
- You want a long-lived cache window (override \`ttl\` upward for stable specs) or a guaranteed-fresh fetch (\`ttl: 0\` or \`force: true\`)
|
|
2665
|
+
|
|
2666
|
+
WHEN NOT:
|
|
2667
|
+
- You already have the content locally — store it via the inline index tool
|
|
2668
|
+
- The page is SPA-rendered (JavaScript-required to materialize content) — this is a plain HTTP fetch, no headless browser
|
|
2669
|
+
|
|
2670
|
+
RETURNS:
|
|
2671
|
+
Per-source preview windows extracted around indexable headings plus indexing metadata (chunk counts, source labels, cache state). Raw content is NOT echoed back — retrieve any section on-demand via ctx_search(source: "<label>"). Concurrency parallelizes the fetch phase up to your chosen value (capped by the host's logical CPU count); the FTS5 write phase always runs serially because SQLite is a single-writer store. Net latency = max(fetch latency across the pool) + sum(per-source index write time). Cache hits skip both phases and return a small freshness hint instead of re-fetching. Use 4-8 for stable I/O-bound batches; lower the value when the target host enforces a per-IP rate limit you cannot raise.
|
|
2672
|
+
|
|
2673
|
+
EXAMPLE: ctx_fetch_and_index(
|
|
2674
|
+
requests: [{url: "https://react.dev/...", source: "react"}, {url: "https://vuejs.org/...", source: "vue"}],
|
|
2675
|
+
concurrency: 5
|
|
2676
|
+
)`,
|
|
2515
2677
|
inputSchema: z.object({
|
|
2516
2678
|
url: z.string().optional().describe("Single URL to fetch and index (legacy single-shape)"),
|
|
2517
2679
|
source: z
|
|
@@ -2519,11 +2681,10 @@ server.registerTool("ctx_fetch_and_index", {
|
|
|
2519
2681
|
.optional()
|
|
2520
2682
|
.describe("Label for the indexed content when using single `url` (e.g., 'React useEffect docs', 'Supabase Auth API'). For batch, put source in each requests entry."),
|
|
2521
2683
|
requests: z
|
|
2522
|
-
.array(z.object({
|
|
2684
|
+
.preprocess(coerceJsonArray, z.array(z.object({
|
|
2523
2685
|
url: z.string().describe("URL to fetch"),
|
|
2524
2686
|
source: z.string().optional().describe("Label for this URL's indexed content"),
|
|
2525
|
-
}))
|
|
2526
|
-
.min(1)
|
|
2687
|
+
})).min(1))
|
|
2527
2688
|
.optional()
|
|
2528
2689
|
.describe("Batch shape: array of {url, source?} entries. Use with concurrency>1 for parallel fetch. " +
|
|
2529
2690
|
"Each request indexed under its own source label. Output preserves input order."),
|
|
@@ -2539,11 +2700,18 @@ server.registerTool("ctx_fetch_and_index", {
|
|
|
2539
2700
|
"Capped by os.cpus().length on small machines (response notes when capped). " +
|
|
2540
2701
|
"Indexing is always serial regardless — only fetches race."),
|
|
2541
2702
|
force: z
|
|
2542
|
-
.boolean()
|
|
2703
|
+
.preprocess(coerceBoolean, z.boolean())
|
|
2543
2704
|
.optional()
|
|
2544
2705
|
.describe("Skip cache and re-fetch even if content was recently indexed"),
|
|
2706
|
+
ttl: z
|
|
2707
|
+
.coerce.number()
|
|
2708
|
+
.int()
|
|
2709
|
+
.min(0)
|
|
2710
|
+
.optional()
|
|
2711
|
+
.describe("Override the cache freshness window for this call, in milliseconds. " +
|
|
2712
|
+
"`ttl: 0` bypasses the cache like `force: true`; omit to use the default 24h TTL."),
|
|
2545
2713
|
}),
|
|
2546
|
-
}, async ({ url, source, requests, concurrency, force }) => {
|
|
2714
|
+
}, async ({ url, source, requests, concurrency, force, ttl }) => {
|
|
2547
2715
|
// Normalize input: legacy {url} or new {requests: [...]}.
|
|
2548
2716
|
// requests wins when both are provided (explicit batch intent).
|
|
2549
2717
|
const batch = requests
|
|
@@ -2565,7 +2733,7 @@ server.registerTool("ctx_fetch_and_index", {
|
|
|
2565
2733
|
// Parallel fetch via shared runPool primitive. capByCpuCount only for batch
|
|
2566
2734
|
// — single-URL doesn't need the cap (only one job, executor is one subprocess).
|
|
2567
2735
|
const jobs = batch.map((req) => ({
|
|
2568
|
-
run: () => fetchOneUrl(req.url, req.source, force),
|
|
2736
|
+
run: () => fetchOneUrl(req.url, req.source, force, ttl),
|
|
2569
2737
|
}));
|
|
2570
2738
|
const { settled, effectiveConcurrency, capped } = await runPool(jobs, {
|
|
2571
2739
|
concurrency: requestedConcurrency,
|
|
@@ -2593,7 +2761,7 @@ server.registerTool("ctx_fetch_and_index", {
|
|
|
2593
2761
|
source: cachedLabel,
|
|
2594
2762
|
bytesAvoided: cachedBytes,
|
|
2595
2763
|
}));
|
|
2596
|
-
finalized.push({ kind: "cached", label: v.label, chunkCount: v.chunkCount, ageStr: v.ageStr });
|
|
2764
|
+
finalized.push({ kind: "cached", label: v.label, chunkCount: v.chunkCount, ageStr: v.ageStr, ttlStr: v.ttlStr });
|
|
2597
2765
|
}
|
|
2598
2766
|
else if (v.kind === "fetch_error") {
|
|
2599
2767
|
finalized.push({ kind: "fetch_error", url: v.url, error: v.error, reason: v.reason });
|
|
@@ -2610,7 +2778,7 @@ server.registerTool("ctx_fetch_and_index", {
|
|
|
2610
2778
|
return trackResponse("ctx_fetch_and_index", {
|
|
2611
2779
|
content: [{
|
|
2612
2780
|
type: "text",
|
|
2613
|
-
text: `Cached: **${r.label}** — ${r.chunkCount} sections, indexed ${r.ageStr} (fresh, TTL:
|
|
2781
|
+
text: `Cached: **${r.label}** — ${r.chunkCount} sections, indexed ${r.ageStr} (fresh, TTL: ${r.ttlStr}).\nTo refresh: call ctx_fetch_and_index again with \`force: true\`.\n\nYou MUST call ctx_search() to answer questions about this content — this cached response contains no content.\nUse: ctx_search(queries: [...], source: "${r.label}")`,
|
|
2614
2782
|
}],
|
|
2615
2783
|
});
|
|
2616
2784
|
}
|
|
@@ -2659,7 +2827,7 @@ server.registerTool("ctx_fetch_and_index", {
|
|
|
2659
2827
|
for (const r of finalized) {
|
|
2660
2828
|
if (r.kind === "cached") {
|
|
2661
2829
|
cachedCount++;
|
|
2662
|
-
lines.push(`- [cache] ${r.label} — ${r.chunkCount} sections (${r.ageStr})`);
|
|
2830
|
+
lines.push(`- [cache] ${r.label} — ${r.chunkCount} sections (${r.ageStr}, TTL: ${r.ttlStr})`);
|
|
2663
2831
|
}
|
|
2664
2832
|
else if (r.kind === "fetched") {
|
|
2665
2833
|
fetchedCount++;
|
|
@@ -2705,17 +2873,32 @@ server.registerTool("ctx_fetch_and_index", {
|
|
|
2705
2873
|
// ─────────────────────────────────────────────────────────
|
|
2706
2874
|
server.registerTool("ctx_batch_execute", {
|
|
2707
2875
|
title: "Batch Execute & Search",
|
|
2708
|
-
description:
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2876
|
+
description: `Run multiple commands in ONE call. Every command's output is auto-indexed into the knowledge base; if you also pass \`queries\`, the matching sections come back in the same round trip so a follow-up search call is not needed.
|
|
2877
|
+
|
|
2878
|
+
Concurrency parallelizes the FETCH phase (run-the-commands). The DERIVATION phase — turning raw output into an answer — still belongs in code: add a processing command that consumes the indexed output and prints only the answer, so the raw bytes never enter your conversation (Think-in-Code, same principle as the sandbox tool).
|
|
2879
|
+
|
|
2880
|
+
WHEN:
|
|
2881
|
+
- You have 3+ related commands you would otherwise run sequentially (multi-issue lookups, git log + git diff + git blame, multi-file reads, multi-region cloud queries)
|
|
2882
|
+
- You want to gather AND query in one round trip — pass \`queries\` so the matching sections come back inline
|
|
2883
|
+
- You want to parallelize I/O-bound work — pass \`concurrency\` 2-8 (network calls, gh CLI, cloud APIs, multi-repo git reads)
|
|
2884
|
+
- The combined output is large enough that piping it through ctx_search later would itself be expensive — let auto-index + inline queries do both in one shot
|
|
2885
|
+
|
|
2886
|
+
WHEN NOT:
|
|
2887
|
+
- Single command with no follow-up query — run it in the sandbox tool directly
|
|
2888
|
+
- CPU-bound or stateful commands — keep concurrency at 1 (npm test, build, lint, port-binding servers, lock-file holders, anything that races on the same resource)
|
|
2889
|
+
|
|
2890
|
+
RETURNS:
|
|
2891
|
+
Auto-indexed section list per command label, plus top matches per query (when \`queries\` is passed). Raw output is NOT echoed in full — only the matched windows. Concurrency>1 switches each command to its own per-command timeout (no shared budget); concurrency=1 preserves the legacy shared-budget cascading-skip-on-timeout path. Use 4-8 for I/O-bound batches; keep at 1 for CPU work or shared-state commands; lower the value when target hosts enforce per-IP rate limits.
|
|
2892
|
+
|
|
2893
|
+
EXAMPLE: ctx_batch_execute(
|
|
2894
|
+
commands: [
|
|
2895
|
+
{label: "issue 1", command: "gh issue view 1"},
|
|
2896
|
+
{label: "issue 2", command: "gh issue view 2"},
|
|
2897
|
+
{label: "summarize", command: "echo done"}
|
|
2898
|
+
],
|
|
2899
|
+
queries: ["root cause", "proposed fix"],
|
|
2900
|
+
concurrency: 2
|
|
2901
|
+
)`,
|
|
2719
2902
|
inputSchema: z.object({
|
|
2720
2903
|
commands: z.preprocess(coerceCommandsArray, z
|
|
2721
2904
|
.array(z.object({
|
|
@@ -2923,7 +3106,47 @@ server.registerTool("ctx_stats", {
|
|
|
2923
3106
|
// 49 MB of indexed content sitting in the content DB.
|
|
2924
3107
|
// Render-time read-only — no DB mutation, no backfill.
|
|
2925
3108
|
const contentDbPath = getStorePath();
|
|
2926
|
-
|
|
3109
|
+
// v1.0.148 Bug E+F: a conversation typically spans many
|
|
3110
|
+
// session_ids (resume cycles, /compact rebirths, PID
|
|
3111
|
+
// sub-process sessions launched by ctx_execute). Scoping
|
|
3112
|
+
// per-session loses sandbox-burst bytes_avoided that the
|
|
3113
|
+
// PID-sessions own. Look up THIS session's project_dir
|
|
3114
|
+
// from META and aggregate via META subquery so all
|
|
3115
|
+
// sibling sessions in the same cwd attribute together.
|
|
3116
|
+
// Fallback to sessionId scope if the META lookup fails
|
|
3117
|
+
// (best-effort — the original metric is still defensible).
|
|
3118
|
+
let convReal;
|
|
3119
|
+
try {
|
|
3120
|
+
const Database = loadDatabase();
|
|
3121
|
+
const dbFiles = (await import("node:fs"))
|
|
3122
|
+
.readdirSync(getSessionDir())
|
|
3123
|
+
.filter((f) => f.endsWith(".db") && (!dbHash || f.startsWith(dbHash)));
|
|
3124
|
+
let projectDirForSid;
|
|
3125
|
+
for (const file of dbFiles) {
|
|
3126
|
+
try {
|
|
3127
|
+
const sdb = new Database((await import("node:path")).join(getSessionDir(), file), { readonly: true });
|
|
3128
|
+
try {
|
|
3129
|
+
const r = sdb
|
|
3130
|
+
.prepare("SELECT project_dir FROM session_meta WHERE session_id = ?")
|
|
3131
|
+
.get(sid);
|
|
3132
|
+
if (r?.project_dir) {
|
|
3133
|
+
projectDirForSid = r.project_dir;
|
|
3134
|
+
break;
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
3137
|
+
finally {
|
|
3138
|
+
sdb.close();
|
|
3139
|
+
}
|
|
3140
|
+
}
|
|
3141
|
+
catch { /* skip unreadable DB */ }
|
|
3142
|
+
}
|
|
3143
|
+
convReal = projectDirForSid
|
|
3144
|
+
? getRealBytesStats({ projectDir: projectDirForSid, sessionsDir: getSessionDir(), worktreeHash: dbHash, contentDbPath })
|
|
3145
|
+
: getRealBytesStats({ sessionId: sid, sessionsDir: getSessionDir(), worktreeHash: dbHash, contentDbPath });
|
|
3146
|
+
}
|
|
3147
|
+
catch {
|
|
3148
|
+
convReal = getRealBytesStats({ sessionId: sid, sessionsDir: getSessionDir(), worktreeHash: dbHash, contentDbPath });
|
|
3149
|
+
}
|
|
2927
3150
|
const lifeRealBase = getRealBytesStats({ sessionsDir: getSessionDir() });
|
|
2928
3151
|
// v1.0.134 SLICE C: lifetime tier sums ALL chunks (no
|
|
2929
3152
|
// session_id filter). Without this fold, lifetime "kept out"
|
|
@@ -3017,6 +3240,12 @@ server.registerTool("ctx_doctor", {
|
|
|
3017
3240
|
else {
|
|
3018
3241
|
lines.push("[WARN] Performance: NORMAL — install Bun for 3-5x speed boost");
|
|
3019
3242
|
}
|
|
3243
|
+
const sessionStorage = resolveSessionStorageDir(getDefaultSessionDir);
|
|
3244
|
+
const contentStorage = resolveContentStorageDir(getDefaultSessionDir);
|
|
3245
|
+
const statsStorage = resolveStatsStorageDir(getDefaultSessionDir);
|
|
3246
|
+
lines.push(`[OK] Storage sessions: ${sessionStorage.path} (${describeStorageDirectorySource(sessionStorage)})`);
|
|
3247
|
+
lines.push(`[OK] Storage content: ${contentStorage.path} (${describeStorageDirectorySource(contentStorage)})`);
|
|
3248
|
+
lines.push(`[OK] Storage stats: ${statsStorage.path} (${describeStorageDirectorySource(statsStorage)})`);
|
|
3020
3249
|
// Server test — cleanup executor to prevent resource leaks (#247)
|
|
3021
3250
|
{
|
|
3022
3251
|
const testExecutor = new PolyglotExecutor({ runtimes });
|
|
@@ -3231,24 +3460,31 @@ server.registerTool("ctx_upgrade", {
|
|
|
3231
3460
|
// tool with "input_schema does not support fields". Issue #563.
|
|
3232
3461
|
server.registerTool("ctx_purge", {
|
|
3233
3462
|
title: "Purge Knowledge Base",
|
|
3234
|
-
description:
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3463
|
+
description: `DESTRUCTIVE: permanently delete indexed content. Cannot be undone. Requires confirm:true and exactly one scope.
|
|
3464
|
+
|
|
3465
|
+
WHEN:
|
|
3466
|
+
- User explicitly asks to clear a specific session ('purge this session', 'wipe this conversation')
|
|
3467
|
+
- User explicitly asks to reset the whole project ('reset everything', 'wipe the knowledge base')
|
|
3468
|
+
|
|
3469
|
+
WHEN NOT:
|
|
3470
|
+
- User says 'reset', 'clear', or 'wipe' without naming a scope -> ask which scope before calling
|
|
3471
|
+
- User wants to free memory or improve performance -> recommend ctx_stats first, do not purge
|
|
3472
|
+
|
|
3473
|
+
SCOPES (pass exactly one):
|
|
3474
|
+
- Per-session: ctx_purge(confirm: true, sessionId: "<uuid>") deletes that session's events (auto-captured decisions, errors, plans, user prompts, rejected approaches, etc.) and per-session FTS5 chunks; sibling sessions and stats file are preserved.
|
|
3475
|
+
- Per-project: ctx_purge(confirm: true, scope: "project") wipes FTS5 knowledge base, every session DB row, events markdown, and resets the stats file. Use ctx_stats first to preview category counts before purging.
|
|
3476
|
+
|
|
3477
|
+
CONTRACT:
|
|
3478
|
+
- confirm:true is required; confirm:false returns 'purge cancelled'.
|
|
3479
|
+
- sessionId and scope:'project' together return 'ambiguous - pick one'.
|
|
3480
|
+
- scope:'session' without sessionId throws (sessionId required).
|
|
3481
|
+
- Bare {confirm:true} is deprecated: maps to scope:'project' with a stderr warning; will hard-error in a future major.
|
|
3482
|
+
|
|
3483
|
+
RETURNS:
|
|
3484
|
+
A summary of removed rows + the resolved scope.
|
|
3485
|
+
|
|
3486
|
+
EXAMPLE: ctx_purge(confirm: true, sessionId: "7c8a-1234-5678-9abc-def012345678")
|
|
3487
|
+
EXAMPLE: ctx_purge(confirm: true, scope: "project")`,
|
|
3252
3488
|
// NOTE: schema MUST be a plain z.object — no .refine()/.transform()/
|
|
3253
3489
|
// .superRefine() wrapper. See block comment above & issue #563. The
|
|
3254
3490
|
// cross-field ambiguity check lives in the handler body below.
|
|
@@ -3540,7 +3776,11 @@ server.registerTool("ctx_insight", {
|
|
|
3540
3776
|
description: "Opens the context-mode Insight dashboard in the browser. " +
|
|
3541
3777
|
"Shows personal analytics: session activity, tool usage, error rate, " +
|
|
3542
3778
|
"parallel work patterns, project focus, and actionable insights. " +
|
|
3543
|
-
"First run installs dependencies (~30s). Subsequent runs open instantly."
|
|
3779
|
+
"First run installs dependencies (~30s). Subsequent runs open instantly. " +
|
|
3780
|
+
"Defaults to port 4747; pass `port` to override. " +
|
|
3781
|
+
"`sessionDir` and `contentDir` override the session/content storage roots " +
|
|
3782
|
+
"(env aliases INSIGHT_SESSION_DIR / INSIGHT_CONTENT_DIR) for diagnosing " +
|
|
3783
|
+
"multi-install setups or pointing at a sibling project's data.",
|
|
3544
3784
|
inputSchema: z.object({
|
|
3545
3785
|
port: z.coerce.number().int().min(1).max(65535).optional().describe("Port to serve on (default: 4747)"),
|
|
3546
3786
|
sessionDir: z.string().optional().describe("Override INSIGHT_SESSION_DIR: directory containing context-mode session .db files"),
|