context-mode 1.0.89 → 1.0.91

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 (128) 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 +12 -140
  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 +13 -0
  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/runtime.js +24 -9
  54. package/build/security.d.ts +17 -1
  55. package/build/security.js +40 -6
  56. package/build/server.js +53 -10
  57. package/build/session/analytics.d.ts +8 -7
  58. package/build/session/analytics.js +107 -76
  59. package/build/session/db.d.ts +10 -1
  60. package/build/session/db.js +67 -8
  61. package/build/session/extract.js +10 -2
  62. package/build/session/project-attribution.d.ts +73 -0
  63. package/build/session/project-attribution.js +231 -0
  64. package/build/store.d.ts +4 -0
  65. package/build/store.js +58 -9
  66. package/build/types.d.ts +8 -0
  67. package/cli.bundle.mjs +135 -121
  68. package/configs/antigravity/GEMINI.md +31 -36
  69. package/configs/claude-code/CLAUDE.md +31 -37
  70. package/configs/codex/AGENTS.md +35 -49
  71. package/configs/cursor/context-mode.mdc +24 -25
  72. package/configs/gemini-cli/GEMINI.md +30 -36
  73. package/configs/jetbrains-copilot/copilot-instructions.md +59 -0
  74. package/configs/jetbrains-copilot/hooks.json +16 -0
  75. package/configs/jetbrains-copilot/mcp.json +8 -0
  76. package/configs/kilo/AGENTS.md +30 -36
  77. package/configs/kiro/KIRO.md +30 -36
  78. package/configs/kiro/agent.json +1 -1
  79. package/configs/openclaw/AGENTS.md +30 -36
  80. package/configs/opencode/AGENTS.md +30 -36
  81. package/configs/pi/AGENTS.md +31 -36
  82. package/configs/qwen-code/QWEN.md +63 -0
  83. package/configs/vscode-copilot/copilot-instructions.md +30 -36
  84. package/configs/zed/AGENTS.md +31 -36
  85. package/hooks/codex/posttooluse.mjs +7 -7
  86. package/hooks/codex/pretooluse.mjs +3 -3
  87. package/hooks/codex/sessionstart.mjs +2 -1
  88. package/hooks/core/formatters.mjs +24 -0
  89. package/hooks/core/routing.mjs +40 -15
  90. package/hooks/core/tool-naming.mjs +2 -0
  91. package/hooks/cursor/posttooluse.mjs +7 -7
  92. package/hooks/cursor/pretooluse.mjs +3 -3
  93. package/hooks/cursor/sessionstart.mjs +2 -1
  94. package/hooks/cursor/stop.mjs +2 -2
  95. package/hooks/ensure-deps.mjs +22 -10
  96. package/hooks/gemini-cli/aftertool.mjs +8 -8
  97. package/hooks/gemini-cli/beforetool.mjs +3 -2
  98. package/hooks/gemini-cli/precompress.mjs +2 -2
  99. package/hooks/gemini-cli/sessionstart.mjs +12 -4
  100. package/hooks/jetbrains-copilot/posttooluse.mjs +61 -0
  101. package/hooks/jetbrains-copilot/precompact.mjs +54 -0
  102. package/hooks/jetbrains-copilot/pretooluse.mjs +27 -0
  103. package/hooks/jetbrains-copilot/sessionstart.mjs +119 -0
  104. package/hooks/kiro/posttooluse.mjs +6 -7
  105. package/hooks/kiro/pretooluse.mjs +3 -2
  106. package/hooks/posttooluse.mjs +8 -8
  107. package/hooks/precompact.mjs +3 -4
  108. package/hooks/pretooluse.mjs +5 -4
  109. package/hooks/routing-block.mjs +35 -33
  110. package/hooks/session-attribution.bundle.mjs +1 -0
  111. package/hooks/session-db.bundle.mjs +27 -8
  112. package/hooks/session-extract.bundle.mjs +2 -1
  113. package/hooks/session-helpers.mjs +44 -3
  114. package/hooks/session-loaders.mjs +37 -0
  115. package/hooks/sessionstart.mjs +5 -5
  116. package/hooks/userpromptsubmit.mjs +26 -9
  117. package/hooks/vscode-copilot/posttooluse.mjs +8 -8
  118. package/hooks/vscode-copilot/precompact.mjs +2 -2
  119. package/hooks/vscode-copilot/pretooluse.mjs +3 -2
  120. package/hooks/vscode-copilot/sessionstart.mjs +2 -2
  121. package/insight/server.mjs +237 -25
  122. package/insight/src/lib/api.ts +2 -1
  123. package/insight/src/routes/index.tsx +16 -3
  124. package/insight/src/routes/search.tsx +1 -1
  125. package/openclaw.plugin.json +1 -1
  126. package/package.json +11 -2
  127. package/server.bundle.mjs +94 -80
  128. package/skills/ctx-insight/SKILL.md +1 -1
