context-mode 1.0.103 → 1.0.105
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 +39 -7
- package/bin/statusline.mjs +321 -0
- package/build/adapters/antigravity/index.d.ts +6 -0
- package/build/adapters/antigravity/index.js +10 -0
- package/build/adapters/base.d.ts +23 -0
- package/build/adapters/base.js +29 -0
- package/build/adapters/codex/index.d.ts +10 -0
- package/build/adapters/codex/index.js +22 -4
- package/build/adapters/cursor/index.d.ts +7 -0
- package/build/adapters/cursor/index.js +11 -0
- package/build/adapters/detect.d.ts +12 -1
- package/build/adapters/detect.js +69 -7
- package/build/adapters/gemini-cli/index.d.ts +8 -1
- package/build/adapters/gemini-cli/index.js +19 -7
- package/build/adapters/jetbrains-copilot/index.d.ts +7 -0
- package/build/adapters/jetbrains-copilot/index.js +12 -0
- package/build/adapters/kiro/index.d.ts +8 -0
- package/build/adapters/kiro/index.js +12 -0
- package/build/adapters/openclaw/index.d.ts +17 -0
- package/build/adapters/openclaw/index.js +29 -4
- package/build/adapters/opencode/index.d.ts +8 -0
- package/build/adapters/opencode/index.js +18 -6
- package/build/adapters/qwen-code/index.d.ts +1 -0
- package/build/adapters/qwen-code/index.js +3 -0
- package/build/adapters/types.d.ts +33 -0
- package/build/adapters/vscode-copilot/index.d.ts +6 -0
- package/build/adapters/vscode-copilot/index.js +10 -0
- package/build/adapters/zed/index.d.ts +1 -0
- package/build/adapters/zed/index.js +3 -0
- package/build/cli.d.ts +15 -0
- package/build/cli.js +62 -16
- package/build/concurrency/runPool.d.ts +36 -0
- package/build/concurrency/runPool.js +51 -0
- package/build/executor.d.ts +11 -1
- package/build/executor.js +77 -21
- package/build/fetch-cache.d.ts +13 -0
- package/build/fetch-cache.js +15 -0
- package/build/lifecycle.d.ts +6 -2
- package/build/lifecycle.js +29 -2
- package/build/opencode-plugin.d.ts +23 -0
- package/build/opencode-plugin.js +80 -6
- package/build/routing-block.d.ts +8 -0
- package/build/routing-block.js +86 -0
- package/build/runtime.d.ts +1 -0
- package/build/runtime.js +54 -3
- package/build/search/auto-memory.d.ts +23 -10
- package/build/search/auto-memory.js +64 -26
- package/build/search/unified.d.ts +3 -0
- package/build/search/unified.js +2 -2
- package/build/server.d.ts +47 -0
- package/build/server.js +736 -188
- package/build/session/analytics.d.ts +49 -1
- package/build/session/analytics.js +278 -16
- package/build/session/db.d.ts +53 -8
- package/build/session/db.js +200 -19
- package/build/session/extract.js +124 -2
- package/build/tool-naming.d.ts +4 -0
- package/build/tool-naming.js +24 -0
- package/cli.bundle.mjs +208 -158
- package/configs/antigravity/GEMINI.md +11 -0
- package/configs/claude-code/CLAUDE.md +11 -0
- package/configs/codex/AGENTS.md +11 -0
- package/configs/cursor/context-mode.mdc +11 -0
- package/configs/gemini-cli/GEMINI.md +11 -0
- package/configs/jetbrains-copilot/copilot-instructions.md +3 -0
- package/configs/kilo/AGENTS.md +11 -0
- package/configs/kiro/KIRO.md +11 -0
- package/configs/openclaw/AGENTS.md +11 -0
- package/configs/opencode/AGENTS.md +11 -0
- package/configs/pi/AGENTS.md +11 -0
- package/configs/qwen-code/QWEN.md +11 -0
- package/configs/vscode-copilot/copilot-instructions.md +3 -0
- package/configs/zed/AGENTS.md +11 -0
- package/hooks/auto-injection.mjs +36 -10
- package/hooks/cache-heal-utils.mjs +231 -0
- package/hooks/codex/sessionstart.mjs +7 -4
- package/hooks/core/routing.mjs +8 -2
- package/hooks/cursor/sessionstart.mjs +7 -4
- package/hooks/formatters/claude-code.mjs +20 -0
- package/hooks/gemini-cli/sessionstart.mjs +7 -2
- package/hooks/jetbrains-copilot/sessionstart.mjs +7 -2
- package/hooks/normalize-hooks.mjs +184 -0
- package/hooks/session-db.bundle.mjs +41 -14
- package/hooks/session-extract.bundle.mjs +2 -2
- package/hooks/session-helpers.mjs +68 -20
- package/hooks/session-loaders.mjs +8 -2
- package/hooks/sessionstart.mjs +8 -2
- package/hooks/vscode-copilot/sessionstart.mjs +7 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -1
- package/server.bundle.mjs +181 -134
- package/skills/ctx-doctor/SKILL.md +3 -3
- package/skills/ctx-insight/SKILL.md +1 -1
- package/start.mjs +63 -3
package/build/server.js
CHANGED
|
@@ -3,15 +3,17 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
|
-
import { existsSync, unlinkSync, readdirSync, readFileSync, writeFileSync, rmSync, mkdirSync, cpSync, statSync, symlinkSync, lstatSync } from "node:fs";
|
|
6
|
+
import { existsSync, unlinkSync, readdirSync, readFileSync, writeFileSync, renameSync, rmSync, mkdirSync, cpSync, statSync, symlinkSync, lstatSync } from "node:fs";
|
|
7
7
|
import { execSync } from "node:child_process";
|
|
8
|
-
import { join, dirname, resolve, sep } from "node:path";
|
|
8
|
+
import { join, dirname, resolve, sep, isAbsolute } from "node:path";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
|
-
import { homedir, tmpdir } from "node:os";
|
|
10
|
+
import { homedir, tmpdir, cpus } from "node:os";
|
|
11
11
|
import { request as httpsRequest } from "node:https";
|
|
12
12
|
import { z } from "zod";
|
|
13
13
|
import { PolyglotExecutor } from "./executor.js";
|
|
14
|
+
import { runPool } from "./concurrency/runPool.js";
|
|
14
15
|
import { ContentStore, cleanupStaleDBs, cleanupStaleContentDBs } from "./store.js";
|
|
16
|
+
import { composeFetchCacheKey } from "./fetch-cache.js";
|
|
15
17
|
import { readBashPolicies, evaluateCommandDenyOnly, extractShellCommands, readToolDenyPatterns, evaluateFilePath, } from "./security.js";
|
|
16
18
|
import { detectRuntimes, getRuntimeSummary, getAvailableLanguages, hasBunRuntime, } from "./runtime.js";
|
|
17
19
|
import { classifyNonZeroExit } from "./exit-classify.js";
|
|
@@ -19,8 +21,9 @@ import { startLifecycleGuard } from "./lifecycle.js";
|
|
|
19
21
|
import { getWorktreeSuffix, SessionDB } from "./session/db.js";
|
|
20
22
|
import { searchAllSources } from "./search/unified.js";
|
|
21
23
|
import { buildNodeCommand } from "./adapters/types.js";
|
|
24
|
+
import { detectPlatform, getSessionDirSegments } from "./adapters/detect.js";
|
|
22
25
|
import { loadDatabase } from "./db-base.js";
|
|
23
|
-
import { AnalyticsEngine, formatReport } from "./session/analytics.js";
|
|
26
|
+
import { AnalyticsEngine, formatReport, getLifetimeStats, OPUS_INPUT_PRICE_PER_TOKEN } from "./session/analytics.js";
|
|
24
27
|
const __pkg_dir = dirname(fileURLToPath(import.meta.url));
|
|
25
28
|
const VERSION = (() => {
|
|
26
29
|
for (const rel of ["../package.json", "./package.json"]) {
|
|
@@ -57,7 +60,7 @@ server.server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resou
|
|
|
57
60
|
server.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ resourceTemplates: [] }));
|
|
58
61
|
const executor = new PolyglotExecutor({
|
|
59
62
|
runtimes,
|
|
60
|
-
projectRoot:
|
|
63
|
+
projectRoot: () => getProjectDir(),
|
|
61
64
|
});
|
|
62
65
|
// ─────────────────────────────────────────────────────────
|
|
63
66
|
// FS read tracking preload for ctx_batch_execute
|
|
@@ -109,6 +112,20 @@ let _insightChild = null;
|
|
|
109
112
|
function getSessionDir() {
|
|
110
113
|
if (_detectedAdapter)
|
|
111
114
|
return _detectedAdapter.getSessionDir();
|
|
115
|
+
// Pre-detection path (race window before MCP `initialize` completes):
|
|
116
|
+
// call detectPlatform() (sync, env-var-based) and look up segments via
|
|
117
|
+
// getSessionDirSegments() (sync map, no adapter instantiation). This keeps
|
|
118
|
+
// non-Claude platforms from spilling sessions into ~/.claude/.
|
|
119
|
+
try {
|
|
120
|
+
const signal = detectPlatform();
|
|
121
|
+
const segments = getSessionDirSegments(signal.platform);
|
|
122
|
+
if (segments) {
|
|
123
|
+
const dir = join(homedir(), ...segments, "context-mode", "sessions");
|
|
124
|
+
mkdirSync(dir, { recursive: true });
|
|
125
|
+
return dir;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch { /* fall through to .claude fallback */ }
|
|
112
129
|
const dir = join(homedir(), ".claude", "context-mode", "sessions");
|
|
113
130
|
mkdirSync(dir, { recursive: true });
|
|
114
131
|
return dir;
|
|
@@ -130,9 +147,18 @@ function getProjectDir() {
|
|
|
130
147
|
|| process.env.VSCODE_CWD
|
|
131
148
|
|| process.env.OPENCODE_PROJECT_DIR
|
|
132
149
|
|| process.env.PI_PROJECT_DIR
|
|
150
|
+
|| process.env.IDEA_INITIAL_DIRECTORY
|
|
133
151
|
|| process.env.CONTEXT_MODE_PROJECT_DIR
|
|
134
152
|
|| process.cwd();
|
|
135
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* Resolve a possibly-relative path against the project directory (full env cascade),
|
|
156
|
+
* not the MCP server's process.cwd(). MCP server is spawned by the host and its cwd
|
|
157
|
+
* is unrelated to where the user is working.
|
|
158
|
+
*/
|
|
159
|
+
function resolveProjectPath(filePath) {
|
|
160
|
+
return isAbsolute(filePath) ? filePath : resolve(getProjectDir(), filePath);
|
|
161
|
+
}
|
|
136
162
|
/**
|
|
137
163
|
* Consistent project dir hashing across all DB paths.
|
|
138
164
|
* Normalizes Windows backslashes before hashing so the same project
|
|
@@ -322,10 +348,120 @@ function trackResponse(toolName, response) {
|
|
|
322
348
|
sessionStats.calls[toolName] = (sessionStats.calls[toolName] || 0) + 1;
|
|
323
349
|
sessionStats.bytesReturned[toolName] =
|
|
324
350
|
(sessionStats.bytesReturned[toolName] || 0) + bytes;
|
|
351
|
+
// Persist a sidecar JSON snapshot for the statusline — read at ~3-5 Hz by
|
|
352
|
+
// bin/statusline.mjs (and any external dashboard) so they don't have to
|
|
353
|
+
// open the SQLite database. Throttled inside persistStats() (500ms) so
|
|
354
|
+
// it's safe to call on every response. The b392c2f concurrency refactor
|
|
355
|
+
// dropped the SessionDB tool-call counter (`persistToolCallCounter`); we
|
|
356
|
+
// keep persistStats here because the statusline depends on it.
|
|
357
|
+
persistStats();
|
|
325
358
|
return response;
|
|
326
359
|
}
|
|
327
360
|
function trackIndexed(bytes) {
|
|
328
361
|
sessionStats.bytesIndexed += bytes;
|
|
362
|
+
persistStats();
|
|
363
|
+
}
|
|
364
|
+
// ─────────────────────────────────────────────────────────
|
|
365
|
+
// Stats persistence — written after every tool call so
|
|
366
|
+
// external readers (status line scripts, dashboards, hooks)
|
|
367
|
+
// can see real-time savings without spawning an MCP client.
|
|
368
|
+
// ─────────────────────────────────────────────────────────
|
|
369
|
+
const STATS_PERSIST_THROTTLE_MS = 500;
|
|
370
|
+
// Schema version for the persisted stats payload (~/.claude/context-mode/sessions/stats-*.json).
|
|
371
|
+
// Bump when a field is added/renamed/removed. Statusline reads `schemaVersion ?? 0` and warns when
|
|
372
|
+
// it sees a future schema, so legacy bundles degrade gracefully on upgrade rather than silently
|
|
373
|
+
// rendering missing fields (PR #401 architect review P1.3).
|
|
374
|
+
// v2: added tokens_saved_lifetime + dollars_saved_lifetime.
|
|
375
|
+
const STATS_SCHEMA_VERSION = 2;
|
|
376
|
+
// OPUS_INPUT_PRICE_PER_TOKEN intentionally NOT defined here — single source in
|
|
377
|
+
// src/session/analytics.ts re-exported above. (P1.1 — pricing constant dedup,
|
|
378
|
+
// PR #401 architect + ops 2-vote convergence.)
|
|
379
|
+
const LIFETIME_REFRESH_MS = 30_000;
|
|
380
|
+
// Matches the conversion factor in src/session/analytics.ts renderBottomLine:
|
|
381
|
+
// ~1KB per session event ÷ 4 bytes/token = 256 tokens/event.
|
|
382
|
+
const TOKENS_PER_EVENT = 256;
|
|
383
|
+
let _lastStatsPersist = 0;
|
|
384
|
+
let _lifetimeCache;
|
|
385
|
+
/**
|
|
386
|
+
* Resolve the per-session stats file path.
|
|
387
|
+
*
|
|
388
|
+
* The session id mirrors the Claude Code adapter contract
|
|
389
|
+
* (`pid-<parent pid>`), so a status line script can derive
|
|
390
|
+
* the same id from `$PPID` without coupling to MCP.
|
|
391
|
+
*/
|
|
392
|
+
function getStatsFilePath() {
|
|
393
|
+
const sessionId = process.env.CLAUDE_SESSION_ID || `pid-${process.ppid}`;
|
|
394
|
+
return join(getSessionDir(), `stats-${sessionId}.json`);
|
|
395
|
+
}
|
|
396
|
+
function persistStats() {
|
|
397
|
+
const now = Date.now();
|
|
398
|
+
if (now - _lastStatsPersist < STATS_PERSIST_THROTTLE_MS)
|
|
399
|
+
return;
|
|
400
|
+
_lastStatsPersist = now;
|
|
401
|
+
try {
|
|
402
|
+
const totalReturned = Object.values(sessionStats.bytesReturned).reduce((a, b) => a + b, 0);
|
|
403
|
+
const totalCalls = Object.values(sessionStats.calls).reduce((a, b) => a + b, 0);
|
|
404
|
+
const keptOut = sessionStats.bytesIndexed +
|
|
405
|
+
sessionStats.bytesSandboxed +
|
|
406
|
+
sessionStats.cacheBytesSaved;
|
|
407
|
+
const totalProcessed = keptOut + totalReturned;
|
|
408
|
+
const reductionPct = totalProcessed > 0
|
|
409
|
+
? Math.round((1 - totalReturned / totalProcessed) * 100)
|
|
410
|
+
: 0;
|
|
411
|
+
const tokensSaved = Math.round(keptOut / 4);
|
|
412
|
+
// Lifetime savings — cached separately because getLifetimeStats() scans
|
|
413
|
+
// disk (per-project SessionDBs + auto-memory dirs) and is too expensive
|
|
414
|
+
// for the 500ms persist throttle. Refresh every 30s; the statusline
|
|
415
|
+
// doesn't need second-by-second lifetime accuracy.
|
|
416
|
+
let lifetimeTokens = _lifetimeCache?.tokens ?? 0;
|
|
417
|
+
if (!_lifetimeCache || now - _lifetimeCache.computedAt > LIFETIME_REFRESH_MS) {
|
|
418
|
+
try {
|
|
419
|
+
const life = getLifetimeStats({ sessionsDir: getSessionDir() });
|
|
420
|
+
lifetimeTokens = (life?.totalEvents ?? 0) * TOKENS_PER_EVENT;
|
|
421
|
+
_lifetimeCache = { tokens: lifetimeTokens, computedAt: now };
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
// best-effort — keep stale cache or 0
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
const payload = {
|
|
428
|
+
schemaVersion: STATS_SCHEMA_VERSION,
|
|
429
|
+
version: VERSION,
|
|
430
|
+
updated_at: now,
|
|
431
|
+
session_start: sessionStats.sessionStart,
|
|
432
|
+
uptime_ms: now - sessionStats.sessionStart,
|
|
433
|
+
total_calls: totalCalls,
|
|
434
|
+
bytes_returned: totalReturned,
|
|
435
|
+
bytes_indexed: sessionStats.bytesIndexed,
|
|
436
|
+
bytes_sandboxed: sessionStats.bytesSandboxed,
|
|
437
|
+
cache_hits: sessionStats.cacheHits,
|
|
438
|
+
cache_bytes_saved: sessionStats.cacheBytesSaved,
|
|
439
|
+
kept_out: keptOut,
|
|
440
|
+
total_processed: totalProcessed,
|
|
441
|
+
reduction_pct: reductionPct,
|
|
442
|
+
tokens_saved: tokensSaved,
|
|
443
|
+
// statusline-facing $ values — pre-computed at Opus input rate so the
|
|
444
|
+
// statusline doesn't have to know pricing. Lets us evolve pricing in
|
|
445
|
+
// one place without touching consumers.
|
|
446
|
+
dollars_saved_session: +(tokensSaved * OPUS_INPUT_PRICE_PER_TOKEN).toFixed(2),
|
|
447
|
+
tokens_saved_lifetime: lifetimeTokens,
|
|
448
|
+
dollars_saved_lifetime: +(lifetimeTokens * OPUS_INPUT_PRICE_PER_TOKEN).toFixed(2),
|
|
449
|
+
by_tool: Object.fromEntries(Object.keys({ ...sessionStats.calls, ...sessionStats.bytesReturned }).map((t) => [
|
|
450
|
+
t,
|
|
451
|
+
{
|
|
452
|
+
calls: sessionStats.calls[t] || 0,
|
|
453
|
+
bytes: sessionStats.bytesReturned[t] || 0,
|
|
454
|
+
},
|
|
455
|
+
])),
|
|
456
|
+
};
|
|
457
|
+
const filePath = getStatsFilePath();
|
|
458
|
+
const tmpPath = `${filePath}.tmp`;
|
|
459
|
+
writeFileSync(tmpPath, JSON.stringify(payload));
|
|
460
|
+
renameSync(tmpPath, filePath);
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
// best-effort — never break tool calls because of stats persistence
|
|
464
|
+
}
|
|
329
465
|
}
|
|
330
466
|
// ==============================================================================
|
|
331
467
|
// Security: server-side deny firewall
|
|
@@ -387,7 +523,7 @@ function checkNonShellDenyPolicy(code, language, toolName) {
|
|
|
387
523
|
*/
|
|
388
524
|
function checkFilePathDenyPolicy(filePath, toolName) {
|
|
389
525
|
try {
|
|
390
|
-
const projectDir =
|
|
526
|
+
const projectDir = getProjectDir();
|
|
391
527
|
const denyGlobs = readToolDenyPatterns("Read", projectDir);
|
|
392
528
|
const result = evaluateFilePath(filePath, denyGlobs, process.platform === "win32", projectDir);
|
|
393
529
|
if (result.denied) {
|
|
@@ -539,6 +675,100 @@ export function formatBatchQueryResults(store, queries, source, maxOutput = 80 *
|
|
|
539
675
|
sections.push(`\n> **Tip:** Results are scoped to this batch only. To search across all indexed sources, use \`ctx_search(queries: [...])\`.`);
|
|
540
676
|
return sections;
|
|
541
677
|
}
|
|
678
|
+
function formatCommandOutput(label, raw, onFsBytes) {
|
|
679
|
+
let output = raw || "(no output)";
|
|
680
|
+
const fsMatches = output.matchAll(/__CM_FS__:(\d+)/g);
|
|
681
|
+
let cmdFsBytes = 0;
|
|
682
|
+
for (const m of fsMatches)
|
|
683
|
+
cmdFsBytes += parseInt(m[1]);
|
|
684
|
+
if (cmdFsBytes > 0) {
|
|
685
|
+
onFsBytes?.(cmdFsBytes);
|
|
686
|
+
output = output.replace(/__CM_FS__:\d+\n?/g, "");
|
|
687
|
+
}
|
|
688
|
+
return `# ${label}\n\n${output}\n`;
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Execute batch commands. concurrency=1 preserves the legacy serial path
|
|
692
|
+
* (shared timeout budget + cascading skip-on-timeout). concurrency>1 runs
|
|
693
|
+
* commands concurrently with at most N in flight; each command receives the
|
|
694
|
+
* full timeout, output is collated by input index, and per-command timeouts
|
|
695
|
+
* record `(timed out)` blocks without skipping siblings.
|
|
696
|
+
*/
|
|
697
|
+
export async function runBatchCommands(commands, opts, executor) {
|
|
698
|
+
const { timeout, concurrency, nodeOptsPrefix, onFsBytes } = opts;
|
|
699
|
+
if (concurrency <= 1) {
|
|
700
|
+
// Serial path — shared timeout budget, cascading skip on timeout.
|
|
701
|
+
// When `timeout` is undefined, no shared budget is enforced; each
|
|
702
|
+
// command runs to completion (Issue #406).
|
|
703
|
+
const outputs = [];
|
|
704
|
+
const startTime = Date.now();
|
|
705
|
+
let timedOut = false;
|
|
706
|
+
for (let i = 0; i < commands.length; i++) {
|
|
707
|
+
const cmd = commands[i];
|
|
708
|
+
let perCmdTimeout;
|
|
709
|
+
if (timeout !== undefined) {
|
|
710
|
+
const elapsed = Date.now() - startTime;
|
|
711
|
+
const remaining = timeout - elapsed;
|
|
712
|
+
if (remaining <= 0) {
|
|
713
|
+
outputs.push(`# ${cmd.label}\n\n(skipped — batch timeout exceeded)\n`);
|
|
714
|
+
timedOut = true;
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
perCmdTimeout = remaining;
|
|
718
|
+
}
|
|
719
|
+
const result = await executor.execute({
|
|
720
|
+
language: "shell",
|
|
721
|
+
code: `${nodeOptsPrefix}${cmd.command} 2>&1`,
|
|
722
|
+
timeout: perCmdTimeout,
|
|
723
|
+
});
|
|
724
|
+
outputs.push(formatCommandOutput(cmd.label, result.stdout, onFsBytes));
|
|
725
|
+
if (result.timedOut) {
|
|
726
|
+
timedOut = true;
|
|
727
|
+
for (let j = i + 1; j < commands.length; j++) {
|
|
728
|
+
outputs.push(`# ${commands[j].label}\n\n(skipped — batch timeout exceeded)\n`);
|
|
729
|
+
}
|
|
730
|
+
break;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return { outputs, timedOut };
|
|
734
|
+
}
|
|
735
|
+
// Parallel path — delegated to the shared runPool primitive.
|
|
736
|
+
// Each job returns { output, timedOut }; runPool handles in-flight cap,
|
|
737
|
+
// throw isolation (Promise.allSettled semantics), and order preservation.
|
|
738
|
+
const jobs = commands.map((cmd) => ({
|
|
739
|
+
run: async () => {
|
|
740
|
+
const result = await executor.execute({
|
|
741
|
+
language: "shell",
|
|
742
|
+
code: `${nodeOptsPrefix}${cmd.command} 2>&1`,
|
|
743
|
+
timeout,
|
|
744
|
+
});
|
|
745
|
+
// Always route partial stdout through formatCommandOutput so __CM_FS__
|
|
746
|
+
// markers are stripped + counted, even when the command timed out.
|
|
747
|
+
const formatted = formatCommandOutput(cmd.label, result.stdout, onFsBytes);
|
|
748
|
+
const output = result.timedOut
|
|
749
|
+
? formatted.replace(/\n$/, "") + `\n(timed out after ${timeout ?? "?"}ms)\n`
|
|
750
|
+
: formatted;
|
|
751
|
+
return { output, timedOut: !!result.timedOut };
|
|
752
|
+
},
|
|
753
|
+
}));
|
|
754
|
+
const { settled } = await runPool(jobs, { concurrency });
|
|
755
|
+
const outputs = new Array(commands.length);
|
|
756
|
+
let timedOut = false;
|
|
757
|
+
for (let i = 0; i < settled.length; i++) {
|
|
758
|
+
const r = settled[i];
|
|
759
|
+
if (r.status === "fulfilled") {
|
|
760
|
+
outputs[i] = r.value.output;
|
|
761
|
+
if (r.value.timedOut)
|
|
762
|
+
timedOut = true;
|
|
763
|
+
}
|
|
764
|
+
else {
|
|
765
|
+
// Isolated executor throw (spawn EAGAIN, ENOMEM, EMFILE, …) — siblings keep running.
|
|
766
|
+
const message = r.reason instanceof Error ? r.reason.message : String(r.reason);
|
|
767
|
+
outputs[i] = `# ${commands[i].label}\n\n(executor error: ${message})\n`;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
return { outputs, timedOut };
|
|
771
|
+
}
|
|
542
772
|
// ─────────────────────────────────────────────────────────
|
|
543
773
|
// Tool: execute
|
|
544
774
|
// ─────────────────────────────────────────────────────────
|
|
@@ -567,8 +797,7 @@ server.registerTool("ctx_execute", {
|
|
|
567
797
|
timeout: z
|
|
568
798
|
.coerce.number()
|
|
569
799
|
.optional()
|
|
570
|
-
.
|
|
571
|
-
.describe("Max execution time in ms"),
|
|
800
|
+
.describe("Max execution time in ms. When omitted, no server-side timer fires — the MCP host's RPC timeout governs (which is the right layer for this policy). Pass an explicit value for long-running builds (Gradle/Maven/SBT)."),
|
|
572
801
|
background: z
|
|
573
802
|
.boolean()
|
|
574
803
|
.optional()
|
|
@@ -863,8 +1092,7 @@ server.registerTool("ctx_execute_file", {
|
|
|
863
1092
|
timeout: z
|
|
864
1093
|
.coerce.number()
|
|
865
1094
|
.optional()
|
|
866
|
-
.
|
|
867
|
-
.describe("Max execution time in ms"),
|
|
1095
|
+
.describe("Max execution time in ms. When omitted, no server-side timer fires — the MCP host's RPC timeout governs."),
|
|
868
1096
|
intent: z
|
|
869
1097
|
.string()
|
|
870
1098
|
.optional()
|
|
@@ -1009,18 +1237,19 @@ server.registerTool("ctx_index", {
|
|
|
1009
1237
|
});
|
|
1010
1238
|
}
|
|
1011
1239
|
try {
|
|
1240
|
+
const resolvedPath = path ? resolveProjectPath(path) : undefined;
|
|
1012
1241
|
// Track the raw bytes being indexed (content or file)
|
|
1013
1242
|
if (content)
|
|
1014
1243
|
trackIndexed(Buffer.byteLength(content));
|
|
1015
|
-
else if (
|
|
1244
|
+
else if (resolvedPath) {
|
|
1016
1245
|
try {
|
|
1017
1246
|
const fs = await import("fs");
|
|
1018
|
-
trackIndexed(fs.readFileSync(
|
|
1247
|
+
trackIndexed(fs.readFileSync(resolvedPath).byteLength);
|
|
1019
1248
|
}
|
|
1020
1249
|
catch { /* ignore — file read errors handled by store */ }
|
|
1021
1250
|
}
|
|
1022
1251
|
const store = getStore();
|
|
1023
|
-
const result = store.index({ content, path, source });
|
|
1252
|
+
const result = store.index({ content, path: resolvedPath, source: source ?? resolvedPath });
|
|
1024
1253
|
return trackResponse("ctx_index", {
|
|
1025
1254
|
content: [
|
|
1026
1255
|
{
|
|
@@ -1178,14 +1407,14 @@ server.registerTool("ctx_search", {
|
|
|
1178
1407
|
if (sort === "timeline") {
|
|
1179
1408
|
try {
|
|
1180
1409
|
const sessionsDir = getSessionDir();
|
|
1181
|
-
const dbFile = join(sessionsDir, `${hashProjectDir()}.db`);
|
|
1410
|
+
const dbFile = join(sessionsDir, `${hashProjectDir()}${getWorktreeSuffix()}.db`);
|
|
1182
1411
|
if (existsSync(dbFile)) {
|
|
1183
1412
|
timelineDB = new SessionDB({ dbPath: dbFile });
|
|
1184
1413
|
}
|
|
1185
1414
|
}
|
|
1186
1415
|
catch { /* SessionDB unavailable — search ContentStore + auto-memory only */ }
|
|
1187
1416
|
}
|
|
1188
|
-
const configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
|
|
1417
|
+
const configDir = _detectedAdapter?.getConfigDir() ?? (process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"));
|
|
1189
1418
|
try {
|
|
1190
1419
|
for (const q of queryList) {
|
|
1191
1420
|
if (totalSize > MAX_TOTAL) {
|
|
@@ -1204,6 +1433,7 @@ server.registerTool("ctx_search", {
|
|
|
1204
1433
|
sessionDB: timelineDB,
|
|
1205
1434
|
projectDir: getProjectDir(),
|
|
1206
1435
|
configDir,
|
|
1436
|
+
adapter: _detectedAdapter ?? undefined,
|
|
1207
1437
|
});
|
|
1208
1438
|
}
|
|
1209
1439
|
else {
|
|
@@ -1342,54 +1572,178 @@ async function main() {
|
|
|
1342
1572
|
main();
|
|
1343
1573
|
`;
|
|
1344
1574
|
}
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1575
|
+
// ─────────────────────────────────────────────────────────
|
|
1576
|
+
// fetch_and_index helpers — split into parallel-safe fetch and serial-only index
|
|
1577
|
+
// ─────────────────────────────────────────────────────────
|
|
1578
|
+
const FETCH_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
1579
|
+
const FETCH_PREVIEW_LIMIT = 3072;
|
|
1580
|
+
/**
|
|
1581
|
+
* Pure fetch step — TTL cache check + subprocess fetch. SAFE TO RUN IN PARALLEL.
|
|
1582
|
+
* Performs zero SQLite writes (only reads source meta). Caller must funnel
|
|
1583
|
+
* fetched results through `indexFetched` serially to avoid FTS5 WAL contention.
|
|
1584
|
+
*/
|
|
1585
|
+
/**
|
|
1586
|
+
* SSRF guard for ctx_fetch_and_index: validate URL scheme + resolve target IP +
|
|
1587
|
+
* block link-local / IMDS / multicast / reserved IP ranges. Returns null if
|
|
1588
|
+
* safe; returns a FetchOneResult fetch_error if blocked.
|
|
1589
|
+
*
|
|
1590
|
+
* Policy (PR #401 ops review, developer-friendly default):
|
|
1591
|
+
*
|
|
1592
|
+
* **HARD BLOCK** (no legitimate dev workflow):
|
|
1593
|
+
* - file://, gopher://, javascript:, data: schemes (only http: and https:)
|
|
1594
|
+
* - 169.254.0.0/16 link-local (INCLUDES 169.254.169.254 = AWS/GCP/Azure IMDS
|
|
1595
|
+
* cloud credential endpoint — high-value target for indirect prompt injection)
|
|
1596
|
+
* - IPv6 link-local fe80::/10
|
|
1597
|
+
* - Multicast (224+ IPv4, ff00::/8 IPv6) and reserved (0.0.0.0/8) ranges
|
|
1598
|
+
*
|
|
1599
|
+
* **ALLOW by default** (legitimate developer use cases dominate):
|
|
1600
|
+
* - localhost, 127.x.x.x, ::1 (local dev servers — Next.js, Vite, Postgres, …)
|
|
1601
|
+
* - 10.x, 172.16-31.x, 192.168.x RFC1918 private (developer's internal network)
|
|
1602
|
+
*
|
|
1603
|
+
* **STRICT MODE** opt-in via env var: `CTX_FETCH_STRICT=1`
|
|
1604
|
+
* - Blocks loopback + RFC1918 too
|
|
1605
|
+
* - For hosted/CI environments where the runtime isn't the user's own machine
|
|
1606
|
+
*
|
|
1607
|
+
* DNS resolution is performed against the resolved IP (not just URL parse) so a
|
|
1608
|
+
* hostname like `evil.com` pointing to 169.254.169.254 is rejected — defends
|
|
1609
|
+
* against attacker-controlled DNS records and DNS rebinding.
|
|
1610
|
+
*/
|
|
1611
|
+
async function ssrfGuard(rawUrl) {
|
|
1612
|
+
let parsed;
|
|
1613
|
+
try {
|
|
1614
|
+
parsed = new URL(rawUrl);
|
|
1615
|
+
}
|
|
1616
|
+
catch {
|
|
1617
|
+
return { kind: "fetch_error", url: rawUrl, error: "invalid URL", reason: "exit" };
|
|
1618
|
+
}
|
|
1619
|
+
// 1. Scheme allowlist — http and https only
|
|
1620
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
1621
|
+
return {
|
|
1622
|
+
kind: "fetch_error",
|
|
1623
|
+
url: rawUrl,
|
|
1624
|
+
error: `URL scheme "${parsed.protocol}" not allowed (only http: and https:)`,
|
|
1625
|
+
reason: "exit",
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
const strict = process.env.CTX_FETCH_STRICT === "1";
|
|
1629
|
+
// 2. DNS resolve + check IP ranges (hard-block + optional strict-mode block)
|
|
1630
|
+
try {
|
|
1631
|
+
const { lookup } = await import("node:dns/promises");
|
|
1632
|
+
const records = await lookup(parsed.hostname, { all: true, verbatim: true });
|
|
1633
|
+
for (const rec of records) {
|
|
1634
|
+
const verdict = classifyIp(rec.address);
|
|
1635
|
+
if (verdict === "block") {
|
|
1636
|
+
return {
|
|
1637
|
+
kind: "fetch_error",
|
|
1638
|
+
url: rawUrl,
|
|
1639
|
+
error: `URL "${parsed.hostname}" resolves to ${rec.address} — blocked (link-local / IMDS / multicast / reserved)`,
|
|
1640
|
+
reason: "exit",
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1643
|
+
if (verdict === "private" && strict) {
|
|
1644
|
+
return {
|
|
1645
|
+
kind: "fetch_error",
|
|
1646
|
+
url: rawUrl,
|
|
1647
|
+
error: `URL "${parsed.hostname}" resolves to private IP ${rec.address} — blocked under CTX_FETCH_STRICT=1`,
|
|
1648
|
+
reason: "exit",
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
catch (err) {
|
|
1654
|
+
return {
|
|
1655
|
+
kind: "fetch_error",
|
|
1656
|
+
url: rawUrl,
|
|
1657
|
+
error: `DNS lookup failed for "${parsed.hostname}": ${err instanceof Error ? err.message : String(err)}`,
|
|
1658
|
+
reason: "exit",
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1661
|
+
return null; // safe to fetch
|
|
1662
|
+
}
|
|
1663
|
+
/**
|
|
1664
|
+
* Classify an IP address.
|
|
1665
|
+
* - "block": always blocked (link-local/IMDS/multicast/reserved/malformed)
|
|
1666
|
+
* - "private": loopback or RFC1918 — allowed by default, blocked in strict mode
|
|
1667
|
+
* - "public": safe to fetch
|
|
1668
|
+
*
|
|
1669
|
+
* Exported (via the function name) so SSRF tests can exercise the matcher directly.
|
|
1670
|
+
*/
|
|
1671
|
+
export function classifyIp(ip) {
|
|
1672
|
+
const lower = ip.toLowerCase();
|
|
1673
|
+
// IPv6 takes priority — check for `:` first so IPv4-mapped addresses
|
|
1674
|
+
// (`::ffff:127.0.0.1`) don't get incorrectly routed through the IPv4 parser.
|
|
1675
|
+
if (lower.includes(":")) {
|
|
1676
|
+
// IPv4-mapped IPv6 (`::ffff:127.0.0.1`) — recurse through IPv4 classifier
|
|
1677
|
+
const v4MappedMatch = lower.match(/^::ffff:([\d.]+)$/);
|
|
1678
|
+
if (v4MappedMatch)
|
|
1679
|
+
return classifyIp(v4MappedMatch[1]);
|
|
1680
|
+
// Hard-block
|
|
1681
|
+
if (lower === "::")
|
|
1682
|
+
return "block"; // unspecified
|
|
1683
|
+
if (lower.startsWith("fe8") || lower.startsWith("fe9") ||
|
|
1684
|
+
lower.startsWith("fea") || lower.startsWith("feb"))
|
|
1685
|
+
return "block"; // fe80::/10 link-local
|
|
1686
|
+
if (lower.startsWith("ff"))
|
|
1687
|
+
return "block"; // ff00::/8 multicast
|
|
1688
|
+
// Private (loopback + ULA)
|
|
1689
|
+
if (lower === "::1")
|
|
1690
|
+
return "private";
|
|
1691
|
+
if (lower.startsWith("fc") || lower.startsWith("fd"))
|
|
1692
|
+
return "private"; // fc00::/7 ULA
|
|
1693
|
+
return "public";
|
|
1694
|
+
}
|
|
1695
|
+
// IPv4 (or non-IP string — malformed = block)
|
|
1696
|
+
if (!ip.includes("."))
|
|
1697
|
+
return "block"; // not an IP at all
|
|
1698
|
+
const parts = ip.split(".").map((p) => parseInt(p, 10));
|
|
1699
|
+
if (parts.length !== 4 || parts.some((p) => isNaN(p) || p < 0 || p > 255))
|
|
1700
|
+
return "block";
|
|
1701
|
+
const [a, b] = parts;
|
|
1702
|
+
// Hard-block (no legitimate use)
|
|
1703
|
+
if (a === 169 && b === 254)
|
|
1704
|
+
return "block"; // link-local incl. 169.254.169.254 (IMDS)
|
|
1705
|
+
if (a === 0)
|
|
1706
|
+
return "block"; // 0.0.0.0/8 (current network)
|
|
1707
|
+
if (a >= 224)
|
|
1708
|
+
return "block"; // 224.0.0.0+ multicast/reserved
|
|
1709
|
+
// Private (loopback + RFC1918) — allow by default
|
|
1710
|
+
if (a === 127)
|
|
1711
|
+
return "private"; // 127.0.0.0/8 loopback
|
|
1712
|
+
if (a === 10)
|
|
1713
|
+
return "private"; // 10.0.0.0/8
|
|
1714
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
1715
|
+
return "private"; // 172.16.0.0/12
|
|
1716
|
+
if (a === 192 && b === 168)
|
|
1717
|
+
return "private"; // 192.168.0.0/16
|
|
1718
|
+
return "public";
|
|
1719
|
+
}
|
|
1720
|
+
async function fetchOneUrl(url, source, force) {
|
|
1721
|
+
// SSRF guard — reject file://, javascript:, loopback, RFC1918, IMDS, link-local
|
|
1722
|
+
// BEFORE any cache lookup or subprocess spawn. Even cached entries shouldn't
|
|
1723
|
+
// serve a previously-poisoned source label.
|
|
1724
|
+
const ssrfBlock = await ssrfGuard(url);
|
|
1725
|
+
if (ssrfBlock)
|
|
1726
|
+
return ssrfBlock;
|
|
1365
1727
|
if (!force) {
|
|
1366
1728
|
const store = getStore();
|
|
1367
|
-
|
|
1368
|
-
|
|
1729
|
+
// Cache key composes (source, url) so two distinct URLs sharing the same
|
|
1730
|
+
// `source` label do not collide — they each get their own cache slot
|
|
1731
|
+
// (commit 1f1243e regression test enforced).
|
|
1732
|
+
const cacheKey = composeFetchCacheKey(source, url);
|
|
1733
|
+
const meta = store.getSourceMeta(cacheKey);
|
|
1369
1734
|
if (meta) {
|
|
1370
1735
|
const indexedAt = new Date(meta.indexedAt + "Z"); // SQLite datetime is UTC without Z
|
|
1371
1736
|
const ageMs = Date.now() - indexedAt.getTime();
|
|
1372
|
-
|
|
1373
|
-
if (ageMs < TTL_MS) {
|
|
1737
|
+
if (ageMs < FETCH_TTL_MS) {
|
|
1374
1738
|
const ageHours = Math.floor(ageMs / (60 * 60 * 1000));
|
|
1375
1739
|
const ageMin = Math.floor(ageMs / (60 * 1000));
|
|
1376
1740
|
const ageStr = ageHours > 0 ? `${ageHours}h ago` : ageMin > 0 ? `${ageMin}m ago` : "just now";
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
sessionStats.cacheHits++;
|
|
1380
|
-
sessionStats.cacheBytesSaved += estimatedBytes;
|
|
1381
|
-
return trackResponse("ctx_fetch_and_index", {
|
|
1382
|
-
content: [{
|
|
1383
|
-
type: "text",
|
|
1384
|
-
text: `Cached: **${meta.label}** — ${meta.chunkCount} sections, indexed ${ageStr} (fresh, TTL: 24h).\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: "${meta.label}")`,
|
|
1385
|
-
}],
|
|
1386
|
-
});
|
|
1741
|
+
const estimatedBytes = meta.chunkCount * 1600; // ~1.6KB/chunk avg
|
|
1742
|
+
return { kind: "cached", label: meta.label, chunkCount: meta.chunkCount, estimatedBytes, ageStr };
|
|
1387
1743
|
}
|
|
1388
|
-
// Stale
|
|
1744
|
+
// Stale — fall through to re-fetch silently
|
|
1389
1745
|
}
|
|
1390
1746
|
}
|
|
1391
|
-
// Generate a unique temp file path for the subprocess to write fetched content.
|
|
1392
|
-
// This bypasses the executor's 100KB stdout truncation — content goes file→handler directly.
|
|
1393
1747
|
const outputPath = join(tmpdir(), `ctx-fetch-${Date.now()}-${Math.random().toString(36).slice(2)}.dat`);
|
|
1394
1748
|
try {
|
|
1395
1749
|
const fetchCode = buildFetchCode(url, outputPath);
|
|
@@ -1399,93 +1753,258 @@ server.registerTool("ctx_fetch_and_index", {
|
|
|
1399
1753
|
timeout: 30_000,
|
|
1400
1754
|
});
|
|
1401
1755
|
if (result.exitCode !== 0) {
|
|
1402
|
-
return
|
|
1403
|
-
content: [
|
|
1404
|
-
{
|
|
1405
|
-
type: "text",
|
|
1406
|
-
text: `Failed to fetch ${url}: ${result.stderr || result.stdout}`,
|
|
1407
|
-
},
|
|
1408
|
-
],
|
|
1409
|
-
isError: true,
|
|
1410
|
-
});
|
|
1756
|
+
return { kind: "fetch_error", url, error: result.stderr || result.stdout || "unknown error", reason: "exit" };
|
|
1411
1757
|
}
|
|
1412
|
-
// Parse content-type marker from stdout (content is in the temp file)
|
|
1413
|
-
const store = getStore();
|
|
1414
1758
|
const header = (result.stdout || "").trim();
|
|
1415
|
-
// Read full content from temp file
|
|
1416
1759
|
let markdown;
|
|
1417
1760
|
try {
|
|
1418
1761
|
markdown = readFileSync(outputPath, "utf-8").trim();
|
|
1419
1762
|
}
|
|
1420
1763
|
catch {
|
|
1421
|
-
return
|
|
1422
|
-
content: [
|
|
1423
|
-
{
|
|
1424
|
-
type: "text",
|
|
1425
|
-
text: `Fetched ${url} but could not read subprocess output`,
|
|
1426
|
-
},
|
|
1427
|
-
],
|
|
1428
|
-
isError: true,
|
|
1429
|
-
});
|
|
1764
|
+
return { kind: "fetch_error", url, error: "could not read subprocess output", reason: "read" };
|
|
1430
1765
|
}
|
|
1431
1766
|
if (markdown.length === 0) {
|
|
1767
|
+
return { kind: "fetch_error", url, error: "empty content", reason: "empty" };
|
|
1768
|
+
}
|
|
1769
|
+
return { kind: "fetched", url, source, markdown, header };
|
|
1770
|
+
}
|
|
1771
|
+
catch (err) {
|
|
1772
|
+
return {
|
|
1773
|
+
kind: "fetch_error",
|
|
1774
|
+
url,
|
|
1775
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1776
|
+
reason: "throw",
|
|
1777
|
+
};
|
|
1778
|
+
}
|
|
1779
|
+
finally {
|
|
1780
|
+
try {
|
|
1781
|
+
rmSync(outputPath);
|
|
1782
|
+
}
|
|
1783
|
+
catch { /* already gone */ }
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
/**
|
|
1787
|
+
* Serial-only indexing step — single FTS5 write per call. Caller loops over
|
|
1788
|
+
* fetched results and calls this one-at-a-time to avoid SQLite WAL contention
|
|
1789
|
+
* (PRD finding E).
|
|
1790
|
+
*/
|
|
1791
|
+
function indexFetched(f) {
|
|
1792
|
+
const store = getStore();
|
|
1793
|
+
// Storage label composed via composeFetchCacheKey so two URLs sharing a
|
|
1794
|
+
// `source` label do not overwrite each other (commit 1f1243e). ctx_search()
|
|
1795
|
+
// still finds both via LIKE-mode source filter on the `source` substring.
|
|
1796
|
+
const storageLabel = composeFetchCacheKey(f.source, f.url);
|
|
1797
|
+
let indexed;
|
|
1798
|
+
if (f.header === "__CM_CT__:json") {
|
|
1799
|
+
indexed = store.indexJSON(f.markdown, storageLabel);
|
|
1800
|
+
}
|
|
1801
|
+
else if (f.header === "__CM_CT__:text") {
|
|
1802
|
+
indexed = store.indexPlainText(f.markdown, storageLabel);
|
|
1803
|
+
}
|
|
1804
|
+
else {
|
|
1805
|
+
indexed = store.index({ content: f.markdown, source: storageLabel });
|
|
1806
|
+
}
|
|
1807
|
+
// Track AFTER the FTS5 write succeeds — failed indexes shouldn't inflate the counter.
|
|
1808
|
+
trackIndexed(Buffer.byteLength(f.markdown));
|
|
1809
|
+
const preview = f.markdown.length > FETCH_PREVIEW_LIMIT
|
|
1810
|
+
? f.markdown.slice(0, FETCH_PREVIEW_LIMIT) + "\n\n…[truncated — use ctx_search() for full content]"
|
|
1811
|
+
: f.markdown;
|
|
1812
|
+
return {
|
|
1813
|
+
label: indexed.label,
|
|
1814
|
+
totalChunks: indexed.totalChunks,
|
|
1815
|
+
totalBytes: Buffer.byteLength(f.markdown),
|
|
1816
|
+
preview,
|
|
1817
|
+
};
|
|
1818
|
+
}
|
|
1819
|
+
server.registerTool("ctx_fetch_and_index", {
|
|
1820
|
+
title: "Fetch & Index URL(s)",
|
|
1821
|
+
description: "Fetches URL content, converts HTML to markdown, indexes into searchable knowledge base, " +
|
|
1822
|
+
"and returns a ~3KB preview. Full content stays in sandbox — use ctx_search() for deeper lookups.\n\n" +
|
|
1823
|
+
"Better than WebFetch: preview is immediate, full content is searchable, raw HTML never enters context.\n\n" +
|
|
1824
|
+
"Content-type aware: HTML is converted to markdown, JSON is chunked by key paths, plain text is indexed directly.\n\n" +
|
|
1825
|
+
"PARALLELIZE I/O: For multi-URL research (library evaluation, migration scans, doc comparisons), pass `requests: [{url, source}, ...]` with `concurrency: 4-8` — speeds up by 3-5x on real workloads.\n" +
|
|
1826
|
+
" ✅ Use concurrency: 4-8 for: library docs sweep, multi-changelog scan, competitive pricing pages, multi-region docs, GitHub raw file pulls.\n" +
|
|
1827
|
+
" ❌ Single URL → use the legacy {url, source} shape (concurrency irrelevant).\n" +
|
|
1828
|
+
" Example: requests: [{url: 'https://react.dev/...', source: 'react'}, {url: 'https://vuejs.org/...', source: 'vue'}], concurrency: 5.\n" +
|
|
1829
|
+
" Indexing is serial regardless of concurrency — fetches race, FTS5 writes don't (avoids SQLite WAL contention).\n\n" +
|
|
1830
|
+
"When reporting results — terse like caveman. Technical substance exact. Only fluff die. Pattern: [thing] [action] [reason]. [next step].",
|
|
1831
|
+
inputSchema: z.object({
|
|
1832
|
+
url: z.string().optional().describe("Single URL to fetch and index (legacy single-shape)"),
|
|
1833
|
+
source: z
|
|
1834
|
+
.string()
|
|
1835
|
+
.optional()
|
|
1836
|
+
.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."),
|
|
1837
|
+
requests: z
|
|
1838
|
+
.array(z.object({
|
|
1839
|
+
url: z.string().describe("URL to fetch"),
|
|
1840
|
+
source: z.string().optional().describe("Label for this URL's indexed content"),
|
|
1841
|
+
}))
|
|
1842
|
+
.min(1)
|
|
1843
|
+
.optional()
|
|
1844
|
+
.describe("Batch shape: array of {url, source?} entries. Use with concurrency>1 for parallel fetch. " +
|
|
1845
|
+
"Each request indexed under its own source label. Output preserves input order."),
|
|
1846
|
+
concurrency: z
|
|
1847
|
+
.coerce.number()
|
|
1848
|
+
.int()
|
|
1849
|
+
.min(1)
|
|
1850
|
+
.max(8)
|
|
1851
|
+
.optional()
|
|
1852
|
+
.default(1)
|
|
1853
|
+
.describe("Max URLs to fetch in parallel (1-8, default: 1). " +
|
|
1854
|
+
"Use 4-8 for I/O-bound multi-URL batches (library docs, changelogs, pricing pages). " +
|
|
1855
|
+
"Capped by os.cpus().length on small machines (response notes when capped). " +
|
|
1856
|
+
"Indexing is always serial regardless — only fetches race."),
|
|
1857
|
+
force: z
|
|
1858
|
+
.boolean()
|
|
1859
|
+
.optional()
|
|
1860
|
+
.describe("Skip cache and re-fetch even if content was recently indexed"),
|
|
1861
|
+
}),
|
|
1862
|
+
}, async ({ url, source, requests, concurrency, force }) => {
|
|
1863
|
+
// Normalize input: legacy {url} or new {requests: [...]}.
|
|
1864
|
+
// requests wins when both are provided (explicit batch intent).
|
|
1865
|
+
const batch = requests
|
|
1866
|
+
? requests
|
|
1867
|
+
: url
|
|
1868
|
+
? [{ url, source }]
|
|
1869
|
+
: [];
|
|
1870
|
+
if (batch.length === 0) {
|
|
1871
|
+
return trackResponse("ctx_fetch_and_index", {
|
|
1872
|
+
content: [{
|
|
1873
|
+
type: "text",
|
|
1874
|
+
text: "ctx_fetch_and_index requires either `url` (single) or `requests: [{url, source?}, ...]` (batch).",
|
|
1875
|
+
}],
|
|
1876
|
+
isError: true,
|
|
1877
|
+
});
|
|
1878
|
+
}
|
|
1879
|
+
const isLegacySingle = !requests && batch.length === 1;
|
|
1880
|
+
const requestedConcurrency = concurrency ?? 1;
|
|
1881
|
+
// Parallel fetch via shared runPool primitive. capByCpuCount only for batch
|
|
1882
|
+
// — single-URL doesn't need the cap (only one job, executor is one subprocess).
|
|
1883
|
+
const jobs = batch.map((req) => ({
|
|
1884
|
+
run: () => fetchOneUrl(req.url, req.source, force),
|
|
1885
|
+
}));
|
|
1886
|
+
const { settled, effectiveConcurrency, capped } = await runPool(jobs, {
|
|
1887
|
+
concurrency: requestedConcurrency,
|
|
1888
|
+
capByCpuCount: !isLegacySingle && requestedConcurrency > 1,
|
|
1889
|
+
});
|
|
1890
|
+
const finalized = [];
|
|
1891
|
+
for (let i = 0; i < settled.length; i++) {
|
|
1892
|
+
const r = settled[i];
|
|
1893
|
+
if (r.status === "rejected") {
|
|
1894
|
+
const message = r.reason instanceof Error ? r.reason.message : String(r.reason);
|
|
1895
|
+
finalized.push({ kind: "job_error", url: batch[i].url, error: message });
|
|
1896
|
+
continue;
|
|
1897
|
+
}
|
|
1898
|
+
const v = r.value;
|
|
1899
|
+
if (v.kind === "cached") {
|
|
1900
|
+
sessionStats.cacheHits++;
|
|
1901
|
+
sessionStats.cacheBytesSaved += v.estimatedBytes;
|
|
1902
|
+
finalized.push({ kind: "cached", label: v.label, chunkCount: v.chunkCount, ageStr: v.ageStr });
|
|
1903
|
+
}
|
|
1904
|
+
else if (v.kind === "fetch_error") {
|
|
1905
|
+
finalized.push({ kind: "fetch_error", url: v.url, error: v.error, reason: v.reason });
|
|
1906
|
+
}
|
|
1907
|
+
else {
|
|
1908
|
+
// Serial FTS5 write here — no parallel store.index calls.
|
|
1909
|
+
finalized.push({ kind: "fetched", indexed: indexFetched(v) });
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
// Backward-compat single-URL response shape — preserve the EXACT original wording.
|
|
1913
|
+
if (isLegacySingle) {
|
|
1914
|
+
const r = finalized[0];
|
|
1915
|
+
if (r.kind === "cached") {
|
|
1432
1916
|
return trackResponse("ctx_fetch_and_index", {
|
|
1433
|
-
content: [
|
|
1434
|
-
{
|
|
1917
|
+
content: [{
|
|
1435
1918
|
type: "text",
|
|
1436
|
-
text: `
|
|
1437
|
-
},
|
|
1438
|
-
],
|
|
1439
|
-
isError: true,
|
|
1919
|
+
text: `Cached: **${r.label}** — ${r.chunkCount} sections, indexed ${r.ageStr} (fresh, TTL: 24h).\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}")`,
|
|
1920
|
+
}],
|
|
1440
1921
|
});
|
|
1441
1922
|
}
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1923
|
+
if (r.kind === "fetched") {
|
|
1924
|
+
const totalKB = (r.indexed.totalBytes / 1024).toFixed(1);
|
|
1925
|
+
const text = [
|
|
1926
|
+
`Fetched and indexed **${r.indexed.totalChunks} sections** (${totalKB}KB) from: ${r.indexed.label}`,
|
|
1927
|
+
`Full content indexed in sandbox — use ctx_search(queries: [...], source: "${r.indexed.label}") for specific lookups.`,
|
|
1928
|
+
"",
|
|
1929
|
+
"---",
|
|
1930
|
+
"",
|
|
1931
|
+
r.indexed.preview,
|
|
1932
|
+
].join("\n");
|
|
1933
|
+
return trackResponse("ctx_fetch_and_index", {
|
|
1934
|
+
content: [{ type: "text", text }],
|
|
1935
|
+
});
|
|
1447
1936
|
}
|
|
1448
|
-
|
|
1449
|
-
|
|
1937
|
+
// fetch_error — preserve original error wording per reason
|
|
1938
|
+
if (r.kind === "fetch_error") {
|
|
1939
|
+
const text = r.reason === "empty" ? `Fetched ${r.url} but got empty content`
|
|
1940
|
+
: r.reason === "read" ? `Fetched ${r.url} but could not read subprocess output`
|
|
1941
|
+
: r.reason === "exit" ? `Failed to fetch ${r.url}: ${r.error}`
|
|
1942
|
+
: /* throw */ `Fetch error: ${r.error}`;
|
|
1943
|
+
return trackResponse("ctx_fetch_and_index", {
|
|
1944
|
+
content: [{ type: "text", text }],
|
|
1945
|
+
isError: true,
|
|
1946
|
+
});
|
|
1450
1947
|
}
|
|
1451
|
-
|
|
1452
|
-
// HTML (default) — content is already converted to markdown
|
|
1453
|
-
indexed = store.index({ content: markdown, source: source ?? url });
|
|
1454
|
-
}
|
|
1455
|
-
// Build preview — first ~3KB of markdown for immediate use
|
|
1456
|
-
const PREVIEW_LIMIT = 3072;
|
|
1457
|
-
const preview = markdown.length > PREVIEW_LIMIT
|
|
1458
|
-
? markdown.slice(0, PREVIEW_LIMIT) + "\n\n…[truncated — use ctx_search() for full content]"
|
|
1459
|
-
: markdown;
|
|
1460
|
-
const totalKB = (Buffer.byteLength(markdown) / 1024).toFixed(1);
|
|
1461
|
-
const text = [
|
|
1462
|
-
`Fetched and indexed **${indexed.totalChunks} sections** (${totalKB}KB) from: ${indexed.label}`,
|
|
1463
|
-
`Full content indexed in sandbox — use ctx_search(queries: [...], source: "${indexed.label}") for specific lookups.`,
|
|
1464
|
-
"",
|
|
1465
|
-
"---",
|
|
1466
|
-
"",
|
|
1467
|
-
preview,
|
|
1468
|
-
].join("\n");
|
|
1469
|
-
return trackResponse("ctx_fetch_and_index", {
|
|
1470
|
-
content: [{ type: "text", text }],
|
|
1471
|
-
});
|
|
1472
|
-
}
|
|
1473
|
-
catch (err) {
|
|
1474
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1948
|
+
// job_error
|
|
1475
1949
|
return trackResponse("ctx_fetch_and_index", {
|
|
1476
|
-
content: [
|
|
1477
|
-
{ type: "text", text: `Fetch error: ${message}` },
|
|
1478
|
-
],
|
|
1950
|
+
content: [{ type: "text", text: `Fetch error: ${r.error}` }],
|
|
1479
1951
|
isError: true,
|
|
1480
1952
|
});
|
|
1481
1953
|
}
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1954
|
+
// Batch response — aggregated summary; isError only when EVERY URL failed.
|
|
1955
|
+
// Per-URL preview capped tightly so a 8-URL batch doesn't undo the
|
|
1956
|
+
// context-savings the tool exists to deliver (PRD review finding G1).
|
|
1957
|
+
const FETCH_BATCH_PREVIEW_LIMIT = 384; // ~3KB total for 8-URL batches
|
|
1958
|
+
const lines = [];
|
|
1959
|
+
let totalSections = 0;
|
|
1960
|
+
let totalBytes = 0;
|
|
1961
|
+
let cachedCount = 0;
|
|
1962
|
+
let fetchedCount = 0;
|
|
1963
|
+
let errorCount = 0;
|
|
1964
|
+
const snippets = [];
|
|
1965
|
+
for (const r of finalized) {
|
|
1966
|
+
if (r.kind === "cached") {
|
|
1967
|
+
cachedCount++;
|
|
1968
|
+
lines.push(`- [cache] ${r.label} — ${r.chunkCount} sections (${r.ageStr})`);
|
|
1969
|
+
}
|
|
1970
|
+
else if (r.kind === "fetched") {
|
|
1971
|
+
fetchedCount++;
|
|
1972
|
+
totalSections += r.indexed.totalChunks;
|
|
1973
|
+
totalBytes += r.indexed.totalBytes;
|
|
1974
|
+
const kb = (r.indexed.totalBytes / 1024).toFixed(1);
|
|
1975
|
+
lines.push(`- [new] ${r.indexed.label} — ${r.indexed.totalChunks} sections (${kb}KB)`);
|
|
1976
|
+
const snippet = r.indexed.preview.length > FETCH_BATCH_PREVIEW_LIMIT
|
|
1977
|
+
? r.indexed.preview.slice(0, FETCH_BATCH_PREVIEW_LIMIT).trimEnd() + "…"
|
|
1978
|
+
: r.indexed.preview;
|
|
1979
|
+
snippets.push(`### ${r.indexed.label}\n\n${snippet}`);
|
|
1486
1980
|
}
|
|
1487
|
-
|
|
1488
|
-
|
|
1981
|
+
else {
|
|
1982
|
+
errorCount++;
|
|
1983
|
+
lines.push(`- [err] ${r.url}: ${r.error}`);
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
const totalKB = (totalBytes / 1024).toFixed(1);
|
|
1987
|
+
const cappedNote = capped
|
|
1988
|
+
? ` cap=${effectiveConcurrency}/${cpus().length}cpu`
|
|
1989
|
+
: "";
|
|
1990
|
+
// Caveman style — terse status line: counts + sections + size.
|
|
1991
|
+
// Singular forms used at count=1 to avoid grammar drift ("1 errors" → "1 error").
|
|
1992
|
+
const fmt = (n, sing, plur) => `${n} ${n === 1 ? sing : plur}`;
|
|
1993
|
+
const headerLine = `fetched ${batch.length} c=${effectiveConcurrency}${cappedNote}. ` +
|
|
1994
|
+
`ok=${fetchedCount} cache=${cachedCount} err=${errorCount}. ` +
|
|
1995
|
+
`${fmt(totalSections, "section", "sections")} ${totalKB}KB.`;
|
|
1996
|
+
const text = [
|
|
1997
|
+
headerLine,
|
|
1998
|
+
"",
|
|
1999
|
+
...lines,
|
|
2000
|
+
"",
|
|
2001
|
+
`ctx_search(queries: [...], source: "<label>") for full content.`,
|
|
2002
|
+
...(snippets.length > 0 ? ["", "---", "", ...snippets] : []),
|
|
2003
|
+
].join("\n");
|
|
2004
|
+
return trackResponse("ctx_fetch_and_index", {
|
|
2005
|
+
content: [{ type: "text", text }],
|
|
2006
|
+
isError: errorCount === batch.length, // only mark error if every URL failed
|
|
2007
|
+
});
|
|
1489
2008
|
});
|
|
1490
2009
|
// ─────────────────────────────────────────────────────────
|
|
1491
2010
|
// Tool: batch_execute
|
|
@@ -1497,7 +2016,12 @@ server.registerTool("ctx_batch_execute", {
|
|
|
1497
2016
|
"THIS IS THE PRIMARY TOOL. Use this instead of multiple ctx_execute() calls.\n\n" +
|
|
1498
2017
|
"One ctx_batch_execute call replaces 30+ ctx_execute calls + 10+ ctx_search calls.\n" +
|
|
1499
2018
|
"Provide all commands to run and all queries to search — everything happens in one round trip.\n\n" +
|
|
1500
|
-
"
|
|
2019
|
+
"PARALLELIZE I/O: For I/O-bound batches (network calls, slow API queries, multi-URL fetches), ALWAYS pass concurrency: 4-8 — speeds up by 3-5x on real workloads.\n" +
|
|
2020
|
+
" ✅ Use concurrency: 4-8 for: gh API calls, curl/web fetches, multi-region cloud queries, multi-repo git reads, dig/DNS, docker inspect.\n" +
|
|
2021
|
+
" ❌ Keep concurrency: 1 for: npm test, build, lint, image processing (CPU-bound), or commands sharing state (ports, lock files, same-repo writes).\n" +
|
|
2022
|
+
" Example: [gh issue view 1, gh issue view 2, gh issue view 3] → concurrency: 3.\n" +
|
|
2023
|
+
" Speedup depends on workload — applies to I/O wait, not CPU work.\n\n" +
|
|
2024
|
+
"THINK IN CODE — NON-NEGOTIABLE: When commands produce data you need to analyze, count, filter, compare, or transform — add a processing command that runs JavaScript and console.log() ONLY the answer. NEVER pull raw output into context to reason over. Concurrency parallelizes the FETCH; THINK IN CODE owns the PROCESSING. One programmed analysis replaces ten read-and-reason rounds. Pure JavaScript, Node.js built-ins (fs, path, child_process), try/catch, null-safe.\n\n" +
|
|
1501
2025
|
"When reporting results — terse like caveman. Technical substance exact. Only fluff die. Pattern: [thing] [action] [reason]. [next step].",
|
|
1502
2026
|
inputSchema: z.object({
|
|
1503
2027
|
commands: z.preprocess(coerceCommandsArray, z
|
|
@@ -1510,7 +2034,8 @@ server.registerTool("ctx_batch_execute", {
|
|
|
1510
2034
|
.describe("Shell command to execute"),
|
|
1511
2035
|
}))
|
|
1512
2036
|
.min(1)
|
|
1513
|
-
.describe("Commands to execute as a batch.
|
|
2037
|
+
.describe("Commands to execute as a batch. Output is labeled with the section header. " +
|
|
2038
|
+
"Default order is sequential; pass concurrency>1 to run in parallel (output stays in input order).")),
|
|
1514
2039
|
queries: z.preprocess(coerceJsonArray, z
|
|
1515
2040
|
.array(z.string())
|
|
1516
2041
|
.min(1)
|
|
@@ -1520,10 +2045,21 @@ server.registerTool("ctx_batch_execute", {
|
|
|
1520
2045
|
timeout: z
|
|
1521
2046
|
.coerce.number()
|
|
1522
2047
|
.optional()
|
|
1523
|
-
.
|
|
1524
|
-
|
|
2048
|
+
.describe("Max execution time in ms. When omitted, no server-side timer fires — the MCP host's RPC timeout governs. With concurrency=1, the value (when set) is a shared budget across commands; with concurrency>1, it is applied per-command."),
|
|
2049
|
+
concurrency: z
|
|
2050
|
+
.coerce.number()
|
|
2051
|
+
.int()
|
|
2052
|
+
.min(1)
|
|
2053
|
+
.max(8)
|
|
2054
|
+
.optional()
|
|
2055
|
+
.default(1)
|
|
2056
|
+
.describe("Max commands to run in parallel (1-8, default: 1). " +
|
|
2057
|
+
"Use 4-8 for I/O-bound batches (network, gh, curl, multi-repo git reads). " +
|
|
2058
|
+
"Keep at 1 for CPU-bound (npm test, build, lint) or stateful commands (ports, locks). " +
|
|
2059
|
+
">1 switches to per-command timeouts (no shared budget) and " +
|
|
2060
|
+
"individual `(timed out)` blocks instead of cascading skip."),
|
|
1525
2061
|
}),
|
|
1526
|
-
}, async ({ commands, queries, timeout }) => {
|
|
2062
|
+
}, async ({ commands, queries, timeout, concurrency }) => {
|
|
1527
2063
|
// Security: check each command against deny patterns
|
|
1528
2064
|
for (const cmd of commands) {
|
|
1529
2065
|
const denied = checkDenyPolicy(cmd.command, "batch_execute");
|
|
@@ -1531,51 +2067,18 @@ server.registerTool("ctx_batch_execute", {
|
|
|
1531
2067
|
return denied;
|
|
1532
2068
|
}
|
|
1533
2069
|
try {
|
|
1534
|
-
// Execute each command individually so every command gets its own
|
|
1535
|
-
// output capture. Full stdout is preserved and indexed into FTS5.
|
|
1536
|
-
// (Issue #61, #197)
|
|
1537
|
-
const perCommandOutputs = [];
|
|
1538
|
-
const startTime = Date.now();
|
|
1539
|
-
let timedOut = false;
|
|
1540
2070
|
// Inject NODE_OPTIONS for FS read tracking in spawned Node processes.
|
|
1541
2071
|
// The executor denies NODE_OPTIONS in its env (security), so we set it
|
|
1542
2072
|
// as an inline shell prefix. This only affects child `node` invocations.
|
|
1543
2073
|
const nodeOptsPrefix = `NODE_OPTIONS="--require ${CM_FS_PRELOAD}" `;
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
const result = await executor.execute({
|
|
1553
|
-
language: "shell",
|
|
1554
|
-
code: `${nodeOptsPrefix}${cmd.command} 2>&1`,
|
|
1555
|
-
timeout: remaining,
|
|
1556
|
-
});
|
|
1557
|
-
let output = result.stdout || "(no output)";
|
|
1558
|
-
// Parse and strip __CM_FS__ markers emitted by the preload script.
|
|
1559
|
-
// Because 2>&1 merges stderr into stdout, markers appear in output.
|
|
1560
|
-
const fsMatches = output.matchAll(/__CM_FS__:(\d+)/g);
|
|
1561
|
-
let cmdFsBytes = 0;
|
|
1562
|
-
for (const m of fsMatches)
|
|
1563
|
-
cmdFsBytes += parseInt(m[1]);
|
|
1564
|
-
if (cmdFsBytes > 0) {
|
|
1565
|
-
sessionStats.bytesSandboxed += cmdFsBytes;
|
|
1566
|
-
output = output.replace(/__CM_FS__:\d+\n?/g, "");
|
|
1567
|
-
}
|
|
1568
|
-
perCommandOutputs.push(`# ${cmd.label}\n\n${output}\n`);
|
|
1569
|
-
if (result.timedOut) {
|
|
1570
|
-
timedOut = true;
|
|
1571
|
-
// Mark remaining commands as skipped
|
|
1572
|
-
const idx = commands.indexOf(cmd);
|
|
1573
|
-
for (let i = idx + 1; i < commands.length; i++) {
|
|
1574
|
-
perCommandOutputs.push(`# ${commands[i].label}\n\n(skipped — batch timeout exceeded)\n`);
|
|
1575
|
-
}
|
|
1576
|
-
break;
|
|
1577
|
-
}
|
|
1578
|
-
}
|
|
2074
|
+
// Full stdout is preserved per-command and indexed into FTS5 (Issue #61, #197).
|
|
2075
|
+
// Concurrency>1 switches to a worker pool with per-command timeouts.
|
|
2076
|
+
const { outputs: perCommandOutputs, timedOut } = await runBatchCommands(commands, {
|
|
2077
|
+
timeout,
|
|
2078
|
+
concurrency,
|
|
2079
|
+
nodeOptsPrefix,
|
|
2080
|
+
onFsBytes: (bytes) => { sessionStats.bytesSandboxed += bytes; },
|
|
2081
|
+
}, executor);
|
|
1579
2082
|
const stdout = perCommandOutputs.join("\n");
|
|
1580
2083
|
const totalBytes = Buffer.byteLength(stdout);
|
|
1581
2084
|
const totalLines = stdout.split("\n").length;
|
|
@@ -1678,24 +2181,37 @@ server.registerTool("ctx_stats", {
|
|
|
1678
2181
|
try {
|
|
1679
2182
|
const engine = new AnalyticsEngine(sdb);
|
|
1680
2183
|
const report = engine.queryAll(sessionStats);
|
|
1681
|
-
|
|
2184
|
+
// MCP usage is read-only and cheap; only available when DB exists.
|
|
2185
|
+
const mcpUsage = engine.getMcpToolUsage();
|
|
2186
|
+
// Lifetime stats span every project's SessionDB + auto-memory dir
|
|
2187
|
+
// (Bugs #3/#4); failures are absorbed inside getLifetimeStats so a
|
|
2188
|
+
// corrupt sidecar can never break ctx_stats.
|
|
2189
|
+
const lifetime = getLifetimeStats();
|
|
2190
|
+
text = formatReport(report, VERSION, _latestVersion, { lifetime, mcpUsage });
|
|
1682
2191
|
}
|
|
1683
2192
|
finally {
|
|
1684
2193
|
sdb.close();
|
|
1685
2194
|
}
|
|
1686
2195
|
}
|
|
1687
2196
|
else {
|
|
1688
|
-
// No session DB — build a minimal report from runtime stats only
|
|
2197
|
+
// No session DB — build a minimal report from runtime stats only.
|
|
2198
|
+
// Lifetime still meaningful (other projects, auto-memory) so include it.
|
|
1689
2199
|
const engine = new AnalyticsEngine(createMinimalDb());
|
|
1690
2200
|
const report = engine.queryAll(sessionStats);
|
|
1691
|
-
|
|
2201
|
+
const lifetime = getLifetimeStats();
|
|
2202
|
+
text = formatReport(report, VERSION, _latestVersion, { lifetime });
|
|
1692
2203
|
}
|
|
1693
2204
|
}
|
|
1694
2205
|
catch {
|
|
1695
2206
|
// Session DB not available or incompatible — build minimal report from runtime stats
|
|
1696
2207
|
const engine = new AnalyticsEngine(createMinimalDb());
|
|
1697
2208
|
const report = engine.queryAll(sessionStats);
|
|
1698
|
-
|
|
2209
|
+
let lifetime;
|
|
2210
|
+
try {
|
|
2211
|
+
lifetime = getLifetimeStats();
|
|
2212
|
+
}
|
|
2213
|
+
catch { /* never block ctx_stats */ }
|
|
2214
|
+
text = formatReport(report, VERSION, _latestVersion, lifetime ? { lifetime } : undefined);
|
|
1699
2215
|
}
|
|
1700
2216
|
return trackResponse("ctx_stats", {
|
|
1701
2217
|
content: [{ type: "text", text }],
|
|
@@ -1705,22 +2221,30 @@ server.registerTool("ctx_stats", {
|
|
|
1705
2221
|
server.registerTool("ctx_doctor", {
|
|
1706
2222
|
title: "Run Diagnostics",
|
|
1707
2223
|
description: "Diagnose context-mode installation. Runs all checks server-side and " +
|
|
1708
|
-
"returns
|
|
2224
|
+
"returns a plain-text status report with [OK]/[FAIL]/[WARN] prefixes " +
|
|
2225
|
+
"(renderer-safe across MCP clients). No CLI execution needed.",
|
|
1709
2226
|
inputSchema: z.object({}),
|
|
1710
2227
|
}, async () => {
|
|
1711
|
-
|
|
2228
|
+
// Renderer-safe output (Mickey #3 — Z.ai GLM 4.7 ReferenceError):
|
|
2229
|
+
// Z.ai's MCP renderer mounts a custom React component for GitHub-flavored
|
|
2230
|
+
// markdown task-list syntax (`- [x]` / `- [ ]` / `- [-]`) that depends on
|
|
2231
|
+
// a missing `client` context, throwing `ReferenceError: client is not
|
|
2232
|
+
// defined`. We avoid both task-list syntax AND `## ` h2 headings to stay
|
|
2233
|
+
// safe across all MCP renderers — using plain-text status prefixes
|
|
2234
|
+
// (`[OK]` / `[FAIL]` / `[WARN]`) instead.
|
|
2235
|
+
const lines = ["context-mode doctor", ""];
|
|
1712
2236
|
// __pkg_dir is build/ for tsc, plugin root for bundle — resolve to plugin root
|
|
1713
2237
|
const pluginRoot = existsSync(resolve(__pkg_dir, "package.json")) ? __pkg_dir : dirname(__pkg_dir);
|
|
1714
2238
|
// Runtimes
|
|
1715
2239
|
const total = 11;
|
|
1716
2240
|
const pct = ((available.length / total) * 100).toFixed(0);
|
|
1717
|
-
lines.push(
|
|
2241
|
+
lines.push(`[OK] Runtimes: ${available.length}/${total} (${pct}%) — ${available.join(", ")}`);
|
|
1718
2242
|
// Performance
|
|
1719
2243
|
if (hasBunRuntime()) {
|
|
1720
|
-
lines.push("
|
|
2244
|
+
lines.push("[OK] Performance: FAST (Bun)");
|
|
1721
2245
|
}
|
|
1722
2246
|
else {
|
|
1723
|
-
lines.push("
|
|
2247
|
+
lines.push("[WARN] Performance: NORMAL — install Bun for 3-5x speed boost");
|
|
1724
2248
|
}
|
|
1725
2249
|
// Server test — cleanup executor to prevent resource leaks (#247)
|
|
1726
2250
|
{
|
|
@@ -1728,15 +2252,15 @@ server.registerTool("ctx_doctor", {
|
|
|
1728
2252
|
try {
|
|
1729
2253
|
const result = await testExecutor.execute({ language: "javascript", code: 'console.log("ok");', timeout: 5000 });
|
|
1730
2254
|
if (result.exitCode === 0 && result.stdout.trim() === "ok") {
|
|
1731
|
-
lines.push("
|
|
2255
|
+
lines.push("[OK] Server test: PASS");
|
|
1732
2256
|
}
|
|
1733
2257
|
else {
|
|
1734
2258
|
const detail = result.stderr?.trim() ? ` (${result.stderr.trim().slice(0, 200)})` : "";
|
|
1735
|
-
lines.push(
|
|
2259
|
+
lines.push(`[FAIL] Server test: FAIL — exit ${result.exitCode}${detail}`);
|
|
1736
2260
|
}
|
|
1737
2261
|
}
|
|
1738
2262
|
catch (err) {
|
|
1739
|
-
lines.push(
|
|
2263
|
+
lines.push(`[FAIL] Server test: FAIL — ${err instanceof Error ? err.message : err}`);
|
|
1740
2264
|
}
|
|
1741
2265
|
finally {
|
|
1742
2266
|
testExecutor.cleanupBackgrounded();
|
|
@@ -1752,14 +2276,14 @@ server.registerTool("ctx_doctor", {
|
|
|
1752
2276
|
testDb.exec("INSERT INTO fts_test(content) VALUES ('hello world')");
|
|
1753
2277
|
const row = testDb.prepare("SELECT * FROM fts_test WHERE fts_test MATCH 'hello'").get();
|
|
1754
2278
|
if (row && row.content === "hello world") {
|
|
1755
|
-
lines.push("
|
|
2279
|
+
lines.push("[OK] FTS5 / SQLite: PASS — native module works");
|
|
1756
2280
|
}
|
|
1757
2281
|
else {
|
|
1758
|
-
lines.push("
|
|
2282
|
+
lines.push("[FAIL] FTS5 / SQLite: FAIL — unexpected result");
|
|
1759
2283
|
}
|
|
1760
2284
|
}
|
|
1761
2285
|
catch (err) {
|
|
1762
|
-
lines.push(
|
|
2286
|
+
lines.push(`[FAIL] FTS5 / SQLite: FAIL — ${err instanceof Error ? err.message : err}`);
|
|
1763
2287
|
}
|
|
1764
2288
|
finally {
|
|
1765
2289
|
try {
|
|
@@ -1771,13 +2295,13 @@ server.registerTool("ctx_doctor", {
|
|
|
1771
2295
|
// Hook script
|
|
1772
2296
|
const hookPath = resolve(pluginRoot, "hooks", "pretooluse.mjs");
|
|
1773
2297
|
if (existsSync(hookPath)) {
|
|
1774
|
-
lines.push(
|
|
2298
|
+
lines.push(`[OK] Hook script: PASS — ${hookPath}`);
|
|
1775
2299
|
}
|
|
1776
2300
|
else {
|
|
1777
|
-
lines.push(
|
|
2301
|
+
lines.push(`[FAIL] Hook script: FAIL — not found at ${hookPath}`);
|
|
1778
2302
|
}
|
|
1779
2303
|
// Version
|
|
1780
|
-
lines.push(
|
|
2304
|
+
lines.push(`[OK] Version: v${VERSION}`);
|
|
1781
2305
|
return trackResponse("ctx_doctor", {
|
|
1782
2306
|
content: [{ type: "text", text: lines.join("\n") }],
|
|
1783
2307
|
});
|
|
@@ -1985,6 +2509,13 @@ server.registerTool("ctx_purge", {
|
|
|
1985
2509
|
sessionStats.cacheBytesSaved = 0;
|
|
1986
2510
|
sessionStats.sessionStart = Date.now();
|
|
1987
2511
|
deleted.push("session stats");
|
|
2512
|
+
// Also drop the persisted stats file so external readers see a fresh state
|
|
2513
|
+
try {
|
|
2514
|
+
const statsFile = getStatsFilePath();
|
|
2515
|
+
if (existsSync(statsFile))
|
|
2516
|
+
unlinkSync(statsFile);
|
|
2517
|
+
}
|
|
2518
|
+
catch { /* best effort */ }
|
|
1988
2519
|
return trackResponse("ctx_purge", {
|
|
1989
2520
|
content: [{
|
|
1990
2521
|
type: "text",
|
|
@@ -2001,14 +2532,22 @@ server.registerTool("ctx_insight", {
|
|
|
2001
2532
|
"First run installs dependencies (~30s). Subsequent runs open instantly.",
|
|
2002
2533
|
inputSchema: z.object({
|
|
2003
2534
|
port: z.coerce.number().optional().describe("Port to serve on (default: 4747)"),
|
|
2535
|
+
sessionDir: z.string().optional().describe("Override INSIGHT_SESSION_DIR: directory containing context-mode session .db files"),
|
|
2536
|
+
contentDir: z.string().optional().describe("Override INSIGHT_CONTENT_DIR: directory containing context-mode content/index .db files"),
|
|
2537
|
+
insightSessionDir: z.string().optional().describe("Alias for sessionDir / INSIGHT_SESSION_DIR"),
|
|
2538
|
+
insightContentDir: z.string().optional().describe("Alias for contentDir / INSIGHT_CONTENT_DIR"),
|
|
2004
2539
|
}),
|
|
2005
|
-
}, async ({ port: userPort }) => {
|
|
2540
|
+
}, async ({ port: userPort, sessionDir, contentDir, insightSessionDir, insightContentDir }) => {
|
|
2006
2541
|
const port = userPort || 4747;
|
|
2542
|
+
const explicitSessionDir = sessionDir || insightSessionDir;
|
|
2543
|
+
const explicitContentDir = contentDir || insightContentDir;
|
|
2007
2544
|
// __pkg_dir is build/ for tsc, plugin root for bundle — resolve to plugin root
|
|
2008
2545
|
const pluginRoot = existsSync(resolve(__pkg_dir, "package.json")) ? __pkg_dir : dirname(__pkg_dir);
|
|
2009
2546
|
const insightSource = resolve(pluginRoot, "insight");
|
|
2010
|
-
// Use adapter-aware path
|
|
2011
|
-
|
|
2547
|
+
// Use adapter-aware path by default, but allow MCP callers to pass explicit
|
|
2548
|
+
// Insight data dirs for hosts whose adapter/default detection is unavailable.
|
|
2549
|
+
const sessDir = explicitSessionDir ? resolve(explicitSessionDir) : getSessionDir();
|
|
2550
|
+
const insightContentDirResolved = explicitContentDir ? resolve(explicitContentDir) : join(dirname(sessDir), "content");
|
|
2012
2551
|
const cacheDir = join(dirname(sessDir), "insight-cache");
|
|
2013
2552
|
// Verify source exists
|
|
2014
2553
|
if (!existsSync(join(insightSource, "server.mjs"))) {
|
|
@@ -2133,8 +2672,8 @@ server.registerTool("ctx_insight", {
|
|
|
2133
2672
|
env: {
|
|
2134
2673
|
...process.env,
|
|
2135
2674
|
PORT: String(port),
|
|
2136
|
-
INSIGHT_SESSION_DIR:
|
|
2137
|
-
INSIGHT_CONTENT_DIR:
|
|
2675
|
+
INSIGHT_SESSION_DIR: sessDir,
|
|
2676
|
+
INSIGHT_CONTENT_DIR: insightContentDirResolved,
|
|
2138
2677
|
INSIGHT_PARENT_PID: String(process.pid),
|
|
2139
2678
|
},
|
|
2140
2679
|
detached: true,
|
|
@@ -2231,6 +2770,15 @@ async function main() {
|
|
|
2231
2770
|
}
|
|
2232
2771
|
};
|
|
2233
2772
|
const gracefulShutdown = async () => {
|
|
2773
|
+
// Final stats flush — bypass throttle so the last 0-500ms of
|
|
2774
|
+
// bytes_indexed / bytes_returned aren't silently lost on SIGTERM/SIGINT
|
|
2775
|
+
// (PR #401 grill-me review B1: persistStats early-returns inside throttle
|
|
2776
|
+
// window; gracefulShutdown previously did NOT bypass).
|
|
2777
|
+
try {
|
|
2778
|
+
_lastStatsPersist = 0;
|
|
2779
|
+
persistStats();
|
|
2780
|
+
}
|
|
2781
|
+
catch { /* best effort — never block shutdown */ }
|
|
2234
2782
|
shutdown();
|
|
2235
2783
|
process.exit(0);
|
|
2236
2784
|
};
|