opencode-episodic-memory 0.1.2 → 0.2.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
@@ -1,5 +1,7 @@
1
1
  # opencode-episodic-memory
2
2
 
3
+ [![skills.sh](https://skills.sh/b/robertn702/opencode-episodic-memory)](https://skills.sh/robertn702/opencode-episodic-memory)
4
+
3
5
  Semantic search over your past [OpenCode](https://opencode.ai) conversations.
4
6
  Remember past discussions, decisions, and patterns across sessions.
5
7
 
@@ -8,67 +10,95 @@ rebuilt for OpenCode primitives — native plugin tools instead of an MCP server
8
10
  plugin events instead of hooks, and OpenCode's own session database as the
9
11
  source.
10
12
 
13
+ Wondering how this compares to opencode-mem, codemem, memsearch, and the rest
14
+ of the OpenCode memory-plugin landscape? See
15
+ [docs/alternatives.md](docs/alternatives.md).
16
+
11
17
  ## How it works
12
18
 
13
19
  1. **Read** — sessions/messages/parts from OpenCode's `~/.local/share/opencode/opencode.db` (read-only)
14
20
  2. **Parse** — condensed exchanges (user text, assistant text, tool names; no reasoning blobs or tool output)
15
- 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)
16
- 4. **Index** — plain SQLite at `~/.local/share/opencode-episodic-memory/index.db`; brute-force cosine over Float32 blobs
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
17
23
  5. **Recall** — native plugin tools `episodic_search` / `episodic_read`, plus a `remembering-conversations` skill that teaches the agent when to search
18
24
  6. **Stay fresh** — the plugin re-indexes each session on the `session.idle` event
19
25
 
20
26
  Design note: `bun:sqlite` cannot load dynamic extensions, so sqlite-vec is not
21
27
  usable inside OpenCode plugins. Brute-force cosine is single-digit milliseconds
22
28
  at this scale (thousands of chunks) and has zero native-dependency risk. The
23
- store layer is the single swap point if a real ANN index is ever needed.
29
+ store layer is the single swap point if a real ANN index is ever needed. FTS5 is
30
+ compiled into `bun:sqlite` (not a loadable extension), so lexical BM25 ranking
31
+ is available; search is vector-only by default, with lexical and hybrid
32
+ (reciprocal-rank-fusion) modes opt-in — hybrid is off by default because BM25
33
+ tends to match injected boilerplate on this corpus.
24
34
 
25
35
  ## Install
26
36
 
27
37
  ```bash
28
- bun install # first embed downloads the model (~100 MB, cached afterwards)
38
+ opencode plugin opencode-episodic-memory@0.1.3 -g
29
39
  ```
30
40
 
41
+ This adds the plugin to your OpenCode config (`-g` = global config; omit it
42
+ to install for the current project only). **Pin the version** — OpenCode
43
+ caches npm plugins and never re-resolves a bare name / `@latest`
44
+ ([anomalyco/opencode#25293](https://github.com/anomalyco/opencode/issues/25293)).
45
+ To update later, re-run with the new version and `--force`.
46
+
47
+ Or edit `~/.config/opencode/opencode.json` manually:
48
+
31
49
  ```jsonc
32
- // ~/.config/opencode/opencode.json
33
50
  {
34
- "plugin": ["/path/to/opencode-episodic-memory/plugin/episodic-memory.ts"]
51
+ "plugin": ["opencode-episodic-memory@0.1.3"]
35
52
  }
36
53
  ```
37
54
 
38
- Or from npm pin the version. OpenCode caches npm plugins and never
39
- re-resolves a bare name / `@latest`
40
- ([anomalyco/opencode#25293](https://github.com/anomalyco/opencode/issues/25293)),
41
- so to update later you bump the pin:
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` and lexical text search also remain
61
+ available without Node.
42
62
 
43
- ```jsonc
44
- {
45
- "plugin": ["opencode-episodic-memory@0.1.1"]
46
- }
63
+ Install the skill so the agent knows when to search, via the
64
+ [`skills` CLI](https://github.com/vercel-labs/skills):
65
+
66
+ ```bash
67
+ npx skills add robertn702/opencode-episodic-memory -g
47
68
  ```
48
69
 
49
- Copy the skill so the agent knows when to search:
70
+ (`-g` installs to `~/.config/opencode/skills/`; omit it to install into the
71
+ current project. `npx skills update` picks up future skill changes.)
72
+
73
+ Alternatively, copy it manually — it's included in the npm package; once
74
+ OpenCode has downloaded the plugin (i.e. after first launch), copy it out of
75
+ the package cache (the path contains your pinned version):
50
76
 
51
77
  ```bash
52
- cp -r skills/remembering-conversations ~/.config/opencode/skills/
78
+ cp -r ~/.cache/opencode/packages/opencode-episodic-memory@0.1.3/node_modules/opencode-episodic-memory/skills/remembering-conversations ~/.config/opencode/skills/
53
79
  ```
54
80
 
55
81
  Then backfill existing history and restart OpenCode:
56
82
 
57
83
  ```bash
58
- bun run src/cli.ts sync
84
+ bunx opencode-episodic-memory@0.1.3 sync
59
85
  ```
60
86
 
61
87
  ## CLI
62
88
 
89
+ The package ships an `opencode-episodic` binary (requires `bun` on PATH).
90
+ Invoke it through the package spec — pin it to match your plugin version:
91
+
63
92
  ```bash
64
- bun run src/cli.ts sync [--force] # index new/changed sessions
65
- bun run src/cli.ts search "query" # semantic search
66
- bun run src/cli.ts search q --text "exact" # require substring
67
- bun run src/cli.ts search q --after 2026-07-01 --limit 5
68
- bun run src/cli.ts read <session-id> # full transcript (live store)
69
- bun run src/cli.ts read <id> --indexed # indexed excerpts (survives deletion)
70
- bun run src/cli.ts stats # index statistics
71
- bun run src/cli.ts doctor # diagnose setup
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
72
102
  ```
73
103
 
74
104
  `--after`/`--before` take `YYYY-MM-DD` (midnight UTC). `--after D` is inclusive
@@ -76,7 +106,7 @@ of day D; `--before D` is exclusive of day D (i.e. up to the start of that day).
76
106
 
77
107
  ## Agent tools
78
108
 
79
- - **`episodic_search`** — `query` (+ optional `text`, `mode: vector|text`, `after`, `before`, `limit`). Returns dated excerpts with session IDs and similarity scores.
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.
80
110
  - **`episodic_read`** — `session_id` (+ optional `indexed`). Full transcript from the live store, falling back to indexed excerpts.
81
111
 
82
112
  ## Excluding conversations
@@ -99,6 +129,18 @@ instruction-tag match — the intent is the same, but our matching is literal.
99
129
  | `EPISODIC_SOURCE_DB` | `~/.local/share/opencode/opencode.db` | OpenCode session store |
100
130
  | `EPISODIC_INDEX_DB` | `~/.local/share/opencode-episodic-memory/index.db` | Index location |
101
131
  | `EPISODIC_EMBED_MODEL` | `Snowflake/snowflake-arctic-embed-m-v1.5` | Transformers.js embedding model |
132
+ | `EPISODIC_EMBED_MODE` | `sidecar` | `sidecar` runs embeddings in Node; `inline` is an explicit escape hatch |
133
+ | `EPISODIC_NODE_BINARY` | `node` | Node 20+ executable used by sidecar mode |
134
+ | `EPISODIC_EMBED_BATCH_SIZE` | `32` | Texts per sidecar request (1-64) |
135
+ | `EPISODIC_EMBED_READY_TIMEOUT_MS` | `600000` | Maximum wait for sidecar/model startup |
136
+ | `EPISODIC_EMBED_REQUEST_TIMEOUT_MS` | `120000` | Maximum wait for a post-startup embedding request |
137
+
138
+ `EPISODIC_EMBED_MODE=inline` loads Transformers.js native addons directly in
139
+ OpenCode's embedded Bun process. It exists only as an explicit compatibility
140
+ escape hatch and is unsafe with affected OpenCode/Bun releases that can crash
141
+ during native-addon teardown. It is never selected automatically if sidecar
142
+ startup fails. Run `bun run src/cli.ts doctor` to diagnose the selected mode,
143
+ Node version, and a real embedding.
102
144
 
103
145
  ## Not yet implemented (deliberate)
104
146
 
@@ -108,6 +150,28 @@ instruction-tag match — the intent is the same, but our matching is literal.
108
150
  - Multi-concept AND search, MCP server wrapper for non-OpenCode clients
109
151
  - ANN index (see design note above)
110
152
 
153
+ ## Development
154
+
155
+ To hack on the plugin itself, clone the repo and point OpenCode at the local
156
+ entrypoint instead of the npm package:
157
+
158
+ ```bash
159
+ git clone https://github.com/robertn702/opencode-episodic-memory.git
160
+ cd opencode-episodic-memory
161
+ bun install
162
+ ```
163
+
164
+ ```jsonc
165
+ // ~/.config/opencode/opencode.json
166
+ {
167
+ "plugin": ["/path/to/opencode-episodic-memory/plugin/episodic-memory.ts"]
168
+ }
169
+ ```
170
+
171
+ Inside the repo, run the CLI as `bun run src/cli.ts <command>` (same
172
+ subcommands as above), tests with `bun test`, and typechecking with
173
+ `bun run typecheck`.
174
+
111
175
  ## License
112
176
 
113
177
  MIT
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-episodic-memory",
3
- "version": "0.1.2",
4
- "description": "Semantic search over past OpenCode conversations. Port of obra/episodic-memory to OpenCode primitives.",
3
+ "version": "0.2.0",
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",
7
7
  "author": "robertn702",
@@ -25,7 +25,8 @@
25
25
  "files": [
26
26
  "plugin",
27
27
  "src",
28
- "skills"
28
+ "skills",
29
+ "!src/*.test.ts"
29
30
  ],
30
31
  "bin": {
31
32
  "opencode-episodic": "src/cli.ts"
@@ -2,26 +2,11 @@
2
2
  // - Native tools: episodic_search, episodic_read
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, getTranscript, transcriptHasMarker } from "../src/reader";
6
- import { openIndex, search, textSearch } from "../src/store";
5
+ import { openSource, getSession, getTranscriptChecked } from "../src/reader";
6
+ import { openIndex, search, textSearch, isIndexEmpty } from "../src/store";
7
7
  import { syncSession, syncAll, pruneOrphans } from "../src/indexer";
8
8
  import { embedQuery } from "../src/embed";
9
-
10
- // Discriminated result so callers handle the parse error explicitly (no cast to
11
- // strip the error arm off a union). `ms` is undefined when no date was given.
12
- type ParsedDate = { ok: true; ms?: number } | { ok: false; error: string };
13
- const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
14
- // Require strict YYYY-MM-DD, then round-trip to reject impossible calendar dates
15
- // (`new Date("2024-02-31")` silently normalizes to March 2 rather than failing).
16
- const parseDateArg = (s?: string): ParsedDate => {
17
- if (!s) return { ok: true };
18
- const ms = new Date(s).getTime();
19
- if (!DATE_RE.test(s) || Number.isNaN(ms) || new Date(ms).toISOString().slice(0, 10) !== s) {
20
- return { ok: false, error: `Invalid date "${s}" (expected YYYY-MM-DD).` };
21
- }
22
- return { ok: true, ms };
23
- };
24
- const fmtDate = (ms: number) => new Date(ms).toISOString().slice(0, 10);
9
+ import { parseDateArg, formatHits, renderTranscript } from "../src/format";
25
10
 
26
11
  export const EpisodicMemory: Plugin = async ({ client }) => {
27
12
  const log = (level: "info" | "warn" | "error", message: string) =>
@@ -75,7 +60,7 @@ export const EpisodicMemory: Plugin = async ({ client }) => {
75
60
  args: {
76
61
  query: tool.schema.string().describe("Natural-language description of what you're looking for"),
77
62
  text: tool.schema.string().optional().describe("Exact substring to require in results (ANDed with semantic ranking)"),
78
- mode: tool.schema.enum(["vector", "text"]).optional().describe("'vector' (default) semantic search; 'text' exact substring only"),
63
+ mode: tool.schema.enum(["vector", "text", "hybrid"]).optional().describe("'vector' (default) semantic search, scores are cosine (~0.4–0.7); 'text' lexical BM25; 'hybrid' fuses both via RRF (may surface lexical noise) — note hybrid hits carry fused RRF scores (~0.03), a DIFFERENT scale from cosine, so don't judge them against the vector thresholds"),
79
64
  after: tool.schema.string().optional().describe("Only conversations after YYYY-MM-DD"),
80
65
  before: tool.schema.string().optional().describe("Only conversations before YYYY-MM-DD"),
81
66
  limit: tool.schema.number().optional().describe("Max results, 1-50 (default 10)"),
@@ -92,21 +77,27 @@ export const EpisodicMemory: Plugin = async ({ client }) => {
92
77
  before: before.ms,
93
78
  text: args.text,
94
79
  };
95
- const hits =
96
- args.mode === "text"
97
- ? textSearch(index, args.query, opts)
98
- : search(index, (await embedQuery(args.query))[0], opts);
99
- if (hits.length === 0) {
100
- const chunkCount = index.prepare<{ n: number }, []>("SELECT COUNT(*) n FROM chunks").get()?.n ?? 0;
101
- if (chunkCount === 0) return "No matching past conversations found. The index is empty — run `bun run src/cli.ts sync` to index conversations.";
102
- return "No matching past conversations found.";
80
+ const noHits = () => isIndexEmpty(index)
81
+ ? "No matching past conversations found. The index is empty — run `bun run src/cli.ts sync` to index conversations."
82
+ : "No matching past conversations found.";
83
+ if (args.mode === "text") {
84
+ const hits = textSearch(index, args.query, opts);
85
+ if (hits.length === 0) return noHits();
86
+ return formatHits(hits, 400, "score");
103
87
  }
104
- return hits
105
- .map((h) => {
106
- const snippet = h.text.replace(/\s+/g, " ").slice(0, 400);
107
- return `## ${fmtDate(h.time_created)} ${h.title}\nsession: ${h.session_id} score: ${h.score.toFixed(3)}\n${h.directory}\n> ${snippet}`;
108
- })
109
- .join("\n\n");
88
+ let vector: Float32Array;
89
+ try {
90
+ vector = (await embedQuery(args.query))[0];
91
+ } catch (e) {
92
+ 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.';
94
+ }
95
+ const hits = args.mode === "hybrid"
96
+ ? search(index, vector, { ...opts, queryText: args.query, hybrid: true })
97
+ : search(index, vector, opts);
98
+ if (hits.length === 0) return noHits();
99
+ // Hybrid hits carry RRF scores (~0.03), not cosine — label them "rrf".
100
+ return formatHits(hits, 400, args.mode === "hybrid" ? "rrf" : "score");
110
101
  },
111
102
  }),
112
103
 
@@ -123,27 +114,18 @@ export const EpisodicMemory: Plugin = async ({ client }) => {
123
114
  const source = openSource();
124
115
  const s = getSession(source, args.session_id);
125
116
  if (s) {
126
- // Authoritative gate: raw part blobs (a marker in an
127
- // unparseable blob would be invisible to the parsed-text scan).
128
- if (transcriptHasMarker(source, args.session_id)) {
117
+ // Privacy gate lives inside getTranscriptChecked (authoritative
118
+ // raw-blob scan before any read).
119
+ const checked = getTranscriptChecked(source, args.session_id);
120
+ if (checked.excluded) {
129
121
  return "Session is marked private (exclusion marker present); transcript withheld.";
130
122
  }
131
- const transcript = getTranscript(source, args.session_id);
132
- const lines: string[] = [`# ${s.title}`, `${fmtDate(s.time_created)} — ${s.directory} — ${s.id}`, ""];
133
- for (const m of transcript) {
134
- const text = m.parts
135
- .filter((p) => p.type === "text" && p.text)
136
- .map((p) => p.text)
137
- .join("\n");
138
- const tools = m.parts.filter((p) => p.type === "tool" && p.tool).map((p) => p.tool);
139
- if (!text && tools.length === 0) continue;
140
- lines.push(`## ${m.role}`);
141
- if (text) lines.push(text);
142
- if (tools.length) lines.push(`*(tools: ${tools.join(", ")})*`, "");
143
- }
144
- return lines.join("\n").slice(0, 50000);
123
+ return renderTranscript(s, checked.messages).slice(0, 50000);
145
124
  }
146
- } catch {
125
+ } catch (e) {
126
+ // Log before falling through — a bare swallow would also hide
127
+ // structural Zod drift, which is meant to be loud.
128
+ await log("warn", `episodic_read live-store read failed for ${args.session_id}: ${e}`);
147
129
  // fall through to indexed copy
148
130
  }
149
131
  }
@@ -27,7 +27,8 @@ conversation — the index is for cross-session recall, not code search.
27
27
  not exact keywords ("migrating from Claude Code to OpenCode", not "claude opencode").
28
28
  - Narrow with `after`/`before` dates or an exact `text` substring when you know one
29
29
  (an error string, a flag name, a file path).
30
- - `mode: "text"` for exact-phrase lookup only.
30
+ - `mode: "text"` for lexical BM25 search: every query word must appear (token-based
31
+ AND, BM25-ranked) — not phrase/adjacency or substring matching.
31
32
  2. Skim the returned excerpts (date, session title, score). Similarity scores are
32
33
  NOT calibrated probabilities: ≥ ~0.55 is a strong match, 0.4–0.55 is likely
33
34
  relevant, < ~0.35 is weak or merely adjacent — judge by the snippet, not the
package/src/cli.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env bun
2
2
  // opencode-episodic <command> [options]
3
3
  // sync [--force] Index new/changed sessions from opencode.db
4
- // search <query> [options] Semantic search over indexed conversations
5
- // --text "phrase" Exact substring match instead of vector
4
+ // search <query> [options] Semantic (vector) search over indexed conversations
5
+ // --text "terms" Lexical BM25 search for these terms (all AND-matched) instead of vector
6
+ // --hybrid Fuse vector + BM25 (RRF); off by default (see AGENTS.md)
6
7
  // --after YYYY-MM-DD Only conversations after this date
7
8
  // --before YYYY-MM-DD Only conversations before this date
8
9
  // --limit N Max results (default 10)
@@ -10,66 +11,66 @@
10
11
  // stats Index statistics
11
12
  // doctor Diagnose setup
12
13
  import { existsSync } from "node:fs";
13
- import { openSource, sourceDbPath, getSession, getTranscript, transcriptHasMarker } from "./reader";
14
- import { openIndex, indexDbPath, search, textSearch, stats, type SearchHit } from "./store";
14
+ import { parseArgs } from "node:util";
15
+ import { openSource, sourceDbPath, getSession, getTranscriptChecked } from "./reader";
16
+ import { openIndex, indexDbPath, search, textSearch, stats, isIndexEmpty } from "./store";
15
17
  import { syncAll } from "./indexer";
16
- import { embed, embedQuery } from "./embed";
18
+ import { embed, embedQuery, getEmbedMode } from "./embed";
19
+ import { parseDateArg, fmtDate, renderTranscript, formatHits } from "./format";
17
20
 
18
21
  const [, , command, ...rest] = process.argv;
22
+ const USAGE = "commands: sync | search | read | stats | doctor";
19
23
 
20
- function flag(name: string): string | null {
21
- const i = rest.indexOf(`--${name}`);
22
- return i >= 0 ? rest[i + 1] ?? null : null;
23
- }
24
- function hasFlag(name: string): boolean {
25
- return rest.includes(`--${name}`);
26
- }
27
- const VALUE_FLAGS = new Set(["--text", "--after", "--before", "--limit"]);
28
- function positional(): string[] {
29
- const out: string[] = [];
30
- for (let i = 0; i < rest.length; i++) {
31
- const t = rest[i];
32
- if (t.startsWith("--")) {
33
- if (VALUE_FLAGS.has(t)) {
34
- const next = rest[i + 1];
35
- if (next === undefined || next.startsWith("--")) {
36
- console.error(`error: ${t} requires a value`);
37
- process.exit(1);
38
- }
39
- i++; // only value flags consume the next token
40
- }
41
- continue;
42
- }
43
- out.push(t);
44
- }
45
- return out;
46
- }
47
- const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
48
- // Require strict YYYY-MM-DD, then round-trip to reject impossible calendar dates
49
- // (`new Date("2024-02-31")` silently normalizes to March 2 rather than failing).
50
- const dateMs = (s: string | null): number | undefined => {
51
- if (!s) return undefined;
52
- const ms = new Date(s).getTime();
53
- if (!DATE_RE.test(s) || Number.isNaN(ms) || new Date(ms).toISOString().slice(0, 10) !== s) {
54
- console.error(`error: invalid date "${s}" (expected YYYY-MM-DD)`);
24
+ // parseArgs (node:util, supported in Bun) over the tokens after the command.
25
+ // strict:true rejects unknown flags and missing values — we map those throws to
26
+ // the same error+usage+exit(1) the hand-rolled parser used. `search` joins the
27
+ // positionals with spaces as its query.
28
+ function parseCli() {
29
+ try {
30
+ return parseArgs({
31
+ args: rest,
32
+ options: {
33
+ text: { type: "string" },
34
+ after: { type: "string" },
35
+ before: { type: "string" },
36
+ limit: { type: "string" },
37
+ force: { type: "boolean" },
38
+ indexed: { type: "boolean" },
39
+ hybrid: { type: "boolean" },
40
+ },
41
+ allowPositionals: true,
42
+ strict: true,
43
+ });
44
+ } catch (e) {
45
+ console.error(`error: ${e instanceof Error ? e.message : e}`);
46
+ console.error(USAGE);
55
47
  process.exit(1);
56
48
  }
57
- return ms;
58
- };
49
+ }
50
+ const { values, positionals } = parseCli();
59
51
 
60
- function fmtDate(ms: number): string {
61
- return new Date(ms).toISOString().slice(0, 10);
52
+ // Map the shared parseDateArg union onto CLI semantics: print the error and
53
+ // exit non-zero. `values.*` is undefined for an absent flag (→ no date filter).
54
+ function dateArg(s: string | undefined): number | undefined {
55
+ const r = parseDateArg(s);
56
+ if (!r.ok) { console.error(`error: ${r.error}`); process.exit(1); }
57
+ return r.ms;
62
58
  }
63
59
 
64
- function printHits(hits: SearchHit[]): void {
65
- if (hits.length === 0) { console.log("No results."); return; }
66
- for (const h of hits) {
67
- const snippet = h.text.replace(/\s+/g, " ").slice(0, 220);
68
- console.log(`## ${fmtDate(h.time_created)} — ${h.title}`);
69
- console.log(`session: ${h.session_id} score: ${h.score.toFixed(3)}`);
70
- console.log(`${h.directory}`);
71
- console.log(`> ${snippet}\n`);
60
+ // Parse/validate --limit (default 10). Same hard error+exit(1) pattern as an
61
+ // invalid date: without this, Number("abc") → NaN silently yields "No results.",
62
+ // and a negative limit slices from the end of the ranked list. Must be a
63
+ // positive integer; clamped to 1000 (a CLI sanity ceiling — plenty for a human
64
+ // debugging session).
65
+ function limitArg(s: string | undefined): number {
66
+ if (s === undefined) return 10;
67
+ const n = Number.parseInt(s, 10);
68
+ if (!Number.isFinite(n) || n < 1) {
69
+ console.error(`error: invalid --limit "${s}" (expected a positive integer).`);
70
+ console.error(USAGE);
71
+ process.exit(1);
72
72
  }
73
+ return Math.min(n, 1000);
73
74
  }
74
75
 
75
76
  async function main() {
@@ -78,7 +79,7 @@ async function main() {
78
79
  const source = openSource();
79
80
  const index = openIndex();
80
81
  const r = await syncAll(source, index, {
81
- force: hasFlag("force"),
82
+ force: values.force,
82
83
  onProgress: (done, total, title) =>
83
84
  process.stderr.write(`\r[${done}/${total}] ${title.slice(0, 60)} `),
84
85
  });
@@ -90,30 +91,36 @@ async function main() {
90
91
  }
91
92
 
92
93
  case "search": {
93
- const query = positional().join(" ");
94
- if (!query) { console.error("usage: opencode-episodic search <query> [--text p] [--after d] [--before d] [--limit n]"); process.exit(1); }
94
+ const query = positionals.join(" ");
95
+ if (!query) { console.error("usage: opencode-episodic search <query> [--text p] [--hybrid] [--after d] [--before d] [--limit n]"); process.exit(1); }
95
96
  const index = openIndex();
96
97
  const opts = {
97
- limit: Number(flag("limit") ?? 10),
98
- after: dateMs(flag("after")),
99
- before: dateMs(flag("before")),
98
+ limit: limitArg(values.limit),
99
+ after: dateArg(values.after),
100
+ before: dateArg(values.before),
100
101
  };
101
- const textFlag = flag("text");
102
- const hits = textFlag
103
- ? textSearch(index, textFlag, opts)
104
- : search(index, (await embedQuery(query))[0], opts);
105
- if (hits.length === 0 && stats(index).chunks === 0) {
106
- console.log("No results. The index is empty — run: bun run src/cli.ts sync");
102
+ const hits = values.text
103
+ ? textSearch(index, values.text, opts)
104
+ : search(
105
+ index,
106
+ (await embedQuery(query))[0],
107
+ values.hybrid ? { ...opts, queryText: query, hybrid: true } : opts
108
+ );
109
+ if (hits.length === 0) {
110
+ console.log(isIndexEmpty(index)
111
+ ? "No results. The index is empty — run: bun run src/cli.ts sync"
112
+ : "No results.");
107
113
  } else {
108
- printHits(hits);
114
+ // Hybrid hits carry RRF scores (~0.03), not cosine — label them "rrf".
115
+ console.log(formatHits(hits, 220, values.hybrid ? "rrf" : "score"));
109
116
  }
110
117
  break;
111
118
  }
112
119
 
113
120
  case "read": {
114
- const id = positional()[0];
121
+ const id = positionals[0];
115
122
  if (!id) { console.error("usage: opencode-episodic read <session-id> [--indexed]"); process.exit(1); }
116
- if (hasFlag("indexed")) {
123
+ if (values.indexed) {
117
124
  const index = openIndex();
118
125
  const rows = index
119
126
  .prepare<{ seq: number; text: string }, [string]>("SELECT seq, text FROM chunks WHERE session_id = ? ORDER BY seq")
@@ -125,23 +132,14 @@ async function main() {
125
132
  const source = openSource();
126
133
  const s = getSession(source, id);
127
134
  if (!s) { console.error("session not found:", id); process.exit(1); }
128
- // Authoritative gate: raw part blobs (a marker in an unparseable blob
129
- // would be invisible to the parsed-text scan).
130
- if (transcriptHasMarker(source, id)) {
135
+ // Privacy gate lives inside getTranscriptChecked (authoritative raw-blob
136
+ // scan before any read).
137
+ const checked = getTranscriptChecked(source, id);
138
+ if (checked.excluded) {
131
139
  console.error("session is marked private (exclusion marker present); transcript withheld");
132
140
  process.exit(1);
133
141
  }
134
- const transcript = getTranscript(source, id);
135
- console.log(`# ${s.title}\n${fmtDate(s.time_created)} — ${s.directory} — ${s.id}\n`);
136
- for (const m of transcript) {
137
- const text = m.parts.filter((p) => p.type === "text" && p.text).map((p) => p.text).join("\n");
138
- const tools = m.parts.filter((p) => p.type === "tool" && p.tool).map((p) => p.tool);
139
- if (!text && tools.length === 0) continue;
140
- console.log(`## ${m.role}`);
141
- if (text) console.log(text);
142
- if (tools.length) console.log(`*(tools: ${tools.join(", ")})*`);
143
- console.log();
144
- }
142
+ console.log(renderTranscript(s, checked.messages));
145
143
  break;
146
144
  }
147
145
 
@@ -159,6 +157,34 @@ async function main() {
159
157
 
160
158
  case "doctor": {
161
159
  let ok = true;
160
+ let mode: "sidecar" | "inline";
161
+ try {
162
+ mode = getEmbedMode();
163
+ console.log(`✓ embedding mode: ${mode}`);
164
+ } catch (e) {
165
+ console.error(`✗ embedding mode: ${e}`);
166
+ process.exit(1);
167
+ }
168
+ if (mode === "sidecar") {
169
+ const nodeBinary = process.env.EPISODIC_NODE_BINARY ?? "node";
170
+ try {
171
+ const node = Bun.spawnSync([nodeBinary, "--version"], { stdout: "pipe", stderr: "pipe" });
172
+ const version = new TextDecoder().decode(node.stdout).trim();
173
+ const match = /^v(\d+)\./.exec(version);
174
+ if (!node.success || !match || Number(match[1]) < 20) {
175
+ const detail = new TextDecoder().decode(node.stderr).trim();
176
+ console.error(`✗ Node 20+ required for sidecar mode (${JSON.stringify(nodeBinary)} ${version || detail || "not found"}). Set EPISODIC_NODE_BINARY to a Node 20+ executable.`);
177
+ ok = false;
178
+ } else {
179
+ console.log(`✓ sidecar Node: ${nodeBinary} ${version}`);
180
+ }
181
+ } catch (e) {
182
+ console.error(`✗ Node 20+ required for sidecar mode (${JSON.stringify(nodeBinary)} could not start: ${e}). Set EPISODIC_NODE_BINARY to a Node 20+ executable.`);
183
+ ok = false;
184
+ }
185
+ } else {
186
+ console.warn("! inline embedding mode loads native ML addons into Bun; it is unsafe on affected OpenCode/Bun versions. Prefer sidecar mode.");
187
+ }
162
188
  const src = sourceDbPath();
163
189
  if (existsSync(src)) console.log(`✓ source DB: ${src}`);
164
190
  else { console.error(`✗ source DB missing: ${src}`); ok = false; }
@@ -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
+ }