opencode-episodic-memory 0.3.0 → 0.3.1

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 CHANGED
@@ -35,7 +35,7 @@ tends to match injected boilerplate on this corpus.
35
35
  ## Install
36
36
 
37
37
  ```bash
38
- opencode plugin opencode-episodic-memory@0.3.0 -g
38
+ opencode plugin opencode-episodic-memory@0.3.1 -g
39
39
  ```
40
40
 
41
41
  This adds the plugin to your OpenCode config (`-g` = global config; omit it
@@ -48,7 +48,7 @@ Or edit `~/.config/opencode/opencode.json` manually:
48
48
 
49
49
  ```jsonc
50
50
  {
51
- "plugin": ["opencode-episodic-memory@0.3.0"]
51
+ "plugin": ["opencode-episodic-memory@0.3.1"]
52
52
  }
53
53
  ```
54
54
 
@@ -75,13 +75,13 @@ OpenCode has downloaded the plugin (i.e. after first launch), copy it out of
75
75
  the package cache (the path contains your pinned version):
76
76
 
77
77
  ```bash
78
- cp -r ~/.cache/opencode/packages/opencode-episodic-memory@0.3.0/node_modules/opencode-episodic-memory/skills/remembering-conversations ~/.config/opencode/skills/
78
+ cp -r ~/.cache/opencode/packages/opencode-episodic-memory@0.3.1/node_modules/opencode-episodic-memory/skills/remembering-conversations ~/.config/opencode/skills/
79
79
  ```
80
80
 
81
81
  Then backfill existing history and restart OpenCode:
82
82
 
83
83
  ```bash
84
- bunx opencode-episodic-memory@0.3.0 sync
84
+ bunx opencode-episodic-memory@0.3.1 sync
85
85
  ```
86
86
 
87
87
  ## CLI
@@ -90,16 +90,16 @@ The package ships an `opencode-episodic` binary (requires `bun` on PATH).
90
90
  Invoke it through the package spec — pin it to match your plugin version:
91
91
 
92
92
  ```bash
93
- bunx opencode-episodic-memory@0.3.0 sync [--force] # index new/changed sessions
94
- bunx opencode-episodic-memory@0.3.0 search "query" # semantic (vector) search
95
- bunx opencode-episodic-memory@0.3.0 search q --text "terms" # lexical BM25 (all terms AND-matched, token-based)
96
- bunx opencode-episodic-memory@0.3.0 search q --hybrid # fuse vector + BM25 (RRF; opt-in)
97
- bunx opencode-episodic-memory@0.3.0 search q --after 2026-07-01 --limit 5
98
- bunx opencode-episodic-memory@0.3.0 read <session-id> # full transcript (live store)
99
- bunx opencode-episodic-memory@0.3.0 read <id> --indexed # local indexed excerpts
93
+ bunx opencode-episodic-memory@0.3.1 sync [--force] # index new/changed sessions
94
+ bunx opencode-episodic-memory@0.3.1 search "query" # semantic (vector) search
95
+ bunx opencode-episodic-memory@0.3.1 search q --text "terms" # lexical BM25 (all terms AND-matched, token-based)
96
+ bunx opencode-episodic-memory@0.3.1 search q --hybrid # fuse vector + BM25 (RRF; opt-in)
97
+ bunx opencode-episodic-memory@0.3.1 search q --after 2026-07-01 --limit 5
98
+ bunx opencode-episodic-memory@0.3.1 read <session-id> # full transcript (live store)
99
+ bunx opencode-episodic-memory@0.3.1 read <id> --indexed # local indexed excerpts
100
100
  bun run src/cli.ts read <id> --indexed --source laptop # remote indexed excerpts (development/source checkout)
101
- bunx opencode-episodic-memory@0.3.0 stats # index statistics
102
- bunx opencode-episodic-memory@0.3.0 doctor # diagnose setup
101
+ bunx opencode-episodic-memory@0.3.1 stats # index statistics
102
+ bunx opencode-episodic-memory@0.3.1 doctor # diagnose setup
103
103
  ```
104
104
 
105
105
  `--after`/`--before` take `YYYY-MM-DD` (midnight UTC). `--after D` is inclusive
@@ -107,9 +107,9 @@ of day D; `--before D` is exclusive of day D (i.e. up to the start of that day).
107
107
 
108
108
  ## Agent tools
109
109
 
110
- - **`episodic_search`** — `query` (+ optional `text`, `mode: vector|text|hybrid`, `after`, `before`, `limit`). `vector` (default) is semantic; `text` is lexical BM25; `hybrid` fuses both via RRF (opt-in — can surface lexical noise). Returns dated excerpts with session IDs, scores, and message anchors. Legacy results without an anchor require a normal sync before bounded window reads.
111
- - **`episodic_read_window`** — `session_id`, `anchor_message_id` (+ optional `source_id`, required in remote mode; `before`, `after`, each 0-20, default 3). Reads a bounded chronological window from the privacy-gated live source transcript. Deleted, private, stale, or legacy-anchored sessions cannot provide a window.
112
- - **`episodic_read_session`** — `session_id` (+ optional `source_id`, required in remote mode; `indexed`). Reads the full session transcript from the live store, falling back to indexed excerpts. Prefer `episodic_search` -> `episodic_read_window` -> `episodic_read_session`, stopping once enough context has been recovered.
110
+ - **`episodic_search`** — `query` (+ optional `text`, `mode: vector|text|hybrid`, `after`, `before`, `limit`). `vector` (default) is semantic; `text` is lexical BM25; `hybrid` fuses both via RRF (opt-in — can surface lexical noise). Returns dated excerpts with session IDs, scores, and message anchors. Remote indexes are vector-only: the plugin exposes only `vector` there, and explicit remote `text`/`hybrid` requests fail before embedding with actionable guidance rather than silently falling back. Local text/hybrid behavior is unchanged.
111
+ - **`episodic_read_window`** — `session_id`, `anchor_message_id` (+ optional `source_id`, required for remote indexes; `before`, `after`, each 0-20, default 3). Current-source hits use privacy-gated live messages. Foreign-source hits use labeled indexed exchanges around the anchor, with before/after counting chunks instead of messages and each chunk rendered at up to 600 UTF-8 bytes. Missing or stale anchors cannot expand; use `episodic_read_session` with the same session/source and `indexed: true` for available indexed excerpts.
112
+ - **`episodic_read_session`** — `session_id` (+ optional `source_id`, required for remote indexes; `indexed`). Reads the full live transcript, or indexed excerpts when requested, deleted, or on another source. Current-source indexed reads still check the live privacy marker and withhold cached content if validation fails. Prefer `episodic_search` -> `episodic_read_window` -> `episodic_read_session`, stopping once enough context has been recovered.
113
113
 
114
114
  ## Excluding conversations
115
115
 
@@ -175,9 +175,13 @@ schema and does not use FTS, so remote mode supports vector search only. `--text
175
175
  Remote cosine search reads embedding candidates in bounded pages and hydrates
176
176
  only the final result set.
177
177
 
178
- Remote search includes the source ID in each hit. Bounded live reads are only
179
- valid for the current source; another device's hits remain available as indexed
180
- excerpts through `episodic_read_session` with that hit's `source_id`.
178
+ Remote search includes the source ID in each hit. `source_id` is required for
179
+ remote indexed reads. Another device's hit can be read through the bounded
180
+ indexed foreign-source path in `episodic_read_window` when its anchor is
181
+ present and current; otherwise use `episodic_read_session` with
182
+ `indexed: true`. These are condensed indexed excerpts, not a live transcript;
183
+ indexes remain stale until the source syncs and carry no live privacy or
184
+ freshness guarantees across devices.
181
185
 
182
186
  Network failures are surfaced by the CLI and doctor; plugin background reindex
183
187
  logs failures and never silently falls back to a local index (which would split
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-episodic-memory",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Semantic search over past OpenCode conversations — local embeddings (Transformers.js), SQLite index, native plugin tools. Inspired by obra/episodic-memory, rebuilt natively for OpenCode.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -2,13 +2,14 @@
2
2
  // - Native tools: episodic_search, episodic_read_window, episodic_read_session
3
3
  // - Incremental reindex on session.idle (fire-and-forget, debounced)
4
4
  import { type Plugin, tool } from "@opencode-ai/plugin";
5
- import { openSource, getSession, getTranscriptChecked, getTranscriptContext } from "../src/reader";
5
+ import { openSource, getSession, getTranscriptChecked, getTranscriptContext, transcriptHasMarker } from "../src/reader";
6
6
  import { canLiveRead, openConfiguredIndex, remoteIndexConfig, type IndexStore } from "../src/store";
7
7
  import { syncSession, syncAll, pruneOrphans } from "../src/indexer";
8
8
  import { embedQuery } from "../src/embed";
9
- import { parseDateArg, formatHits, renderTranscript, renderTranscriptContext } from "../src/format";
9
+ import { parseDateArg, formatHits, renderTranscript, renderTranscriptContext, renderIndexedContext } from "../src/format";
10
10
 
11
11
  export const EpisodicMemory: Plugin = async ({ client }) => {
12
+ const remoteSearch = Boolean(process.env.EPISODIC_INDEX_URL);
12
13
  const log = (level: "info" | "warn" | "error", message: string) =>
13
14
  client.app
14
15
  .log({ body: { service: "episodic-memory", level, message } })
@@ -67,16 +68,22 @@ export const EpisodicMemory: Plugin = async ({ client }) => {
67
68
  tool: {
68
69
  episodic_search: tool({
69
70
  description:
70
- "Semantic search over your PAST OpenCode conversations. Use when the user references prior work, past decisions, or previous sessions (e.g. 'how did we handle X', 'the conversation about Y', 'what did we decide about Z'). Returns dated excerpts, session IDs, and anchors. Prefer episodic_search -> episodic_read_window for a bounded live window -> episodic_read_session only when the full session is needed.",
71
+ "Semantic search over your PAST OpenCode conversations. Use when the user references prior work, past decisions, or previous sessions (e.g. 'how did we handle X', 'the conversation about Y', 'what did we decide about Z'). Returns dated excerpts, session IDs, and anchors. Prefer episodic_search -> episodic_read_window for bounded context -> episodic_read_session only when more context is needed." +
72
+ (remoteSearch ? " Remote index: vector search only, without text filtering. Preserve source_id when reading hits; foreign-source windows contain indexed excerpts, not live messages." : ""),
71
73
  args: {
72
74
  query: tool.schema.string().describe("Natural-language description of what you're looking for"),
73
- text: tool.schema.string().optional().describe("Exact substring to require in results (ANDed with semantic ranking)"),
74
- mode: tool.schema.enum(["vector", "text", "hybrid"]).optional().describe("'vector' (default) semantic search, scores are cosine (~0.4–0.7); 'text' lexical BM25; 'hybrid' fuses both via RRF (may surface lexical noise) — note hybrid hits carry fused RRF scores (~0.03), a DIFFERENT scale from cosine, so don't judge them against the vector thresholds"),
75
+ ...(!remoteSearch ? { text: tool.schema.string().optional().describe("Exact substring to require in results (ANDed with semantic ranking)") } : {}),
76
+ mode: tool.schema.enum(remoteSearch ? ["vector"] : ["vector", "text", "hybrid"]).optional().describe(remoteSearch
77
+ ? "'vector' (default): the only supported remote mode. Scores are cosine (~0.4-0.7)."
78
+ : "'vector' (default) semantic search, scores are cosine (~0.4–0.7); 'text' lexical BM25; 'hybrid' fuses both via RRF (may surface lexical noise) — note hybrid hits carry fused RRF scores (~0.03), a DIFFERENT scale from cosine, so don't judge them against the vector thresholds"),
75
79
  after: tool.schema.string().optional().describe("Only conversations after YYYY-MM-DD"),
76
80
  before: tool.schema.string().optional().describe("Only conversations before YYYY-MM-DD"),
77
81
  limit: tool.schema.number().optional().describe("Max results, 1-50 (default 10)"),
78
82
  },
79
83
  async execute(args) {
84
+ if (remoteIndexConfig() && (args.mode === "text" || args.mode === "hybrid" || args.text !== undefined)) {
85
+ return 'Remote indexes support vector search only, without text filtering. Retry with mode: "vector" and omit text; include relevant terms in query. Use a local index for exact text filtering, BM25, or hybrid search. No search was run.';
86
+ }
80
87
  const index = await getIndex();
81
88
  const after = parseDateArg(args.after);
82
89
  if (!after.ok) return after.error;
@@ -86,7 +93,7 @@ export const EpisodicMemory: Plugin = async ({ client }) => {
86
93
  limit: Math.min(Math.max(args.limit ?? 10, 1), 50),
87
94
  after: after.ms,
88
95
  before: before.ms,
89
- text: args.text,
96
+ text: typeof args.text === "string" ? args.text : undefined,
90
97
  };
91
98
  const noHits = async () => await index.isEmpty()
92
99
  ? "No matching past conversations found. The index is empty — run `bun run src/cli.ts sync` to index conversations."
@@ -116,18 +123,26 @@ export const EpisodicMemory: Plugin = async ({ client }) => {
116
123
 
117
124
  episodic_read_window: tool({
118
125
  description:
119
- "Read a bounded live-source message window around an anchor from episodic_search. Use the returned session_id and anchor_message_id; this cannot read deleted sessions or indexed-only excerpts.",
126
+ "Read bounded context around an anchor from episodic_search. Preserve session_id, anchor_message_id, and source_id. Current-source hits use privacy-gated live messages; foreign-source hits use labeled indexed condensed exchanges that may be stale. Missing or stale anchors cannot provide a window; use episodic_read_session with source_id and indexed: true for indexed excerpts instead.",
120
127
  args: {
121
128
  session_id: tool.schema.string().describe("Session ID from episodic_search, e.g. ses_..."),
122
129
  source_id: tool.schema.string().optional().describe("Source ID shown by remote episodic_search results; required for remote indexes"),
123
130
  anchor_message_id: tool.schema.string().describe("Anchor message ID from episodic_search"),
124
- before: tool.schema.number().optional().describe("Messages before the anchor, 0-20 (default 3)"),
125
- after: tool.schema.number().optional().describe("Messages after the anchor, 0-20 (default 3)"),
131
+ before: tool.schema.number().optional().describe("Messages before the anchor, or indexed chunks for a foreign source, 0-20 (default 3)"),
132
+ after: tool.schema.number().optional().describe("Messages after the anchor, or indexed chunks for a foreign source, 0-20 (default 3)"),
126
133
  },
127
134
  async execute(args) {
128
135
  const remote = remoteIndexConfig();
129
- if (!canLiveRead(remote, args.source_id)) {
130
- throw new Error("This indexed hit belongs to another source (or has no source_id). Its excerpt is searchable, but live windows are only available for the current source.");
136
+ if (remote && !args.source_id) {
137
+ throw new Error("source_id is required for episodic_read_window with a remote index. Use the source from the search hit.");
138
+ }
139
+ if (args.source_id && !canLiveRead(remote, args.source_id)) {
140
+ const index = await getIndex();
141
+ const rows = await index.readIndexedWindow(args.session_id, args.anchor_message_id, args.before, args.after, args.source_id);
142
+ if (rows.length === 0) {
143
+ throw new Error('No indexed window found for this source, session, and anchor. The anchor may be stale; use episodic_read_session with the same session_id, source_id, and indexed: true for available indexed excerpts.');
144
+ }
145
+ return renderIndexedContext(args.session_id, args.source_id, args.anchor_message_id, rows);
131
146
  }
132
147
  const source = openSource();
133
148
  const context = getTranscriptContext(source, args.session_id, args.anchor_message_id, args.before, args.after);
@@ -155,24 +170,30 @@ export const EpisodicMemory: Plugin = async ({ client }) => {
155
170
  throw new Error("source_id is required for episodic_read_session with a remote index.");
156
171
  }
157
172
  const foreign = !canLiveRead(remote, args.source_id);
158
- if (!args.indexed && !foreign) {
173
+ if (!foreign) {
159
174
  try {
160
175
  const source = openSource();
161
- const s = getSession(source, args.session_id);
162
- if (s) {
163
- // Privacy gate lives inside getTranscriptChecked (authoritative
164
- // raw-blob scan before any read).
165
- const checked = getTranscriptChecked(source, args.session_id);
166
- if (checked.excluded) {
176
+ try {
177
+ // Explicit indexed reads must also respect a marker added since sync.
178
+ if (transcriptHasMarker(source, args.session_id)) {
167
179
  return "Session is marked private (exclusion marker present); transcript withheld.";
168
180
  }
169
- return renderTranscript(s, checked.messages).slice(0, 50000);
181
+ if (!args.indexed) {
182
+ const s = getSession(source, args.session_id);
183
+ if (s) {
184
+ const checked = getTranscriptChecked(source, args.session_id);
185
+ if (checked.excluded) {
186
+ return "Session is marked private (exclusion marker present); transcript withheld.";
187
+ }
188
+ return renderTranscript(s, checked.messages).slice(0, 50000);
189
+ }
190
+ }
191
+ } finally {
192
+ source.close();
170
193
  }
171
194
  } catch (e) {
172
- // Log before falling through — a bare swallow would also hide
173
- // structural Zod drift, which is meant to be loud.
174
195
  await log("warn", `episodic_read_session live-store read failed for ${args.session_id}: ${e}`);
175
- // fall through to indexed copy
196
+ return "Live-source validation failed; indexed content withheld because current privacy status could not be verified.";
176
197
  }
177
198
  }
178
199
  const index = await getIndex();
@@ -25,21 +25,27 @@ conversation — the index is for cross-session recall, not code search.
25
25
 
26
26
  1. `episodic_search` with a natural-language query describing the *topic and intent*,
27
27
  not exact keywords ("migrating from Claude Code to OpenCode", not "claude opencode").
28
- - Narrow with `after`/`before` dates or an exact `text` substring when you know one
29
- (an error string, a flag name, a file path).
30
- - `mode: "text"` for lexical BM25 search: every query word must appear (token-based
28
+ - Narrow with `after`/`before` dates. Local indexes also support an exact `text`
29
+ substring (an error string, a flag name, a file path).
30
+ - On local indexes, use `mode: "text"` for lexical BM25 search: every query word must appear (token-based
31
31
  AND, BM25-ranked) — not phrase/adjacency or substring matching.
32
+ - Remote indexes support only vector search, without `text` filtering. Include
33
+ relevant terms in `query`; do not retry unsupported text/hybrid modes.
32
34
  2. Skim the returned excerpts (date, session title, score, and anchor). Vector
33
35
  similarity scores are NOT calibrated probabilities: ≥ ~0.55 is a strong
34
36
  match, 0.4–0.55 is likely relevant, and < ~0.35 is weak or merely adjacent.
35
37
  These thresholds apply only to vector results. For hybrid results, use the
36
38
  snippet and the `rrf` label instead; RRF scores are on a different scale.
37
39
  Say when the corpus doesn't really contain the topic.
38
- 3. `episodic_read_window` with the result's session ID and anchor message ID to
39
- inspect a small chronological window first. It is live-source only: private,
40
- deleted, stale, and legacy unanchored results cannot expand.
41
- 4. `episodic_read_session` with the session ID only when that window isn't enough
42
- and the full session transcript is needed.
40
+ 3. `episodic_read_window` with the result's session ID, anchor message ID, and
41
+ `source_id` (required for remote indexes) to inspect a small window first.
42
+ Current-source windows read privacy-gated live messages. Foreign-source windows
43
+ read labeled indexed exchanges: `before`/`after` count chunks, not messages,
44
+ and content may be stale until that source syncs. Missing or stale anchors
45
+ cannot expand. Stop on privacy denials rather than trying indexed reads.
46
+ 4. `episodic_read_session` with the session ID and the same `source_id` when the
47
+ window is insufficient or unavailable. Use `indexed: true` for indexed excerpts,
48
+ including legacy unanchored hits. Do not present these as a full live transcript.
43
49
 
44
50
  ## Answering
45
51
 
package/src/format.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // parsing, date formatting, transcript→markdown, and search-hit formatting live
3
3
  // here so the two front-ends can't drift apart.
4
4
  import type { SourceMessage } from "./reader";
5
- import type { SearchHit } from "./store";
5
+ import type { IndexedWindowRow, SearchHit } from "./store";
6
6
  import { Buffer } from "node:buffer";
7
7
 
8
8
  const MAX_CONTEXT_BODY_BYTES = 600;
@@ -86,6 +86,20 @@ export function renderTranscriptContext(
86
86
  return lines.join("\n");
87
87
  }
88
88
 
89
+ export function renderIndexedContext(sessionId: string, sourceId: string, anchorMessageId: string, rows: IndexedWindowRow[]): string {
90
+ const lines = [
91
+ "# Indexed excerpts (not a live transcript)",
92
+ `source: ${truncateContext(sourceId, MAX_CONTEXT_FIELD_BYTES)} session: ${truncateContext(sessionId, MAX_CONTEXT_FIELD_BYTES)}`,
93
+ "Window bounds count condensed exchange chunks, not messages. Content may be stale until the source syncs.",
94
+ "",
95
+ ];
96
+ for (const row of rows) {
97
+ lines.push(`## Chunk ${row.seq}${row.anchor_message_id === anchorMessageId ? " (anchor)" : ""}`);
98
+ lines.push(truncateContext(row.text, MAX_CONTEXT_BODY_BYTES), "");
99
+ }
100
+ return lines.join("\n");
101
+ }
102
+
89
103
  function truncateContext(value: string, byteLimit: number): string {
90
104
  if (Buffer.byteLength(value, "utf8") <= byteLimit) return value;
91
105
  const suffix = "... [truncated]";
package/src/store.ts CHANGED
@@ -442,9 +442,23 @@ export interface IndexStore {
442
442
  isEmpty(): Promise<boolean>;
443
443
  stats(): Promise<IndexStats>;
444
444
  readIndexed(sessionId: string, sourceId?: string): Promise<{ text: string }[]>;
445
+ readIndexedWindow(sessionId: string, anchorMessageId: string, before?: number, after?: number, sourceId?: string): Promise<IndexedWindowRow[]>;
445
446
  close(): void;
446
447
  }
447
448
 
449
+ export interface IndexedWindowRow {
450
+ seq: number;
451
+ anchor_message_id: string | null;
452
+ text: string;
453
+ }
454
+
455
+ function indexedWindowBounds(before: number = 3, after: number = 3): { before: number; after: number } {
456
+ if (!Number.isInteger(before) || before < 0 || before > 20 || !Number.isInteger(after) || after < 0 || after > 20) {
457
+ throw new Error("before and after must be non-negative integers no greater than 20.");
458
+ }
459
+ return { before, after };
460
+ }
461
+
448
462
  class LocalIndexStore implements IndexStore {
449
463
  readonly remote = false;
450
464
  constructor(private readonly db: Database) {}
@@ -479,6 +493,28 @@ class LocalIndexStore implements IndexStore {
479
493
  async readIndexed(sessionId: string) {
480
494
  return this.db.prepare<{ text: string }, [string]>("SELECT text FROM chunks WHERE session_id = ? ORDER BY seq").all(sessionId);
481
495
  }
496
+ async readIndexedWindow(sessionId: string, anchorMessageId: string, before?: number, after?: number) {
497
+ const bounds = indexedWindowBounds(before, after);
498
+ return this.db.prepare<IndexedWindowRow, [string, string, string, number, string, number]>(
499
+ `WITH anchor AS (
500
+ SELECT seq FROM chunks WHERE session_id = ? AND anchor_message_id = ? ORDER BY seq LIMIT 1
501
+ ), previous AS (
502
+ SELECT c.seq, c.anchor_message_id, substr(c.text, 1, 4000) AS text
503
+ FROM chunks c JOIN anchor a
504
+ WHERE c.session_id = ? AND c.seq < a.seq
505
+ ORDER BY c.seq DESC LIMIT ?
506
+ ), following AS (
507
+ SELECT c.seq, c.anchor_message_id, substr(c.text, 1, 4000) AS text
508
+ FROM chunks c JOIN anchor a
509
+ WHERE c.session_id = ? AND c.seq >= a.seq
510
+ ORDER BY c.seq ASC LIMIT ?
511
+ )
512
+ SELECT seq, anchor_message_id, text FROM previous
513
+ UNION ALL
514
+ SELECT seq, anchor_message_id, text FROM following
515
+ ORDER BY seq`
516
+ ).all(sessionId, anchorMessageId, sessionId, bounds.before, sessionId, bounds.after + 1);
517
+ }
482
518
  close() { this.db.close(); }
483
519
  }
484
520
 
@@ -763,6 +799,35 @@ class RemoteIndexStore implements IndexStore {
763
799
  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
800
  return result.rows.map((row) => ({ text: rowString(row, "text") }));
765
801
  }
802
+ async readIndexedWindow(sessionId: string, anchorMessageId: string, before?: number, after?: number, sourceId?: string): Promise<IndexedWindowRow[]> {
803
+ if (!sourceId) throw new Error("sourceId is required for remote indexed windows.");
804
+ const bounds = indexedWindowBounds(before, after);
805
+ const result = await this.client.execute({
806
+ sql: `WITH anchor AS (
807
+ SELECT seq FROM episodic_chunks
808
+ WHERE source_id = ? AND session_id = ? AND anchor_message_id = ?
809
+ ORDER BY seq LIMIT 1
810
+ ), previous AS (
811
+ SELECT c.seq, c.anchor_message_id, substr(c.text, 1, 4000) AS text
812
+ FROM episodic_chunks c JOIN anchor a
813
+ WHERE c.source_id = ? AND c.session_id = ? AND c.seq < a.seq
814
+ ORDER BY c.seq DESC LIMIT ?
815
+ ), following AS (
816
+ SELECT c.seq, c.anchor_message_id, substr(c.text, 1, 4000) AS text
817
+ FROM episodic_chunks c JOIN anchor a
818
+ WHERE c.source_id = ? AND c.session_id = ? AND c.seq >= a.seq
819
+ ORDER BY c.seq ASC LIMIT ?
820
+ )
821
+ SELECT seq, anchor_message_id, text FROM previous
822
+ UNION ALL
823
+ SELECT seq, anchor_message_id, text FROM following
824
+ ORDER BY seq`,
825
+ args: [sourceId, sessionId, anchorMessageId, sourceId, sessionId, bounds.before, sourceId, sessionId, bounds.after + 1],
826
+ });
827
+ return result.rows.map((row) => ({
828
+ seq: rowNumber(row, "seq"), anchor_message_id: rowNullableString(row, "anchor_message_id"), text: rowString(row, "text"),
829
+ }));
830
+ }
766
831
  close() { this.client.close(); }
767
832
  }
768
833