opencode-episodic-memory 0.1.2 → 0.1.3
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 +71 -25
- package/package.json +4 -3
- package/plugin/episodic-memory.ts +19 -46
- package/skills/remembering-conversations/SKILL.md +2 -1
- package/src/cli.ts +78 -80
- package/src/format.ts +70 -0
- package/src/indexer.ts +7 -6
- package/src/parser.ts +4 -3
- package/src/reader.ts +19 -1
- package/src/store.ts +222 -33
- package/src/parser.test.ts +0 -64
- package/src/reader.test.ts +0 -205
- package/src/store.test.ts +0 -72
package/src/reader.ts
CHANGED
|
@@ -128,7 +128,10 @@ export function transcriptHasMarker(db: Database, sessionId: string): boolean {
|
|
|
128
128
|
return row.n > 0;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
|
|
131
|
+
// Module-internal: the raw read with no privacy gate. Production code must go
|
|
132
|
+
// through getTranscriptChecked so the exclusion marker can never be bypassed by
|
|
133
|
+
// forgetting a manual transcriptHasMarker() call. Not exported.
|
|
134
|
+
function getTranscript(db: Database, sessionId: string): SourceMessage[] {
|
|
132
135
|
const messages = MessageRowSchema.array().parse(
|
|
133
136
|
db
|
|
134
137
|
.prepare(
|
|
@@ -162,3 +165,18 @@ export function getTranscript(db: Database, sessionId: string): SourceMessage[]
|
|
|
162
165
|
parts: partsByMsg.get(m.id) ?? [],
|
|
163
166
|
}));
|
|
164
167
|
}
|
|
168
|
+
|
|
169
|
+
// Discriminated result: excluded conversations never yield a transcript.
|
|
170
|
+
export type CheckedTranscript =
|
|
171
|
+
| { excluded: true }
|
|
172
|
+
| { excluded: false; messages: SourceMessage[] };
|
|
173
|
+
|
|
174
|
+
// The single privacy-gated entry point for reading a transcript. Runs the
|
|
175
|
+
// AUTHORITATIVE raw-blob exclusion check (transcriptHasMarker) BEFORE reading,
|
|
176
|
+
// so the opt-out marker cannot be bypassed by a caller forgetting to check.
|
|
177
|
+
// All production call sites (CLI read, plugin episodic_read, indexer) use this;
|
|
178
|
+
// the raw getTranscript is module-internal.
|
|
179
|
+
export function getTranscriptChecked(db: Database, sessionId: string): CheckedTranscript {
|
|
180
|
+
if (transcriptHasMarker(db, sessionId)) return { excluded: true };
|
|
181
|
+
return { excluded: false, messages: getTranscript(db, sessionId) };
|
|
182
|
+
}
|
package/src/store.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
// Index database: plain SQLite (bun:sqlite). Embeddings stored as Float32
|
|
2
|
-
// blobs; similarity is brute-force cosine in JS. At our scale (tens of
|
|
2
|
+
// blobs; vector similarity is brute-force cosine in JS. At our scale (tens of
|
|
3
3
|
// thousands of chunks) this is single-digit milliseconds per query and has
|
|
4
4
|
// zero native-extension risk. (sqlite-vec was rejected in Phase 0: bun:sqlite
|
|
5
5
|
// cannot load dynamic extensions. Swap in a vec0 backend here if scale ever
|
|
6
6
|
// demands it.)
|
|
7
|
+
//
|
|
8
|
+
// Lexical retrieval uses SQLite's built-in FTS5 (compiled into bun:sqlite —
|
|
9
|
+
// verified in spikes/fts5-check.ts; it's a static module, NOT a loadable
|
|
10
|
+
// extension, so the sqlite-vec limitation doesn't apply). search() fuses the
|
|
11
|
+
// vector and BM25 rankings via reciprocal rank fusion.
|
|
7
12
|
import { Database } from "bun:sqlite";
|
|
8
13
|
import { mkdirSync } from "node:fs";
|
|
9
14
|
import { homedir } from "node:os";
|
|
@@ -11,6 +16,13 @@ import { dirname, join } from "node:path";
|
|
|
11
16
|
|
|
12
17
|
export const DEFAULT_INDEX_DB = join(homedir(), ".local/share/opencode-episodic-memory/index.db");
|
|
13
18
|
|
|
19
|
+
// Bump when the FTS schema changes to force a one-time rebuild on next open.
|
|
20
|
+
const FTS_SCHEMA_VERSION = 1;
|
|
21
|
+
// Reciprocal rank fusion constant (standard default) and how deep into each
|
|
22
|
+
// ranked list fusion looks — contributions past this depth are negligible.
|
|
23
|
+
const RRF_K = 60;
|
|
24
|
+
const FUSE_DEPTH = 200;
|
|
25
|
+
|
|
14
26
|
export function indexDbPath(): string {
|
|
15
27
|
return process.env.EPISODIC_INDEX_DB ?? DEFAULT_INDEX_DB;
|
|
16
28
|
}
|
|
@@ -39,9 +51,45 @@ export function openIndex(path: string = indexDbPath()): Database {
|
|
|
39
51
|
PRIMARY KEY (session_id, seq)
|
|
40
52
|
)`);
|
|
41
53
|
db.run("CREATE INDEX IF NOT EXISTS chunks_time_idx ON chunks(time_created)");
|
|
54
|
+
|
|
55
|
+
// Full-text index over chunk text. External content (content='chunks') means
|
|
56
|
+
// the text isn't duplicated; the FTS index is kept in sync by triggers on
|
|
57
|
+
// chunks — robust to any write path (not just replaceSessionChunks), which is
|
|
58
|
+
// the standard SQLite pattern for external-content FTS5.
|
|
59
|
+
//
|
|
60
|
+
// WARNING — never VACUUM this DB. content_rowid rides chunks' IMPLICIT rowid
|
|
61
|
+
// (the PK is (session_id, seq), so there is no explicit INTEGER PRIMARY KEY
|
|
62
|
+
// alias for rowid). VACUUM may renumber implicit rowids, which would silently
|
|
63
|
+
// misalign every FTS posting from its chunk row. If VACUUM ever becomes
|
|
64
|
+
// necessary, give chunks an explicit `rowid INTEGER PRIMARY KEY` first (a
|
|
65
|
+
// migration) — do not just run it. See AGENTS.md.
|
|
66
|
+
db.run("CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(text, content='chunks', content_rowid='rowid')");
|
|
67
|
+
db.run(`CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN
|
|
68
|
+
INSERT INTO chunks_fts(rowid, text) VALUES (new.rowid, new.text);
|
|
69
|
+
END`);
|
|
70
|
+
db.run(`CREATE TRIGGER IF NOT EXISTS chunks_ad AFTER DELETE ON chunks BEGIN
|
|
71
|
+
INSERT INTO chunks_fts(chunks_fts, rowid, text) VALUES('delete', old.rowid, old.text);
|
|
72
|
+
END`);
|
|
73
|
+
db.run(`CREATE TRIGGER IF NOT EXISTS chunks_au AFTER UPDATE ON chunks BEGIN
|
|
74
|
+
INSERT INTO chunks_fts(chunks_fts, rowid, text) VALUES('delete', old.rowid, old.text);
|
|
75
|
+
INSERT INTO chunks_fts(rowid, text) VALUES (new.rowid, new.text);
|
|
76
|
+
END`);
|
|
77
|
+
migrateFts(db);
|
|
42
78
|
return db;
|
|
43
79
|
}
|
|
44
80
|
|
|
81
|
+
// One-time FTS backfill for index DBs created before FTS existed: they have
|
|
82
|
+
// chunks but an empty FTS index. COUNT(*) on an external-content FTS returns the
|
|
83
|
+
// content-row count (can't reveal "not indexed"), so gate on PRAGMA user_version
|
|
84
|
+
// instead. 'rebuild' repopulates from chunks and is a no-op on a fresh/empty DB.
|
|
85
|
+
function migrateFts(db: Database): void {
|
|
86
|
+
const version = db.prepare<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version ?? 0;
|
|
87
|
+
if (version < FTS_SCHEMA_VERSION) {
|
|
88
|
+
db.run("INSERT INTO chunks_fts(chunks_fts) VALUES('rebuild')");
|
|
89
|
+
db.run(`PRAGMA user_version = ${FTS_SCHEMA_VERSION}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
45
93
|
export interface IndexedSession {
|
|
46
94
|
id: string;
|
|
47
95
|
project_id: string;
|
|
@@ -102,32 +150,56 @@ export interface SearchOptions {
|
|
|
102
150
|
limit?: number;
|
|
103
151
|
after?: number; // ms epoch
|
|
104
152
|
before?: number; // ms epoch
|
|
105
|
-
text?: string; // exact substring filter (ANDed with
|
|
153
|
+
text?: string; // exact substring filter (ANDed with ranking)
|
|
106
154
|
minScore?: number;
|
|
155
|
+
// Raw natural-language query for the BM25/lexical arm of hybrid search
|
|
156
|
+
// (used only together with hybrid: true).
|
|
157
|
+
queryText?: string;
|
|
158
|
+
// Opt in to hybrid (vector + BM25 fused via RRF) retrieval. Default is pure
|
|
159
|
+
// vector: on this corpus BM25 tends to match injected boilerplate (e.g.
|
|
160
|
+
// [MEMORY] preamble), so fusion is offered, not forced (see AGENTS.md).
|
|
161
|
+
// Requires queryText.
|
|
162
|
+
hybrid?: boolean;
|
|
107
163
|
}
|
|
108
164
|
|
|
109
|
-
|
|
110
|
-
|
|
165
|
+
// A scored candidate before display fields are fetched (phase 1 output).
|
|
166
|
+
interface ScoredChunk {
|
|
167
|
+
session_id: string;
|
|
168
|
+
seq: number;
|
|
169
|
+
time_created: number;
|
|
170
|
+
score: number;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Shared time/text filter clauses (no leading WHERE). `after`/`before` use
|
|
174
|
+
// `!== undefined` (not truthiness) so a legitimate epoch-0 bound isn't dropped
|
|
175
|
+
// as "absent". `text` keeps a truthiness check: an empty substring filter is a
|
|
176
|
+
// no-op, not a match-everything `LIKE '%%'`. Reused by the vector, BM25, and
|
|
177
|
+
// LIKE-fallback candidate scans so the three stay filter-consistent.
|
|
178
|
+
function filterClauses(opts: SearchOptions): { clauses: string[]; params: (string | number)[] } {
|
|
111
179
|
const clauses: string[] = [];
|
|
112
180
|
const params: (string | number)[] = [];
|
|
113
|
-
if (opts.after) { clauses.push("c.time_created >= ?"); params.push(opts.after); }
|
|
114
|
-
if (opts.before) { clauses.push("c.time_created < ?"); params.push(opts.before); }
|
|
181
|
+
if (opts.after !== undefined) { clauses.push("c.time_created >= ?"); params.push(opts.after); }
|
|
182
|
+
if (opts.before !== undefined) { clauses.push("c.time_created < ?"); params.push(opts.before); }
|
|
115
183
|
if (opts.text) { clauses.push("c.text LIKE ? ESCAPE '\\'"); params.push(`%${escapeLike(opts.text)}%`); }
|
|
116
|
-
|
|
184
|
+
return { clauses, params };
|
|
185
|
+
}
|
|
117
186
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
187
|
+
// Phase 1 (vector): score every candidate chunk by cosine against the query,
|
|
188
|
+
// apply the filters + minScore, and return them sorted best-first. Reads only
|
|
189
|
+
// the embedding blob (not the bulky text/title/directory), so the per-query
|
|
190
|
+
// cost is dims arithmetic, not full-row materialization.
|
|
191
|
+
function scoreVector(db: Database, queryVec: Float32Array, opts: SearchOptions): ScoredChunk[] {
|
|
192
|
+
const { clauses, params } = filterClauses(opts);
|
|
193
|
+
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
194
|
+
const candidates = db
|
|
195
|
+
.prepare<{ session_id: string; seq: number; time_created: number; embedding: Uint8Array }, (string | number)[]>(
|
|
196
|
+
`SELECT c.session_id, c.seq, c.time_created, c.embedding FROM chunks c ${where}`
|
|
125
197
|
)
|
|
126
198
|
.all(...params);
|
|
127
199
|
|
|
128
200
|
const dims = queryVec.length;
|
|
129
201
|
const minScore = opts.minScore ?? 0;
|
|
130
|
-
return
|
|
202
|
+
return candidates
|
|
131
203
|
// Skip vectors from a different embedding model (e.g. mid-migration or
|
|
132
204
|
// orphaned rows) — a dims mismatch would corrupt the dot product or throw.
|
|
133
205
|
.filter((r) => r.embedding.byteLength === dims * 4)
|
|
@@ -135,30 +207,147 @@ export function search(db: Database, queryVec: Float32Array, opts: SearchOptions
|
|
|
135
207
|
const v = new Float32Array(r.embedding.buffer, r.embedding.byteOffset, dims);
|
|
136
208
|
let dot = 0;
|
|
137
209
|
for (let i = 0; i < dims; i++) dot += queryVec[i] * v[i];
|
|
138
|
-
return {
|
|
139
|
-
session_id: r.session_id, seq: r.seq, time_created: r.time_created,
|
|
140
|
-
text: r.text, score: dot, title: r.title, directory: r.directory,
|
|
141
|
-
};
|
|
210
|
+
return { session_id: r.session_id, seq: r.seq, time_created: r.time_created, score: dot };
|
|
142
211
|
})
|
|
143
212
|
.filter((h) => h.score >= minScore)
|
|
144
|
-
.sort((a, b) => b.score - a.score)
|
|
145
|
-
.slice(0, limit);
|
|
213
|
+
.sort((a, b) => b.score - a.score);
|
|
146
214
|
}
|
|
147
215
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
216
|
+
// Phase 2: fetch display fields (text/title/directory) only for the winners —
|
|
217
|
+
// a point lookup per hit on the (session_id, seq) primary key. K is bounded by
|
|
218
|
+
// the caller's limit (≤ 50 in the plugin), so this is a tiny handful of reads.
|
|
219
|
+
function hydrate(db: Database, scored: ScoredChunk[]): SearchHit[] {
|
|
220
|
+
const detail = db.prepare<{ text: string; title: string; directory: string }, [string, number]>(
|
|
221
|
+
`SELECT c.text, s.title, s.directory
|
|
222
|
+
FROM chunks c JOIN sessions s ON s.id = c.session_id
|
|
223
|
+
WHERE c.session_id = ? AND c.seq = ?`
|
|
224
|
+
);
|
|
225
|
+
const hits: SearchHit[] = [];
|
|
226
|
+
for (const h of scored) {
|
|
227
|
+
const d = detail.get(h.session_id, h.seq);
|
|
228
|
+
// Inner-join semantics: skip a chunk whose session row is gone (shouldn't
|
|
229
|
+
// happen — replaceSessionChunks/pruneOrphans keep chunks and sessions in
|
|
230
|
+
// lockstep).
|
|
231
|
+
if (!d) continue;
|
|
232
|
+
hits.push({
|
|
233
|
+
session_id: h.session_id, seq: h.seq, time_created: h.time_created,
|
|
234
|
+
text: d.text, score: h.score, title: d.title, directory: d.directory,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
return hits;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Turn a raw user query into a safe FTS5 MATCH expression: each whitespace-
|
|
241
|
+
// separated token is wrapped as a quoted string (internal quotes doubled). This
|
|
242
|
+
// neutralizes FTS operators (AND/OR/NOT/NEAR) and syntax chars in user input —
|
|
243
|
+
// they become literal search terms, never MATCH syntax — while preserving
|
|
244
|
+
// implicit-AND semantics across tokens. Empty input yields "" (→ no match).
|
|
245
|
+
function ftsQueryString(query: string): string {
|
|
246
|
+
return query
|
|
247
|
+
.split(/\s+/)
|
|
248
|
+
.filter(Boolean)
|
|
249
|
+
.map((t) => `"${t.replace(/"/g, '""')}"`)
|
|
250
|
+
.join(" ");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Phase 1 (lexical): rank candidate chunks by BM25 over the FTS index, applying
|
|
254
|
+
// the shared filters. Returns best-first with score = -bm25 (bm25 is
|
|
255
|
+
// smaller-is-better/negative, so negating gives the higher-is-better convention
|
|
256
|
+
// used by the vector score). Falls back to a LIKE substring scan only if the
|
|
257
|
+
// MATCH expression is somehow still a syntax error.
|
|
258
|
+
function scoreFts(db: Database, query: string, opts: SearchOptions, depth: number): ScoredChunk[] {
|
|
259
|
+
const match = ftsQueryString(query);
|
|
260
|
+
if (!match) return [];
|
|
261
|
+
const { clauses, params } = filterClauses(opts);
|
|
262
|
+
const where = ["chunks_fts MATCH ?", ...clauses].join(" AND ");
|
|
263
|
+
try {
|
|
264
|
+
const rows = db
|
|
265
|
+
.prepare<{ session_id: string; seq: number; time_created: number; rank: number }, (string | number)[]>(
|
|
266
|
+
`SELECT c.session_id, c.seq, c.time_created, bm25(chunks_fts) AS rank
|
|
267
|
+
FROM chunks_fts JOIN chunks c ON c.rowid = chunks_fts.rowid
|
|
268
|
+
WHERE ${where} ORDER BY rank LIMIT ?`
|
|
269
|
+
)
|
|
270
|
+
.all(match, ...params, depth);
|
|
271
|
+
return rows.map((r) => ({ session_id: r.session_id, seq: r.seq, time_created: r.time_created, score: -r.rank }));
|
|
272
|
+
} catch (e) {
|
|
273
|
+
// Degrade to the unranked LIKE scan ONLY for a malformed MATCH expression.
|
|
274
|
+
// ftsQueryString fully quotes every token, so this is defensive/near-dead —
|
|
275
|
+
// but a different SqliteError (e.g. a corrupt/missing FTS index) must NOT be
|
|
276
|
+
// masked as "no ranking"; rethrow it so the real failure surfaces.
|
|
277
|
+
if (e instanceof Error && e.message.includes("fts5: syntax error")) {
|
|
278
|
+
return scoreLike(db, query, opts, depth);
|
|
279
|
+
}
|
|
280
|
+
throw e;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// LIKE substring fallback for scoreFts. Order by recency (the pre-FTS textSearch
|
|
285
|
+
// behavior); score is a constant since substring match has no ranking signal.
|
|
286
|
+
function scoreLike(db: Database, query: string, opts: SearchOptions, depth: number): ScoredChunk[] {
|
|
287
|
+
const { clauses, params } = filterClauses(opts);
|
|
288
|
+
const where = ["c.text LIKE ? ESCAPE '\\'", ...clauses].join(" AND ");
|
|
154
289
|
const rows = db
|
|
155
|
-
.prepare<
|
|
156
|
-
`SELECT c.session_id, c.seq, c.time_created
|
|
157
|
-
|
|
158
|
-
WHERE ${clauses.join(" AND ")} ORDER BY c.time_created DESC LIMIT ?`
|
|
290
|
+
.prepare<{ session_id: string; seq: number; time_created: number }, (string | number)[]>(
|
|
291
|
+
`SELECT c.session_id, c.seq, c.time_created FROM chunks c
|
|
292
|
+
WHERE ${where} ORDER BY c.time_created DESC LIMIT ?`
|
|
159
293
|
)
|
|
160
|
-
.all(...params,
|
|
161
|
-
return rows.map((r) => ({
|
|
294
|
+
.all(`%${escapeLike(query)}%`, ...params, depth);
|
|
295
|
+
return rows.map((r) => ({ session_id: r.session_id, seq: r.seq, time_created: r.time_created, score: 1 }));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Reciprocal rank fusion: combine several best-first ranked lists into one.
|
|
299
|
+
// Each list contributes 1/(k + rank) per item (rank 1-based); scores sum across
|
|
300
|
+
// lists, so an item ranked well by either signal surfaces. Ties/overlap dedupe
|
|
301
|
+
// by (session_id, seq).
|
|
302
|
+
function reciprocalRankFusion(lists: ScoredChunk[][], k: number = RRF_K): ScoredChunk[] {
|
|
303
|
+
const fused = new Map<string, { chunk: ScoredChunk; score: number }>();
|
|
304
|
+
for (const list of lists) {
|
|
305
|
+
list.forEach((c, i) => {
|
|
306
|
+
const key = `${c.session_id}\u0000${c.seq}`;
|
|
307
|
+
const contribution = 1 / (k + i + 1);
|
|
308
|
+
const existing = fused.get(key);
|
|
309
|
+
if (existing) existing.score += contribution;
|
|
310
|
+
else fused.set(key, { chunk: c, score: contribution });
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
return [...fused.values()]
|
|
314
|
+
.map((e) => ({ ...e.chunk, score: e.score }))
|
|
315
|
+
.sort((a, b) => b.score - a.score);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Pure vector search by default. Opt in to hybrid retrieval (vector + BM25
|
|
319
|
+
// fused via RRF) with hybrid: true + queryText. Pure vector is the default
|
|
320
|
+
// because, empirically on this corpus, the BM25 arm surfaces boilerplate noise
|
|
321
|
+
// and drags relevant semantic hits down (see AGENTS.md). minScore is applied to
|
|
322
|
+
// the vector scores BEFORE fusion (its calibration is cosine, not BM25).
|
|
323
|
+
export function search(db: Database, queryVec: Float32Array, opts: SearchOptions = {}): SearchHit[] {
|
|
324
|
+
const limit = opts.limit ?? 10;
|
|
325
|
+
const vector = scoreVector(db, queryVec, opts);
|
|
326
|
+
|
|
327
|
+
const queryText = opts.hybrid === true ? opts.queryText : undefined;
|
|
328
|
+
if (queryText === undefined || queryText.length === 0) {
|
|
329
|
+
return hydrate(db, vector.slice(0, limit));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const lexical = scoreFts(db, queryText, opts, FUSE_DEPTH);
|
|
333
|
+
const fused = reciprocalRankFusion([vector.slice(0, FUSE_DEPTH), lexical]);
|
|
334
|
+
return hydrate(db, fused.slice(0, limit));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Lexical BM25 search. NOTE the behavior change from the pre-FTS LIKE
|
|
338
|
+
// implementation: an empty or whitespace-only query now returns [] (the FTS
|
|
339
|
+
// MATCH expression is empty → matches nothing), whereas the old LIKE '%%' scan
|
|
340
|
+
// returned the most recent chunks. Callers wanting "recent" must query for it.
|
|
341
|
+
export function textSearch(db: Database, query: string, opts: SearchOptions = {}): SearchHit[] {
|
|
342
|
+
const limit = opts.limit ?? 10;
|
|
343
|
+
return hydrate(db, scoreFts(db, query, opts, limit));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Cheap "is there anything to search?" check. Shared by the CLI and the plugin
|
|
347
|
+
// so their empty-index messaging stays consistent (a single COUNT, not the full
|
|
348
|
+
// stats() roll-up).
|
|
349
|
+
export function isIndexEmpty(db: Database): boolean {
|
|
350
|
+
return (db.prepare<{ n: number }, []>("SELECT COUNT(*) n FROM chunks").get()?.n ?? 0) === 0;
|
|
162
351
|
}
|
|
163
352
|
|
|
164
353
|
export interface IndexStats {
|
package/src/parser.test.ts
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect } from "bun:test";
|
|
2
|
-
import { parseTranscript, exchangeText, EXCLUDE_MARKER } from "./parser";
|
|
3
|
-
import type { SourceMessage } from "./reader";
|
|
4
|
-
|
|
5
|
-
const msg = (role: string, timeCreated: number, parts: SourceMessage["parts"]): SourceMessage =>
|
|
6
|
-
({ id: `${role}-${timeCreated}`, role, timeCreated, parts });
|
|
7
|
-
|
|
8
|
-
describe("parseTranscript", () => {
|
|
9
|
-
test("builds exchanges from user/assistant pairs with tool names", () => {
|
|
10
|
-
const { exchanges, excluded } = parseTranscript([
|
|
11
|
-
msg("assistant", 1, [{ type: "text", text: "dropped: no user context" }]),
|
|
12
|
-
msg("user", 2, [{ type: "text", text: "how do I fix the redirect?" }]),
|
|
13
|
-
msg("assistant", 3, [
|
|
14
|
-
{ type: "reasoning", text: "thinking..." },
|
|
15
|
-
{ type: "text", text: "Change the callback URL." },
|
|
16
|
-
{ type: "tool", tool: "edit" },
|
|
17
|
-
{ type: "tool", tool: "bash" },
|
|
18
|
-
]),
|
|
19
|
-
msg("user", 4, [{ type: "text", text: "thanks" }]),
|
|
20
|
-
msg("assistant", 5, [{ type: "text", text: "anytime" }]),
|
|
21
|
-
]);
|
|
22
|
-
expect(excluded).toBe(false);
|
|
23
|
-
expect(exchanges).toHaveLength(2);
|
|
24
|
-
expect(exchanges[0].user).toBe("how do I fix the redirect?");
|
|
25
|
-
expect(exchanges[0].assistant).toBe("Change the callback URL.");
|
|
26
|
-
expect(exchanges[0].tools).toEqual(["edit", "bash"]);
|
|
27
|
-
expect(exchanges[1].tools).toEqual([]);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
test("user turns without text are skipped", () => {
|
|
31
|
-
const { exchanges } = parseTranscript([
|
|
32
|
-
msg("user", 1, [{ type: "tool", tool: "read" }]), // pure tool-result turn
|
|
33
|
-
msg("user", 2, [{ type: "text", text: "real question" }]),
|
|
34
|
-
]);
|
|
35
|
-
expect(exchanges).toHaveLength(1);
|
|
36
|
-
expect(exchanges[0].user).toBe("real question");
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test("exclusion marker anywhere opts out the whole transcript", () => {
|
|
40
|
-
const { exchanges, excluded } = parseTranscript([
|
|
41
|
-
msg("user", 1, [{ type: "text", text: "hi" }]),
|
|
42
|
-
msg("assistant", 2, [{ type: "text", text: `note: ${EXCLUDE_MARKER}` }]),
|
|
43
|
-
]);
|
|
44
|
-
expect(excluded).toBe(true);
|
|
45
|
-
expect(exchanges).toHaveLength(0);
|
|
46
|
-
});
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
describe("exchangeText", () => {
|
|
50
|
-
test("includes date, title, participants, deduped tools", () => {
|
|
51
|
-
const text = exchangeText("My session", "2026-07-22", {
|
|
52
|
-
user: "q", assistant: "a", tools: ["bash", "bash", "edit"], time: 0,
|
|
53
|
-
});
|
|
54
|
-
expect(text).toStartWith("2026-07-22 — My session\nUser: q\nAssistant: a");
|
|
55
|
-
expect(text).toContain("Tools used: bash, edit");
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
test("caps at 4000 chars", () => {
|
|
59
|
-
const text = exchangeText("t", "2026-07-22", {
|
|
60
|
-
user: "x".repeat(10000), assistant: "", tools: [], time: 0,
|
|
61
|
-
});
|
|
62
|
-
expect(text.length).toBe(4000);
|
|
63
|
-
});
|
|
64
|
-
});
|
package/src/reader.test.ts
DELETED
|
@@ -1,205 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect } from "bun:test";
|
|
2
|
-
import { Database } from "bun:sqlite";
|
|
3
|
-
import { listSessions, getSession, getTranscript, transcriptHasMarker, EXCLUDE_MARKER } from "./reader";
|
|
4
|
-
|
|
5
|
-
// A minimal opencode.db mirroring only the columns reader.ts SELECTs. Writable
|
|
6
|
-
// here so we can seed rows; the reader functions take a Database and never write.
|
|
7
|
-
function makeSource(): Database {
|
|
8
|
-
const db = new Database(":memory:");
|
|
9
|
-
db.run(`CREATE TABLE session (
|
|
10
|
-
id TEXT, project_id TEXT, parent_id TEXT, title TEXT, directory TEXT,
|
|
11
|
-
time_created INTEGER, time_updated INTEGER, time_archived INTEGER
|
|
12
|
-
)`);
|
|
13
|
-
db.run(`CREATE TABLE message (
|
|
14
|
-
id TEXT, session_id TEXT, time_created INTEGER, data TEXT
|
|
15
|
-
)`);
|
|
16
|
-
db.run(`CREATE TABLE part (
|
|
17
|
-
id TEXT, message_id TEXT, session_id TEXT, time_created INTEGER, data TEXT
|
|
18
|
-
)`);
|
|
19
|
-
return db;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function addSession(db: Database, s: {
|
|
23
|
-
id: string; parent_id?: string | null; title?: string;
|
|
24
|
-
time_created?: number; time_updated?: number; time_archived?: number | null;
|
|
25
|
-
}): void {
|
|
26
|
-
db.run(
|
|
27
|
-
`INSERT INTO session (id, project_id, parent_id, title, directory, time_created, time_updated, time_archived)
|
|
28
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
29
|
-
[s.id, "proj", s.parent_id ?? null, s.title ?? "Title", "/dir",
|
|
30
|
-
s.time_created ?? 1000, s.time_updated ?? 1000, s.time_archived ?? null]
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
function addMessage(db: Database, id: string, sessionId: string, time: number, data: string): void {
|
|
34
|
-
db.run("INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)",
|
|
35
|
-
[id, sessionId, time, data]);
|
|
36
|
-
}
|
|
37
|
-
function addPart(db: Database, id: string, messageId: string, sessionId: string, time: number, data: string): void {
|
|
38
|
-
db.run("INSERT INTO part (id, message_id, session_id, time_created, data) VALUES (?, ?, ?, ?, ?)",
|
|
39
|
-
[id, messageId, sessionId, time, data]);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
describe("listSessions / getSession (structural rows)", () => {
|
|
43
|
-
test("lists active sessions ordered by time_created, excludes archived", () => {
|
|
44
|
-
const db = makeSource();
|
|
45
|
-
addSession(db, { id: "ses_b", time_created: 2000 });
|
|
46
|
-
addSession(db, { id: "ses_a", time_created: 1000, parent_id: "ses_b" });
|
|
47
|
-
addSession(db, { id: "ses_arch", time_created: 1500, time_archived: 9999 });
|
|
48
|
-
const sessions = listSessions(db);
|
|
49
|
-
expect(sessions.map((s) => s.id)).toEqual(["ses_a", "ses_b"]);
|
|
50
|
-
expect(sessions[0].parent_id).toBe("ses_b");
|
|
51
|
-
expect(sessions[1].parent_id).toBeNull();
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test("getSession returns a row, or null for an unknown id", () => {
|
|
55
|
-
const db = makeSource();
|
|
56
|
-
addSession(db, { id: "ses_a", title: "Hello" });
|
|
57
|
-
expect(getSession(db, "ses_a")?.title).toBe("Hello");
|
|
58
|
-
expect(getSession(db, "nope")).toBeNull();
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
test("throws (does not silently mis-read) when a structural column drifts", () => {
|
|
62
|
-
const db = makeSource();
|
|
63
|
-
// time_created NULL violates z.number() — simulates OpenCode schema drift.
|
|
64
|
-
db.run(
|
|
65
|
-
`INSERT INTO session (id, project_id, parent_id, title, directory, time_created, time_updated, time_archived)
|
|
66
|
-
VALUES ('ses_x', 'p', NULL, 't', '/d', NULL, 1000, NULL)`
|
|
67
|
-
);
|
|
68
|
-
expect(() => listSessions(db)).toThrow();
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test("getSession throws on a drifted session row for an existing id", () => {
|
|
72
|
-
const db = makeSource();
|
|
73
|
-
// title NULL violates z.string() — simulates OpenCode schema drift.
|
|
74
|
-
db.run(
|
|
75
|
-
`INSERT INTO session (id, project_id, parent_id, title, directory, time_created, time_updated, time_archived)
|
|
76
|
-
VALUES ('ses_y', 'p', NULL, NULL, '/d', 1000, 1000, NULL)`
|
|
77
|
-
);
|
|
78
|
-
expect(() => getSession(db, "ses_y")).toThrow();
|
|
79
|
-
});
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
describe("getTranscript (JSON blob degradation)", () => {
|
|
83
|
-
test("parses roles and part fields; degrades malformed blobs per-row", () => {
|
|
84
|
-
const db = makeSource();
|
|
85
|
-
addSession(db, { id: "ses_a" });
|
|
86
|
-
addMessage(db, "m1", "ses_a", 1, `{"role":"user"}`);
|
|
87
|
-
addMessage(db, "m2", "ses_a", 2, `{"role":"assistant"}`);
|
|
88
|
-
addMessage(db, "m3", "ses_a", 3, `{not valid json`); // role -> "unknown"
|
|
89
|
-
addMessage(db, "m4", "ses_a", 4, `{"noRole":true}`); // role -> "unknown"
|
|
90
|
-
|
|
91
|
-
addPart(db, "p1", "m1", "ses_a", 1, `{"type":"text","text":"hello"}`);
|
|
92
|
-
addPart(db, "p2", "m1", "ses_a", 2, `{"type":"tool","tool":"edit"}`);
|
|
93
|
-
addPart(db, "p3", "m2", "ses_a", 3, `{oops not json`); // -> {type:"unknown"}
|
|
94
|
-
addPart(db, "p4", "m2", "ses_a", 4, `{"type":123,"text":"keep"}`); // type->unknown, text kept
|
|
95
|
-
addPart(db, "p5", "m4", "ses_a", 5, `42`); // non-object -> {type:"unknown"}
|
|
96
|
-
|
|
97
|
-
const t = getTranscript(db, "ses_a");
|
|
98
|
-
expect(t.map((m) => m.role)).toEqual(["user", "assistant", "unknown", "unknown"]);
|
|
99
|
-
|
|
100
|
-
expect(t[0].parts).toEqual([
|
|
101
|
-
{ type: "text", text: "hello" },
|
|
102
|
-
{ type: "tool", tool: "edit" },
|
|
103
|
-
]);
|
|
104
|
-
expect(t[1].parts).toEqual([
|
|
105
|
-
{ type: "unknown" },
|
|
106
|
-
{ type: "unknown", text: "keep" },
|
|
107
|
-
]);
|
|
108
|
-
expect(t[3].parts).toEqual([{ type: "unknown" }]);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
test("per-field catch: bad text/tool fields are dropped, type is preserved", () => {
|
|
112
|
-
const db = makeSource();
|
|
113
|
-
addSession(db, { id: "ses_a" });
|
|
114
|
-
addMessage(db, "m1", "ses_a", 1, `{"role":"user"}`);
|
|
115
|
-
addPart(db, "p1", "m1", "ses_a", 1, `{"type":"text","text":123}`); // bad text dropped
|
|
116
|
-
addPart(db, "p2", "m1", "ses_a", 2, `{"type":"tool","tool":123}`); // bad tool dropped
|
|
117
|
-
|
|
118
|
-
const t = getTranscript(db, "ses_a");
|
|
119
|
-
expect(t[0].parts).toEqual([{ type: "text" }, { type: "tool" }]);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
test("throws when a part row's data column is non-string (structural drift)", () => {
|
|
123
|
-
const db = makeSource();
|
|
124
|
-
addSession(db, { id: "ses_a" });
|
|
125
|
-
// Valid message first so the message-row parse passes and the throw comes
|
|
126
|
-
// from the part row below.
|
|
127
|
-
addMessage(db, "m1", "ses_a", 1, `{"role":"user"}`);
|
|
128
|
-
db.run("INSERT INTO part (id, message_id, session_id, time_created, data) VALUES ('p1', 'm1', 'ses_a', 1, NULL)");
|
|
129
|
-
expect(() => getTranscript(db, "ses_a")).toThrow();
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
test("throws when a message row's data column is non-string (structural drift)", () => {
|
|
133
|
-
const db = makeSource();
|
|
134
|
-
addSession(db, { id: "ses_a" });
|
|
135
|
-
// data NULL violates the row schema's z.string(); structural, so it throws.
|
|
136
|
-
db.run("INSERT INTO message (id, session_id, time_created, data) VALUES ('m1', 'ses_a', 1, NULL)");
|
|
137
|
-
expect(() => getTranscript(db, "ses_a")).toThrow();
|
|
138
|
-
});
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
describe("transcriptHasMarker (raw blob scan)", () => {
|
|
142
|
-
test("detects the marker in a well-formed text part", () => {
|
|
143
|
-
const db = makeSource();
|
|
144
|
-
addSession(db, { id: "ses_a" });
|
|
145
|
-
addMessage(db, "m1", "ses_a", 1, `{"role":"user"}`);
|
|
146
|
-
addPart(db, "p1", "m1", "ses_a", 1, `{"type":"text","text":"note: ${EXCLUDE_MARKER}"}`);
|
|
147
|
-
expect(transcriptHasMarker(db, "ses_a")).toBe(true);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
// Regression for issue #10: the parsed-text scan degrades this blob to
|
|
151
|
-
// text: undefined, so the marker is invisible to hasExcludeMarker — but the
|
|
152
|
-
// raw scan must still see it. The privacy kill-switch must not depend on
|
|
153
|
-
// blob parseability.
|
|
154
|
-
test("detects the marker inside a malformed/unparseable part blob", () => {
|
|
155
|
-
const db = makeSource();
|
|
156
|
-
addSession(db, { id: "ses_a" });
|
|
157
|
-
addMessage(db, "m1", "ses_a", 1, `{"role":"user"}`);
|
|
158
|
-
addPart(db, "p1", "m1", "ses_a", 1, `{oops not json ${EXCLUDE_MARKER}`);
|
|
159
|
-
|
|
160
|
-
// Sanity: the parsed view really does lose the marker text.
|
|
161
|
-
const t = getTranscript(db, "ses_a");
|
|
162
|
-
expect(t[0].parts).toEqual([{ type: "unknown" }]);
|
|
163
|
-
|
|
164
|
-
expect(transcriptHasMarker(db, "ses_a")).toBe(true);
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
test("detects the marker in a blob whose fields all fail validation", () => {
|
|
168
|
-
const db = makeSource();
|
|
169
|
-
addSession(db, { id: "ses_a" });
|
|
170
|
-
addMessage(db, "m1", "ses_a", 1, `{"role":"user"}`);
|
|
171
|
-
// Valid JSON, but type is non-string → degrades to {type:"unknown"}.
|
|
172
|
-
addPart(db, "p1", "m1", "ses_a", 1, `{"type":123,"note":"${EXCLUDE_MARKER}"}`);
|
|
173
|
-
|
|
174
|
-
const t = getTranscript(db, "ses_a");
|
|
175
|
-
expect(t[0].parts).toEqual([{ type: "unknown" }]);
|
|
176
|
-
expect(transcriptHasMarker(db, "ses_a")).toBe(true);
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
test("returns false when no part contains the marker", () => {
|
|
180
|
-
const db = makeSource();
|
|
181
|
-
addSession(db, { id: "ses_a" });
|
|
182
|
-
addMessage(db, "m1", "ses_a", 1, `{"role":"user"}`);
|
|
183
|
-
addPart(db, "p1", "m1", "ses_a", 1, `{"type":"text","text":"hello"}`);
|
|
184
|
-
expect(transcriptHasMarker(db, "ses_a")).toBe(false);
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
test("is scoped to the requested session", () => {
|
|
188
|
-
const db = makeSource();
|
|
189
|
-
addSession(db, { id: "ses_a" });
|
|
190
|
-
addSession(db, { id: "ses_b" });
|
|
191
|
-
addMessage(db, "m1", "ses_b", 1, `{"role":"user"}`);
|
|
192
|
-
addPart(db, "p1", "m1", "ses_b", 1, `{"type":"text","text":"${EXCLUDE_MARKER}"}`);
|
|
193
|
-
expect(transcriptHasMarker(db, "ses_a")).toBe(false);
|
|
194
|
-
expect(transcriptHasMarker(db, "ses_b")).toBe(true);
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
test("does not match case variants or partial markers (exact substring)", () => {
|
|
198
|
-
const db = makeSource();
|
|
199
|
-
addSession(db, { id: "ses_a" });
|
|
200
|
-
addMessage(db, "m1", "ses_a", 1, `{"role":"user"}`);
|
|
201
|
-
addPart(db, "p1", "m1", "ses_a", 1, `{"type":"text","text":"do not index this chat"}`);
|
|
202
|
-
addPart(db, "p2", "m1", "ses_a", 2, `{"type":"text","text":"DO NOT INDEX THIS"}`);
|
|
203
|
-
expect(transcriptHasMarker(db, "ses_a")).toBe(false);
|
|
204
|
-
});
|
|
205
|
-
});
|