opencode-lore 0.1.0

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/src/index.ts ADDED
@@ -0,0 +1,324 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ import { load, config } from "./config";
3
+ import { ensureProject } from "./db";
4
+ import * as temporal from "./temporal";
5
+ import * as ltm from "./ltm";
6
+ import * as distillation from "./distillation";
7
+ import * as curator from "./curator";
8
+ import {
9
+ transform,
10
+ setModelLimits,
11
+ needsUrgentDistillation,
12
+ calibrate,
13
+ estimateMessages,
14
+ } from "./gradient";
15
+ import { formatKnowledge } from "./prompt";
16
+ import { createRecallTool } from "./reflect";
17
+
18
+ export const LorePlugin: Plugin = async (ctx) => {
19
+ const projectPath = ctx.worktree || ctx.directory;
20
+ await load(ctx.directory);
21
+ ensureProject(projectPath);
22
+
23
+ // Track user turns for periodic curation
24
+ let turnsSinceCuration = 0;
25
+
26
+ // Track active sessions for distillation
27
+ const activeSessions = new Set<string>();
28
+
29
+ // Sessions to skip for temporal storage and distillation. Includes worker sessions
30
+ // (distillation, curator) and child sessions (eval, any other children).
31
+ // Checked once per session ID and cached to avoid repeated API calls.
32
+ const skipSessions = new Set<string>();
33
+
34
+ async function shouldSkip(sessionID: string): Promise<boolean> {
35
+ if (distillation.isWorkerSession(sessionID)) return true;
36
+ if (skipSessions.has(sessionID)) return true;
37
+ if (activeSessions.has(sessionID)) return false; // already known good
38
+ // First encounter — check if this is a child session.
39
+ // session.get() uses exact storage key lookup and only works with full IDs
40
+ // (e.g. "ses_384e7de8dffeBDc4Z3dK9kfx1k"). Message events deliver short IDs
41
+ // (e.g. "ses_384e7de8dffe") which cause session.get() to fail with NotFound.
42
+ // Fall back to the session list to find a session whose full ID starts with
43
+ // the short ID, then check its parentID.
44
+ try {
45
+ const session = await ctx.client.session.get({ path: { id: sessionID } });
46
+ if (session.data?.parentID) {
47
+ skipSessions.add(sessionID);
48
+ return true;
49
+ }
50
+ } catch {
51
+ // session.get failed (likely short ID) — search list for matching full ID
52
+ try {
53
+ const list = await ctx.client.session.list();
54
+ const match = list.data?.find((s) => s.id.startsWith(sessionID));
55
+ if (match?.parentID) {
56
+ skipSessions.add(sessionID);
57
+ return true;
58
+ }
59
+ } catch {
60
+ // If we can't fetch session info, don't skip
61
+ }
62
+ }
63
+ return false;
64
+ }
65
+
66
+ // Background distillation — debounced, non-blocking
67
+ let distilling = false;
68
+ async function backgroundDistill(sessionID: string, force?: boolean) {
69
+ if (distilling) return;
70
+ distilling = true;
71
+ try {
72
+ const cfg = config();
73
+ const pending = temporal.undistilledCount(projectPath, sessionID);
74
+ if (
75
+ force ||
76
+ pending >= cfg.distillation.minMessages ||
77
+ needsUrgentDistillation()
78
+ ) {
79
+ await distillation.run({
80
+ client: ctx.client,
81
+ projectPath,
82
+ sessionID,
83
+ model: cfg.model,
84
+ force,
85
+ });
86
+ }
87
+ } catch (e) {
88
+ console.error("[lore] distillation error:", e);
89
+ } finally {
90
+ distilling = false;
91
+ }
92
+ }
93
+
94
+ async function backgroundCurate(sessionID: string) {
95
+ try {
96
+ const cfg = config();
97
+ if (!cfg.curator.enabled) return;
98
+ await curator.run({
99
+ client: ctx.client,
100
+ projectPath,
101
+ sessionID,
102
+ model: cfg.model,
103
+ });
104
+ } catch (e) {
105
+ console.error("[lore] curator error:", e);
106
+ }
107
+ }
108
+
109
+ return {
110
+ // Disable built-in compaction and register hidden worker agents
111
+ config: async (input) => {
112
+ const cfg = input as Record<string, unknown>;
113
+ cfg.compaction = { auto: false, prune: false };
114
+ cfg.agent = {
115
+ ...(cfg.agent as Record<string, unknown> | undefined),
116
+ "lore-distill": {
117
+ hidden: true,
118
+ description: "Lore memory distillation worker",
119
+ },
120
+ "lore-curator": {
121
+ hidden: true,
122
+ description: "Lore knowledge curator worker",
123
+ },
124
+ };
125
+ },
126
+
127
+ // Store all messages in temporal DB for full-text search and distillation.
128
+ // Skips child sessions (eval, worker) to prevent pollution.
129
+ event: async ({ event }) => {
130
+ if (event.type === "message.updated") {
131
+ const msg = event.properties.info;
132
+ if (await shouldSkip(msg.sessionID)) return;
133
+ try {
134
+ const full = await ctx.client.session.message({
135
+ path: { id: msg.sessionID, messageID: msg.id },
136
+ });
137
+ if (full.data) {
138
+ temporal.store({
139
+ projectPath,
140
+ info: full.data.info,
141
+ parts: full.data.parts,
142
+ });
143
+ activeSessions.add(msg.sessionID);
144
+ if (msg.role === "user") turnsSinceCuration++;
145
+
146
+ // Incremental distillation: when undistilled messages accumulate past
147
+ // maxSegment, distill immediately instead of waiting for session.idle.
148
+ if (
149
+ msg.role === "assistant" &&
150
+ msg.tokens &&
151
+ (msg.tokens.input > 0 || msg.tokens.cache.read > 0)
152
+ ) {
153
+ const pending = temporal.undistilledCount(projectPath, msg.sessionID);
154
+ if (pending >= config().distillation.maxSegment) {
155
+ console.error(
156
+ `[lore] incremental distillation: ${pending} undistilled messages in ${msg.sessionID.substring(0, 16)}`,
157
+ );
158
+ backgroundDistill(msg.sessionID);
159
+ }
160
+
161
+ // Calibrate overhead estimate using real token counts
162
+ const allMsgs = await ctx.client.session.messages({
163
+ path: { id: msg.sessionID },
164
+ });
165
+ if (allMsgs.data) {
166
+ const withParts = allMsgs.data
167
+ .filter((m) => m.info.id !== msg.id)
168
+ .map((m) => ({ info: m.info, parts: m.parts }));
169
+ const msgEstimate = estimateMessages(withParts);
170
+ const actualInput = msg.tokens.input + msg.tokens.cache.read;
171
+ calibrate(actualInput, msgEstimate);
172
+ }
173
+ }
174
+ }
175
+ } catch {
176
+ // Message may not be fetchable yet during streaming
177
+ }
178
+ }
179
+
180
+ if (event.type === "session.idle") {
181
+ const sessionID = event.properties.sessionID;
182
+ if (await shouldSkip(sessionID)) return;
183
+ if (!activeSessions.has(sessionID)) return;
184
+
185
+ // Run background distillation for any remaining undistilled messages
186
+ backgroundDistill(sessionID);
187
+
188
+ // Run curator periodically
189
+ const cfg = config();
190
+ if (
191
+ cfg.curator.onIdle ||
192
+ turnsSinceCuration >= cfg.curator.afterTurns
193
+ ) {
194
+ backgroundCurate(sessionID);
195
+ turnsSinceCuration = 0;
196
+ }
197
+ }
198
+ },
199
+
200
+ // Inject LTM knowledge into system prompt
201
+ "experimental.chat.system.transform": async (input, output) => {
202
+ if (input.model?.limit) {
203
+ setModelLimits(input.model.limit);
204
+ }
205
+
206
+ const entries = ltm.forProject(projectPath, config().crossProject);
207
+ if (!entries.length) return;
208
+
209
+ const formatted = formatKnowledge(
210
+ entries.map((e) => ({
211
+ category: e.category,
212
+ title: e.title,
213
+ content: e.content,
214
+ })),
215
+ );
216
+ if (formatted) {
217
+ output.system.push(formatted);
218
+ }
219
+ },
220
+
221
+ // Transform message history: distilled prefix + raw recent
222
+ "experimental.chat.messages.transform": async (_input, output) => {
223
+ if (!output.messages.length) return;
224
+
225
+ const sessionID = output.messages[0]?.info.sessionID;
226
+
227
+ const lastUserMsg = [...output.messages].reverse().find((m) => m.info.role === "user");
228
+ const statsPart = lastUserMsg?.parts.find((p) => p.type === "text");
229
+
230
+ const result = transform({
231
+ messages: output.messages,
232
+ projectPath,
233
+ sessionID,
234
+ });
235
+ while (
236
+ result.messages.length > 0 &&
237
+ result.messages.at(-1)!.info.role !== "user"
238
+ ) {
239
+ const last = result.messages.at(-1)!;
240
+ if (last.parts.some((p) => p.type === "tool")) break;
241
+ const dropped = result.messages.pop()!;
242
+ console.error(
243
+ "[lore] WARN: dropping trailing",
244
+ dropped.info.role,
245
+ "message to prevent prefill error. id:",
246
+ dropped.info.id,
247
+ );
248
+ }
249
+ output.messages.splice(0, output.messages.length, ...result.messages);
250
+
251
+ if (result.layer >= 2 && sessionID) {
252
+ backgroundDistill(sessionID);
253
+ }
254
+
255
+ if (sessionID && statsPart && lastUserMsg) {
256
+ const loreMeta = {
257
+ layer: result.layer,
258
+ distilledTokens: result.distilledTokens,
259
+ rawTokens: result.rawTokens,
260
+ totalTokens: result.totalTokens,
261
+ usable: result.usable,
262
+ distilledBudget: result.distilledBudget,
263
+ rawBudget: result.rawBudget,
264
+ updatedAt: Date.now(),
265
+ };
266
+ const url = new URL(
267
+ `/session/${sessionID}/message/${lastUserMsg.info.id}/part/${statsPart.id}`,
268
+ ctx.serverUrl,
269
+ );
270
+ const updatedPart = {
271
+ ...(statsPart as Record<string, unknown>),
272
+ metadata: {
273
+ ...((statsPart as { metadata?: Record<string, unknown> }).metadata ?? {}),
274
+ lore: loreMeta,
275
+ },
276
+ };
277
+ fetch(url, {
278
+ method: "PATCH",
279
+ headers: { "Content-Type": "application/json" },
280
+ body: JSON.stringify(updatedPart),
281
+ }).catch((e: unknown) => {
282
+ console.error("[lore] failed to write gradient stats to part metadata:", e);
283
+ });
284
+ }
285
+ },
286
+
287
+ // Replace compaction prompt with distillation-aware prompt when manual /compact is used
288
+ "experimental.session.compacting": async (input, output) => {
289
+ const entries = ltm.forProject(projectPath, config().crossProject);
290
+ const knowledge = entries.length
291
+ ? formatKnowledge(
292
+ entries.map((e) => ({
293
+ category: e.category,
294
+ title: e.title,
295
+ content: e.content,
296
+ })),
297
+ )
298
+ : "";
299
+
300
+ output.prompt = `You are creating a distilled memory summary for an AI coding agent. This summary will be the ONLY context available in the next part of the conversation.
301
+
302
+ Structure your response as follows:
303
+
304
+ ## Session History
305
+
306
+ For each major topic or task covered in the conversation, write:
307
+ - A 1-3 sentence narrative of what happened (past tense, focus on outcomes)
308
+ - A bullet list of specific, actionable facts (file paths, values, decisions, what failed and why)
309
+
310
+ PRESERVE: file paths, specific values, decisions with rationale, user preferences, failed approaches with reasons, environment details.
311
+ DROP: debugging back-and-forth, verbose tool output, pleasantries, redundant restatements.
312
+
313
+ ${knowledge ? `\n${knowledge}\n` : ""}
314
+ End with "I'm ready to continue." so the agent knows to pick up where it left off.`;
315
+ },
316
+
317
+ // Register the recall tool
318
+ tool: {
319
+ recall: createRecallTool(projectPath),
320
+ },
321
+ };
322
+ };
323
+
324
+ export default LorePlugin;
package/src/ltm.ts ADDED
@@ -0,0 +1,186 @@
1
+ import { db, ensureProject } from "./db";
2
+ import { ftsQuery } from "./temporal";
3
+
4
+ export type KnowledgeEntry = {
5
+ id: string;
6
+ project_id: string | null;
7
+ category: string;
8
+ title: string;
9
+ content: string;
10
+ source_session: string | null;
11
+ cross_project: number;
12
+ confidence: number;
13
+ created_at: number;
14
+ updated_at: number;
15
+ metadata: string | null;
16
+ };
17
+
18
+ export function create(input: {
19
+ projectPath?: string;
20
+ category: string;
21
+ title: string;
22
+ content: string;
23
+ session?: string;
24
+ scope: "project" | "global";
25
+ crossProject?: boolean;
26
+ }): string {
27
+ const pid =
28
+ input.scope === "project" && input.projectPath
29
+ ? ensureProject(input.projectPath)
30
+ : null;
31
+ const id = crypto.randomUUID();
32
+ const now = Date.now();
33
+ db()
34
+ .query(
35
+ `INSERT INTO knowledge (id, project_id, category, title, content, source_session, cross_project, confidence, created_at, updated_at)
36
+ VALUES (?, ?, ?, ?, ?, ?, ?, 1.0, ?, ?)`,
37
+ )
38
+ .run(
39
+ id,
40
+ pid,
41
+ input.category,
42
+ input.title,
43
+ input.content,
44
+ input.session ?? null,
45
+ (input.crossProject ?? true) ? 1 : 0,
46
+ now,
47
+ now,
48
+ );
49
+ return id;
50
+ }
51
+
52
+ export function update(
53
+ id: string,
54
+ input: { content?: string; confidence?: number },
55
+ ) {
56
+ const sets: string[] = [];
57
+ const params: unknown[] = [];
58
+ if (input.content !== undefined) {
59
+ sets.push("content = ?");
60
+ params.push(input.content);
61
+ }
62
+ if (input.confidence !== undefined) {
63
+ sets.push("confidence = ?");
64
+ params.push(input.confidence);
65
+ }
66
+ sets.push("updated_at = ?");
67
+ params.push(Date.now());
68
+ params.push(id);
69
+ db()
70
+ .query(`UPDATE knowledge SET ${sets.join(", ")} WHERE id = ?`)
71
+ .run(...(params as [string, ...string[]]));
72
+ }
73
+
74
+ export function remove(id: string) {
75
+ db().query("DELETE FROM knowledge WHERE id = ?").run(id);
76
+ }
77
+
78
+ export function forProject(
79
+ projectPath: string,
80
+ includeCross = true,
81
+ ): KnowledgeEntry[] {
82
+ const pid = ensureProject(projectPath);
83
+ if (includeCross) {
84
+ return db()
85
+ .query(
86
+ `SELECT * FROM knowledge
87
+ WHERE (project_id = ? OR (project_id IS NULL) OR (cross_project = 1))
88
+ AND confidence > 0.2
89
+ ORDER BY confidence DESC, updated_at DESC`,
90
+ )
91
+ .all(pid) as KnowledgeEntry[];
92
+ }
93
+ return db()
94
+ .query(
95
+ `SELECT * FROM knowledge
96
+ WHERE (project_id = ? OR project_id IS NULL)
97
+ AND confidence > 0.2
98
+ ORDER BY confidence DESC, updated_at DESC`,
99
+ )
100
+ .all(pid) as KnowledgeEntry[];
101
+ }
102
+
103
+ export function all(): KnowledgeEntry[] {
104
+ return db()
105
+ .query(
106
+ "SELECT * FROM knowledge WHERE confidence > 0.2 ORDER BY confidence DESC, updated_at DESC",
107
+ )
108
+ .all() as KnowledgeEntry[];
109
+ }
110
+
111
+ // LIKE-based fallback for when FTS5 fails unexpectedly.
112
+ function searchLike(input: {
113
+ query: string;
114
+ projectPath?: string;
115
+ limit: number;
116
+ }): KnowledgeEntry[] {
117
+ const terms = input.query
118
+ .toLowerCase()
119
+ .split(/\s+/)
120
+ .filter((t) => t.length > 2);
121
+ if (!terms.length) return [];
122
+ const conditions = terms
123
+ .map(() => "(LOWER(title) LIKE ? OR LOWER(content) LIKE ?)")
124
+ .join(" AND ");
125
+ const likeParams = terms.flatMap((t) => [`%${t}%`, `%${t}%`]);
126
+ if (input.projectPath) {
127
+ const pid = ensureProject(input.projectPath);
128
+ return db()
129
+ .query(
130
+ `SELECT * FROM knowledge WHERE (project_id = ? OR project_id IS NULL OR cross_project = 1) AND confidence > 0.2 AND ${conditions} ORDER BY updated_at DESC LIMIT ?`,
131
+ )
132
+ .all(pid, ...likeParams, input.limit) as KnowledgeEntry[];
133
+ }
134
+ return db()
135
+ .query(
136
+ `SELECT * FROM knowledge WHERE confidence > 0.2 AND ${conditions} ORDER BY updated_at DESC LIMIT ?`,
137
+ )
138
+ .all(...likeParams, input.limit) as KnowledgeEntry[];
139
+ }
140
+
141
+ export function search(input: {
142
+ query: string;
143
+ projectPath?: string;
144
+ limit?: number;
145
+ }): KnowledgeEntry[] {
146
+ const limit = input.limit ?? 20;
147
+ const q = ftsQuery(input.query);
148
+ if (input.projectPath) {
149
+ const pid = ensureProject(input.projectPath);
150
+ try {
151
+ return db()
152
+ .query(
153
+ `SELECT k.* FROM knowledge k
154
+ WHERE k.rowid IN (SELECT rowid FROM knowledge_fts WHERE knowledge_fts MATCH ?)
155
+ AND (k.project_id = ? OR k.project_id IS NULL OR k.cross_project = 1)
156
+ AND k.confidence > 0.2
157
+ ORDER BY k.updated_at DESC LIMIT ?`,
158
+ )
159
+ .all(q, pid, limit) as KnowledgeEntry[];
160
+ } catch {
161
+ return searchLike({
162
+ query: input.query,
163
+ projectPath: input.projectPath,
164
+ limit,
165
+ });
166
+ }
167
+ }
168
+ try {
169
+ return db()
170
+ .query(
171
+ `SELECT k.* FROM knowledge k
172
+ WHERE k.rowid IN (SELECT rowid FROM knowledge_fts WHERE knowledge_fts MATCH ?)
173
+ AND k.confidence > 0.2
174
+ ORDER BY k.updated_at DESC LIMIT ?`,
175
+ )
176
+ .all(q, limit) as KnowledgeEntry[];
177
+ } catch {
178
+ return searchLike({ query: input.query, limit });
179
+ }
180
+ }
181
+
182
+ export function get(id: string): KnowledgeEntry | null {
183
+ return db()
184
+ .query("SELECT * FROM knowledge WHERE id = ?")
185
+ .get(id) as KnowledgeEntry | null;
186
+ }
@@ -0,0 +1,81 @@
1
+ import { remark } from "remark";
2
+ import type {
3
+ Root,
4
+ Heading,
5
+ List,
6
+ ListItem,
7
+ Paragraph,
8
+ Text,
9
+ Strong,
10
+ BlockContent,
11
+ PhrasingContent,
12
+ } from "mdast";
13
+
14
+ // Reuse a single processor — remark freezes on first use anyway
15
+ const processor = remark();
16
+
17
+ // Serialize an mdast tree to a markdown string.
18
+ // The serializer automatically escapes any characters in text nodes
19
+ // that would be structurally ambiguous (code fences, headings, list
20
+ // markers, thematic breaks, etc.), so callers never need to pre-escape.
21
+ export function serialize(tree: Root): string {
22
+ return processor.stringify(tree);
23
+ }
24
+
25
+ // Collapse newlines in LLM-generated text before inserting into a text node.
26
+ // Embedded blank lines (\n\n) cause list items to become "spread" (loose),
27
+ // which then breaks the surrounding markdown structure on re-parse.
28
+ // Newlines within a single fact/narrative are replaced with a space.
29
+ export function inline(value: string): string {
30
+ return value.replace(/\s*\n\s*/g, " ").trim();
31
+ }
32
+
33
+ // Normalize arbitrary markdown via parse → stringify roundtrip.
34
+ // Used for content we don't control (e.g. existing text parts in Layer 4
35
+ // after tool parts are stripped out), where we can't build from AST.
36
+ // Two passes are needed: remark's asterisk/underscore escaping can introduce
37
+ // new sequences on the first pass that the second pass then stabilizes.
38
+ export function normalize(md: string): string {
39
+ const once = processor.stringify(processor.parse(md));
40
+ return processor.stringify(processor.parse(once));
41
+ }
42
+
43
+ // --- Node builders ---
44
+
45
+ export function h(depth: 1 | 2 | 3 | 4 | 5 | 6, value: string): Heading {
46
+ return { type: "heading", depth, children: [t(value)] };
47
+ }
48
+
49
+ export function p(value: string): Paragraph {
50
+ return { type: "paragraph", children: [t(value)] };
51
+ }
52
+
53
+ export function ul(items: ListItem[]): List {
54
+ return { type: "list", ordered: false, spread: false, children: items };
55
+ }
56
+
57
+ export function li(...children: BlockContent[]): ListItem {
58
+ return { type: "listItem", spread: false, children };
59
+ }
60
+
61
+ // List item containing a single paragraph (the common case for facts/entries)
62
+ export function lip(value: string): ListItem {
63
+ return li(p(value));
64
+ }
65
+
66
+ // List item with inline phrasing content — e.g. **bold**: text
67
+ export function liph(...children: PhrasingContent[]): ListItem {
68
+ return li({ type: "paragraph", children });
69
+ }
70
+
71
+ export function t(value: string): Text {
72
+ return { type: "text", value };
73
+ }
74
+
75
+ export function strong(value: string): Strong {
76
+ return { type: "strong", children: [t(value)] };
77
+ }
78
+
79
+ export function root(...children: Root["children"]): Root {
80
+ return { type: "root", children };
81
+ }