pi-context-engine 0.1.4 → 0.3.0

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/src/memory/db.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  /**
2
- * Per-project memory.db — port of upstream `memory/db.py` (MIT), schema v3
2
+ * Per-project memory.db — port of upstream `memory/db.py` (MIT), schema v4
3
3
  * (minus `migrated_files`, which only served upstream's session-file import).
4
+ * Schema is enforced idempotently on every open (no version-step migrations),
5
+ * so databases from any earlier release converge to the current shape in place.
4
6
  * Synchronous better-sqlite3; vec tables degrade to FTS-only when the
5
7
  * sqlite-vec extension can't load.
6
8
  */
@@ -11,7 +13,7 @@ import { batchedParams, epochSeconds } from "../utils.js";
11
13
  import { redactPii } from "../indexer/secrets.js";
12
14
  import type { Embedder } from "../indexer/embedder.js";
13
15
 
14
- export const CURRENT_VERSION = 3;
16
+ export const CURRENT_VERSION = 4;
15
17
  const VEC_DIM = 384; // bootstrap dim; rebuilt automatically on mismatch
16
18
 
17
19
  /** Canonical savings buckets — keep in sync with the savings renderer. */
@@ -27,7 +29,7 @@ export const BUCKETS = [
27
29
  export type Bucket = (typeof BUCKETS)[number];
28
30
 
29
31
  const SCHEMA_V1 = `
30
- CREATE TABLE sessions (
32
+ CREATE TABLE IF NOT EXISTS sessions (
31
33
  id TEXT PRIMARY KEY,
32
34
  project TEXT NOT NULL,
33
35
  started_at_epoch INTEGER NOT NULL,
@@ -40,9 +42,9 @@ CREATE TABLE sessions (
40
42
  rollup_summary TEXT,
41
43
  rollup_summary_at_epoch INTEGER
42
44
  );
43
- CREATE INDEX idx_sessions_started ON sessions(started_at_epoch DESC);
45
+ CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at_epoch DESC);
44
46
 
45
- CREATE TABLE prompts (
47
+ CREATE TABLE IF NOT EXISTS prompts (
46
48
  id INTEGER PRIMARY KEY AUTOINCREMENT,
47
49
  session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
48
50
  prompt_number INTEGER NOT NULL,
@@ -51,16 +53,16 @@ CREATE TABLE prompts (
51
53
  created_at TEXT NOT NULL,
52
54
  UNIQUE(session_id, prompt_number)
53
55
  );
54
- CREATE INDEX idx_prompts_session ON prompts(session_id, prompt_number);
56
+ CREATE INDEX IF NOT EXISTS idx_prompts_session ON prompts(session_id, prompt_number);
55
57
 
56
- CREATE TABLE tool_event_payloads (
58
+ CREATE TABLE IF NOT EXISTS tool_event_payloads (
57
59
  id INTEGER PRIMARY KEY AUTOINCREMENT,
58
60
  raw_input TEXT NOT NULL,
59
61
  raw_output TEXT,
60
62
  size_bytes INTEGER NOT NULL
61
63
  );
62
64
 
63
- CREATE TABLE tool_events (
65
+ CREATE TABLE IF NOT EXISTS tool_events (
64
66
  id INTEGER PRIMARY KEY AUTOINCREMENT,
65
67
  session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
66
68
  prompt_number INTEGER NOT NULL,
@@ -70,9 +72,9 @@ CREATE TABLE tool_events (
70
72
  created_at_epoch INTEGER NOT NULL,
71
73
  created_at TEXT NOT NULL
72
74
  );
73
- CREATE INDEX idx_events_session_turn ON tool_events(session_id, prompt_number);
75
+ CREATE INDEX IF NOT EXISTS idx_events_session_turn ON tool_events(session_id, prompt_number);
74
76
 
75
- CREATE TABLE turn_summaries (
77
+ CREATE TABLE IF NOT EXISTS turn_summaries (
76
78
  id INTEGER PRIMARY KEY AUTOINCREMENT,
77
79
  session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
78
80
  prompt_number INTEGER NOT NULL,
@@ -82,7 +84,7 @@ CREATE TABLE turn_summaries (
82
84
  UNIQUE(session_id, prompt_number)
83
85
  );
84
86
 
85
- CREATE TABLE decisions (
87
+ CREATE TABLE IF NOT EXISTS decisions (
86
88
  id INTEGER PRIMARY KEY AUTOINCREMENT,
87
89
  session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL,
88
90
  decision TEXT NOT NULL,
@@ -91,10 +93,10 @@ CREATE TABLE decisions (
91
93
  created_at_epoch INTEGER NOT NULL,
92
94
  created_at TEXT NOT NULL
93
95
  );
94
- CREATE INDEX idx_decisions_created ON decisions(created_at_epoch DESC);
95
- CREATE INDEX idx_decisions_source ON decisions(source);
96
+ CREATE INDEX IF NOT EXISTS idx_decisions_created ON decisions(created_at_epoch DESC);
97
+ CREATE INDEX IF NOT EXISTS idx_decisions_source ON decisions(source);
96
98
 
97
- CREATE TABLE code_areas (
99
+ CREATE TABLE IF NOT EXISTS code_areas (
98
100
  id INTEGER PRIMARY KEY AUTOINCREMENT,
99
101
  session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL,
100
102
  file_path TEXT NOT NULL,
@@ -102,9 +104,9 @@ CREATE TABLE code_areas (
102
104
  source TEXT NOT NULL CHECK(source IN ('manual','migrated','auto')) DEFAULT 'manual',
103
105
  created_at_epoch INTEGER NOT NULL
104
106
  );
105
- CREATE INDEX idx_code_areas_file ON code_areas(file_path);
107
+ CREATE INDEX IF NOT EXISTS idx_code_areas_file ON code_areas(file_path);
106
108
 
107
- CREATE TABLE pending_compressions (
109
+ CREATE TABLE IF NOT EXISTS pending_compressions (
108
110
  id INTEGER PRIMARY KEY AUTOINCREMENT,
109
111
  kind TEXT NOT NULL CHECK(kind IN ('turn','session_rollup')),
110
112
  session_id TEXT NOT NULL,
@@ -115,44 +117,44 @@ CREATE TABLE pending_compressions (
115
117
  UNIQUE(kind, session_id, prompt_number)
116
118
  );
117
119
 
118
- CREATE VIRTUAL TABLE prompts_fts USING fts5(prompt_text, content='prompts', content_rowid='id');
119
- CREATE VIRTUAL TABLE decisions_fts USING fts5(decision, reason, content='decisions', content_rowid='id');
120
- CREATE VIRTUAL TABLE turn_summaries_fts USING fts5(summary, content='turn_summaries', content_rowid='id');
120
+ CREATE VIRTUAL TABLE IF NOT EXISTS prompts_fts USING fts5(prompt_text, content='prompts', content_rowid='id');
121
+ CREATE VIRTUAL TABLE IF NOT EXISTS decisions_fts USING fts5(decision, reason, content='decisions', content_rowid='id');
122
+ CREATE VIRTUAL TABLE IF NOT EXISTS turn_summaries_fts USING fts5(summary, content='turn_summaries', content_rowid='id');
121
123
 
122
- CREATE TRIGGER prompts_ai AFTER INSERT ON prompts BEGIN
124
+ CREATE TRIGGER IF NOT EXISTS prompts_ai AFTER INSERT ON prompts BEGIN
123
125
  INSERT INTO prompts_fts(rowid, prompt_text) VALUES (new.id, new.prompt_text);
124
126
  END;
125
- CREATE TRIGGER prompts_ad AFTER DELETE ON prompts BEGIN
127
+ CREATE TRIGGER IF NOT EXISTS prompts_ad AFTER DELETE ON prompts BEGIN
126
128
  INSERT INTO prompts_fts(prompts_fts, rowid, prompt_text) VALUES('delete', old.id, old.prompt_text);
127
129
  END;
128
- CREATE TRIGGER prompts_au AFTER UPDATE ON prompts BEGIN
130
+ CREATE TRIGGER IF NOT EXISTS prompts_au AFTER UPDATE ON prompts BEGIN
129
131
  INSERT INTO prompts_fts(prompts_fts, rowid, prompt_text) VALUES('delete', old.id, old.prompt_text);
130
132
  INSERT INTO prompts_fts(rowid, prompt_text) VALUES (new.id, new.prompt_text);
131
133
  END;
132
134
 
133
- CREATE TRIGGER decisions_ai AFTER INSERT ON decisions BEGIN
135
+ CREATE TRIGGER IF NOT EXISTS decisions_ai AFTER INSERT ON decisions BEGIN
134
136
  INSERT INTO decisions_fts(rowid, decision, reason) VALUES (new.id, new.decision, new.reason);
135
137
  END;
136
- CREATE TRIGGER decisions_ad AFTER DELETE ON decisions BEGIN
138
+ CREATE TRIGGER IF NOT EXISTS decisions_ad AFTER DELETE ON decisions BEGIN
137
139
  INSERT INTO decisions_fts(decisions_fts, rowid, decision, reason) VALUES('delete', old.id, old.decision, old.reason);
138
140
  END;
139
- CREATE TRIGGER decisions_au AFTER UPDATE ON decisions BEGIN
141
+ CREATE TRIGGER IF NOT EXISTS decisions_au AFTER UPDATE ON decisions BEGIN
140
142
  INSERT INTO decisions_fts(decisions_fts, rowid, decision, reason) VALUES('delete', old.id, old.decision, old.reason);
141
143
  INSERT INTO decisions_fts(rowid, decision, reason) VALUES (new.id, new.decision, new.reason);
142
144
  END;
143
145
 
144
- CREATE TRIGGER turn_summaries_ai AFTER INSERT ON turn_summaries BEGIN
146
+ CREATE TRIGGER IF NOT EXISTS turn_summaries_ai AFTER INSERT ON turn_summaries BEGIN
145
147
  INSERT INTO turn_summaries_fts(rowid, summary) VALUES (new.id, new.summary);
146
148
  END;
147
- CREATE TRIGGER turn_summaries_ad AFTER DELETE ON turn_summaries BEGIN
149
+ CREATE TRIGGER IF NOT EXISTS turn_summaries_ad AFTER DELETE ON turn_summaries BEGIN
148
150
  INSERT INTO turn_summaries_fts(turn_summaries_fts, rowid, summary) VALUES('delete', old.id, old.summary);
149
151
  END;
150
- CREATE TRIGGER turn_summaries_au AFTER UPDATE ON turn_summaries BEGIN
152
+ CREATE TRIGGER IF NOT EXISTS turn_summaries_au AFTER UPDATE ON turn_summaries BEGIN
151
153
  INSERT INTO turn_summaries_fts(turn_summaries_fts, rowid, summary) VALUES('delete', old.id, old.summary);
152
154
  INSERT INTO turn_summaries_fts(rowid, summary) VALUES (new.id, new.summary);
153
155
  END;
154
156
 
155
- CREATE TABLE schema_versions (
157
+ CREATE TABLE IF NOT EXISTS schema_versions (
156
158
  version INTEGER PRIMARY KEY,
157
159
  applied_at_epoch INTEGER NOT NULL
158
160
  );
@@ -170,6 +172,20 @@ CREATE TABLE IF NOT EXISTS savings_log (
170
172
  CREATE INDEX IF NOT EXISTS idx_savings_bucket_ts ON savings_log(bucket, ts);
171
173
  `;
172
174
 
175
+ const SCHEMA_V4 = `
176
+ CREATE TABLE IF NOT EXISTS decision_links (
177
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
178
+ from_id INTEGER NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
179
+ to_id INTEGER NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
180
+ kind TEXT NOT NULL CHECK(kind IN ('related_to','annotates')),
181
+ note TEXT,
182
+ created_at_epoch INTEGER NOT NULL,
183
+ UNIQUE(from_id, to_id, kind)
184
+ );
185
+ CREATE INDEX IF NOT EXISTS idx_decision_links_from ON decision_links(from_id);
186
+ CREATE INDEX IF NOT EXISTS idx_decision_links_to ON decision_links(to_id);
187
+ `;
188
+
173
189
  const VEC_TRIGGERS = `
174
190
  CREATE TRIGGER IF NOT EXISTS decisions_vec_ad AFTER DELETE ON decisions BEGIN
175
191
  DELETE FROM decisions_vec WHERE rowid = old.id;
@@ -199,40 +215,137 @@ export class MemoryDb {
199
215
  }
200
216
 
201
217
  private ensureSchema(): void {
202
- const bootstrap = this.db
203
- .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='schema_versions'")
204
- .get();
205
- if (!bootstrap) {
206
- const run = this.db.transaction(() => {
207
- this.db.exec(SCHEMA_V1);
208
- if (this.hasVec) {
209
- this.db.exec(this.vecTableStmts(VEC_DIM));
210
- this.db.exec(VEC_TRIGGERS);
211
- }
212
- this.db.exec(SCHEMA_V3);
213
- this.db
214
- .prepare("INSERT INTO schema_versions (version, applied_at_epoch) VALUES (?, ?)")
215
- .run(this.hasVec ? CURRENT_VERSION : 1, epochSeconds());
216
- });
217
- run();
218
- return;
219
- }
220
- const current = this.schemaVersion();
221
- if (current >= CURRENT_VERSION) return;
218
+ // Shape enforcement instead of a version ladder: every statement below
219
+ // is idempotent, so a database from ANY earlier release converges to the
220
+ // current schema on open without version bookkeeping or stepwise
221
+ // migrations. Vec tables are created whenever sqlite-vec is available
222
+ // (and skipped when it isn't), which subsumes the old "created without
223
+ // vec, upgrade later" version dance.
222
224
  const run = this.db.transaction(() => {
223
- if (current < 2 && this.hasVec) {
225
+ this.db.exec(SCHEMA_V1);
226
+ this.applyV4Columns();
227
+ this.db.exec(SCHEMA_V3);
228
+ this.db.exec(SCHEMA_V4);
229
+ if (this.hasVec) {
224
230
  this.db.exec(this.vecTableStmts(VEC_DIM));
225
231
  this.db.exec(VEC_TRIGGERS);
226
232
  }
227
- if (current < 3) this.db.exec(SCHEMA_V3);
228
- if (current < 2 && !this.hasVec) return; // vec step still pending
233
+ // Informational stamp only — nothing branches on it anymore.
229
234
  this.db
230
- .prepare("INSERT INTO schema_versions (version, applied_at_epoch) VALUES (?, ?)")
235
+ .prepare("INSERT OR REPLACE INTO schema_versions (version, applied_at_epoch) VALUES (?, ?)")
231
236
  .run(CURRENT_VERSION, epochSeconds());
232
237
  });
233
238
  run();
234
239
  }
235
240
 
241
+ /** `decisions.tags` / `decisions.files` (JSON arrays) on databases created
242
+ * before R13; guarded so re-runs and partial states stay safe. */
243
+ private applyV4Columns(): void {
244
+ const cols = new Set(
245
+ (this.db.prepare("PRAGMA table_info(decisions)").all() as { name: string }[]).map((c) => c.name),
246
+ );
247
+ if (!cols.has("tags")) this.db.exec("ALTER TABLE decisions ADD COLUMN tags TEXT");
248
+ if (!cols.has("files")) this.db.exec("ALTER TABLE decisions ADD COLUMN files TEXT");
249
+ }
250
+
251
+ // ── R13: linked memory (A-MEM style) ─────────────────────────────────────
252
+
253
+ /** Outgoing `related_to` links a single decision may accumulate. */
254
+ static readonly MAX_OUTGOING_RELATED = 6;
255
+ /** Inbound `related_to` links a hub decision accepts before new links skip it. */
256
+ static readonly MAX_INBOUND_RELATED = 8;
257
+
258
+ /**
259
+ * Create a directed link between two decisions. Refuses self-links,
260
+ * unknown ids and cap overflow; dedupes via UNIQUE(from_id, to_id, kind)
261
+ * (returns false on conflict instead of throwing).
262
+ */
263
+ linkDecisions(fromId: number, toId: number, kind: "related_to" | "annotates", note: string | null = null): boolean {
264
+ if (!Number.isInteger(fromId) || !Number.isInteger(toId) || fromId === toId) return false;
265
+ const fromRow = this.db.prepare("SELECT 1 FROM decisions WHERE id = ?").get(fromId);
266
+ const toRow = this.db.prepare("SELECT 1 FROM decisions WHERE id = ?").get(toId);
267
+ if (!fromRow || !toRow) return false;
268
+ if (kind === "related_to") {
269
+ const outN = (
270
+ this.db.prepare("SELECT COUNT(*) AS n FROM decision_links WHERE from_id = ? AND kind = 'related_to'").get(fromId) as { n: number }
271
+ ).n;
272
+ if (outN >= MemoryDb.MAX_OUTGOING_RELATED) return false;
273
+ const inN = (
274
+ this.db.prepare("SELECT COUNT(*) AS n FROM decision_links WHERE to_id = ? AND kind = 'related_to'").get(toId) as { n: number }
275
+ ).n;
276
+ if (inN >= MemoryDb.MAX_INBOUND_RELATED) return false;
277
+ }
278
+ try {
279
+ this.db
280
+ .prepare("INSERT INTO decision_links (from_id, to_id, kind, note, created_at_epoch) VALUES (?, ?, ?, ?, ?)")
281
+ .run(fromId, toId, kind, note, epochSeconds());
282
+ return true;
283
+ } catch {
284
+ return false; // duplicate or FK violation — already linked
285
+ }
286
+ }
287
+
288
+ /** Both directions of a decision's links, ready for recall rendering. */
289
+ getDecisionLinks(id: number): {
290
+ relatedTo: number[];
291
+ relatedFrom: number[];
292
+ annotates: { id: number; note: string | null }[];
293
+ annotatedBy: { id: number; note: string | null }[];
294
+ } {
295
+ const rows = this.db
296
+ .prepare("SELECT from_id, to_id, kind, note FROM decision_links WHERE from_id = ? OR to_id = ?")
297
+ .all(id, id) as { from_id: number; to_id: number; kind: string; note: string | null }[];
298
+ const out = {
299
+ relatedTo: [] as number[],
300
+ relatedFrom: [] as number[],
301
+ annotates: [] as { id: number; note: string | null }[],
302
+ annotatedBy: [] as { id: number; note: string | null }[],
303
+ };
304
+ for (const r of rows) {
305
+ if (r.kind === "related_to") {
306
+ if (r.from_id === id) out.relatedTo.push(r.to_id);
307
+ else out.relatedFrom.push(r.from_id);
308
+ } else {
309
+ if (r.from_id === id) out.annotates.push({ id: r.to_id, note: r.note });
310
+ else out.annotatedBy.push({ id: r.from_id, note: r.note });
311
+ }
312
+ }
313
+ return out;
314
+ }
315
+
316
+ /** Short one-line text of a decision (for link rendering). */
317
+ decisionHeadline(id: number): string | null {
318
+ const row = this.db.prepare("SELECT decision FROM decisions WHERE id = ?").get(id) as { decision: string } | undefined;
319
+ return row ? row.decision : null;
320
+ }
321
+
322
+ /**
323
+ * R13 auto-linking: embed the new decision, find similar past decisions
324
+ * and connect them with `related_to` edges (A-MEM's dynamic indexing).
325
+ * The similarity gate is deliberately tighter than recall (0.85 vs 0.92
326
+ * L2 on normalized vectors ≈ cosine ≥ 0.64): a wrong link is worse than a
327
+ * missing one, because recall walks them.
328
+ */
329
+ autoLinkDecision(
330
+ embedder: Embedder,
331
+ newId: number,
332
+ text: string,
333
+ opts: { k?: number; maxDistance?: number; cap?: number } = {},
334
+ ): Promise<number[]> {
335
+ const { k = 4, maxDistance = 0.85, cap = 3 } = opts;
336
+ return (async () => {
337
+ if (!this.hasVecTables() || !text.trim()) return [];
338
+ const ids = await this.searchDecisionsVec(embedder, text, k, maxDistance);
339
+ const linked: number[] = [];
340
+ for (const id of ids) {
341
+ if (id === newId) continue;
342
+ if (linked.length >= cap) break;
343
+ if (this.linkDecisions(newId, id, "related_to")) linked.push(id);
344
+ }
345
+ return linked;
346
+ })();
347
+ }
348
+
236
349
  private vecTableStmts(dim: number): string {
237
350
  return `
238
351
  CREATE VIRTUAL TABLE IF NOT EXISTS decisions_vec USING vec0(embedding float[${dim}]);
@@ -3,6 +3,8 @@
3
3
  * Weighted sum: vector similarity 0.5, keyword/file-hint 0.4, recency 0.1.
4
4
  */
5
5
  import type { Chunk } from "../types.js";
6
+ import type { ParsedQuery } from "./query-parser.js";
7
+ import type { QuerySignals } from "./retriever.js";
6
8
 
7
9
  const VECTOR_WEIGHT = 0.5;
8
10
  const KEYWORD_WEIGHT = 0.4;
@@ -27,3 +29,54 @@ export class ConfidenceScorer {
27
29
  return Math.pow(0.5, ageSeconds / RECENCY_HALF_LIFE);
28
30
  }
29
31
  }
