opencode-episodic-memory 0.2.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
@@ -19,8 +19,8 @@ of the OpenCode memory-plugin landscape? See
19
19
  1. **Read** — sessions/messages/parts from OpenCode's `~/.local/share/opencode/opencode.db` (read-only)
20
20
  2. **Parse** — condensed exchanges (user text, assistant text, tool names; no reasoning blobs or tool output)
21
21
  3. **Embed** — local, offline embeddings via Transformers.js in a persistent system-Node sidecar (`Snowflake/snowflake-arctic-embed-m-v1.5` q8, 768 dims; retrieval prefix on search queries). Chosen by empirical eval on a real corpus — see [docs/embedding-model-eval.md](docs/embedding-model-eval.md)
22
- 4. **Index** — plain SQLite at `~/.local/share/opencode-episodic-memory/index.db`; brute-force cosine over Float32 blobs, plus a built-in FTS5 BM25 index for lexical/hybrid search
23
- 5. **Recall** — native plugin tools `episodic_search` / `episodic_read`, plus a `remembering-conversations` skill that teaches the agent when to search
22
+ 4. **Index** — plain SQLite at `~/.local/share/opencode-episodic-memory/index.db` by default; optional libSQL/Turso remote storage can combine source-scoped indexes across devices
23
+ 5. **Recall** — native plugin tools `episodic_search` / `episodic_read_window` / `episodic_read_session`, plus a `remembering-conversations` skill that teaches the agent when to search
24
24
  6. **Stay fresh** — the plugin re-indexes each session on the `session.idle` event
25
25
 
26
26
  Design note: `bun:sqlite` cannot load dynamic extensions, so sqlite-vec is not
@@ -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.1.3 -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.1.3"]
51
+ "plugin": ["opencode-episodic-memory@0.3.1"]
52
52
  }
53
53
  ```
54
54
 
@@ -57,7 +57,7 @@ Default sidecar-mode semantic indexing and vector/hybrid search require a system
57
57
  model (~100 MB, cached afterward). The model and its native runtime live in
58
58
  that Node sidecar, not inside OpenCode's Bun/TUI process. Explicit
59
59
  `EPISODIC_EMBED_MODE=inline` works without Node but is unsafe in affected
60
- OpenCode/Bun versions. `episodic_read` and lexical text search also remain
60
+ OpenCode/Bun versions. `episodic_read_window`, `episodic_read_session`, and lexical text search also remain
61
61
  available without Node.
62
62
 
63
63
  Install the skill so the agent knows when to search, via the
@@ -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.1.3/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.1.3 sync
84
+ bunx opencode-episodic-memory@0.3.1 sync
85
85
  ```
86
86
 
87
87
  ## CLI
@@ -90,15 +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.1.3 sync [--force] # index new/changed sessions
94
- bunx opencode-episodic-memory@0.1.3 search "query" # semantic (vector) search
95
- bunx opencode-episodic-memory@0.1.3 search q --text "terms" # lexical BM25 (all terms AND-matched, token-based)
96
- bunx opencode-episodic-memory@0.1.3 search q --hybrid # fuse vector + BM25 (RRF; opt-in)
97
- bunx opencode-episodic-memory@0.1.3 search q --after 2026-07-01 --limit 5
98
- bunx opencode-episodic-memory@0.1.3 read <session-id> # full transcript (live store)
99
- bunx opencode-episodic-memory@0.1.3 read <id> --indexed # indexed excerpts (survives deletion)
100
- bunx opencode-episodic-memory@0.1.3 stats # index statistics
101
- bunx opencode-episodic-memory@0.1.3 doctor # diagnose setup
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
+ bun run src/cli.ts read <id> --indexed --source laptop # remote indexed excerpts (development/source checkout)
101
+ bunx opencode-episodic-memory@0.3.1 stats # index statistics
102
+ bunx opencode-episodic-memory@0.3.1 doctor # diagnose setup
102
103
  ```
103
104
 
104
105
  `--after`/`--before` take `YYYY-MM-DD` (midnight UTC). `--after D` is inclusive
@@ -106,8 +107,9 @@ of day D; `--before D` is exclusive of day D (i.e. up to the start of that day).
106
107
 
107
108
  ## Agent tools
108
109
 
109
- - **`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 and scores.
110
- - **`episodic_read`** — `session_id` (+ optional `indexed`). Full transcript from the live store, falling back to indexed excerpts.
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.
111
113
 