@@ -8,6 +8,17 @@
8
8
  * const engine = new AnalyticsEngine(sessionDb);
9
9
  * const report = engine.queryAll(runtimeStats);
10
10
  */
11
+ function semverNewer(a, b) {
12
+ const pa = a.split(".").map(Number);
13
+ const pb = b.split(".").map(Number);
14
+ for (let i = 0; i < 3; i++) {
15
+ if ((pa[i] ?? 0) > (pb[i] ?? 0))
16
+ return true;
17
+ if ((pa[i] ?? 0) < (pb[i] ?? 0))
18
+ return false;
19
+ }
20
+ return false;
21
+ }
11
22
  // ─────────────────────────────────────────────────────────
12
23
  // Category labels and hints for session continuity display
13
24
  // ─────────────────────────────────────────────────────────
@@ -216,7 +227,7 @@ export class AnalyticsEngine {
216
227
  }
217
228
  }
218
229
  // ─────────────────────────────────────────────────────────
219
- // formatReport — renders FullReport as concise, honest output
230
+ // formatReport — renders FullReport as sales-grade savings dashboard
220
231
  // ─────────────────────────────────────────────────────────
221
232
  /** Format bytes as human-readable KB or MB. */
222
233
  function kb(b) {
@@ -224,7 +235,7 @@ function kb(b) {
224
235
  return `${(b / 1024 / 1024).toFixed(1)} MB`;
225
236
  if (b >= 1024)
226
237
  return `${(b / 1024).toFixed(1)} KB`;
227
- return `${b} B`;
238
+ return `${Math.round(b)} B`;
228
239
  }
229
240
  /** Format session uptime as human-readable duration. */
230
241
  function formatDuration(uptimeMin) {
@@ -237,28 +248,34 @@ function formatDuration(uptimeMin) {
237
248
  const m = Math.round(min % 60);
238
249
  return m > 0 ? `${h}h ${m}m` : `${h}h`;
239
250
  }
251
+ /** Format large numbers with K/M suffixes */
252
+ function fmtNum(n) {
253
+ if (n >= 1_000_000)
254
+ return `${(n / 1_000_000).toFixed(1)}M`;
255
+ if (n >= 1_000)
256
+ return `${(n / 1_000).toFixed(1)}K`;
257
+ return String(n);
258
+ }
240
259
  /**
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.
260
+ * Build a proportional bar using █ chars, scaled to a fixed width.
261
+ * Returns e.g. "████████████████████████████████████████" for full width.
245
262
  */
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 };
263
+ function dataBar(bytes, maxBytes, width = 40) {
264
+ if (maxBytes <= 0)
265
+ return "".repeat(width);
266
+ const filled = Math.max(1, Math.round((bytes / maxBytes) * width));
267
+ return "".repeat(Math.min(filled, width)) + "".repeat(Math.max(0, width - filled));
252
268
  }
253
269
  /**
254
- * Render a FullReport as a before/after comparison developers instantly understand.
270
+ * Render a FullReport as a visual savings dashboard designed for screenshotting.
255
271
  *
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
272
+ * Design principles:
273
+ * - Before/After comparison bar is the HERO one glance = "wow"
274
+ * - "tokens saved" is the number people share
275
+ * - Per-tool breakdown shows what each tool SAVED, sorted by impact
276
+ * - Session memory: one line, reframed as value
277
+ * - No: Pct column, category tables, tips, jargon
278
+ * - Under 22 lines for heavy sessions, under 10 for fresh
262
279
  */
263
280
  export function formatReport(report, version, latestVersion) {
264
281
  const lines = [];
@@ -267,86 +284,100 @@ export function formatReport(report, version, latestVersion) {
267
284
  const totalKeptOut = report.savings.kept_out + (report.cache ? report.cache.bytes_saved : 0);
268
285
  const totalReturned = report.savings.total_bytes_returned;
269
286
  const totalCalls = report.savings.total_calls;
270
- // ── Fresh session: almost no activity ──
287
+ const grandTotal = totalKeptOut + totalReturned;
288
+ const savingsPct = grandTotal > 0 ? (totalKeptOut / grandTotal) * 100 : 0;
289
+ const tokensSaved = Math.round(totalKeptOut / 4);
290
+ // ── Fresh session: no savings yet ──
271
291
  if (totalKeptOut === 0) {
272
- lines.push(`context-mode -- session (${duration})`);
292
+ lines.push(`context-mode ${duration} ${totalCalls} calls`);
273
293
  lines.push("");
274
294
  if (totalCalls === 0) {
275
- lines.push("No tool calls yet.");
295
+ lines.push("No tool calls yet. Use batch_execute or execute to start saving tokens.");
276
296
  }
277
297
  else {
278
- const callLabel = totalCalls === 1 ? "1 tool call" : `${totalCalls} tool calls`;
279
- lines.push(`${callLabel} | ${kb(totalReturned)} in context | no savings yet`);
298
+ lines.push(`${kb(totalReturned)} entered context | 0 tokens saved`);
280
299
  }
300
+ // Footer
281
301
  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");
285
- if (version && latestVersion && latestVersion !== "unknown" && latestVersion !== version) {
286
- lines.push(`Update available: v${version} -> v${latestVersion} | Run: ctx_upgrade`);
302
+ const versionStr = version ? `v${version}` : "context-mode";
303
+ lines.push(versionStr);
304
+ if (version && latestVersion && latestVersion !== "unknown" && semverNewer(latestVersion, version)) {
305
+ lines.push(`Update available: v${version} -> v${latestVersion} | ctx_upgrade`);
287
306
  }
288
307
  return lines.join("\n");
289
308
  }
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})`);
309
+ // ── Active session: visual savings dashboard ──
310
+ // Line 1: Hero metric — the screenshottable number
311
+ lines.push(`${fmtNum(tokensSaved)} tokens saved · ${savingsPct.toFixed(1)}% reduction · ${duration}`);
312
+ lines.push("");
313
+ // Lines 2-3: Before/After comparison bars — the visual proof
314
+ lines.push(`Without context-mode |${dataBar(grandTotal, grandTotal)}| ${kb(grandTotal)}`);
315
+ lines.push(`With context-mode |${dataBar(totalReturned, grandTotal)}| ${kb(totalReturned)}`);
299
316
  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`);
317
+ // Value statement the line people share
318
+ lines.push(`${kb(totalKeptOut)} kept out of your conversation. Never entered context.`);
304
319
  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.`);
320
+ // Compact stats row
321
+ const statParts = [`${totalCalls} calls`];
322
+ if (report.cache && report.cache.hits > 0) {
323
+ statParts.push(`${report.cache.hits} cache hits (+${kb(report.cache.bytes_saved)})`);
312
324
  }
313
- // ── Per-tool table (only if 2+ different tools) ──
325
+ lines.push(statParts.join(" · "));
326
+ // ── Per-tool breakdown (only if 2+ tools, sorted by saved) ──
314
327
  const activatedTools = report.savings.by_tool.filter((t) => t.calls > 0);
315
328
  if (activatedTools.length >= 2) {
316
329
  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`);
