@loreai/core 0.11.1 → 0.13.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/dist/bun/agents-file.d.ts +29 -8
- package/dist/bun/agents-file.d.ts.map +1 -1
- package/dist/bun/config.d.ts +1 -0
- package/dist/bun/config.d.ts.map +1 -1
- package/dist/bun/db.d.ts.map +1 -1
- package/dist/bun/distillation.d.ts +55 -0
- package/dist/bun/distillation.d.ts.map +1 -1
- package/dist/bun/embedding.d.ts +15 -1
- package/dist/bun/embedding.d.ts.map +1 -1
- package/dist/bun/gradient.d.ts +53 -5
- package/dist/bun/gradient.d.ts.map +1 -1
- package/dist/bun/index.d.ts +4 -4
- package/dist/bun/index.d.ts.map +1 -1
- package/dist/bun/index.js +799 -256
- package/dist/bun/index.js.map +4 -4
- package/dist/bun/pattern-extract.d.ts +36 -0
- package/dist/bun/pattern-extract.d.ts.map +1 -0
- package/dist/bun/recall.d.ts +1 -0
- package/dist/bun/recall.d.ts.map +1 -1
- package/dist/bun/search.d.ts +13 -1
- package/dist/bun/search.d.ts.map +1 -1
- package/dist/bun/temporal.d.ts +15 -0
- package/dist/bun/temporal.d.ts.map +1 -1
- package/dist/bun/types.d.ts +41 -1
- package/dist/bun/types.d.ts.map +1 -1
- package/dist/bun/worker-model.d.ts +22 -0
- package/dist/bun/worker-model.d.ts.map +1 -1
- package/dist/node/agents-file.d.ts +29 -8
- package/dist/node/agents-file.d.ts.map +1 -1
- package/dist/node/config.d.ts +1 -0
- package/dist/node/config.d.ts.map +1 -1
- package/dist/node/db.d.ts.map +1 -1
- package/dist/node/distillation.d.ts +55 -0
- package/dist/node/distillation.d.ts.map +1 -1
- package/dist/node/embedding.d.ts +15 -1
- package/dist/node/embedding.d.ts.map +1 -1
- package/dist/node/gradient.d.ts +53 -5
- package/dist/node/gradient.d.ts.map +1 -1
- package/dist/node/index.d.ts +4 -4
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +799 -256
- package/dist/node/index.js.map +4 -4
- package/dist/node/pattern-extract.d.ts +36 -0
- package/dist/node/pattern-extract.d.ts.map +1 -0
- package/dist/node/recall.d.ts +1 -0
- package/dist/node/recall.d.ts.map +1 -1
- package/dist/node/search.d.ts +13 -1
- package/dist/node/search.d.ts.map +1 -1
- package/dist/node/temporal.d.ts +15 -0
- package/dist/node/temporal.d.ts.map +1 -1
- package/dist/node/types.d.ts +41 -1
- package/dist/node/types.d.ts.map +1 -1
- package/dist/node/worker-model.d.ts +22 -0
- package/dist/node/worker-model.d.ts.map +1 -1
- package/dist/types/agents-file.d.ts +29 -8
- package/dist/types/agents-file.d.ts.map +1 -1
- package/dist/types/config.d.ts +1 -0
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/db.d.ts.map +1 -1
- package/dist/types/distillation.d.ts +55 -0
- package/dist/types/distillation.d.ts.map +1 -1
- package/dist/types/embedding.d.ts +15 -1
- package/dist/types/embedding.d.ts.map +1 -1
- package/dist/types/gradient.d.ts +53 -5
- package/dist/types/gradient.d.ts.map +1 -1
- package/dist/types/index.d.ts +4 -4
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/pattern-extract.d.ts +36 -0
- package/dist/types/pattern-extract.d.ts.map +1 -0
- package/dist/types/recall.d.ts +1 -0
- package/dist/types/recall.d.ts.map +1 -1
- package/dist/types/search.d.ts +13 -1
- package/dist/types/search.d.ts.map +1 -1
- package/dist/types/temporal.d.ts +15 -0
- package/dist/types/temporal.d.ts.map +1 -1
- package/dist/types/types.d.ts +41 -1
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/worker-model.d.ts +22 -0
- package/dist/types/worker-model.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/agents-file.ts +111 -28
- package/src/config.ts +25 -18
- package/src/curator.ts +2 -2
- package/src/db.ts +83 -4
- package/src/distillation.ts +270 -27
- package/src/embedding.ts +158 -14
- package/src/gradient.ts +398 -227
- package/src/index.ts +13 -5
- package/src/pattern-extract.ts +108 -0
- package/src/recall.ts +142 -6
- package/src/search.ts +37 -1
- package/src/temporal.ts +39 -0
- package/src/types.ts +41 -1
- package/src/worker-model.ts +142 -5
package/src/db.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Database } from "#db/driver";
|
|
2
2
|
import { join, dirname } from "path";
|
|
3
3
|
import { mkdirSync } from "fs";
|
|
4
|
+
import { homedir } from "os";
|
|
4
5
|
|
|
5
|
-
const SCHEMA_VERSION =
|
|
6
|
+
const SCHEMA_VERSION = 12;
|
|
6
7
|
|
|
7
8
|
const MIGRATIONS: string[] = [
|
|
8
9
|
`
|
|
@@ -333,11 +334,27 @@ const MIGRATIONS: string[] = [
|
|
|
333
334
|
WHERE content LIKE '%' || char(10) || '[tool:%'
|
|
334
335
|
OR content LIKE '%' || char(10) || '[reasoning] %';
|
|
335
336
|
`,
|
|
337
|
+
`
|
|
338
|
+
-- Version 12: Context health diagnostic columns on distillations.
|
|
339
|
+
--
|
|
340
|
+
-- r_compression: k/√N where k = distilled token count, N = source token
|
|
341
|
+
-- count. Values < 1.0 signal likely lossy compression. NULL for rows
|
|
342
|
+
-- created before this migration or for meta-distillations (gen > 0)
|
|
343
|
+
-- where the metric is not computed.
|
|
344
|
+
--
|
|
345
|
+
-- c_norm: normalized variance of relative-existence weights over source
|
|
346
|
+
-- message timestamps. Range [0, 1]; 0 = uniform distribution, 1 = attention
|
|
347
|
+
-- dominated by distant past. NULL for pre-migration rows or meta-distillations.
|
|
348
|
+
--
|
|
349
|
+
-- Both columns are nullable REALs — cheap to add, no backfill needed.
|
|
350
|
+
ALTER TABLE distillations ADD COLUMN r_compression REAL;
|
|
351
|
+
ALTER TABLE distillations ADD COLUMN c_norm REAL;
|
|
352
|
+
`,
|
|
336
353
|
];
|
|
337
354
|
|
|
338
355
|
function dataDir() {
|
|
339
356
|
const xdg = process.env.XDG_DATA_HOME;
|
|
340
|
-
const base = xdg || join(
|
|
357
|
+
const base = xdg || join(homedir(), ".local", "share");
|
|
341
358
|
return join(base, "opencode-lore");
|
|
342
359
|
}
|
|
343
360
|
|
|
@@ -396,7 +413,13 @@ function migrate(database: Database) {
|
|
|
396
413
|
}
|
|
397
414
|
)?.version ?? 0)
|
|
398
415
|
: 0;
|
|
399
|
-
if (current >= MIGRATIONS.length)
|
|
416
|
+
if (current >= MIGRATIONS.length) {
|
|
417
|
+
// Schema is at the expected version but a prior partial run may have left
|
|
418
|
+
// holes (e.g. ALTER TABLE succeeded but CREATE TABLE in the same migration
|
|
419
|
+
// string was skipped). Run idempotent recovery for known fragile objects.
|
|
420
|
+
recoverMissingObjects(database);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
400
423
|
for (let i = current; i < MIGRATIONS.length; i++) {
|
|
401
424
|
if (i === VACUUM_MIGRATION_INDEX) {
|
|
402
425
|
// VACUUM cannot run inside a transaction. Run it directly.
|
|
@@ -406,12 +429,68 @@ function migrate(database: Database) {
|
|
|
406
429
|
database.exec("PRAGMA auto_vacuum = INCREMENTAL");
|
|
407
430
|
database.exec("VACUUM");
|
|
408
431
|
} else {
|
|
409
|
-
|
|
432
|
+
try {
|
|
433
|
+
database.exec(MIGRATIONS[i]);
|
|
434
|
+
} catch (e: unknown) {
|
|
435
|
+
// Multi-statement migrations can partially fail when an early
|
|
436
|
+
// statement (e.g. ALTER TABLE ADD COLUMN) hits a duplicate-column
|
|
437
|
+
// error from a prior partial run. Swallow duplicate-column errors
|
|
438
|
+
// so the rest of the migration loop and the version bump proceed.
|
|
439
|
+
// Any genuinely new error is re-thrown.
|
|
440
|
+
if (
|
|
441
|
+
e instanceof Error &&
|
|
442
|
+
/duplicate column name/i.test(e.message)
|
|
443
|
+
) {
|
|
444
|
+
// The ALTER TABLE already applied — run remaining statements in
|
|
445
|
+
// this migration by stripping the offending ALTER and re-exec'ing.
|
|
446
|
+
// (Important: migrate() in db.ts runs each migration via database.exec()
|
|
447
|
+
// which stops at the first error in a multi-statement string.)
|
|
448
|
+
const stripped = stripAppliedAlters(MIGRATIONS[i], database);
|
|
449
|
+
if (stripped.trim()) database.exec(stripped);
|
|
450
|
+
} else {
|
|
451
|
+
throw e;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
410
454
|
}
|
|
411
455
|
}
|
|
412
456
|
// Update version to latest. Migration 0 inserts version=1 via its own INSERT,
|
|
413
457
|
// but subsequent migrations don't update it, so always normalize to MIGRATIONS.length.
|
|
414
458
|
database.exec(`UPDATE schema_version SET version = ${MIGRATIONS.length}`);
|
|
459
|
+
|
|
460
|
+
// Also run recovery for existing DBs that are already at the latest version
|
|
461
|
+
// but have holes from past partial runs.
|
|
462
|
+
recoverMissingObjects(database);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Strip ALTER TABLE ADD COLUMN statements for columns that already exist.
|
|
467
|
+
* Returns the migration string with those statements removed.
|
|
468
|
+
*/
|
|
469
|
+
function stripAppliedAlters(migration: string, database: Database): string {
|
|
470
|
+
return migration.replace(
|
|
471
|
+
/ALTER\s+TABLE\s+(\w+)\s+ADD\s+COLUMN\s+(\w+)\b[^;]*;/gi,
|
|
472
|
+
(match, table, column) => {
|
|
473
|
+
const cols = database
|
|
474
|
+
.query(`PRAGMA table_info(${table})`)
|
|
475
|
+
.all() as Array<{ name: string }>;
|
|
476
|
+
if (cols.some((c) => c.name === column)) return ""; // already exists
|
|
477
|
+
return match; // keep — this ALTER hasn't been applied
|
|
478
|
+
},
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Idempotent recovery for objects that may be missing due to multi-statement
|
|
484
|
+
* migration partial failures (e.g. ALTER TABLE throws duplicate-column,
|
|
485
|
+
* aborting the exec before a subsequent CREATE TABLE in the same string).
|
|
486
|
+
*/
|
|
487
|
+
function recoverMissingObjects(database: Database) {
|
|
488
|
+
database.exec(`
|
|
489
|
+
CREATE TABLE IF NOT EXISTS kv_meta (
|
|
490
|
+
key TEXT PRIMARY KEY,
|
|
491
|
+
value TEXT NOT NULL
|
|
492
|
+
);
|
|
493
|
+
`);
|
|
415
494
|
}
|
|
416
495
|
|
|
417
496
|
export function close() {
|
package/src/distillation.ts
CHANGED
|
@@ -3,7 +3,9 @@ import { config } from "./config";
|
|
|
3
3
|
import * as temporal from "./temporal";
|
|
4
4
|
import { CHUNK_TERMINATOR } from "./temporal";
|
|
5
5
|
import * as embedding from "./embedding";
|
|
6
|
+
import * as ltm from "./ltm";
|
|
6
7
|
import * as log from "./log";
|
|
8
|
+
import { extractPatterns } from "./pattern-extract";
|
|
7
9
|
import {
|
|
8
10
|
DISTILLATION_SYSTEM,
|
|
9
11
|
distillationUser,
|
|
@@ -19,32 +21,125 @@ export { workerSessionIDs };
|
|
|
19
21
|
|
|
20
22
|
type TemporalMessage = temporal.TemporalMessage;
|
|
21
23
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Compression health ratio: k / √N.
|
|
26
|
+
*
|
|
27
|
+
* k = distilled token count, N = source token count.
|
|
28
|
+
* Values < 1.0 signal likely lossy compression (below the square-root
|
|
29
|
+
* boundary). Values > 1.0 signal relatively faithful compression.
|
|
30
|
+
*
|
|
31
|
+
* Based on the "LLM Context Square Root Theory" heuristic from
|
|
32
|
+
* D7x7z49/llm-context-idea. The specific threshold is unvalidated —
|
|
33
|
+
* use as a diagnostic signal, not a hard gate.
|
|
34
|
+
*/
|
|
35
|
+
export function compressionRatio(
|
|
36
|
+
distilledTokens: number,
|
|
37
|
+
sourceTokens: number,
|
|
38
|
+
): number {
|
|
39
|
+
if (sourceTokens <= 0) return 0;
|
|
40
|
+
return distilledTokens / Math.sqrt(sourceTokens);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Segment detection: group related messages into distillation-sized chunks.
|
|
45
|
+
*
|
|
46
|
+
* When the message count exceeds `maxSegment`, prefers splitting at the
|
|
47
|
+
* largest inter-message time gap (if it's ≥ 3× the median gap) to respect
|
|
48
|
+
* natural conversation boundaries. Falls back to count-based splitting at
|
|
49
|
+
* `maxSegment` when timestamps are uniform.
|
|
50
|
+
*
|
|
51
|
+
* Trailing segments with < 3 messages are merged into the previous segment
|
|
52
|
+
* to avoid tiny distillation inputs with too little context.
|
|
53
|
+
*
|
|
54
|
+
* Exported for testing; `run()` is the production caller.
|
|
55
|
+
*/
|
|
56
|
+
export function detectSegments(
|
|
24
57
|
messages: TemporalMessage[],
|
|
25
58
|
maxSegment: number,
|
|
26
59
|
): TemporalMessage[][] {
|
|
27
60
|
if (messages.length <= maxSegment) return [messages];
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
61
|
+
return splitSegments(messages, maxSegment);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Minimum segment size — segments smaller than this get merged. */
|
|
65
|
+
const MIN_SEGMENT = 3;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Multiplier for the median gap threshold: a time gap must be at least
|
|
69
|
+
* this many times the median gap to be used as a split point.
|
|
70
|
+
*/
|
|
71
|
+
const GAP_THRESHOLD_MULTIPLIER = 3;
|
|
72
|
+
|
|
73
|
+
function splitSegments(
|
|
74
|
+
messages: TemporalMessage[],
|
|
75
|
+
maxSegment: number,
|
|
76
|
+
): TemporalMessage[][] {
|
|
77
|
+
if (messages.length <= maxSegment) return [messages];
|
|
78
|
+
|
|
79
|
+
// Find the split point: prefer the largest time gap if it's significant
|
|
80
|
+
const splitIdx = findSplitIndex(messages, maxSegment);
|
|
81
|
+
|
|
82
|
+
const left = messages.slice(0, splitIdx);
|
|
83
|
+
const right = messages.slice(splitIdx);
|
|
84
|
+
|
|
85
|
+
// Recurse on both halves
|
|
86
|
+
const result = splitSegments(left, maxSegment);
|
|
87
|
+
|
|
88
|
+
if (right.length < MIN_SEGMENT) {
|
|
89
|
+
// Merge tiny trailing segment into the last segment
|
|
90
|
+
result[result.length - 1].push(...right);
|
|
91
|
+
} else {
|
|
92
|
+
result.push(...splitSegments(right, maxSegment));
|
|
38
93
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
94
|
+
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Choose where to split an oversized message array.
|
|
100
|
+
*
|
|
101
|
+
* If there's a time gap ≥ 3× the median gap AND it falls within a range
|
|
102
|
+
* that would produce segments of at least MIN_SEGMENT size, use it.
|
|
103
|
+
* Otherwise fall back to the count-based boundary at `maxSegment`.
|
|
104
|
+
*/
|
|
105
|
+
function findSplitIndex(
|
|
106
|
+
messages: TemporalMessage[],
|
|
107
|
+
maxSegment: number,
|
|
108
|
+
): number {
|
|
109
|
+
// Compute consecutive time gaps
|
|
110
|
+
const gaps: Array<{ index: number; gap: number }> = [];
|
|
111
|
+
for (let i = 1; i < messages.length; i++) {
|
|
112
|
+
gaps.push({
|
|
113
|
+
index: i,
|
|
114
|
+
gap: messages[i].created_at - messages[i - 1].created_at,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (gaps.length === 0) return maxSegment;
|
|
119
|
+
|
|
120
|
+
// Find median gap
|
|
121
|
+
const sortedGaps = gaps.map((g) => g.gap).sort((a, b) => a - b);
|
|
122
|
+
const medianGap = sortedGaps[Math.floor(sortedGaps.length / 2)];
|
|
123
|
+
|
|
124
|
+
// Find the largest gap that would produce viable segments (≥ MIN_SEGMENT on each side)
|
|
125
|
+
let bestGap = { index: -1, gap: 0 };
|
|
126
|
+
for (const g of gaps) {
|
|
127
|
+
if (
|
|
128
|
+
g.gap > bestGap.gap &&
|
|
129
|
+
g.index >= MIN_SEGMENT &&
|
|
130
|
+
messages.length - g.index >= MIN_SEGMENT
|
|
131
|
+
) {
|
|
132
|
+
bestGap = g;
|
|
45
133
|
}
|
|
46
134
|
}
|
|
47
|
-
|
|
135
|
+
|
|
136
|
+
// Use the time gap if it's significantly larger than median
|
|
137
|
+
if (bestGap.index > 0 && bestGap.gap >= medianGap * GAP_THRESHOLD_MULTIPLIER) {
|
|
138
|
+
return bestGap.index;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Fall back to count-based splitting
|
|
142
|
+
return maxSegment;
|
|
48
143
|
}
|
|
49
144
|
|
|
50
145
|
function formatTime(ms: number): string {
|
|
@@ -235,6 +330,10 @@ export type Distillation = {
|
|
|
235
330
|
generation: number;
|
|
236
331
|
token_count: number;
|
|
237
332
|
created_at: number;
|
|
333
|
+
/** k/√N compression ratio. NULL for pre-v12 rows or meta-distillations. */
|
|
334
|
+
r_compression: number | null;
|
|
335
|
+
/** Temporal clustering [0,1]. NULL for pre-v12 rows or meta-distillations. */
|
|
336
|
+
c_norm: number | null;
|
|
238
337
|
};
|
|
239
338
|
|
|
240
339
|
/**
|
|
@@ -258,8 +357,8 @@ export function loadForSession(
|
|
|
258
357
|
): Distillation[] {
|
|
259
358
|
const pid = ensureProject(projectPath);
|
|
260
359
|
const sql = includeArchived
|
|
261
|
-
? "SELECT id, project_id, session_id, observations, source_ids, generation, token_count, created_at FROM distillations WHERE project_id = ? AND session_id = ? ORDER BY created_at ASC"
|
|
262
|
-
: "SELECT id, project_id, session_id, observations, source_ids, generation, token_count, created_at FROM distillations WHERE project_id = ? AND session_id = ? AND archived = 0 ORDER BY created_at ASC";
|
|
360
|
+
? "SELECT id, project_id, session_id, observations, source_ids, generation, token_count, created_at, r_compression, c_norm FROM distillations WHERE project_id = ? AND session_id = ? ORDER BY created_at ASC"
|
|
361
|
+
: "SELECT id, project_id, session_id, observations, source_ids, generation, token_count, created_at, r_compression, c_norm FROM distillations WHERE project_id = ? AND session_id = ? AND archived = 0 ORDER BY created_at ASC";
|
|
263
362
|
const rows = db()
|
|
264
363
|
.query(sql)
|
|
265
364
|
.all(pid, sessionID) as Array<{
|
|
@@ -271,6 +370,8 @@ export function loadForSession(
|
|
|
271
370
|
generation: number;
|
|
272
371
|
token_count: number;
|
|
273
372
|
created_at: number;
|
|
373
|
+
r_compression: number | null;
|
|
374
|
+
c_norm: number | null;
|
|
274
375
|
}>;
|
|
275
376
|
return rows.map((r) => ({
|
|
276
377
|
...r,
|
|
@@ -284,6 +385,8 @@ function storeDistillation(input: {
|
|
|
284
385
|
observations: string;
|
|
285
386
|
sourceIDs: string[];
|
|
286
387
|
generation: number;
|
|
388
|
+
rCompression?: number;
|
|
389
|
+
cNorm?: number;
|
|
287
390
|
}): string {
|
|
288
391
|
const pid = ensureProject(input.projectPath);
|
|
289
392
|
const id = crypto.randomUUID();
|
|
@@ -291,8 +394,8 @@ function storeDistillation(input: {
|
|
|
291
394
|
const tokens = Math.ceil(input.observations.length / 3);
|
|
292
395
|
db()
|
|
293
396
|
.query(
|
|
294
|
-
`INSERT INTO distillations (id, project_id, session_id, narrative, facts, observations, source_ids, generation, token_count, created_at)
|
|
295
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
397
|
+
`INSERT INTO distillations (id, project_id, session_id, narrative, facts, observations, source_ids, generation, token_count, created_at, r_compression, c_norm)
|
|
398
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
296
399
|
)
|
|
297
400
|
.run(
|
|
298
401
|
id,
|
|
@@ -305,6 +408,8 @@ function storeDistillation(input: {
|
|
|
305
408
|
input.generation,
|
|
306
409
|
tokens,
|
|
307
410
|
Date.now(),
|
|
411
|
+
input.rCompression ?? null,
|
|
412
|
+
input.cNorm ?? null,
|
|
308
413
|
);
|
|
309
414
|
return id;
|
|
310
415
|
}
|
|
@@ -327,7 +432,7 @@ function loadGen0(projectPath: string, sessionID: string): Distillation[] {
|
|
|
327
432
|
const pid = ensureProject(projectPath);
|
|
328
433
|
const rows = db()
|
|
329
434
|
.query(
|
|
330
|
-
"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 AND archived = 0 ORDER BY created_at ASC",
|
|
435
|
+
"SELECT id, project_id, session_id, observations, source_ids, generation, token_count, created_at, r_compression, c_norm FROM distillations WHERE project_id = ? AND session_id = ? AND generation = 0 AND archived = 0 ORDER BY created_at ASC",
|
|
331
436
|
)
|
|
332
437
|
.all(pid, sessionID) as Array<{
|
|
333
438
|
id: string;
|
|
@@ -338,6 +443,8 @@ function loadGen0(projectPath: string, sessionID: string): Distillation[] {
|
|
|
338
443
|
generation: number;
|
|
339
444
|
token_count: number;
|
|
340
445
|
created_at: number;
|
|
446
|
+
r_compression: number | null;
|
|
447
|
+
c_norm: number | null;
|
|
341
448
|
}>;
|
|
342
449
|
return rows.map((r) => ({
|
|
343
450
|
...r,
|
|
@@ -421,6 +528,17 @@ export async function run(input: {
|
|
|
421
528
|
model?: { providerID: string; modelID: string };
|
|
422
529
|
/** Skip minMessages threshold check — distill whatever is pending */
|
|
423
530
|
force?: boolean;
|
|
531
|
+
/** Skip meta-distillation even when gen-0 count exceeds the threshold.
|
|
532
|
+
* Used when the upstream prompt cache is likely still warm — meta-distillation
|
|
533
|
+
* rewrites distillation row IDs, which invalidates the distilled prefix cache
|
|
534
|
+
* and causes a cache bust on the next turn. Callers should set this to true
|
|
535
|
+
* when `Date.now() - getLastTurnAt(sessionID) < cacheTTL`. */
|
|
536
|
+
skipMeta?: boolean;
|
|
537
|
+
/** When true, all LLM calls in this run are marked urgent and bypass the
|
|
538
|
+
* batch queue (if one is active). Use for compaction and overflow recovery
|
|
539
|
+
* where the caller is blocking on the result. Background/idle distillation
|
|
540
|
+
* should leave this false to benefit from batch API 50% cost savings. */
|
|
541
|
+
urgent?: boolean;
|
|
424
542
|
}): Promise<{ rounds: number; distilled: number }> {
|
|
425
543
|
// Reset orphaned messages (marked distilled by a deleted/migrated distillation)
|
|
426
544
|
const orphans = resetOrphans(input.projectPath, input.sessionID);
|
|
@@ -454,6 +572,7 @@ export async function run(input: {
|
|
|
454
572
|
sessionID: input.sessionID,
|
|
455
573
|
messages: segment,
|
|
456
574
|
model: input.model,
|
|
575
|
+
urgent: input.urgent,
|
|
457
576
|
});
|
|
458
577
|
if (result) {
|
|
459
578
|
distilled += segment.length;
|
|
@@ -462,8 +581,11 @@ export async function run(input: {
|
|
|
462
581
|
}
|
|
463
582
|
}
|
|
464
583
|
|
|
465
|
-
// Check if meta-distillation is needed
|
|
584
|
+
// Check if meta-distillation is needed (skip when cache is warm to avoid
|
|
585
|
+
// prefix cache invalidation — row IDs change after meta-distill, busting
|
|
586
|
+
// the prompt cache on the next turn).
|
|
466
587
|
if (
|
|
588
|
+
!input.skipMeta &&
|
|
467
589
|
gen0Count(input.projectPath, input.sessionID) >=
|
|
468
590
|
cfg.distillation.metaThreshold
|
|
469
591
|
) {
|
|
@@ -472,6 +594,7 @@ export async function run(input: {
|
|
|
472
594
|
projectPath: input.projectPath,
|
|
473
595
|
sessionID: input.sessionID,
|
|
474
596
|
model: input.model,
|
|
597
|
+
urgent: input.urgent,
|
|
475
598
|
});
|
|
476
599
|
rounds++;
|
|
477
600
|
}
|
|
@@ -489,6 +612,7 @@ async function distillSegment(input: {
|
|
|
489
612
|
sessionID: string;
|
|
490
613
|
messages: TemporalMessage[];
|
|
491
614
|
model?: { providerID: string; modelID: string };
|
|
615
|
+
urgent?: boolean;
|
|
492
616
|
}): Promise<DistillationResult | null> {
|
|
493
617
|
const prior = latestObservations(input.projectPath, input.sessionID);
|
|
494
618
|
const text = messagesToText(input.messages);
|
|
@@ -511,27 +635,59 @@ async function distillSegment(input: {
|
|
|
511
635
|
const responseText = await input.llm.prompt(
|
|
512
636
|
DISTILLATION_SYSTEM,
|
|
513
637
|
userContent,
|
|
514
|
-
{ model, workerID: "lore-distill" },
|
|
638
|
+
{ model, workerID: "lore-distill", thinking: false, urgent: input.urgent, sessionID: input.sessionID },
|
|
515
639
|
);
|
|
516
640
|
if (!responseText) return null;
|
|
517
641
|
|
|
518
642
|
const result = parseDistillationResult(responseText);
|
|
519
643
|
if (!result) return null;
|
|
520
644
|
|
|
645
|
+
// Compute context health metrics before storing.
|
|
646
|
+
const distilledTokens = Math.ceil(result.observations.length / 3);
|
|
647
|
+
const sourceTokens = input.messages.reduce((sum, m) => sum + m.tokens, 0);
|
|
648
|
+
const rComp = compressionRatio(distilledTokens, sourceTokens);
|
|
649
|
+
const cNorm = temporal.temporalCnorm(input.messages.map((m) => m.created_at));
|
|
650
|
+
|
|
521
651
|
const distillId = storeDistillation({
|
|
522
652
|
projectPath: input.projectPath,
|
|
523
653
|
sessionID: input.sessionID,
|
|
524
654
|
observations: result.observations,
|
|
525
655
|
sourceIDs: input.messages.map((m) => m.id),
|
|
526
656
|
generation: 0,
|
|
657
|
+
rCompression: rComp,
|
|
658
|
+
cNorm,
|
|
527
659
|
});
|
|
528
660
|
temporal.markDistilled(input.messages.map((m) => m.id));
|
|
529
661
|
|
|
662
|
+
log.info(
|
|
663
|
+
`distill segment: ${input.messages.length} msgs, ` +
|
|
664
|
+
`${sourceTokens}→${distilledTokens} tokens, ` +
|
|
665
|
+
`R=${rComp.toFixed(2)}, C_norm=${cNorm.toFixed(3)}`,
|
|
666
|
+
);
|
|
667
|
+
|
|
530
668
|
// Fire-and-forget: embed the distillation for vector search
|
|
531
669
|
if (embedding.isAvailable()) {
|
|
532
670
|
embedding.embedDistillation(distillId, result.observations);
|
|
533
671
|
}
|
|
534
672
|
|
|
673
|
+
// Fire-and-forget: extract decision/preference patterns → knowledge entries
|
|
674
|
+
if (config().knowledge.enabled) {
|
|
675
|
+
for (const pat of extractPatterns(result.observations)) {
|
|
676
|
+
try {
|
|
677
|
+
ltm.create({
|
|
678
|
+
projectPath: input.projectPath,
|
|
679
|
+
category: pat.category,
|
|
680
|
+
title: pat.title,
|
|
681
|
+
content: pat.content,
|
|
682
|
+
session: input.sessionID,
|
|
683
|
+
scope: "project",
|
|
684
|
+
});
|
|
685
|
+
} catch {
|
|
686
|
+
// Dedup guard in ltm.create() handles duplicates — swallow errors
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
535
691
|
return result;
|
|
536
692
|
}
|
|
537
693
|
|
|
@@ -548,6 +704,7 @@ export async function metaDistill(input: {
|
|
|
548
704
|
projectPath: string;
|
|
549
705
|
sessionID: string;
|
|
550
706
|
model?: { providerID: string; modelID: string };
|
|
707
|
+
urgent?: boolean;
|
|
551
708
|
}): Promise<DistillationResult | null> {
|
|
552
709
|
const existing = loadGen0(input.projectPath, input.sessionID);
|
|
553
710
|
|
|
@@ -575,7 +732,7 @@ export async function metaDistill(input: {
|
|
|
575
732
|
const responseText = await input.llm.prompt(
|
|
576
733
|
RECURSIVE_SYSTEM,
|
|
577
734
|
userContent,
|
|
578
|
-
{ model, workerID: "lore-distill" },
|
|
735
|
+
{ model, workerID: "lore-distill", thinking: false, urgent: input.urgent, sessionID: input.sessionID },
|
|
579
736
|
);
|
|
580
737
|
if (!responseText) return null;
|
|
581
738
|
|
|
@@ -626,5 +783,91 @@ export async function metaDistill(input: {
|
|
|
626
783
|
embedding.embedDistillation(metaId, result.observations);
|
|
627
784
|
}
|
|
628
785
|
|
|
786
|
+
// Fire-and-forget: extract decision/preference patterns → knowledge entries
|
|
787
|
+
if (config().knowledge.enabled) {
|
|
788
|
+
for (const pat of extractPatterns(result.observations)) {
|
|
789
|
+
try {
|
|
790
|
+
ltm.create({
|
|
791
|
+
projectPath: input.projectPath,
|
|
792
|
+
category: pat.category,
|
|
793
|
+
title: pat.title,
|
|
794
|
+
content: pat.content,
|
|
795
|
+
session: input.sessionID,
|
|
796
|
+
scope: "project",
|
|
797
|
+
});
|
|
798
|
+
} catch {
|
|
799
|
+
// Dedup guard in ltm.create() handles duplicates — swallow errors
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
629
804
|
return result;
|
|
630
805
|
}
|
|
806
|
+
|
|
807
|
+
// ---------------------------------------------------------------------------
|
|
808
|
+
// Retroactive metric backfill
|
|
809
|
+
// ---------------------------------------------------------------------------
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Backfill `r_compression` and `c_norm` for distillations that were created
|
|
813
|
+
* before schema v12 (or before PR #113 added the computation).
|
|
814
|
+
*
|
|
815
|
+
* For each distillation with NULL metrics, loads source temporal messages via
|
|
816
|
+
* `source_ids`, computes `compressionRatio()` and `temporalCnorm()`, and
|
|
817
|
+
* writes the values back. Skips rows where source messages have been pruned
|
|
818
|
+
* or source_ids is empty.
|
|
819
|
+
*
|
|
820
|
+
* Designed to run once at startup — idempotent (only touches NULL rows).
|
|
821
|
+
* Returns the number of rows updated.
|
|
822
|
+
*/
|
|
823
|
+
export function backfillMetrics(): number {
|
|
824
|
+
const rows = db()
|
|
825
|
+
.query(
|
|
826
|
+
"SELECT id, source_ids, token_count FROM distillations WHERE r_compression IS NULL",
|
|
827
|
+
)
|
|
828
|
+
.all() as Array<{
|
|
829
|
+
id: string;
|
|
830
|
+
source_ids: string;
|
|
831
|
+
token_count: number;
|
|
832
|
+
}>;
|
|
833
|
+
|
|
834
|
+
if (!rows.length) return 0;
|
|
835
|
+
|
|
836
|
+
const update = db().prepare(
|
|
837
|
+
"UPDATE distillations SET r_compression = ?, c_norm = ? WHERE id = ?",
|
|
838
|
+
);
|
|
839
|
+
|
|
840
|
+
let updated = 0;
|
|
841
|
+
|
|
842
|
+
for (const row of rows) {
|
|
843
|
+
const sourceIds = parseSourceIds(row.source_ids);
|
|
844
|
+
if (!sourceIds.length) continue;
|
|
845
|
+
|
|
846
|
+
// Load source temporal messages — they may have been pruned.
|
|
847
|
+
const placeholders = sourceIds.map(() => "?").join(",");
|
|
848
|
+
const sources = db()
|
|
849
|
+
.query(
|
|
850
|
+
`SELECT tokens, created_at FROM temporal_messages WHERE id IN (${placeholders})`,
|
|
851
|
+
)
|
|
852
|
+
.all(...sourceIds) as Array<{ tokens: number; created_at: number }>;
|
|
853
|
+
|
|
854
|
+
if (!sources.length) continue;
|
|
855
|
+
|
|
856
|
+
const sourceTokens = sources.reduce((sum, s) => sum + s.tokens, 0);
|
|
857
|
+
const timestamps = sources.map((s) => s.created_at);
|
|
858
|
+
|
|
859
|
+
const rComp = compressionRatio(row.token_count, sourceTokens);
|
|
860
|
+
const cNorm = temporal.temporalCnorm(timestamps);
|
|
861
|
+
|
|
862
|
+
update.run(rComp, cNorm, row.id);
|
|
863
|
+
updated++;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
if (updated > 0) {
|
|
867
|
+
log.info(
|
|
868
|
+
`backfilled metrics for ${updated} distillations (${rows.length - updated} skipped — missing sources)`,
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
return updated;
|
|
873
|
+
}
|