32
+
33
+ // ── R8: query-level confidence ─────────────────────────────────────────────
34
+
35
+ export type QueryConfidence = "high" | "medium" | "low";
36
+
37
+ export interface QueryAssessment {
38
+ level: QueryConfidence;
39
+ /** Composite 0..1 quality score (reported for observability). */
40
+ quality: number;
41
+ /** 1-3 deterministic recovery hints; non-empty only below `high`. */
42
+ hints: string[];
43
+ }
44
+
45
+ /** Signal weights for the composite quality score. */
46
+ const SIGNAL_WEIGHTS = { topScore: 0.5, agreement: 0.35, margin: 0.15 };
47
+ /** Candidate pools this small cap the level at `medium` (tiny indexes lie). */
48
+ const MIN_CANDIDATES = 5;
49
+
50
+ /**
51
+ * Map query signals to high/medium/low plus recovery hints. Thresholds come
52
+ * from config (calibrated on the eval fixture); hints are derived from the
53
+ * parsed query so the agent gets actionable, deterministic advice instead of
54
+ * a silent rewrite it cannot inspect.
55
+ */
56
+ export function assessQuery(
57
+ signals: QuerySignals,
58
+ parsed: ParsedQuery,
59
+ thresholds: { high: number; low: number },
60
+ ): QueryAssessment {
61
+ const marginNorm = Math.min(1, Math.max(0, signals.margin));
62
+ const quality =
63
+ SIGNAL_WEIGHTS.topScore * Math.min(1, Math.max(0, signals.topScore)) +
64
+ SIGNAL_WEIGHTS.agreement * Math.min(1, Math.max(0, signals.agreement)) +
65
+ SIGNAL_WEIGHTS.margin * marginNorm;
66
+
67
+ let level: QueryConfidence;
68
+ if (quality >= thresholds.high) level = "high";
69
+ else if (quality >= thresholds.low) level = "medium";
70
+ else level = "low";
71
+ if (signals.candidates < MIN_CANDIDATES && level === "high") level = "medium";
72
+
73
+ const hints: string[] = [];
74
+ if (level !== "high") {
75
+ if (parsed.fileHints.length === 0) hints.push("name a file or path fragment (e.g. retriever.ts)");
76
+ const identifierLike = parsed.keywords.some((k) => k.length >= 4 && /[a-z][A-Z]|[A-Z][a-z]/.test(k));
77
+ if (!identifierLike) hints.push("use an exact symbol name (function or class, e.g. HybridRetriever)");
78
+ if (parsed.keywords.length > 8) hints.push("drop vague terms; keep 2-4 specific keywords");
79
+ if (signals.agreement < 0.2) hints.push("results disagree between retrieval paths; try a more specific query");
80
+ }
81
+ return { level, quality, hints: hints.slice(0, 3) };
82
+ }
@@ -80,3 +80,36 @@ export class QueryParser {
80
80
  return result;
81
81
  }
