@loreai/core 0.18.0 → 0.20.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 (88) hide show
  1. package/dist/bun/agents-file.d.ts.map +1 -1
  2. package/dist/bun/config.d.ts.map +1 -1
  3. package/dist/bun/curator.d.ts.map +1 -1
  4. package/dist/bun/db.d.ts +86 -1
  5. package/dist/bun/db.d.ts.map +1 -1
  6. package/dist/bun/distillation.d.ts +2 -13
  7. package/dist/bun/distillation.d.ts.map +1 -1
  8. package/dist/bun/embedding.d.ts +5 -1
  9. package/dist/bun/embedding.d.ts.map +1 -1
  10. package/dist/bun/git.d.ts.map +1 -1
  11. package/dist/bun/gradient.d.ts +13 -1
  12. package/dist/bun/gradient.d.ts.map +1 -1
  13. package/dist/bun/hosted.d.ts +36 -0
  14. package/dist/bun/hosted.d.ts.map +1 -0
  15. package/dist/bun/index.d.ts +3 -2
  16. package/dist/bun/index.d.ts.map +1 -1
  17. package/dist/bun/index.js +1049 -247
  18. package/dist/bun/index.js.map +4 -4
  19. package/dist/bun/lat-reader.d.ts.map +1 -1
  20. package/dist/bun/ltm.d.ts +99 -5
  21. package/dist/bun/ltm.d.ts.map +1 -1
  22. package/dist/bun/session-limiter.d.ts +26 -0
  23. package/dist/bun/session-limiter.d.ts.map +1 -0
  24. package/dist/bun/temporal.d.ts +2 -0
  25. package/dist/bun/temporal.d.ts.map +1 -1
  26. package/dist/node/agents-file.d.ts.map +1 -1
  27. package/dist/node/config.d.ts.map +1 -1
  28. package/dist/node/curator.d.ts.map +1 -1
  29. package/dist/node/db.d.ts +86 -1
  30. package/dist/node/db.d.ts.map +1 -1
  31. package/dist/node/distillation.d.ts +2 -13
  32. package/dist/node/distillation.d.ts.map +1 -1
  33. package/dist/node/embedding.d.ts +5 -1
  34. package/dist/node/embedding.d.ts.map +1 -1
  35. package/dist/node/git.d.ts.map +1 -1
  36. package/dist/node/gradient.d.ts +13 -1
  37. package/dist/node/gradient.d.ts.map +1 -1
  38. package/dist/node/hosted.d.ts +36 -0
  39. package/dist/node/hosted.d.ts.map +1 -0
  40. package/dist/node/index.d.ts +3 -2
  41. package/dist/node/index.d.ts.map +1 -1
  42. package/dist/node/index.js +1049 -247
  43. package/dist/node/index.js.map +4 -4
  44. package/dist/node/lat-reader.d.ts.map +1 -1
  45. package/dist/node/ltm.d.ts +99 -5
  46. package/dist/node/ltm.d.ts.map +1 -1
  47. package/dist/node/session-limiter.d.ts +26 -0
  48. package/dist/node/session-limiter.d.ts.map +1 -0
  49. package/dist/node/temporal.d.ts +2 -0
  50. package/dist/node/temporal.d.ts.map +1 -1
  51. package/dist/types/agents-file.d.ts.map +1 -1
  52. package/dist/types/config.d.ts.map +1 -1
  53. package/dist/types/curator.d.ts.map +1 -1
  54. package/dist/types/db.d.ts +86 -1
  55. package/dist/types/db.d.ts.map +1 -1
  56. package/dist/types/distillation.d.ts +2 -13
  57. package/dist/types/distillation.d.ts.map +1 -1
  58. package/dist/types/embedding.d.ts +5 -1
  59. package/dist/types/embedding.d.ts.map +1 -1
  60. package/dist/types/git.d.ts.map +1 -1
  61. package/dist/types/gradient.d.ts +13 -1
  62. package/dist/types/gradient.d.ts.map +1 -1
  63. package/dist/types/hosted.d.ts +36 -0
  64. package/dist/types/hosted.d.ts.map +1 -0
  65. package/dist/types/index.d.ts +3 -2
  66. package/dist/types/index.d.ts.map +1 -1
  67. package/dist/types/lat-reader.d.ts.map +1 -1
  68. package/dist/types/ltm.d.ts +99 -5
  69. package/dist/types/ltm.d.ts.map +1 -1
  70. package/dist/types/session-limiter.d.ts +26 -0
  71. package/dist/types/session-limiter.d.ts.map +1 -0
  72. package/dist/types/temporal.d.ts +2 -0
  73. package/dist/types/temporal.d.ts.map +1 -1
  74. package/package.json +3 -1
  75. package/src/agents-file.ts +12 -0
  76. package/src/config.ts +10 -5
  77. package/src/curator.ts +54 -2
  78. package/src/db.ts +386 -6
  79. package/src/distillation.ts +55 -14
  80. package/src/embedding.ts +71 -8
  81. package/src/git.ts +4 -0
  82. package/src/gradient.ts +227 -74
  83. package/src/hosted.ts +46 -0
  84. package/src/index.ts +12 -0
  85. package/src/lat-reader.ts +4 -0
  86. package/src/ltm.ts +480 -45
  87. package/src/session-limiter.ts +47 -0
  88. package/src/temporal.ts +10 -0
