opencode-episodic-memory 0.2.0 → 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/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.0 -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.0"]
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.0/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.0 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.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
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
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. 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.
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,44 @@ 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. 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`.
181
+
182
+ Network failures are surfaced by the CLI and doctor; plugin background reindex
183
+ logs failures and never silently falls back to a local index (which would split
184
+ history). To return to local-only mode, unset `EPISODIC_INDEX_URL`,
185
+ `EPISODIC_INDEX_AUTH_TOKEN`, and `EPISODIC_SOURCE_ID`; the existing local index
186
+ is selected unchanged. Re-run `sync` if the local index needs rebuilding.
187
+
145
188
  ## Not yet implemented (deliberate)
146
189
 
147
190
  - 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.0",
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,58 @@
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 } 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 } from "../src/format";
10
10
 
11
11
  export const EpisodicMemory: Plugin = async ({ client }) => {
12
12
  const log = (level: "info" | "warn" | "error", message: string) =>
13
13
  client.app
14
14
  .log({ body: { service: "episodic-memory", level, message } })
15
15
  .catch(() => {});
16
+ let configuredIndex: Promise<IndexStore> | undefined;
17
+ const getIndex = () => configuredIndex ??= openConfiguredIndex().catch((error) => {
18
+ configuredIndex = undefined;
19
+ throw error;
20
+ });
16
21
 
17
22
  // Debounce concurrent reindex runs for the same session.
18
23
  const inflight = new Map<string, Promise<void>>();
24
+ const pending = new Set<string>();
19
25
  function reindex(sessionId?: string) {
20
26
  const key = sessionId ?? "__all__";
21
- if (inflight.has(key)) return inflight.get(key)!;
27
+ if (inflight.has(key)) {
28
+ pending.add(key);
29
+ return inflight.get(key)!;
30
+ }
22
31
  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
32
+ do {
33
+ pending.delete(key);
34
+ try {
35
+ const source = openSource();
36
+ const index = await getIndex();
37
+ if (sessionId) {
38
+ const s = getSession(source, sessionId);
39
+ if (s) await syncSession(source, index, s);
40
+ // Cheap (two small SELECTs + rare DELETEs), so prune on every idle:
41
+ // the syncAll path below effectively never fires (session.idle always
42
+ // carries a sessionID), and without this, deleted conversations would
43
+ // linger in the index — searchable and readable — for plugin-only users.
44
+ await pruneOrphans(source, index);
45
+ } else {
46
+ await syncAll(source, index); // syncAll prunes source-deleted orphans
47
+ }
48
+ await log("info", `reindexed ${key}`);
49
+ } catch (e) {
50
+ await log("warn", `reindex failed for ${key}: ${e}`);
36
51
  }
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
- }
52
+ } while (pending.has(key));
43
53
  })();
44
54
  inflight.set(key, p);
55
+ p.finally(() => inflight.delete(key));
45
56
  return p;
46
57
  }
47
58
 
@@ -56,7 +67,7 @@ export const EpisodicMemory: Plugin = async ({ client }) => {
56
67
  tool: {
57
68
  episodic_search: tool({
58
69
  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.",
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.",
60
71
  args: {
61
72
  query: tool.schema.string().describe("Natural-language description of what you're looking for"),
62
73
  text: tool.schema.string().optional().describe("Exact substring to require in results (ANDed with semantic ranking)"),
@@ -66,7 +77,7 @@ export const EpisodicMemory: Plugin = async ({ client }) => {
66
77
  limit: tool.schema.number().optional().describe("Max results, 1-50 (default 10)"),
67
78
  },
68
79
  async execute(args) {
69
- const index = openIndex();
80
+ const index = await getIndex();
70
81
  const after = parseDateArg(args.after);
71
82
  if (!after.ok) return after.error;
72
83
  const before = parseDateArg(args.before);
@@ -77,11 +88,11 @@ export const EpisodicMemory: Plugin = async ({ client }) => {
77
88
  before: before.ms,
78
89
  text: args.text,
79
90
  };
80
- const noHits = () => isIndexEmpty(index)
91
+ const noHits = async () => await index.isEmpty()
81
92
  ? "No matching past conversations found. The index is empty — run `bun run src/cli.ts sync` to index conversations."
82
93
  : "No matching past conversations found.";
83
94
  if (args.mode === "text") {
84
- const hits = textSearch(index, args.query, opts);
95
+ const hits = await index.textSearch(args.query, opts);
85
96
  if (hits.length === 0) return noHits();
86
97
  return formatHits(hits, 400, "score");
87
98
  }
@@ -90,26 +101,61 @@ export const EpisodicMemory: Plugin = async ({ client }) => {
90
101
  vector = (await embedQuery(args.query))[0];
91
102
  } catch (e) {
92
103
  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.';
104
+ return index.remote
105
+ ? "Semantic search unavailable: the embedding backend failed. Remote indexes support vector search only; run `bun run src/cli.ts doctor` for details."
106
+ : '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
107
  }
95
108
  const hits = args.mode === "hybrid"
96
- ? search(index, vector, { ...opts, queryText: args.query, hybrid: true })
97
- : search(index, vector, opts);
109
+ ? await index.search(vector, { ...opts, queryText: args.query, hybrid: true })
110
+ : await index.search(vector, opts);
98
111
  if (hits.length === 0) return noHits();
99
112
  // Hybrid hits carry RRF scores (~0.03), not cosine — label them "rrf".
100
113
  return formatHits(hits, 400, args.mode === "hybrid" ? "rrf" : "score");
101
114
  },
102
115
  }),
103
116
 
104
- episodic_read: tool({
117
+ episodic_read_window: tool({
118
+ 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.",
120
+ args: {
121
+ session_id: tool.schema.string().describe("Session ID from episodic_search, e.g. ses_..."),
122
+ source_id: tool.schema.string().optional().describe("Source ID shown by remote episodic_search results; required for remote indexes"),
123
+ 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)"),
126
+ },
127
+ async execute(args) {
128
+ 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.");
131
+ }
132
+ const source = openSource();
133
+ const context = getTranscriptContext(source, args.session_id, args.anchor_message_id, args.before, args.after);
134
+ if (!context.ok) {
135
+ if (context.reason === "unknown_session") throw new Error(`No live conversation found for session ${args.session_id}.`);
136
+ if (context.reason === "excluded") throw new Error("Session is marked private (exclusion marker present); context withheld.");
137
+ if (context.reason === "invalid_anchor") throw new Error(`Anchor message ${args.anchor_message_id} is stale or invalid for session ${args.session_id}.`);
138
+ throw new Error("before and after must be non-negative integers no greater than 20.");
139
+ }
140
+ return renderTranscriptContext(context.session, context);
141
+ },
142
+ }),
143
+
144
+ episodic_read_session: tool({
105
145
  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.",
146
+ "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
147
  args: {
108
148
  session_id: tool.schema.string().describe("Session ID, e.g. ses_..."),
149
+ source_id: tool.schema.string().optional().describe("Source ID shown by remote episodic_search results"),
109
150
  indexed: tool.schema.boolean().optional().describe("Force reading from the index instead of the live session store"),
110
151
  },
111
152
  async execute(args) {
112
- if (!args.indexed) {
153
+ const remote = remoteIndexConfig();
154
+ if (remote && args.source_id === undefined) {
155
+ throw new Error("source_id is required for episodic_read_session with a remote index.");
156
+ }
157
+ const foreign = !canLiveRead(remote, args.source_id);
158
+ if (!args.indexed && !foreign) {
113
159
  try {
114
160
  const source = openSource();
115
161
  const s = getSession(source, args.session_id);
@@ -125,14 +171,12 @@ export const EpisodicMemory: Plugin = async ({ client }) => {
125
171
  } catch (e) {
126
172
  // Log before falling through — a bare swallow would also hide
127
173
  // structural Zod drift, which is meant to be loud.
128
- await log("warn", `episodic_read live-store read failed for ${args.session_id}: ${e}`);
174
+ await log("warn", `episodic_read_session live-store read failed for ${args.session_id}: ${e}`);
129
175
  // fall through to indexed copy
130
176
  }
131
177
  }
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);
178
+ const index = await getIndex();
179
+ const rows = await index.readIndexed(args.session_id, args.source_id);
136
180
  if (rows.length === 0) return `No conversation found for session ${args.session_id}.`;
137
181
  return `(indexed excerpts — live session unavailable)\n\n${rows.map((r) => r.text).join("\n\n---\n\n")}`.slice(0, 50000);
138
182
  },
@@ -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
 
@@ -29,12 +29,17 @@ conversation — the index is for cross-session recall, not code search.
29
29
  (an error string, a flag name, a file path).
30
30
  - `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
