context-mode 1.0.103 → 1.0.105

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) 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 +39 -7
  6. package/bin/statusline.mjs +321 -0
  7. package/build/adapters/antigravity/index.d.ts +6 -0
  8. package/build/adapters/antigravity/index.js +10 -0
  9. package/build/adapters/base.d.ts +23 -0
  10. package/build/adapters/base.js +29 -0
  11. package/build/adapters/codex/index.d.ts +10 -0
  12. package/build/adapters/codex/index.js +22 -4
  13. package/build/adapters/cursor/index.d.ts +7 -0
  14. package/build/adapters/cursor/index.js +11 -0
  15. package/build/adapters/detect.d.ts +12 -1
  16. package/build/adapters/detect.js +69 -7
  17. package/build/adapters/gemini-cli/index.d.ts +8 -1
  18. package/build/adapters/gemini-cli/index.js +19 -7
  19. package/build/adapters/jetbrains-copilot/index.d.ts +7 -0
  20. package/build/adapters/jetbrains-copilot/index.js +12 -0
  21. package/build/adapters/kiro/index.d.ts +8 -0
  22. package/build/adapters/kiro/index.js +12 -0
  23. package/build/adapters/openclaw/index.d.ts +17 -0
  24. package/build/adapters/openclaw/index.js +29 -4
  25. package/build/adapters/opencode/index.d.ts +8 -0
  26. package/build/adapters/opencode/index.js +18 -6
  27. package/build/adapters/qwen-code/index.d.ts +1 -0
  28. package/build/adapters/qwen-code/index.js +3 -0
  29. package/build/adapters/types.d.ts +33 -0
  30. package/build/adapters/vscode-copilot/index.d.ts +6 -0
  31. package/build/adapters/vscode-copilot/index.js +10 -0
  32. package/build/adapters/zed/index.d.ts +1 -0
  33. package/build/adapters/zed/index.js +3 -0
  34. package/build/cli.d.ts +15 -0
  35. package/build/cli.js +62 -16
  36. package/build/concurrency/runPool.d.ts +36 -0
  37. package/build/concurrency/runPool.js +51 -0
  38. package/build/executor.d.ts +11 -1
  39. package/build/executor.js +77 -21
  40. package/build/fetch-cache.d.ts +13 -0
  41. package/build/fetch-cache.js +15 -0
  42. package/build/lifecycle.d.ts +6 -2
  43. package/build/lifecycle.js +29 -2
  44. package/build/opencode-plugin.d.ts +23 -0
  45. package/build/opencode-plugin.js +80 -6
  46. package/build/routing-block.d.ts +8 -0
  47. package/build/routing-block.js +86 -0
  48. package/build/runtime.d.ts +1 -0
  49. package/build/runtime.js +54 -3
  50. package/build/search/auto-memory.d.ts +23 -10
  51. package/build/search/auto-memory.js +64 -26
  52. package/build/search/unified.d.ts +3 -0
  53. package/build/search/unified.js +2 -2
  54. package/build/server.d.ts +47 -0
  55. package/build/server.js +736 -188
  56. package/build/session/analytics.d.ts +49 -1
  57. package/build/session/analytics.js +278 -16
  58. package/build/session/db.d.ts +53 -8
  59. package/build/session/db.js +200 -19
  60. package/build/session/extract.js +124 -2
  61. package/build/tool-naming.d.ts +4 -0
  62. package/build/tool-naming.js +24 -0
  63. package/cli.bundle.mjs +208 -158
  64. package/configs/antigravity/GEMINI.md +11 -0
  65. package/configs/claude-code/CLAUDE.md +11 -0
  66. package/configs/codex/AGENTS.md +11 -0
  67. package/configs/cursor/context-mode.mdc +11 -0
  68. package/configs/gemini-cli/GEMINI.md +11 -0
  69. package/configs/jetbrains-copilot/copilot-instructions.md +3 -0
  70. package/configs/kilo/AGENTS.md +11 -0
  71. package/configs/kiro/KIRO.md +11 -0
  72. package/configs/openclaw/AGENTS.md +11 -0
  73. package/configs/opencode/AGENTS.md +11 -0
  74. package/configs/pi/AGENTS.md +11 -0
  75. package/configs/qwen-code/QWEN.md +11 -0
  76. package/configs/vscode-copilot/copilot-instructions.md +3 -0
  77. package/configs/zed/AGENTS.md +11 -0
  78. package/hooks/auto-injection.mjs +36 -10
  79. package/hooks/cache-heal-utils.mjs +231 -0
  80. package/hooks/codex/sessionstart.mjs +7 -4
  81. package/hooks/core/routing.mjs +8 -2
  82. package/hooks/cursor/sessionstart.mjs +7 -4
  83. package/hooks/formatters/claude-code.mjs +20 -0
  84. package/hooks/gemini-cli/sessionstart.mjs +7 -2
  85. package/hooks/jetbrains-copilot/sessionstart.mjs +7 -2
  86. package/hooks/normalize-hooks.mjs +184 -0
  87. package/hooks/session-db.bundle.mjs +41 -14
  88. package/hooks/session-extract.bundle.mjs +2 -2
  89. package/hooks/session-helpers.mjs +68 -20
  90. package/hooks/session-loaders.mjs +8 -2
  91. package/hooks/sessionstart.mjs +8 -2
  92. package/hooks/vscode-copilot/sessionstart.mjs +7 -2
  93. package/openclaw.plugin.json +1 -1
  94. package/package.json +2 -1
  95. package/server.bundle.mjs +181 -134
  96. package/skills/ctx-doctor/SKILL.md +3 -3
  97. package/skills/ctx-insight/SKILL.md +1 -1
  98. package/start.mjs +63 -3
