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 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.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
- "prepublishOnly": "bun run typecheck && bun test"
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
- const transcript = getTranscript(source, args.session_id);
128
- if (hasExcludeMarker(transcript)) {
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
- const transcript = getTranscript(source, id);
130
- if (hasExcludeMarker(transcript)) {
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
- const { exchanges, excluded } = parseTranscript(getTranscript(source, s.id));
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 { SourceMessage, SourcePart } from "./reader";
4
+ import { EXCLUDE_MARKER, type SourceMessage, type SourcePart } from "./reader";
5
5
 
6
- export const EXCLUDE_MARKER = "DO NOT INDEX THIS CHAT";
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
- // True if any text part in the conversation contains the opt-out marker.
9
- // Used both at index time (skip embedding) and at read time (refuse to return
10
- // the transcript), so a markered chat is never surfaced verbatim.
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) {
@@ -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