330
+ // Estimate per-tool saved using global savings ratio
331
+ const toolRows = activatedTools.map((t) => {
332
+ const returnedBytes = t.context_kb * 1024;
333
+ const estimatedTotal = savingsPct < 100
334
+ ? returnedBytes / (1 - savingsPct / 100)
335
+ : returnedBytes;
336
+ const estimatedSaved = Math.max(0, estimatedTotal - returnedBytes);
337
+ return { ...t, returnedBytes, estimatedSaved };
338
+ }).sort((a, b) => b.estimatedSaved - a.estimatedSaved);
339
+ // Compact table: tool name, calls, saved
340
+ for (const t of toolRows) {
341
+ const name = t.tool.length > 22 ? t.tool.slice(0, 19) + "..." : t.tool;
342
+ lines.push(` ${name.padEnd(22)} ${String(t.calls).padStart(4)} calls ${kb(t.estimatedSaved).padStart(8)} saved`);
321
343
  }
322
344
  }
323
- // ── Session continuity breakdown ──
324
- if (report.continuity.by_category.length > 0) {
345
+ // ── Session memory business-friendly ──
346
+ if (report.continuity.total_events > 0) {
325
347
  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" : ""}`);
327
- 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}`);
348
+ const cats = report.continuity.by_category;
349
+ // Pick the top 3-4 most impactful categories for a human-readable summary
350
+ const highlights = [];
351
+ const fileCount = cats.find(c => c.category === "file")?.count;
352
+ const gitCount = cats.find(c => c.category === "git")?.count;
353
+ const promptCount = cats.find(c => c.category === "prompt")?.count;
354
+ const errorCount = cats.find(c => c.category === "error")?.count;
355
+ const taskCount = cats.find(c => c.category === "task")?.count;
356
+ if (fileCount)
357
+ highlights.push(`${fileCount} files`);
358
+ if (gitCount)
359
+ highlights.push(`${gitCount} git ops`);
360
+ if (promptCount)
361
+ highlights.push(`${promptCount} prompts`);
362
+ if (errorCount)
363
+ highlights.push(`${errorCount} errors`);
364
+ if (taskCount)
365
+ highlights.push(`${taskCount} tasks`);
366
+ const summary = highlights.length > 0 ? ` · ${highlights.join(", ")}` : "";
367
+ if (report.continuity.compact_count > 0) {
368
+ lines.push(`${fmtNum(report.continuity.total_events)} events remembered across ${report.continuity.compact_count} compaction${report.continuity.compact_count !== 1 ? "s" : ""}${summary}`);
369
+ lines.push("Zero knowledge lost — picks up exactly where you left off.");
370
+ }
371
+ else {
372
+ lines.push(`${fmtNum(report.continuity.total_events)} events tracked${summary}`);
333
373
  }
334
374
  }
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);
375
+ // ── Footer ──
345
376
  lines.push("");
346
- lines.push(footerParts.join(" | "));
347
- // Outdated warning in footer
377
+ const versionStr = version ? `v${version}` : "context-mode";
378
+ lines.push(versionStr);
348
379
  if (version && latestVersion && latestVersion !== "unknown" && latestVersion !== version) {
349
- lines.push(`Update available: v${version} -> v${latestVersion} | Run: ctx_upgrade`);
380
+ lines.push(`Update available: v${version} -> v${latestVersion} | ctx_upgrade`);
350
381
  }
351
382
  return lines.join("\n");
352
383
  }
@@ -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
  /**
@@ -62,6 +62,7 @@ const S = {
62
62
  getEventsByPriority: "getEventsByPriority",
63
63
  getEventsByTypeAndPriority: "getEventsByTypeAndPriority",
64
64
  getEventCount: "getEventCount",
65
+ getLatestAttributedProject: "getLatestAttributedProject",
65
66
  checkDuplicate: "checkDuplicate",
66
67
  evictLowestPriority: "evictLowestPriority",
67
68
  updateMetaLastEvent: "updateMetaLastEvent",
@@ -109,6 +110,9 @@ export class SessionDB extends SQLiteBase {
109
110
  category TEXT NOT NULL,
110
111
  priority INTEGER NOT NULL DEFAULT 2,
111
112
  data TEXT NOT NULL,
113
+ project_dir TEXT NOT NULL DEFAULT '',
114
+ attribution_source TEXT NOT NULL DEFAULT 'unknown',
115
+ attribution_confidence REAL NOT NULL DEFAULT 0,
112
116
  source_hook TEXT NOT NULL,
113
117
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
114
118
  data_hash TEXT NOT NULL DEFAULT ''
@@ -136,6 +140,24 @@ export class SessionDB extends SQLiteBase {
136
140
  consumed INTEGER NOT NULL DEFAULT 0
137
141
  );
138
142
  `);
