opencode-episodic-memory 0.1.1 → 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 +74 -17
- package/package.json +7 -4
- package/plugin/episodic-memory.ts +19 -45
- package/skills/remembering-conversations/SKILL.md +2 -1
- package/src/cli.ts +78 -79
- package/src/format.ts +70 -0
- package/src/indexer.ts +8 -2
- package/src/parser.ts +14 -8
- package/src/reader.ts +45 -1
- package/src/store.ts +222 -33
- package/src/parser.test.ts +0 -64
- package/src/reader.test.ts +0 -139
- package/src/store.test.ts +0 -72
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# opencode-episodic-memory
|
|
2
2
|
|
|
3
|
+
[](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,56 +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
|
-
|
|
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": ["
|
|
51
|
+
"plugin": ["opencode-episodic-memory@0.1.3"]
|
|
35
52
|
}
|
|
36
53
|
```
|
|
37
54
|
|
|
38
|
-
|
|
55
|
+
The first embedding run downloads the model (~100 MB, cached afterwards).
|
|
56
|
+
|
|
57
|
+
Install the skill so the agent knows when to search, via the
|
|
58
|
+
[`skills` CLI](https://github.com/vercel-labs/skills):
|
|
39
59
|
|
|
40
60
|
```bash
|
|
41
|
-
|
|
61
|
+
npx skills add robertn702/opencode-episodic-memory -g
|
|
62
|
+
```
|
|
63
|
+
|
|
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):
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
cp -r ~/.cache/opencode/packages/opencode-episodic-memory@0.1.3/node_modules/opencode-episodic-memory/skills/remembering-conversations ~/.config/opencode/skills/
|
|
42
73
|
```
|
|
43
74
|
|
|
44
75
|
Then backfill existing history and restart OpenCode:
|
|
45
76
|
|
|
46
77
|
```bash
|
|
47
|
-
|
|
78
|
+
bunx opencode-episodic-memory@0.1.3 sync
|
|
48
79
|
```
|
|
49
80
|
|
|
50
81
|
## CLI
|
|
51
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
|
+
|
|
52
86
|
```bash
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
61
96
|
```
|
|
62
97
|
|
|
63
98
|
`--after`/`--before` take `YYYY-MM-DD` (midnight UTC). `--after D` is inclusive
|
|
@@ -65,7 +100,7 @@ of day D; `--before D` is exclusive of day D (i.e. up to the start of that day).
|
|
|
65
100
|
|
|
66
101
|
## Agent tools
|
|
67
102
|
|
|
68
|
-
- **`episodic_search`** — `query` (+ optional `text`, `mode: vector|text`, `after`, `before`, `limit`). Returns dated excerpts with session IDs and
|
|
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.
|
|
69
104
|
- **`episodic_read`** — `session_id` (+ optional `indexed`). Full transcript from the live store, falling back to indexed excerpts.
|
|
70
105
|
|
|
71
106
|
## Excluding conversations
|
|
@@ -97,6 +132,28 @@ instruction-tag match — the intent is the same, but our matching is literal.
|
|
|
97
132
|
- Multi-concept AND search, MCP server wrapper for non-OpenCode clients
|
|
98
133
|
- ANN index (see design note above)
|
|
99
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
|
+
|
|
100
157
|
## License
|
|
101
158
|
|
|
102
159
|
MIT
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-episodic-memory",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Semantic search over past OpenCode conversations.
|
|
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",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"embeddings",
|
|
18
18
|
"transformersjs"
|
|
19
19
|
],
|
|
20
|
+
"main": "./plugin/episodic-memory.ts",
|
|
20
21
|
"exports": {
|
|
21
22
|
".": "./plugin/episodic-memory.ts",
|
|
22
23
|
"./cli": "./src/cli.ts"
|
|
@@ -24,7 +25,8 @@
|
|
|
24
25
|
"files": [
|
|
25
26
|
"plugin",
|
|
26
27
|
"src",
|
|
27
|
-
"skills"
|
|
28
|
+
"skills",
|
|
29
|
+
"!src/*.test.ts"
|
|
28
30
|
],
|
|
29
31
|
"bin": {
|
|
30
32
|
"opencode-episodic": "src/cli.ts"
|
|
@@ -33,7 +35,8 @@
|
|
|
33
35
|
"spike": "bun run spikes/spike.ts",
|
|
34
36
|
"test": "bun test",
|
|
35
37
|
"typecheck": "tsc --noEmit",
|
|
36
|
-
"
|
|
38
|
+
"verify:entrypoint": "bun run spikes/verify-opencode-entrypoint.ts",
|
|
39
|
+
"prepublishOnly": "bun run typecheck && bun test && bun run verify:entrypoint"
|
|
37
40
|
},
|
|
38
41
|
"dependencies": {
|
|
39
42
|
"@huggingface/transformers": "^4.2.0",
|
|
@@ -2,27 +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,
|
|
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
|
-
import {
|
|
10
|
-
|
|
11
|
-
// Discriminated result so callers handle the parse error explicitly (no cast to
|
|
12
|
-
// strip the error arm off a union). `ms` is undefined when no date was given.
|
|
13
|
-
type ParsedDate = { ok: true; ms?: number } | { ok: false; error: string };
|
|
14
|
-
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
15
|
-
// Require strict YYYY-MM-DD, then round-trip to reject impossible calendar dates
|
|
16
|
-
// (`new Date("2024-02-31")` silently normalizes to March 2 rather than failing).
|
|
17
|
-
const parseDateArg = (s?: string): ParsedDate => {
|
|
18
|
-
if (!s) return { ok: true };
|
|
19
|
-
const ms = new Date(s).getTime();
|
|
20
|
-
if (!DATE_RE.test(s) || Number.isNaN(ms) || new Date(ms).toISOString().slice(0, 10) !== s) {
|
|
21
|
-
return { ok: false, error: `Invalid date "${s}" (expected YYYY-MM-DD).` };
|
|
22
|
-
}
|
|
23
|
-
return { ok: true, ms };
|
|
24
|
-
};
|
|
25
|
-
const fmtDate = (ms: number) => new Date(ms).toISOString().slice(0, 10);
|
|
9
|
+
import { parseDateArg, formatHits, renderTranscript } from "../src/format";
|
|
26
10
|
|
|
27
11
|
export const EpisodicMemory: Plugin = async ({ client }) => {
|
|
28
12
|
const log = (level: "info" | "warn" | "error", message: string) =>
|
|
@@ -76,7 +60,7 @@ export const EpisodicMemory: Plugin = async ({ client }) => {
|
|
|
76
60
|
args: {
|
|
77
61
|
query: tool.schema.string().describe("Natural-language description of what you're looking for"),
|
|
78
62
|
text: tool.schema.string().optional().describe("Exact substring to require in results (ANDed with semantic ranking)"),
|
|
79
|
-
mode: tool.schema.enum(["vector", "text"]).optional().describe("'vector' (default) semantic search; 'text'
|
|
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"),
|
|
80
64
|
after: tool.schema.string().optional().describe("Only conversations after YYYY-MM-DD"),
|
|
81
65
|
before: tool.schema.string().optional().describe("Only conversations before YYYY-MM-DD"),
|
|
82
66
|
limit: tool.schema.number().optional().describe("Max results, 1-50 (default 10)"),
|
|
@@ -96,18 +80,15 @@ export const EpisodicMemory: Plugin = async ({ client }) => {
|
|
|
96
80
|
const hits =
|
|
97
81
|
args.mode === "text"
|
|
98
82
|
? textSearch(index, args.query, opts)
|
|
99
|
-
:
|
|
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);
|
|
100
86
|
if (hits.length === 0) {
|
|
101
|
-
|
|
102
|
-
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.";
|
|
103
88
|
return "No matching past conversations found.";
|
|
104
89
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const snippet = h.text.replace(/\s+/g, " ").slice(0, 400);
|
|
108
|
-
return `## ${fmtDate(h.time_created)} — ${h.title}\nsession: ${h.session_id} score: ${h.score.toFixed(3)}\n${h.directory}\n> ${snippet}`;
|
|
109
|
-
})
|
|
110
|
-
.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");
|
|
111
92
|
},
|
|
112
93
|
}),
|
|
113
94
|
|
|
@@ -124,25 +105,18 @@ export const EpisodicMemory: Plugin = async ({ client }) => {
|
|
|
124
105
|
const source = openSource();
|
|
125
106
|
const s = getSession(source, args.session_id);
|
|
126
107
|
if (s) {
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
132
|
-
for (const m of transcript) {
|
|
133
|
-
const text = m.parts
|
|
134
|
-
.filter((p) => p.type === "text" && p.text)
|
|
135
|
-
.map((p) => p.text)
|
|
136
|
-
.join("\n");
|
|
137
|
-
const tools = m.parts.filter((p) => p.type === "tool" && p.tool).map((p) => p.tool);
|
|
138
|
-
if (!text && tools.length === 0) continue;
|
|
139
|
-
lines.push(`## ${m.role}`);
|
|
140
|
-
if (text) lines.push(text);
|
|
141
|
-
if (tools.length) lines.push(`*(tools: ${tools.join(", ")})*`, "");
|
|
142
|
-
}
|
|
143
|
-
return lines.join("\n").slice(0, 50000);
|
|
114
|
+
return renderTranscript(s, checked.messages).slice(0, 50000);
|
|
144
115
|
}
|
|
145
|
-
} 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}`);
|
|
146
120
|
// fall through to indexed copy
|
|
147
121
|
}
|
|
148
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
|
|
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 "
|
|
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,67 +11,66 @@
|
|
|
10
11
|
// stats Index statistics
|
|
11
12
|
// doctor Diagnose setup
|
|
12
13
|
import { existsSync } from "node:fs";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
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";
|
|
17
|
-
import {
|
|
19
|
+
import { parseDateArg, fmtDate, renderTranscript, formatHits } from "./format";
|
|
18
20
|
|
|
19
21
|
const [, , command, ...rest] = process.argv;
|
|
22
|
+
const USAGE = "commands: sync | search | read | stats | doctor";
|
|
20
23
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
function
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
out.push(t);
|
|
45
|
-
}
|
|
46
|
-
return out;
|
|
47
|
-
}
|
|
48
|
-
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
49
|
-
// Require strict YYYY-MM-DD, then round-trip to reject impossible calendar dates
|
|
50
|
-
// (`new Date("2024-02-31")` silently normalizes to March 2 rather than failing).
|
|
51
|
-
const dateMs = (s: string | null): number | undefined => {
|
|
52
|
-
if (!s) return undefined;
|
|
53
|
-
const ms = new Date(s).getTime();
|
|
54
|
-
if (!DATE_RE.test(s) || Number.isNaN(ms) || new Date(ms).toISOString().slice(0, 10) !== s) {
|
|
55
|
-
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);
|
|
56
47
|
process.exit(1);
|
|
57
48
|
}
|
|
58
|
-
|
|
59
|
-
};
|
|
49
|
+
}
|
|
50
|
+
const { values, positionals } = parseCli();
|
|
60
51
|
|
|
61
|
-
|
|
62
|
-
|
|
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;
|
|
63
58
|
}
|
|
64
59
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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);
|
|
73
72
|
}
|
|
73
|
+
return Math.min(n, 1000);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
async function main() {
|
|
@@ -79,7 +79,7 @@ async function main() {
|
|
|
79
79
|
const source = openSource();
|
|
80
80
|
const index = openIndex();
|
|
81
81
|
const r = await syncAll(source, index, {
|
|
82
|
-
force:
|
|
82
|
+
force: values.force,
|
|
83
83
|
onProgress: (done, total, title) =>
|
|
84
84
|
process.stderr.write(`\r[${done}/${total}] ${title.slice(0, 60)} `),
|
|
85
85
|
});
|
|
@@ -91,30 +91,36 @@ async function main() {
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
case "search": {
|
|
94
|
-
const query =
|
|
95
|
-
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); }
|
|
96
96
|
const index = openIndex();
|
|
97
97
|
const opts = {
|
|
98
|
-
limit:
|
|
99
|
-
after:
|
|
100
|
-
before:
|
|
98
|
+
limit: limitArg(values.limit),
|
|
99
|
+
after: dateArg(values.after),
|
|
100
|
+
before: dateArg(values.before),
|
|
101
101
|
};
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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.");
|
|
108
113
|
} else {
|
|
109
|
-
|
|
114
|
+
// Hybrid hits carry RRF scores (~0.03), not cosine — label them "rrf".
|
|
115
|
+
console.log(formatHits(hits, 220, values.hybrid ? "rrf" : "score"));
|
|
110
116
|
}
|
|
111
117
|
break;
|
|
112
118
|
}
|
|
113
119
|
|
|
114
120
|
case "read": {
|
|
115
|
-
const id =
|
|
121
|
+
const id = positionals[0];
|
|
116
122
|
if (!id) { console.error("usage: opencode-episodic read <session-id> [--indexed]"); process.exit(1); }
|
|
117
|
-
if (
|
|
123
|
+
if (values.indexed) {
|
|
118
124
|
const index = openIndex();
|
|
119
125
|
const rows = index
|
|
120
126
|
.prepare<{ seq: number; text: string }, [string]>("SELECT seq, text FROM chunks WHERE session_id = ? ORDER BY seq")
|
|
@@ -126,21 +132,14 @@ async function main() {
|
|
|
126
132
|
const source = openSource();
|
|
127
133
|
const s = getSession(source, id);
|
|
128
134
|
if (!s) { console.error("session not found:", id); process.exit(1); }
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
console.log(
|
|
135
|
-
for (const m of transcript) {
|
|
136
|
-
const text = m.parts.filter((p) => p.type === "text" && p.text).map((p) => p.text).join("\n");
|
|
137
|
-
const tools = m.parts.filter((p) => p.type === "tool" && p.tool).map((p) => p.tool);
|
|
138
|
-
if (!text && tools.length === 0) continue;
|
|
139
|
-
console.log(`## ${m.role}`);
|
|
140
|
-
if (text) console.log(text);
|
|
141
|
-
if (tools.length) console.log(`*(tools: ${tools.join(", ")})*`);
|
|
142
|
-
console.log();
|
|
143
|
-
}
|
|
142
|
+
console.log(renderTranscript(s, checked.messages));
|
|
144
143
|
break;
|
|
145
144
|
}
|
|
146
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 {
|
|
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,7 +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
|
-
|
|
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
|
|
32
|
+
? { exchanges: [], excluded: true }
|
|
33
|
+
: parseTranscript(checked.messages);
|
|
28
34
|
const meta = {
|
|
29
35
|
id: s.id, project_id: s.project_id, parent_id: s.parent_id,
|
|
30
36
|
title: s.title, directory: s.directory,
|