112
114
  ## Excluding conversations
113
115
 
@@ -128,6 +130,9 @@ instruction-tag match — the intent is the same, but our matching is literal.
128
130
  |---|---|---|
129
131
  | `EPISODIC_SOURCE_DB` | `~/.local/share/opencode/opencode.db` | OpenCode session store |
130
132
  | `EPISODIC_INDEX_DB` | `~/.local/share/opencode-episodic-memory/index.db` | Index location |
133
+ | `EPISODIC_INDEX_URL` | unset | Opt-in libSQL/Turso index URL; activates remote vector-only mode |
134
+ | `EPISODIC_INDEX_AUTH_TOKEN` | unset | Required for remote network URLs; passed to `@libsql/client` |
135
+ | `EPISODIC_SOURCE_ID` | unset | Required in remote mode; stable device/source identity |
131
136
  | `EPISODIC_EMBED_MODEL` | `Snowflake/snowflake-arctic-embed-m-v1.5` | Transformers.js embedding model |
132
137
  | `EPISODIC_EMBED_MODE` | `sidecar` | `sidecar` runs embeddings in Node; `inline` is an explicit escape hatch |
133
138
  | `EPISODIC_NODE_BINARY` | `node` | Node 20+ executable used by sidecar mode |
@@ -142,6 +147,48 @@ during native-addon teardown. It is never selected automatically if sidecar
142
147
  startup fails. Run `bun run src/cli.ts doctor` to diagnose the selected mode,
143
148
  Node version, and a real embedding.
144
149
 
150
+ ### Optional shared remote index
151
+
152
+ Set all three variables on each device (use a distinct, stable source ID per
153
+ device):
154
+
155
+ ```bash
156
+ export EPISODIC_INDEX_URL='libsql://your-index.turso.io'
157
+ export EPISODIC_INDEX_AUTH_TOKEN='...'
158
+ export EPISODIC_SOURCE_ID='laptop'
159
+ ```
160
+
161
+ This is an explicit privacy boundary: sync still reads OpenCode locally and
162
+ generates embeddings locally, but it **uploads condensed chunk text, session
163
+ metadata, anchors, and embedding vectors** to the configured database. Do not
164
+ configure it unless that database is appropriate for this conversation data.
165
+ Sessions containing `DO NOT INDEX THIS CHAT` upload neither content nor a
166
+ metadata tombstone; an existing row for that source/session is removed.
167
+ Absolute local `file:` libSQL URLs are supported without a token for hermetic
168
+ testing; network
169
+ URLs must use `https:`, `wss:`, or TLS-enabled `libsql:` and require a token
170
+ supplied only through `EPISODIC_INDEX_AUTH_TOKEN` (not embedded in the URL).
171
+ For `libsql:`, omit `tls` or use exactly one lowercase `tls=1` parameter.
172
+ The remote schema is freshly created or validates/adopts a compatible existing
173
+ schema and does not use FTS, so remote mode supports vector search only. `--text`,
174
+ `--hybrid`, and plugin `text`/`hybrid` modes fail clearly in remote mode.
175
+ Remote cosine search reads embedding candidates in bounded pages and hydrates
176
+ only the final result set.
177
+
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.
185
+
186
+ Network failures are surfaced by the CLI and doctor; plugin background reindex
187
+ logs failures and never silently falls back to a local index (which would split
188
+ history). To return to local-only mode, unset `EPISODIC_INDEX_URL`,
189
+ `EPISODIC_INDEX_AUTH_TOKEN`, and `EPISODIC_SOURCE_ID`; the existing local index
190
+ is selected unchanged. Re-run `sync` if the local index needs rebuilding.
191
+
145
192
  ## Not yet implemented (deliberate)
