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 +65 -18
- package/package.json +2 -1
- package/plugin/episodic-memory.ts +117 -52
- package/skills/remembering-conversations/SKILL.md +22 -11
- package/src/cli.ts +27 -20
- package/src/format.ts +64 -2
- package/src/indexer.ts +38 -25
- package/src/parser.ts +3 -2
- package/src/reader.ts +168 -16
- package/src/store.ts +462 -6
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
|
|
23
|
-
5. **Recall** — native plugin tools `episodic_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
|
|
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
|
|
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. `
|
|
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
|
|
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
|
|
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
|
|
94
|
-
bunx opencode-episodic-memory@0.1
|
|
95
|
-
bunx opencode-episodic-memory@0.1
|
|
96
|
-
bunx opencode-episodic-memory@0.1
|
|
97
|
-
bunx opencode-episodic-memory@0.1
|
|
98
|
-
bunx opencode-episodic-memory@0.1
|
|
99
|
-
bunx opencode-episodic-memory@0.1
|
|
100
|
-
|
|
101
|
-
bunx opencode-episodic-memory@0.1
|
|
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
|
|
110
|
-
- **`
|
|
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.
|
|
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,
|
|
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 {
|
|
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))
|
|
28
|
+
if (inflight.has(key)) {
|
|
29
|
+
pending.add(key);
|
|
30
|
+
return inflight.get(key)!;
|
|
31
|
+
}
|
|
22
32
|
const p = (async () => {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
if (
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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 = () =>
|
|
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(
|
|
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
|
|
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(
|
|
97
|
-
: search(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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 =
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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(
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
.
|
|
127
|
-
|
|
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
|
|
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 =
|
|
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 {
|