context-mode 1.0.89 → 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 (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 +41 -9
  57. package/build/session/analytics.d.ts +8 -7
  58. package/build/session/analytics.js +95 -75
  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
@@ -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;
@@ -0,0 +1,231 @@
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 { dirname, isAbsolute, normalize, resolve, sep } from "node:path";
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 const ATTRIBUTION_CONFIDENCE = {
18
+ /** Explicit workspace root from IDE/editor config */
19
+ WORKSPACE_ROOT: 0.98,
20
+ /** User explicitly navigated here (cd command) */
21
+ CWD_EVENT: 0.9,
22
+ /** Hook payload cwd — reliable but implicit */
23
+ INPUT_CWD: 0.88,
24
+ /** Session startup directory */
25
+ SESSION_ORIGIN: 0.82,
26
+ /** Carry-forward from previous high-confidence event */
27
+ LAST_SEEN: 0.76,
28
+ /** Inferred from file path prefix matching */
29
+ EVENT_PATH: 0.7,
30
+ /** Minimum confidence to carry forward as lastKnownProjectDir */
31
+ CARRY_FORWARD_THRESHOLD: 0.55,
32
+ /** Fallback: input_cwd without path signal */
33
+ FALLBACK_INPUT_CWD: 0.45,
34
+ /** Fallback: last_seen without path signal */
35
+ FALLBACK_LAST_SEEN: 0.4,
36
+ /** Fallback: session_origin without path signal */
37
+ FALLBACK_SESSION_ORIGIN: 0.35,
38
+ };
39
+ function normalizePath(path) {
40
+ const norm = normalize(path).replace(/\\/g, "/");
41
+ if (norm.length <= 1)
42
+ return norm;
43
+ return norm.replace(/\/+$/, "");
44
+ }
45
+ function isPrefixPath(path, prefix) {
46
+ if (!path || !prefix)
47
+ return false;
48
+ if (path === prefix)
49
+ return true;
50
+ return path.startsWith(`${prefix}/`);
51
+ }
52
+ function normalizeRoots(roots) {
53
+ if (!roots || roots.length === 0)
54
+ return [];
55
+ const normalized = roots
56
+ .filter((r) => typeof r === "string" && r.trim().length > 0)
57
+ .map((r) => normalizePath(r));
58
+ // dedupe + longest-first for stable best match
59
+ const unique = Array.from(new Set(normalized));
60
+ return unique.sort((a, b) => b.length - a.length);
61
+ }
62
+ function parseFileSearchPath(data) {
63
+ const marker = " in ";
64
+ const idx = data.lastIndexOf(marker);
65
+ if (idx < 0)
66
+ return null;
67
+ const path = data.slice(idx + marker.length).trim();
68
+ return path.length > 0 ? path : null;
69
+ }
70
+ function looksLikePath(value) {
71
+ if (!value)
72
+ return false;
73
+ // Fast path-like checks: separators, dot segments, drive roots.
74
+ return value.includes("/")
75
+ || value.includes("\\")
76
+ || value.startsWith(".")
77
+ || /^[A-Za-z]:[\\/]/.test(value);
78
+ }
79
+ function extractPathSignal(event) {
80
+ if (event.type === "cwd") {
81
+ return { rawPath: event.data, fromCwdEvent: true };
82
+ }
83
+ if (event.type === "file_search") {
84
+ const path = parseFileSearchPath(event.data);
85
+ if (path)
86
+ return { rawPath: path, fromCwdEvent: false };
87
+ }
88
+ const fileTypes = new Set([
89
+ "file_read",
90
+ "file_write",
91
+ "file_edit",
92
+ "file_glob",
93
+ "rule",
94
+ ]);
95
+ if (fileTypes.has(event.type) && looksLikePath(event.data)) {
96
+ return { rawPath: event.data, fromCwdEvent: false };
97
+ }
98
+ return null;
99
+ }
100
+ function absolutizePath(rawPath, context) {
101
+ if (!rawPath)
102
+ return null;
103
+ // Ignore broad glob-only patterns that aren't useful for attribution.
104
+ if (rawPath.includes("*") && !isAbsolute(rawPath) && !/^[A-Za-z]:[\\/]/.test(rawPath)) {
105
+ return null;
106
+ }
107
+ if (isAbsolute(rawPath) || /^[A-Za-z]:[\\/]/.test(rawPath)) {
108
+ return normalizePath(rawPath);
109
+ }
110
+ // For relative paths, anchor to the most recent known project first.
111
+ const anchor = context.lastKnownProjectDir
112
+ || context.inputProjectDir
113
+ || context.sessionOriginDir
114
+ || null;
115
+ if (!anchor)
116
+ return null;
117
+ return normalizePath(resolve(anchor, rawPath));
118
+ }
119
+ function inferProjectFromAbsolutePath(absPath, event, context) {
120
+ const normalizedRoots = normalizeRoots(context.workspaceRoots);
121
+ const normalizedOrigin = context.sessionOriginDir ? normalizePath(context.sessionOriginDir) : "";
122
+ const normalizedInput = context.inputProjectDir ? normalizePath(context.inputProjectDir) : "";
123
+ const normalizedLast = context.lastKnownProjectDir ? normalizePath(context.lastKnownProjectDir) : "";
124
+ // 1) Prefer explicit workspace roots (highest confidence).
125
+ const workspaceRoot = normalizedRoots.find((root) => isPrefixPath(absPath, root));
126
+ if (workspaceRoot) {
127
+ return { projectDir: workspaceRoot, source: "workspace_root", confidence: ATTRIBUTION_CONFIDENCE.WORKSPACE_ROOT };
128
+ }
129
+ // 2) Prefer stable known roots from session context.
130
+ if (normalizedInput && isPrefixPath(absPath, normalizedInput)) {
131
+ return { projectDir: normalizedInput, source: "input_cwd", confidence: ATTRIBUTION_CONFIDENCE.INPUT_CWD };
132
+ }
133
+ if (normalizedOrigin && isPrefixPath(absPath, normalizedOrigin)) {
134
+ return { projectDir: normalizedOrigin, source: "session_origin", confidence: ATTRIBUTION_CONFIDENCE.SESSION_ORIGIN };
135
+ }
136
+ if (normalizedLast && isPrefixPath(absPath, normalizedLast)) {
137
+ return { projectDir: normalizedLast, source: "last_seen", confidence: ATTRIBUTION_CONFIDENCE.LAST_SEEN };
138
+ }
139
+ // 3) Direct cwd events indicate explicit operator intent to shift project.
140
+ if (event.type === "cwd") {
141
+ return { projectDir: absPath, source: "cwd_event", confidence: ATTRIBUTION_CONFIDENCE.CWD_EVENT };
142
+ }
143
+ // 4) Fallback for out-of-root absolute paths.
144
+ // For known file events, use parent directory to avoid attributing to a file path.
145
+ const fileLike = new Set(["file_read", "file_write", "file_edit", "rule"]);
146
+ const projectDir = fileLike.has(event.type) ? normalizePath(dirname(absPath)) : absPath;
147
+ return { projectDir, source: "event_path", confidence: ATTRIBUTION_CONFIDENCE.EVENT_PATH };
148
+ }
149
+ function fallbackAttribution(context) {
150
+ if (context.inputProjectDir) {
151
+ return {
152
+ projectDir: normalizePath(context.inputProjectDir),
153
+ source: "input_cwd",
154
+ confidence: ATTRIBUTION_CONFIDENCE.FALLBACK_INPUT_CWD,
155
+ };
156
+ }
157
+ if (context.lastKnownProjectDir) {
158
+ return {
159
+ projectDir: normalizePath(context.lastKnownProjectDir),
160
+ source: "last_seen",
161
+ confidence: ATTRIBUTION_CONFIDENCE.FALLBACK_LAST_SEEN,
162
+ };
163
+ }
164
+ if (context.sessionOriginDir) {
165
+ return {
166
+ projectDir: normalizePath(context.sessionOriginDir),
167
+ source: "session_origin",
168
+ confidence: ATTRIBUTION_CONFIDENCE.FALLBACK_SESSION_ORIGIN,
169
+ };
170
+ }
171
+ return { projectDir: "", source: "unknown", confidence: 0 };
172
+ }
173
+ /**
174
+ * Resolve the most likely project directory for one event.
175
+ */
176
+ export function resolveProjectAttribution(event, context) {
177
+ try {
178
+ const pathSignal = extractPathSignal(event);
179
+ if (!pathSignal)
180
+ return fallbackAttribution(context);
181
+ const absPath = absolutizePath(pathSignal.rawPath, context);
182
+ if (!absPath)
183
+ return fallbackAttribution(context);
184
+ return inferProjectFromAbsolutePath(absPath, event, context);
185
+ }
186
+ catch {
187
+ return fallbackAttribution(context);
188
+ }
189
+ }
190
+ /**
191
+ * Convenience helper: resolve attributions for a stream of events while
192
+ * carrying forward the latest confident project as context.
193
+ */
194
+ export function resolveProjectAttributions(events, context) {
195
+ const out = [];
196
+ let lastKnown = context.lastKnownProjectDir ? normalizePath(context.lastKnownProjectDir) : "";
197
+ for (const ev of events) {
198
+ const attribution = resolveProjectAttribution(ev, {
199
+ ...context,
200
+ lastKnownProjectDir: lastKnown || context.lastKnownProjectDir || null,
201
+ });
202
+ out.push(attribution);
203
+ if (attribution.projectDir && attribution.confidence >= ATTRIBUTION_CONFIDENCE.CARRY_FORWARD_THRESHOLD) {
204
+ lastKnown = attribution.projectDir;
205
+ }
206
+ }
207
+ return out;
208
+ }
209
+ /**
210
+ * 0..100 score for UI display.
211
+ */
212
+ export function confidenceToPercent(confidence) {
213
+ const clamped = Math.max(0, Math.min(1, confidence));
214
+ return Math.round(clamped * 100);
215
+ }
216
+ /**
217
+ * True when attribution is strong enough for project-level spending claims.
218
+ */
219
+ export function isHighConfidenceAttribution(confidence) {
220
+ return confidence >= 0.8;
221
+ }
222
+ /**
223
+ * Lightweight utility used by some hooks to normalize path separators
224
+ * before writing attribution metadata.
225
+ */
226
+ export function normalizeProjectDir(projectDir) {
227
+ return normalizePath(projectDir);
228
+ }
229
+ export const PROJECT_ATTRIBUTION_VERSION = 1;
230
+ // Keep explicit references to path separator for bundlers that tree-shake too aggressively.
231
+ void sep;
package/build/store.d.ts CHANGED
@@ -53,11 +53,15 @@ export declare class ContentStore {
53
53
  searchTrigram(query: string, limit?: number, source?: string, mode?: "AND" | "OR", contentType?: "code" | "prose", sourceMatchMode?: SourceMatchMode): SearchResult[];
54
54
  fuzzyCorrect(query: string): string | null;
55
55
  searchWithFallback(query: string, limit?: number, source?: string, contentType?: "code" | "prose", sourceMatchMode?: SourceMatchMode): SearchResult[];
56
+ /** Number of sources auto-refreshed in the last searchWithFallback call. */
57
+ lastRefreshCount: number;
56
58
  getSourceMeta(label: string): {
57
59
  label: string;
58
60
  chunkCount: number;
59
61
  codeChunkCount: number;
60
62
  indexedAt: string;
63
+ filePath: string | null;
64
+ contentHash: string | null;
61
65
  } | null;
62
66
  listSources(): Array<{
63
67
  label: string;
package/build/store.js CHANGED
@@ -10,6 +10,7 @@
10
10
  var _a;
11
11
  import { loadDatabase, applyWALPragmas, closeDB, cleanOrphanedWALFiles, withRetry, deleteDBFiles, isSQLiteCorruptionError } from "./db-base.js";
12
12
  import { readFileSync, readdirSync, unlinkSync, existsSync, statSync } from "node:fs";
13
+ import { createHash } from "node:crypto";
13
14
  import { tmpdir } from "node:os";
14
15
  import { join } from "node:path";
15
16
  // ─────────────────────────────────────────────────────────
@@ -357,7 +358,9 @@ export class ContentStore {
357
358
  label TEXT NOT NULL,
358
359
  chunk_count INTEGER NOT NULL DEFAULT 0,
359
360
  code_chunk_count INTEGER NOT NULL DEFAULT 0,
360
- indexed_at TEXT NOT NULL DEFAULT (datetime('now'))
361
+ indexed_at TEXT NOT NULL DEFAULT (datetime('now')),
362
+ file_path TEXT,
363
+ content_hash TEXT
361
364
  );
362
365
 
363
366
  CREATE VIRTUAL TABLE IF NOT EXISTS chunks USING fts5(
@@ -382,11 +385,20 @@ export class ContentStore {
382
385
 
383
386
  CREATE INDEX IF NOT EXISTS idx_sources_label ON sources(label);
384
387
  `);
388
+ // Stale detection columns — safe for existing DBs (ALTER is O(1) in SQLite)
389
+ try {
390
+ this.#db.exec("ALTER TABLE sources ADD COLUMN file_path TEXT");
391
+ }
392
+ catch { /* already exists */ }
393
+ try {
394
+ this.#db.exec("ALTER TABLE sources ADD COLUMN content_hash TEXT");
395
+ }
396
+ catch { /* already exists */ }
385
397
  }
386
398
  #prepareStatements() {
387
399
  // Write path
388
- this.#stmtInsertSourceEmpty = this.#db.prepare("INSERT INTO sources (label, chunk_count, code_chunk_count) VALUES (?, 0, 0)");
389
- this.#stmtInsertSource = this.#db.prepare("INSERT INTO sources (label, chunk_count, code_chunk_count) VALUES (?, ?, ?)");
400
+ this.#stmtInsertSourceEmpty = this.#db.prepare("INSERT INTO sources (label, chunk_count, code_chunk_count, file_path, content_hash) VALUES (?, 0, 0, ?, ?)");
401
+ this.#stmtInsertSource = this.#db.prepare("INSERT INTO sources (label, chunk_count, code_chunk_count, file_path, content_hash) VALUES (?, ?, ?, ?, ?)");
390
402
  this.#stmtInsertChunk = this.#db.prepare("INSERT INTO chunks (title, content, source_id, content_type) VALUES (?, ?, ?, ?)");
391
403
  this.#stmtInsertChunkTrigram = this.#db.prepare("INSERT INTO chunks_trigram (title, content, source_id, content_type) VALUES (?, ?, ?, ?)");
392
404
  this.#stmtInsertVocab = this.#db.prepare("INSERT OR IGNORE INTO vocabulary (word) VALUES (?)");
@@ -576,7 +588,7 @@ export class ContentStore {
576
588
  ORDER BY c.rowid`);
577
589
  this.#stmtSourceChunkCount = this.#db.prepare("SELECT chunk_count FROM sources WHERE id = ?");
578
590
  this.#stmtChunkContent = this.#db.prepare("SELECT content FROM chunks WHERE source_id = ?");
579
- this.#stmtSourceMeta = this.#db.prepare("SELECT label, chunk_count, code_chunk_count, indexed_at FROM sources WHERE label = ?");
591
+ this.#stmtSourceMeta = this.#db.prepare("SELECT label, chunk_count, code_chunk_count, indexed_at, file_path, content_hash FROM sources WHERE label = ?");
580
592
  this.#stmtStats = this.#db.prepare(`
581
593
  SELECT
582
594
  (SELECT COUNT(*) FROM sources) AS sources,
@@ -597,7 +609,10 @@ export class ContentStore {
597
609
  const text = content ?? readFileSync(path, "utf-8");
598
610
  const label = source ?? path ?? "untitled";
599
611
  const chunks = this.#chunkMarkdown(text);
600
- return withRetry(() => this.#insertChunks(chunks, label, text));
612
+ // Stale detection: store file_path + SHA-256 for file-backed sources
613
+ const filePath = path ?? undefined;
614
+ const contentHash = filePath ? createHash("sha256").update(text).digest("hex") : undefined;
615
+ return withRetry(() => this.#insertChunks(chunks, label, text, filePath, contentHash));
601
616
  }
602
617
  // ── Index Plain Text ──
603
618
  /**
@@ -644,7 +659,7 @@ export class ContentStore {
644
659
  * into both FTS5 tables within a transaction and extracts vocabulary.
645
660
  * Uses cached prepared statements from #prepareStatements().
646
661
  */
647
- #insertChunks(chunks, label, text) {
662
+ #insertChunks(chunks, label, text, filePath, contentHash) {
648
663
  const codeChunks = chunks.filter((c) => c.hasCode).length;
649
664
  // Atomic dedup + insert: delete previous source with same label,
650
665
  // then insert new content — all within a single transaction.
@@ -654,10 +669,10 @@ export class ContentStore {
654
669
  this.#stmtDeleteChunksTrigramByLabel.run(label);
655
670
  this.#stmtDeleteSourcesByLabel.run(label);
656
671
  if (chunks.length === 0) {
657
- const info = this.#stmtInsertSourceEmpty.run(label);
672
+ const info = this.#stmtInsertSourceEmpty.run(label, filePath ?? null, contentHash ?? null);
658
673
  return Number(info.lastInsertRowid);
659
674
  }
660
- const info = this.#stmtInsertSource.run(label, chunks.length, codeChunks);
675
+ const info = this.#stmtInsertSource.run(label, chunks.length, codeChunks, filePath ?? null, contentHash ?? null);
661
676
  const sourceId = Number(info.lastInsertRowid);
662
677
  for (const chunk of chunks) {
663
678
  const ct = chunk.hasCode ? "code" : "prose";
@@ -861,6 +876,8 @@ export class ContentStore {
861
876
  }
862
877
  // ── Unified Fallback Search ──
863
878
  searchWithFallback(query, limit = 3, source, contentType, sourceMatchMode = "like") {
879
+ // Step 0: Auto-refresh stale file-backed sources before searching
880
+ this.#refreshStaleSources();
864
881
  // Step 1: RRF fusion (porter OR + trigram OR → merge)
865
882
  const rrfResults = this.#rrfSearch(query, limit, source, contentType, sourceMatchMode);
866
883
  if (rrfResults.length > 0) {
@@ -887,12 +904,44 @@ export class ContentStore {
887
904
  }
888
905
  return [];
889
906
  }
907
+ /** Number of sources auto-refreshed in the last searchWithFallback call. */
908
+ lastRefreshCount = 0;
909
+ /**
910
+ * Check all file-backed sources for staleness and auto re-index changed files.
911
+ * Uses mtime as a fast gate — only computes SHA-256 when mtime has advanced
912
+ * past indexed_at. Gracefully skips deleted files and non-file sources.
913
+ */
914
+ #refreshStaleSources() {
915
+ this.lastRefreshCount = 0;
916
+ const sources = this.#db.prepare("SELECT label, file_path, content_hash, indexed_at FROM sources WHERE file_path IS NOT NULL").all();
917
+ for (const src of sources) {
918
+ try {
919
+ if (!existsSync(src.file_path))
920
+ continue; // file deleted — keep cached results
921
+ const mtime = statSync(src.file_path).mtime;
922
+ const indexedAt = new Date(src.indexed_at + "Z");
923
+ if (mtime <= indexedAt)
924
+ continue; // file unchanged — fast path
925
+ // mtime advanced — check hash to confirm real change (not just touch)
926
+ const newContent = readFileSync(src.file_path, "utf-8");
927
+ const newHash = createHash("sha256").update(newContent).digest("hex");
928
+ if (newHash === src.content_hash)
929
+ continue; // content identical — skip
930
+ // File genuinely changed — re-index
931
+ this.index({ path: src.file_path, source: src.label });
932
+ this.lastRefreshCount++;
933
+ }
934
+ catch {
935
+ // Graceful degradation — never break search for stale detection
936
+ }
937
+ }
938
+ }
890
939
  // ── Sources ──
891
940
  getSourceMeta(label) {
892
941
  const row = this.#stmtSourceMeta.get(label);
893
942
  if (!row)
894
943
  return null;
895
- return { label: row.label, chunkCount: row.chunk_count, codeChunkCount: row.code_chunk_count, indexedAt: row.indexed_at };
944
+ return { label: row.label, chunkCount: row.chunk_count, codeChunkCount: row.code_chunk_count, indexedAt: row.indexed_at, filePath: row.file_path ?? null, contentHash: row.content_hash ?? null };
896
945
  }
897
946
  listSources() {
898
947
  return this.#stmtListSources.all();