143
+ // Migration: add per-event attribution columns for existing DBs.
144
+ try {
145
+ const colInfo = this.db.pragma("table_xinfo(session_events)");
146
+ const cols = new Set(colInfo.map((c) => c.name));
147
+ if (!cols.has("project_dir")) {
148
+ this.db.exec("ALTER TABLE session_events ADD COLUMN project_dir TEXT NOT NULL DEFAULT ''");
149
+ }
150
+ if (!cols.has("attribution_source")) {
151
+ this.db.exec("ALTER TABLE session_events ADD COLUMN attribution_source TEXT NOT NULL DEFAULT 'unknown'");
152
+ }
153
+ if (!cols.has("attribution_confidence")) {
154
+ this.db.exec("ALTER TABLE session_events ADD COLUMN attribution_confidence REAL NOT NULL DEFAULT 0");
155
+ }
156
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_session_events_project ON session_events(session_id, project_dir)");
157
+ }
158
+ catch {
159
+ // best-effort migration only
160
+ }
139
161
  }
140
162
  prepareStatements() {
141
163
  this.stmts = new Map();
@@ -143,17 +165,34 @@ export class SessionDB extends SQLiteBase {
143
165
  this.stmts.set(key, this.db.prepare(sql));
144
166
  };
145
167
  // ── Events ──
146
- p(S.insertEvent, `INSERT INTO session_events (session_id, type, category, priority, data, source_hook, data_hash)
147
- VALUES (?, ?, ?, ?, ?, ?, ?)`);
148
- p(S.getEvents, `SELECT id, session_id, type, category, priority, data, source_hook, created_at, data_hash
168
+ p(S.insertEvent, `INSERT INTO session_events (
169
+ session_id, type, category, priority, data,
170
+ project_dir, attribution_source, attribution_confidence,
171
+ source_hook, data_hash
172
+ )
173
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
174
+ p(S.getEvents, `SELECT id, session_id, type, category, priority, data,
175
+ project_dir, attribution_source, attribution_confidence,
176
+ source_hook, created_at, data_hash
149
177
  FROM session_events WHERE session_id = ? ORDER BY id ASC LIMIT ?`);
150
- p(S.getEventsByType, `SELECT id, session_id, type, category, priority, data, source_hook, created_at, data_hash
178
+ p(S.getEventsByType, `SELECT id, session_id, type, category, priority, data,
179
+ project_dir, attribution_source, attribution_confidence,
180
+ source_hook, created_at, data_hash
151
181
  FROM session_events WHERE session_id = ? AND type = ? ORDER BY id ASC LIMIT ?`);
152
- p(S.getEventsByPriority, `SELECT id, session_id, type, category, priority, data, source_hook, created_at, data_hash
182
+ p(S.getEventsByPriority, `SELECT id, session_id, type, category, priority, data,
183
+ project_dir, attribution_source, attribution_confidence,
184
+ source_hook, created_at, data_hash
153
185
  FROM session_events WHERE session_id = ? AND priority >= ? ORDER BY id ASC LIMIT ?`);
154
- p(S.getEventsByTypeAndPriority, `SELECT id, session_id, type, category, priority, data, source_hook, created_at, data_hash
186
+ p(S.getEventsByTypeAndPriority, `SELECT id, session_id, type, category, priority, data,
187
+ project_dir, attribution_source, attribution_confidence,
188
+ source_hook, created_at, data_hash
155
189
  FROM session_events WHERE session_id = ? AND type = ? AND priority >= ? ORDER BY id ASC LIMIT ?`);
156
190
  p(S.getEventCount, `SELECT COUNT(*) AS cnt FROM session_events WHERE session_id = ?`);
191
+ p(S.getLatestAttributedProject, `SELECT project_dir
192
+ FROM session_events
193
+ WHERE session_id = ? AND project_dir != ''
194
+ ORDER BY id DESC
195
+ LIMIT 1`);
157
196
  p(S.checkDuplicate, `SELECT 1 FROM (
158
197
  SELECT type, data_hash FROM session_events
159
198
  WHERE session_id = ? ORDER BY id DESC LIMIT ?
@@ -201,13 +240,25 @@ export class SessionDB extends SQLiteBase {
201
240
  * Eviction: if session exceeds MAX_EVENTS_PER_SESSION, evicts the
202
241
  * lowest-priority (then oldest) event.
203
242
  */
204
- insertEvent(sessionId, event, sourceHook = "PostToolUse") {
243
+ insertEvent(sessionId, event, sourceHook = "PostToolUse", attribution) {
205
244
  // SHA256-based dedup hash (first 16 hex chars = 8 bytes of entropy)
206
245
  const dataHash = createHash("sha256")
207
246
  .update(event.data)
208
247
  .digest("hex")
209
248
  .slice(0, 16)
210
249
  .toUpperCase();
250
+ const projectDir = String(attribution?.projectDir
251
+ ?? event.project_dir
252
+ ?? "").trim();
253
+ const attributionSource = String(attribution?.source
254
+ ?? event.attribution_source
255
+ ?? "unknown");
256
+ const rawConfidence = Number(attribution?.confidence
257
+ ?? event.attribution_confidence
258
+ ?? 0);
259
+ const attributionConfidence = Number.isFinite(rawConfidence)
260
+ ? Math.max(0, Math.min(1, rawConfidence))
261
+ : 0;
211
262
  // Atomic: dedup check + eviction + insert in a single transaction
212
263
  // to prevent race conditions from concurrent hook calls.
213
264
  const transaction = this.db.transaction(() => {
@@ -221,7 +272,7 @@ export class SessionDB extends SQLiteBase {
221
272
  this.stmt(S.evictLowestPriority).run(sessionId);
222
273
  }
223
274
  // Insert the event
224
- this.stmt(S.insertEvent).run(sessionId, event.type, event.category, event.priority, event.data, sourceHook, dataHash);
275
+ this.stmt(S.insertEvent).run(sessionId, event.type, event.category, event.priority, event.data, projectDir, attributionSource, attributionConfidence, sourceHook, dataHash);
225
276
  // Update meta if session exists
226
277
  this.stmt(S.updateMetaLastEvent).run(sessionId);
227
278
  });
@@ -252,11 +303,19 @@ export class SessionDB extends SQLiteBase {
252
303
  const row = this.stmt(S.getEventCount).get(sessionId);
253
304
  return row.cnt;
254
305
  }
306
+ /**
307
+ * Return the most recently attributed project dir for a session.
308
+ */
309
+ getLatestAttributedProjectDir(sessionId) {
310
+ const row = this.stmt(S.getLatestAttributedProject).get(sessionId);
311
+ return row?.project_dir || null;
312
+ }
255
313
  // ═══════════════════════════════════════════
256
314
  // Meta
257
315
  // ═══════════════════════════════════════════
258
316
  /**
259
317
  * Ensure a session metadata entry exists. Idempotent (INSERT OR IGNORE).
318
+ * `projectDir` is the session origin directory, not per-event attribution.
260
319
  */
261
320
  ensureSession(sessionId, projectDir) {
262
321
  this.stmt(S.ensureSession).run(sessionId, projectDir);
@@ -364,7 +364,7 @@ function extractSubagent(input) {
364
364
  * MCP tool calls (context7, playwright, claude-mem, ctx-stats, etc.).
365
365
  */
366
366
  function extractMcp(input) {
367
- const { tool_name, tool_input } = input;
367
+ const { tool_name, tool_input, tool_response } = input;
368
368
  if (!tool_name.startsWith("mcp__"))
369
369
  return [];
370
370
  // Extract readable tool name: last segment after __
@@ -373,10 +373,18 @@ function extractMcp(input) {
373
373
  // Extract first string argument for context
374
374
  const firstArg = Object.values(tool_input).find((v) => typeof v === "string");
375
375
  const argStr = firstArg ? `: ${safeString(String(firstArg))}` : "";
376
+ // Append tool_response so ctx_search can find what the MCP returned — not
377
+ // just the call shape. Without this, bodies from external MCPs (jira tickets,
378
+ // grafana loki lines, sentry issues, context7 docs) are invisible to search.
379
+ // No truncation: matches the rule_content precedent above — SQLite TEXT is
380
+ // unbounded and large responses are the ones a cache most wants to preserve.
381
+ const responseStr = tool_response && tool_response.length > 0
382
+ ? `\nresponse: ${safeString(tool_response)}`
383
+ : "";
376
384
  return [{
377
385
  type: "mcp",
378
386
  category: "mcp",
379
- data: safeString(`${toolShort}${argStr}`),
387
+ data: safeString(`${toolShort}${argStr}${responseStr}`),
380
388
  priority: 3,
381
389
  }];
382
390
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Project attribution heuristics for session events.
3
+ *
4
+ * Goal: avoid pinning all activity to the startup directory when work shifts
5
+ * across projects mid-session. This module resolves a best-effort project
6
+ * directory per event and attaches a confidence score + source signal.
7
+ */
8
+ import type { SessionEvent } from "../types.js";
9
+ /**
10
+ * Confidence scores for project attribution sources.
11
+ *
12
+ * Higher = more reliable signal. The hierarchy reflects how directly
13
+ * the signal indicates the user's intended project:
14
+ * - Explicit config (workspace roots) > explicit navigation (cd) > implicit context
15
+ * - Path-bearing events score higher than fallbacks without path signals
16
+ */
17
+ export declare const ATTRIBUTION_CONFIDENCE: {
18
+ /** Explicit workspace root from IDE/editor config */
19
+ readonly WORKSPACE_ROOT: 0.98;
20
+ /** User explicitly navigated here (cd command) */
21
+ readonly CWD_EVENT: 0.9;
22
+ /** Hook payload cwd — reliable but implicit */
23
+ readonly INPUT_CWD: 0.88;
24
+ /** Session startup directory */
25
+ readonly SESSION_ORIGIN: 0.82;
26
+ /** Carry-forward from previous high-confidence event */
27
+ readonly LAST_SEEN: 0.76;
28
+ /** Inferred from file path prefix matching */
29
+ readonly EVENT_PATH: 0.7;
30
+ /** Minimum confidence to carry forward as lastKnownProjectDir */
31
+ readonly CARRY_FORWARD_THRESHOLD: 0.55;
32
+ /** Fallback: input_cwd without path signal */
33
+ readonly FALLBACK_INPUT_CWD: 0.45;
34
+ /** Fallback: last_seen without path signal */
35
+ readonly FALLBACK_LAST_SEEN: 0.4;
36
+ /** Fallback: session_origin without path signal */
37
+ readonly FALLBACK_SESSION_ORIGIN: 0.35;
38
+ };
39
+ export type AttributionSource = "event_path" | "cwd_event" | "input_cwd" | "workspace_root" | "last_seen" | "session_origin" | "unknown";
40
+ export interface ProjectAttribution {
41
+ projectDir: string;
42
+ source: AttributionSource;
43
+ confidence: number;
44
+ }
45
+ export interface AttributionContext {
46
+ sessionOriginDir?: string | null;
47
+ inputProjectDir?: string | null;
48
+ workspaceRoots?: string[] | null;
49
+ lastKnownProjectDir?: string | null;
50
+ }
51
+ /**
52
+ * Resolve the most likely project directory for one event.
53
+ */
54
+ export declare function resolveProjectAttribution(event: SessionEvent, context: AttributionContext): ProjectAttribution;
55
+ /**
56
+ * Convenience helper: resolve attributions for a stream of events while
57
+ * carrying forward the latest confident project as context.
58
+ */
59
+ export declare function resolveProjectAttributions(events: SessionEvent[], context: AttributionContext): ProjectAttribution[];
60
+ /**
61
+ * 0..100 score for UI display.
62
+ */
63
+ export declare function confidenceToPercent(confidence: number): number;
64
+ /**
65
+ * True when attribution is strong enough for project-level spending claims.
66
+ */
67
+ export declare function isHighConfidenceAttribution(confidence: number): boolean;
68
+ /**
69
+ * Lightweight utility used by some hooks to normalize path separators
70
+ * before writing attribution metadata.
71
+ */
72
+ export declare function normalizeProjectDir(projectDir: string): string;
73
+ export declare const PROJECT_ATTRIBUTION_VERSION = 1;