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/README.md +63 -0
- package/docs/IMPLEMENTATION-PLAN.md +30 -13
- package/package.json +83 -81
- package/src/compression/compressor.ts +119 -20
- package/src/config.ts +63 -3
- package/src/engine.ts +238 -13
- package/src/extension.ts +120 -9
- package/src/guidance.ts +1 -0
- package/src/indexer/chunker.ts +321 -21
- package/src/indexer/context-header.ts +86 -0
- package/src/indexer/embedder.ts +32 -5
- package/src/indexer/manifest.ts +41 -19
- package/src/indexer/pipeline.ts +435 -197
- package/src/memory/db.ts +166 -53
- package/src/retrieval/confidence.ts +53 -0
- package/src/retrieval/query-parser.ts +33 -0
- package/src/retrieval/reranker.ts +112 -0
- package/src/retrieval/retriever.ts +351 -14
- package/src/storage/fts-store.ts +48 -6
- package/src/storage/graph-store.ts +160 -4
- package/src/storage/local-backend.ts +36 -4
- package/src/storage/vector-store.ts +35 -29
- package/src/utils.ts +13 -0
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
|
|
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 =
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|