opencode-episodic-memory 0.1.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -19
- package/package.json +2 -1
- package/plugin/episodic-memory.ts +96 -43
- package/skills/remembering-conversations/SKILL.md +13 -8
- package/src/cli.ts +56 -21
- package/src/embed-inline.ts +28 -0
- package/src/embed-sidecar.mjs +110 -0
- package/src/embed.ts +287 -34
- package/src/format.ts +49 -1
- package/src/indexer.ts +38 -25
- package/src/parser.ts +3 -2
- package/src/reader.ts +168 -16
- package/src/store.ts +397 -6
package/README.md
CHANGED
|
@@ -18,9 +18,9 @@ of the OpenCode memory-plugin landscape? See
|
|
|
18
18
|
|
|
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
|
-
3. **Embed** — local, offline embeddings via Transformers.js (`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` / `
|
|
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` 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.
|
|
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,11 +48,17 @@ Or edit `~/.config/opencode/opencode.json` manually:
|
|
|
48
48
|
|
|
49
49
|
```jsonc
|
|
50
50
|
{
|
|
51
|
-
"plugin": ["opencode-episodic-memory@0.
|
|
51
|
+
"plugin": ["opencode-episodic-memory@0.3.0"]
|
|
52
52
|
}
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
Default sidecar-mode semantic indexing and vector/hybrid search require a system
|
|
56
|
+
**Node 20+** binary (`node` by default). The first embedding run downloads the
|
|
57
|
+
model (~100 MB, cached afterward). The model and its native runtime live in
|
|
58
|
+
that Node sidecar, not inside OpenCode's Bun/TUI process. Explicit
|
|
59
|
+
`EPISODIC_EMBED_MODE=inline` works without Node but is unsafe in affected
|
|
60
|
+
OpenCode/Bun versions. `episodic_read_window`, `episodic_read_session`, and lexical text search also remain
|
|
61
|
+
available without Node.
|
|
56
62
|
|
|
57
63
|
Install the skill so the agent knows when to search, via the
|
|
58
64
|
[`skills` CLI](https://github.com/vercel-labs/skills):
|
|
@@ -69,13 +75,13 @@ OpenCode has downloaded the plugin (i.e. after first launch), copy it out of
|
|
|
69
75
|
the package cache (the path contains your pinned version):
|
|
70
76
|
|
|
71
77
|
```bash
|
|
72
|
-
cp -r ~/.cache/opencode/packages/opencode-episodic-memory@0.
|
|
78
|
+
cp -r ~/.cache/opencode/packages/opencode-episodic-memory@0.3.0/node_modules/opencode-episodic-memory/skills/remembering-conversations ~/.config/opencode/skills/
|
|
73
79
|
```
|
|
74
80
|
|
|
75
81
|
Then backfill existing history and restart OpenCode:
|
|
76
82
|
|
|
77
83
|
```bash
|
|
78
|
-
bunx opencode-episodic-memory@0.
|
|
84
|
+
bunx opencode-episodic-memory@0.3.0 sync
|
|
79
85
|
```
|
|
80
86
|
|
|
81
87
|
## CLI
|
|
@@ -84,15 +90,16 @@ The package ships an `opencode-episodic` binary (requires `bun` on PATH).
|
|
|
84
90
|
Invoke it through the package spec — pin it to match your plugin version:
|
|
85
91
|
|
|
86
92
|
```bash
|
|
87
|
-
bunx opencode-episodic-memory@0.
|
|
88
|
-
bunx opencode-episodic-memory@0.
|
|
89
|
-
bunx opencode-episodic-memory@0.
|
|
90
|
-
bunx opencode-episodic-memory@0.
|
|
91
|
-
bunx opencode-episodic-memory@0.
|
|
92
|
-
bunx opencode-episodic-memory@0.
|
|
93
|
-
bunx opencode-episodic-memory@0.
|
|
94
|
-
|
|
95
|
-
bunx opencode-episodic-memory@0.
|
|
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
|
|
96
103
|
```
|
|
97
104
|
|
|
98
105
|
`--after`/`--before` take `YYYY-MM-DD` (midnight UTC). `--after D` is inclusive
|
|
@@ -100,8 +107,9 @@ of day D; `--before D` is exclusive of day D (i.e. up to the start of that day).
|
|
|
100
107
|
|
|
101
108
|
## Agent tools
|
|
102
109
|
|
|
103
|
-
- **`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
|
|
104
|
-
- **`
|
|
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.
|
|
105
113
|
|
|
106
114
|
## Excluding conversations
|
|
107
115
|
|
|
@@ -122,7 +130,60 @@ instruction-tag match — the intent is the same, but our matching is literal.
|
|
|
122
130
|
|---|---|---|
|
|
123
131
|
| `EPISODIC_SOURCE_DB` | `~/.local/share/opencode/opencode.db` | OpenCode session store |
|
|
124
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 |
|
|
125
136
|
| `EPISODIC_EMBED_MODEL` | `Snowflake/snowflake-arctic-embed-m-v1.5` | Transformers.js embedding model |
|
|
137
|
+
| `EPISODIC_EMBED_MODE` | `sidecar` | `sidecar` runs embeddings in Node; `inline` is an explicit escape hatch |
|
|
138
|
+
| `EPISODIC_NODE_BINARY` | `node` | Node 20+ executable used by sidecar mode |
|
|
139
|
+
| `EPISODIC_EMBED_BATCH_SIZE` | `32` | Texts per sidecar request (1-64) |
|
|
140
|
+
| `EPISODIC_EMBED_READY_TIMEOUT_MS` | `600000` | Maximum wait for sidecar/model startup |
|
|
141
|
+
| `EPISODIC_EMBED_REQUEST_TIMEOUT_MS` | `120000` | Maximum wait for a post-startup embedding request |
|
|
142
|
+
|
|
143
|
+
`EPISODIC_EMBED_MODE=inline` loads Transformers.js native addons directly in
|
|
144
|
+
OpenCode's embedded Bun process. It exists only as an explicit compatibility
|
|
145
|
+
escape hatch and is unsafe with affected OpenCode/Bun releases that can crash
|
|
146
|
+
during native-addon teardown. It is never selected automatically if sidecar
|
|
147
|
+
startup fails. Run `bun run src/cli.ts doctor` to diagnose the selected mode,
|
|
148
|
+
Node version, and a real embedding.
|
|
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.
|
|
126
187
|
|
|
127
188
|
## Not yet implemented (deliberate)
|
|
128
189
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-episodic-memory",
|
|
3
|
-
"version": "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,
|
|
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 } 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))
|
|
27
|
+
if (inflight.has(key)) {
|
|
28
|
+
pending.add(key);
|
|
29
|
+
return inflight.get(key)!;
|
|
30
|
+
}
|
|
22
31
|
const p = (async () => {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
if (
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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,30 +88,74 @@ export const EpisodicMemory: Plugin = async ({ client }) => {
|
|
|
77
88
|
before: before.ms,
|
|
78
89
|
text: args.text,
|
|
79
90
|
};
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if (isIndexEmpty(index)) return "No matching past conversations found. The index is empty — run `bun run src/cli.ts sync` to index conversations.";
|
|
88
|
-
return "No matching past conversations found.";
|
|
91
|
+
const noHits = async () => await index.isEmpty()
|
|
92
|
+
? "No matching past conversations found. The index is empty — run `bun run src/cli.ts sync` to index conversations."
|
|
93
|
+
: "No matching past conversations found.";
|
|
94
|
+
if (args.mode === "text") {
|
|
95
|
+
const hits = await index.textSearch(args.query, opts);
|
|
96
|
+
if (hits.length === 0) return noHits();
|
|
97
|
+
return formatHits(hits, 400, "score");
|
|
89
98
|
}
|
|
99
|
+
let vector: Float32Array;
|
|
100
|
+
try {
|
|
101
|
+
vector = (await embedQuery(args.query))[0];
|
|
102
|
+
} catch (e) {
|
|
103
|
+
await log("warn", `episodic_search embedding failed: ${e instanceof Error ? e.message : e}`);
|
|
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.';
|
|
107
|
+
}
|
|
108
|
+
const hits = args.mode === "hybrid"
|
|
109
|
+
? await index.search(vector, { ...opts, queryText: args.query, hybrid: true })
|
|
110
|
+
: await index.search(vector, opts);
|
|
111
|
+
if (hits.length === 0) return noHits();
|
|
90
112
|
// Hybrid hits carry RRF scores (~0.03), not cosine — label them "rrf".
|
|
91
113
|
return formatHits(hits, 400, args.mode === "hybrid" ? "rrf" : "score");
|
|
92
114
|
},
|
|
93
115
|
}),
|
|
94
116
|
|
|
95
|
-
|
|
117
|
+
episodic_read_window: tool({
|
|
96
118
|
description:
|
|
97
|
-
"Read
|
|
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({
|
|
145
|
+
description:
|
|
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.",
|
|
98
147
|
args: {
|
|
99
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"),
|
|
100
150
|
indexed: tool.schema.boolean().optional().describe("Force reading from the index instead of the live session store"),
|
|
101
151
|
},
|
|
102
152
|
async execute(args) {
|
|
103
|
-
|
|
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) {
|
|
104
159
|
try {
|
|
105
160
|
const source = openSource();
|
|
106
161
|
const s = getSession(source, args.session_id);
|
|
@@ -116,14 +171,12 @@ export const EpisodicMemory: Plugin = async ({ client }) => {
|
|
|
116
171
|
} catch (e) {
|
|
117
172
|
// Log before falling through — a bare swallow would also hide
|
|
118
173
|
// structural Zod drift, which is meant to be loud.
|
|
119
|
-
await log("warn", `
|
|
174
|
+
await log("warn", `episodic_read_session live-store read failed for ${args.session_id}: ${e}`);
|
|
120
175
|
// fall through to indexed copy
|
|
121
176
|
}
|
|
122
177
|
}
|
|
123
|
-
const index =
|
|
124
|
-
const rows = index
|
|
125
|
-
.prepare<{ text: string }, [string]>("SELECT text FROM chunks WHERE session_id = ? ORDER BY seq")
|
|
126
|
-
.all(args.session_id);
|
|
178
|
+
const index = await getIndex();
|
|
179
|
+
const rows = await index.readIndexed(args.session_id, args.source_id);
|
|
127
180
|
if (rows.length === 0) return `No conversation found for session ${args.session_id}.`;
|
|
128
181
|
return `(indexed excerpts — live session unavailable)\n\n${rows.map((r) => r.text).join("\n\n---\n\n")}`.slice(0, 50000);
|
|
129
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
|
|
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
|
|
|
@@ -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).
|
|
33
|
-
NOT calibrated probabilities: ≥ ~0.55 is a strong
|
|
34
|
-
relevant, < ~0.35 is weak or merely adjacent
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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,15 +7,15 @@
|
|
|
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
|
-
import { embed, embedQuery } from "./embed";
|
|
18
|
+
import { embed, embedQuery, getEmbedMode } from "./embed";
|
|
19
19
|
import { parseDateArg, fmtDate, renderTranscript, formatHits } from "./format";
|
|
20
20
|
|
|
21
21
|
const [, , command, ...rest] = process.argv;
|
|
@@ -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:");
|
|
@@ -157,6 +164,34 @@ async function main() {
|
|
|
157
164
|
|
|
158
165
|
case "doctor": {
|
|
159
166
|
let ok = true;
|
|
167
|
+
let mode: "sidecar" | "inline";
|
|
168
|
+
try {
|
|
169
|
+
mode = getEmbedMode();
|
|
170
|
+
console.log(`✓ embedding mode: ${mode}`);
|
|
171
|
+
} catch (e) {
|
|
172
|
+
console.error(`✗ embedding mode: ${e}`);
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
if (mode === "sidecar") {
|
|
176
|
+
const nodeBinary = process.env.EPISODIC_NODE_BINARY ?? "node";
|
|
177
|
+
try {
|
|
178
|
+
const node = Bun.spawnSync([nodeBinary, "--version"], { stdout: "pipe", stderr: "pipe" });
|
|
179
|
+
const version = new TextDecoder().decode(node.stdout).trim();
|
|
180
|
+
const match = /^v(\d+)\./.exec(version);
|
|
181
|
+
if (!node.success || !match || Number(match[1]) < 20) {
|
|
182
|
+
const detail = new TextDecoder().decode(node.stderr).trim();
|
|
183
|
+
console.error(`✗ Node 20+ required for sidecar mode (${JSON.stringify(nodeBinary)} ${version || detail || "not found"}). Set EPISODIC_NODE_BINARY to a Node 20+ executable.`);
|
|
184
|
+
ok = false;
|
|
185
|
+
} else {
|
|
186
|
+
console.log(`✓ sidecar Node: ${nodeBinary} ${version}`);
|
|
187
|
+
}
|
|
188
|
+
} catch (e) {
|
|
189
|
+
console.error(`✗ Node 20+ required for sidecar mode (${JSON.stringify(nodeBinary)} could not start: ${e}). Set EPISODIC_NODE_BINARY to a Node 20+ executable.`);
|
|
190
|
+
ok = false;
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
console.warn("! inline embedding mode loads native ML addons into Bun; it is unsafe on affected OpenCode/Bun versions. Prefer sidecar mode.");
|
|
194
|
+
}
|
|
160
195
|
const src = sourceDbPath();
|
|
161
196
|
if (existsSync(src)) console.log(`✓ source DB: ${src}`);
|
|
162
197
|
else { console.error(`✗ source DB missing: ${src}`); ok = false; }
|
|
@@ -166,8 +201,8 @@ async function main() {
|
|
|
166
201
|
console.log(`✓ source readable: ${n} sessions`);
|
|
167
202
|
} catch (e) { console.error(`✗ source unreadable: ${e}`); ok = false; }
|
|
168
203
|
try {
|
|
169
|
-
const idx =
|
|
170
|
-
console.log(`✓ index writable: ${indexDbPath()}`);
|
|
204
|
+
const idx = await openConfiguredIndex();
|
|
205
|
+
console.log(`✓ index writable: ${idx.remote ? "remote index" : indexDbPath()}`);
|
|
171
206
|
idx.close();
|
|
172
207
|
} catch (e) { console.error(`✗ index not writable: ${e}`); ok = false; }
|
|
173
208
|
try {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Explicit escape hatch for hosts where loading native ML addons in Bun is safe.
|
|
2
|
+
// Keep this module dynamically imported by embed.ts so the normal plugin import
|
|
3
|
+
// path cannot load Transformers.js.
|
|
4
|
+
import type { FeatureExtractionPipeline } from "@huggingface/transformers";
|
|
5
|
+
|
|
6
|
+
import { DEFAULT_MODEL } from "./embed.ts";
|
|
7
|
+
|
|
8
|
+
let cached: Promise<FeatureExtractionPipeline> | null = null;
|
|
9
|
+
|
|
10
|
+
async function getEmbedder(): Promise<FeatureExtractionPipeline> {
|
|
11
|
+
if (!cached) {
|
|
12
|
+
cached = import("@huggingface/transformers")
|
|
13
|
+
.then(({ pipeline }) => pipeline("feature-extraction", process.env.EPISODIC_EMBED_MODEL ?? DEFAULT_MODEL, { dtype: "q8" }) as Promise<FeatureExtractionPipeline>);
|
|
14
|
+
// A rejected promise (for example, a failed model download) must not poison
|
|
15
|
+
// the cache for the lifetime of the process.
|
|
16
|
+
cached.catch(() => { cached = null; });
|
|
17
|
+
}
|
|
18
|
+
return cached;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function embedInline(texts: string[]): Promise<Float32Array[]> {
|
|
22
|
+
const embedder = await getEmbedder();
|
|
23
|
+
const output = await embedder(texts, { pooling: "cls", normalize: true });
|
|
24
|
+
const dimensions: number = output.dims[output.dims.length - 1];
|
|
25
|
+
// A normalized feature-extraction tensor is Float32Array at runtime.
|
|
26
|
+
const flat = new Float32Array(output.data as Float32Array);
|
|
27
|
+
return texts.map((_, index) => flat.subarray(index * dimensions, (index + 1) * dimensions));
|
|
28
|
+
}
|