opencode-episodic-memory 0.1.2 → 0.1.3

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,89 @@ 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
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)
16
- 4. **Index** — plain SQLite at `~/.local/share/opencode-episodic-memory/index.db`; brute-force cosine over Float32 blobs
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
+ The first embedding run downloads the model (~100 MB, cached afterwards).
42
56
 
43
- ```jsonc
44
- {
45
- "plugin": ["opencode-episodic-memory@0.1.1"]
46
- }
57
+ Install the skill so the agent knows when to search, via the
58
+ [`skills` CLI](https://github.com/vercel-labs/skills):
59
+
60
+ ```bash
61
+ npx skills add robertn702/opencode-episodic-memory -g
47
62
  ```
48
63
 
49
- Copy the skill so the agent knows when to search:
64
+ (`-g` installs to `~/.config/opencode/skills/`; omit it to install into the
65
+ current project. `npx skills update` picks up future skill changes.)
66
+
67
+ Alternatively, copy it manually — it's included in the npm package; once
68
+ OpenCode has downloaded the plugin (i.e. after first launch), copy it out of
69
+ the package cache (the path contains your pinned version):
50
70
 
51
71
  ```bash
52
- cp -r skills/remembering-conversations ~/.config/opencode/skills/
72
+ cp -r ~/.cache/opencode/packages/opencode-episodic-memory@0.1.3/node_modules/opencode-episodic-memory/skills/remembering-conversations ~/.config/opencode/skills/
53
73
  ```
54
74
 
55
75
  Then backfill existing history and restart OpenCode:
56
76
 
57
77
  ```bash
58
- bun run src/cli.ts sync
78
+ bunx opencode-episodic-memory@0.1.3 sync
59
79
  ```
60
80
 
61
81
  ## CLI
62
82
 
83
+ The package ships an `opencode-episodic` binary (requires `bun` on PATH).
84
+ Invoke it through the package spec — pin it to match your plugin version:
85
+
63
86
  ```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
