opencode-episodic-memory 0.1.1 → 0.1.2
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 +11 -0
- package/package.json +4 -2
- package/plugin/episodic-memory.ts +5 -4
- package/src/cli.ts +5 -4
- package/src/indexer.ts +7 -2
- package/src/parser.ts +10 -5
- package/src/reader.test.ts +67 -1
- package/src/reader.ts +26 -0
package/README.md
CHANGED
|
@@ -35,6 +35,17 @@ bun install # first embed downloads the model (~100 MB, cached afterwards)
|
|
|
35
35
|
}
|
|
36
36
|
```
|
|
37
37
|
|
|
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:
|
|
42
|
+
|
|
43
|
+
```jsonc
|
|
44
|
+
{
|
|
45
|
+
"plugin": ["opencode-episodic-memory@0.1.1"]
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
38
49
|
Copy the skill so the agent knows when to search:
|
|
39
50
|
|
|
40
51
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-episodic-memory",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Semantic search over past OpenCode conversations. Port of obra/episodic-memory to OpenCode primitives.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -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"
|
|
@@ -33,7 +34,8 @@
|
|
|
33
34
|
"spike": "bun run spikes/spike.ts",
|
|
34
35
|
"test": "bun test",
|
|
35
36
|
"typecheck": "tsc --noEmit",
|
|
36
|
-
"
|
|
37
|
+
"verify:entrypoint": "bun run spikes/verify-opencode-entrypoint.ts",
|
|
38
|
+
"prepublishOnly": "bun run typecheck && bun test && bun run verify:entrypoint"
|
|
37
39
|
},
|
|
38
40
|
"dependencies": {
|
|
39
41
|
"@huggingface/transformers": "^4.2.0",
|
|
@@ -2,11 +2,10 @@
|
|
|
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 } from "../src/reader";
|
|
5
|
+
import { openSource, getSession, getTranscript, transcriptHasMarker } from "../src/reader";
|
|
6
6
|
import { openIndex, search, textSearch } from "../src/store";
|
|
7
7
|
import { syncSession, syncAll, pruneOrphans } from "../src/indexer";
|
|
8
8
|
import { embedQuery } from "../src/embed";
|
|
9
|
-
import { hasExcludeMarker } from "../src/parser";
|
|
10
9
|
|
|
11
10
|
// Discriminated result so callers handle the parse error explicitly (no cast to
|
|
12
11
|
// strip the error arm off a union). `ms` is undefined when no date was given.
|
|
@@ -124,10 +123,12 @@ export const EpisodicMemory: Plugin = async ({ client }) => {
|
|
|
124
123
|
const source = openSource();
|
|
125
124
|
const s = getSession(source, args.session_id);
|
|
126
125
|
if (s) {
|
|
127
|
-
|
|
128
|
-
|
|
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)) {
|
|
129
129
|
return "Session is marked private (exclusion marker present); transcript withheld.";
|
|
130
130
|
}
|
|
131
|
+
const transcript = getTranscript(source, args.session_id);
|
|
131
132
|
const lines: string[] = [`# ${s.title}`, `${fmtDate(s.time_created)} — ${s.directory} — ${s.id}`, ""];
|
|
132
133
|
for (const m of transcript) {
|
|
133
134
|
const text = m.parts
|
package/src/cli.ts
CHANGED
|
@@ -10,11 +10,10 @@
|
|
|
10
10
|
// stats Index statistics
|
|
11
11
|
// doctor Diagnose setup
|
|
12
12
|
import { existsSync } from "node:fs";
|
|
13
|
-
import { openSource, sourceDbPath, getSession, getTranscript } from "./reader";
|
|
13
|
+
import { openSource, sourceDbPath, getSession, getTranscript, transcriptHasMarker } from "./reader";
|
|
14
14
|
import { openIndex, indexDbPath, search, textSearch, stats, type SearchHit } from "./store";
|
|
15
15
|
import { syncAll } from "./indexer";
|
|
16
16
|
import { embed, embedQuery } from "./embed";
|
|
17
|
-
import { hasExcludeMarker } from "./parser";
|
|
18
17
|
|
|
19
18
|
const [, , command, ...rest] = process.argv;
|
|
20
19
|
|
|
@@ -126,11 +125,13 @@ async function main() {
|
|
|
126
125
|
const source = openSource();
|
|
127
126
|
const s = getSession(source, id);
|
|
128
127
|
if (!s) { console.error("session not found:", id); process.exit(1); }
|
|
129
|
-
|
|
130
|
-
|
|
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)) {
|
|
131
131
|
console.error("session is marked private (exclusion marker present); transcript withheld");
|
|
132
132
|
process.exit(1);
|
|
133
133
|
}
|
|
134
|
+
const transcript = getTranscript(source, id);
|
|
134
135
|
console.log(`# ${s.title}\n${fmtDate(s.time_created)} — ${s.directory} — ${s.id}\n`);
|
|
135
136
|
for (const m of transcript) {
|
|
136
137
|
const text = m.parts.filter((p) => p.type === "text" && p.text).map((p) => p.text).join("\n");
|
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, type SourceSession } from "./reader";
|
|
4
|
+
import { getTranscript, listSessions, transcriptHasMarker, 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,12 @@ 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: 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
|
|
31
|
+
? { exchanges: [], excluded: true }
|
|
32
|
+
: parseTranscript(getTranscript(source, s.id));
|
|
28
33
|
const meta = {
|
|
29
34
|
id: s.id, project_id: s.project_id, parent_id: s.parent_id,
|
|
30
35
|
title: s.title, directory: s.directory,
|
package/src/parser.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
// Turn a raw transcript into condensed exchanges suitable for embedding.
|
|
2
2
|
// Keeps user text, assistant text, and tool *names* (not tool output, which is
|
|
3
3
|
// bulky and low-signal). Skips reasoning blobs and step markers.
|
|
4
|
-
import type
|
|
4
|
+
import { EXCLUDE_MARKER, type SourceMessage, type SourcePart } from "./reader";
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
// Defined in reader.ts (single source of truth); re-exported for existing
|
|
7
|
+
// consumers of this module.
|
|
8
|
+
export { EXCLUDE_MARKER };
|
|
7
9
|
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
10
|
+
// Fast-path check over PARSED part text. Cheaper than the raw scan, but can
|
|
11
|
+
// miss the marker when a part blob fails to parse and degrades to
|
|
12
|
+
// text: undefined — the AUTHORITATIVE check is transcriptHasMarker() in
|
|
13
|
+
// reader.ts, which substring-matches the raw `data` column. Callers that gate
|
|
14
|
+
// privacy-sensitive paths should use the raw check; this remains useful for
|
|
15
|
+
// parseTranscript's in-memory flow and tests.
|
|
11
16
|
export function hasExcludeMarker(messages: SourceMessage[]): boolean {
|
|
12
17
|
for (const m of messages) {
|
|
13
18
|
for (const p of m.parts) {
|
package/src/reader.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, test, expect } from "bun:test";
|
|
2
2
|
import { Database } from "bun:sqlite";
|
|
3
|
-
import { listSessions, getSession, getTranscript } from "./reader";
|
|
3
|
+
import { listSessions, getSession, getTranscript, transcriptHasMarker, EXCLUDE_MARKER } from "./reader";
|
|
4
4
|
|
|
5
5
|
// A minimal opencode.db mirroring only the columns reader.ts SELECTs. Writable
|
|
6
6
|
// here so we can seed rows; the reader functions take a Database and never write.
|
|
@@ -137,3 +137,69 @@ describe("getTranscript (JSON blob degradation)", () => {
|
|
|
137
137
|
expect(() => getTranscript(db, "ses_a")).toThrow();
|
|
138
138
|
});
|
|
139
139
|
});
|
|
140
|
+
|
|
141
|
+
describe("transcriptHasMarker (raw blob scan)", () => {
|
|
142
|
+
test("detects the marker in a well-formed text part", () => {
|
|
143
|
+
const db = makeSource();
|
|
144
|
+
addSession(db, { id: "ses_a" });
|
|
145
|
+
addMessage(db, "m1", "ses_a", 1, `{"role":"user"}`);
|
|
146
|
+
addPart(db, "p1", "m1", "ses_a", 1, `{"type":"text","text":"note: ${EXCLUDE_MARKER}"}`);
|
|
147
|
+
expect(transcriptHasMarker(db, "ses_a")).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Regression for issue #10: the parsed-text scan degrades this blob to
|
|
151
|
+
// text: undefined, so the marker is invisible to hasExcludeMarker — but the
|
|
152
|
+
// raw scan must still see it. The privacy kill-switch must not depend on
|
|
153
|
+
// blob parseability.
|
|
154
|
+
test("detects the marker inside a malformed/unparseable part blob", () => {
|
|
155
|
+
const db = makeSource();
|
|
156
|
+
addSession(db, { id: "ses_a" });
|
|
157
|
+
addMessage(db, "m1", "ses_a", 1, `{"role":"user"}`);
|
|
158
|
+
addPart(db, "p1", "m1", "ses_a", 1, `{oops not json ${EXCLUDE_MARKER}`);
|
|
159
|
+
|
|
160
|
+
// Sanity: the parsed view really does lose the marker text.
|
|
161
|
+
const t = getTranscript(db, "ses_a");
|
|
162
|
+
expect(t[0].parts).toEqual([{ type: "unknown" }]);
|
|
163
|
+
|
|
164
|
+
expect(transcriptHasMarker(db, "ses_a")).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("detects the marker in a blob whose fields all fail validation", () => {
|
|
168
|
+
const db = makeSource();
|
|
169
|
+
addSession(db, { id: "ses_a" });
|
|
170
|
+
addMessage(db, "m1", "ses_a", 1, `{"role":"user"}`);
|
|
171
|
+
// Valid JSON, but type is non-string → degrades to {type:"unknown"}.
|
|
172
|
+
addPart(db, "p1", "m1", "ses_a", 1, `{"type":123,"note":"${EXCLUDE_MARKER}"}`);
|
|
173
|
+
|
|
174
|
+
const t = getTranscript(db, "ses_a");
|
|
175
|
+
expect(t[0].parts).toEqual([{ type: "unknown" }]);
|
|
176
|
+
expect(transcriptHasMarker(db, "ses_a")).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("returns false when no part contains the marker", () => {
|
|
180
|
+
const db = makeSource();
|
|
181
|
+
addSession(db, { id: "ses_a" });
|
|
182
|
+
addMessage(db, "m1", "ses_a", 1, `{"role":"user"}`);
|
|
183
|
+
addPart(db, "p1", "m1", "ses_a", 1, `{"type":"text","text":"hello"}`);
|
|
184
|
+
expect(transcriptHasMarker(db, "ses_a")).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("is scoped to the requested session", () => {
|
|
188
|
+
const db = makeSource();
|
|
189
|
+
addSession(db, { id: "ses_a" });
|
|
190
|
+
addSession(db, { id: "ses_b" });
|
|
191
|
+
addMessage(db, "m1", "ses_b", 1, `{"role":"user"}`);
|
|
192
|
+
addPart(db, "p1", "m1", "ses_b", 1, `{"type":"text","text":"${EXCLUDE_MARKER}"}`);
|
|
193
|
+
expect(transcriptHasMarker(db, "ses_a")).toBe(false);
|
|
194
|
+
expect(transcriptHasMarker(db, "ses_b")).toBe(true);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("does not match case variants or partial markers (exact substring)", () => {
|
|
198
|
+
const db = makeSource();
|
|
199
|
+
addSession(db, { id: "ses_a" });
|
|
200
|
+
addMessage(db, "m1", "ses_a", 1, `{"role":"user"}`);
|
|
201
|
+
addPart(db, "p1", "m1", "ses_a", 1, `{"type":"text","text":"do not index this chat"}`);
|
|
202
|
+
addPart(db, "p2", "m1", "ses_a", 2, `{"type":"text","text":"DO NOT INDEX THIS"}`);
|
|
203
|
+
expect(transcriptHasMarker(db, "ses_a")).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
});
|
package/src/reader.ts
CHANGED
|
@@ -7,6 +7,11 @@ import { z } from "zod";
|
|
|
7
7
|
|
|
8
8
|
export const DEFAULT_SOURCE_DB = join(homedir(), ".local/share/opencode/opencode.db");
|
|
9
9
|
|
|
10
|
+
// Opt-out marker. Matched as a BARE SUBSTRING anywhere in any message part —
|
|
11
|
+
// broader than upstream's full instruction-tag match, so it also fires on
|
|
12
|
+
// conversations that merely quote the phrase. Re-exported by parser.ts.
|
|
13
|
+
export const EXCLUDE_MARKER = "DO NOT INDEX THIS CHAT";
|
|
14
|
+
|
|
10
15
|
// --- Validation strategy ----------------------------------------------------
|
|
11
16
|
// Two surfaces, two failure modes (see AGENTS.md):
|
|
12
17
|
// 1. Structural rows we SELECT from opencode.db (columns: id, time_created,
|
|
@@ -43,6 +48,9 @@ const PartRowSchema = z.object({
|
|
|
43
48
|
data: z.string(),
|
|
44
49
|
});
|
|
45
50
|
|
|
51
|
+
// Aggregate row for the raw marker scan (structural: throw on drift).
|
|
52
|
+
const MarkerCountSchema = z.object({ n: z.number() });
|
|
53
|
+
|
|
46
54
|
// --- JSON blob schemas (degrade to "unknown" on mismatch) -------------------
|
|
47
55
|
const PartDataSchema = z
|
|
48
56
|
.object({
|
|
@@ -102,6 +110,24 @@ export function getSession(db: Database, sessionId: string): SourceSession | nul
|
|
|
102
110
|
return row === null || row === undefined ? null : SessionRowSchema.parse(row);
|
|
103
111
|
}
|
|
104
112
|
|
|
113
|
+
// AUTHORITATIVE exclusion check: bare-substring match over the RAW `data`
|
|
114
|
+
// column of the session's part rows, with no JSON parsing. The parsed-text
|
|
115
|
+
// scan (parser.ts hasExcludeMarker) can miss the marker when a part blob fails
|
|
116
|
+
// to parse and degrades to text: undefined — the privacy kill-switch must not
|
|
117
|
+
// depend on blob parseability. `instr` is an exact, case-sensitive substring
|
|
118
|
+
// match (unlike LIKE, which is case-insensitive and has wildcard chars).
|
|
119
|
+
export function transcriptHasMarker(db: Database, sessionId: string): boolean {
|
|
120
|
+
const row = MarkerCountSchema.parse(
|
|
121
|
+
db
|
|
122
|
+
.prepare(
|
|
123
|
+
`SELECT COUNT(*) AS n FROM part
|
|
124
|
+
WHERE session_id = ? AND instr(data, ?) > 0`
|
|
125
|
+
)
|
|
126
|
+
.get(sessionId, EXCLUDE_MARKER)
|
|
127
|
+
);
|
|
128
|
+
return row.n > 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
105
131
|
export function getTranscript(db: Database, sessionId: string): SourceMessage[] {
|
|
106
132
|
const messages = MessageRowSchema.array().parse(
|
|
107
133
|
db
|