@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.
Files changed (94) hide show
  1. package/dist/bun/agents-file.d.ts +29 -8
  2. package/dist/bun/agents-file.d.ts.map +1 -1
  3. package/dist/bun/config.d.ts +1 -0
  4. package/dist/bun/config.d.ts.map +1 -1
  5. package/dist/bun/db.d.ts.map +1 -1
  6. package/dist/bun/distillation.d.ts +55 -0
  7. package/dist/bun/distillation.d.ts.map +1 -1
  8. package/dist/bun/embedding.d.ts +15 -1
  9. package/dist/bun/embedding.d.ts.map +1 -1
  10. package/dist/bun/gradient.d.ts +53 -5
  11. package/dist/bun/gradient.d.ts.map +1 -1
  12. package/dist/bun/index.d.ts +4 -4
  13. package/dist/bun/index.d.ts.map +1 -1
  14. package/dist/bun/index.js +799 -256
  15. package/dist/bun/index.js.map +4 -4
  16. package/dist/bun/pattern-extract.d.ts +36 -0
  17. package/dist/bun/pattern-extract.d.ts.map +1 -0
  18. package/dist/bun/recall.d.ts +1 -0
  19. package/dist/bun/recall.d.ts.map +1 -1
  20. package/dist/bun/search.d.ts +13 -1
  21. package/dist/bun/search.d.ts.map +1 -1
  22. package/dist/bun/temporal.d.ts +15 -0
  23. package/dist/bun/temporal.d.ts.map +1 -1
  24. package/dist/bun/types.d.ts +41 -1
  25. package/dist/bun/types.d.ts.map +1 -1
  26. package/dist/bun/worker-model.d.ts +22 -0
  27. package/dist/bun/worker-model.d.ts.map +1 -1
  28. package/dist/node/agents-file.d.ts +29 -8
  29. package/dist/node/agents-file.d.ts.map +1 -1
  30. package/dist/node/config.d.ts +1 -0
  31. package/dist/node/config.d.ts.map +1 -1
  32. package/dist/node/db.d.ts.map +1 -1
  33. package/dist/node/distillation.d.ts +55 -0
  34. package/dist/node/distillation.d.ts.map +1 -1
  35. package/dist/node/embedding.d.ts +15 -1
  36. package/dist/node/embedding.d.ts.map +1 -1
  37. package/dist/node/gradient.d.ts +53 -5
  38. package/dist/node/gradient.d.ts.map +1 -1
  39. package/dist/node/index.d.ts +4 -4
  40. package/dist/node/index.d.ts.map +1 -1
  41. package/dist/node/index.js +799 -256
  42. package/dist/node/index.js.map +4 -4
  43. package/dist/node/pattern-extract.d.ts +36 -0
  44. package/dist/node/pattern-extract.d.ts.map +1 -0
  45. package/dist/node/recall.d.ts +1 -0
  46. package/dist/node/recall.d.ts.map +1 -1
  47. package/dist/node/search.d.ts +13 -1
  48. package/dist/node/search.d.ts.map +1 -1
  49. package/dist/node/temporal.d.ts +15 -0
  50. package/dist/node/temporal.d.ts.map +1 -1
  51. package/dist/node/types.d.ts +41 -1
  52. package/dist/node/types.d.ts.map +1 -1
  53. package/dist/node/worker-model.d.ts +22 -0
  54. package/dist/node/worker-model.d.ts.map +1 -1
  55. package/dist/types/agents-file.d.ts +29 -8
  56. package/dist/types/agents-file.d.ts.map +1 -1
  57. package/dist/types/config.d.ts +1 -0
  58. package/dist/types/config.d.ts.map +1 -1
  59. package/dist/types/db.d.ts.map +1 -1
  60. package/dist/types/distillation.d.ts +55 -0
  61. package/dist/types/distillation.d.ts.map +1 -1
  62. package/dist/types/embedding.d.ts +15 -1
  63. package/dist/types/embedding.d.ts.map +1 -1
  64. package/dist/types/gradient.d.ts +53 -5
  65. package/dist/types/gradient.d.ts.map +1 -1
  66. package/dist/types/index.d.ts +4 -4
  67. package/dist/types/index.d.ts.map +1 -1
  68. package/dist/types/pattern-extract.d.ts +36 -0
  69. package/dist/types/pattern-extract.d.ts.map +1 -0
  70. package/dist/types/recall.d.ts +1 -0
  71. package/dist/types/recall.d.ts.map +1 -1
  72. package/dist/types/search.d.ts +13 -1
  73. package/dist/types/search.d.ts.map +1 -1
  74. package/dist/types/temporal.d.ts +15 -0
  75. package/dist/types/temporal.d.ts.map +1 -1
  76. package/dist/types/types.d.ts +41 -1
  77. package/dist/types/types.d.ts.map +1 -1
  78. package/dist/types/worker-model.d.ts +22 -0
  79. package/dist/types/worker-model.d.ts.map +1 -1
  80. package/package.json +3 -2
  81. package/src/agents-file.ts +111 -28
  82. package/src/config.ts +25 -18
  83. package/src/curator.ts +2 -2
  84. package/src/db.ts +83 -4
  85. package/src/distillation.ts +270 -27
  86. package/src/embedding.ts +158 -14
  87. package/src/gradient.ts +398 -227
  88. package/src/index.ts +13 -5
  89. package/src/pattern-extract.ts +108 -0
  90. package/src/recall.ts +142 -6
  91. package/src/search.ts +37 -1
  92. package/src/temporal.ts +39 -0
  93. package/src/types.ts +41 -1
  94. 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 = 11;
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(process.env.HOME || "~", ".local", "share");
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) return;
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
- database.exec(MIGRATIONS[i]);
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() {
@@ -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
- // Segment detection: group related messages together
23
- function detectSegments(
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
- const segments: TemporalMessage[][] = [];
29
- let current: TemporalMessage[] = [];
30
-
31
- for (const msg of messages) {
32
- current.push(msg);
33
- // Split on segment size limit
34
- if (current.length >= maxSegment) {
35
- segments.push(current);
36
- current = [];
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
- if (current.length > 0) {
40
- // Merge small trailing segment with previous if too small
41
- if (current.length < 3 && segments.length > 0) {
42
- segments[segments.length - 1].push(...current);
43
- } else {
44
- segments.push(current);
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
- return segments;
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
+ }