@@ -19,31 +19,46 @@ import { execFileSync } from "node:child_process";
19
19
  * (useful in CI environments or when git is unavailable).
20
20
  * Set to empty string to disable isolation entirely.
21
21
  */
22
+ // Memoized per (cwd, env override) — recomputing on every tool call cost
23
+ // ~12ms (git worktree list subprocess fork) on macOS, 50ms+ on Windows.
24
+ // Key by cwd so a defensive `process.chdir()` invalidates rather than
25
+ // returning stale data.
26
+ let _wtCache;
22
27
  export function getWorktreeSuffix() {
23
28
  const envSuffix = process.env.CONTEXT_MODE_SESSION_SUFFIX;
29
+ const cwd = process.cwd();
30
+ if (_wtCache && _wtCache.cwd === cwd && _wtCache.envSuffix === envSuffix) {
31
+ return _wtCache.suffix;
32
+ }
33
+ let suffix = "";
24
34
  if (envSuffix !== undefined) {
25
- return envSuffix ? `__${envSuffix}` : "";
35
+ suffix = envSuffix ? `__${envSuffix}` : "";
26
36
  }
27
- try {
28
- const cwd = process.cwd();
29
- const mainWorktree = execFileSync("git", ["worktree", "list", "--porcelain"], {
30
- encoding: "utf-8",
31
- timeout: 2000,
32
- stdio: ["ignore", "pipe", "ignore"],
33
- })
34
- .split(/\r?\n/)
35
- .find((l) => l.startsWith("worktree "))
36
- ?.replace("worktree ", "")
37
- ?.trim();
38
- if (mainWorktree && cwd !== mainWorktree) {
39
- const suffix = createHash("sha256").update(cwd).digest("hex").slice(0, 8);
40
- return `__${suffix}`;
37
+ else {
38
+ try {
39
+ const mainWorktree = execFileSync("git", ["worktree", "list", "--porcelain"], {
40
+ encoding: "utf-8",
41
+ timeout: 2000,
42
+ stdio: ["ignore", "pipe", "ignore"],
43
+ })
44
+ .split(/\r?\n/)
45
+ .find((l) => l.startsWith("worktree "))
46
+ ?.replace("worktree ", "")
47
+ ?.trim();
48
+ if (mainWorktree && cwd !== mainWorktree) {
49
+ suffix = `__${createHash("sha256").update(cwd).digest("hex").slice(0, 8)}`;
50
+ }
51
+ }
52
+ catch {
53
+ // git not available or not a git repo — no suffix
41
54
  }
42
55
  }
43
- catch {
44
- // git not available or not a git repo — no suffix
45
- }
46
- return "";
56
+ _wtCache = { cwd, envSuffix, suffix };
57
+ return suffix;
58
+ }
59
+ // Test-only helper: clear the memoization between cases.
60
+ export function _resetWorktreeSuffixCacheForTests() {
61
+ _wtCache = undefined;
47
62
  }
48
63
  // ─────────────────────────────────────────────────────────
49
64
  // Constants
@@ -72,11 +87,15 @@ const S = {
72
87
  upsertResume: "upsertResume",
73
88
  getResume: "getResume",
74
89
  markResumeConsumed: "markResumeConsumed",
90
+ claimLatestUnconsumedResume: "claimLatestUnconsumedResume",
75
91
  deleteEvents: "deleteEvents",
76
92
  deleteMeta: "deleteMeta",
77
93
  deleteResume: "deleteResume",
78
94
  getOldSessions: "getOldSessions",
79
95
  searchEvents: "searchEvents",
96
+ incrementToolCall: "incrementToolCall",
97
+ getToolCallTotals: "getToolCallTotals",
98
+ getToolCallByTool: "getToolCallByTool",
80
99
  };
81
100
  // ─────────────────────────────────────────────────────────
82
101
  // SessionDB
@@ -140,6 +159,17 @@ export class SessionDB extends SQLiteBase {
140
159
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
141
160
  consumed INTEGER NOT NULL DEFAULT 0
142
161
  );
162
+
163
+ CREATE TABLE IF NOT EXISTS tool_calls (
164
+ session_id TEXT NOT NULL,
165
+ tool TEXT NOT NULL,
166
+ calls INTEGER NOT NULL DEFAULT 0,
167
+ bytes_returned INTEGER NOT NULL DEFAULT 0,
168
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
169
+ PRIMARY KEY (session_id, tool)
170
+ );
171
+
172
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_session ON tool_calls(session_id);
143
173
  `);
144
174
  // Migration: add per-event attribution columns for existing DBs.
145
175
  try {
@@ -222,6 +252,19 @@ export class SessionDB extends SQLiteBase {
222
252
  consumed = 0`);
223
253
  p(S.getResume, `SELECT snapshot, event_count, consumed FROM session_resume WHERE session_id = ?`);
224
254
  p(S.markResumeConsumed, `UPDATE session_resume SET consumed = 1 WHERE session_id = ?`);
255
+ // Atomic "pick newest unconsumed snapshot AND mark it consumed in one
256
+ // statement". Required for race-safe cross-session resume injection
257
+ // (Mickey / PR #376) — two parallel chat-turn hooks must not both read
258
+ // the same row before either one writes consumed=1.
259
+ p(S.claimLatestUnconsumedResume, `UPDATE session_resume
260
+ SET consumed = 1
261
+ WHERE id = (
262
+ SELECT id FROM session_resume
263
+ WHERE consumed = 0
264
+ ORDER BY created_at DESC, id DESC
265
+ LIMIT 1
266
+ )
267
+ RETURNING session_id, snapshot`);
225
268
  // ── Delete ──
226
269
  p(S.deleteEvents, `DELETE FROM session_events WHERE session_id = ?`);
227
270
  p(S.deleteMeta, `DELETE FROM session_meta WHERE session_id = ?`);
@@ -236,6 +279,18 @@ export class SessionDB extends SQLiteBase {
236
279
  LIMIT ?`);
237
280
  // ── Cleanup ──
238
281
  p(S.getOldSessions, `SELECT session_id FROM session_meta WHERE started_at < datetime('now', ? || ' days')`);
282
+ // ── Tool calls (persistent counter) ──
283
+ p(S.incrementToolCall, `INSERT INTO tool_calls (session_id, tool, calls, bytes_returned)
284
+ VALUES (?, ?, 1, ?)
285
+ ON CONFLICT(session_id, tool) DO UPDATE SET
286
+ calls = calls + 1,
287
+ bytes_returned = bytes_returned + excluded.bytes_returned,
288
+ updated_at = datetime('now')`);
289
+ p(S.getToolCallTotals, `SELECT COALESCE(SUM(calls), 0) AS calls,
290
+ COALESCE(SUM(bytes_returned), 0) AS bytes_returned
291
+ FROM tool_calls WHERE session_id = ?`);
292
+ p(S.getToolCallByTool, `SELECT tool, calls, bytes_returned
293
+ FROM tool_calls WHERE session_id = ? ORDER BY calls DESC`);
239
294
  }
240
295
  // ═══════════════════════════════════════════
241
296
  // Events
@@ -287,6 +342,60 @@ export class SessionDB extends SQLiteBase {
287
342
  });
288
343
  this.withRetry(() => transaction());
289
344
  }
345
+ /**
346
+ * Bulk-insert N events in a SINGLE transaction.
347
+ *
348
+ * PostToolUse hooks emit 5–15 events per tool call. Calling insertEvent()
349
+ * in a loop runs N transactions = N WAL commits = N fsync candidates,
350
+ * which is painful on Windows NTFS where commit latency dominates.
351
+ * One transaction = one commit, dedup/evict checks reuse cached statements.
352
+ *
353
+ * Cross-platform: uses the same WAL-mode transaction primitive as
354
+ * insertEvent — behavior identical on macOS / Linux / Windows.
355
+ */
356
+ bulkInsertEvents(sessionId, events, sourceHook = "PostToolUse", attributions) {
357
+ if (!events || events.length === 0)
358
+ return;
359
+ if (events.length === 1) {
360
+ // Cheaper to fall through to insertEvent (its own dedicated transaction).
361
+ this.insertEvent(sessionId, events[0], sourceHook, attributions?.[0]);
362
+ return;
363
+ }
364
+ // Pre-compute hashes + normalized attribution outside the transaction
365
+ // so the SQL transaction holds only DB work (shorter lock window).
366
+ const prepared = events.map((event, i) => {
367
+ const dataHash = createHash("sha256")
368
+ .update(event.data)
369
+ .digest("hex")
370
+ .slice(0, 16)
371
+ .toUpperCase();
372
+ const attribution = attributions?.[i];
373
+ const projectDir = String(attribution?.projectDir ?? event.project_dir ?? "").trim();
374
+ const attributionSource = String(attribution?.source ?? event.attribution_source ?? "unknown");
375
+ const rawConfidence = Number(attribution?.confidence ?? event.attribution_confidence ?? 0);
376
+ const attributionConfidence = Number.isFinite(rawConfidence)
377
+ ? Math.max(0, Math.min(1, rawConfidence))
378
+ : 0;
379
+ return { event, dataHash, projectDir, attributionSource, attributionConfidence };
380
+ });
381
+ const transaction = this.db.transaction(() => {
382
+ let cnt = this.stmt(S.getEventCount).get(sessionId).cnt;
383
+ for (const row of prepared) {
384
+ const dup = this.stmt(S.checkDuplicate).get(sessionId, DEDUP_WINDOW, row.event.type, row.dataHash);
385
+ if (dup)
386
+ continue;
387
+ if (cnt >= MAX_EVENTS_PER_SESSION) {
388
+ this.stmt(S.evictLowestPriority).run(sessionId);
389
+ }
390
+ else {
391
+ cnt++;
392
+ }
393
+ this.stmt(S.insertEvent).run(sessionId, row.event.type, row.event.category, row.event.priority, row.event.data, row.projectDir, row.attributionSource, row.attributionConfidence, sourceHook, row.dataHash);
394
+ }
395
+ this.stmt(S.updateMetaLastEvent).run(sessionId);
396
+ });
397
+ this.withRetry(() => transaction());
398
+ }
290
399
  /**
291
400
  * Retrieve events for a session with optional filtering.
292
401
  */
@@ -383,6 +492,78 @@ export class SessionDB extends SQLiteBase {
383
492
  markResumeConsumed(sessionId) {
384
493
  this.stmt(S.markResumeConsumed).run(sessionId);
385
494
  }
495
+ /**
496
+ * Atomically claim the most recent unconsumed resume snapshot in this DB.
497
+ *
498
+ * `SessionDB` is sharded per project (see `getSessionDBPath` — SHA-256 of
499
+ * project dir), so "this DB" already implies "this project". The atomic
500
+ * `UPDATE … RETURNING` ensures concurrent processes for the same project
501
+ * cannot both inject the same snapshot (Mickey / PR #376 race).
502
+ *
503
+ * Returns null when no unconsumed snapshot exists.
504
+ */
505
+ claimLatestUnconsumedResume() {
506
+ const row = this.stmt(S.claimLatestUnconsumedResume).get();
507
+ if (!row)
508
+ return null;
509
+ return { sessionId: row.session_id, snapshot: row.snapshot };
510
+ }
511
+ /**
512
+ * Return the most recent session_id from session_meta, or null if none.
513
+ * Used by the runtime to attach persistent counters to the right session
514
+ * after a process restart.
515
+ */
516
+ getLatestSessionId() {
517
+ try {
518
+ const row = this.db.prepare("SELECT session_id FROM session_meta ORDER BY started_at DESC LIMIT 1").get();
519
+ return row?.session_id ?? null;
520
+ }
521
+ catch {
522
+ return null;
523
+ }
524
+ }
525
+ // ═══════════════════════════════════════════
526
+ // Tool call counters (Bug #1 + #2 — survive restart, --continue, upgrade)
527
+ // ═══════════════════════════════════════════
528
+ /**
529
+ * Increment the persistent tool-call counter for `tool` in `sessionId`.
530
+ * Adds `bytesReturned` to the cumulative total. Idempotent across
531
+ * SessionDB instances — counters survive process restart.
532
+ */
533
+ incrementToolCall(sessionId, tool, bytesReturned = 0) {
534
+ const safeBytes = Number.isFinite(bytesReturned) && bytesReturned > 0 ? Math.round(bytesReturned) : 0;
535
+ try {
536
+ this.stmt(S.incrementToolCall).run(sessionId, tool, safeBytes);
537
+ }
538
+ catch {
539
+ // best-effort: counter must never throw and break the parent call
540
+ }
541
+ }
542
+ /**
543
+ * Get aggregated tool-call stats for `sessionId`. Returns zero-stats
544
+ * when the session has no recorded calls.
545
+ */
546
+ getToolCallStats(sessionId) {
547
+ try {
548
+ const totals = this.stmt(S.getToolCallTotals).get(sessionId);
549
+ const rows = this.stmt(S.getToolCallByTool).all(sessionId);
550
+ const byTool = {};
551
+ for (const row of rows) {
552
+ byTool[row.tool] = {
553
+ calls: row.calls,
554
+ bytesReturned: row.bytes_returned,
555
+ };
556
+ }
557
+ return {
558
+ totalCalls: totals?.calls ?? 0,
559
+ totalBytesReturned: totals?.bytes_returned ?? 0,
560
+ byTool,
561
+ };
562
+ }
563
+ catch {
564
+ return { totalCalls: 0, totalBytesReturned: 0, byTool: {} };
565
+ }
566
+ }
386
567
  // ═══════════════════════════════════════════
387
568
  // Lifecycle
388
569
  // ═══════════════════════════════════════════
@@ -32,8 +32,23 @@ function extractFileAndRule(input) {
32
32
  const events = [];
33
33
  if (tool_name === "Read") {
34
34
  const filePath = String(tool_input["file_path"] ?? "");
35
- // Rule detection: CLAUDE.md or anything inside a .claude/ directory
36
- const isRuleFile = /CLAUDE\.md$|\.claude[\\/]/i.test(filePath);
35
+ // Rule detection covers every supported platform's instruction
36
+ // file convention plus per-user memory directories. Hardcoding here
37
+ // (instead of dispatching through the adapter) keeps extract.ts
38
+ // pure / sync / hot-path-safe — the tradeoff is that adding a new
39
+ // platform requires updating this regex.
40
+ //
41
+ // Filenames: CLAUDE.md, AGENTS.md, AGENTS.override.md, GEMINI.md,
42
+ // QWEN.md, KIRO.md, copilot-instructions.md,
43
+ // context-mode.mdc
44
+ // Directories: .claude/, .codex/memories/, .qwen/memory/,
45
+ // .gemini/memory/, .config/<plat>/memory/, .cursor/memory/,
46
+ // .github/memory/, .kiro/memory/, etc.
47
+ const isRuleFile = /(?:CLAUDE|AGENTS(?:\.override)?|GEMINI|QWEN|KIRO)\.md$/i.test(filePath)
48
+ || /\/copilot-instructions\.md$/i.test(filePath)
49
+ || /\/context-mode\.mdc$/i.test(filePath)
50
+ || /\.claude[\\/]/i.test(filePath)
51
+ || /[\\/]memor(?:y|ies)[\\/][^\\/]+\.md$/i.test(filePath);
37
52
  if (isRuleFile) {
38
53
  events.push({
39
54
  type: "rule",
@@ -415,6 +430,112 @@ function extractMcp(input) {
415
430
  priority: 3,
416
431
  }];
417
432
  }
433
+ /**
434
+ * Category 27: mcp_tool_call
435
+ * Records the raw MCP call shape (tool_name + tool_input) so analytics
436
+ * can compute usage patterns like batch concurrency.
437
+ *
438
+ * Distinct from `extractMcp` (category "mcp"), which captures the textual
439
+ * call+response for FTS5 search. This emits a structured JSON payload
440
+ * keyed by tool_name + params, capped to ~2KB to keep SQLite rows small.
441
+ *
442
+ * Priority 4 (informational) — should not crowd out high-signal events
443
+ * during FIFO eviction.
444
+ */
445
+ const MCP_PARAMS_BUDGET_BYTES = 2048;
446
+ /**
447
+ * UTF-8-aware string truncation. Returns the longest prefix of `s` whose
448
+ * UTF-8 byte length is <= `maxBytes`, never landing mid-multibyte-codepoint.
449
+ *
450
+ * Naive `s.slice(0, N)` operates on UTF-16 code units, so a 2KB cap could
451
+ * either over-shoot (multi-byte codepoints occupy fewer code units than
452
+ * bytes — e.g. a chunk of CJK / emoji-heavy JSON would silently exceed
453
+ * the byte budget) or land mid surrogate pair (corrupt JSON downstream).
454
+ */
455
+ function truncateToBytes(s, maxBytes) {
456
+ if (Buffer.byteLength(s, "utf8") <= maxBytes)
457
+ return { value: s, truncated: false };
458
+ const buf = Buffer.from(s, "utf8");
459
+ // Walk back from maxBytes until the byte starts a fresh codepoint:
460
+ // 0xxxxxxx → ASCII (start)
461
+ // 11xxxxxx → start of multi-byte
462
+ // 10xxxxxx → continuation; keep walking
463
+ let cut = maxBytes;
464
+ while (cut > 0 && (buf[cut] & 0xc0) === 0x80)
465
+ cut--;
466
+ return { value: buf.subarray(0, cut).toString("utf8"), truncated: true };
467
+ }
468
+ /**
469
+ * Keys whose VALUES must be redacted before persisting tool_input — secrets,
470
+ * tokens, credentials, signatures. Match is on the LAST path segment of the
471
+ * key (case-insensitive substring), so `headers.Authorization`, `auth.token`,
472
+ * `apiKey`, `API_KEY`, `password`, `secret`, `cookie`, `set-cookie`, `signature`,
473
+ * `private_key`, etc. all redact. False-positive risk acceptable — we'd rather
474
+ * over-redact than ship a Bearer token to SQLite.
475
+ */
476
+ const SECRET_KEY_PATTERN = /(authorization|auth_token|access_token|refresh_token|bearer|token|secret|password|passwd|pwd|api[-_]?key|apikey|cookie|set-cookie|signature|private[-_]?key|client[-_]?secret|x[-_]?api[-_]?key)/i;
477
+ const REDACTED = "[REDACTED]";
478
+ /**
479
+ * Walk an arbitrary JSON-serializable value and return a clone with values
480
+ * redacted under any key matching SECRET_KEY_PATTERN. Cycle-safe.
481
+ */
482
+ function redactSecrets(value, ancestors = new WeakSet()) {
483
+ if (value == null || typeof value !== "object")
484
+ return value;
485
+ // Path-based ancestor check: only flag TRUE cycles, not DAG / shared refs
486
+ // (e.g., a single `headers` object passed to multiple sub-requests must
487
+ // be processed at every reference site, not flagged as circular).
488
+ if (ancestors.has(value))
489
+ return "[CIRCULAR]";
490
+ ancestors.add(value);
491
+ let out;
492
+ if (Array.isArray(value)) {
493
+ out = value.map((v) => redactSecrets(v, ancestors));
494
+ }
495
+ else {
496
+ const obj = {};
497
+ for (const [k, v] of Object.entries(value)) {
498
+ if (SECRET_KEY_PATTERN.test(k)) {
499
+ obj[k] = REDACTED;
500
+ }
501
+ else {
502
+ obj[k] = redactSecrets(v, ancestors);
503
+ }
504
+ }
505
+ out = obj;
506
+ }
507
+ ancestors.delete(value); // pop ancestor — siblings can re-visit
508
+ return out;
509
+ }
510
+ function extractMcpToolCall(input) {
511
+ const { tool_name, tool_input } = input;
512
+ if (!tool_name.startsWith("mcp__"))
513
+ return [];
514
+ // Redact secrets BEFORE serialization. Any `tool_input` carrying
515
+ // `Authorization: Bearer …`, `api_key: "sk-…"`, cookies, signatures, etc.
516
+ // is masked before it touches SQLite. Over-redaction acceptable — under-
517
+ // redaction is a credential leak to SessionDB.
518
+ const redactedInput = redactSecrets(tool_input ?? {});
519
+ // Serialize the redacted shape, then truncate the *string* (not the object)
520
+ // so the diagnosable shape survives huge payloads.
521
+ let paramsStr;
522
+ try {
523
+ paramsStr = JSON.stringify(redactedInput);
524
+ }
525
+ catch {
526
+ paramsStr = "{}";
527
+ }
528
+ const { value: cappedStr, truncated } = truncateToBytes(paramsStr, MCP_PARAMS_BUDGET_BYTES);
529
+ const payload = truncated
530
+ ? `{"tool_name":${JSON.stringify(tool_name)},"params_raw":${JSON.stringify(cappedStr)},"truncated":true}`
531
+ : `{"tool_name":${JSON.stringify(tool_name)},"params":${cappedStr}}`;
532
+ return [{
533
+ type: "mcp_tool_call",
534
+ category: "mcp_tool_call",
535
+ data: safeString(payload),
536
+ priority: 4,
537
+ }];
538
+ }
418
539
  /**
419
540
  * Category 6 (tool-based): decision
420
541
  * AskUserQuestion tool — tracks questions posed to user and their answers.
@@ -751,6 +872,7 @@ export function extractEvents(input) {
751
872
  events.push(...extractSkill(input));
752
873
  events.push(...extractSubagent(input));
753
874
  events.push(...extractMcp(input));
875
+ events.push(...extractMcpToolCall(input));
754
876
  events.push(...extractDecision(input));
755
877
  events.push(...extractConstraint(input));
756
878
  events.push(...extractWorktree(input));
@@ -0,0 +1,4 @@
1
+ export declare function getToolName(platform: string, bareTool: string): string;
2
+ export type ToolNamer = (bareTool: string) => string;
3
+ export declare function createToolNamer(platform: string): ToolNamer;
4
+ export declare const KNOWN_PLATFORMS: string[];
@@ -0,0 +1,24 @@
1
+ const TOOL_PREFIXES = {
2
+ "claude-code": (tool) => `mcp__plugin_context-mode_context-mode__${tool}`,
3
+ "gemini-cli": (tool) => `mcp__context-mode__${tool}`,
4
+ "antigravity": (tool) => `mcp__context-mode__${tool}`,
5
+ "opencode": (tool) => `context-mode_${tool}`,
6
+ "kilo": (tool) => `context-mode_${tool}`,
7
+ "vscode-copilot": (tool) => `context-mode_${tool}`,
8
+ "jetbrains-copilot": (tool) => `context-mode_${tool}`,
9
+ "kiro": (tool) => `@context-mode/${tool}`,
10
+ "zed": (tool) => `mcp:context-mode:${tool}`,
11
+ "cursor": (tool) => tool,
12
+ "codex": (tool) => tool,
13
+ "openclaw": (tool) => tool,
14
+ "pi": (tool) => tool,
15
+ "qwen-code": (tool) => `mcp__context-mode__${tool}`,
16
+ };
17
+ export function getToolName(platform, bareTool) {
18
+ const fn = TOOL_PREFIXES[platform] || TOOL_PREFIXES["claude-code"];
19
+ return fn(bareTool);
20
+ }
21
+ export function createToolNamer(platform) {
22
+ return (bareTool) => getToolName(platform, bareTool);
23
+ }
24
+ export const KNOWN_PLATFORMS = Object.keys(TOOL_PREFIXES);