87
+ bunx opencode-episodic-memory@0.1.3 sync [--force] # index new/changed sessions
88
+ bunx opencode-episodic-memory@0.1.3 search "query" # semantic (vector) search
89
+ bunx opencode-episodic-memory@0.1.3 search q --text "terms" # lexical BM25 (all terms AND-matched, token-based)
90
+ bunx opencode-episodic-memory@0.1.3 search q --hybrid # fuse vector + BM25 (RRF; opt-in)
91
+ bunx opencode-episodic-memory@0.1.3 search q --after 2026-07-01 --limit 5
92
+ bunx opencode-episodic-memory@0.1.3 read <session-id> # full transcript (live store)
93
+ bunx opencode-episodic-memory@0.1.3 read <id> --indexed # indexed excerpts (survives deletion)
94
+ bunx opencode-episodic-memory@0.1.3 stats # index statistics
95
+ bunx opencode-episodic-memory@0.1.3 doctor # diagnose setup
72
96
  ```
73
97
 
74
98
  `--after`/`--before` take `YYYY-MM-DD` (midnight UTC). `--after D` is inclusive
@@ -76,7 +100,7 @@ of day D; `--before D` is exclusive of day D (i.e. up to the start of that day).
76
100
 
77
101
  ## Agent tools
78
102
 
79
- - **`episodic_search`** — `query` (+ optional `text`, `mode: vector|text`, `after`, `before`, `limit`). Returns dated excerpts with session IDs and similarity scores.
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 scores.
80
104
  - **`episodic_read`** — `session_id` (+ optional `indexed`). Full transcript from the live store, falling back to indexed excerpts.
81
105
 
82
106
  ## Excluding conversations
@@ -108,6 +132,28 @@ instruction-tag match — the intent is the same, but our matching is literal.
108
132
  - Multi-concept AND search, MCP server wrapper for non-OpenCode clients
109
133
  - ANN index (see design note above)
110
134
 
135
+ ## Development
136
+
137
+ To hack on the plugin itself, clone the repo and point OpenCode at the local
138
+ entrypoint instead of the npm package:
139
+
140
+ ```bash
141
+ git clone https://github.com/robertn702/opencode-episodic-memory.git
142
+ cd opencode-episodic-memory
143
+ bun install
144
+ ```
145
+
146
+ ```jsonc
147
+ // ~/.config/opencode/opencode.json
148
+ {
149
+ "plugin": ["/path/to/opencode-episodic-memory/plugin/episodic-memory.ts"]
150
+ }
151
+ ```
152
+
153
+ Inside the repo, run the CLI as `bun run src/cli.ts <command>` (same
154
+ subcommands as above), tests with `bun test`, and typechecking with
155
+ `bun run typecheck`.
156
+
111
157
  ## License
112
158
 
113
159
  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.1.3",
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)"),
@@ -95,18 +80,15 @@ export const EpisodicMemory: Plugin = async ({ client }) => {
95
80
  const hits =
96
81
  args.mode === "text"
97
82
  ? textSearch(index, args.query, opts)
98
- : search(index, (await embedQuery(args.query))[0], opts);
83
+ : args.mode === "hybrid"
84
+ ? search(index, (await embedQuery(args.query))[0], { ...opts, queryText: args.query, hybrid: true })
85
+ : search(index, (await embedQuery(args.query))[0], opts);
99
86
  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.";
87
+ if (isIndexEmpty(index)) return "No matching past conversations found. The index is empty run `bun run src/cli.ts sync` to index conversations.";
102
88
  return "No matching past conversations found.";
103
89
  }
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");
90
+ // Hybrid hits carry RRF scores (~0.03), not cosine — label them "rrf".
91
+ return formatHits(hits, 400, args.mode === "hybrid" ? "rrf" : "score");
110
92
  },
111
93
  }),
112
94
 
@@ -123,27 +105,18 @@ export const EpisodicMemory: Plugin = async ({ client }) => {
123
105
  const source = openSource();
124
106
  const s = getSession(source, args.session_id);
125
107
  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)) {
108
+ // Privacy gate lives inside getTranscriptChecked (authoritative
109
+ // raw-blob scan before any read).
110
+ const checked = getTranscriptChecked(source, args.session_id);
111
+ if (checked.excluded) {
129
112
  return "Session is marked private (exclusion marker present); transcript withheld.";
130
113
  }
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);
114
+ return renderTranscript(s, checked.messages).slice(0, 50000);
145
115
  }
146
- } catch {
116
+ } catch (e) {
117
+ // Log before falling through — a bare swallow would also hide
118
+ // structural Zod drift, which is meant to be loud.
119
+ await log("warn", `episodic_read live-store read failed for ${args.session_id}: ${e}`);
147
120
  // fall through to indexed copy
148
121
  }
149
122
  }
@@ -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
18
  import { embed, embedQuery } 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
 
package/src/format.ts ADDED
@@ -0,0 +1,70 @@
1
+ // Shared presentation layer for the CLI and the plugin. Both stay thin: date
2
+ // parsing, date formatting, transcript→markdown, and search-hit formatting live
3
+ // here so the two front-ends can't drift apart.
4
+ import type { SourceMessage } from "./reader";
5
+ import type { SearchHit } from "./store";
6
+
7
+ // Discriminated result so callers handle the parse error explicitly (no cast to
8
+ // strip the error arm off a union). `ms` is undefined when no date was given.
9
+ export type ParsedDate = { ok: true; ms?: number } | { ok: false; error: string };
10
+
11
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
12
+ // Require strict YYYY-MM-DD, then round-trip to reject impossible calendar dates
13
+ // (`new Date("2024-02-31")` silently normalizes to March 2 rather than failing).
14
+ export function parseDateArg(s?: string): ParsedDate {
15
+ if (!s) return { ok: true };
16
+ const ms = new Date(s).getTime();
17
+ if (!DATE_RE.test(s) || Number.isNaN(ms) || new Date(ms).toISOString().slice(0, 10) !== s) {
18
+ return { ok: false, error: `Invalid date "${s}" (expected YYYY-MM-DD).` };
19
+ }
20
+ return { ok: true, ms };
21
+ }
22
+
23
+ export function fmtDate(ms: number): string {
24
+ return new Date(ms).toISOString().slice(0, 10);
25
+ }
26
+
27
+ // Render a full transcript as markdown:
28
+ // # title
29
+ // date — directory — id
30
+ //
31
+ // ## role
32
+ // text
33
+ // *(tools: …)*
34
+ //
35
+ // A blank line follows every rendered message. Callers decide truncation (the
36
+ // plugin caps at 50k chars; the CLI prints in full).
37
+ export function renderTranscript(
38
+ meta: { title: string; time_created: number; directory: string; id: string },
39
+ messages: SourceMessage[]
40
+ ): string {
41
+ const lines: string[] = [
42
+ `# ${meta.title}`,
43
+ `${fmtDate(meta.time_created)} — ${meta.directory} — ${meta.id}`,
44
+ "",
45
+ ];
46
+ for (const m of messages) {
47
+ const text = m.parts.filter((p) => p.type === "text" && p.text).map((p) => p.text).join("\n");
48
+ const tools = m.parts.filter((p) => p.type === "tool" && p.tool).map((p) => p.tool);
49
+ if (!text && tools.length === 0) continue;
50
+ lines.push(`## ${m.role}`);
51
+ if (text) lines.push(text);
52
+ if (tools.length) lines.push(`*(tools: ${tools.join(", ")})*`);
53
+ lines.push("");
54
+ }
55
+ return lines.join("\n");
56
+ }
57
+
58
+ // One search hit as a markdown block. snippetLength defaults to 400 (plugin
59
+ // tool output); the CLI passes 220 to keep terminal output brief. scoreLabel
60
+ // names the score field: "score" for vector (cosine ~0.4–0.7) and BM25, "rrf"
61
+ // for hybrid (fused reciprocal-rank scores ~0.03, a different scale — see
62
+ // AGENTS.md) so the number isn't misread against the cosine thresholds.
63
+ export function formatHit(h: SearchHit, snippetLength = 400, scoreLabel = "score"): string {
64
+ const snippet = h.text.replace(/\s+/g, " ").slice(0, snippetLength);
65
+ return `## ${fmtDate(h.time_created)} — ${h.title}\nsession: ${h.session_id} ${scoreLabel}: ${h.score.toFixed(3)}\n${h.directory}\n> ${snippet}`;
66
+ }
67
+
68
+ export function formatHits(hits: SearchHit[], snippetLength = 400, scoreLabel = "score"): string {
69
+ return hits.map((h) => formatHit(h, snippetLength, scoreLabel)).join("\n\n");
70
+ }
package/src/indexer.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Incremental, idempotent indexer. Watermark = session.time_updated; a session
2
2
  // is re-embedded only when the source changed since we last indexed it.