146
193
 
147
194
  - LLM-generated per-session summaries embedded instead of raw exchange text
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-episodic-memory",
3
- "version": "0.2.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",
@@ -39,6 +39,7 @@
39
39
  "prepublishOnly": "bun run typecheck && bun test && bun run verify:entrypoint"
40
40
  },
41
41
  "dependencies": {
42
+ "@libsql/client": "^0.18.0",
42
43
  "@huggingface/transformers": "^4.2.0",
43
44
  "@opencode-ai/plugin": "^1.18.4",
44
45
  "zod": "^4.4.3"
@@ -1,47 +1,59 @@
1
1
  // OpenCode plugin: episodic memory over past conversations.
2
- // - Native tools: episodic_search, episodic_read
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 } from "../src/reader";
6
- import { openIndex, search, textSearch, isIndexEmpty } from "../src/store";
5
+ import { openSource, getSession, getTranscriptChecked, getTranscriptContext, transcriptHasMarker } from "../src/reader";
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 } 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 } })
15
16
  .catch(() => {});
17
+ let configuredIndex: Promise<IndexStore> | undefined;
18
+ const getIndex = () => configuredIndex ??= openConfiguredIndex().catch((error) => {
19
+ configuredIndex = undefined;
20
+ throw error;
21
+ });
16
22
 
17
23
  // Debounce concurrent reindex runs for the same session.
18
24
  const inflight = new Map<string, Promise<void>>();
