opencode-episodic-memory 0.1.0 → 0.1.1

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/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "opencode-episodic-memory",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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",
7
7
  "author": "robertn702",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "https://github.com/robertn702/opencode-episodic-memory"
10
+ "url": "git+https://github.com/robertn702/opencode-episodic-memory.git"
11
11
  },
12
12
  "keywords": [
13
13
  "opencode",
@@ -27,7 +27,7 @@
27
27
  "skills"
28
28
  ],
29
29
  "bin": {
30
- "opencode-episodic": "./src/cli.ts"
30
+ "opencode-episodic": "src/cli.ts"
31
31
  },
32
32
  "scripts": {
33
33
  "spike": "bun run spikes/spike.ts",
@@ -37,7 +37,8 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@huggingface/transformers": "^4.2.0",
40
- "@opencode-ai/plugin": "^1.18.4"
40
+ "@opencode-ai/plugin": "^1.18.4",
41
+ "zod": "^4.4.3"
41
42
  },
42
43
  "devDependencies": {
43
44
  "@types/bun": "latest",
@@ -0,0 +1,139 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { listSessions, getSession, getTranscript } from "./reader";
4
+
5
+ // A minimal opencode.db mirroring only the columns reader.ts SELECTs. Writable
6
+ // here so we can seed rows; the reader functions take a Database and never write.
7
+ function makeSource(): Database {
8
+ const db = new Database(":memory:");
9
+ db.run(`CREATE TABLE session (
10
+ id TEXT, project_id TEXT, parent_id TEXT, title TEXT, directory TEXT,
11
+ time_created INTEGER, time_updated INTEGER, time_archived INTEGER
12
+ )`);
13
+ db.run(`CREATE TABLE message (
14
+ id TEXT, session_id TEXT, time_created INTEGER, data TEXT
15
+ )`);
16
+ db.run(`CREATE TABLE part (
17
+ id TEXT, message_id TEXT, session_id TEXT, time_created INTEGER, data TEXT
18
+ )`);
19
+ return db;
20
+ }
21
+
22
+ function addSession(db: Database, s: {
23
+ id: string; parent_id?: string | null; title?: string;
24
+ time_created?: number; time_updated?: number; time_archived?: number | null;
25
+ }): void {
26
+ db.run(
27
+ `INSERT INTO session (id, project_id, parent_id, title, directory, time_created, time_updated, time_archived)
28
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
29
+ [s.id, "proj", s.parent_id ?? null, s.title ?? "Title", "/dir",
30
+ s.time_created ?? 1000, s.time_updated ?? 1000, s.time_archived ?? null]
31
+ );
32
+ }
33
+ function addMessage(db: Database, id: string, sessionId: string, time: number, data: string): void {
34
+ db.run("INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)",
35
+ [id, sessionId, time, data]);
36
+ }
37
+ function addPart(db: Database, id: string, messageId: string, sessionId: string, time: number, data: string): void {
38
+ db.run("INSERT INTO part (id, message_id, session_id, time_created, data) VALUES (?, ?, ?, ?, ?)",
39
+ [id, messageId, sessionId, time, data]);
40
+ }
41
+
42
+ describe("listSessions / getSession (structural rows)", () => {
43
+ test("lists active sessions ordered by time_created, excludes archived", () => {
44
+ const db = makeSource();
45
+ addSession(db, { id: "ses_b", time_created: 2000 });
46
+ addSession(db, { id: "ses_a", time_created: 1000, parent_id: "ses_b" });
47
+ addSession(db, { id: "ses_arch", time_created: 1500, time_archived: 9999 });
48
+ const sessions = listSessions(db);
49
+ expect(sessions.map((s) => s.id)).toEqual(["ses_a", "ses_b"]);
50
+ expect(sessions[0].parent_id).toBe("ses_b");
51
+ expect(sessions[1].parent_id).toBeNull();
52
+ });
53
+
54
+ test("getSession returns a row, or null for an unknown id", () => {
55
+ const db = makeSource();
56
+ addSession(db, { id: "ses_a", title: "Hello" });
57
+ expect(getSession(db, "ses_a")?.title).toBe("Hello");
58
+ expect(getSession(db, "nope")).toBeNull();
59
+ });
60
+
61
+ test("throws (does not silently mis-read) when a structural column drifts", () => {
62
+ const db = makeSource();
63
+ // time_created NULL violates z.number() — simulates OpenCode schema drift.
64
+ db.run(
65
+ `INSERT INTO session (id, project_id, parent_id, title, directory, time_created, time_updated, time_archived)
66
+ VALUES ('ses_x', 'p', NULL, 't', '/d', NULL, 1000, NULL)`
67
+ );
68
+ expect(() => listSessions(db)).toThrow();
69
+ });
70
+
71
+ test("getSession throws on a drifted session row for an existing id", () => {
72
+ const db = makeSource();
73
+ // title NULL violates z.string() — simulates OpenCode schema drift.
74
+ db.run(
75
+ `INSERT INTO session (id, project_id, parent_id, title, directory, time_created, time_updated, time_archived)
76
+ VALUES ('ses_y', 'p', NULL, NULL, '/d', 1000, 1000, NULL)`
77
+ );
78
+ expect(() => getSession(db, "ses_y")).toThrow();
79
+ });
80
+ });
81
+
82
+ describe("getTranscript (JSON blob degradation)", () => {
83
+ test("parses roles and part fields; degrades malformed blobs per-row", () => {
84
+ const db = makeSource();
85
+ addSession(db, { id: "ses_a" });
86
+ addMessage(db, "m1", "ses_a", 1, `{"role":"user"}`);
87
+ addMessage(db, "m2", "ses_a", 2, `{"role":"assistant"}`);
88
+ addMessage(db, "m3", "ses_a", 3, `{not valid json`); // role -> "unknown"
89
+ addMessage(db, "m4", "ses_a", 4, `{"noRole":true}`); // role -> "unknown"
90
+
91
+ addPart(db, "p1", "m1", "ses_a", 1, `{"type":"text","text":"hello"}`);
92
+ addPart(db, "p2", "m1", "ses_a", 2, `{"type":"tool","tool":"edit"}`);
93
+ addPart(db, "p3", "m2", "ses_a", 3, `{oops not json`); // -> {type:"unknown"}
94
+ addPart(db, "p4", "m2", "ses_a", 4, `{"type":123,"text":"keep"}`); // type->unknown, text kept
95
+ addPart(db, "p5", "m4", "ses_a", 5, `42`); // non-object -> {type:"unknown"}
96
+
97
+ const t = getTranscript(db, "ses_a");
98
+ expect(t.map((m) => m.role)).toEqual(["user", "assistant", "unknown", "unknown"]);
99
+
100
+ expect(t[0].parts).toEqual([
101
+ { type: "text", text: "hello" },
102
+ { type: "tool", tool: "edit" },
103
+ ]);
104
+ expect(t[1].parts).toEqual([
105
+ { type: "unknown" },
106
+ { type: "unknown", text: "keep" },
107
+ ]);
108
+ expect(t[3].parts).toEqual([{ type: "unknown" }]);
109
+ });
110
+
111
+ test("per-field catch: bad text/tool fields are dropped, type is preserved", () => {
112
+ const db = makeSource();
113
+ addSession(db, { id: "ses_a" });
114
+ addMessage(db, "m1", "ses_a", 1, `{"role":"user"}`);
115
+ addPart(db, "p1", "m1", "ses_a", 1, `{"type":"text","text":123}`); // bad text dropped
116
+ addPart(db, "p2", "m1", "ses_a", 2, `{"type":"tool","tool":123}`); // bad tool dropped
117
+
118
+ const t = getTranscript(db, "ses_a");
119
+ expect(t[0].parts).toEqual([{ type: "text" }, { type: "tool" }]);
120
+ });
121
+
122
+ test("throws when a part row's data column is non-string (structural drift)", () => {
123
+ const db = makeSource();
124
+ addSession(db, { id: "ses_a" });
125
+ // Valid message first so the message-row parse passes and the throw comes
126
+ // from the part row below.
127
+ addMessage(db, "m1", "ses_a", 1, `{"role":"user"}`);
128
+ db.run("INSERT INTO part (id, message_id, session_id, time_created, data) VALUES ('p1', 'm1', 'ses_a', 1, NULL)");
129
+ expect(() => getTranscript(db, "ses_a")).toThrow();
130
+ });
131
+
132
+ test("throws when a message row's data column is non-string (structural drift)", () => {
133
+ const db = makeSource();
134
+ addSession(db, { id: "ses_a" });
135
+ // data NULL violates the row schema's z.string(); structural, so it throws.
136
+ db.run("INSERT INTO message (id, session_id, time_created, data) VALUES ('m1', 'ses_a', 1, NULL)");
137
+ expect(() => getTranscript(db, "ses_a")).toThrow();
138
+ });
139
+ });
package/src/reader.ts CHANGED
@@ -3,24 +3,59 @@
3
3
  import { Database } from "bun:sqlite";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
+ import { z } from "zod";
6
7
 
7
8
  export const DEFAULT_SOURCE_DB = join(homedir(), ".local/share/opencode/opencode.db");
8
9
 
9
- export interface SourceSession {
10
- id: string;
11
- project_id: string;
12
- parent_id: string | null;
13
- title: string;
14
- directory: string;
15
- time_created: number;
16
- time_updated: number;
17
- }
18
-
19
- export interface SourcePart {
20
- type: string;
21
- text?: string;
22
- tool?: string;
23
- }
10
+ // --- Validation strategy ----------------------------------------------------
11
+ // Two surfaces, two failure modes (see AGENTS.md):
12
+ // 1. Structural rows we SELECT from opencode.db (columns: id, time_created,
13
+ // data, ...). These are a uniform contract; if a column's type/nullability
14
+ // drifts it drifts for every row, so we THROW (`.parse`) to surface
15
+ // OpenCode schema changes loudly instead of silently mis-reading them.
16
+ // 2. The JSON blob inside each `data` column (message role, part contents).
17
+ // This format evolves and carries many part shapes we don't model, so we
18
+ // DEGRADE per-row to "unknown"/undefined (`.catch`): one corrupt or
19
+ // unfamiliar blob can never abort a whole transcript read, and the parser
20
+ // already filters unknown types/roles downstream.
21
+ // No `as` assertions: schemas narrow via `.parse()`.
22
+
23
+ // --- Structural row schemas (throw on drift) --------------------------------
24
+ const SessionRowSchema = z.object({
25
+ id: z.string(),
26
+ project_id: z.string(),
27
+ parent_id: z.string().nullable(),
28
+ title: z.string(),
29
+ directory: z.string(),
30
+ time_created: z.number(),
31
+ time_updated: z.number(),
32
+ });
33
+ export type SourceSession = z.infer<typeof SessionRowSchema>;
34
+
35
+ const MessageRowSchema = z.object({
36
+ id: z.string(),
37
+ time_created: z.number(),
38
+ data: z.string(),
39
+ });
40
+
41
+ const PartRowSchema = z.object({
42
+ message_id: z.string(),
43
+ data: z.string(),
44
+ });
45
+
46
+ // --- JSON blob schemas (degrade to "unknown" on mismatch) -------------------
47
+ const PartDataSchema = z
48
+ .object({
49
+ type: z.string().catch("unknown"),
50
+ text: z.string().optional().catch(undefined),
51
+ tool: z.string().optional().catch(undefined),
52
+ })
53
+ .catch({ type: "unknown" });
54
+ export type SourcePart = z.infer<typeof PartDataSchema>;
55
+
56
+ const MessageDataSchema = z
57
+ .object({ role: z.string().catch("unknown") })
58
+ .catch({ role: "unknown" });
24
59
 
25
60
  export interface SourceMessage {
26
61
  id: string;
@@ -37,35 +72,9 @@ export function sourceDbPath(): string {
37
72
  return process.env.EPISODIC_SOURCE_DB ?? DEFAULT_SOURCE_DB;
38
73
  }
39
74
 
40
- export function listSessions(db: Database): SourceSession[] {
41
- return db
42
- .prepare<SourceSession, []>(
43
- `SELECT id, project_id, parent_id, title, directory, time_created, time_updated
44
- FROM session WHERE time_archived IS NULL ORDER BY time_created`
45
- )
46
- .all();
47
- }
48
-
49
- export function getSession(db: Database, sessionId: string): SourceSession | null {
50
- return (
51
- db
52
- .prepare<SourceSession, [string]>(
53
- `SELECT id, project_id, parent_id, title, directory, time_created, time_updated
54
- FROM session WHERE id = ?`
55
- )
56
- .get(sessionId) ?? null
57
- );
58
- }
59
-
60
- // JSON.parse returns `any`; these guards validate shape at runtime so no type
61
- // assertion is needed. Malformed rows degrade gracefully (unknown type/role) —
62
- // including a corrupt `data` blob whose JSON.parse throws, so one bad row can't
63
- // abort the whole transcript read.
64
- function isRecord(v: unknown): v is Record<string, unknown> {
65
- return typeof v === "object" && v !== null;
66
- }
67
-
68
- function safeParse(data: string): unknown {
75
+ // JSON.parse throws on malformed input; return undefined so the blob schema's
76
+ // `.catch` fallback applies (one bad blob can't abort a transcript read).
77
+ function safeJsonParse(data: string): unknown {
69
78
  try {
70
79
  return JSON.parse(data);
71
80
  } catch {
@@ -73,39 +82,48 @@ function safeParse(data: string): unknown {
73
82
  }
74
83
  }
75
84
 
76
- function parsePart(data: string): SourcePart {
77
- const raw = safeParse(data);
78
- if (!isRecord(raw)) return { type: "unknown" };
79
- return {
80
- type: typeof raw.type === "string" ? raw.type : "unknown",
81
- text: typeof raw.text === "string" ? raw.text : undefined,
82
- tool: typeof raw.tool === "string" ? raw.tool : undefined,
83
- };
85
+ export function listSessions(db: Database): SourceSession[] {
86
+ const rows = db
87
+ .prepare(
88
+ `SELECT id, project_id, parent_id, title, directory, time_created, time_updated
89
+ FROM session WHERE time_archived IS NULL ORDER BY time_created`
90
+ )
91
+ .all();
92
+ return SessionRowSchema.array().parse(rows);
84
93
  }
85
94
 
86
- function parseRole(data: string): string {
87
- const raw = safeParse(data);
88
- return isRecord(raw) && typeof raw.role === "string" ? raw.role : "unknown";
95
+ export function getSession(db: Database, sessionId: string): SourceSession | null {
96
+ const row = db
97
+ .prepare(
98
+ `SELECT id, project_id, parent_id, title, directory, time_created, time_updated
99
+ FROM session WHERE id = ?`
100
+ )
101
+ .get(sessionId);
102
+ return row === null || row === undefined ? null : SessionRowSchema.parse(row);
89
103
  }
90
104
 
91
105
  export function getTranscript(db: Database, sessionId: string): SourceMessage[] {
92
- const messages = db
93
- .prepare<{ id: string; time_created: number; data: string }, [string]>(
94
- `SELECT id, time_created, data FROM message
95
- WHERE session_id = ? ORDER BY time_created, id`
96
- )
97
- .all(sessionId);
106
+ const messages = MessageRowSchema.array().parse(
107
+ db
108
+ .prepare(
109
+ `SELECT id, time_created, data FROM message
110
+ WHERE session_id = ? ORDER BY time_created, id`
111
+ )
112
+ .all(sessionId)
113
+ );
98
114
 
99
- const parts = db
100
- .prepare<{ message_id: string; data: string }, [string]>(
101
- `SELECT message_id, data FROM part
102
- WHERE session_id = ? ORDER BY time_created, id`
103
- )
104
- .all(sessionId);
115
+ const parts = PartRowSchema.array().parse(
116
+ db
117
+ .prepare(
118
+ `SELECT message_id, data FROM part
119
+ WHERE session_id = ? ORDER BY time_created, id`
120
+ )
121
+ .all(sessionId)
122
+ );
105
123
 
106
124
  const partsByMsg = new Map<string, SourcePart[]>();
107
125
  for (const p of parts) {
108
- const d = parsePart(p.data);
126
+ const d = PartDataSchema.parse(safeJsonParse(p.data));
109
127
  let list = partsByMsg.get(p.message_id);
110
128
  if (!list) partsByMsg.set(p.message_id, (list = []));
111
129
  list.push(d);
@@ -113,7 +131,7 @@ export function getTranscript(db: Database, sessionId: string): SourceMessage[]
113
131
 
114
132
  return messages.map((m) => ({
115
133
  id: m.id,
116
- role: parseRole(m.data),
134
+ role: MessageDataSchema.parse(safeJsonParse(m.data)).role,
117
135
  timeCreated: m.time_created,
118
136
  parts: partsByMsg.get(m.id) ?? [],
119
137
  }));