3
3
  import type { Database } from "bun:sqlite";
4
- import { getTranscript, listSessions, transcriptHasMarker, type SourceSession } from "./reader";
4
+ import { getTranscriptChecked, listSessions, type SourceSession } from "./reader";
5
5
  import { parseTranscript, exchangeText } from "./parser";
6
6
  import { embed } from "./embed";
7
7
  import { getIndexedSession, replaceSessionChunks } from "./store";
@@ -24,12 +24,13 @@ export async function syncSession(
24
24
  const prior = getIndexedSession(index, s.id);
25
25
  if (!force && prior && prior.source_time_updated >= s.time_updated) return "fresh";
26
26
 
27
- // Authoritative opt-out gate: raw part blobs. The parsed-text scan inside
28
- // parseTranscript would miss a marker in an unparseable blob.
29
- const excludedRaw = transcriptHasMarker(source, s.id);
30
- const { exchanges, excluded } = excludedRaw
27
+ // Authoritative opt-out gate lives inside getTranscriptChecked (raw-blob
28
+ // scan before any read); parseTranscript's own parsed-text check is a
29
+ // harmless redundant fast path for the non-excluded branch.
30
+ const checked = getTranscriptChecked(source, s.id);
31
+ const { exchanges, excluded } = checked.excluded
31
32
  ? { exchanges: [], excluded: true }
32
- : parseTranscript(getTranscript(source, s.id));
33
+ : parseTranscript(checked.messages);
33
34
  const meta = {
34
35
  id: s.id, project_id: s.project_id, parent_id: s.parent_id,
35
36
  title: s.title, directory: s.directory,
package/src/parser.ts CHANGED
@@ -29,8 +29,9 @@ export interface Exchange {
29
29
  time: number;
30
30
  }
31
31
 
32
- const SKIP_PART_TYPES = new Set(["reasoning", "step-start", "step-finish", "file", "patch", "snapshot"]);
33
-
32
+ // reasoning blobs, step markers, and every other non-text/-tool part type are
33
+ // excluded implicitly: textOf keeps only type === "text" and toolNames only
34
+ // type === "tool", so nothing else can slip through.
34
35
  function textOf(parts: SourcePart[]): string {
35
36
  return parts
36
37
  .filter((p) => p.type === "text" && p.text)
@@ -41,7 +42,7 @@ function textOf(parts: SourcePart[]): string {
41
42
 
42
43
  function toolNames(parts: SourcePart[]): string[] {
43
44
  return parts
44
- .filter((p) => p.type === "tool" && p.tool && !SKIP_PART_TYPES.has(p.type))
45
+ .filter((p) => p.type === "tool" && p.tool)
45
46
  .map((p) => p.tool!);
46
47
  }
47
48