25
+ const pending = new Set<string>();
19
26
  function reindex(sessionId?: string) {
20
27
  const key = sessionId ?? "__all__";
21
- if (inflight.has(key)) return inflight.get(key)!;
28
+ if (inflight.has(key)) {
29
+ pending.add(key);
30
+ return inflight.get(key)!;
31
+ }
22
32
  const p = (async () => {
23
- try {
24
- const source = openSource();
25
- const index = openIndex();
26
- if (sessionId) {
27
- const s = getSession(source, sessionId);
28
- if (s) await syncSession(source, index, s);
29
- // Cheap (two small SELECTs + rare DELETEs), so prune on every idle:
30
- // the syncAll path below effectively never fires (session.idle always
31
- // carries a sessionID), and without this, deleted conversations would
32
- // linger in the index searchable and readable for plugin-only users.
33
- pruneOrphans(source, index);
34
- } else {
35
- await syncAll(source, index); // syncAll prunes source-deleted orphans
33
+ do {
34
+ pending.delete(key);
35
+ try {
36
+ const source = openSource();
37
+ const index = await getIndex();
38
+ if (sessionId) {
39
+ const s = getSession(source, sessionId);
40
+ if (s) await syncSession(source, index, s);
41
+ // Cheap (two small SELECTs + rare DELETEs), so prune on every idle:
42
+ // the syncAll path below effectively never fires (session.idle always
43
+ // carries a sessionID), and without this, deleted conversations would
44
+ // linger in the index — searchable and readable — for plugin-only users.
45
+ await pruneOrphans(source, index);
46
+ } else {
47
+ await syncAll(source, index); // syncAll prunes source-deleted orphans
48
+ }
49
+ await log("info", `reindexed ${key}`);
50
+ } catch (e) {
51
+ await log("warn", `reindex failed for ${key}: ${e}`);
36
52
  }
37
- await log("info", `reindexed ${key}`);
38
- } catch (e) {
39
- await log("warn", `reindex failed for ${key}: ${e}`);
40
- } finally {
41
- inflight.delete(key);
42
- }
53
+ } while (pending.has(key));
43
54
  })();
44
55
  inflight.set(key, p);
56
+ p.finally(() => inflight.delete(key));
45
57
  return p;
46
58
  }
47
59
 
@@ -56,17 +68,23 @@ export const EpisodicMemory: Plugin = async ({ client }) => {
56
68
  tool: {
57
69
  episodic_search: tool({
58
70
  description:
59
- "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 with session IDs; follow up with episodic_read for the full conversation.",
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." : ""),
60
73
  args: {
61
74
  query: tool.schema.string().describe("Natural-language description of what you're looking for"),
62
- text: tool.schema.string().optional().describe("Exact substring to require in results (ANDed with semantic ranking)"),
63
- 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"),
64
79
  after: tool.schema.string().optional().describe("Only conversations after YYYY-MM-DD"),
65
80
  before: tool.schema.string().optional().describe("Only conversations before YYYY-MM-DD"),
66
81
  limit: tool.schema.number().optional().describe("Max results, 1-50 (default 10)"),
67
82
  },
68
83
  async execute(args) {
69
- const index = openIndex();
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
+ }
87
+ const index = await getIndex();
70
88
  const after = parseDateArg(args.after);
71
89
  if (!after.ok) return after.error;
72
90
  const before = parseDateArg(args.before);
@@ -75,13 +93,13 @@ export const EpisodicMemory: Plugin = async ({ client }) => {
75
93
  limit: Math.min(Math.max(args.limit ?? 10, 1), 50),
76
94
  after: after.ms,
77
95
  before: before.ms,
78
- text: args.text,
96
+ text: typeof args.text === "string" ? args.text : undefined,
79
97
  };
80
- const noHits = () => isIndexEmpty(index)
98
+ const noHits = async () => await index.isEmpty()
81
99
  ? "No matching past conversations found. The index is empty — run `bun run src/cli.ts sync` to index conversations."
82
100
  : "No matching past conversations found.";
83
101
  if (args.mode === "text") {
84
- const hits = textSearch(index, args.query, opts);
102
+ const hits = await index.textSearch(args.query, opts);
85
103
  if (hits.length === 0) return noHits();
86
104
  return formatHits(hits, 400, "score");
87
105
  }
@@ -90,49 +108,96 @@ export const EpisodicMemory: Plugin = async ({ client }) => {
90
108
  vector = (await embedQuery(args.query))[0];
91
109
  } catch (e) {
92
110
  await log("warn", `episodic_search embedding failed: ${e instanceof Error ? e.message : e}`);
93
- return 'Semantic search unavailable: the embedding backend failed. Use mode: "text" for embedding-free lexical search, or run `bun run src/cli.ts doctor` for details.';
111
+ return index.remote
112
+ ? "Semantic search unavailable: the embedding backend failed. Remote indexes support vector search only; run `bun run src/cli.ts doctor` for details."
113
+ : 'Semantic search unavailable: the embedding backend failed. Use mode: "text" for embedding-free lexical search, or run `bun run src/cli.ts doctor` for details.';
94
114
  }
95
115
  const hits = args.mode === "hybrid"
96
- ? search(index, vector, { ...opts, queryText: args.query, hybrid: true })
97
- : search(index, vector, opts);
116
+ ? await index.search(vector, { ...opts, queryText: args.query, hybrid: true })
117
+ : await index.search(vector, opts);
98
118
  if (hits.length === 0) return noHits();
99
119
  // Hybrid hits carry RRF scores (~0.03), not cosine — label them "rrf".
100
120
  return formatHits(hits, 400, args.mode === "hybrid" ? "rrf" : "score");
101
121
  },
102
122
  }),
103
123
 
104
- episodic_read: tool({
124
+ episodic_read_window: tool({
125
+ description:
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.",
127
+ args: {
128
+ session_id: tool.schema.string().describe("Session ID from episodic_search, e.g. ses_..."),
129
+ source_id: tool.schema.string().optional().describe("Source ID shown by remote episodic_search results; required for remote indexes"),
130
+ anchor_message_id: tool.schema.string().describe("Anchor message ID from episodic_search"),
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)"),
133
+ },
134
+ async execute(args) {
135
+ const remote = remoteIndexConfig();
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);
146
+ }
147
+ const source = openSource();
148
+ const context = getTranscriptContext(source, args.session_id, args.anchor_message_id, args.before, args.after);
149
+ if (!context.ok) {
150
+ if (context.reason === "unknown_session") throw new Error(`No live conversation found for session ${args.session_id}.`);
151
+ if (context.reason === "excluded") throw new Error("Session is marked private (exclusion marker present); context withheld.");
152
+ if (context.reason === "invalid_anchor") throw new Error(`Anchor message ${args.anchor_message_id} is stale or invalid for session ${args.session_id}.`);
153
+ throw new Error("before and after must be non-negative integers no greater than 20.");
154
+ }
155
+ return renderTranscriptContext(context.session, context);
156
+ },
157
+ }),
158
+
159
+ episodic_read_session: tool({
105
160
  description:
106
- "Read the full transcript of a past OpenCode conversation, given a session ID (from episodic_search results). Reconstructs from the live session store; falls back to indexed excerpts if the session was deleted.",
161
+ "Read the full transcript of a past OpenCode session, given a session ID (from episodic_search results). Use after episodic_read_window when the bounded window is insufficient. Reconstructs from the live session store; falls back to indexed excerpts if the session was deleted.",
107
162
  args: {
108
163
  session_id: tool.schema.string().describe("Session ID, e.g. ses_..."),
164
+ source_id: tool.schema.string().optional().describe("Source ID shown by remote episodic_search results"),
109
165
  indexed: tool.schema.boolean().optional().describe("Force reading from the index instead of the live session store"),
110
166
  },
111
167
  async execute(args) {
112
- if (!args.indexed) {
168
+ const remote = remoteIndexConfig();
169
+ if (remote && args.source_id === undefined) {
170
+ throw new Error("source_id is required for episodic_read_session with a remote index.");
171
+ }
172
+ const foreign = !canLiveRead(remote, args.source_id);
173
+ if (!foreign) {
113
174
  try {
114
175
  const source = openSource();
115
- const s = getSession(source, args.session_id);
116
- if (s) {
117
- // Privacy gate lives inside getTranscriptChecked (authoritative
118
- // raw-blob scan before any read).
119
- const checked = getTranscriptChecked(source, args.session_id);
120
- if (checked.excluded) {
176
+ try {
177
+ // Explicit indexed reads must also respect a marker added since sync.
178
+ if (transcriptHasMarker(source, args.session_id)) {
121
179
  return "Session is marked private (exclusion marker present); transcript withheld.";
122
180
  }
123
- 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();
124
193
  }
125
194
  } catch (e) {
126
- // Log before falling through a bare swallow would also hide
127
- // structural Zod drift, which is meant to be loud.
128
- await log("warn", `episodic_read live-store read failed for ${args.session_id}: ${e}`);
129
- // fall through to indexed copy
195
+ await log("warn", `episodic_read_session live-store read failed for ${args.session_id}: ${e}`);
196
+ return "Live-source validation failed; indexed content withheld because current privacy status could not be verified.";
130
197
  }
131
198
  }
132
- const index = openIndex();
133
- const rows = index
134
- .prepare<{ text: string }, [string]>("SELECT text FROM chunks WHERE session_id = ? ORDER BY seq")
135
- .all(args.session_id);
199
+ const index = await getIndex();
200
+ const rows = await index.readIndexed(args.session_id, args.source_id);
136
201
  if (rows.length === 0) return `No conversation found for session ${args.session_id}.`;
137
202
  return `(indexed excerpts — live session unavailable)\n\n${rows.map((r) => r.text).join("\n\n---\n\n")}`.slice(0, 50000);
138
203
  },
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  name: remembering-conversations
3
- description: Recall past OpenCode conversations when the user references previous work, past decisions, or earlier sessions — "how did we handle X", "the conversation about Y", "what did we decide", "we tried this before", "last week we...". Use the episodic_search and episodic_read tools to search semantically and read full transcripts.
3
+ description: Recall past OpenCode conversations when the user references previous work, past decisions, or earlier sessions — "how did we handle X", "the conversation about Y", "what did we decide", "we tried this before", "last week we...". Use episodic_search, then episodic_read_window for a bounded window, and episodic_read_session only when the full session is needed.
4
4
  ---
5
5
 
6
6
  # Remembering Conversations
7
7
 
8
8
  You have episodic memory: every past OpenCode session is indexed and searchable
9
- via two native tools.
9
+ via three native tools.
10
10
 
11
11
  ## When to search
12
12
 
@@ -25,16 +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
- 2. Skim the returned excerpts (date, session title, score). Similarity scores are
33
- NOT calibrated probabilities: ~0.55 is a strong match, 0.4–0.55 is likely
34
- relevant, < ~0.35 is weak or merely adjacent judge by the snippet, not the
35
- number, and say when the corpus doesn't really contain the topic.
36
- 3. `episodic_read` with the session ID for the full transcript when an excerpt
37
- isn't enough.
32
+ - Remote indexes support only vector search, without `text` filtering. Include
33
+ relevant terms in `query`; do not retry unsupported text/hybrid modes.
34
+ 2. Skim the returned excerpts (date, session title, score, and anchor). Vector
35
+ similarity scores are NOT calibrated probabilities: ~0.55 is a strong
36
+ match, 0.4–0.55 is likely relevant, and < ~0.35 is weak or merely adjacent.
37
+ These thresholds apply only to vector results. For hybrid results, use the
38
+ snippet and the `rrf` label instead; RRF scores are on a different scale.
39
+ Say when the corpus doesn't really contain the topic.
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.
38
49
 
39
50
  ## Answering
40
51
 
package/src/cli.ts CHANGED
@@ -7,13 +7,13 @@
7
7
  // --after YYYY-MM-DD Only conversations after this date
8
8
  // --before YYYY-MM-DD Only conversations before this date
9
9
  // --limit N Max results (default 10)
10
- // read <session-id> [--indexed] Print a readable transcript (live DB, or --indexed for index copy)
10
+ // read <session-id> [--indexed --source source-id] Print a readable transcript
11
11
  // stats Index statistics
12
12
  // doctor Diagnose setup
13
13
  import { existsSync } from "node:fs";
14
14
  import { parseArgs } from "node:util";
15
15
  import { openSource, sourceDbPath, getSession, getTranscriptChecked } from "./reader";
16
- import { openIndex, indexDbPath, search, textSearch, stats, isIndexEmpty } from "./store";
16
+ import { openConfiguredIndex, indexDbPath, type IndexStore } from "./store";
17
17
  import { syncAll } from "./indexer";
18
18
  import { embed, embedQuery, getEmbedMode } from "./embed";
19
19
  import { parseDateArg, fmtDate, renderTranscript, formatHits } from "./format";
@@ -37,6 +37,7 @@ function parseCli() {
37
37
  force: { type: "boolean" },
38
38
  indexed: { type: "boolean" },
39
39
  hybrid: { type: "boolean" },
40
+ source: { type: "string" },
40
41
  },
41
42
  allowPositionals: true,
42
43
  strict: true,
@@ -73,16 +74,21 @@ function limitArg(s: string | undefined): number {
73
74
  return Math.min(n, 1000);
74
75
  }
75
76
 
77
+ async function withConfiguredIndex<T>(fn: (index: IndexStore) => Promise<T>): Promise<T> {
78
+ const index = await openConfiguredIndex();
79
+ try { return await fn(index); }
80
+ finally { index.close(); }
81
+ }
82
+
76
83
  async function main() {
77
84
  switch (command) {
78
85
  case "sync": {
79
86
  const source = openSource();
80
- const index = openIndex();
81
- const r = await syncAll(source, index, {
87
+ const r = await withConfiguredIndex((index) => syncAll(source, index, {
82
88
  force: values.force,
83
89
  onProgress: (done, total, title) =>
84
90
  process.stderr.write(`\r[${done}/${total}] ${title.slice(0, 60)} `),
85
- });
91
+ }));
86
92
  process.stderr.write("\n");
87
93
  console.log(
88
94
  `scanned=${r.scanned} indexed=${r.indexed} fresh=${r.skippedFresh} excluded=${r.excluded} empty=${r.empty} pruned=${r.pruned}`
@@ -93,21 +99,22 @@ async function main() {
93
99
  case "search": {
94
100
  const query = positionals.join(" ");
95
101
  if (!query) { console.error("usage: opencode-episodic search <query> [--text p] [--hybrid] [--after d] [--before d] [--limit n]"); process.exit(1); }
96
- const index = openIndex();
97
102
  const opts = {
98
103
  limit: limitArg(values.limit),
99
104
  after: dateArg(values.after),
100
105
  before: dateArg(values.before),
101
106
  };
102
- const hits = values.text
103
- ? textSearch(index, values.text, opts)
104
- : search(
105
- index,
107
+ const { hits, empty } = await withConfiguredIndex(async (index) => {
108
+ const hits = values.text
109
+ ? await index.textSearch(values.text, opts)
110
+ : await index.search(
106
111
  (await embedQuery(query))[0],
107
112
  values.hybrid ? { ...opts, queryText: query, hybrid: true } : opts
108
113
  );
114
+ return { hits, empty: hits.length === 0 && await index.isEmpty() };
115
+ });
109
116
  if (hits.length === 0) {
110
- console.log(isIndexEmpty(index)
117
+ console.log(empty
111
118
  ? "No results. The index is empty — run: bun run src/cli.ts sync"
112
119
  : "No results.");
113
120
  } else {
@@ -119,12 +126,13 @@ async function main() {
119
126
 
120
127
  case "read": {
121
128
  const id = positionals[0];
122
- if (!id) { console.error("usage: opencode-episodic read <session-id> [--indexed]"); process.exit(1); }
129
+ if (!id) { console.error("usage: opencode-episodic read <session-id> [--indexed --source source-id]"); process.exit(1); }
123
130
  if (values.indexed) {
124
- const index = openIndex();
125
- const rows = index
126
- .prepare<{ seq: number; text: string }, [string]>("SELECT seq, text FROM chunks WHERE session_id = ? ORDER BY seq")
127
- .all(id);
131
+ if (process.env.EPISODIC_INDEX_URL && !values.source) {
132
+ console.error("error: --source is required for remote indexed reads.");
133
+ process.exit(1);
134
+ }
135
+ const rows = await withConfiguredIndex((index) => index.readIndexed(id, values.source));
128
136
  if (rows.length === 0) { console.error("no indexed content for", id); process.exit(1); }
129
137
  for (const r of rows) console.log(r.text, "\n---");
130
138
  break;
@@ -144,8 +152,7 @@ async function main() {
144
152
  }
145
153
 
146
154
  case "stats": {
147
- const index = openIndex();
148
- const s = stats(index);
155
+ const s = await withConfiguredIndex((index) => index.stats());
149
156
  console.log(`sessions: ${s.sessions} (${s.excluded} excluded/empty), chunks: ${s.chunks}`);
150
157
  if (s.oldest) console.log(`range: ${fmtDate(Number(s.oldest))} → ${fmtDate(Number(s.newest))}`);
151
158
  console.log("\nTop directories:");
@@ -194,8 +201,8 @@ async function main() {
194
201
  console.log(`✓ source readable: ${n} sessions`);
195
202
  } catch (e) { console.error(`✗ source unreadable: ${e}`); ok = false; }
196
203
  try {
197
- const idx = openIndex();
198
- console.log(`✓ index writable: ${indexDbPath()}`);
204
+ const idx = await openConfiguredIndex();
205
+ console.log(`✓ index writable: ${idx.remote ? "remote index" : indexDbPath()}`);
199
206
  idx.close();
200
207
  } catch (e) { console.error(`✗ index not writable: ${e}`); ok = false; }
201
208
  try {