opencode-episodic-memory 0.1.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/store.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  // extension, so the sqlite-vec limitation doesn't apply). search() fuses the
11
11
  // vector and BM25 rankings via reciprocal rank fusion.
12
12
  import { Database } from "bun:sqlite";
13
+ import type { Client, InStatement, Transaction } from "@libsql/client";
13
14
  import { mkdirSync } from "node:fs";
14
15
  import { homedir } from "node:os";
15
16
  import { dirname, join } from "node:path";
@@ -22,6 +23,7 @@ const FTS_SCHEMA_VERSION = 1;
22
23
  // ranked list fusion looks — contributions past this depth are negligible.
23
24
  const RRF_K = 60;
24
25
  const FUSE_DEPTH = 200;
26
+ const REMOTE_PAGE_SIZE = 250;
25
27
 
26
28
  export function indexDbPath(): string {
27
29
  return process.env.EPISODIC_INDEX_DB ?? DEFAULT_INDEX_DB;
@@ -31,6 +33,7 @@ export function openIndex(path: string = indexDbPath()): Database {
31
33
  mkdirSync(dirname(path), { recursive: true });
32
34
  const db = new Database(path);
33
35
  db.run("PRAGMA journal_mode = WAL");
36
+ db.run("PRAGMA busy_timeout = 5000");
34
37
  db.run(`CREATE TABLE IF NOT EXISTS sessions (
35
38
  id TEXT PRIMARY KEY,
36
39
  project_id TEXT NOT NULL,
@@ -46,6 +49,7 @@ export function openIndex(path: string = indexDbPath()): Database {
46
49
  session_id TEXT NOT NULL,
47
50
  seq INTEGER NOT NULL,
48
51
  time_created INTEGER NOT NULL,
52
+ anchor_message_id TEXT,
49
53
  text TEXT NOT NULL,
50
54
  embedding BLOB NOT NULL,
51
55
  PRIMARY KEY (session_id, seq)
@@ -74,10 +78,45 @@ export function openIndex(path: string = indexDbPath()): Database {
74
78
  INSERT INTO chunks_fts(chunks_fts, rowid, text) VALUES('delete', old.rowid, old.text);
75
79
  INSERT INTO chunks_fts(rowid, text) VALUES (new.rowid, new.text);
76
80
  END`);
81
+ migrateAnchors(db);
77
82
  migrateFts(db);
78
83
  return db;
79
84
  }
80
85
 
86
+ // Existing indexes predate per-exchange source anchors. Adding this nullable
87
+ // column preserves the chunks table's implicit rowids, which the external-
88
+ // content FTS table depends on. An immediate transaction serializes concurrent
89
+ // openIndex calls before the schema check; the busy timeout above lets a second
90
+ // opener wait for the first migration rather than racing a duplicate ALTER.
91
+ // Mark every existing session stale so its next normal sync replaces chunks with
92
+ // anchored versions; do not rebuild or VACUUM.
93
+ function migrateAnchors(db: Database): void {
94
+ // The steady-state path stays read-like: don't take a write lock just to
95
+ // confirm an already-migrated index has its anchor column.
96
+ if (hasAnchorColumn(db)) return;
97
+ let inTransaction = false;
98
+ try {
99
+ db.run("BEGIN IMMEDIATE");
100
+ inTransaction = true;
101
+ // A concurrent first opener may have completed migration while this caller
102
+ // waited for the write lock, so re-check inside the atomic transaction.
103
+ if (!hasAnchorColumn(db)) {
104
+ db.run("ALTER TABLE chunks ADD COLUMN anchor_message_id TEXT");
105
+ db.run("UPDATE sessions SET source_time_updated = -1");
106
+ }
107
+ db.run("COMMIT");
108
+ inTransaction = false;
109
+ } catch (error) {
110
+ if (inTransaction) db.run("ROLLBACK");
111
+ throw error;
112
+ }
113
+ }
114
+
115
+ function hasAnchorColumn(db: Database): boolean {
116
+ return db.prepare<{ name: string }, []>("PRAGMA table_info(chunks)").all()
117
+ .some((column) => column.name === "anchor_message_id");
118
+ }
119
+
81
120
  // One-time FTS backfill for index DBs created before FTS existed: they have
82
121
  // chunks but an empty FTS index. COUNT(*) on an external-content FTS returns the
83
122
  // content-row count (can't reveal "not indexed"), so gate on PRAGMA user_version
@@ -109,7 +148,7 @@ export function getIndexedSession(db: Database, id: string): IndexedSession | nu
109
148
  export function replaceSessionChunks(
110
149
  db: Database,
111
150
  s: { id: string; project_id: string; parent_id: string | null; title: string; directory: string; time_created: number; source_time_updated: number },
112
- chunks: { seq: number; time_created: number; text: string; embedding: Float32Array }[],
151
+ chunks: { seq: number; time_created: number; text: string; embedding: Float32Array; anchor_message_id?: string | null }[],
113
152
  status: string = "indexed"
114
153
  ): void {
115
154
  db.transaction(() => {
@@ -124,16 +163,18 @@ export function replaceSessionChunks(
124
163
  );
125
164
  db.run("DELETE FROM chunks WHERE session_id = ?", [s.id]);
126
165
  const ins = db.prepare(
127
- "INSERT INTO chunks (session_id, seq, time_created, text, embedding) VALUES (?, ?, ?, ?, ?)"
166
+ "INSERT INTO chunks (session_id, seq, time_created, anchor_message_id, text, embedding) VALUES (?, ?, ?, ?, ?, ?)"
128
167
  );
129
- for (const c of chunks) ins.run(s.id, c.seq, c.time_created, c.text, c.embedding);
168
+ for (const c of chunks) ins.run(s.id, c.seq, c.time_created, c.anchor_message_id ?? null, c.text, c.embedding);
130
169
  })();
131
170
  }
132
171
 
133
172
  export interface SearchHit {
173
+ source_id?: string;
134
174
  session_id: string;
135
175
  seq: number;
136
176
  time_created: number;
177
+ anchor_message_id?: string | null;
137
178
  text: string;
138
179
  score: number;
139
180
  title: string;
@@ -164,6 +205,7 @@ export interface SearchOptions {
164
205
 
165
206
  // A scored candidate before display fields are fetched (phase 1 output).
166
207
  interface ScoredChunk {
208
+ source_id?: string;
167
209
  session_id: string;
168
210
  seq: number;
169
211
  time_created: number;
@@ -217,8 +259,8 @@ function scoreVector(db: Database, queryVec: Float32Array, opts: SearchOptions):
217
259
  // a point lookup per hit on the (session_id, seq) primary key. K is bounded by
218
260
  // the caller's limit (≤ 50 in the plugin), so this is a tiny handful of reads.
219
261
  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
262
+ const detail = db.prepare<{ text: string; anchor_message_id: string | null; title: string; directory: string }, [string, number]>(
263
+ `SELECT c.text, c.anchor_message_id, s.title, s.directory
222
264
  FROM chunks c JOIN sessions s ON s.id = c.session_id
223
265
  WHERE c.session_id = ? AND c.seq = ?`
224
266
  );
@@ -231,7 +273,7 @@ function hydrate(db: Database, scored: ScoredChunk[]): SearchHit[] {
231
273
  if (!d) continue;
232
274
  hits.push({
233
275
  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,
276
+ text: d.text, anchor_message_id: d.anchor_message_id, score: h.score, title: d.title, directory: d.directory,
235
277
  });
236
278
  }
237
279
  return hits;
@@ -380,3 +422,352 @@ export function stats(db: Database): IndexStats {
380
422
  .all(),
381
423
  };
382
424
  }
425
+
426
+ // The production boundary is intentionally small: local callers retain the
427
+ // synchronous helpers above, while configured indexes use this async shape so
428
+ // libSQL's network operations cannot be accidentally treated as local I/O.
429
+ export interface IndexStore {
430
+ readonly remote: boolean;
431
+ readonly sourceId?: string;
432
+ getIndexedSession(id: string): Promise<IndexedSession | null>;
433
+ replaceSessionChunks(
434
+ session: { id: string; project_id: string; parent_id: string | null; title: string; directory: string; time_created: number; source_time_updated: number },
435
+ chunks: { seq: number; time_created: number; text: string; embedding: Float32Array; anchor_message_id?: string | null }[],
436
+ status?: string,
437
+ ): Promise<void>;
438
+ removeSession(id: string): Promise<void>;
439
+ pruneOrphans(sourceIds: string[]): Promise<number>;
440
+ search(query: Float32Array, opts?: SearchOptions): Promise<SearchHit[]>;
441
+ textSearch(query: string, opts?: SearchOptions): Promise<SearchHit[]>;
442
+ isEmpty(): Promise<boolean>;
443
+ stats(): Promise<IndexStats>;
444
+ readIndexed(sessionId: string, sourceId?: string): Promise<{ text: string }[]>;
445
+ close(): void;
446
+ }
447
+
448
+ class LocalIndexStore implements IndexStore {
449
+ readonly remote = false;
450
+ constructor(private readonly db: Database) {}
451
+ async getIndexedSession(id: string) { return getIndexedSession(this.db, id); }
452
+ async replaceSessionChunks(...args: Parameters<IndexStore["replaceSessionChunks"]>) {
453
+ replaceSessionChunks(this.db, ...args);
454
+ }
455
+ async removeSession(id: string) {
456
+ this.db.transaction(() => {
457
+ this.db.run("DELETE FROM chunks WHERE session_id = ?", [id]);
458
+ this.db.run("DELETE FROM sessions WHERE id = ?", [id]);
459
+ })();
460
+ }
461
+ async pruneOrphans(sourceIds: string[]) {
462
+ const ids = new Set(sourceIds);
463
+ const rows = this.db.prepare<{ id: string }, []>("SELECT id FROM sessions").all();
464
+ let pruned = 0;
465
+ this.db.transaction(() => {
466
+ for (const { id } of rows) {
467
+ if (ids.has(id)) continue;
468
+ this.db.run("DELETE FROM chunks WHERE session_id = ?", [id]);
469
+ this.db.run("DELETE FROM sessions WHERE id = ?", [id]);
470
+ pruned++;
471
+ }
472
+ })();
473
+ return pruned;
474
+ }
475
+ async search(query: Float32Array, opts: SearchOptions = {}) { return search(this.db, query, opts); }
476
+ async textSearch(query: string, opts: SearchOptions = {}) { return textSearch(this.db, query, opts); }
477
+ async isEmpty() { return isIndexEmpty(this.db); }
478
+ async stats() { return stats(this.db); }
479
+ async readIndexed(sessionId: string) {
480
+ return this.db.prepare<{ text: string }, [string]>("SELECT text FROM chunks WHERE session_id = ? ORDER BY seq").all(sessionId);
481
+ }
482
+ close() { this.db.close(); }
483
+ }
484
+
485
+ export function localIndexStore(db: Database): IndexStore {
486
+ return new LocalIndexStore(db);
487
+ }
488
+
489
+ export interface RemoteIndexConfig { url: string; sourceId: string; authToken?: string; }
490
+
491
+ export function canLiveRead(config: RemoteIndexConfig | null, sourceId: string | undefined): boolean {
492
+ return config === null || sourceId === config.sourceId;
493
+ }
494
+
495
+ export function remoteIndexConfig(env: NodeJS.ProcessEnv = process.env): RemoteIndexConfig | null {
496
+ const url = env.EPISODIC_INDEX_URL;
497
+ if (!url) return null;
498
+ if (url.includes("\\")) throw new Error("EPISODIC_INDEX_URL must not contain backslashes.");
499
+ const authority = /^\w+:\/\/([^/?#]*)/.exec(url)?.[1];
500
+ if (authority?.includes("%")) throw new Error("EPISODIC_INDEX_URL must not percent-encode its authority.");
501
+ let parsed: URL;
502
+ try { parsed = new URL(url); } catch { throw new Error("EPISODIC_INDEX_URL must be a valid URL."); }
503
+ if (parsed.protocol === "file:" && (!url.slice("file:".length).startsWith("/") || parsed.host)) {
504
+ throw new Error("EPISODIC_INDEX_URL file URLs must use an absolute local path.");
505
+ }
506
+ if (parsed.username || parsed.password) throw new Error("EPISODIC_INDEX_URL must not contain credentials; use EPISODIC_INDEX_AUTH_TOKEN.");
507
+ if ([...parsed.searchParams.keys()].some((key) => key.toLowerCase() === "authtoken")) {
508
+ throw new Error("EPISODIC_INDEX_URL must not contain credentials; use EPISODIC_INDEX_AUTH_TOKEN.");
509
+ }
510
+ if (parsed.protocol === "http:" || parsed.protocol === "ws:") throw new Error("EPISODIC_INDEX_URL requires secure transport.");
511
+ if (!["file:", "https:", "wss:", "libsql:"].includes(parsed.protocol)) throw new Error("EPISODIC_INDEX_URL must use file:, https:, wss:, or libsql:.");
512
+ if (parsed.protocol !== "file:" && !/^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(parsed.hostname)) {
513
+ throw new Error("EPISODIC_INDEX_URL must use a canonical DNS hostname.");
514
+ }
515
+ if (parsed.protocol === "libsql:") {
516
+ const queryStart = url.indexOf("?");
517
+ const fragmentStart = url.indexOf("#", queryStart < 0 ? 0 : queryStart);
518
+ const rawQuery = queryStart < 0 ? "" : url.slice(queryStart + 1, fragmentStart < 0 ? undefined : fragmentStart);
519
+ if (parsed.hash || (rawQuery !== "" && rawQuery !== "tls=1")) {
520
+ throw new Error("EPISODIC_INDEX_URL supports only the exact libsql query parameter tls=1.");
521
+ }
522
+ }
523
+ const sourceId = env.EPISODIC_SOURCE_ID;
524
+ if (!sourceId) throw new Error("EPISODIC_SOURCE_ID is required when EPISODIC_INDEX_URL is configured.");
525
+ const authToken = env.EPISODIC_INDEX_AUTH_TOKEN;
526
+ if (parsed.protocol !== "file:" && !authToken) throw new Error("EPISODIC_INDEX_AUTH_TOKEN is required for a remote EPISODIC_INDEX_URL.");
527
+ return { url: parsed.href, sourceId, authToken };
528
+ }
529
+
530
+ function rowString(row: Record<string, unknown>, key: string): string {
531
+ const value = row[key];
532
+ if (typeof value !== "string") throw new Error(`Remote index returned invalid ${key}.`);
533
+ return value;
534
+ }
535
+
536
+ function rowNumber(row: Record<string, unknown>, key: string): number {
537
+ const value = row[key];
538
+ if (typeof value !== "number") throw new Error(`Remote index returned invalid ${key}.`);
539
+ return value;
540
+ }
541
+
542
+ function rowNullableString(row: Record<string, unknown>, key: string): string | null {
543
+ const value = row[key];
544
+ if (value === null) return null;
545
+ if (typeof value !== "string") throw new Error(`Remote index returned invalid ${key}.`);
546
+ return value;
547
+ }
548
+
549
+ function rowBlob(row: Record<string, unknown>, key: string): Uint8Array {
550
+ const value = row[key];
551
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
552
+ if (value instanceof Uint8Array) return value;
553
+ throw new Error(`Remote index returned invalid ${key}.`);
554
+ }
555
+
556
+ class RemoteIndexStore implements IndexStore {
557
+ readonly remote = true;
558
+ constructor(readonly sourceId: string, private readonly client: Client) {}
559
+
560
+ static async open(url: string, sourceId: string, authToken?: string): Promise<RemoteIndexStore> {
561
+ const { createClient } = await import("@libsql/client");
562
+ const client = createClient({ url, authToken });
563
+ const store = new RemoteIndexStore(sourceId, client);
564
+ try {
565
+ for (let attempt = 0; ; attempt++) {
566
+ try {
567
+ await store.initialize();
568
+ return store;
569
+ } catch (error) {
570
+ if (!(error instanceof Error) || !error.message.includes("SQLITE_BUSY") || attempt === 9) throw error;
571
+ await new Promise((resolve) => setTimeout(resolve, 10 * (attempt + 1)));
572
+ }
573
+ }
574
+ } catch (error) {
575
+ client.close();
576
+ throw error;
577
+ }
578
+ }
579
+
580
+ private async tableExists(db: Pick<Transaction, "execute">, name: string): Promise<boolean> {
581
+ const result = await db.execute({ sql: "SELECT name FROM sqlite_master WHERE type = 'table' AND lower(name) = lower(?)", args: [name] });
582
+ return result.rows.length > 0;
583
+ }
584
+
585
+ private async validateTable(db: Pick<Transaction, "execute">, name: string, requiredColumns: string[], primaryKey: string[]): Promise<void> {
586
+ const result = await db.execute(`PRAGMA table_info(${name})`);
587
+ const columns = result.rows.map((row) => ({ name: rowString(row, "name").toLowerCase(), pk: rowNumber(row, "pk") }));
588
+ const actual = new Set(columns.map((column) => column.name));
589
+ if (requiredColumns.some((column) => !actual.has(column.toLowerCase()))) {
590
+ throw new Error(`Incompatible remote index schema: ${name} is missing required columns.`);
591
+ }
592
+ const actualPrimaryKey = columns.filter((column) => column.pk > 0).sort((a, b) => a.pk - b.pk).map((column) => column.name);
593
+ if (actualPrimaryKey.join("\0") !== primaryKey.map((column) => column.toLowerCase()).join("\0")) {
594
+ throw new Error(`Incompatible remote index schema: ${name} must use primary key (${primaryKey.join(", ")}).`);
595
+ }
596
+ }
597
+
598
+ private async initialize(): Promise<void> {
599
+ const transaction = await this.client.transaction("write");
600
+ try {
601
+ const versionsExist = await this.tableExists(transaction, "episodic_schema_versions");
602
+ const initialVersion = versionsExist
603
+ ? (await transaction.execute({ sql: "SELECT version FROM episodic_schema_versions WHERE name = ?", args: ["remote-index"] })).rows[0]
604
+ : undefined;
605
+ if (initialVersion) {
606
+ const version = rowNumber(initialVersion, "version");
607
+ if (version > 1) throw new Error(`Unsupported future remote index schema version: ${version}.`);
608
+ if (version !== 1) throw new Error(`Unsupported remote index schema version: ${version}.`);
609
+ }
610
+ const sessionsExist = await this.tableExists(transaction, "episodic_sessions");
611
+ const chunksExist = await this.tableExists(transaction, "episodic_chunks");
612
+ if (sessionsExist !== chunksExist) throw new Error("Incompatible remote index schema: episodic_sessions and episodic_chunks must both exist.");
613
+ if (sessionsExist) {
614
+ await this.validateTable(transaction, "episodic_sessions", ["source_id", "session_id", "project_id", "parent_id", "title", "directory", "time_created", "source_time_updated", "indexed_at", "status"], ["source_id", "session_id"]);
615
+ await this.validateTable(transaction, "episodic_chunks", ["source_id", "session_id", "seq", "time_created", "anchor_message_id", "text", "embedding"], ["source_id", "session_id", "seq"]);
616
+ } else if (initialVersion) {
617
+ throw new Error("Incompatible remote index schema: version 1 is missing required tables.");
618
+ }
619
+
620
+ if (!sessionsExist) {
621
+ await transaction.batch([
622
+ { sql: "CREATE TABLE IF NOT EXISTS episodic_schema_versions (name TEXT PRIMARY KEY, version INTEGER NOT NULL)" },
623
+ { sql: `CREATE TABLE IF NOT EXISTS episodic_sessions (
624
+ source_id TEXT NOT NULL, session_id TEXT NOT NULL, project_id TEXT NOT NULL, parent_id TEXT,
625
+ title TEXT NOT NULL, directory TEXT NOT NULL, time_created INTEGER NOT NULL,
626
+ source_time_updated INTEGER NOT NULL, indexed_at INTEGER NOT NULL, status TEXT NOT NULL,
627
+ PRIMARY KEY (source_id, session_id))` },
628
+ { sql: `CREATE TABLE IF NOT EXISTS episodic_chunks (
629
+ source_id TEXT NOT NULL, session_id TEXT NOT NULL, seq INTEGER NOT NULL, time_created INTEGER NOT NULL,
630
+ anchor_message_id TEXT, text TEXT NOT NULL, embedding BLOB NOT NULL,
631
+ PRIMARY KEY (source_id, session_id, seq))` },
632
+ { sql: "CREATE INDEX IF NOT EXISTS episodic_chunks_time_idx ON episodic_chunks(time_created)" },
633
+ { sql: "INSERT INTO episodic_schema_versions(name, version) VALUES ('remote-index', 1) ON CONFLICT(name) DO NOTHING" },
634
+ ]);
635
+ } else {
636
+ // Existing tables were validated before this first write, so malformed
637
+ // unversioned databases remain completely untouched.
638
+ await transaction.batch([
639
+ { sql: "CREATE TABLE IF NOT EXISTS episodic_schema_versions (name TEXT PRIMARY KEY, version INTEGER NOT NULL)" },
640
+ { sql: "CREATE INDEX IF NOT EXISTS episodic_chunks_time_idx ON episodic_chunks(time_created)" },
641
+ { sql: "INSERT INTO episodic_schema_versions(name, version) VALUES ('remote-index', 1) ON CONFLICT(name) DO NOTHING" },
642
+ ]);
643
+ }
644
+ const versionResult = await transaction.execute({ sql: "SELECT version FROM episodic_schema_versions WHERE name = ?", args: ["remote-index"] });
645
+ const versionRow = versionResult.rows[0];
646
+ if (versionRow) {
647
+ const version = rowNumber(versionRow, "version");
648
+ if (version > 1) throw new Error(`Unsupported future remote index schema version: ${version}.`);
649
+ if (version !== 1) throw new Error(`Unsupported remote index schema version: ${version}.`);
650
+ } else throw new Error("Remote index schema version was not recorded.");
651
+ await transaction.commit();
652
+ } finally {
653
+ transaction.close();
654
+ }
655
+ }
656
+
657
+ async getIndexedSession(id: string): Promise<IndexedSession | null> {
658
+ const result = await this.client.execute({ sql: "SELECT session_id AS id, project_id, parent_id, title, directory, time_created, source_time_updated, indexed_at, status FROM episodic_sessions WHERE source_id = ? AND session_id = ?", args: [this.sourceId, id] });
659
+ const row = result.rows[0];
660
+ if (!row) return null;
661
+ return {
662
+ id: rowString(row, "id"), project_id: rowString(row, "project_id"), parent_id: rowNullableString(row, "parent_id"),
663
+ title: rowString(row, "title"), directory: rowString(row, "directory"), time_created: rowNumber(row, "time_created"),
664
+ source_time_updated: rowNumber(row, "source_time_updated"), indexed_at: rowNumber(row, "indexed_at"), status: rowString(row, "status"),
665
+ };
666
+ }
667
+
668
+ async replaceSessionChunks(session: { id: string; project_id: string; parent_id: string | null; title: string; directory: string; time_created: number; source_time_updated: number }, chunks: { seq: number; time_created: number; text: string; embedding: Float32Array; anchor_message_id?: string | null }[], status: string = "indexed"): Promise<void> {
669
+ const statements: InStatement[] = [{
670
+ sql: `INSERT INTO episodic_sessions (source_id, session_id, project_id, parent_id, title, directory, time_created, source_time_updated, indexed_at, status)
671
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
672
+ ON CONFLICT(source_id, session_id) DO UPDATE SET project_id=excluded.project_id, parent_id=excluded.parent_id, title=excluded.title, directory=excluded.directory, time_created=excluded.time_created, source_time_updated=excluded.source_time_updated, indexed_at=excluded.indexed_at, status=excluded.status`,
673
+ args: [this.sourceId, session.id, session.project_id, session.parent_id, session.title, session.directory, session.time_created, session.source_time_updated, Date.now(), status],
674
+ }, { sql: "DELETE FROM episodic_chunks WHERE source_id = ? AND session_id = ?", args: [this.sourceId, session.id] }];
675
+ for (const chunk of chunks) {
676
+ statements.push({ sql: "INSERT INTO episodic_chunks (source_id, session_id, seq, time_created, anchor_message_id, text, embedding) VALUES (?, ?, ?, ?, ?, ?, ?)", args: [this.sourceId, session.id, chunk.seq, chunk.time_created, chunk.anchor_message_id ?? null, chunk.text, new Uint8Array(chunk.embedding.buffer, chunk.embedding.byteOffset, chunk.embedding.byteLength)] });
677
+ }
678
+ await this.client.batch(statements, "write");
679
+ }
680
+
681
+ async removeSession(id: string): Promise<void> {
682
+ await this.client.batch([
683
+ { sql: "DELETE FROM episodic_chunks WHERE source_id = ? AND session_id = ?", args: [this.sourceId, id] },
684
+ { sql: "DELETE FROM episodic_sessions WHERE source_id = ? AND session_id = ?", args: [this.sourceId, id] },
685
+ ], "write");
686
+ }
687
+
688
+ async pruneOrphans(sourceIds: string[]): Promise<number> {
689
+ const existing = await this.client.execute({ sql: "SELECT session_id FROM episodic_sessions WHERE source_id = ?", args: [this.sourceId] });
690
+ const sourceSet = new Set(sourceIds);
691
+ const stale = existing.rows.map((row) => rowString(row, "session_id")).filter((id) => !sourceSet.has(id));
692
+ if (stale.length === 0) return 0;
693
+ const statements = stale.flatMap((id) => [
694
+ { sql: "DELETE FROM episodic_chunks WHERE source_id = ? AND session_id = ?", args: [this.sourceId, id] },
695
+ { sql: "DELETE FROM episodic_sessions WHERE source_id = ? AND session_id = ?", args: [this.sourceId, id] },
696
+ ]);
697
+ await this.client.batch(statements, "write");
698
+ return stale.length;
699
+ }
700
+
701
+ async search(query: Float32Array, opts: SearchOptions = {}): Promise<SearchHit[]> {
702
+ if (opts.hybrid) throw new Error("Hybrid search is unavailable with EPISODIC_INDEX_URL; remote indexes support vector search only.");
703
+ if (opts.text) throw new Error("Text filtering is unavailable with EPISODIC_INDEX_URL; remote indexes support vector search only.");
704
+ const clauses: string[] = [];
705
+ const args: (string | number)[] = [];
706
+ if (opts.after !== undefined) { clauses.push("c.time_created >= ?"); args.push(opts.after); }
707
+ if (opts.before !== undefined) { clauses.push("c.time_created < ?"); args.push(opts.before); }
708
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
709
+ const dims = query.length;
710
+ const scored: ScoredChunk[] = [];
711
+ const transaction = await this.client.transaction("read");
712
+ try {
713
+ for (let offset = 0; ; offset += REMOTE_PAGE_SIZE) {
714
+ const result = await transaction.execute({ sql: `SELECT c.source_id, c.session_id, c.seq, c.time_created, c.embedding FROM episodic_chunks c ${where} ORDER BY c.source_id, c.session_id, c.seq LIMIT ? OFFSET ?`, args: [...args, REMOTE_PAGE_SIZE, offset] });
715
+ for (const row of result.rows) {
716
+ const blob = rowBlob(row, "embedding");
717
+ if (blob.byteLength !== dims * 4) continue;
718
+ const vector = new Float32Array(blob.buffer, blob.byteOffset, dims);
719
+ let score = 0;
720
+ for (let i = 0; i < dims; i++) score += query[i] * vector[i];
721
+ if (score < (opts.minScore ?? 0)) continue;
722
+ scored.push({ source_id: rowString(row, "source_id"), session_id: rowString(row, "session_id"), seq: rowNumber(row, "seq"), time_created: rowNumber(row, "time_created"), score });
723
+ }
724
+ if (result.rows.length < REMOTE_PAGE_SIZE) break;
725
+ }
726
+ const winners = scored.sort((a, b) => b.score - a.score).slice(0, opts.limit ?? 10);
727
+ if (winners.length === 0) return [];
728
+ const details = await transaction.batch(winners.map((winner) => ({
729
+ sql: `SELECT c.text, c.anchor_message_id, s.title, s.directory FROM episodic_chunks c
730
+ JOIN episodic_sessions s ON s.source_id = c.source_id AND s.session_id = c.session_id
731
+ WHERE c.source_id = ? AND c.session_id = ? AND c.seq = ?`,
732
+ args: [winner.source_id ?? "", winner.session_id, winner.seq],
733
+ })));
734
+ const hits: SearchHit[] = [];
735
+ for (let i = 0; i < winners.length; i++) {
736
+ const row = details[i].rows[0];
737
+ if (!row) continue;
738
+ const winner = winners[i];
739
+ hits.push({ source_id: winner.source_id, session_id: winner.session_id, seq: winner.seq, time_created: winner.time_created, score: winner.score, text: rowString(row, "text"), anchor_message_id: rowNullableString(row, "anchor_message_id"), title: rowString(row, "title"), directory: rowString(row, "directory") });
740
+ }
741
+ return hits;
742
+ } finally {
743
+ transaction.close();
744
+ }
745
+ }
746
+
747
+ async textSearch(): Promise<SearchHit[]> { throw new Error("Text search is unavailable with EPISODIC_INDEX_URL; remote indexes support vector search only."); }
748
+ async isEmpty(): Promise<boolean> {
749
+ const result = await this.client.execute("SELECT COUNT(*) AS n FROM episodic_chunks");
750
+ const row = result.rows[0];
751
+ return !row || rowNumber(row, "n") === 0;
752
+ }
753
+ async stats(): Promise<IndexStats> {
754
+ const sessions = await this.client.execute("SELECT COUNT(*) AS n FROM episodic_sessions");
755
+ const excluded = await this.client.execute("SELECT COUNT(*) AS n FROM episodic_sessions WHERE status != 'indexed'");
756
+ const chunks = await this.client.execute("SELECT COUNT(*) AS n, MIN(time_created) AS oldest, MAX(time_created) AS newest FROM episodic_chunks");
757
+ const directories = await this.client.execute("SELECT directory, COUNT(*) AS n FROM episodic_sessions WHERE status = 'indexed' GROUP BY directory ORDER BY n DESC LIMIT 10");
758
+ const chunk = chunks.rows[0];
759
+ if (!chunk) throw new Error("Remote stats query returned no row.");
760
+ return { sessions: rowNumber(sessions.rows[0], "n"), excluded: rowNumber(excluded.rows[0], "n"), chunks: rowNumber(chunk, "n"), oldest: chunk.oldest === null ? null : rowNumber(chunk, "oldest"), newest: chunk.newest === null ? null : rowNumber(chunk, "newest"), byDirectory: directories.rows.map((row) => ({ directory: rowString(row, "directory"), n: rowNumber(row, "n") })) };
761
+ }
762
+ async readIndexed(sessionId: string, sourceId: string = this.sourceId): Promise<{ text: string }[]> {
763
+ const result = await this.client.execute({ sql: "SELECT text FROM episodic_chunks WHERE source_id = ? AND session_id = ? ORDER BY seq", args: [sourceId, sessionId] });
764
+ return result.rows.map((row) => ({ text: rowString(row, "text") }));
765
+ }
766
+ close() { this.client.close(); }
767
+ }
768
+
769
+ export async function openConfiguredIndex(): Promise<IndexStore> {
770
+ const config = remoteIndexConfig();
771
+ if (!config) return localIndexStore(openIndex());
772
+ return RemoteIndexStore.open(config.url, config.sourceId, config.authToken);
773
+ }