82
82
  }
83
+
84
+ /**
85
+ * R8: deterministic query elaboration. Strips stopwords, caps the raw terms,
86
+ * and injects up to three identifier-like words that appear in ≥2 of the
87
+ * near-miss FTS hits. No LLM, no network, fully reproducible — the raw
88
+ * query's own ranking is never modified (callers fuse the variant as an
89
+ * additional, lower-weighted candidate source only).
90
+ */
91
+ export function elaborateQuery(query: string, nearMissChunks: { content: string }[]): string | null {
92
+ const words = query.match(/[a-zA-Z_$][a-zA-Z0-9_$]*/g) ?? [];
93
+ const kept: string[] = [];
94
+ for (const w of words) {
95
+ if (STOP_WORDS.has(w.toLowerCase()) || w.length < 3) continue;
96
+ if (!kept.includes(w)) kept.push(w);
97
+ }
98
+ const freq = new Map<string, number>();
99
+ for (const chunk of nearMissChunks) {
100
+ const local = new Set<string>();
101
+ for (const m of chunk.content.matchAll(/[A-Za-z_$][A-Za-z0-9_$]{3,}/g)) {
102
+ const w = m[0];
103
+ if (!kept.includes(w)) local.add(w);
104
+ }
105
+ for (const w of local) freq.set(w, (freq.get(w) ?? 0) + 1);
106
+ }
107
+ const extras = [...freq.entries()]
108
+ .filter(([, n]) => n >= 2)
109
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
110
+ .slice(0, 3)
111
+ .map(([w]) => w);
112
+ const variant = [...kept.slice(0, 8), ...extras].join(" ");
113
+ if (variant.length < 3 || variant === query.trim()) return null;
114
+ return variant;
115
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * R10: opt-in local cross-encoder reranker (plan: docs/IMPLEMENTATION-PLAN.md).
3
+ *
4
+ * Why opt-in and OFF by default: reranking evidence is split — a core gain in
5
+ * CodeRAG-style pipelines, but measured harmful for SE tasks when it costs
6
+ * ~800 ms per query. Only this package's own eval can decide, and the model
7
+ * download (~80 MB) must never happen implicitly in a package that markets
8
+ * "one npm command".
9
+ *
10
+ * The reranker reorders the fused candidate list (capped); path penalty, file
11
+ * diversity and the marginal stop are applied AFTER it, unchanged. If the
12
+ * model cannot load, retrieval silently falls back to the fused order — a
13
+ * missing optional download degrades, never breaks.
14
+ *
15
+ * Implementation notes (PR #7 review): we do NOT go through the
16
+ * text-classification pipeline. Two reasons:
17
+ *
18
+ * 1. Its input is a plain string — there is no `{text, text_pair}` shape, so
19
+ * pair scoring via the pipeline throws and permanently disables reranking.
20
+ * 2. Cross-encoder rerankers (ms-marco-MiniLM) emit ONE logit; the pipeline
21
+ * softmaxes it, so every document scores exactly 1.0 and no reordering
22
+ * can happen. Sentence-transformers' CrossEncoder applies a sigmoid to
23
+ * the raw logit instead — we do the same, on raw model logits.
24
+ */
25
+
26
+ import { transformersCacheDir } from "../indexer/embedder.js";
27
+
28
+ /** Minimal interface so unit tests can inject a stub (no model download). */
29
+ export interface RerankerLike {
30
+ rerank(query: string, docs: string[]): Promise<number[]>;
31
+ }
32
+
33
+ /** Structural slice of @huggingface/transformers we need (kept `any`-light). */
34
+ interface TransformersModule {
35
+ AutoTokenizer: {
36
+ from_pretrained: (model: string, options?: Record<string, unknown>) => Promise<TokenizerLike>;
37
+ };
38
+ AutoModelForSequenceClassification: {
39
+ from_pretrained: (model: string, options?: Record<string, unknown>) => Promise<ModelLike>;
40
+ };
41
+ env: Record<string, unknown>;
42
+ }
43
+
44
+ interface TokenizerLike {
45
+ (text: string, options: Record<string, unknown>): unknown;
46
+ }
47
+
48
+ interface ModelLike {
49
+ (inputs: unknown): Promise<{ logits: { data: Float32Array | number[] } }>;
50
+ }
51
+
52
+ type LoadedReranker = { tokenizer: TokenizerLike; model: ModelLike };
53
+
54
+ export class LocalReranker implements RerankerLike {
55
+ private loaded: LoadedReranker | null = null;
56
+ private loading: Promise<LoadedReranker | null> | null = null;
57
+ /** Set after a load failure; every later call short-circuits to no-op. */
58
+ failed = false;
59
+
60
+ constructor(
61
+ readonly model: string,
62
+ private readonly loadTransformers: () => Promise<TransformersModule> = async () =>
63
+ (await import("@huggingface/transformers")) as unknown as TransformersModule,
64
+ ) {}
65
+
66
+ async rerank(query: string, docs: string[]): Promise<number[]> {
67
+ if (this.failed || docs.length === 0) return [];
68
+ try {
69
+ const loaded = await this.ensureLoaded();
70
+ if (!loaded) return [];
71
+ const { tokenizer, model } = loaded;
72
+ const scores: number[] = [];
73
+ // One pair per call: 30 sequential CPU inferences are well inside
74
+ // the latency budget this opt-in feature accepts, and per-pair
75
+ // truncation keeps a long doc from padding the whole batch.
76
+ for (const doc of docs) {
77
+ const inputs = tokenizer(query, { text_pair: doc, padding: true, truncation: true });
78
+ const out = await model(inputs);
79
+ const logit = Number(out.logits.data[0] ?? 0);
80
+ // Sigmoid, not softmax: single-logit cross-encoders must keep
81
+ // their ranking signal (softmax over one logit is constant 1).
82
+ scores.push(1 / (1 + Math.exp(-logit)));
83
+ }
84
+ return scores;
85
+ } catch {
86
+ this.failed = true;
87
+ return [];
88
+ }
89
+ }
90
+
91
+ private ensureLoaded(): Promise<LoadedReranker | null> {
92
+ if (this.loaded) return Promise.resolve(this.loaded);
93
+ if (!this.loading) {
94
+ this.loading = (async () => {
95
+ const mod = await this.loadTransformers();
96
+ // Same models dir as the embedder, so opt-in reranking never
97
+ // downloads into a stray ./Xenova folder inside the project.
98
+ mod.env.cacheDir = transformersCacheDir();
99
+ const tokenizer = await mod.AutoTokenizer.from_pretrained(this.model);
100
+ const model = await mod.AutoModelForSequenceClassification.from_pretrained(this.model, {
101
+ quantized: true,
102
+ });
103
+ this.loaded = { tokenizer, model };
104
+ return this.loaded;
105
+ })().catch(() => {
106
+ this.failed = true;
107
+ return null;
108
+ });
109
+ }
110
+ return this.loading;
111
+ }
112
+ }