context-mode 1.0.88 → 1.0.90

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.
Files changed (132) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.openclaw-plugin/openclaw.plugin.json +1 -1
  4. package/.openclaw-plugin/package.json +1 -1
  5. package/README.md +184 -60
  6. package/build/adapters/antigravity/index.d.ts +3 -5
  7. package/build/adapters/antigravity/index.js +7 -35
  8. package/build/adapters/base.d.ts +27 -0
  9. package/build/adapters/base.js +59 -0
  10. package/build/adapters/claude-code/index.d.ts +9 -25
  11. package/build/adapters/claude-code/index.js +27 -141
  12. package/build/adapters/claude-code-base.d.ts +49 -0
  13. package/build/adapters/claude-code-base.js +113 -0
  14. package/build/adapters/client-map.js +5 -0
  15. package/build/adapters/codex/hooks.d.ts +21 -14
  16. package/build/adapters/codex/hooks.js +22 -15
  17. package/build/adapters/codex/index.d.ts +6 -10
  18. package/build/adapters/codex/index.js +13 -43
  19. package/build/adapters/copilot-base.d.ts +78 -0
  20. package/build/adapters/copilot-base.js +281 -0
  21. package/build/adapters/cursor/index.d.ts +3 -5
  22. package/build/adapters/cursor/index.js +6 -34
  23. package/build/adapters/detect.d.ts +7 -0
  24. package/build/adapters/detect.js +57 -56
  25. package/build/adapters/gemini-cli/index.d.ts +3 -5
  26. package/build/adapters/gemini-cli/index.js +7 -35
  27. package/build/adapters/jetbrains-copilot/config.d.ts +8 -0
  28. package/build/adapters/jetbrains-copilot/config.js +8 -0
  29. package/build/adapters/jetbrains-copilot/hooks.d.ts +51 -0
  30. package/build/adapters/jetbrains-copilot/hooks.js +82 -0
  31. package/build/adapters/jetbrains-copilot/index.d.ts +24 -0
  32. package/build/adapters/jetbrains-copilot/index.js +119 -0
  33. package/build/adapters/kiro/hooks.d.ts +14 -0
  34. package/build/adapters/kiro/hooks.js +23 -0
  35. package/build/adapters/kiro/index.d.ts +3 -5
  36. package/build/adapters/kiro/index.js +10 -38
  37. package/build/adapters/openclaw/index.d.ts +3 -4
  38. package/build/adapters/openclaw/index.js +6 -22
  39. package/build/adapters/opencode/index.d.ts +2 -3
  40. package/build/adapters/opencode/index.js +5 -16
  41. package/build/adapters/qwen-code/index.d.ts +39 -0
  42. package/build/adapters/qwen-code/index.js +199 -0
  43. package/build/adapters/types.d.ts +1 -1
  44. package/build/adapters/vscode-copilot/index.d.ts +16 -46
  45. package/build/adapters/vscode-copilot/index.js +29 -320
  46. package/build/adapters/zed/index.d.ts +3 -5
  47. package/build/adapters/zed/index.js +7 -35
  48. package/build/cli.js +113 -47
  49. package/build/lifecycle.d.ts +23 -0
  50. package/build/lifecycle.js +54 -13
  51. package/build/opencode-plugin.d.ts +19 -7
  52. package/build/opencode-plugin.js +19 -7
  53. package/build/pi-extension.js +24 -7
  54. package/build/runtime.js +24 -9
  55. package/build/security.d.ts +17 -1
  56. package/build/security.js +40 -6
  57. package/build/server.js +129 -21
  58. package/build/session/analytics.d.ts +8 -7
  59. package/build/session/analytics.js +95 -75
  60. package/build/session/db.d.ts +10 -1
  61. package/build/session/db.js +67 -8
  62. package/build/session/extract.js +10 -2
  63. package/build/session/project-attribution.d.ts +73 -0
  64. package/build/session/project-attribution.js +231 -0
  65. package/build/store.d.ts +7 -0
  66. package/build/store.js +117 -18
  67. package/build/truncate.d.ts +6 -0
  68. package/build/truncate.js +51 -29
  69. package/build/types.d.ts +8 -0
  70. package/cli.bundle.mjs +157 -136
  71. package/configs/antigravity/GEMINI.md +31 -36
  72. package/configs/claude-code/CLAUDE.md +31 -37
  73. package/configs/codex/AGENTS.md +35 -49
  74. package/configs/cursor/context-mode.mdc +24 -25
  75. package/configs/gemini-cli/GEMINI.md +30 -36
  76. package/configs/jetbrains-copilot/copilot-instructions.md +59 -0
  77. package/configs/jetbrains-copilot/hooks.json +16 -0
  78. package/configs/jetbrains-copilot/mcp.json +8 -0
  79. package/configs/kilo/AGENTS.md +30 -36
  80. package/configs/kiro/KIRO.md +30 -36
  81. package/configs/kiro/agent.json +1 -1
  82. package/configs/openclaw/AGENTS.md +30 -36
  83. package/configs/opencode/AGENTS.md +30 -36
  84. package/configs/pi/AGENTS.md +31 -36
  85. package/configs/qwen-code/QWEN.md +63 -0
  86. package/configs/vscode-copilot/copilot-instructions.md +30 -36
  87. package/configs/zed/AGENTS.md +31 -36
  88. package/hooks/codex/posttooluse.mjs +7 -7
  89. package/hooks/codex/pretooluse.mjs +3 -3
  90. package/hooks/codex/sessionstart.mjs +2 -1
  91. package/hooks/core/formatters.mjs +24 -0
  92. package/hooks/core/routing.mjs +40 -15
  93. package/hooks/core/tool-naming.mjs +2 -0
  94. package/hooks/cursor/posttooluse.mjs +7 -7
  95. package/hooks/cursor/pretooluse.mjs +3 -3
  96. package/hooks/cursor/sessionstart.mjs +2 -1
  97. package/hooks/cursor/stop.mjs +2 -2
  98. package/hooks/ensure-deps.mjs +22 -10
  99. package/hooks/gemini-cli/aftertool.mjs +8 -8
  100. package/hooks/gemini-cli/beforetool.mjs +3 -2
  101. package/hooks/gemini-cli/precompress.mjs +2 -2
  102. package/hooks/gemini-cli/sessionstart.mjs +12 -4
  103. package/hooks/jetbrains-copilot/posttooluse.mjs +61 -0
  104. package/hooks/jetbrains-copilot/precompact.mjs +54 -0
  105. package/hooks/jetbrains-copilot/pretooluse.mjs +27 -0
  106. package/hooks/jetbrains-copilot/sessionstart.mjs +119 -0
  107. package/hooks/kiro/posttooluse.mjs +6 -7
  108. package/hooks/kiro/pretooluse.mjs +3 -2
  109. package/hooks/posttooluse.mjs +8 -8
  110. package/hooks/precompact.mjs +3 -4
  111. package/hooks/pretooluse.mjs +43 -20
  112. package/hooks/routing-block.mjs +35 -33
  113. package/hooks/session-attribution.bundle.mjs +1 -0
  114. package/hooks/session-db.bundle.mjs +27 -8
  115. package/hooks/session-extract.bundle.mjs +2 -1
  116. package/hooks/session-helpers.mjs +44 -3
  117. package/hooks/session-loaders.mjs +37 -0
  118. package/hooks/session-snapshot.bundle.mjs +14 -14
  119. package/hooks/sessionstart.mjs +5 -5
  120. package/hooks/userpromptsubmit.mjs +26 -9
  121. package/hooks/vscode-copilot/posttooluse.mjs +8 -8
  122. package/hooks/vscode-copilot/precompact.mjs +2 -2
  123. package/hooks/vscode-copilot/pretooluse.mjs +3 -2
  124. package/hooks/vscode-copilot/sessionstart.mjs +2 -2
  125. package/insight/server.mjs +262 -32
  126. package/insight/src/lib/api.ts +2 -1
  127. package/insight/src/routes/index.tsx +16 -3
  128. package/insight/src/routes/search.tsx +1 -1
  129. package/openclaw.plugin.json +1 -1
  130. package/package.json +11 -2
  131. package/server.bundle.mjs +117 -99
  132. package/skills/ctx-insight/SKILL.md +1 -1
