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 +5 -4
- package/src/reader.test.ts +139 -0
- package/src/reader.ts +87 -69
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-episodic-memory",
|
|
3
|
-
"version": "0.1.
|
|
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": "
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
87
|
-
const
|
|
88
|
-
|
|
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 =
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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 =
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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 =
|
|
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:
|
|
134
|
+
role: MessageDataSchema.parse(safeJsonParse(m.data)).role,
|
|
117
135
|
timeCreated: m.time_created,
|
|
118
136
|
parts: partsByMsg.get(m.id) ?? [],
|
|
119
137
|
}));
|