context-mode 1.0.151 → 1.0.153
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/.codex-plugin/mcp.json +5 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.openclaw-plugin/openclaw.plugin.json +16 -1
- package/.openclaw-plugin/package.json +1 -1
- package/README.md +89 -3
- package/build/adapters/claude-code/hooks.js +2 -2
- package/build/adapters/claude-code/index.js +14 -13
- package/build/adapters/client-map.js +3 -0
- package/build/adapters/detect.js +13 -1
- package/build/adapters/gemini-cli/hooks.d.ts +10 -0
- package/build/adapters/gemini-cli/hooks.js +12 -2
- package/build/adapters/gemini-cli/index.d.ts +21 -1
- package/build/adapters/gemini-cli/index.js +37 -1
- package/build/adapters/kimi/config.d.ts +8 -0
- package/build/adapters/kimi/config.js +8 -0
- package/build/adapters/kimi/hooks.d.ts +28 -0
- package/build/adapters/kimi/hooks.js +34 -0
- package/build/adapters/kimi/index.d.ts +66 -0
- package/build/adapters/kimi/index.js +537 -0
- package/build/adapters/kimi/paths.d.ts +1 -0
- package/build/adapters/kimi/paths.js +12 -0
- package/build/adapters/kiro/hooks.js +2 -2
- package/build/adapters/openclaw/plugin.d.ts +14 -13
- package/build/adapters/openclaw/plugin.js +140 -40
- package/build/adapters/opencode/plugin.js +4 -3
- package/build/adapters/opencode/zod3tov4.js +8 -8
- package/build/adapters/pi/extension.js +9 -24
- package/build/adapters/pi/mcp-bridge.js +37 -0
- package/build/adapters/qwen-code/index.js +7 -7
- package/build/adapters/types.d.ts +39 -2
- package/build/adapters/types.js +55 -2
- package/build/cli.js +433 -25
- package/build/executor.js +6 -3
- package/build/runtime.d.ts +81 -1
- package/build/runtime.js +195 -9
- package/build/search/ctx-search-schema.d.ts +90 -0
- package/build/search/ctx-search-schema.js +135 -0
- package/build/search/unified.d.ts +12 -0
- package/build/search/unified.js +17 -2
- package/build/server.d.ts +2 -1
- package/build/server.js +378 -97
- package/build/session/analytics.d.ts +36 -3
- package/build/session/analytics.js +88 -26
- package/build/session/db.d.ts +24 -0
- package/build/session/db.js +41 -0
- package/build/session/extract.js +30 -0
- package/build/session/snapshot.js +24 -0
- package/build/store.d.ts +12 -1
- package/build/store.js +72 -20
- package/build/types.d.ts +7 -0
- package/build/util/project-dir.d.ts +19 -16
- package/build/util/project-dir.js +80 -45
- package/cli.bundle.mjs +370 -319
- package/configs/kimi/hooks.json +54 -0
- package/configs/pi/AGENTS.md +3 -85
- package/hooks/cache-heal-utils.mjs +148 -0
- package/hooks/core/formatters.mjs +26 -0
- package/hooks/core/routing.mjs +9 -1
- package/hooks/core/stdin.mjs +74 -3
- package/hooks/core/tool-naming.mjs +1 -0
- package/hooks/heal-partial-install.mjs +712 -0
- package/hooks/kimi/platform.mjs +1 -0
- package/hooks/kimi/posttooluse.mjs +72 -0
- package/hooks/kimi/precompact.mjs +80 -0
- package/hooks/kimi/pretooluse.mjs +42 -0
- package/hooks/kimi/sessionend.mjs +61 -0
- package/hooks/kimi/sessionstart.mjs +113 -0
- package/hooks/kimi/stop.mjs +61 -0
- package/hooks/kimi/userpromptsubmit.mjs +90 -0
- package/hooks/normalize-hooks.mjs +66 -12
- package/hooks/routing-block.mjs +8 -2
- package/hooks/security.bundle.mjs +1 -1
- package/hooks/session-db.bundle.mjs +6 -4
- package/hooks/session-extract.bundle.mjs +2 -2
- package/hooks/session-helpers.mjs +93 -3
- package/hooks/session-snapshot.bundle.mjs +20 -19
- package/hooks/sessionstart.mjs +64 -0
- package/insight/server.mjs +15 -3
- package/openclaw.plugin.json +16 -1
- package/package.json +1 -1
- package/scripts/heal-installed-plugins.mjs +31 -10
- package/scripts/postinstall.mjs +10 -0
- package/server.bundle.mjs +206 -157
- package/skills/ctx-index/SKILL.md +46 -0
- package/skills/ctx-search/SKILL.md +35 -0
- package/start.mjs +84 -11
- package/build/cache-heal.d.ts +0 -48
- package/build/cache-heal.js +0 -150
- package/build/concurrency/runPool.d.ts +0 -36
- package/build/concurrency/runPool.js +0 -51
- package/build/openclaw/mcp-tools.d.ts +0 -54
- package/build/openclaw/mcp-tools.js +0 -198
- package/build/openclaw/workspace-router.d.ts +0 -29
- package/build/openclaw/workspace-router.js +0 -64
- package/build/openclaw-plugin.d.ts +0 -130
- package/build/openclaw-plugin.js +0 -626
- package/build/opencode-plugin.d.ts +0 -122
- package/build/opencode-plugin.js +0 -375
- package/build/pi-extension.d.ts +0 -14
- package/build/pi-extension.js +0 -451
- package/build/routing-block.d.ts +0 -8
- package/build/routing-block.js +0 -86
- package/build/tool-naming.d.ts +0 -4
- package/build/tool-naming.js +0 -24
package/build/server.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
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
|
-
import { existsSync, unlinkSync, readdirSync, readFileSync, writeFileSync, renameSync, rmSync, mkdirSync, cpSync, statSync, symlinkSync, lstatSync } from "node:fs";
|
|
5
|
+
import { existsSync, unlinkSync, readdirSync, readFileSync, writeFileSync, renameSync, rmSync, mkdirSync, cpSync, statSync, symlinkSync, lstatSync, realpathSync } from "node:fs";
|
|
6
6
|
import { execSync, spawnSync } from "node:child_process";
|
|
7
7
|
import { join, dirname, resolve, sep, isAbsolute } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
@@ -24,13 +24,14 @@ import { purgeSession } from "./session/purge.js";
|
|
|
24
24
|
import { emitCacheHitEvent, emitIndexWriteEvent, emitSandboxExecuteEvent, } from "./session/event-emit.js";
|
|
25
25
|
import { persistToolCallCounter, restoreSessionStats } from "./session/persist-tool-calls.js";
|
|
26
26
|
import { searchAllSources } from "./search/unified.js";
|
|
27
|
-
import {
|
|
27
|
+
import { buildCtxSearchInputSchema, CTX_SEARCH_SHARED_MODE, resolveProjectScope, } from "./search/ctx-search-schema.js";
|
|
28
|
+
import { buildNodeCommand, isInProcessPluginPlatform } from "./adapters/types.js";
|
|
28
29
|
import { detectPlatform, getSessionDirSegments } from "./adapters/detect.js";
|
|
29
30
|
import { getHookScriptPaths } from "./util/hook-config.js";
|
|
30
31
|
import { resolveClaudeConfigDir } from "./util/claude-config.js";
|
|
31
32
|
import { resolveProjectDir } from "./util/project-dir.js";
|
|
32
33
|
import { loadDatabase } from "./db-base.js";
|
|
33
|
-
import { AnalyticsEngine, formatReport, getConversationStats, getContentBytesAllSessions, getLifetimeStats, getMultiAdapterLifetimeStats, getRealBytesStats,
|
|
34
|
+
import { AnalyticsEngine, formatReport, getConversationStats, getContentBytesAllSessions, getLifetimeStats, getMultiAdapterLifetimeStats, getRealBytesStats, pricePerToken } from "./session/analytics.js";
|
|
34
35
|
const __pkg_dir = dirname(fileURLToPath(import.meta.url));
|
|
35
36
|
const VERSION = (() => {
|
|
36
37
|
for (const rel of ["../package.json", "./package.json"]) {
|
|
@@ -611,6 +612,7 @@ const sessionStats = {
|
|
|
611
612
|
bytesIndexed: 0,
|
|
612
613
|
bytesSandboxed: 0, // network I/O consumed inside sandbox (never enters context)
|
|
613
614
|
cacheHits: 0,
|
|
615
|
+
cacheMisses: 0, // ctx_fetch_and_index calls that bypassed the TTL cache
|
|
614
616
|
cacheBytesSaved: 0, // bytes avoided by TTL cache hits
|
|
615
617
|
sessionStart: Date.now(),
|
|
616
618
|
};
|
|
@@ -804,7 +806,7 @@ const STATS_PERSIST_THROTTLE_MS = 500;
|
|
|
804
806
|
// rendering missing fields (PR #401 architect review P1.3).
|
|
805
807
|
// v2: added tokens_saved_lifetime + dollars_saved_lifetime.
|
|
806
808
|
const STATS_SCHEMA_VERSION = 2;
|
|
807
|
-
//
|
|
809
|
+
// pricePerToken() intentionally NOT defined here — single source in
|
|
808
810
|
// src/session/analytics.ts re-exported above. (P1.1 — pricing constant dedup,
|
|
809
811
|
// PR #401 architect + ops 2-vote convergence.)
|
|
810
812
|
const LIFETIME_REFRESH_MS = 30_000;
|
|
@@ -820,8 +822,21 @@ let _lifetimeCache;
|
|
|
820
822
|
* (`pid-<parent pid>`), so a status line script can derive
|
|
821
823
|
* the same id from `$PPID` without coupling to MCP.
|
|
822
824
|
*/
|
|
825
|
+
// CLAUDE_SESSION_ID flows from the hosting process (Claude Code, pi, etc.)
|
|
826
|
+
// straight into a path.join, and path.join collapses ".." into the result,
|
|
827
|
+
// so a host env CLAUDE_SESSION_ID=../../evil writes "stats-evil.json" two
|
|
828
|
+
// levels above statsDir. The env var is not under direct MCP-tool-caller
|
|
829
|
+
// control, but in CI / multi-tenant contexts where the host env is partly
|
|
830
|
+
// influenceable this is an arbitrary-write primitive within the MCP server
|
|
831
|
+
// process's filesystem permissions. Constrain to a UUID-shaped charset
|
|
832
|
+
// before splicing into the stats filename.
|
|
833
|
+
const SESSION_ID_RE = /^[A-Za-z0-9._-]+$/;
|
|
834
|
+
function sanitizeSessionId(raw) {
|
|
835
|
+
return SESSION_ID_RE.test(raw) ? raw : `pid-${process.ppid}`;
|
|
836
|
+
}
|
|
823
837
|
function getStatsFilePath() {
|
|
824
|
-
const
|
|
838
|
+
const raw = process.env.CLAUDE_SESSION_ID || `pid-${process.ppid}`;
|
|
839
|
+
const sessionId = sanitizeSessionId(raw);
|
|
825
840
|
const statsDir = ensureWritableStorageDir(resolveStatsStorageDir(getDefaultSessionDir));
|
|
826
841
|
return join(statsDir, `stats-${sessionId}.json`);
|
|
827
842
|
}
|
|
@@ -872,12 +887,13 @@ function persistStats() {
|
|
|
872
887
|
total_processed: totalProcessed,
|
|
873
888
|
reduction_pct: reductionPct,
|
|
874
889
|
tokens_saved: tokensSaved,
|
|
875
|
-
// statusline-facing $ values — pre-computed at
|
|
876
|
-
//
|
|
877
|
-
//
|
|
878
|
-
|
|
890
|
+
// statusline-facing $ values — pre-computed at the current per-token
|
|
891
|
+
// rate (dynamic when PI_CONTEXT_MODE_PRICE_OUTPUT_PER_TOKEN is set by a
|
|
892
|
+
// Pi host; Opus $15/1M otherwise). Resolved on every persist via
|
|
893
|
+
// pricePerToken() so the env override picks up without an MCP restart.
|
|
894
|
+
dollars_saved_session: +(tokensSaved * pricePerToken()).toFixed(2),
|
|
879
895
|
tokens_saved_lifetime: lifetimeTokens,
|
|
880
|
-
dollars_saved_lifetime: +(lifetimeTokens *
|
|
896
|
+
dollars_saved_lifetime: +(lifetimeTokens * pricePerToken()).toFixed(2),
|
|
881
897
|
by_tool: Object.fromEntries(Object.keys({ ...sessionStats.calls, ...sessionStats.bytesReturned }).map((t) => [
|
|
882
898
|
t,
|
|
883
899
|
{
|
|
@@ -1080,15 +1096,20 @@ export function extractSnippet(content, query, maxLen = 1500, highlighted) {
|
|
|
1080
1096
|
}
|
|
1081
1097
|
return parts.join("\n\n");
|
|
1082
1098
|
}
|
|
1083
|
-
export function formatBatchQueryResults(store, queries, source, maxOutput = 80 * 1024) {
|
|
1099
|
+
export function formatBatchQueryResults(store, queries, source, maxOutput = 80 * 1024, scope = "batch") {
|
|
1084
1100
|
const sections = [];
|
|
1085
1101
|
let outputSize = 0;
|
|
1102
|
+
// When scope is "global", searchWithFallback receives `undefined` for the
|
|
1103
|
+
// source filter, which makes it query the entire persistent index instead
|
|
1104
|
+
// of only the chunks just produced by this batch's commands. Default
|
|
1105
|
+
// remains "batch" to preserve the historical behavior.
|
|
1106
|
+
const searchSource = scope === "global" ? undefined : source;
|
|
1086
1107
|
for (const query of queries) {
|
|
1087
1108
|
if (outputSize > maxOutput) {
|
|
1088
1109
|
sections.push(`## ${query}\n(output cap reached — use ctx_search(queries: ["${query}"]) for details)\n`);
|
|
1089
1110
|
continue;
|
|
1090
1111
|
}
|
|
1091
|
-
const results = store.searchWithFallback(query, 3,
|
|
1112
|
+
const results = store.searchWithFallback(query, 3, searchSource, undefined, "exact");
|
|
1092
1113
|
sections.push(`## ${query}`);
|
|
1093
1114
|
sections.push("");
|
|
1094
1115
|
if (results.length > 0) {
|
|
@@ -1104,7 +1125,12 @@ export function formatBatchQueryResults(store, queries, source, maxOutput = 80 *
|
|
|
1104
1125
|
sections.push("No matching sections found.");
|
|
1105
1126
|
sections.push("");
|
|
1106
1127
|
}
|
|
1107
|
-
|
|
1128
|
+
if (scope === "global") {
|
|
1129
|
+
sections.push(`\n> **Scope:** Queries searched the entire persistent index (query_scope: "global").`);
|
|
1130
|
+
}
|
|
1131
|
+
else {
|
|
1132
|
+
sections.push(`\n> **Tip:** Results are scoped to this batch only. To search across all indexed sources, use \`ctx_search(queries: [...])\` or call ctx_batch_execute with \`query_scope: "global"\`.`);
|
|
1133
|
+
}
|
|
1108
1134
|
return sections;
|
|
1109
1135
|
}
|
|
1110
1136
|
function quotePosixSingle(value) {
|
|
@@ -1125,7 +1151,42 @@ export function buildBatchNodeOptionsPrefix(shellPath, preloadPath) {
|
|
|
1125
1151
|
}
|
|
1126
1152
|
return `NODE_OPTIONS=${quotePosixSingle(option)} `;
|
|
1127
1153
|
}
|
|
1128
|
-
|
|
1154
|
+
/**
|
|
1155
|
+
* Per-section budget for the echoed `$ <command>` line so a 50KB heredoc
|
|
1156
|
+
* payload cannot dominate the response body. The full command always reaches
|
|
1157
|
+
* the executor — only the echo is clipped (Issues #717 + #736).
|
|
1158
|
+
*/
|
|
1159
|
+
const COMMAND_ECHO_MAX = 500;
|
|
1160
|
+
function truncateCommandForEcho(command) {
|
|
1161
|
+
const cleaned = command.replace(/\s+/g, " ").trim();
|
|
1162
|
+
if (cleaned.length <= COMMAND_ECHO_MAX)
|
|
1163
|
+
return cleaned;
|
|
1164
|
+
return cleaned.slice(0, COMMAND_ECHO_MAX) + "…";
|
|
1165
|
+
}
|
|
1166
|
+
/**
|
|
1167
|
+
* Per-call budget for the source-code echo prepended by `ctx_execute` and
|
|
1168
|
+
* `ctx_execute_file` (Issues #717 + #736). The full code always reaches the
|
|
1169
|
+
* sandbox — only the echo is clipped so massive payloads don't dominate
|
|
1170
|
+
* the response. Multi-line preserved (unlike command echo) so the user
|
|
1171
|
+
* sees the actual program shape.
|
|
1172
|
+
*/
|
|
1173
|
+
const CODE_ECHO_MAX = 2000;
|
|
1174
|
+
function truncateCodeForEcho(code) {
|
|
1175
|
+
if (code.length <= CODE_ECHO_MAX)
|
|
1176
|
+
return code;
|
|
1177
|
+
return code.slice(0, CODE_ECHO_MAX) + "\n… (truncated)";
|
|
1178
|
+
}
|
|
1179
|
+
/**
|
|
1180
|
+
* Build the source-code preamble surfaced before tool stdout. Provenance
|
|
1181
|
+
* survives in indexed chunks (FTS5 sees the fenced block) so later
|
|
1182
|
+
* ctx_search hits remember what ran.
|
|
1183
|
+
*/
|
|
1184
|
+
function buildExecuteEcho(language, code, path) {
|
|
1185
|
+
const header = path ? `path=${path}\n` : "";
|
|
1186
|
+
const fenced = `\`\`\`${language}\n${truncateCodeForEcho(code)}\n\`\`\``;
|
|
1187
|
+
return `${header}${fenced}\n\n`;
|
|
1188
|
+
}
|
|
1189
|
+
function formatCommandOutput(label, command, raw, onFsBytes) {
|
|
1129
1190
|
let output = raw || "(no output)";
|
|
1130
1191
|
const fsMatches = output.matchAll(/__CM_FS__:(\d+)/g);
|
|
1131
1192
|
let cmdFsBytes = 0;
|
|
@@ -1135,7 +1196,11 @@ function formatCommandOutput(label, raw, onFsBytes) {
|
|
|
1135
1196
|
onFsBytes?.(cmdFsBytes);
|
|
1136
1197
|
output = output.replace(/__CM_FS__:\d+\n?/g, "");
|
|
1137
1198
|
}
|
|
1138
|
-
|
|
1199
|
+
// Echo the executed command below the section heading so per-chunk
|
|
1200
|
+
// indexed content retains provenance for later ctx_search hits
|
|
1201
|
+
// (Issues #717 + #736).
|
|
1202
|
+
const echoed = truncateCommandForEcho(command);
|
|
1203
|
+
return `# ${label}\n\n$ ${echoed}\n\n${output}\n`;
|
|
1139
1204
|
}
|
|
1140
1205
|
function combineExecOutput(result) {
|
|
1141
1206
|
const stdout = result.stdout || "";
|
|
@@ -1180,7 +1245,7 @@ export async function runBatchCommands(commands, opts, executor) {
|
|
|
1180
1245
|
code: `${nodeOptsPrefix}${cmd.command}`,
|
|
1181
1246
|
timeout: perCmdTimeout,
|
|
1182
1247
|
});
|
|
1183
|
-
outputs.push(formatCommandOutput(cmd.label, combineExecOutput(result), onFsBytes));
|
|
1248
|
+
outputs.push(formatCommandOutput(cmd.label, cmd.command, combineExecOutput(result), onFsBytes));
|
|
1184
1249
|
if (result.timedOut) {
|
|
1185
1250
|
timedOut = true;
|
|
1186
1251
|
for (let j = i + 1; j < commands.length; j++) {
|
|
@@ -1203,7 +1268,7 @@ export async function runBatchCommands(commands, opts, executor) {
|
|
|
1203
1268
|
});
|
|
1204
1269
|
// Always route partial output through formatCommandOutput so __CM_FS__
|
|
1205
1270
|
// markers are stripped + counted, even when the command timed out.
|
|
1206
|
-
const formatted = formatCommandOutput(cmd.label, combineExecOutput(result), onFsBytes);
|
|
1271
|
+
const formatted = formatCommandOutput(cmd.label, cmd.command, combineExecOutput(result), onFsBytes);
|
|
1207
1272
|
const output = result.timedOut
|
|
1208
1273
|
? formatted.replace(/\n$/, "") + `\n(timed out after ${timeout ?? "?"}ms)\n`
|
|
1209
1274
|
: formatted;
|
|
@@ -1388,6 +1453,10 @@ __cm_main().catch(e=>{console.error(e);process.exitCode=1});${background ? '\nse
|
|
|
1388
1453
|
})(typeof require!=='undefined'?require:null);`;
|
|
1389
1454
|
}
|
|
1390
1455
|
const result = await executor.execute({ language, code: instrumentedCode, timeout, background });
|
|
1456
|
+
// Echo the executed source code before stdout so users can audit
|
|
1457
|
+
// and tooling can block command patterns (Issues #717 + #736).
|
|
1458
|
+
// Built from the user-supplied `code`, NOT the instrumented variant.
|
|
1459
|
+
const echo = buildExecuteEcho(language, code);
|
|
1391
1460
|
// Parse sandbox network metrics from stderr
|
|
1392
1461
|
const netMatch = result.stderr?.match(/__CM_NET__:(\d+)/);
|
|
1393
1462
|
if (netMatch) {
|
|
@@ -1409,7 +1478,7 @@ __cm_main().catch(e=>{console.error(e);process.exitCode=1});${background ? '\nse
|
|
|
1409
1478
|
content: [
|
|
1410
1479
|
{
|
|
1411
1480
|
type: "text",
|
|
1412
|
-
text: `${partialOutput}\n\n_(process backgrounded after ${timeout}ms — still running)_`,
|
|
1481
|
+
text: `${echo}${partialOutput}\n\n_(process backgrounded after ${timeout}ms — still running)_`,
|
|
1413
1482
|
},
|
|
1414
1483
|
],
|
|
1415
1484
|
});
|
|
@@ -1420,7 +1489,7 @@ __cm_main().catch(e=>{console.error(e);process.exitCode=1});${background ? '\nse
|
|
|
1420
1489
|
content: [
|
|
1421
1490
|
{
|
|
1422
1491
|
type: "text",
|
|
1423
|
-
text: `${partialOutput}\n\n_(timed out after ${timeout}ms — partial output shown above)_`,
|
|
1492
|
+
text: `${echo}${partialOutput}\n\n_(timed out after ${timeout}ms — partial output shown above)_`,
|
|
1424
1493
|
},
|
|
1425
1494
|
],
|
|
1426
1495
|
});
|
|
@@ -1429,7 +1498,7 @@ __cm_main().catch(e=>{console.error(e);process.exitCode=1});${background ? '\nse
|
|
|
1429
1498
|
content: [
|
|
1430
1499
|
{
|
|
1431
1500
|
type: "text",
|
|
1432
|
-
text:
|
|
1501
|
+
text: `${echo}Execution timed out after ${timeout}ms\n\nstderr:\n${result.stderr}`,
|
|
1433
1502
|
},
|
|
1434
1503
|
],
|
|
1435
1504
|
isError: true,
|
|
@@ -1443,7 +1512,7 @@ __cm_main().catch(e=>{console.error(e);process.exitCode=1});${background ? '\nse
|
|
|
1443
1512
|
trackIndexed(Buffer.byteLength(output));
|
|
1444
1513
|
return trackResponse("ctx_execute", {
|
|
1445
1514
|
content: [
|
|
1446
|
-
{ type: "text", text: intentSearch(output, intent, isError ? `execute:${language}:error` : `execute:${language}`) },
|
|
1515
|
+
{ type: "text", text: `${echo}${intentSearch(output, intent, isError ? `execute:${language}:error` : `execute:${language}`)}` },
|
|
1447
1516
|
],
|
|
1448
1517
|
isError,
|
|
1449
1518
|
});
|
|
@@ -1453,14 +1522,14 @@ __cm_main().catch(e=>{console.error(e);process.exitCode=1});${background ? '\nse
|
|
|
1453
1522
|
trackIndexed(Buffer.byteLength(output));
|
|
1454
1523
|
return trackResponse("ctx_execute", {
|
|
1455
1524
|
content: [
|
|
1456
|
-
{ type: "text", text: intentSearch(output, "errors failures exceptions", isError ? `execute:${language}:error` : `execute:${language}`) },
|
|
1525
|
+
{ type: "text", text: `${echo}${intentSearch(output, "errors failures exceptions", isError ? `execute:${language}:error` : `execute:${language}`)}` },
|
|
1457
1526
|
],
|
|
1458
1527
|
isError,
|
|
1459
1528
|
});
|
|
1460
1529
|
}
|
|
1461
1530
|
return trackResponse("ctx_execute", {
|
|
1462
1531
|
content: [
|
|
1463
|
-
{ type: "text", text: output },
|
|
1532
|
+
{ type: "text", text: `${echo}${output}` },
|
|
1464
1533
|
],
|
|
1465
1534
|
isError,
|
|
1466
1535
|
});
|
|
@@ -1471,17 +1540,25 @@ __cm_main().catch(e=>{console.error(e);process.exitCode=1});${background ? '\nse
|
|
|
1471
1540
|
trackIndexed(Buffer.byteLength(stdout));
|
|
1472
1541
|
return trackResponse("ctx_execute", {
|
|
1473
1542
|
content: [
|
|
1474
|
-
{ type: "text", text: intentSearch(stdout, intent, `execute:${language}`) },
|
|
1543
|
+
{ type: "text", text: `${echo}${intentSearch(stdout, intent, `execute:${language}`)}` },
|
|
1475
1544
|
],
|
|
1476
1545
|
});
|
|
1477
1546
|
}
|
|
1478
1547
|
// Auto-index large stdout into FTS5 — return pointer, not raw content
|
|
1479
1548
|
if (Buffer.byteLength(stdout) > LARGE_OUTPUT_THRESHOLD) {
|
|
1480
|
-
|
|
1549
|
+
const indexed = indexStdout(stdout, `execute:${language}`);
|
|
1550
|
+
// Prepend echo to the first text content so provenance still surfaces
|
|
1551
|
+
const echoed = {
|
|
1552
|
+
...indexed,
|
|
1553
|
+
content: indexed.content.map((c, i) => i === 0 && c.type === "text"
|
|
1554
|
+
? { ...c, text: `${echo}${c.text}` }
|
|
1555
|
+
: c),
|
|
1556
|
+
};
|
|
1557
|
+
return trackResponse("ctx_execute", echoed);
|
|
1481
1558
|
}
|
|
1482
1559
|
return trackResponse("ctx_execute", {
|
|
1483
1560
|
content: [
|
|
1484
|
-
{ type: "text", text: stdout },
|
|
1561
|
+
{ type: "text", text: `${echo}${stdout}` },
|
|
1485
1562
|
],
|
|
1486
1563
|
});
|
|
1487
1564
|
}
|
|
@@ -1638,12 +1715,15 @@ EXAMPLE: ctx_execute_file(path: "data.csv", language: "javascript", code: "const
|
|
|
1638
1715
|
code,
|
|
1639
1716
|
timeout,
|
|
1640
1717
|
});
|
|
1718
|
+
// Echo path + executed source code before stdout for audit/debug
|
|
1719
|
+
// (Issues #717 + #736).
|
|
1720
|
+
const echo = buildExecuteEcho(language, code, path);
|
|
1641
1721
|
if (result.timedOut) {
|
|
1642
1722
|
return trackResponse("ctx_execute_file", {
|
|
1643
1723
|
content: [
|
|
1644
1724
|
{
|
|
1645
1725
|
type: "text",
|
|
1646
|
-
text:
|
|
1726
|
+
text: `${echo}Timed out processing ${path} after ${timeout}ms`,
|
|
1647
1727
|
},
|
|
1648
1728
|
],
|
|
1649
1729
|
isError: true,
|
|
@@ -1657,7 +1737,7 @@ EXAMPLE: ctx_execute_file(path: "data.csv", language: "javascript", code: "const
|
|
|
1657
1737
|
trackIndexed(Buffer.byteLength(output));
|
|
1658
1738
|
return trackResponse("ctx_execute_file", {
|
|
1659
1739
|
content: [
|
|
1660
|
-
{ type: "text", text: intentSearch(output, intent, isError ? `file:${path}:error` : `file:${path}`) },
|
|
1740
|
+
{ type: "text", text: `${echo}${intentSearch(output, intent, isError ? `file:${path}:error` : `file:${path}`)}` },
|
|
1661
1741
|
],
|
|
1662
1742
|
isError,
|
|
1663
1743
|
});
|
|
@@ -1667,14 +1747,14 @@ EXAMPLE: ctx_execute_file(path: "data.csv", language: "javascript", code: "const
|
|
|
1667
1747
|
trackIndexed(Buffer.byteLength(output));
|
|
1668
1748
|
return trackResponse("ctx_execute_file", {
|
|
1669
1749
|
content: [
|
|
1670
|
-
{ type: "text", text: intentSearch(output, "errors failures exceptions", isError ? `file:${path}:error` : `file:${path}`) },
|
|
1750
|
+
{ type: "text", text: `${echo}${intentSearch(output, "errors failures exceptions", isError ? `file:${path}:error` : `file:${path}`)}` },
|
|
1671
1751
|
],
|
|
1672
1752
|
isError,
|
|
1673
1753
|
});
|
|
1674
1754
|
}
|
|
1675
1755
|
return trackResponse("ctx_execute_file", {
|
|
1676
1756
|
content: [
|
|
1677
|
-
{ type: "text", text: output },
|
|
1757
|
+
{ type: "text", text: `${echo}${output}` },
|
|
1678
1758
|
],
|
|
1679
1759
|
isError,
|
|
1680
1760
|
});
|
|
@@ -1684,17 +1764,24 @@ EXAMPLE: ctx_execute_file(path: "data.csv", language: "javascript", code: "const
|
|
|
1684
1764
|
trackIndexed(Buffer.byteLength(stdout));
|
|
1685
1765
|
return trackResponse("ctx_execute_file", {
|
|
1686
1766
|
content: [
|
|
1687
|
-
{ type: "text", text: intentSearch(stdout, intent, `file:${path}`) },
|
|
1767
|
+
{ type: "text", text: `${echo}${intentSearch(stdout, intent, `file:${path}`)}` },
|
|
1688
1768
|
],
|
|
1689
1769
|
});
|
|
1690
1770
|
}
|
|
1691
1771
|
// Auto-index large stdout into FTS5 — return pointer, not raw content
|
|
1692
1772
|
if (Buffer.byteLength(stdout) > LARGE_OUTPUT_THRESHOLD) {
|
|
1693
|
-
|
|
1773
|
+
const indexed = indexStdout(stdout, `file:${path}`);
|
|
1774
|
+
const echoed = {
|
|
1775
|
+
...indexed,
|
|
1776
|
+
content: indexed.content.map((c, i) => i === 0 && c.type === "text"
|
|
1777
|
+
? { ...c, text: `${echo}${c.text}` }
|
|
1778
|
+
: c),
|
|
1779
|
+
};
|
|
1780
|
+
return trackResponse("ctx_execute_file", echoed);
|
|
1694
1781
|
}
|
|
1695
1782
|
return trackResponse("ctx_execute_file", {
|
|
1696
1783
|
content: [
|
|
1697
|
-
{ type: "text", text: stdout },
|
|
1784
|
+
{ type: "text", text: `${echo}${stdout}` },
|
|
1698
1785
|
],
|
|
1699
1786
|
});
|
|
1700
1787
|
}
|
|
@@ -1780,6 +1867,35 @@ EXAMPLE: ctx_index(path: "/path/to/large-spec.md", source: "openapi-v2-spec")`,
|
|
|
1780
1867
|
// resolved path is a directory, walk it bounded and re-enter `index()`
|
|
1781
1868
|
// per-file so the security gate at store.ts:845 (TOCTOU defense from
|
|
1782
1869
|
// #442 round-3) keeps running for every file.
|
|
1870
|
+
//
|
|
1871
|
+
// Root-level symlink defense: the deny-glob check above ran on the
|
|
1872
|
+
// user-supplied `path`. If `path` is a symlink whose target lands in
|
|
1873
|
+
// a sensitive directory (e.g. `/tmp/link -> /etc`), statSync would
|
|
1874
|
+
// happily report directory and walkDirectoryDetailed would
|
|
1875
|
+
// realpathSync internally, walking /etc with the user's deny globs
|
|
1876
|
+
// bound to /tmp/link instead of the real target. Detect the symlink
|
|
1877
|
+
// with lstatSync, follow it once, and re-apply the deny check
|
|
1878
|
+
// against the realpath so the user's deny globs see the actual
|
|
1879
|
+
// walk root.
|
|
1880
|
+
if (resolvedPath && existsSync(resolvedPath)) {
|
|
1881
|
+
const lst = lstatSync(resolvedPath);
|
|
1882
|
+
if (lst.isSymbolicLink()) {
|
|
1883
|
+
let realTarget;
|
|
1884
|
+
try {
|
|
1885
|
+
realTarget = realpathSync(resolvedPath);
|
|
1886
|
+
}
|
|
1887
|
+
catch {
|
|
1888
|
+
return trackResponse("ctx_index", {
|
|
1889
|
+
content: [{ type: "text", text: "Error: symlink target could not be resolved." }],
|
|
1890
|
+
});
|
|
1891
|
+
}
|
|
1892
|
+
if (realTarget !== resolvedPath) {
|
|
1893
|
+
const realDenied = checkFilePathDenyPolicy(realTarget, "ctx_index");
|
|
1894
|
+
if (realDenied)
|
|
1895
|
+
return realDenied;
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1783
1899
|
if (resolvedPath && existsSync(resolvedPath) && statSync(resolvedPath).isDirectory()) {
|
|
1784
1900
|
const store = getStore();
|
|
1785
1901
|
const projectDir = getProjectDir();
|
|
@@ -1858,12 +1974,24 @@ EXAMPLE: ctx_index(path: "/path/to/large-spec.md", source: "openapi-v2-spec")`,
|
|
|
1858
1974
|
// ─────────────────────────────────────────────────────────
|
|
1859
1975
|
// Tool: search — progressive throttling
|
|
1860
1976
|
// ─────────────────────────────────────────────────────────
|
|
1861
|
-
// Track search calls per
|
|
1977
|
+
// Track search calls per N-second window for progressive throttling.
|
|
1978
|
+
// Defaults preserve the historical behavior (60s window, soft-cap at 3
|
|
1979
|
+
// calls, hard-block at 8). All three thresholds are overridable via env
|
|
1980
|
+
// vars so users can loosen or tighten the policy without forking. Invalid
|
|
1981
|
+
// values (non-positive numbers, NaN) fall back to the default to avoid
|
|
1982
|
+
// silently disabling the protection.
|
|
1983
|
+
function readPositiveEnv(name, defaultValue) {
|
|
1984
|
+
const raw = process.env[name];
|
|
1985
|
+
if (!raw)
|
|
1986
|
+
return defaultValue;
|
|
1987
|
+
const parsed = Number(raw);
|
|
1988
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultValue;
|
|
1989
|
+
}
|
|
1862
1990
|
let searchCallCount = 0;
|
|
1863
1991
|
let searchWindowStart = Date.now();
|
|
1864
|
-
const SEARCH_WINDOW_MS = 60_000;
|
|
1865
|
-
const SEARCH_MAX_RESULTS_AFTER = 3; // after
|
|
1866
|
-
const SEARCH_BLOCK_AFTER = 8; // after
|
|
1992
|
+
const SEARCH_WINDOW_MS = readPositiveEnv("CONTEXT_MODE_SEARCH_WINDOW_MS", 60_000);
|
|
1993
|
+
const SEARCH_MAX_RESULTS_AFTER = readPositiveEnv("CONTEXT_MODE_SEARCH_MAX_RESULTS_AFTER", 3); // after N calls: 1 result per query
|
|
1994
|
+
const SEARCH_BLOCK_AFTER = readPositiveEnv("CONTEXT_MODE_SEARCH_BLOCK_AFTER", 8); // after N calls: refuse, demand batching
|
|
1867
1995
|
/**
|
|
1868
1996
|
* Defensive coercion: parse stringified JSON arrays, AND lift a bare
|
|
1869
1997
|
* non-empty string into a single-element array.
|
|
@@ -1948,45 +2076,17 @@ WHEN NOT:
|
|
|
1948
2076
|
- 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
|
|
1949
2077
|
|
|
1950
2078
|
RETURNS:
|
|
1951
|
-
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.
|
|
2079
|
+
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. Each response carries a throttle counter (call #N/M in the rolling time window); results taper toward the soft cap and calls block after the hard cap. Tune via CONTEXT_MODE_SEARCH_WINDOW_MS, CONTEXT_MODE_SEARCH_MAX_RESULTS_AFTER, CONTEXT_MODE_SEARCH_BLOCK_AFTER.
|
|
1952
2080
|
|
|
1953
2081
|
EXAMPLE: ctx_search(queries: ["root cause", "proposed fix", "test coverage"], source: "issue-#683")
|
|
1954
2082
|
EXAMPLE: ctx_search(queries: ["what did we decide about caching"], source: "decision", sort: "timeline")
|
|
1955
2083
|
EXAMPLE: ctx_search(queries: ["useEffect cleanup pattern"], source: "react-docs", contentType: "code", limit: 5)
|
|
1956
2084
|
EXAMPLE: ctx_search(queries: ["last user prompt", "active skills", "open blockers"], sort: "timeline")`,
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
// limit: z.coerce.number() (not z.number()) — OpenCode's native
|
|
1963
|
-
// plugin path delivers tool args straight from the LLM provider's
|
|
1964
|
-
// tool-call JSON, where several providers stringify primitives
|
|
1965
|
-
// (limit:"4" instead of limit:4). Since v1.0.139 / #621 we run
|
|
1966
|
-
// inputSchema.parse() on that path, so a plain z.number() rejects
|
|
1967
|
-
// "4" with "Expected number, received string". z.coerce mirrors what
|
|
1968
|
-
// ctx_batch_execute / ctx_fetch_and_index / ctx_execute already do.
|
|
1969
|
-
// Fixes #627.
|
|
1970
|
-
limit: z
|
|
1971
|
-
.coerce.number()
|
|
1972
|
-
.optional()
|
|
1973
|
-
.default(3)
|
|
1974
|
-
.describe("Results per query (default: 3)"),
|
|
1975
|
-
source: z
|
|
1976
|
-
.string()
|
|
1977
|
-
.optional()
|
|
1978
|
-
.describe("Filter to a specific indexed source (partial match)."),
|
|
1979
|
-
contentType: z
|
|
1980
|
-
.enum(["code", "prose"])
|
|
1981
|
-
.optional()
|
|
1982
|
-
.describe("Filter results by content type: 'code' or 'prose'."),
|
|
1983
|
-
sort: z
|
|
1984
|
-
.enum(["relevance", "timeline"])
|
|
1985
|
-
.optional()
|
|
1986
|
-
.default("relevance")
|
|
1987
|
-
.describe("Sort mode. 'relevance' (default): BM25 ranked, current session only. " +
|
|
1988
|
-
"'timeline': chronological across current session, prior sessions, and auto-memory."),
|
|
1989
|
-
}),
|
|
2085
|
+
// Schema construction is centralised in `src/search/ctx-search-schema.ts`
|
|
2086
|
+
// so the conditional `project` field (only registered when the host runs
|
|
2087
|
+
// in shared-DB mode, `CONTEXT_MODE_PROJECT_DIR` set at module load) is a
|
|
2088
|
+
// hard property of the tool surface — not a runtime hint. Fixes #737.
|
|
2089
|
+
inputSchema: buildCtxSearchInputSchema(CTX_SEARCH_SHARED_MODE),
|
|
1990
2090
|
}, async (params) => {
|
|
1991
2091
|
try {
|
|
1992
2092
|
const store = getStore();
|
|
@@ -2023,7 +2123,12 @@ EXAMPLE: ctx_search(queries: ["last user prompt", "active skills", "open blocker
|
|
|
2023
2123
|
isError: true,
|
|
2024
2124
|
});
|
|
2025
2125
|
}
|
|
2026
|
-
const { limit = 3, source, contentType } = params;
|
|
2126
|
+
const { limit = 3, source, contentType, project } = params;
|
|
2127
|
+
// Resolve the per-project scope (#737). When shared-DB mode is off the
|
|
2128
|
+
// resolver returns `undefined` and `project` is silently ignored — the
|
|
2129
|
+
// per-project DB is naturally isolated by directory hash, so there is
|
|
2130
|
+
// nothing for an in-process filter to do.
|
|
2131
|
+
const projectScope = resolveProjectScope(project, CTX_SEARCH_SHARED_MODE, () => getProjectDir());
|
|
2027
2132
|
// Progressive throttling: track calls in time window
|
|
2028
2133
|
const now = Date.now();
|
|
2029
2134
|
if (now - searchWindowStart > SEARCH_WINDOW_MS) {
|
|
@@ -2050,9 +2155,13 @@ EXAMPLE: ctx_search(queries: ["last user prompt", "active skills", "open blocker
|
|
|
2050
2155
|
const MAX_TOTAL = 40 * 1024; // 40KB total cap
|
|
2051
2156
|
let totalSize = 0;
|
|
2052
2157
|
const sections = [];
|
|
2053
|
-
// Open SessionDB once before the loop (Blocker 4: avoid open/close per query)
|
|
2158
|
+
// Open SessionDB once before the loop (Blocker 4: avoid open/close per query).
|
|
2159
|
+
// Issue #737: also open in relevance mode when a string `projectScope`
|
|
2160
|
+
// is in play — the 2-step IN-clause needs SessionDB to translate
|
|
2161
|
+
// `project_dir` → allow-set of session ids for the ContentStore filter.
|
|
2054
2162
|
let timelineDB = null;
|
|
2055
|
-
|
|
2163
|
+
const needsSessionDB = sort === "timeline" || typeof projectScope === "string";
|
|
2164
|
+
if (needsSessionDB) {
|
|
2056
2165
|
try {
|
|
2057
2166
|
const sessionsDir = getSessionDir();
|
|
2058
2167
|
const projectDir = getProjectDir();
|
|
@@ -2063,6 +2172,17 @@ EXAMPLE: ctx_search(queries: ["last user prompt", "active skills", "open blocker
|
|
|
2063
2172
|
}
|
|
2064
2173
|
catch { /* SessionDB unavailable — search ContentStore + auto-memory only */ }
|
|
2065
2174
|
}
|
|
2175
|
+
// Resolve the session-id allow-set once for the relevance-mode path —
|
|
2176
|
+
// searchAllSources resolves its own copy for timeline mode. Empty set
|
|
2177
|
+
// is preserved (means "no events for this project"), which surfaces
|
|
2178
|
+
// only legacy `session_id=''` chunks via the post-filter.
|
|
2179
|
+
let relevanceAllowSet;
|
|
2180
|
+
if (typeof projectScope === "string" && timelineDB) {
|
|
2181
|
+
try {
|
|
2182
|
+
relevanceAllowSet = new Set(timelineDB.getSessionIdsForProject(projectScope));
|
|
2183
|
+
}
|
|
2184
|
+
catch { /* best-effort */ }
|
|
2185
|
+
}
|
|
2066
2186
|
const configDir = _detectedAdapter?.getConfigDir() ?? resolveClaudeConfigDir();
|
|
2067
2187
|
try {
|
|
2068
2188
|
for (const q of queryList) {
|
|
@@ -2083,10 +2203,11 @@ EXAMPLE: ctx_search(queries: ["last user prompt", "active skills", "open blocker
|
|
|
2083
2203
|
projectDir: getProjectDir(),
|
|
2084
2204
|
configDir,
|
|
2085
2205
|
adapter: _detectedAdapter ?? undefined,
|
|
2206
|
+
projectScope,
|
|
2086
2207
|
});
|
|
2087
2208
|
}
|
|
2088
2209
|
else {
|
|
2089
|
-
results = store.searchWithFallback(q, effectiveLimit, source, contentType);
|
|
2210
|
+
results = store.searchWithFallback(q, effectiveLimit, source, contentType, "like", relevanceAllowSet);
|
|
2090
2211
|
}
|
|
2091
2212
|
if (results.length === 0) {
|
|
2092
2213
|
sections.push(`## ${q}\nNo results found.`);
|
|
@@ -2117,12 +2238,22 @@ EXAMPLE: ctx_search(queries: ["last user prompt", "active skills", "open blocker
|
|
|
2117
2238
|
if (store.lastRefreshCount > 0) {
|
|
2118
2239
|
output = `> Auto-refreshed ${store.lastRefreshCount} stale source${store.lastRefreshCount > 1 ? "s" : ""} (file changed since indexing).\n\n` + output;
|
|
2119
2240
|
}
|
|
2120
|
-
//
|
|
2241
|
+
// Throttle counter — always surfaced so agents can pace themselves
|
|
2242
|
+
// proactively instead of discovering the limit only after results are
|
|
2243
|
+
// already truncated. Soft warning after SEARCH_MAX_RESULTS_AFTER calls;
|
|
2244
|
+
// gentle informational line before that.
|
|
2245
|
+
const throttleRemaining = Math.max(0, SEARCH_BLOCK_AFTER - searchCallCount);
|
|
2246
|
+
const softCapRemaining = Math.max(0, SEARCH_MAX_RESULTS_AFTER - searchCallCount);
|
|
2121
2247
|
if (searchCallCount >= SEARCH_MAX_RESULTS_AFTER) {
|
|
2122
2248
|
output += `\n\n⚠ search call #${searchCallCount}/${SEARCH_BLOCK_AFTER} in this window. ` +
|
|
2123
|
-
`Results limited to ${effectiveLimit}/query. ` +
|
|
2249
|
+
`Results limited to ${effectiveLimit}/query. ${throttleRemaining} call(s) remaining before block. ` +
|
|
2124
2250
|
`Batch queries: ctx_search(queries: ["q1","q2","q3"]) or use ctx_batch_execute.`;
|
|
2125
2251
|
}
|
|
2252
|
+
else {
|
|
2253
|
+
output += `\n\n> Throttle: call #${searchCallCount}/${SEARCH_BLOCK_AFTER} in this window. ` +
|
|
2254
|
+
`${softCapRemaining} call(s) before soft cap. ` +
|
|
2255
|
+
`Prefer ctx_search(queries: [...]) array form for multi-query workloads — it counts as a single call.`;
|
|
2256
|
+
}
|
|
2126
2257
|
if (output.trim().length === 0) {
|
|
2127
2258
|
const sources = store.listSources();
|
|
2128
2259
|
const sourceList = sources.length > 0
|
|
@@ -2390,6 +2521,25 @@ async function fetchWithManualRedirect(initialUrl) {
|
|
|
2390
2521
|
throw new Error('SSRF blocked: redirect chain exceeded ' + MAX_REDIRECTS + ' hops');
|
|
2391
2522
|
}
|
|
2392
2523
|
|
|
2524
|
+
// Subprocess response-body size cap. A malicious or unexpectedly large
|
|
2525
|
+
// endpoint reachable through ctx_fetch_and_index would otherwise stream
|
|
2526
|
+
// gigabytes into resp.text(), then into outputPath, then into the parent
|
|
2527
|
+
// MCP server's heap via readFileSync. 50 MB is far above typical web
|
|
2528
|
+
// page / API response sizes (~1-5 MB) but bounded enough to keep parent
|
|
2529
|
+
// heap survivable. Cap both early via Content-Length and after the read.
|
|
2530
|
+
const MAX_FETCH_BYTES = 50 * 1024 * 1024;
|
|
2531
|
+
async function safeText(resp) {
|
|
2532
|
+
const cl = parseInt(resp.headers.get('content-length') || '0', 10);
|
|
2533
|
+
if (cl > MAX_FETCH_BYTES) {
|
|
2534
|
+
throw new Error('Response too large: Content-Length ' + cl + ' exceeds ' + MAX_FETCH_BYTES);
|
|
2535
|
+
}
|
|
2536
|
+
const text = await resp.text();
|
|
2537
|
+
if (text.length > MAX_FETCH_BYTES) {
|
|
2538
|
+
throw new Error('Response too large: ' + text.length + ' bytes exceeds ' + MAX_FETCH_BYTES);
|
|
2539
|
+
}
|
|
2540
|
+
return text;
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2393
2543
|
async function main() {
|
|
2394
2544
|
const resp = await fetchWithManualRedirect(url);
|
|
2395
2545
|
if (!resp.ok) { console.error("HTTP " + resp.status); process.exit(1); }
|
|
@@ -2397,7 +2547,7 @@ async function main() {
|
|
|
2397
2547
|
|
|
2398
2548
|
// --- JSON responses ---
|
|
2399
2549
|
if (contentType.includes('application/json') || contentType.includes('+json')) {
|
|
2400
|
-
const text = await resp
|
|
2550
|
+
const text = await safeText(resp);
|
|
2401
2551
|
try {
|
|
2402
2552
|
const pretty = JSON.stringify(JSON.parse(text), null, 2);
|
|
2403
2553
|
emit('json', pretty);
|
|
@@ -2409,7 +2559,7 @@ async function main() {
|
|
|
2409
2559
|
|
|
2410
2560
|
// --- HTML responses (default for text/html, application/xhtml+xml) ---
|
|
2411
2561
|
if (contentType.includes('text/html') || contentType.includes('application/xhtml')) {
|
|
2412
|
-
const html = await resp
|
|
2562
|
+
const html = await safeText(resp);
|
|
2413
2563
|
const td = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });
|
|
2414
2564
|
td.use(gfm);
|
|
2415
2565
|
td.remove(['script', 'style', 'nav', 'header', 'footer', 'noscript']);
|
|
@@ -2418,7 +2568,7 @@ async function main() {
|
|
|
2418
2568
|
}
|
|
2419
2569
|
|
|
2420
2570
|
// --- Everything else: plain text, CSV, XML, etc. ---
|
|
2421
|
-
const text = await resp
|
|
2571
|
+
const text = await safeText(resp);
|
|
2422
2572
|
emit('text', text);
|
|
2423
2573
|
}
|
|
2424
2574
|
main();
|
|
@@ -2656,6 +2806,16 @@ async function fetchOneUrl(url, source, force, ttl) {
|
|
|
2656
2806
|
const header = (result.stdout || "").trim();
|
|
2657
2807
|
let markdown;
|
|
2658
2808
|
try {
|
|
2809
|
+
// Parent-side defense-in-depth on the subprocess output size. The
|
|
2810
|
+
// embedded safeText() in buildFetchCode already caps before writing,
|
|
2811
|
+
// but a torn write (subprocess killed mid-write, fs cache desync,
|
|
2812
|
+
// etc.) could still leave an oversized file. Bail before slurping
|
|
2813
|
+
// multiple gigabytes into the long-running MCP server's heap.
|
|
2814
|
+
const MAX_FETCH_OUTPUT_BYTES = 50 * 1024 * 1024;
|
|
2815
|
+
const fileSize = statSync(outputPath).size;
|
|
2816
|
+
if (fileSize > MAX_FETCH_OUTPUT_BYTES) {
|
|
2817
|
+
return { kind: "fetch_error", url, error: `subprocess output ${fileSize} bytes exceeds cap ${MAX_FETCH_OUTPUT_BYTES}`, reason: "read" };
|
|
2818
|
+
}
|
|
2659
2819
|
markdown = readFileSync(outputPath, "utf-8").trim();
|
|
2660
2820
|
}
|
|
2661
2821
|
catch {
|
|
@@ -2832,6 +2992,10 @@ EXAMPLE: ctx_fetch_and_index(
|
|
|
2832
2992
|
}
|
|
2833
2993
|
else {
|
|
2834
2994
|
// Serial FTS5 write here — no parallel store.index calls.
|
|
2995
|
+
// Cache miss: the URL was not in the TTL window so we paid the
|
|
2996
|
+
// network round-trip + re-indexed. Counted here so ctx_stats can
|
|
2997
|
+
// report nominal cache_hit_rate alongside the existing hit metrics.
|
|
2998
|
+
sessionStats.cacheMisses++;
|
|
2835
2999
|
finalized.push({ kind: "fetched", indexed: indexFetched(v) });
|
|
2836
3000
|
}
|
|
2837
3001
|
}
|
|
@@ -2998,8 +3162,18 @@ EXAMPLE: ctx_batch_execute(
|
|
|
2998
3162
|
"Keep at 1 for CPU-bound (npm test, build, lint) or stateful commands (ports, locks). " +
|
|
2999
3163
|
">1 switches to per-command timeouts (no shared budget) and " +
|
|
3000
3164
|
"individual `(timed out)` blocks instead of cascading skip."),
|
|
3165
|
+
query_scope: z
|
|
3166
|
+
.enum(["batch", "global"])
|
|
3167
|
+
.optional()
|
|
3168
|
+
.default("batch")
|
|
3169
|
+
.describe("Scope for `queries` (default: `batch`). " +
|
|
3170
|
+
"`batch` searches ONLY the chunks produced by this batch's commands " +
|
|
3171
|
+
"— useful when you want answers about the just-fetched output. " +
|
|
3172
|
+
"`global` searches the entire persistent index (same scope as ctx_search) " +
|
|
3173
|
+
"— useful when you want the batch commands to enrich context and " +
|
|
3174
|
+
"the queries to also surface related prior knowledge in one round trip."),
|
|
3001
3175
|
}),
|
|
3002
|
-
}, async ({ commands, queries, timeout, concurrency }) => {
|
|
3176
|
+
}, async ({ commands, queries, timeout, concurrency, query_scope }) => {
|
|
3003
3177
|
// Security: check each command against deny patterns
|
|
3004
3178
|
for (const cmd of commands) {
|
|
3005
3179
|
const denied = checkDenyPolicy(cmd.command, "batch_execute");
|
|
@@ -3042,6 +3216,14 @@ EXAMPLE: ctx_batch_execute(
|
|
|
3042
3216
|
.join(",")
|
|
3043
3217
|
.slice(0, 80)}`;
|
|
3044
3218
|
const indexed = store.index({ content: stdout, source, attribution: currentAttribution() });
|
|
3219
|
+
// Commands inventory — list what the agent actually ran so the
|
|
3220
|
+
// response itself documents intent, not just per-section echoes.
|
|
3221
|
+
// Placed before "## Indexed Sections" so it scans top-down with
|
|
3222
|
+
// the human asking "what just happened" (Issues #717 + #736).
|
|
3223
|
+
const commandsInventory = ["## Commands", ""];
|
|
3224
|
+
for (const c of commands) {
|
|
3225
|
+
commandsInventory.push(`- ${c.label}: \`${truncateCommandForEcho(c.command)}\``);
|
|
3226
|
+
}
|
|
3045
3227
|
// Build section inventory — direct query by source_id (no FTS5 MATCH needed)
|
|
3046
3228
|
const allSections = store.getChunksBySource(indexed.sourceId);
|
|
3047
3229
|
const inventory = ["## Indexed Sections", ""];
|
|
@@ -3051,9 +3233,11 @@ EXAMPLE: ctx_batch_execute(
|
|
|
3051
3233
|
inventory.push(`- ${s.title} (${(bytes / 1024).toFixed(1)}KB)`);
|
|
3052
3234
|
sectionTitles.push(s.title);
|
|
3053
3235
|
}
|
|
3054
|
-
// Run all search queries —
|
|
3055
|
-
//
|
|
3056
|
-
|
|
3236
|
+
// Run all search queries — default scope is batch-local (legacy behavior).
|
|
3237
|
+
// When the caller passes query_scope: "global", searches reach the entire
|
|
3238
|
+
// persistent index in the same round trip. Cross-source search remains
|
|
3239
|
+
// available via explicit ctx_search() as well.
|
|
3240
|
+
const queryResults = formatBatchQueryResults(store, queries, source, undefined, query_scope);
|
|
3057
3241
|
// Get searchable terms for edge cases where follow-up is needed
|
|
3058
3242
|
const distinctiveTerms = store.getDistinctiveTerms
|
|
3059
3243
|
? store.getDistinctiveTerms(indexed.sourceId)
|
|
@@ -3062,6 +3246,8 @@ EXAMPLE: ctx_batch_execute(
|
|
|
3062
3246
|
`Executed ${commands.length} commands (${totalLines} lines, ${(totalBytes / 1024).toFixed(1)}KB). ` +
|
|
3063
3247
|
`Indexed ${indexed.totalChunks} sections. Searched ${queries.length} queries.`,
|
|
3064
3248
|
"",
|
|
3249
|
+
...commandsInventory,
|
|
3250
|
+
"",
|
|
3065
3251
|
...inventory,
|
|
3066
3252
|
"",
|
|
3067
3253
|
...queryResults,
|
|
@@ -3086,6 +3272,32 @@ EXAMPLE: ctx_batch_execute(
|
|
|
3086
3272
|
});
|
|
3087
3273
|
}
|
|
3088
3274
|
});
|
|
3275
|
+
/**
|
|
3276
|
+
* Pi byte accounting: patch lifetime.totalEvents from bytes_sandboxed
|
|
3277
|
+
* in stats-*.json files instead of the default events × 256 heuristic.
|
|
3278
|
+
* Only active for Pi adapter — other platforms use getLifetimeStats() as-is.
|
|
3279
|
+
*/
|
|
3280
|
+
function patchPiLifetimeFromStatsFiles(lifetime, sessionsDir) {
|
|
3281
|
+
if (!existsSync(sessionsDir))
|
|
3282
|
+
return;
|
|
3283
|
+
let sandboxedBytes = 0;
|
|
3284
|
+
try {
|
|
3285
|
+
for (const f of readdirSync(sessionsDir)) {
|
|
3286
|
+
if (!f.startsWith("stats-") || !f.endsWith(".json"))
|
|
3287
|
+
continue;
|
|
3288
|
+
try {
|
|
3289
|
+
const raw = JSON.parse(readFileSync(join(sessionsDir, f), "utf-8"));
|
|
3290
|
+
sandboxedBytes += (raw?.bytes_sandboxed ?? 0) + (raw?.bytes_indexed ?? 0);
|
|
3291
|
+
}
|
|
3292
|
+
catch { /* corrupt file — skip */ }
|
|
3293
|
+
}
|
|
3294
|
+
}
|
|
3295
|
+
catch { /* never block ctx_stats on stats file I/O */ }
|
|
3296
|
+
if (sandboxedBytes > 0) {
|
|
3297
|
+
const rescueTokens = (lifetime.rescueBytes ?? 0) / 4;
|
|
3298
|
+
lifetime.totalEvents = Math.round((sandboxedBytes / 4 + rescueTokens) / 256);
|
|
3299
|
+
}
|
|
3300
|
+
}
|
|
3089
3301
|
// ─────────────────────────────────────────────────────────
|
|
3090
3302
|
// Tool: stats
|
|
3091
3303
|
// ─────────────────────────────────────────────────────────
|
|
@@ -3230,12 +3442,23 @@ server.registerTool("ctx_stats", {
|
|
|
3230
3442
|
}
|
|
3231
3443
|
}
|
|
3232
3444
|
catch { /* never block ctx_stats */ }
|
|
3445
|
+
// Pi byte accounting: patch lifetime from stats-*.json files
|
|
3446
|
+
// (actual bytes_sandboxed, not events × 256 heuristic).
|
|
3447
|
+
if (_detectedAdapter?.name === "Pi") {
|
|
3448
|
+
patchPiLifetimeFromStatsFiles(lifetime, getSessionDir());
|
|
3449
|
+
}
|
|
3233
3450
|
// v1.0.117: pass projectDir as cwd so the narrative renderer's
|
|
3234
|
-
// "started in <path>" line matches the user's actual project
|
|
3235
|
-
// the
|
|
3236
|
-
//
|
|
3237
|
-
//
|
|
3238
|
-
|
|
3451
|
+
// "started in <path>" line matches the user's actual project.
|
|
3452
|
+
// Snapshot the persistent store so the renderer can show
|
|
3453
|
+
// total_chunks / last_indexed_at without callers having to query
|
|
3454
|
+
// separately. Best-effort — getStore() is process-local and may
|
|
3455
|
+
// be unavailable on cold paths; failures are absorbed.
|
|
3456
|
+
let indexState;
|
|
3457
|
+
try {
|
|
3458
|
+
indexState = getStore().getIndexState();
|
|
3459
|
+
}
|
|
3460
|
+
catch { /* never block ctx_stats */ }
|
|
3461
|
+
text = formatReport(report, VERSION, _latestVersion, { lifetime, mcpUsage, multiAdapter, conversation, realBytes, indexState, cwd: projectDir });
|
|
3239
3462
|
}
|
|
3240
3463
|
finally {
|
|
3241
3464
|
sdb.close();
|
|
@@ -3247,12 +3470,20 @@ server.registerTool("ctx_stats", {
|
|
|
3247
3470
|
const engine = new AnalyticsEngine(createMinimalDb());
|
|
3248
3471
|
const report = engine.queryAll(sessionStats);
|
|
3249
3472
|
const lifetime = getLifetimeStats({ sessionsDir: getSessionDir() });
|
|
3473
|
+
if (_detectedAdapter?.name === "Pi") {
|
|
3474
|
+
patchPiLifetimeFromStatsFiles(lifetime, getSessionDir());
|
|
3475
|
+
}
|
|
3250
3476
|
let multiAdapter;
|
|
3251
3477
|
try {
|
|
3252
3478
|
multiAdapter = getMultiAdapterLifetimeStats();
|
|
3253
3479
|
}
|
|
3254
3480
|
catch { /* never block ctx_stats */ }
|
|
3255
|
-
|
|
3481
|
+
let indexState;
|
|
3482
|
+
try {
|
|
3483
|
+
indexState = getStore().getIndexState();
|
|
3484
|
+
}
|
|
3485
|
+
catch { /* never block ctx_stats */ }
|
|
3486
|
+
text = formatReport(report, VERSION, _latestVersion, { lifetime, multiAdapter, indexState });
|
|
3256
3487
|
}
|
|
3257
3488
|
}
|
|
3258
3489
|
catch {
|
|
@@ -3264,6 +3495,9 @@ server.registerTool("ctx_stats", {
|
|
|
3264
3495
|
lifetime = getLifetimeStats({ sessionsDir: getSessionDir() });
|
|
3265
3496
|
}
|
|
3266
3497
|
catch { /* never block ctx_stats */ }
|
|
3498
|
+
if (_detectedAdapter?.name === "Pi" && lifetime) {
|
|
3499
|
+
patchPiLifetimeFromStatsFiles(lifetime, getSessionDir());
|
|
3500
|
+
}
|
|
3267
3501
|
let multiAdapter;
|
|
3268
3502
|
try {
|
|
3269
3503
|
multiAdapter = getMultiAdapterLifetimeStats();
|
|
@@ -3422,19 +3656,23 @@ server.registerTool("ctx_upgrade", {
|
|
|
3422
3656
|
// and cmd.exe — unlike env-var prefixes). If detection fails we
|
|
3423
3657
|
// skip the flag and let upgrade()'s own detectPlatform() fall back.
|
|
3424
3658
|
let platformFlag = "";
|
|
3659
|
+
let nodeOpts = undefined;
|
|
3425
3660
|
try {
|
|
3426
3661
|
const { detectPlatform } = await import("./adapters/detect.js");
|
|
3427
3662
|
const clientInfo = server.server.getClientVersion();
|
|
3428
3663
|
const signal = detectPlatform(clientInfo ?? undefined);
|
|
3429
3664
|
platformFlag = ` --platform ${signal.platform}`;
|
|
3665
|
+
nodeOpts = isInProcessPluginPlatform(signal.platform) && runtimes.javascript
|
|
3666
|
+
? { platform: signal.platform, jsRuntime: runtimes.javascript }
|
|
3667
|
+
: undefined;
|
|
3430
3668
|
}
|
|
3431
3669
|
catch { /* best effort — fall back to upgrade()'s own detect */ }
|
|
3432
3670
|
let cmd;
|
|
3433
3671
|
if (existsSync(bundlePath)) {
|
|
3434
|
-
cmd = `${buildNodeCommand(bundlePath)} upgrade${platformFlag}`;
|
|
3672
|
+
cmd = `${buildNodeCommand(bundlePath, nodeOpts)} upgrade${platformFlag}`;
|
|
3435
3673
|
}
|
|
3436
3674
|
else if (existsSync(fallbackPath)) {
|
|
3437
|
-
cmd = `${buildNodeCommand(fallbackPath)} upgrade${platformFlag}`;
|
|
3675
|
+
cmd = `${buildNodeCommand(fallbackPath, nodeOpts)} upgrade${platformFlag}`;
|
|
3438
3676
|
}
|
|
3439
3677
|
else {
|
|
3440
3678
|
// Inline fallback: neither CLI file exists (e.g. marketplace installs).
|
|
@@ -3444,8 +3682,8 @@ server.registerTool("ctx_upgrade", {
|
|
|
3444
3682
|
// across cmd.exe, PowerShell, and bash (node -e '...' breaks on Windows).
|
|
3445
3683
|
const scriptLines = [
|
|
3446
3684
|
`import{execFileSync}from"node:child_process";`,
|
|
3447
|
-
`import{cpSync,rmSync,existsSync,mkdtempSync,readFileSync,writeFileSync}from"node:fs";`,
|
|
3448
|
-
`import{join}from"node:path";`,
|
|
3685
|
+
`import{cpSync,rmSync,existsSync,mkdtempSync,readFileSync,writeFileSync,lstatSync}from"node:fs";`,
|
|
3686
|
+
`import{join,resolve,sep}from"node:path";`,
|
|
3449
3687
|
`import{tmpdir}from"node:os";`,
|
|
3450
3688
|
`const P=${JSON.stringify(pluginRoot)};`,
|
|
3451
3689
|
`const T=mkdtempSync(join(tmpdir(),"ctx-upgrade-"));`,
|
|
@@ -3458,7 +3696,18 @@ server.registerTool("ctx_upgrade", {
|
|
|
3458
3696
|
`console.log("- [x] Built from source");`,
|
|
3459
3697
|
`const pkg=JSON.parse(readFileSync(join(T,"package.json"),"utf8"));`,
|
|
3460
3698
|
`const items=[...(Array.isArray(pkg.files)?pkg.files:[]),"src","package.json"];`,
|
|
3461
|
-
|
|
3699
|
+
// Supply-chain containment on items[]. Mirror the cli.ts upgrade()
|
|
3700
|
+
// guard: a compromised upstream package.json with files:["../etc"]
|
|
3701
|
+
// would otherwise let path.join follow ".." out of pluginRoot.
|
|
3702
|
+
// path.resolve normalizes "..", so the lexical startsWith catches
|
|
3703
|
+
// both relative-".." traversal and absolute-path bypass. Plus a
|
|
3704
|
+
// symlink filter so a committed symlink inside the clone can't
|
|
3705
|
+
// plant itself in pluginRoot (cpSync default preserves source
|
|
3706
|
+
// symlinks; a planted symlink in pluginRoot/src then redirects
|
|
3707
|
+
// every subsequent load through to an attacker target).
|
|
3708
|
+
`const PW=resolve(P)+sep;const TW=resolve(T)+sep;`,
|
|
3709
|
+
`const noSymlink=(src)=>{try{return !lstatSync(src).isSymbolicLink()}catch{return false}};`,
|
|
3710
|
+
`for(const item of items){const from=resolve(T,item);const to=resolve(P,item);if(!(to+sep).startsWith(PW))continue;if(!(from+sep).startsWith(TW))continue;if(!noSymlink(from))continue;if(existsSync(from)){rmSync(to,{recursive:true,force:true});cpSync(from,to,{recursive:true,force:true,filter:noSymlink});}}`,
|
|
3462
3711
|
// Issue #609: do NOT write .mcp.json into the cache dir. Claude Code reads
|
|
3463
3712
|
// .claude-plugin/plugin.json.mcpServers as the canonical MCP source — the
|
|
3464
3713
|
// per-version .mcp.json file is a stale-write vector. Same architectural
|
|
@@ -3478,7 +3727,7 @@ server.registerTool("ctx_upgrade", {
|
|
|
3478
3727
|
const tmpScript = resolve(pluginRoot, ".ctx-upgrade-inline.mjs");
|
|
3479
3728
|
const { writeFileSync: writeTmp } = await import("node:fs");
|
|
3480
3729
|
writeTmp(tmpScript, scriptLines);
|
|
3481
|
-
cmd = buildNodeCommand(tmpScript);
|
|
3730
|
+
cmd = buildNodeCommand(tmpScript, nodeOpts);
|
|
3482
3731
|
}
|
|
3483
3732
|
const text = [
|
|
3484
3733
|
"## ctx-upgrade",
|
|
@@ -3837,7 +4086,7 @@ export function killProcessOnPort(port, platform = process.platform, runner = sp
|
|
|
3837
4086
|
// ── ctx-insight: analytics dashboard ──────────────────────────────────────────
|
|
3838
4087
|
server.registerTool("ctx_insight", {
|
|
3839
4088
|
title: "Open Insight Dashboard",
|
|
3840
|
-
description: "Opens the context-mode Insight dashboard in the browser. " +
|
|
4089
|
+
description: "Opens the context-mode Insight dashboard in the browser — a dashboard launcher for session analytics; for natural-language queries over indexed content, use ctx_search. " +
|
|
3841
4090
|
"Shows personal analytics: session activity, tool usage, error rate, " +
|
|
3842
4091
|
"parallel work patterns, project focus, and actionable insights. " +
|
|
3843
4092
|
"First run installs dependencies (~30s). Subsequent runs open instantly. " +
|
|
@@ -3864,6 +4113,38 @@ server.registerTool("ctx_insight", {
|
|
|
3864
4113
|
const sessDir = explicitSessionDir ? resolve(explicitSessionDir) : getSessionDir();
|
|
3865
4114
|
const insightContentDirResolved = explicitContentDir ? resolve(explicitContentDir) : join(dirname(sessDir), "content");
|
|
3866
4115
|
const cacheDir = join(dirname(sessDir), "insight-cache");
|
|
4116
|
+
// Confused-deputy guard on explicit overrides. The spawned insight
|
|
4117
|
+
// server reads every .db file under sessDir and insightContentDir, and
|
|
4118
|
+
// its /api/content DELETE endpoint can rewrite hex-named .db files in
|
|
4119
|
+
// those trees. A prompt-injected caller passing sessionDir="~/.ssh"
|
|
4120
|
+
// or contentDir="~/.gnupg" would otherwise let the dashboard
|
|
4121
|
+
// enumerate (and, for hex-named SQLite files, mutate rows in) those
|
|
4122
|
+
// directories. Contain explicit overrides to the adapter's config
|
|
4123
|
+
// root: broad enough for the documented "multi-install setups or
|
|
4124
|
+
// pointing at a sibling project's data" use case, narrow enough to
|
|
4125
|
+
// block /etc, ~/.ssh, /tmp/<foreign-user>, etc.
|
|
4126
|
+
if (explicitSessionDir || explicitContentDir) {
|
|
4127
|
+
const defaultSessDir = getSessionDir();
|
|
4128
|
+
const containmentRoot = dirname(dirname(defaultSessDir));
|
|
4129
|
+
const containmentRootWithSep = resolve(containmentRoot) + sep;
|
|
4130
|
+
const isContained = (dir) => (resolve(dir) + sep).startsWith(containmentRootWithSep);
|
|
4131
|
+
if (explicitSessionDir && !isContained(sessDir)) {
|
|
4132
|
+
return trackResponse("ctx_insight", {
|
|
4133
|
+
content: [{
|
|
4134
|
+
type: "text",
|
|
4135
|
+
text: `Error: sessionDir must resolve under ${containmentRoot} (got ${sessDir}).`,
|
|
4136
|
+
}],
|
|
4137
|
+
});
|
|
4138
|
+
}
|
|
4139
|
+
if (explicitContentDir && !isContained(insightContentDirResolved)) {
|
|
4140
|
+
return trackResponse("ctx_insight", {
|
|
4141
|
+
content: [{
|
|
4142
|
+
type: "text",
|
|
4143
|
+
text: `Error: contentDir must resolve under ${containmentRoot} (got ${insightContentDirResolved}).`,
|
|
4144
|
+
}],
|
|
4145
|
+
});
|
|
4146
|
+
}
|
|
4147
|
+
}
|
|
3867
4148
|
// Verify source exists
|
|
3868
4149
|
if (!existsSync(join(insightSource, "server.mjs"))) {
|
|
3869
4150
|
return trackResponse("ctx_insight", {
|