package/src/db.ts CHANGED
@@ -515,6 +515,55 @@ const MIGRATIONS: string[] = [
515
515
  AND ih.source_id = '__declined__'
516
516
  );
517
517
  `,
518
+ `
519
+ -- Version 23: Persist volatile session tracking state across restarts.
520
+ -- Previously these were in-memory only, causing duplicate processing,
521
+ -- false compaction detection, and expensive prompt cache busts on restart.
522
+ ALTER TABLE session_state ADD COLUMN last_curated_at INTEGER NOT NULL DEFAULT 0;
523
+ ALTER TABLE session_state ADD COLUMN message_count INTEGER NOT NULL DEFAULT 0;
524
+ ALTER TABLE session_state ADD COLUMN turns_since_curation INTEGER NOT NULL DEFAULT 0;
525
+ ALTER TABLE session_state ADD COLUMN ltm_cache_text TEXT;
526
+ ALTER TABLE session_state ADD COLUMN ltm_cache_tokens INTEGER;
527
+ ALTER TABLE session_state ADD COLUMN ltm_pin_text TEXT;
528
+ ALTER TABLE session_state ADD COLUMN ltm_pin_tokens INTEGER;
529
+ ALTER TABLE session_state ADD COLUMN consecutive_text_only_turns INTEGER NOT NULL DEFAULT 0;
530
+ `,
531
+ `
532
+ -- Version 24: Persist remaining volatile session state across restarts.
533
+ -- Session identity (Tier 1/2/3 session correlation)
534
+ ALTER TABLE session_state ADD COLUMN fingerprint TEXT NOT NULL DEFAULT '';
535
+ ALTER TABLE session_state ADD COLUMN header_session_id TEXT;
536
+ ALTER TABLE session_state ADD COLUMN header_name TEXT;
537
+ -- Cache warming state
538
+ ALTER TABLE session_state ADD COLUMN resolved_conversation_ttl TEXT NOT NULL DEFAULT '5m';
539
+ ALTER TABLE session_state ADD COLUMN warmup_state TEXT;
540
+ -- Gradient calibration state (survives restarts to avoid uncalibrated busts)
541
+ ALTER TABLE session_state ADD COLUMN dynamic_context_cap REAL NOT NULL DEFAULT 0;
542
+ ALTER TABLE session_state ADD COLUMN bust_rate_ema REAL NOT NULL DEFAULT -1;
543
+ ALTER TABLE session_state ADD COLUMN inter_bust_interval_ema REAL NOT NULL DEFAULT -1;
544
+ ALTER TABLE session_state ADD COLUMN last_layer INTEGER NOT NULL DEFAULT 0;
545
+ ALTER TABLE session_state ADD COLUMN last_known_input INTEGER NOT NULL DEFAULT 0;
546
+ ALTER TABLE session_state ADD COLUMN last_turn_at INTEGER NOT NULL DEFAULT 0;
547
+ ALTER TABLE session_state ADD COLUMN last_bust_at INTEGER NOT NULL DEFAULT 0;
548
+ `,
549
+ `
550
+ -- Version 25: Adaptive dedup threshold — store accept/reject feedback
551
+ -- on embedding-based duplicate pairs for per-project threshold calibration.
552
+ -- Titles stored instead of FK IDs because entries are deleted during dedup;
553
+ -- the similarity float is the actual calibration input.
554
+ CREATE TABLE IF NOT EXISTS dedup_feedback (
555
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
556
+ project_id TEXT,
557
+ entry_a_title TEXT NOT NULL,
558
+ entry_b_title TEXT NOT NULL,
559
+ similarity REAL NOT NULL,
560
+ accepted INTEGER NOT NULL,
561
+ source TEXT NOT NULL DEFAULT 'manual',
562
+ created_at INTEGER NOT NULL
563
+ );
564
+ CREATE INDEX IF NOT EXISTS idx_dedup_feedback_project
565
+ ON dedup_feedback(project_id);
566
+ `,
518
567
  ];
519
568
 
520
569
  /** Return the resolved path of the SQLite database file. */
@@ -764,7 +813,22 @@ export function close() {
764
813
  * an alias so subsequent calls skip the subprocess. If the matched project's
765
814
  * git_remote was not yet populated (pre-v14 rows), it is backfilled lazily.
766
815
  */
767
- export function ensureProject(path: string, name?: string): string {
816
+ export function ensureProject(path: string, name?: string, suppliedGitRemote?: string | null): string {
817
+ // Guard: reject synthetic test paths when targeting the production DB.
818
+ // Test paths like "/test/ltm/project" are absolute paths that don't exist
819
+ // on any real filesystem — they're only valid in test suites running against
820
+ // a temp DB (LORE_DB_PATH set by test preload). If we see such a path
821
+ // without LORE_DB_PATH being set, a test is likely hitting the production DB.
822
+ // Note: LORE_DB_PATH unset is used as a proxy for "production DB". This
823
+ // wouldn't catch the unlikely case of someone explicitly setting LORE_DB_PATH
824
+ // to the default production path, but that's not a realistic scenario.
825
+ if (!process.env.LORE_DB_PATH && /^\/test\//.test(path)) {
826
+ throw new Error(
827
+ `Refusing to create project with test path "${path}" in the production DB. ` +
828
+ `Set LORE_DB_PATH to a temp path, or run tests via \`bun test\` from the repo root.`,
829
+ );
830
+ }
831
+
768
832
  // 1. Exact path match (fast path)
