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/LICENSE +21 -0
- package/README.md +123 -0
- package/package.json +47 -0
- package/src/config.ts +54 -0
- package/src/curator.ts +154 -0
- package/src/db.ts +198 -0
- package/src/distillation.ts +426 -0
- package/src/gradient.ts +541 -0
- package/src/index.ts +324 -0
- package/src/ltm.ts +186 -0
- package/src/markdown.ts +81 -0
- package/src/prompt.ts +294 -0
- package/src/reflect.ts +153 -0
- package/src/temporal.ts +230 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import type { createOpencodeClient } from "@opencode-ai/sdk";
|
|
2
|
+
import { db, ensureProject } from "./db";
|
|
3
|
+
import { config } from "./config";
|
|
4
|
+
import * as temporal from "./temporal";
|
|
5
|
+
import {
|
|
6
|
+
DISTILLATION_SYSTEM,
|
|
7
|
+
distillationUser,
|
|
8
|
+
RECURSIVE_SYSTEM,
|
|
9
|
+
recursiveUser,
|
|
10
|
+
} from "./prompt";
|
|
11
|
+
import { needsUrgentDistillation } from "./gradient";
|
|
12
|
+
|
|
13
|
+
type Client = ReturnType<typeof createOpencodeClient>;
|
|
14
|
+
type TemporalMessage = temporal.TemporalMessage;
|
|
15
|
+
|
|
16
|
+
// Worker sessions keyed by parent session ID — hidden children, one per source session
|
|
17
|
+
const workerSessions = new Map<string, string>();
|
|
18
|
+
|
|
19
|
+
// Set of worker session IDs — used to skip storage and distillation for worker sessions
|
|
20
|
+
// Exported so curator.ts can register its own worker sessions here too
|
|
21
|
+
export const workerSessionIDs = new Set<string>();
|
|
22
|
+
|
|
23
|
+
export function isWorkerSession(sessionID: string): boolean {
|
|
24
|
+
return workerSessionIDs.has(sessionID);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function ensureWorkerSession(
|
|
28
|
+
client: Client,
|
|
29
|
+
parentID: string,
|
|
30
|
+
): Promise<string> {
|
|
31
|
+
const existing = workerSessions.get(parentID);
|
|
32
|
+
if (existing) return existing;
|
|
33
|
+
const session = await client.session.create({
|
|
34
|
+
body: { parentID, title: "lore distillation" },
|
|
35
|
+
});
|
|
36
|
+
const id = session.data!.id;
|
|
37
|
+
workerSessions.set(parentID, id);
|
|
38
|
+
workerSessionIDs.add(id);
|
|
39
|
+
return id;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Segment detection: group related messages together
|
|
43
|
+
function detectSegments(
|
|
44
|
+
messages: TemporalMessage[],
|
|
45
|
+
maxSegment: number,
|
|
46
|
+
): TemporalMessage[][] {
|
|
47
|
+
if (messages.length <= maxSegment) return [messages];
|
|
48
|
+
const segments: TemporalMessage[][] = [];
|
|
49
|
+
let current: TemporalMessage[] = [];
|
|
50
|
+
|
|
51
|
+
for (const msg of messages) {
|
|
52
|
+
current.push(msg);
|
|
53
|
+
// Split on segment size limit
|
|
54
|
+
if (current.length >= maxSegment) {
|
|
55
|
+
segments.push(current);
|
|
56
|
+
current = [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (current.length > 0) {
|
|
60
|
+
// Merge small trailing segment with previous if too small
|
|
61
|
+
if (current.length < 3 && segments.length > 0) {
|
|
62
|
+
segments[segments.length - 1].push(...current);
|
|
63
|
+
} else {
|
|
64
|
+
segments.push(current);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return segments;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function formatTime(ms: number): string {
|
|
71
|
+
const d = new Date(ms);
|
|
72
|
+
const h = d.getHours().toString().padStart(2, "0");
|
|
73
|
+
const m = d.getMinutes().toString().padStart(2, "0");
|
|
74
|
+
return `${h}:${m}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function messagesToText(messages: TemporalMessage[]): string {
|
|
78
|
+
return messages
|
|
79
|
+
.map((m) => `[${m.role}] (${formatTime(m.created_at)}) ${m.content}`)
|
|
80
|
+
.join("\n\n");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type DistillationResult = {
|
|
84
|
+
observations: string;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
function parseDistillationResult(text: string): DistillationResult | null {
|
|
88
|
+
// Extract content from <observations>...</observations> block
|
|
89
|
+
const match = text.match(/<observations>([\s\S]*?)<\/observations>/i);
|
|
90
|
+
const observations = match ? match[1].trim() : text.trim();
|
|
91
|
+
if (!observations) return null;
|
|
92
|
+
return { observations };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Get the most recent observations for context
|
|
96
|
+
function latestObservations(
|
|
97
|
+
projectPath: string,
|
|
98
|
+
sessionID: string,
|
|
99
|
+
): string | undefined {
|
|
100
|
+
const pid = ensureProject(projectPath);
|
|
101
|
+
const row = db()
|
|
102
|
+
.query(
|
|
103
|
+
"SELECT observations FROM distillations WHERE project_id = ? AND session_id = ? ORDER BY created_at DESC LIMIT 1",
|
|
104
|
+
)
|
|
105
|
+
.get(pid, sessionID) as { observations: string } | null;
|
|
106
|
+
return row?.observations || undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type Distillation = {
|
|
110
|
+
id: string;
|
|
111
|
+
project_id: string;
|
|
112
|
+
session_id: string;
|
|
113
|
+
observations: string;
|
|
114
|
+
source_ids: string[];
|
|
115
|
+
generation: number;
|
|
116
|
+
token_count: number;
|
|
117
|
+
created_at: number;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
function storeDistillation(input: {
|
|
121
|
+
projectPath: string;
|
|
122
|
+
sessionID: string;
|
|
123
|
+
observations: string;
|
|
124
|
+
sourceIDs: string[];
|
|
125
|
+
generation: number;
|
|
126
|
+
}): string {
|
|
127
|
+
const pid = ensureProject(input.projectPath);
|
|
128
|
+
const id = crypto.randomUUID();
|
|
129
|
+
const sourceJson = JSON.stringify(input.sourceIDs);
|
|
130
|
+
const tokens = Math.ceil(input.observations.length / 4);
|
|
131
|
+
db()
|
|
132
|
+
.query(
|
|
133
|
+
`INSERT INTO distillations (id, project_id, session_id, narrative, facts, observations, source_ids, generation, token_count, created_at)
|
|
134
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
135
|
+
)
|
|
136
|
+
.run(
|
|
137
|
+
id,
|
|
138
|
+
pid,
|
|
139
|
+
input.sessionID,
|
|
140
|
+
"", // legacy column — kept for schema compat
|
|
141
|
+
"[]", // legacy column — kept for schema compat
|
|
142
|
+
input.observations,
|
|
143
|
+
sourceJson,
|
|
144
|
+
input.generation,
|
|
145
|
+
tokens,
|
|
146
|
+
Date.now(),
|
|
147
|
+
);
|
|
148
|
+
return id;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function gen0Count(projectPath: string, sessionID: string): number {
|
|
152
|
+
const pid = ensureProject(projectPath);
|
|
153
|
+
return (
|
|
154
|
+
db()
|
|
155
|
+
.query(
|
|
156
|
+
"SELECT COUNT(*) as count FROM distillations WHERE project_id = ? AND session_id = ? AND generation = 0",
|
|
157
|
+
)
|
|
158
|
+
.get(pid, sessionID) as { count: number }
|
|
159
|
+
).count;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function loadGen0(projectPath: string, sessionID: string): Distillation[] {
|
|
163
|
+
const pid = ensureProject(projectPath);
|
|
164
|
+
const rows = db()
|
|
165
|
+
.query(
|
|
166
|
+
"SELECT id, project_id, session_id, observations, source_ids, generation, token_count, created_at FROM distillations WHERE project_id = ? AND session_id = ? AND generation = 0 ORDER BY created_at ASC",
|
|
167
|
+
)
|
|
168
|
+
.all(pid, sessionID) as Array<{
|
|
169
|
+
id: string;
|
|
170
|
+
project_id: string;
|
|
171
|
+
session_id: string;
|
|
172
|
+
observations: string;
|
|
173
|
+
source_ids: string;
|
|
174
|
+
generation: number;
|
|
175
|
+
token_count: number;
|
|
176
|
+
created_at: number;
|
|
177
|
+
}>;
|
|
178
|
+
return rows.map((r) => ({
|
|
179
|
+
...r,
|
|
180
|
+
source_ids: JSON.parse(r.source_ids) as string[],
|
|
181
|
+
}));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function removeDistillations(ids: string[]) {
|
|
185
|
+
if (!ids.length) return;
|
|
186
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
187
|
+
db()
|
|
188
|
+
.query(`DELETE FROM distillations WHERE id IN (${placeholders})`)
|
|
189
|
+
.run(...ids);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Reset messages that were marked distilled by a previous format/run but aren't
|
|
193
|
+
// covered by any current distillation. This happens when distillations are deleted
|
|
194
|
+
// (e.g., format migration from v1 to v2) but the temporal messages keep distilled=1.
|
|
195
|
+
function resetOrphans(projectPath: string, sessionID: string): number {
|
|
196
|
+
const pid = ensureProject(projectPath);
|
|
197
|
+
// Collect all message IDs referenced by existing distillations
|
|
198
|
+
const rows = db()
|
|
199
|
+
.query(
|
|
200
|
+
"SELECT source_ids FROM distillations WHERE project_id = ? AND session_id = ?",
|
|
201
|
+
)
|
|
202
|
+
.all(pid, sessionID) as Array<{ source_ids: string }>;
|
|
203
|
+
const covered = new Set<string>();
|
|
204
|
+
for (const r of rows) {
|
|
205
|
+
for (const id of JSON.parse(r.source_ids) as string[]) covered.add(id);
|
|
206
|
+
}
|
|
207
|
+
if (rows.length === 0) {
|
|
208
|
+
// No distillations at all — reset everything to undistilled
|
|
209
|
+
const result = db()
|
|
210
|
+
.query(
|
|
211
|
+
"UPDATE temporal_messages SET distilled = 0 WHERE project_id = ? AND session_id = ? AND distilled = 1",
|
|
212
|
+
)
|
|
213
|
+
.run(pid, sessionID);
|
|
214
|
+
return result.changes;
|
|
215
|
+
}
|
|
216
|
+
// Find orphans: marked distilled but not in any source_ids
|
|
217
|
+
const distilled = db()
|
|
218
|
+
.query(
|
|
219
|
+
"SELECT id FROM temporal_messages WHERE project_id = ? AND session_id = ? AND distilled = 1",
|
|
220
|
+
)
|
|
221
|
+
.all(pid, sessionID) as Array<{ id: string }>;
|
|
222
|
+
const orphans = distilled.filter((m) => !covered.has(m.id)).map((m) => m.id);
|
|
223
|
+
if (!orphans.length) return 0;
|
|
224
|
+
// Reset in batches to avoid SQLite parameter limit
|
|
225
|
+
const batch = 500;
|
|
226
|
+
for (let i = 0; i < orphans.length; i += batch) {
|
|
227
|
+
const chunk = orphans.slice(i, i + batch);
|
|
228
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
229
|
+
db()
|
|
230
|
+
.query(
|
|
231
|
+
`UPDATE temporal_messages SET distilled = 0 WHERE id IN (${placeholders})`,
|
|
232
|
+
)
|
|
233
|
+
.run(...chunk);
|
|
234
|
+
}
|
|
235
|
+
return orphans.length;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Main distillation entry point — called on session.idle or when urgent
|
|
239
|
+
export async function run(input: {
|
|
240
|
+
client: Client;
|
|
241
|
+
projectPath: string;
|
|
242
|
+
sessionID: string;
|
|
243
|
+
model?: { providerID: string; modelID: string };
|
|
244
|
+
/** Skip minMessages threshold check — distill whatever is pending */
|
|
245
|
+
force?: boolean;
|
|
246
|
+
}): Promise<{ rounds: number; distilled: number }> {
|
|
247
|
+
// Reset orphaned messages (marked distilled by a deleted/migrated distillation)
|
|
248
|
+
const orphans = resetOrphans(input.projectPath, input.sessionID);
|
|
249
|
+
if (orphans > 0) {
|
|
250
|
+
console.error(
|
|
251
|
+
`[lore] Reset ${orphans} orphaned messages for re-observation`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const cfg = config();
|
|
256
|
+
const maxRounds = 3;
|
|
257
|
+
let rounds = 0;
|
|
258
|
+
let distilled = 0;
|
|
259
|
+
|
|
260
|
+
for (let round = 0; round < maxRounds; round++) {
|
|
261
|
+
// Check if there are enough undistilled messages
|
|
262
|
+
const pending = temporal.undistilled(input.projectPath, input.sessionID);
|
|
263
|
+
if (
|
|
264
|
+
!input.force &&
|
|
265
|
+
pending.length < cfg.distillation.minMessages &&
|
|
266
|
+
round === 0
|
|
267
|
+
)
|
|
268
|
+
break;
|
|
269
|
+
|
|
270
|
+
if (pending.length > 0) {
|
|
271
|
+
const segments = detectSegments(pending, cfg.distillation.maxSegment);
|
|
272
|
+
for (const segment of segments) {
|
|
273
|
+
const result = await distillSegment({
|
|
274
|
+
client: input.client,
|
|
275
|
+
projectPath: input.projectPath,
|
|
276
|
+
sessionID: input.sessionID,
|
|
277
|
+
messages: segment,
|
|
278
|
+
model: input.model,
|
|
279
|
+
});
|
|
280
|
+
if (result) {
|
|
281
|
+
distilled += segment.length;
|
|
282
|
+
rounds++;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Check if meta-distillation is needed
|
|
288
|
+
if (
|
|
289
|
+
gen0Count(input.projectPath, input.sessionID) >=
|
|
290
|
+
cfg.distillation.metaThreshold
|
|
291
|
+
) {
|
|
292
|
+
await metaDistill({
|
|
293
|
+
client: input.client,
|
|
294
|
+
projectPath: input.projectPath,
|
|
295
|
+
sessionID: input.sessionID,
|
|
296
|
+
model: input.model,
|
|
297
|
+
});
|
|
298
|
+
rounds++;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Check if we still need urgent distillation
|
|
302
|
+
if (!needsUrgentDistillation()) break;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return { rounds, distilled };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function distillSegment(input: {
|
|
309
|
+
client: Client;
|
|
310
|
+
projectPath: string;
|
|
311
|
+
sessionID: string;
|
|
312
|
+
messages: TemporalMessage[];
|
|
313
|
+
model?: { providerID: string; modelID: string };
|
|
314
|
+
}): Promise<DistillationResult | null> {
|
|
315
|
+
const prior = latestObservations(input.projectPath, input.sessionID);
|
|
316
|
+
const text = messagesToText(input.messages);
|
|
317
|
+
// Derive session date from first message timestamp
|
|
318
|
+
const first = input.messages[0];
|
|
319
|
+
const date = first
|
|
320
|
+
? new Date(first.created_at).toLocaleDateString("en-US", {
|
|
321
|
+
year: "numeric",
|
|
322
|
+
month: "long",
|
|
323
|
+
day: "numeric",
|
|
324
|
+
})
|
|
325
|
+
: "unknown date";
|
|
326
|
+
const userContent = distillationUser({
|
|
327
|
+
priorObservations: prior,
|
|
328
|
+
date,
|
|
329
|
+
messages: text,
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const workerID = await ensureWorkerSession(input.client, input.sessionID);
|
|
333
|
+
const model = input.model ?? config().model;
|
|
334
|
+
const parts = [
|
|
335
|
+
{ type: "text" as const, text: `${DISTILLATION_SYSTEM}\n\n${userContent}` },
|
|
336
|
+
];
|
|
337
|
+
|
|
338
|
+
await input.client.session.prompt({
|
|
339
|
+
path: { id: workerID },
|
|
340
|
+
body: {
|
|
341
|
+
parts,
|
|
342
|
+
agent: "lore-distill",
|
|
343
|
+
...(model ? { model } : {}),
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// Read the response
|
|
348
|
+
const msgs = await input.client.session.messages({
|
|
349
|
+
path: { id: workerID },
|
|
350
|
+
query: { limit: 2 },
|
|
351
|
+
});
|
|
352
|
+
const last = msgs.data?.at(-1);
|
|
353
|
+
if (!last || last.info.role !== "assistant") return null;
|
|
354
|
+
|
|
355
|
+
const responsePart = last.parts.find((p) => p.type === "text");
|
|
356
|
+
if (!responsePart || responsePart.type !== "text") return null;
|
|
357
|
+
|
|
358
|
+
const result = parseDistillationResult(responsePart.text);
|
|
359
|
+
if (!result) return null;
|
|
360
|
+
|
|
361
|
+
storeDistillation({
|
|
362
|
+
projectPath: input.projectPath,
|
|
363
|
+
sessionID: input.sessionID,
|
|
364
|
+
observations: result.observations,
|
|
365
|
+
sourceIDs: input.messages.map((m) => m.id),
|
|
366
|
+
generation: 0,
|
|
367
|
+
});
|
|
368
|
+
temporal.markDistilled(input.messages.map((m) => m.id));
|
|
369
|
+
return result;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function metaDistill(input: {
|
|
373
|
+
client: Client;
|
|
374
|
+
projectPath: string;
|
|
375
|
+
sessionID: string;
|
|
376
|
+
model?: { providerID: string; modelID: string };
|
|
377
|
+
}): Promise<DistillationResult | null> {
|
|
378
|
+
const existing = loadGen0(input.projectPath, input.sessionID);
|
|
379
|
+
if (existing.length < 3) return null;
|
|
380
|
+
|
|
381
|
+
const userContent = recursiveUser(existing);
|
|
382
|
+
|
|
383
|
+
const workerID = await ensureWorkerSession(input.client, input.sessionID);
|
|
384
|
+
const model = input.model ?? config().model;
|
|
385
|
+
const parts = [
|
|
386
|
+
{ type: "text" as const, text: `${RECURSIVE_SYSTEM}\n\n${userContent}` },
|
|
387
|
+
];
|
|
388
|
+
|
|
389
|
+
await input.client.session.prompt({
|
|
390
|
+
path: { id: workerID },
|
|
391
|
+
body: {
|
|
392
|
+
parts,
|
|
393
|
+
agent: "lore-distill",
|
|
394
|
+
...(model ? { model } : {}),
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const msgs = await input.client.session.messages({
|
|
399
|
+
path: { id: workerID },
|
|
400
|
+
query: { limit: 2 },
|
|
401
|
+
});
|
|
402
|
+
const last = msgs.data?.at(-1);
|
|
403
|
+
if (!last || last.info.role !== "assistant") return null;
|
|
404
|
+
|
|
405
|
+
const responsePart = last.parts.find((p) => p.type === "text");
|
|
406
|
+
if (!responsePart || responsePart.type !== "text") return null;
|
|
407
|
+
|
|
408
|
+
const result = parseDistillationResult(responsePart.text);
|
|
409
|
+
if (!result) return null;
|
|
410
|
+
|
|
411
|
+
// Store the meta-distillation at generation N+1
|
|
412
|
+
const maxGen = Math.max(...existing.map((d) => d.generation));
|
|
413
|
+
const allSourceIDs = existing.flatMap((d) => d.source_ids);
|
|
414
|
+
storeDistillation({
|
|
415
|
+
projectPath: input.projectPath,
|
|
416
|
+
sessionID: input.sessionID,
|
|
417
|
+
observations: result.observations,
|
|
418
|
+
sourceIDs: allSourceIDs,
|
|
419
|
+
generation: maxGen + 1,
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// Remove the gen-0 distillations that were merged
|
|
423
|
+
removeDistillations(existing.map((d) => d.id));
|
|
424
|
+
|
|
425
|
+
return result;
|
|
426
|
+
}
|