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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.openclaw-plugin/openclaw.plugin.json +1 -1
- package/.openclaw-plugin/package.json +1 -1
- package/README.md +184 -60
- package/build/adapters/antigravity/index.d.ts +3 -5
- package/build/adapters/antigravity/index.js +7 -35
- package/build/adapters/base.d.ts +27 -0
- package/build/adapters/base.js +59 -0
- package/build/adapters/claude-code/index.d.ts +9 -25
- package/build/adapters/claude-code/index.js +12 -140
- package/build/adapters/claude-code-base.d.ts +49 -0
- package/build/adapters/claude-code-base.js +113 -0
- package/build/adapters/client-map.js +5 -0
- package/build/adapters/codex/hooks.d.ts +21 -14
- package/build/adapters/codex/hooks.js +22 -15
- package/build/adapters/codex/index.d.ts +6 -10
- package/build/adapters/codex/index.js +13 -43
- package/build/adapters/copilot-base.d.ts +78 -0
- package/build/adapters/copilot-base.js +281 -0
- package/build/adapters/cursor/index.d.ts +3 -5
- package/build/adapters/cursor/index.js +6 -34
- package/build/adapters/detect.d.ts +7 -0
- package/build/adapters/detect.js +57 -56
- package/build/adapters/gemini-cli/index.d.ts +3 -5
- package/build/adapters/gemini-cli/index.js +7 -35
- package/build/adapters/jetbrains-copilot/config.d.ts +8 -0
- package/build/adapters/jetbrains-copilot/config.js +8 -0
- package/build/adapters/jetbrains-copilot/hooks.d.ts +51 -0
- package/build/adapters/jetbrains-copilot/hooks.js +82 -0
- package/build/adapters/jetbrains-copilot/index.d.ts +24 -0
- package/build/adapters/jetbrains-copilot/index.js +119 -0
- package/build/adapters/kiro/hooks.d.ts +14 -0
- package/build/adapters/kiro/hooks.js +23 -0
- package/build/adapters/kiro/index.d.ts +3 -5
- package/build/adapters/kiro/index.js +10 -38
- package/build/adapters/openclaw/index.d.ts +3 -4
- package/build/adapters/openclaw/index.js +6 -22
- package/build/adapters/opencode/index.d.ts +2 -3
- package/build/adapters/opencode/index.js +5 -16
- package/build/adapters/qwen-code/index.d.ts +39 -0
- package/build/adapters/qwen-code/index.js +199 -0
- package/build/adapters/types.d.ts +1 -1
- package/build/adapters/vscode-copilot/index.d.ts +16 -46
- package/build/adapters/vscode-copilot/index.js +29 -320
- package/build/adapters/zed/index.d.ts +3 -5
- package/build/adapters/zed/index.js +7 -35
- package/build/cli.js +13 -0
- package/build/lifecycle.d.ts +23 -0
- package/build/lifecycle.js +54 -13
- package/build/opencode-plugin.d.ts +19 -7
- package/build/opencode-plugin.js +19 -7
- package/build/runtime.js +24 -9
- package/build/security.d.ts +17 -1
- package/build/security.js +40 -6
- package/build/server.js +41 -9
- package/build/session/analytics.d.ts +8 -7
- package/build/session/analytics.js +95 -75
- package/build/session/db.d.ts +10 -1
- package/build/session/db.js +67 -8
- package/build/session/extract.js +10 -2
- package/build/session/project-attribution.d.ts +73 -0
- package/build/session/project-attribution.js +231 -0
- package/build/store.d.ts +4 -0
- package/build/store.js +58 -9
- package/build/types.d.ts +8 -0
- package/cli.bundle.mjs +135 -121
- package/configs/antigravity/GEMINI.md +31 -36
- package/configs/claude-code/CLAUDE.md +31 -37
- package/configs/codex/AGENTS.md +35 -49
- package/configs/cursor/context-mode.mdc +24 -25
- package/configs/gemini-cli/GEMINI.md +30 -36
- package/configs/jetbrains-copilot/copilot-instructions.md +59 -0
- package/configs/jetbrains-copilot/hooks.json +16 -0
- package/configs/jetbrains-copilot/mcp.json +8 -0
- package/configs/kilo/AGENTS.md +30 -36
- package/configs/kiro/KIRO.md +30 -36
- package/configs/kiro/agent.json +1 -1
- package/configs/openclaw/AGENTS.md +30 -36
- package/configs/opencode/AGENTS.md +30 -36
- package/configs/pi/AGENTS.md +31 -36
- package/configs/qwen-code/QWEN.md +63 -0
- package/configs/vscode-copilot/copilot-instructions.md +30 -36
- package/configs/zed/AGENTS.md +31 -36
- package/hooks/codex/posttooluse.mjs +7 -7
- package/hooks/codex/pretooluse.mjs +3 -3
- package/hooks/codex/sessionstart.mjs +2 -1
- package/hooks/core/formatters.mjs +24 -0
- package/hooks/core/routing.mjs +40 -15
- package/hooks/core/tool-naming.mjs +2 -0
- package/hooks/cursor/posttooluse.mjs +7 -7
- package/hooks/cursor/pretooluse.mjs +3 -3
- package/hooks/cursor/sessionstart.mjs +2 -1
- package/hooks/cursor/stop.mjs +2 -2
- package/hooks/ensure-deps.mjs +22 -10
- package/hooks/gemini-cli/aftertool.mjs +8 -8
- package/hooks/gemini-cli/beforetool.mjs +3 -2
- package/hooks/gemini-cli/precompress.mjs +2 -2
- package/hooks/gemini-cli/sessionstart.mjs +12 -4
- package/hooks/jetbrains-copilot/posttooluse.mjs +61 -0
- package/hooks/jetbrains-copilot/precompact.mjs +54 -0
- package/hooks/jetbrains-copilot/pretooluse.mjs +27 -0
- package/hooks/jetbrains-copilot/sessionstart.mjs +119 -0
- package/hooks/kiro/posttooluse.mjs +6 -7
- package/hooks/kiro/pretooluse.mjs +3 -2
- package/hooks/posttooluse.mjs +8 -8
- package/hooks/precompact.mjs +3 -4
- package/hooks/pretooluse.mjs +5 -4
- package/hooks/routing-block.mjs +35 -33
- package/hooks/session-attribution.bundle.mjs +1 -0
- package/hooks/session-db.bundle.mjs +27 -8
- package/hooks/session-extract.bundle.mjs +2 -1
- package/hooks/session-helpers.mjs +44 -3
- package/hooks/session-loaders.mjs +37 -0
- package/hooks/sessionstart.mjs +5 -5
- package/hooks/userpromptsubmit.mjs +26 -9
- package/hooks/vscode-copilot/posttooluse.mjs +8 -8
- package/hooks/vscode-copilot/precompact.mjs +2 -2
- package/hooks/vscode-copilot/pretooluse.mjs +3 -2
- package/hooks/vscode-copilot/sessionstart.mjs +2 -2
- package/insight/server.mjs +237 -25
- package/insight/src/lib/api.ts +2 -1
- package/insight/src/routes/index.tsx +16 -3
- package/insight/src/routes/search.tsx +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +11 -2
- package/server.bundle.mjs +94 -80
- package/skills/ctx-insight/SKILL.md +1 -1
package/build/session/db.d.ts
CHANGED
|
@@ -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
|
/**
|
package/build/session/db.js
CHANGED
|
@@ -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 (
|
|
147
|
-
|
|
148
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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);
|
package/build/session/extract.js
CHANGED
|
@@ -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
|
-
|
|
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();
|