769
833
  const existing = db()
770
834
  .query("SELECT id, git_remote FROM projects WHERE path = ?")
@@ -772,21 +836,21 @@ export function ensureProject(path: string, name?: string): string {
772
836
  if (existing) {
773
837
  // Lazy backfill: populate git_remote on pre-v14 rows
774
838
  if (!existing.git_remote) {
775
- const gitRemote = getGitRemote(path);
776
- if (gitRemote) {
839
+ const resolvedRemote = suppliedGitRemote ?? getGitRemote(path);
840
+ if (resolvedRemote) {
777
841
  // Check for conflict: another project already has this git_remote.
778
842
  // If so, merge the conflicting project into this one (one-time).
779
843
  const conflict = db()
780
844
  .query(
781
845
  "SELECT id FROM projects WHERE git_remote = ? AND id != ? LIMIT 1",
782
846
  )
783
- .get(gitRemote, existing.id) as { id: string } | null;
847
+ .get(resolvedRemote, existing.id) as { id: string } | null;
784
848
  if (conflict) {
785
849
  mergeProjectInternal(conflict.id, existing.id);
786
850
  }
787
851
  db()
788
852
  .query("UPDATE projects SET git_remote = ? WHERE id = ?")
789
- .run(gitRemote, existing.id);
853
+ .run(resolvedRemote, existing.id);
790
854
  }
791
855
  }
792
856
  return existing.id;
@@ -799,7 +863,7 @@ export function ensureProject(path: string, name?: string): string {
799
863
  if (alias) return alias.project_id;
800
864
 
801
865
  // 3. Git remote identification
802
- const gitRemote = getGitRemote(path);
866
+ const gitRemote = suppliedGitRemote ?? getGitRemote(path);
803
867
  if (gitRemote) {
804
868
  const byRemote = db()
805
869
  .query("SELECT id FROM projects WHERE git_remote = ? LIMIT 1")
@@ -844,6 +908,39 @@ export function projectId(path: string): string | undefined {
844
908
  return alias?.project_id;
845
909
  }
846
910
 
911
+ /**
912
+ * Look up a project by git_remote (preferred) or path. Returns the project ID
913
+ * or null if not found. Unlike `ensureProject()`, this is read-only — it never
914
+ * creates a project or registers path aliases.
915
+ */
916
+ export function resolveProjectByRemoteOrPath(
917
+ gitRemote?: string,
918
+ path?: string,
919
+ ): string | null {
920
+ if (gitRemote) {
921
+ const row = db()
922
+ .query("SELECT id FROM projects WHERE git_remote = ? LIMIT 1")
923
+ .get(gitRemote) as { id: string } | null;
924
+ if (row) return row.id;
925
+ }
926
+ if (path) {
927
+ return projectId(path) ?? null;
928
+ }
929
+ return null;
930
+ }
931
+
932
+ /**
933
+ * Look up the path for a project by its internal ID.
934
+ * Used by the REST API to resolve project UUID → path for core functions
935
+ * that require a path argument.
936
+ */
937
+ export function projectPath(id: string): string | null {
938
+ const row = db()
939
+ .query("SELECT path FROM projects WHERE id = ?")
940
+ .get(id) as { path: string } | null;
941
+ return row?.path ?? null;
942
+ }
943
+
847
944
  /** Look up a project's display name by its internal ID. */
848
945
  export function projectName(id: string): string | null {
849
946
  const row = db()
@@ -1068,6 +1165,289 @@ export function loadAllSessionCosts(): Map<string, SessionCostSnapshot> {
1068
1165
  return result;
1069
1166
  }
1070
1167
 
1168
+ // ---------------------------------------------------------------------------
1169
+ // Session tracking state (session_state table, v23 columns)
1170
+ // ---------------------------------------------------------------------------
1171
+
1172
+ /** Fields that can be persisted for session tracking state. */
1173
+ export type SessionTrackingState = {
1174
+ lastCuratedAt?: number;
1175
+ messageCount?: number;
1176
+ turnsSinceCuration?: number;
1177
+ consecutiveTextOnlyTurns?: number;
1178
+ ltmCacheText?: string | null;
1179
+ ltmCacheTokens?: number | null;
1180
+ ltmPinText?: string | null;
1181
+ ltmPinTokens?: number | null;
1182
+ // v24: session identity
1183
+ fingerprint?: string;
1184
+ headerSessionId?: string | null;
1185
+ headerName?: string | null;
1186
+ // v24: cache warming
1187
+ resolvedConversationTTL?: string;
1188
+ warmupState?: string | null; // JSON blob
1189
+ // v24: gradient calibration
1190
+ dynamicContextCap?: number;
1191
+ bustRateEMA?: number;
1192
+ interBustIntervalEMA?: number;
1193
+ lastLayer?: number;
1194
+ lastKnownInput?: number;
1195
+ lastTurnAt?: number;
1196
+ lastBustAt?: number;
1197
+ };
1198
+
1199
+ /**
1200
+ * Persist session tracking state. Ensures the row exists, then updates
1201
+ * only the fields that are explicitly provided (not undefined).
1202
+ */
1203
+ export function saveSessionTracking(sessionID: string, state: SessionTrackingState): void {
1204
+ const now = Date.now();
1205
+
1206
+ // Ensure row exists (no-op if it already does)
1207
+ db()
1208
+ .query(
1209
+ "INSERT OR IGNORE INTO session_state (session_id, force_min_layer, updated_at) VALUES (?, 0, ?)",
1210
+ )
1211
+ .run(sessionID, now);
1212
+
1213
+ // Build SET clauses for only the provided fields
1214
+ const sets: string[] = ["updated_at = ?"];
1215
+ const vals: (string | number | null)[] = [now];
1216
+
1217
+ if (state.lastCuratedAt !== undefined) {
1218
+ sets.push("last_curated_at = ?");
1219
+ vals.push(state.lastCuratedAt);
1220
+ }
1221
+ if (state.messageCount !== undefined) {
1222
+ sets.push("message_count = ?");
1223
+ vals.push(state.messageCount);
1224
+ }
1225
+ if (state.turnsSinceCuration !== undefined) {
1226
+ sets.push("turns_since_curation = ?");
1227
+ vals.push(state.turnsSinceCuration);
1228
+ }
1229
+ if (state.consecutiveTextOnlyTurns !== undefined) {
1230
+ sets.push("consecutive_text_only_turns = ?");
1231
+ vals.push(state.consecutiveTextOnlyTurns);
1232
+ }
1233
+ if (state.ltmCacheText !== undefined) {
1234
+ sets.push("ltm_cache_text = ?");
1235
+ vals.push(state.ltmCacheText);
1236
+ }
1237
+ if (state.ltmCacheTokens !== undefined) {
1238
+ sets.push("ltm_cache_tokens = ?");
1239
+ vals.push(state.ltmCacheTokens);
1240
+ }
1241
+ if (state.ltmPinText !== undefined) {
1242
+ sets.push("ltm_pin_text = ?");
1243
+ vals.push(state.ltmPinText);
1244
+ }
1245
+ if (state.ltmPinTokens !== undefined) {
1246
+ sets.push("ltm_pin_tokens = ?");
1247
+ vals.push(state.ltmPinTokens);
1248
+ }
1249
+ // v24: session identity
1250
+ if (state.fingerprint !== undefined) {
1251
+ sets.push("fingerprint = ?");
1252
+ vals.push(state.fingerprint);
1253
+ }
1254
+ if (state.headerSessionId !== undefined) {
1255
+ sets.push("header_session_id = ?");
1256
+ vals.push(state.headerSessionId);
1257
+ }
1258
+ if (state.headerName !== undefined) {
1259
+ sets.push("header_name = ?");
1260
+ vals.push(state.headerName);
1261
+ }
1262
+ // v24: cache warming
1263
+ if (state.resolvedConversationTTL !== undefined) {
1264
+ sets.push("resolved_conversation_ttl = ?");
1265
+ vals.push(state.resolvedConversationTTL);
1266
+ }
1267
+ if (state.warmupState !== undefined) {
1268
+ sets.push("warmup_state = ?");
1269
+ vals.push(state.warmupState);
1270
+ }
1271
+ // v24: gradient calibration
1272
+ if (state.dynamicContextCap !== undefined) {
1273
+ sets.push("dynamic_context_cap = ?");
1274
+ vals.push(state.dynamicContextCap);
1275
+ }
1276
+ if (state.bustRateEMA !== undefined) {
1277
+ sets.push("bust_rate_ema = ?");
1278
+ vals.push(state.bustRateEMA);
1279
+ }
1280
+ if (state.interBustIntervalEMA !== undefined) {
1281
+ sets.push("inter_bust_interval_ema = ?");
1282
+ vals.push(state.interBustIntervalEMA);
1283
+ }
1284
+ if (state.lastLayer !== undefined) {
1285
+ sets.push("last_layer = ?");
1286
+ vals.push(state.lastLayer);
1287
+ }
1288
+ if (state.lastKnownInput !== undefined) {
1289
+ sets.push("last_known_input = ?");
1290
+ vals.push(state.lastKnownInput);
1291
+ }
1292
+ if (state.lastTurnAt !== undefined) {
1293
+ sets.push("last_turn_at = ?");
1294
+ vals.push(state.lastTurnAt);
1295
+ }
1296
+ if (state.lastBustAt !== undefined) {
1297
+ sets.push("last_bust_at = ?");
1298
+ vals.push(state.lastBustAt);
1299
+ }
1300
+
1301
+ // Update only the specified columns
1302
+ db()
1303
+ .query(
1304
+ "UPDATE session_state SET " + sets.join(", ") + " WHERE session_id = ?",
1305
+ )
1306
+ .run(...vals, sessionID);
1307
+ }
1308
+
1309
+ /** Loaded session tracking state. */
1310
+ export type LoadedSessionTracking = {
1311
+ lastCuratedAt: number;
1312
+ messageCount: number;
1313
+ turnsSinceCuration: number;
1314
+ consecutiveTextOnlyTurns: number;
1315
+ ltmCacheText: string | null;
1316
+ ltmCacheTokens: number | null;
1317
+ ltmPinText: string | null;
1318
+ ltmPinTokens: number | null;
1319
+ // v24: session identity
1320
+ fingerprint: string;
1321
+ headerSessionId: string | null;
1322
+ headerName: string | null;
1323
+ // v24: cache warming
1324
+ resolvedConversationTTL: string;
1325
+ warmupState: string | null;
1326
+ // v24: gradient calibration
1327
+ dynamicContextCap: number;
1328
+ bustRateEMA: number;
1329
+ interBustIntervalEMA: number;
1330
+ lastLayer: number;
1331
+ lastKnownInput: number;
1332
+ lastTurnAt: number;
1333
+ lastBustAt: number;
1334
+ };
1335
+
1336
+ /**
1337
+ * Load persisted session tracking state. Returns null if no row exists.
1338
+ */
1339
+ export function loadSessionTracking(sessionID: string): LoadedSessionTracking | null {
1340
+ const row = db()
1341
+ .query(
1342
+ `SELECT last_curated_at, message_count, turns_since_curation,
1343
+ consecutive_text_only_turns,
1344
+ ltm_cache_text, ltm_cache_tokens, ltm_pin_text, ltm_pin_tokens,
1345
+ fingerprint, header_session_id, header_name,
1346
+ resolved_conversation_ttl, warmup_state,
1347
+ dynamic_context_cap, bust_rate_ema, inter_bust_interval_ema,
1348
+ last_layer, last_known_input, last_turn_at, last_bust_at
1349
+ FROM session_state WHERE session_id = ?`,
1350
+ )
1351
+ .get(sessionID) as {
1352
+ last_curated_at: number;
1353
+ message_count: number;
1354
+ turns_since_curation: number;
1355
+ consecutive_text_only_turns: number;
1356
+ ltm_cache_text: string | null;
1357
+ ltm_cache_tokens: number | null;
1358
+ ltm_pin_text: string | null;
1359
+ ltm_pin_tokens: number | null;
1360
+ fingerprint: string;
1361
+ header_session_id: string | null;
1362
+ header_name: string | null;
1363
+ resolved_conversation_ttl: string;
1364
+ warmup_state: string | null;
1365
+ dynamic_context_cap: number;
1366
+ bust_rate_ema: number;
1367
+ inter_bust_interval_ema: number;
1368
+ last_layer: number;
1369
+ last_known_input: number;
1370
+ last_turn_at: number;
1371
+ last_bust_at: number;
1372
+ } | null;
1373
+ if (!row) return null;
1374
+ return {
1375
+ lastCuratedAt: row.last_curated_at,
1376
+ messageCount: row.message_count,
1377
+ turnsSinceCuration: row.turns_since_curation,
1378
+ consecutiveTextOnlyTurns: row.consecutive_text_only_turns,
1379
+ ltmCacheText: row.ltm_cache_text,
1380
+ ltmCacheTokens: row.ltm_cache_tokens,
1381
+ ltmPinText: row.ltm_pin_text,
1382
+ ltmPinTokens: row.ltm_pin_tokens,
1383
+ fingerprint: row.fingerprint,
1384
+ headerSessionId: row.header_session_id,
1385
+ headerName: row.header_name,
1386
+ resolvedConversationTTL: row.resolved_conversation_ttl,
1387
+ warmupState: row.warmup_state,
1388
+ dynamicContextCap: row.dynamic_context_cap,
1389
+ bustRateEMA: row.bust_rate_ema,
1390
+ interBustIntervalEMA: row.inter_bust_interval_ema,
1391
+ lastLayer: row.last_layer,
1392
+ lastKnownInput: row.last_known_input,
1393
+ lastTurnAt: row.last_turn_at,
1394
+ lastBustAt: row.last_bust_at,
1395
+ };
1396
+ }
1397
+
1398
+ /**
1399
+ * Load all persisted header → session ID mappings from the session_state table.
1400
+ *
1401
+ * Used on gateway startup (in initIfNeeded) to pre-populate the in-memory
1402
+ * headerSessionIndex so Tier 1 session identification works immediately
1403
+ * after a process restart — without this, the first post-restart request
1404
+ * with a known session header would generate a new session ID and orphan
1405
+ * the old session's persisted state.
1406
+ */
1407
+ export function loadHeaderSessionIndex(): Array<{
1408
+ sessionId: string;
1409
+ headerSessionId: string;
1410
+ headerName: string;
1411
+ }> {
1412
+ const rows = db()
1413
+ .query(
1414
+ `SELECT session_id, header_session_id, header_name
1415
+ FROM session_state
1416
+ WHERE header_session_id IS NOT NULL AND header_name IS NOT NULL`,
1417
+ )
1418
+ .all() as Array<{
1419
+ session_id: string;
1420
+ header_session_id: string;
1421
+ header_name: string;
1422
+ }>;
1423
+ return rows.map((row) => ({
1424
+ sessionId: row.session_id,
1425
+ headerSessionId: row.header_session_id,
1426
+ headerName: row.header_name,
1427
+ }));
1428
+ }
1429
+
1430
+ // ---------------------------------------------------------------------------
1431
+ // Key-value store (kv_meta table)
1432
+ // ---------------------------------------------------------------------------
1433
+
1434
+ /** Get a kv_meta value by key. Returns null if not found. */
1435
+ export function getKV(key: string): string | null {
1436
+ const row = db()
1437
+ .query("SELECT value FROM kv_meta WHERE key = ?")
1438
+ .get(key) as { value: string } | null;
1439
+ return row?.value ?? null;
1440
+ }
1441
+
1442
+ /** Set a kv_meta value (upsert). */
1443
+ export function setKV(key: string, value: string): void {
1444
+ db()
1445
+ .query(
1446
+ "INSERT INTO kv_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?",
1447
+ )
1448
+ .run(key, value, value);
1449
+ }
1450
+
1071
1451
  // ---------------------------------------------------------------------------
1072
1452
  // Installation metadata (metadata table)
1073
1453
  // ---------------------------------------------------------------------------
@@ -14,6 +14,7 @@ import {
14
14
  } from "./prompt";
15
15
  import { toolStripAnnotation } from "./gradient";
16
16
  import { workerSessionIDs } from "./worker";
17
+ import { distillLimiter } from "./session-limiter";
17
18
  import type { LLMClient } from "./types";
18
19
 
19
20
  // Re-export for backwards compat — index.ts and others may still import from here.
@@ -610,8 +611,23 @@ function resetOrphans(projectPath: string, sessionID: string): number {
610
611
  return orphans.length;
611
612
  }
612
613
 
613
- // Main distillation entry point — called on session.idle or when urgent
614
+ // Main distillation entry point — called on session.idle or when urgent.
615
+ // Serialized per session via p-limit(1) to prevent concurrent runs from
616
+ // reading the same undistilled messages and producing duplicate rows.
614
617
  export async function run(input: {
618
+ llm: LLMClient;
619
+ projectPath: string;
620
+ sessionID: string;
621
+ model?: { providerID: string; modelID: string };
622
+ force?: boolean;
623
+ skipMeta?: boolean;
624
+ urgent?: boolean;
625
+ callType?: "batch" | "direct";
626
+ }): Promise<{ rounds: number; distilled: number }> {
627
+ return distillLimiter.get(input.sessionID)(() => runInner(input));
628
+ }
629
+
630
+ async function runInner(input: {
615
631
  llm: LLMClient;
616
632
  projectPath: string;
617
633
  sessionID: string;
@@ -697,7 +713,8 @@ export async function run(input: {
697
713
  gen0Count(input.projectPath, input.sessionID) >=
698
714
  cfg.distillation.metaThreshold
699
715
  ) {
700
- await metaDistill({
716
+ // Call inner directly — we're already under the per-session limiter.
717
+ await metaDistillInner({
701
718
  llm: input.llm,
702
719
  projectPath: input.projectPath,
703
720
  sessionID: input.sessionID,
@@ -776,17 +793,29 @@ async function distillSegment(input: {
776
793
  return null;
777
794
  }
778
795
 
779
- const distillId = storeDistillation({
780
- projectPath: input.projectPath,
781
- sessionID: input.sessionID,
782
- observations: result.observations,
783
- sourceIDs: input.messages.map((m) => m.id),
784
- generation: 0,
785
- rCompression: rComp,
786
- cNorm,
787
- callType: input.callType,
788
- });
789
- temporal.markDistilled(input.messages.map((m) => m.id));
796
+ // Atomic: store distillation + mark source messages as distilled in one
797
+ // transaction. Without this, a crash between the two statements would leave
798
+ // messages undistilled but with an existing distillation row, causing
799
+ // re-processing on restart and duplicate distillation content.
800
+ let distillId: string;
801
+ db().exec("BEGIN IMMEDIATE");
802
+ try {
803
+ distillId = storeDistillation({
804
+ projectPath: input.projectPath,
805
+ sessionID: input.sessionID,
806
+ observations: result.observations,
807
+ sourceIDs: input.messages.map((m) => m.id),
808
+ generation: 0,
809
+ rCompression: rComp,
810
+ cNorm,
811
+ callType: input.callType,
812
+ });
813
+ temporal.markDistilled(input.messages.map((m) => m.id));
814
+ db().exec("COMMIT");
815
+ } catch (e) {
816
+ db().exec("ROLLBACK");
817
+ throw e;
818
+ }
790
819
 
791
820
  log.info(
792
821
  `distill segment: ${input.messages.length} msgs, ` +
@@ -840,7 +869,8 @@ async function distillSegment(input: {
840
869
  * via `<previous-meta-summary>` so the LLM updates in place rather than
841
870
  * re-deriving from scratch.
842
871
  *
843
- * Exported for tests; `run()` is the production entry point.
872
+ * Serialized per session via the same p-limit(1) as `run()`. Exported for
873
+ * the idle handler which calls metaDistill() independently of run().
844
874
  */
845
875
  export async function metaDistill(input: {
846
876
  llm: LLMClient;
@@ -849,6 +879,17 @@ export async function metaDistill(input: {
849
879
  model?: { providerID: string; modelID: string };
850
880
  urgent?: boolean;
851
881
  callType?: "batch" | "direct";
882
+ }): Promise<DistillationResult | null> {
883
+ return distillLimiter.get(input.sessionID)(() => metaDistillInner(input));
884
+ }
885
+
886
+ async function metaDistillInner(input: {
887
+ llm: LLMClient;
888
+ projectPath: string;
889
+ sessionID: string;
890
+ model?: { providerID: string; modelID: string };
891
+ urgent?: boolean;
892
+ callType?: "batch" | "direct";
852
893
  }): Promise<DistillationResult | null> {
853
894
  const existing = loadGen0(input.projectPath, input.sessionID);
854
895