+ 2. Skim the returned excerpts (date, session title, score, and anchor). Vector
33
+ similarity scores are NOT calibrated probabilities: ≥ ~0.55 is a strong
34
+ match, 0.4–0.55 is likely relevant, and < ~0.35 is weak or merely adjacent.
35
+ These thresholds apply only to vector results. For hybrid results, use the
36
+ snippet and the `rrf` label instead; RRF scores are on a different scale.
37
+ 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.
38
43
 
39
44
  ## Answering
40
45
 
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 {
package/src/format.ts CHANGED
@@ -3,6 +3,12 @@
3
3
  // here so the two front-ends can't drift apart.
4
4
  import type { SourceMessage } from "./reader";
5
5
  import type { SearchHit } from "./store";
6
+ import { Buffer } from "node:buffer";
7
+
8
+ const MAX_CONTEXT_BODY_BYTES = 600;
9
+ const MAX_CONTEXT_TOOLS_BYTES = 200;
10
+ const MAX_CONTEXT_FIELD_BYTES = 100;
11
+ const MAX_CONTEXT_SESSION_FIELD_BYTES = 300;
6
12
 
7
13
  // Discriminated result so callers handle the parse error explicitly (no cast to
8
14
  // strip the error arm off a union). `ms` is undefined when no date was given.
@@ -55,6 +61,46 @@ export function renderTranscript(
55
61
  return lines.join("\n");
56
62
  }
57
63
 
64
+ // Render a bounded live-source window around a search-hit anchor. The helper in
65
+ // reader.ts has already applied the privacy gate and validated the anchor.
66
+ export function renderTranscriptContext(
67
+ meta: { title: string; time_created: number; directory: string; id: string },
68
+ context: { messages: SourceMessage[]; anchorIndex: number; sliceStart: number; total: number }
69
+ ): string {
70
+ const lines = [
71
+ `# ${truncateContext(meta.title, MAX_CONTEXT_SESSION_FIELD_BYTES)}`,
72
+ `${fmtDate(meta.time_created)} — ${truncateContext(meta.directory, MAX_CONTEXT_SESSION_FIELD_BYTES)} — ${truncateContext(meta.id, MAX_CONTEXT_FIELD_BYTES)}`,
73
+ `Context around message ${context.anchorIndex + 1}/${context.total}`,
74
+ "",
75
+ ];
76
+ for (const [offset, message] of context.messages.entries()) {
77
+ const position = context.sliceStart + offset;
78
+ const text = message.parts.filter((part) => part.type === "text" && part.text).map((part) => part.text).join("\n");
79
+ const tools = message.parts.filter((part) => part.type === "tool" && part.tool).map((part) => part.tool);
80
+ lines.push(`## ${truncateContext(message.role, MAX_CONTEXT_FIELD_BYTES)} — ${truncateContext(message.id, MAX_CONTEXT_FIELD_BYTES)} — ${position + 1}/${context.total}${position === context.anchorIndex ? " (anchor)" : ""}`);
81
+ if (text) lines.push(truncateContext(text, MAX_CONTEXT_BODY_BYTES));
82
+ if (tools.length) lines.push(truncateContext(`*(tools: ${tools.join(", ")})*`, MAX_CONTEXT_TOOLS_BYTES));
83
+ if (message.contextPartsOmitted) lines.push(`*(${message.contextPartsOmitted} parts omitted from bounded context)*`);
84
+ lines.push("");
85
+ }
86
+ return lines.join("\n");
87
+ }
88
+
89
+ function truncateContext(value: string, byteLimit: number): string {
90
+ if (Buffer.byteLength(value, "utf8") <= byteLimit) return value;
91
+ const suffix = "... [truncated]";
92
+ const contentBudget = byteLimit - Buffer.byteLength(suffix, "utf8");
93
+ let bytes = 0;
94
+ let result = "";
95
+ for (const codePoint of value) {
96
+ const codePointBytes = Buffer.byteLength(codePoint, "utf8");
97
+ if (bytes + codePointBytes > contentBudget) break;
98
+ result += codePoint;
99
+ bytes += codePointBytes;
100
+ }
101
+ return result + suffix;
102
+ }
103
+
58
104
  // One search hit as a markdown block. snippetLength defaults to 400 (plugin
59
105
  // tool output); the CLI passes 220 to keep terminal output brief. scoreLabel
60
106
  // names the score field: "score" for vector (cosine ~0.4–0.7) and BM25, "rrf"
@@ -62,7 +108,9 @@ export function renderTranscript(
62
108
  // AGENTS.md) so the number isn't misread against the cosine thresholds.
63
109
  export function formatHit(h: SearchHit, snippetLength = 400, scoreLabel = "score"): string {
64
110
  const snippet = h.text.replace(/\s+/g, " ").slice(0, snippetLength);
65
- return `## ${fmtDate(h.time_created)} ${h.title}\nsession: ${h.session_id} ${scoreLabel}: ${h.score.toFixed(3)}\n${h.directory}\n> ${snippet}`;
111
+ const anchor = h.anchor_message_id ?? "unavailable (refresh/reindex required)";
112
+ const source = h.source_id ? `source: ${h.source_id}\n` : "";
113
+ return `## ${fmtDate(h.time_created)} — ${h.title}\n${source}session: ${h.session_id} ${scoreLabel}: ${h.score.toFixed(3)}\nanchor: ${anchor}\n${h.directory}\n> ${snippet}`;
66
114
  }
67
115
 
68
116
  export function formatHits(hits: SearchHit[], snippetLength = 400, scoreLabel = "score"): string {