package/build/server.js CHANGED
@@ -97,6 +97,9 @@ function maybeIndexSessionEvents(store) {
97
97
  // platform-specific paths. All session DB paths go through it — no
98
98
  // hardcoded configDir detection in tool handlers.
99
99
  let _detectedAdapter = null;
100
+ // Tracks the ctx_insight dashboard child so shutdown can terminate it.
101
+ // See ctx_insight handler + shutdown() in main().
102
+ let _insightChild = null;
100
103
  /**
101
104
  * Get the platform-specific sessions directory from the detected adapter.
102
105
  * Falls back to ~/.claude/context-mode/sessions/ before adapter detection.
@@ -325,8 +328,9 @@ function checkNonShellDenyPolicy(code, language, toolName) {
325
328
  */
326
329
  function checkFilePathDenyPolicy(filePath, toolName) {
327
330
  try {
328
- const denyGlobs = readToolDenyPatterns("Read", process.env.CLAUDE_PROJECT_DIR);
329
- const result = evaluateFilePath(filePath, denyGlobs);
331
+ const projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
332
+ const denyGlobs = readToolDenyPatterns("Read", projectDir);
333
+ const result = evaluateFilePath(filePath, denyGlobs, process.platform === "win32", projectDir);
330
334
  if (result.denied) {
331
335
  return trackResponse(toolName, {
332
336
  content: [{
@@ -481,7 +485,7 @@ export function formatBatchQueryResults(store, queries, source, maxOutput = 80 *
481
485
  // ─────────────────────────────────────────────────────────
482
486
  server.registerTool("ctx_execute", {
483
487
  title: "Execute Code",
484
- description: `MANDATORY: Use for any command where output exceeds 20 lines. Execute code in a sandboxed subprocess. Only stdout enters context — raw data stays in the subprocess.${bunNote} Available: ${langList}.\n\nPREFER THIS OVER BASH for: API calls (gh, curl, aws), test runners (npm test, pytest), git queries (git log, git diff), data processing, and ANY CLI command that may produce large output. Bash should only be used for file mutations, git writes, and navigation.\n\nTHINK IN CODE: When you need to analyze, count, filter, compare, or process data — write code that does the work and console.log() only the answer. Do NOT read raw data into context to process mentally. Program the analysis, don't compute it in your reasoning. Write robust, pure JavaScript (no npm dependencies). Use only Node.js built-ins (fs, path, child_process). Always wrap in try/catch. Handle null/undefined. Works on both Node.js and Bun.`,
488
+ description: `MANDATORY: Use for any command where output exceeds 20 lines. Execute code in a sandboxed subprocess. Only stdout enters context — raw data stays in the subprocess.${bunNote} Available: ${langList}.\n\nPREFER THIS OVER BASH for: API calls (gh, curl, aws), test runners (npm test, pytest), git queries (git log, git diff), data processing, and ANY CLI command that may produce large output. Bash should only be used for file mutations, git writes, and navigation.\n\nTHINK IN CODE: When you need to analyze, count, filter, compare, or process data — write code that does the work and console.log() only the answer. Do NOT read raw data into context to process mentally. Program the analysis, don't compute it in your reasoning. Write robust, pure JavaScript (no npm dependencies). Use only Node.js built-ins (fs, path, child_process). Always wrap in try/catch. Handle null/undefined. Works on both Node.js and Bun.\n\nWhen reporting results — terse like caveman. Technical substance exact. Only fluff die. Pattern: [thing] [action] [reason]. [next step].`,
485
489
  inputSchema: z.object({
486
490
  language: z
487
491
  .enum([
@@ -502,7 +506,7 @@ server.registerTool("ctx_execute", {
502
506
  .string()
503
507
  .describe("Source code to execute. Use console.log (JS/TS), print (Python/Ruby/Perl/R), echo (Shell), echo (PHP), fmt.Println (Go), or IO.puts (Elixir) to output a summary to context."),
504
508
  timeout: z
505
- .number()
509
+ .coerce.number()
506
510
  .optional()
507
511
  .default(30000)
508
512
  .describe("Max execution time in ms"),
@@ -774,7 +778,7 @@ function intentSearch(stdout, intent, source, maxResults = 5) {
774
778
  // ─────────────────────────────────────────────────────────
775
779
  server.registerTool("ctx_execute_file", {
776
780
  title: "Execute File Processing",
777
- description: "Read a file and process it without loading contents into context. The file is read into a FILE_CONTENT variable inside the sandbox. Only your printed summary enters context.\n\nPREFER THIS OVER Read/cat for: log files, data files (CSV, JSON, XML), large source files for analysis, and any file where you need to extract specific information rather than read the entire content.\n\nTHINK IN CODE: Write code that processes FILE_CONTENT and console.log() only the answer. Don't read files into context to analyze mentally. Write robust, pure JavaScript — no npm deps, try/catch, null-safe. Node.js + Bun compatible.",
781
+ description: "Read a file and process it without loading contents into context. The file is read into a FILE_CONTENT variable inside the sandbox. Only your printed summary enters context.\n\nPREFER THIS OVER Read/cat for: log files, data files (CSV, JSON, XML), large source files for analysis, and any file where you need to extract specific information rather than read the entire content.\n\nTHINK IN CODE: Write code that processes FILE_CONTENT and console.log() only the answer. Don't read files into context to analyze mentally. Write robust, pure JavaScript — no npm deps, try/catch, null-safe. Node.js + Bun compatible.\n\nWhen reporting results — terse like caveman. Technical substance exact. Only fluff die. Pattern: [thing] [action] [reason]. [next step].",
778
782
  inputSchema: z.object({
779
783
  path: z
780
784
  .string()
@@ -798,7 +802,7 @@ server.registerTool("ctx_execute_file", {
798
802
  .string()
799
803
  .describe("Code to process FILE_CONTENT (file_content in Elixir). Print summary via console.log/print/echo/IO.puts."),
800
804
  timeout: z
801
- .number()
805
+ .coerce.number()
802
806
  .optional()
803
807
  .default(30000)
804
808
  .describe("Max execution time in ms"),
@@ -917,6 +921,7 @@ server.registerTool("ctx_index", {
917
921
  "- README files, migration guides, changelog entries\n" +
918
922
  "- Any content with code examples you may need to reference precisely\n\n" +
919
923
  "After indexing, use 'search' to retrieve specific sections on-demand.\n" +
924
+ "When `path` is provided, a content hash is stored for automatic stale detection in search results.\n" +
920
925
  "Do NOT use for: log files, test output, CSV, build output — use 'execute_file' for those.",
921
926
  inputSchema: z.object({
922
927
  content: z
@@ -1016,8 +1021,10 @@ function coerceCommandsArray(val) {
1016
1021
  server.registerTool("ctx_search", {
1017
1022
  title: "Search Indexed Content",
1018
1023
  description: "Search indexed content. Requires prior indexing via ctx_batch_execute, ctx_index, or ctx_fetch_and_index. " +
1019
- "Pass ALL search questions as queries array in ONE call.\n\n" +
1020
- "TIPS: 2-4 specific terms per query. Use 'source' to scope results.",
1024
+ "Pass ALL search questions as queries array in ONE call. " +
1025
+ "File-backed sources are auto-refreshed when the source file changes.\n\n" +
1026
+ "TIPS: 2-4 specific terms per query. Use 'source' to scope results.\n\n" +
1027
+ "When reporting results — terse like caveman. Technical substance exact. Only fluff die. Pattern: [thing] [action] [reason]. [next step].",
1021
1028
  inputSchema: z.object({
1022
1029
  queries: z.preprocess(coerceJsonArray, z
1023
1030
  .array(z.string())
@@ -1121,6 +1128,10 @@ server.registerTool("ctx_search", {
1121
1128
  totalSize += formatted.length;
1122
1129
  }
1123
1130
  let output = sections.join("\n\n---\n\n");
1131
+ // Report auto-refreshed stale sources
1132
+ if (store.lastRefreshCount > 0) {
1133
+ output = `> Auto-refreshed ${store.lastRefreshCount} stale source${store.lastRefreshCount > 1 ? "s" : ""} (file changed since indexing).\n\n` + output;
1134
+ }
1124
1135
  // Add throttle warning after threshold
1125
1136
  if (searchCallCount >= SEARCH_MAX_RESULTS_AFTER) {
1126
1137
  output += `\n\n⚠ search call #${searchCallCount}/${SEARCH_BLOCK_AFTER} in this window. ` +
@@ -1230,7 +1241,8 @@ server.registerTool("ctx_fetch_and_index", {
1230
1241
  description: "Fetches URL content, converts HTML to markdown, indexes into searchable knowledge base, " +
1231
1242
  "and returns a ~3KB preview. Full content stays in sandbox — use search() for deeper lookups.\n\n" +
1232
1243
  "Better than WebFetch: preview is immediate, full content is searchable, raw HTML never enters context.\n\n" +
1233
- "Content-type aware: HTML is converted to markdown, JSON is chunked by key paths, plain text is indexed directly.",
1244
+ "Content-type aware: HTML is converted to markdown, JSON is chunked by key paths, plain text is indexed directly.\n\n" +
1245
+ "When reporting results — terse like caveman. Technical substance exact. Only fluff die. Pattern: [thing] [action] [reason]. [next step].",
1234
1246
  inputSchema: z.object({
1235
1247
  url: z.string().describe("The URL to fetch and index"),
1236
1248
  source: z
@@ -1379,7 +1391,8 @@ server.registerTool("ctx_batch_execute", {
1379
1391
  "THIS IS THE PRIMARY TOOL. Use this instead of multiple execute() calls.\n\n" +
1380
1392
  "One batch_execute call replaces 30+ execute calls + 10+ search calls.\n" +
1381
1393
  "Provide all commands to run and all queries to search — everything happens in one round trip.\n\n" +
1382
- "THINK IN CODE: When commands produce data you need to analyze, add processing commands that filter and summarize. Don't pull raw output into context — let the sandbox do the work.",
1394
+ "THINK IN CODE: When commands produce data you need to analyze, add processing commands that filter and summarize. Don't pull raw output into context — let the sandbox do the work.\n\n" +
1395
+ "When reporting results — terse like caveman. Technical substance exact. Only fluff die. Pattern: [thing] [action] [reason]. [next step].",
1383
1396
  inputSchema: z.object({
1384
1397
  commands: z.preprocess(coerceCommandsArray, z
1385
1398
  .array(z.object({
@@ -1399,7 +1412,7 @@ server.registerTool("ctx_batch_execute", {
1399
1412
  "Each returns top 5 matching sections with full content. " +
1400
1413
  "This is your ONLY chance — put ALL your questions here. No follow-up calls needed.")),
1401
1414
  timeout: z
1402
- .number()
1415
+ .coerce.number()
1403
1416
  .optional()
1404
1417
  .default(60000)
1405
1418
  .describe("Max execution time in ms (default: 60s)"),
@@ -1862,7 +1875,7 @@ server.registerTool("ctx_insight", {
1862
1875
  "parallel work patterns, project focus, and actionable insights. " +
1863
1876
  "First run installs dependencies (~30s). Subsequent runs open instantly.",
1864
1877
  inputSchema: z.object({
1865
- port: z.number().optional().describe("Port to serve on (default: 4747)"),
1878
+ port: z.coerce.number().optional().describe("Port to serve on (default: 4747)"),
1866
1879
  }),
1867
1880
  }, async ({ port: userPort }) => {
1868
1881
  const port = userPort || 4747;
@@ -1895,11 +1908,26 @@ server.registerTool("ctx_insight", {
1895
1908
  const hasNodeModules = existsSync(join(cacheDir, "node_modules"));
1896
1909
  if (!hasNodeModules) {
1897
1910
  steps.push("Installing dependencies (first run, ~30s)...");
1898
- execSync("npm install --production=false", {
1899
- cwd: cacheDir,
1900
- stdio: "pipe",
1901
- timeout: 120000,
1902
- });
1911
+ try {
1912
+ execSync("npm install --production=false", {
1913
+ cwd: cacheDir,
1914
+ stdio: "pipe",
1915
+ timeout: 300000,
1916
+ });
1917
+ }
1918
+ catch {
1919
+ // Clean up partial install so next run retries fresh
1920
+ try {
1921
+ rmSync(join(cacheDir, "node_modules"), { recursive: true, force: true });
1922
+ }
1923
+ catch { }
1924
+ throw new Error("npm install failed — please retry");
1925
+ }
1926
+ // Sentinel check: verify install completed (cold cache can timeout leaving partial node_modules)
1927
+ if (!existsSync(join(cacheDir, "node_modules", "vite")) || !existsSync(join(cacheDir, "node_modules", "better-sqlite3"))) {
1928
+ rmSync(join(cacheDir, "node_modules"), { recursive: true, force: true });
1929
+ throw new Error("npm install incomplete — please retry");
1930
+ }
1903
1931
  steps.push("Dependencies installed.");
1904
1932
  }
1905
1933
  // Build
@@ -1907,20 +1935,93 @@ server.registerTool("ctx_insight", {
1907
1935
  execSync("npx vite build", {
1908
1936
  cwd: cacheDir,
1909
1937
  stdio: "pipe",
1910
- timeout: 30000,
1938
+ timeout: 60000,
1911
1939
  });
1912
1940
  steps.push("Build complete.");
1913
- // Start server in background
1941
+ // Pre-check: is port already in use? (prevents orphan zombie processes)
1942
+ try {
1943
+ const { request } = await import("node:http");
1944
+ await new Promise((resolve, reject) => {
1945
+ const req = request(`http://127.0.0.1:${port}/api/overview`, { timeout: 2000 }, (res) => {
1946
+ res.resume();
1947
+ resolve(); // port is responding = already running
1948
+ });
1949
+ req.on("error", () => reject()); // port free
1950
+ req.on("timeout", () => { req.destroy(); reject(); });
1951
+ req.end();
1952
+ });
1953
+ // If we get here, port is already responding
1954
+ steps.push("Dashboard already running.");
1955
+ // Open browser anyway
1956
+ const url = `http://localhost:${port}`;
1957
+ const platform = process.platform;
1958
+ try {
1959
+ if (platform === "darwin")
1960
+ execSync(`open "${url}"`, { stdio: "pipe" });
1961
+ else if (platform === "win32")
1962
+ execSync(`start "" "${url}"`, { stdio: "pipe" });
1963
+ else
1964
+ execSync(`xdg-open "${url}" 2>/dev/null || sensible-browser "${url}" 2>/dev/null`, { stdio: "pipe" });
1965
+ }
1966
+ catch { /* browser open is best-effort */ }
1967
+ return trackResponse("ctx_insight", {
1968
+ content: [{ type: "text", text: `Dashboard already running at http://localhost:${port}` }],
1969
+ });
1970
+ }
1971
+ catch {
1972
+ // Port is free, proceed with spawn
1973
+ }
1974
+ // Kill any previous insight child this MCP spawned (e.g. re-invocation).
1975
+ if (_insightChild && _insightChild.pid && !_insightChild.killed) {
1976
+ try {
1977
+ _insightChild.kill("SIGTERM");
1978
+ }
1979
+ catch { /* best effort */ }
1980
+ }
1981
+ // Start server in background. `detached: true` keeps MCP stdio free, but
1982
+ // we track the handle and kill it in shutdown() so the dashboard does
1983
+ // not orphan when Claude closes. The child also watches INSIGHT_PARENT_PID
1984
+ // as a fallback for SIGKILL/crash paths.
1914
1985
  const { spawn } = await import("node:child_process");
1915
1986
  const child = spawn("node", [join(cacheDir, "server.mjs")], {
1916
1987
  cwd: cacheDir,
1917
- env: { ...process.env, PORT: String(port) },
1988
+ env: {
1989
+ ...process.env,
1990
+ PORT: String(port),
1991
+ INSIGHT_SESSION_DIR: getSessionDir(),
1992
+ INSIGHT_CONTENT_DIR: join(dirname(getSessionDir()), "content"),
1993
+ INSIGHT_PARENT_PID: String(process.pid),
1994
+ },
1918
1995
  detached: true,
1919
1996
  stdio: "ignore",
1920
1997
  });
1998
+ child.on("error", () => { }); // prevent unhandled error crash
1921
1999
  child.unref();
2000
+ _insightChild = child;
1922
2001
  // Wait for server to be ready
1923
2002
  await new Promise(r => setTimeout(r, 1500));
2003
+ // Verify server is actually running
2004
+ try {
2005
+ const { request } = await import("node:http");
2006
+ await new Promise((resolve, reject) => {
2007
+ const req = request(`http://127.0.0.1:${port}/api/overview`, { timeout: 3000 }, (res) => {
2008
+ resolve();
2009
+ res.resume();
2010
+ });
2011
+ req.on("error", reject);
2012
+ req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); });
2013
+ req.end();
2014
+ });
2015
+ }
2016
+ catch {
2017
+ // Server didn't start — likely port in use
2018
+ return trackResponse("ctx_insight", {
2019
+ content: [{
2020
+ type: "text",
2021
+ text: `Port ${port} appears to be in use. Either a previous dashboard is still running, or another service is using this port.\n\nTo fix:\n- Kill the existing process: ${process.platform === "win32" ? `netstat -ano | findstr :${port}` : `lsof -ti:${port} | xargs kill`}\n- Or use a different port: ctx_insight({ port: ${port + 1} })`,
2022
+ }],
2023
+ });
2024
+ }
1924
2025
  // Open browser (cross-platform)
1925
2026
  const url = `http://localhost:${port}`;
1926
2027
  const platform = process.platform;
@@ -1937,7 +2038,7 @@ server.registerTool("ctx_insight", {
1937
2038
  return trackResponse("ctx_insight", {
1938
2039
  content: [{
1939
2040
  type: "text",
1940
- text: steps.map(s => `- ${s}`).join("\n") + `\n\nOpen: ${url}\nPID: ${child.pid} · Stop: kill ${child.pid}`,
2041
+ text: steps.map(s => `- ${s}`).join("\n") + `\n\nOpen: ${url}\nPID: ${child.pid} · Stop: ${process.platform === "win32" ? `taskkill /PID ${child.pid} /F` : `kill ${child.pid}`}`,
1941
2042
  }],
1942
2043
  });
1943
2044
  }
@@ -1973,6 +2074,13 @@ async function main() {
1973
2074
  unlinkSync(mcpSentinel);
1974
2075
  }
1975
2076
  catch { /* best effort */ }
2077
+ // Stop ctx_insight dashboard so it does not outlive Claude.
2078
+ if (_insightChild && _insightChild.pid && !_insightChild.killed) {
2079
+ try {
2080
+ _insightChild.kill("SIGTERM");
2081
+ }
2082
+ catch { /* best effort */ }
2083
+ }
1976
2084
  };
1977
2085
  const gracefulShutdown = async () => {
1978
2086
  shutdown();
@@ -149,13 +149,14 @@ export declare class AnalyticsEngine {
149
149
  queryAll(runtimeStats: RuntimeStats): FullReport;
150
150
  }
151
151
  /**
152
- * Render a FullReport as a before/after comparison developers instantly understand.
152
+ * Render a FullReport as a visual savings dashboard designed for screenshotting.
153
153
  *
154
- * Design rules:
155
- * - If no savings, show "fresh session" format (no fake percentages)
156
- * - Active session shows BEFORE vs AFTER -- what would have flooded your conversation vs what actually did
157
- * - Per-tool table only if 2+ different tools were called
158
- * - Time gained is the hero metric
159
- * - Under 15 lines for typical sessions
154
+ * Design principles:
155
+ * - Before/After comparison bar is the HERO one glance = "wow"
156
+ * - "tokens saved" is the number people share
157
+ * - Per-tool breakdown shows what each tool SAVED, sorted by impact
158
+ * - Session memory: one line, reframed as value
159
+ * - No: Pct column, category tables, tips, jargon
160
+ * - Under 22 lines for heavy sessions, under 10 for fresh
160
161
  */
161
162
  export declare function formatReport(report: FullReport, version?: string, latestVersion?: string | null): string;
@@ -216,7 +216,7 @@ export class AnalyticsEngine {
216
216
  }
217
217
  }
218
218
  // ─────────────────────────────────────────────────────────
219
- // formatReport — renders FullReport as concise, honest output
219
+ // formatReport — renders FullReport as sales-grade savings dashboard
220
220
  // ─────────────────────────────────────────────────────────
221
221
  /** Format bytes as human-readable KB or MB. */
222
222
  function kb(b) {
@@ -224,7 +224,7 @@ function kb(b) {
224
224
  return `${(b / 1024 / 1024).toFixed(1)} MB`;
225
225
  if (b >= 1024)
226
226
  return `${(b / 1024).toFixed(1)} KB`;
227
- return `${b} B`;
227
+ return `${Math.round(b)} B`;
228
228
  }
229
229
  /** Format session uptime as human-readable duration. */
230
230
  function formatDuration(uptimeMin) {
@@ -237,28 +237,34 @@ function formatDuration(uptimeMin) {
237
237
  const m = Math.round(min % 60);
238
238
  return m > 0 ? `${h}h ${m}m` : `${h}h`;
239
239
  }
240
+ /** Format large numbers with K/M suffixes */
241
+ function fmtNum(n) {
242
+ if (n >= 1_000_000)
243
+ return `${(n / 1_000_000).toFixed(1)}M`;
244
+ if (n >= 1_000)
245
+ return `${(n / 1_000).toFixed(1)}K`;
246
+ return String(n);
247
+ }
240
248
  /**
241
- * Build a before/after comparison bar.
242
- *
243
- * The "without" bar is always full (40 chars).
244
- * The "with" bar is proportional to the ratio of returned vs total.
249
+ * Build a proportional bar using █ chars, scaled to a fixed width.
250
+ * Returns e.g. "████████████████████████████████████████" for full width.
245
251
  */
246
- function comparisonBars(total, returned) {
247
- const BAR_WIDTH = 40;
248
- const withoutBar = "#".repeat(BAR_WIDTH);
249
- const withFill = total > 0 ? Math.max(1, Math.round((returned / total) * BAR_WIDTH)) : BAR_WIDTH;
250
- const withBar = "#".repeat(withFill) + " ".repeat(BAR_WIDTH - withFill);
251
- return { withoutBar, withBar };
252
+ function dataBar(bytes, maxBytes, width = 40) {
253
+ if (maxBytes <= 0)
254
+ return "".repeat(width);
255
+ const filled = Math.max(1, Math.round((bytes / maxBytes) * width));
256
+ return "".repeat(Math.min(filled, width)) + "".repeat(Math.max(0, width - filled));
252
257
  }
253
258
  /**
254
- * Render a FullReport as a before/after comparison developers instantly understand.
259
+ * Render a FullReport as a visual savings dashboard designed for screenshotting.
255
260
  *
256
- * Design rules:
257
- * - If no savings, show "fresh session" format (no fake percentages)
258
- * - Active session shows BEFORE vs AFTER -- what would have flooded your conversation vs what actually did
259
- * - Per-tool table only if 2+ different tools were called
260
- * - Time gained is the hero metric
261
- * - Under 15 lines for typical sessions
261
+ * Design principles:
262
+ * - Before/After comparison bar is the HERO one glance = "wow"
263
+ * - "tokens saved" is the number people share
264
+ * - Per-tool breakdown shows what each tool SAVED, sorted by impact
265
+ * - Session memory: one line, reframed as value
266
+ * - No: Pct column, category tables, tips, jargon
267
+ * - Under 22 lines for heavy sessions, under 10 for fresh
262
268
  */
263
269
  export function formatReport(report, version, latestVersion) {
264
270
  const lines = [];
@@ -267,86 +273,100 @@ export function formatReport(report, version, latestVersion) {
267
273
  const totalKeptOut = report.savings.kept_out + (report.cache ? report.cache.bytes_saved : 0);
268
274
  const totalReturned = report.savings.total_bytes_returned;
269
275
  const totalCalls = report.savings.total_calls;
270
- // ── Fresh session: almost no activity ──
276
+ const grandTotal = totalKeptOut + totalReturned;
277
+ const savingsPct = grandTotal > 0 ? (totalKeptOut / grandTotal) * 100 : 0;
278
+ const tokensSaved = Math.round(totalKeptOut / 4);
279
+ // ── Fresh session: no savings yet ──
271
280
  if (totalKeptOut === 0) {
272
- lines.push(`context-mode -- session (${duration})`);
281
+ lines.push(`context-mode ${duration} ${totalCalls} calls`);
273
282
  lines.push("");
274
283
  if (totalCalls === 0) {
275
- lines.push("No tool calls yet.");
284
+ lines.push("No tool calls yet. Use batch_execute or execute to start saving tokens.");
276
285
  }
277
286
  else {
278
- const callLabel = totalCalls === 1 ? "1 tool call" : `${totalCalls} tool calls`;
279
- lines.push(`${callLabel} | ${kb(totalReturned)} in context | no savings yet`);
287
+ lines.push(`${kb(totalReturned)} entered context | 0 tokens saved`);
280
288
  }
289
+ // Footer
281
290
  lines.push("");
282
- lines.push("Tip: Use ctx_execute to analyze files in sandbox -- savings start there.");
283
- lines.push("");
284
- lines.push(version ? `v${version}` : "context-mode");
291
+ const versionStr = version ? `v${version}` : "context-mode";
292
+ lines.push(versionStr);
285
293
  if (version && latestVersion && latestVersion !== "unknown" && latestVersion !== version) {
286
- lines.push(`Update available: v${version} -> v${latestVersion} | Run: ctx_upgrade`);
294
+ lines.push(`Update available: v${version} -> v${latestVersion} | ctx_upgrade`);
287
295
  }
288
296
  return lines.join("\n");
289
297
  }
290
- // ── Active session with real savings ──
291
- const grandTotal = totalKeptOut + totalReturned;
292
- const savingsPercent = grandTotal > 0
293
- ? ((totalKeptOut / grandTotal) * 100).toFixed(1)
294
- : "0.0";
295
- // ── Time saved estimate (hero metric) ──
296
- // ~4 bytes per token, ~1000 tokens per minute of context window capacity
297
- const minSaved = Math.round(totalKeptOut / 4 / 1000);
298
- lines.push(`context-mode -- session (${duration})`);
298
+ // ── Active session: visual savings dashboard ──
299
+ // Line 1: Hero metric — the screenshottable number
300
+ lines.push(`${fmtNum(tokensSaved)} tokens saved · ${savingsPct.toFixed(1)}% reduction · ${duration}`);
301
+ lines.push("");
302
+ // Lines 2-3: Before/After comparison bars — the visual proof
303
+ lines.push(`Without context-mode |${dataBar(grandTotal, grandTotal)}| ${kb(grandTotal)}`);
304
+ lines.push(`With context-mode |${dataBar(totalReturned, grandTotal)}| ${kb(totalReturned)}`);
299
305
  lines.push("");
300
- // ── Before/after comparison ──
301
- const { withoutBar, withBar } = comparisonBars(grandTotal, totalReturned);
302
- lines.push(`Without context-mode: |${withoutBar}| ${kb(grandTotal)} in your conversation`);
303
- lines.push(`With context-mode: |${withBar}| ${kb(totalReturned)} in your conversation`);
306
+ // Value statement the line people share
307
+ lines.push(`${kb(totalKeptOut)} kept out of your conversation. Never entered context.`);
304
308
  lines.push("");
305
- const savingsLine = `${kb(totalKeptOut)} processed in sandbox, never entered your conversation. (${savingsPercent}% reduction)`;
306
- lines.push(savingsLine);
307
- if (minSaved > 0) {
308
- const timeSaved = minSaved >= 60
309
- ? `+${Math.floor(minSaved / 60)}h ${minSaved % 60}m`
310
- : `+${minSaved}m`;
311
- lines.push(`${timeSaved} session time gained.`);
309
+ // Compact stats row
310
+ const statParts = [`${totalCalls} calls`];
311
+ if (report.cache && report.cache.hits > 0) {
312
+ statParts.push(`${report.cache.hits} cache hits (+${kb(report.cache.bytes_saved)})`);
312
313
  }
313
- // ── Per-tool table (only if 2+ different tools) ──
314
+ lines.push(statParts.join(" · "));
315
+ // ── Per-tool breakdown (only if 2+ tools, sorted by saved) ──
314
316
  const activatedTools = report.savings.by_tool.filter((t) => t.calls > 0);
315
317
  if (activatedTools.length >= 2) {
316
318
  lines.push("");
317
- for (const t of activatedTools) {
318
- const returned = t.context_kb * 1024;
319
- const callLabel = `${t.calls} call${t.calls !== 1 ? "s" : ""}`;
320
- lines.push(` ${t.tool.padEnd(22)} ${callLabel.padEnd(10)} ${kb(returned)} used`);
319
+ // Estimate per-tool saved using global savings ratio
320
+ const toolRows = activatedTools.map((t) => {
321
+ const returnedBytes = t.context_kb * 1024;
322
+ const estimatedTotal = savingsPct < 100
323
+ ? returnedBytes / (1 - savingsPct / 100)
324
+ : returnedBytes;
325
+ const estimatedSaved = Math.max(0, estimatedTotal - returnedBytes);
326
+ return { ...t, returnedBytes, estimatedSaved };
327
+ }).sort((a, b) => b.estimatedSaved - a.estimatedSaved);
328
+ // Compact table: tool name, calls, saved
329
+ for (const t of toolRows) {
330
+ const name = t.tool.length > 22 ? t.tool.slice(0, 19) + "..." : t.tool;
331
+ lines.push(` ${name.padEnd(22)} ${String(t.calls).padStart(4)} calls ${kb(t.estimatedSaved).padStart(8)} saved`);
321
332
  }
322
333
  }
323
- // ── Session continuity breakdown ──
324
- if (report.continuity.by_category.length > 0) {
325
- lines.push("");
326
- lines.push(`Session continuity: ${report.continuity.total_events} events preserved across ${report.continuity.compact_count} compaction${report.continuity.compact_count !== 1 ? "s" : ""}`);
334
+ // ── Session memory business-friendly ──
335
+ if (report.continuity.total_events > 0) {
327
336
  lines.push("");
328
- for (const c of report.continuity.by_category) {
329
- const cat = c.category.padEnd(9);
330
- const count = String(c.count).padStart(3);
331
- const preview = c.preview.length > 45 ? c.preview.slice(0, 42) + "..." : c.preview;
332
- lines.push(` ${cat} ${count} ${preview.padEnd(47)} ${c.why}`);
337
+ const cats = report.continuity.by_category;
338
+ // Pick the top 3-4 most impactful categories for a human-readable summary
339
+ const highlights = [];
340
+ const fileCount = cats.find(c => c.category === "file")?.count;
341
+ const gitCount = cats.find(c => c.category === "git")?.count;
342
+ const promptCount = cats.find(c => c.category === "prompt")?.count;
343
+ const errorCount = cats.find(c => c.category === "error")?.count;
344
+ const taskCount = cats.find(c => c.category === "task")?.count;
345
+ if (fileCount)
346
+ highlights.push(`${fileCount} files`);
347
+ if (gitCount)
348
+ highlights.push(`${gitCount} git ops`);
349
+ if (promptCount)
350
+ highlights.push(`${promptCount} prompts`);
351
+ if (errorCount)
352
+ highlights.push(`${errorCount} errors`);
353
+ if (taskCount)
354
+ highlights.push(`${taskCount} tasks`);
355
+ const summary = highlights.length > 0 ? ` · ${highlights.join(", ")}` : "";
356
+ if (report.continuity.compact_count > 0) {
357
+ lines.push(`${fmtNum(report.continuity.total_events)} events remembered across ${report.continuity.compact_count} compaction${report.continuity.compact_count !== 1 ? "s" : ""}${summary}`);
358
+ lines.push("Zero knowledge lost — picks up exactly where you left off.");
359
+ }
360
+ else {
361
+ lines.push(`${fmtNum(report.continuity.total_events)} events tracked${summary}`);
333
362
  }
334
363
  }
335
- // ── Footer: version + outdated warning ──
336
- const footerParts = [];
337
- if (report.continuity.by_category.length === 0 && report.continuity.compact_count > 0) {
338
- footerParts.push(`${report.continuity.compact_count} compaction${report.continuity.compact_count !== 1 ? "s" : ""}`);
339
- }
340
- if (report.continuity.by_category.length === 0 && report.continuity.total_events > 0) {
341
- footerParts.push(`${report.continuity.total_events} event${report.continuity.total_events !== 1 ? "s" : ""} preserved`);
342
- }
343
- const versionStr = version ? `v${version}` : "context-mode";
344
- footerParts.push(versionStr);
364
+ // ── Footer ──
345
365
  lines.push("");
346
- lines.push(footerParts.join(" | "));
347
- // Outdated warning in footer
366
+ const versionStr = version ? `v${version}` : "context-mode";
367
+ lines.push(versionStr);
348
368
  if (version && latestVersion && latestVersion !== "unknown" && latestVersion !== version) {
349
- lines.push(`Update available: v${version} -> v${latestVersion} | Run: ctx_upgrade`);
369
+ lines.push(`Update available: v${version} -> v${latestVersion} | ctx_upgrade`);
350
370
  }
351
371
  return lines.join("\n");
352
372
  }
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { SQLiteBase } from "../db-base.js";
9
9
  import type { SessionEvent } from "../types.js";
10
+ import type { ProjectAttribution } from "./project-attribution.js";
10
11
  /**
11
12
  * Returns the worktree suffix to append to session identifiers.
12
13
  * Returns empty string when running in the main working tree.
@@ -24,6 +25,9 @@ export interface StoredEvent {
24
25
  category: string;
25
26
  priority: number;
26
27
  data: string;
28
+ project_dir: string;
29
+ attribution_source: string;
30
+ attribution_confidence: number;
27
31
  source_hook: string;
28
32
  created_at: string;
29
33
  data_hash: string;
@@ -71,7 +75,7 @@ export declare class SessionDB extends SQLiteBase {
71
75
  * Eviction: if session exceeds MAX_EVENTS_PER_SESSION, evicts the
72
76
  * lowest-priority (then oldest) event.
73
77
  */
74
- insertEvent(sessionId: string, event: SessionEvent, sourceHook?: string): void;
78
+ insertEvent(sessionId: string, event: SessionEvent, sourceHook?: string, attribution?: Partial<ProjectAttribution>): void;
75
79
  /**
76
80
  * Retrieve events for a session with optional filtering.
77
81
  */
@@ -84,8 +88,13 @@ export declare class SessionDB extends SQLiteBase {
84
88
  * Get the total event count for a session.
85
89
  */
86
90
  getEventCount(sessionId: string): number;
91
+ /**
92
+ * Return the most recently attributed project dir for a session.
93
+ */
94
+ getLatestAttributedProjectDir(sessionId: string): string | null;
87
95
  /**
88
96
  * Ensure a session metadata entry exists. Idempotent (INSERT OR IGNORE).
97
+ * `projectDir` is the session origin directory, not per-event attribution.
89
98
  */
90
99
  ensureSession(sessionId: string, projectDir: string): void;
91
100
  /**