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
@@ -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();
package/build/types.d.ts CHANGED
@@ -29,6 +29,14 @@ export interface SessionEvent {
29
29
  data: string;
30
30
  priority: number;
31
31
  data_hash: string;
32
+ /**
33
+ * Best-effort project attribution for this event.
34
+ * Empty string means unattributed/unknown.
35
+ */
36
+ project_dir?: string;
37
+ attribution_source?: string;
38
+ /** 0..1 confidence score for project attribution. */
39
+ attribution_confidence?: number;
32
40
  }
33
41
  /**
34
42
  * Result returned by PolyglotExecutor after running a code snippet.