opencode-episodic-memory 0.1.2 → 0.2.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/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 vector ranking)
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
- export function search(db: Database, queryVec: Float32Array, opts: SearchOptions = {}): SearchHit[] {
110
- const limit = opts.limit ?? 10;
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
- const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
184
+ return { clauses, params };
185
+ }
117
186
 
118
- const rows = db
119
- .prepare<{
120
- session_id: string; seq: number; time_created: number; text: string;
121
- embedding: Uint8Array; title: string; directory: string;
122
- }, (string | number)[]>(
123
- `SELECT c.session_id, c.seq, c.time_created, c.text, c.embedding, s.title, s.directory
124
- FROM chunks c JOIN sessions s ON s.id = c.session_id ${where}`
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 rows
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
- export function textSearch(db: Database, query: string, opts: SearchOptions = {}): SearchHit[] {
149
- const limit = opts.limit ?? 10;
150
- const clauses = ["c.text LIKE ? ESCAPE '\\'"];
151
- const params: (string | number)[] = [`%${escapeLike(query)}%`];
152
- if (opts.after) { clauses.push("c.time_created >= ?"); params.push(opts.after); }
153
- if (opts.before) { clauses.push("c.time_created < ?"); params.push(opts.before); }
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<Omit<SearchHit, "score">, (string | number)[]>(
156
- `SELECT c.session_id, c.seq, c.time_created, c.text, s.title, s.directory
157
- FROM chunks c JOIN sessions s ON s.id = c.session_id
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, limit);
161
- return rows.map((r) => ({ ...r, score: 1 }));
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 {
@@ -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
- });
@@ -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
- });
package/src/store.test.ts DELETED
@@ -1,72 +0,0 @@
1
- import { describe, test, expect, afterAll } from "bun:test";
2
- import { mkdtempSync, rmSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import { openIndex, replaceSessionChunks, search, textSearch, getIndexedSession } from "./store";
6
-
7
- const dir = mkdtempSync(join(tmpdir(), "episodic-store-test-"));
8
- const db = openIndex(join(dir, "index.db"));
9
- afterAll(() => rmSync(dir, { recursive: true, force: true }));
10
-
11
- const meta = {
12
- id: "ses_test", project_id: "p", parent_id: null,
13
- title: "Test session", directory: "/tmp",
14
- time_created: 1000, source_time_updated: 1000,
15
- };
16
-
17
- describe("store", () => {
18
- test("replaceSessionChunks + search round-trip ranks by cosine", () => {
19
- replaceSessionChunks(db, meta, [
20
- { seq: 0, time_created: 1000, text: "alpha chunk", embedding: new Float32Array([1, 0]) },
21
- { seq: 1, time_created: 1001, text: "beta chunk", embedding: new Float32Array([0, 1]) },
22
- ]);
23
- const hits = search(db, new Float32Array([1, 0]));
24
- expect(hits).toHaveLength(2);
25
- expect(hits[0].text).toBe("alpha chunk");
26
- expect(hits[0].score).toBeCloseTo(1);
27
- expect(hits[1].score).toBeCloseTo(0);
28
- expect(getIndexedSession(db, "ses_test")?.title).toBe("Test session");
29
- });
30
-
31
- test("search skips embeddings with mismatched dims instead of crashing", () => {
32
- // 4-byte blob while the query is 2 dims (8 bytes) — must be skipped.
33
- db.run("INSERT INTO chunks (session_id, seq, time_created, text, embedding) VALUES (?, ?, ?, ?, ?)",
34
- ["ses_test", 99, 1002, "stale wrong-dims chunk", new Float32Array([0.5])]);
35
- const hits = search(db, new Float32Array([1, 0]));
36
- expect(hits.map((h) => h.text)).not.toContain("stale wrong-dims chunk");
37
- expect(hits).toHaveLength(2);
38
- });
39
-
40
- test("re-embedding a session replaces its chunks", () => {
41
- replaceSessionChunks(db, meta, [
42
- { seq: 0, time_created: 1000, text: "only chunk now", embedding: new Float32Array([1, 0]) },
43
- ]);
44
- const hits = search(db, new Float32Array([1, 0]));
45
- expect(hits.map((h) => h.text)).toEqual(["only chunk now"]);
46
- });
47
-
48
- test("textSearch does exact substring matching", () => {
49
- expect(textSearch(db, "only chunk")).toHaveLength(1);
50
- expect(textSearch(db, "no such phrase")).toHaveLength(0);
51
- });
52
-
53
- test("LIKE wildcards in user input are escaped (treated literally)", () => {
54
- replaceSessionChunks(db, meta, [
55
- { seq: 0, time_created: 1000, text: "progress at 50% done", embedding: new Float32Array([1, 0]) },
56
- { seq: 1, time_created: 1001, text: "snake_case name here", embedding: new Float32Array([0, 1]) },
57
- { seq: 2, time_created: 1002, text: "path \\tmp", embedding: new Float32Array([1, 0]) },
58
- ]);
59
- // % must match literally, not as a wildcard
60
- expect(textSearch(db, "50%").map((h) => h.text)).toEqual(["progress at 50% done"]);
61
- // _ must match literally, not as a single-char wildcard
62
- expect(textSearch(db, "snake_case").map((h) => h.text)).toEqual(["snake_case name here"]);
63
- // a bare % should NOT match everything (would if unescaped)
64
- expect(textSearch(db, "%")).toHaveLength(1);
65
- expect(textSearch(db, "_")).toHaveLength(1);
66
- // a literal backslash must match only the row containing one — escapeLike
67
- // escapes the escape char itself, so this would break if that were missed
68
- expect(textSearch(db, "\\").map((h) => h.text)).toEqual(["path \\tmp"]);
69
- // search() text filter should also escape
70
- expect(search(db, new Float32Array([1, 0]), { text: "50%" })).toHaveLength(1);
71
- });
72
- });