@martian-engineering/lossless-claw 0.6.3 → 0.8.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 (38) hide show
  1. package/README.md +26 -6
  2. package/docs/agent-tools.md +16 -5
  3. package/docs/configuration.md +223 -214
  4. package/openclaw.plugin.json +123 -0
  5. package/package.json +1 -1
  6. package/skills/lossless-claw/SKILL.md +3 -2
  7. package/skills/lossless-claw/references/architecture.md +12 -0
  8. package/skills/lossless-claw/references/config.md +135 -3
  9. package/skills/lossless-claw/references/diagnostics.md +13 -0
  10. package/src/assembler.ts +17 -5
  11. package/src/compaction.ts +161 -53
  12. package/src/db/config.ts +102 -4
  13. package/src/db/connection.ts +35 -7
  14. package/src/db/features.ts +24 -5
  15. package/src/db/migration.ts +257 -78
  16. package/src/engine.ts +1007 -110
  17. package/src/estimate-tokens.ts +80 -0
  18. package/src/lcm-log.ts +37 -0
  19. package/src/plugin/index.ts +493 -101
  20. package/src/plugin/lcm-command.ts +288 -7
  21. package/src/plugin/lcm-doctor-apply.ts +1 -3
  22. package/src/plugin/lcm-doctor-cleaners.ts +655 -0
  23. package/src/plugin/shared-init.ts +59 -0
  24. package/src/prune.ts +391 -0
  25. package/src/retrieval.ts +8 -9
  26. package/src/startup-banner-log.ts +1 -0
  27. package/src/store/compaction-telemetry-store.ts +156 -0
  28. package/src/store/conversation-store.ts +6 -1
  29. package/src/store/fts5-sanitize.ts +25 -4
  30. package/src/store/full-text-sort.ts +21 -0
  31. package/src/store/index.ts +8 -0
  32. package/src/store/summary-store.ts +21 -14
  33. package/src/summarize.ts +55 -34
  34. package/src/tools/lcm-describe-tool.ts +9 -4
  35. package/src/tools/lcm-expand-query-tool.ts +609 -200
  36. package/src/tools/lcm-expand-tool.ts +9 -4
  37. package/src/tools/lcm-grep-tool.ts +22 -8
  38. package/src/types.ts +1 -0
package/src/engine.ts CHANGED
@@ -43,9 +43,16 @@ import {
43
43
  generateExplorationSummary,
44
44
  parseFileBlocks,
45
45
  } from "./large-files.js";
46
+ import { describeLogError } from "./lcm-log.js";
46
47
  import { RetrievalEngine } from "./retrieval.js";
47
48
  import { compileSessionPatterns, matchesSessionPattern } from "./session-patterns.js";
48
49
  import { logStartupBannerOnce } from "./startup-banner-log.js";
50
+ import {
51
+ CompactionTelemetryStore,
52
+ type ConversationCompactionTelemetryRecord,
53
+ type CacheState,
54
+ type ActivityBand,
55
+ } from "./store/compaction-telemetry-store.js";
49
56
  import {
50
57
  ConversationStore,
51
58
  type ConversationRecord,
@@ -56,6 +63,7 @@ import {
56
63
  import { SummaryStore } from "./store/summary-store.js";
57
64
  import { createLcmSummarizeFromLegacyParams, LcmProviderAuthError } from "./summarize.js";
58
65
  import type { LcmDependencies } from "./types.js";
66
+ import { estimateTokens } from "./estimate-tokens.js";
59
67
 
60
68
  type AgentMessage = Parameters<ContextEngine["ingest"]>[0]["message"];
61
69
  type AssembleResultWithSystemPrompt = AssembleResult & { systemPromptAddition?: string };
@@ -63,6 +71,30 @@ type CircuitBreakerState = {
63
71
  failures: number;
64
72
  openSince: number | null;
65
73
  };
74
+ type PromptCacheSnapshot = {
75
+ lastObservedCacheRead?: number;
76
+ lastObservedCacheWrite?: number;
77
+ cacheState: CacheState;
78
+ retention?: string;
79
+ sawExplicitBreak: boolean;
80
+ };
81
+ type IncrementalCompactionDecision = {
82
+ shouldCompact: boolean;
83
+ cacheState: CacheState;
84
+ maxPasses: number;
85
+ rawTokensOutsideTail: number;
86
+ threshold: number;
87
+ leafChunkTokens: number;
88
+ fallbackLeafChunkTokens: number[];
89
+ activityBand: ActivityBand;
90
+ allowCondensedPasses: boolean;
91
+ };
92
+ type DynamicLeafChunkBounds = {
93
+ floor: number;
94
+ medium: number;
95
+ high: number;
96
+ max: number;
97
+ };
66
98
  type TranscriptRewriteReplacement = {
67
99
  entryId: string;
68
100
  message: AgentMessage;
@@ -83,14 +115,16 @@ type ContextEngineMaintenanceRuntimeContext = Record<string, unknown> & {
83
115
  };
84
116
 
85
117
  const TRANSCRIPT_GC_BATCH_SIZE = 12;
118
+ const HOT_CACHE_HYSTERESIS_TURNS = 2;
119
+ const DYNAMIC_LEAF_CHUNK_MEDIUM_MULTIPLIER = 1.5;
120
+ const DYNAMIC_LEAF_CHUNK_HIGH_MULTIPLIER = 2;
121
+ const DYNAMIC_ACTIVITY_MEDIUM_UPSHIFT_FACTOR = 0.5;
122
+ const DYNAMIC_ACTIVITY_MEDIUM_DOWNSHIFT_FACTOR = 0.35;
123
+ const DYNAMIC_ACTIVITY_HIGH_UPSHIFT_FACTOR = 1.0;
124
+ const DYNAMIC_ACTIVITY_HIGH_DOWNSHIFT_FACTOR = 0.75;
86
125
 
87
126
  // ── Helpers ──────────────────────────────────────────────────────────────────
88
127
 
89
- /** Rough token estimate: ~4 chars per token. */
90
- function estimateTokens(text: string): number {
91
- return Math.ceil(text.length / 4);
92
- }
93
-
94
128
  function toJson(value: unknown): string {
95
129
  const encoded = JSON.stringify(value);
96
130
  return typeof encoded === "string" ? encoded : "";
@@ -100,6 +134,10 @@ function safeString(value: unknown): string | undefined {
100
134
  return typeof value === "string" ? value : undefined;
101
135
  }
102
136
 
137
+ function formatDurationMs(durationMs: number): string {
138
+ return `${durationMs}ms`;
139
+ }
140
+
103
141
  function asRecord(value: unknown): Record<string, unknown> | undefined {
104
142
  return value && typeof value === "object" && !Array.isArray(value)
105
143
  ? (value as Record<string, unknown>)
@@ -835,12 +873,25 @@ function isBootstrapMessage(value: unknown): value is AgentMessage {
835
873
  return "content" in msg || ("command" in msg && "output" in msg);
836
874
  }
837
875
 
876
+ function extractCanonicalBootstrapMessage(value: unknown): AgentMessage | null {
877
+ if (isBootstrapMessage(value)) {
878
+ return value;
879
+ }
880
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
881
+ return null;
882
+ }
883
+ const entry = value as { type?: unknown; message?: unknown };
884
+ if ("message" in entry) {
885
+ if (entry.type !== undefined && entry.type !== "message") {
886
+ return null;
887
+ }
888
+ return isBootstrapMessage(entry.message) ? entry.message : null;
889
+ }
890
+ return null;
891
+ }
892
+
838
893
  function extractBootstrapMessageCandidate(value: unknown): AgentMessage | null {
839
- const candidate =
840
- value && typeof value === "object" && "message" in value
841
- ? (value as { message?: unknown }).message
842
- : value;
843
- return isBootstrapMessage(candidate) ? candidate : null;
894
+ return extractCanonicalBootstrapMessage(value);
844
895
  }
845
896
 
846
897
  function parseBootstrapJsonl(raw: string, options?: {
@@ -863,9 +914,6 @@ function parseBootstrapJsonl(raw: string, options?: {
863
914
  messages.push(candidate);
864
915
  continue;
865
916
  }
866
- if (options?.strict) {
867
- hadMalformedLine = true;
868
- }
869
917
  } catch {
870
918
  if (options?.strict) {
871
919
  hadMalformedLine = true;
@@ -1018,7 +1066,12 @@ function readFileSegment(sessionFile: string, offset: number): string | null {
1018
1066
  }
1019
1067
  }
1020
1068
 
1021
- function readLastJsonlEntryBeforeOffset(sessionFile: string, offset: number, messageOnly = false): string | null {
1069
+ function readLastJsonlEntryBeforeOffset(
1070
+ sessionFile: string,
1071
+ offset: number,
1072
+ messageOnly = false,
1073
+ matcher?: (message: AgentMessage) => boolean,
1074
+ ): string | null {
1022
1075
  const chunkSize = 16_384;
1023
1076
  let fd: number | null = null;
1024
1077
  try {
@@ -1056,11 +1109,11 @@ function readLastJsonlEntryBeforeOffset(sessionFile: string, offset: number, mes
1056
1109
  const candidate = trimmedEnd.slice(newlineIndex + 1).trim();
1057
1110
  if (candidate) {
1058
1111
  if (messageOnly) {
1059
- let isMessage = false;
1112
+ let matchedMessage: AgentMessage | null = null;
1060
1113
  try {
1061
- isMessage = extractBootstrapMessageCandidate(JSON.parse(candidate)) != null;
1114
+ matchedMessage = extractBootstrapMessageCandidate(JSON.parse(candidate));
1062
1115
  } catch { /* not valid JSON, skip */ }
1063
- if (!isMessage) {
1116
+ if (!matchedMessage || (matcher && !matcher(matchedMessage))) {
1064
1117
  carry = trimmedEnd.slice(0, newlineIndex);
1065
1118
  continue;
1066
1119
  }
@@ -1075,11 +1128,11 @@ function readLastJsonlEntryBeforeOffset(sessionFile: string, offset: number, mes
1075
1128
  if (reachedStart) {
1076
1129
  const firstLine = trimmedEnd.trim() || null;
1077
1130
  if (firstLine && messageOnly) {
1078
- let isMessage = false;
1131
+ let matchedMessage: AgentMessage | null = null;
1079
1132
  try {
1080
- isMessage = extractBootstrapMessageCandidate(JSON.parse(firstLine)) != null;
1133
+ matchedMessage = extractBootstrapMessageCandidate(JSON.parse(firstLine));
1081
1134
  } catch { /* not valid JSON */ }
1082
- if (!isMessage) return null;
1135
+ if (!matchedMessage || (matcher && !matcher(matchedMessage))) return null;
1083
1136
  }
1084
1137
  return firstLine;
1085
1138
  }
@@ -1155,6 +1208,7 @@ export class LcmContextEngine implements ContextEngine {
1155
1208
 
1156
1209
  private conversationStore: ConversationStore;
1157
1210
  private summaryStore: SummaryStore;
1211
+ private compactionTelemetryStore: CompactionTelemetryStore;
1158
1212
  private assembler: ContextAssembler;
1159
1213
  private compaction: CompactionEngine;
1160
1214
  private retrieval: RetrievalEngine;
@@ -1181,13 +1235,14 @@ export class LcmContextEngine implements ContextEngine {
1181
1235
  this.statelessSessionPatterns = compileSessionPatterns(this.config.statelessSessionPatterns);
1182
1236
  this.db = database;
1183
1237
 
1184
- this.fts5Available = getLcmDbFeatures(this.db).fts5Available;
1185
-
1186
1238
  // Run migrations eagerly at construction time so the schema exists
1187
1239
  // before any lifecycle hook fires.
1188
1240
  let migrationOk = false;
1241
+ const migrationStartedAt = Date.now();
1189
1242
  try {
1190
- runLcmMigrations(this.db, { fts5Available: this.fts5Available });
1243
+ runLcmMigrations(this.db, {
1244
+ log: this.deps.log,
1245
+ });
1191
1246
  this.migrated = true;
1192
1247
 
1193
1248
  // Verify tables were actually created
@@ -1200,16 +1255,21 @@ export class LcmContextEngine implements ContextEngine {
1200
1255
  );
1201
1256
  } else {
1202
1257
  migrationOk = true;
1258
+ this.deps.log.info(
1259
+ `[lcm] Migration run completed during engine init: duration=${formatDurationMs(Date.now() - migrationStartedAt)} fts5=${this.fts5Available}`,
1260
+ );
1203
1261
  this.deps.log.debug(
1204
1262
  `[lcm] Migration successful — ${tables.length} tables: ${tables.map((t) => t.name).join(", ")}`,
1205
1263
  );
1206
1264
  }
1207
1265
  } catch (err) {
1208
1266
  this.deps.log.error(
1209
- `[lcm] Migration failed: ${err instanceof Error ? err.message : String(err)}`,
1267
+ `[lcm] Migration failed after ${formatDurationMs(Date.now() - migrationStartedAt)}: ${err instanceof Error ? err.message : String(err)}`,
1210
1268
  );
1211
1269
  }
1212
1270
 
1271
+ this.fts5Available = getLcmDbFeatures(this.db).fts5Available;
1272
+
1213
1273
  // Only claim ownership of compaction when the DB is operational.
1214
1274
  // Without a working schema, ownsCompaction would disable the runtime's
1215
1275
  // built-in compaction safeguard and inflate the context budget.
@@ -1224,6 +1284,7 @@ export class LcmContextEngine implements ContextEngine {
1224
1284
  fts5Available: this.fts5Available,
1225
1285
  });
1226
1286
  this.summaryStore = new SummaryStore(this.db, { fts5Available: this.fts5Available });
1287
+ this.compactionTelemetryStore = new CompactionTelemetryStore(this.db);
1227
1288
 
1228
1289
  if (!this.fts5Available) {
1229
1290
  this.deps.log.warn(
@@ -1269,6 +1330,7 @@ export class LcmContextEngine implements ContextEngine {
1269
1330
  this.conversationStore,
1270
1331
  this.summaryStore,
1271
1332
  compactionConfig,
1333
+ this.deps.log,
1272
1334
  );
1273
1335
 
1274
1336
  this.retrieval = new RetrievalEngine(this.conversationStore, this.summaryStore);
@@ -1335,10 +1397,17 @@ export class LcmContextEngine implements ContextEngine {
1335
1397
  private recordCompactionAuthFailure(key: string): void {
1336
1398
  const state = this.getCircuitBreakerState(key);
1337
1399
  state.failures++;
1400
+ const halfThreshold = Math.ceil(this.config.circuitBreakerThreshold / 2);
1401
+ if (state.failures === halfThreshold && state.failures < this.config.circuitBreakerThreshold) {
1402
+ this.deps.log.warn(
1403
+ `[lcm] WARNING: compaction degraded — ${state.failures}/${this.config.circuitBreakerThreshold} consecutive auth failures for ${key}`,
1404
+ );
1405
+ }
1338
1406
  if (state.failures >= this.config.circuitBreakerThreshold) {
1339
1407
  state.openSince = Date.now();
1340
- console.error(
1341
- `[lcm] compaction circuit breaker OPEN: ${state.failures} consecutive auth failures for ${key}. Compaction halted. Will auto-retry after ${Math.round(this.config.circuitBreakerCooldownMs / 60000)}m or gateway restart.`,
1408
+ const cooldownMin = Math.round(this.config.circuitBreakerCooldownMs / 60000);
1409
+ this.deps.log.warn(
1410
+ `[lcm] CIRCUIT BREAKER OPEN: compaction disabled for ${key}. Auto-retry in ${cooldownMin}m. LCM is operating in degraded mode.`,
1342
1411
  );
1343
1412
  }
1344
1413
  }
@@ -1349,7 +1418,7 @@ export class LcmContextEngine implements ContextEngine {
1349
1418
  return;
1350
1419
  }
1351
1420
  if (state.failures > 0 || state.openSince !== null) {
1352
- console.error(
1421
+ this.deps.log.info(
1353
1422
  `[lcm] compaction circuit breaker CLOSED: successful compaction for ${key} after ${state.failures} prior failures.`,
1354
1423
  );
1355
1424
  }
@@ -1365,17 +1434,29 @@ export class LcmContextEngine implements ContextEngine {
1365
1434
  if (this.migrated) {
1366
1435
  return;
1367
1436
  }
1368
- runLcmMigrations(this.db, { fts5Available: this.fts5Available });
1437
+ const migrationStartedAt = Date.now();
1438
+ this.deps.log.info("[lcm] ensureMigrated: running migrations lazily");
1439
+ runLcmMigrations(this.db, {
1440
+ log: this.deps.log,
1441
+ });
1369
1442
  this.migrated = true;
1443
+ this.deps.log.info(
1444
+ `[lcm] ensureMigrated: completed in ${formatDurationMs(Date.now() - migrationStartedAt)}`,
1445
+ );
1370
1446
  }
1371
1447
 
1372
1448
  /**
1373
1449
  * Serialize mutating operations per stable session identity to prevent
1374
1450
  * ingest/compaction races across runtime UUID recycling.
1375
1451
  */
1376
- private async withSessionQueue<T>(queueKey: string, operation: () => Promise<T>): Promise<T> {
1452
+ private async withSessionQueue<T>(
1453
+ queueKey: string,
1454
+ operation: () => Promise<T>,
1455
+ options?: { operationName?: string; context?: string },
1456
+ ): Promise<T> {
1377
1457
  const entry = this.sessionOperationQueues.get(queueKey);
1378
1458
  const previous = entry?.promise ?? Promise.resolve();
1459
+ const queuedAhead = entry?.refCount ?? 0;
1379
1460
  let releaseQueue: () => void = () => {};
1380
1461
  const current = new Promise<void>((resolve) => {
1381
1462
  releaseQueue = resolve;
@@ -1389,7 +1470,15 @@ export class LcmContextEngine implements ContextEngine {
1389
1470
  this.sessionOperationQueues.set(queueKey, { promise: next, refCount: 1 });
1390
1471
  }
1391
1472
 
1473
+ const waitStartedAt = Date.now();
1392
1474
  await previous.catch(() => {});
1475
+ const waitMs = Date.now() - waitStartedAt;
1476
+ if (options?.operationName) {
1477
+ const detail = options.context ? ` ${options.context}` : "";
1478
+ this.deps.log.info(
1479
+ `[lcm] ${options.operationName}: session queue acquired queueKey=${queueKey} queuedAhead=${queuedAhead} wait=${formatDurationMs(waitMs)}${detail}`,
1480
+ );
1481
+ }
1393
1482
  try {
1394
1483
  return await operation();
1395
1484
  } finally {
@@ -1446,6 +1535,486 @@ export class LcmContextEngine implements ContextEngine {
1446
1535
  return cap != null && cap > 0 ? Math.min(budget, cap) : budget;
1447
1536
  }
1448
1537
 
1538
+ /** Normalize token counters that may legitimately be zero. */
1539
+ private normalizeOptionalCount(value: unknown): number | undefined {
1540
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
1541
+ return undefined;
1542
+ }
1543
+ return Math.floor(value);
1544
+ }
1545
+
1546
+ /** Treat a recent cache hit as still-hot for a couple of turns unless telemetry observed a later break. */
1547
+ private shouldApplyHotCacheHysteresis(
1548
+ telemetry: ConversationCompactionTelemetryRecord | null,
1549
+ ): boolean {
1550
+ if (!telemetry?.lastObservedCacheHitAt) {
1551
+ return false;
1552
+ }
1553
+ if (
1554
+ telemetry.lastObservedCacheBreakAt
1555
+ && telemetry.lastObservedCacheBreakAt >= telemetry.lastObservedCacheHitAt
1556
+ ) {
1557
+ return false;
1558
+ }
1559
+ return telemetry.turnsSinceLeafCompaction <= HOT_CACHE_HYSTERESIS_TURNS;
1560
+ }
1561
+
1562
+ /** Resolve the effective cache state the incremental compaction policy should react to. */
1563
+ private resolveCacheAwareState(
1564
+ telemetry: ConversationCompactionTelemetryRecord | null,
1565
+ ): CacheState {
1566
+ if (!telemetry) {
1567
+ return "unknown";
1568
+ }
1569
+ if (telemetry.cacheState === "hot") {
1570
+ return "hot";
1571
+ }
1572
+ if (this.shouldApplyHotCacheHysteresis(telemetry)) {
1573
+ return "hot";
1574
+ }
1575
+ return telemetry.cacheState;
1576
+ }
1577
+
1578
+ /** Decide whether a hot cache still has enough real token-budget headroom to skip incremental maintenance. */
1579
+ private isComfortablyUnderTokenBudget(params: {
1580
+ currentTokenCount?: number;
1581
+ tokenBudget: number;
1582
+ }): boolean {
1583
+ if (
1584
+ typeof params.currentTokenCount !== "number"
1585
+ || !Number.isFinite(params.currentTokenCount)
1586
+ || params.currentTokenCount < 0
1587
+ ) {
1588
+ return false;
1589
+ }
1590
+ const budget = Math.max(1, Math.floor(params.tokenBudget));
1591
+ const safeBudget = Math.floor(
1592
+ budget * (1 - this.config.cacheAwareCompaction.hotCacheBudgetHeadroomRatio),
1593
+ );
1594
+ return params.currentTokenCount <= safeBudget;
1595
+ }
1596
+
1597
+ /** Resolve bounded dynamic leaf chunk sizes from config and the active token budget. */
1598
+ private resolveDynamicLeafChunkBounds(tokenBudget?: number): DynamicLeafChunkBounds {
1599
+ const floor = Math.max(1, Math.floor(this.config.leafChunkTokens));
1600
+ const configuredMax = this.config.dynamicLeafChunkTokens.enabled
1601
+ ? Math.max(floor, Math.floor(this.config.dynamicLeafChunkTokens.max))
1602
+ : floor;
1603
+ const budgetCap =
1604
+ typeof tokenBudget === "number" &&
1605
+ Number.isFinite(tokenBudget) &&
1606
+ tokenBudget > 0
1607
+ ? Math.max(floor, Math.floor(tokenBudget * this.config.contextThreshold))
1608
+ : configuredMax;
1609
+ const max = Math.max(floor, Math.min(configuredMax, budgetCap));
1610
+ const medium = Math.max(
1611
+ floor,
1612
+ Math.min(max, Math.floor(floor * DYNAMIC_LEAF_CHUNK_MEDIUM_MULTIPLIER)),
1613
+ );
1614
+ const high = Math.max(
1615
+ floor,
1616
+ Math.min(max, Math.floor(floor * DYNAMIC_LEAF_CHUNK_HIGH_MULTIPLIER)),
1617
+ );
1618
+ return { floor, medium, high, max };
1619
+ }
1620
+
1621
+ /** Classify the current refill rate into a simple step band with downshift hysteresis. */
1622
+ private classifyDynamicLeafActivityBand(params: {
1623
+ lastActivityBand?: ActivityBand;
1624
+ tokensAccumulatedSinceLeafCompaction: number;
1625
+ turnsSinceLeafCompaction: number;
1626
+ floor: number;
1627
+ }): ActivityBand {
1628
+ const turns = Math.max(1, params.turnsSinceLeafCompaction);
1629
+ const tokensPerTurn = params.tokensAccumulatedSinceLeafCompaction / turns;
1630
+ const mediumUpshift = params.floor * DYNAMIC_ACTIVITY_MEDIUM_UPSHIFT_FACTOR;
1631
+ const mediumDownshift = params.floor * DYNAMIC_ACTIVITY_MEDIUM_DOWNSHIFT_FACTOR;
1632
+ const highUpshift = params.floor * DYNAMIC_ACTIVITY_HIGH_UPSHIFT_FACTOR;
1633
+ const highDownshift = params.floor * DYNAMIC_ACTIVITY_HIGH_DOWNSHIFT_FACTOR;
1634
+ const lastBand = params.lastActivityBand ?? "low";
1635
+
1636
+ if (lastBand === "high") {
1637
+ if (tokensPerTurn >= highDownshift) {
1638
+ return "high";
1639
+ }
1640
+ return tokensPerTurn >= mediumDownshift ? "medium" : "low";
1641
+ }
1642
+ if (lastBand === "medium") {
1643
+ if (tokensPerTurn >= highUpshift) {
1644
+ return "high";
1645
+ }
1646
+ if (tokensPerTurn < mediumDownshift) {
1647
+ return "low";
1648
+ }
1649
+ return "medium";
1650
+ }
1651
+ if (tokensPerTurn >= highUpshift) {
1652
+ return "high";
1653
+ }
1654
+ if (tokensPerTurn >= mediumUpshift) {
1655
+ return "medium";
1656
+ }
1657
+ return "low";
1658
+ }
1659
+
1660
+ /** Map an activity band to the corresponding working leaf chunk size. */
1661
+ private resolveLeafChunkTokensForBand(
1662
+ band: ActivityBand,
1663
+ bounds: DynamicLeafChunkBounds,
1664
+ ): number {
1665
+ switch (band) {
1666
+ case "high":
1667
+ return bounds.high;
1668
+ case "medium":
1669
+ return bounds.medium;
1670
+ default:
1671
+ return bounds.floor;
1672
+ }
1673
+ }
1674
+
1675
+ /** Build descending fallback chunk sizes used when a provider rejects a larger chunk. */
1676
+ private buildLeafChunkFallbacks(params: {
1677
+ preferred: number;
1678
+ bounds: DynamicLeafChunkBounds;
1679
+ }): number[] {
1680
+ const ordered = [params.preferred, params.bounds.max, params.bounds.high, params.bounds.medium, params.bounds.floor];
1681
+ const seen = new Set<number>();
1682
+ const fallbacks: number[] = [];
1683
+ for (const value of ordered) {
1684
+ const normalized = Math.max(params.bounds.floor, Math.floor(value));
1685
+ if (seen.has(normalized)) {
1686
+ continue;
1687
+ }
1688
+ seen.add(normalized);
1689
+ fallbacks.push(normalized);
1690
+ }
1691
+ return fallbacks.sort((a, b) => b - a);
1692
+ }
1693
+
1694
+ /** Detect provider/model token-limit failures that should trigger a lower chunk retry. */
1695
+ private isRecoverableLeafChunkOverflowError(error: unknown): boolean {
1696
+ const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
1697
+ if (!message) {
1698
+ return false;
1699
+ }
1700
+ return [
1701
+ "context length",
1702
+ "context window",
1703
+ "maximum context",
1704
+ "max context",
1705
+ "too many tokens",
1706
+ "too many input tokens",
1707
+ "input tokens",
1708
+ "token limit",
1709
+ "context limit",
1710
+ "input is too large",
1711
+ "input too large",
1712
+ "prompt is too long",
1713
+ "request too large",
1714
+ "exceeds the model",
1715
+ "exceeds context",
1716
+ ].some((fragment) => message.includes(fragment));
1717
+ }
1718
+
1719
+ /** Extract the current prompt-cache snapshot from runtime context, if present. */
1720
+ private readPromptCacheSnapshot(runtimeContext?: Record<string, unknown>): PromptCacheSnapshot | null {
1721
+ const promptCache = asRecord(runtimeContext?.promptCache);
1722
+ if (!promptCache) {
1723
+ return null;
1724
+ }
1725
+
1726
+ const lastCallUsage = asRecord(promptCache.lastCallUsage);
1727
+ const observation = asRecord(promptCache.observation);
1728
+ const cacheRead = this.normalizeOptionalCount(lastCallUsage?.cacheRead);
1729
+ const cacheWrite = this.normalizeOptionalCount(lastCallUsage?.cacheWrite);
1730
+ const sawExplicitBreak = safeBoolean(observation?.broke) === true;
1731
+ const retention = safeString(promptCache.retention)?.trim();
1732
+ const hasUsageSignal = cacheRead !== undefined || cacheWrite !== undefined;
1733
+ const hasObservationSignal =
1734
+ typeof observation?.cacheRead === "number"
1735
+ || typeof observation?.previousCacheRead === "number"
1736
+ || sawExplicitBreak;
1737
+
1738
+ let cacheState: CacheState = "unknown";
1739
+ if (sawExplicitBreak) {
1740
+ cacheState = "cold";
1741
+ } else if (typeof cacheRead === "number" && cacheRead > 0) {
1742
+ cacheState = "hot";
1743
+ } else if (hasUsageSignal || hasObservationSignal) {
1744
+ cacheState = "cold";
1745
+ }
1746
+
1747
+ return {
1748
+ ...(cacheRead !== undefined ? { lastObservedCacheRead: cacheRead } : {}),
1749
+ ...(cacheWrite !== undefined ? { lastObservedCacheWrite: cacheWrite } : {}),
1750
+ cacheState,
1751
+ ...(retention ? { retention } : {}),
1752
+ sawExplicitBreak,
1753
+ };
1754
+ }
1755
+
1756
+ /** Persist the current turn's compaction telemetry for later policy decisions. */
1757
+ private async updateCompactionTelemetry(params: {
1758
+ conversationId: number;
1759
+ runtimeContext?: Record<string, unknown>;
1760
+ tokenBudget?: number;
1761
+ rawTokensOutsideTail?: number;
1762
+ }): Promise<ConversationCompactionTelemetryRecord | null> {
1763
+ const snapshot = this.readPromptCacheSnapshot(params.runtimeContext);
1764
+ const existing = await this.compactionTelemetryStore.getConversationCompactionTelemetry(
1765
+ params.conversationId,
1766
+ );
1767
+ if (!snapshot && params.rawTokensOutsideTail === undefined) {
1768
+ return existing;
1769
+ }
1770
+
1771
+ const now = new Date();
1772
+ const bounds = this.resolveDynamicLeafChunkBounds(params.tokenBudget);
1773
+ const turnsSinceLeafCompaction =
1774
+ (existing?.turnsSinceLeafCompaction ?? 0) + 1;
1775
+ const tokensAccumulatedSinceLeafCompaction =
1776
+ params.rawTokensOutsideTail ?? existing?.tokensAccumulatedSinceLeafCompaction ?? 0;
1777
+ const lastActivityBand = this.classifyDynamicLeafActivityBand({
1778
+ lastActivityBand: existing?.lastActivityBand,
1779
+ tokensAccumulatedSinceLeafCompaction,
1780
+ turnsSinceLeafCompaction,
1781
+ floor: bounds.floor,
1782
+ });
1783
+ await this.compactionTelemetryStore.upsertConversationCompactionTelemetry({
1784
+ conversationId: params.conversationId,
1785
+ lastObservedCacheRead: snapshot?.lastObservedCacheRead ?? existing?.lastObservedCacheRead ?? null,
1786
+ lastObservedCacheWrite:
1787
+ snapshot?.lastObservedCacheWrite ?? existing?.lastObservedCacheWrite ?? null,
1788
+ lastObservedCacheHitAt:
1789
+ snapshot?.cacheState === "hot"
1790
+ ? now
1791
+ : existing?.lastObservedCacheHitAt ?? null,
1792
+ lastObservedCacheBreakAt:
1793
+ snapshot?.sawExplicitBreak
1794
+ ? now
1795
+ : existing?.lastObservedCacheBreakAt ?? null,
1796
+ cacheState: snapshot?.cacheState ?? existing?.cacheState ?? "unknown",
1797
+ retention: snapshot?.retention ?? existing?.retention ?? null,
1798
+ lastLeafCompactionAt: existing?.lastLeafCompactionAt ?? null,
1799
+ turnsSinceLeafCompaction,
1800
+ tokensAccumulatedSinceLeafCompaction,
1801
+ lastActivityBand,
1802
+ });
1803
+ const updated = await this.compactionTelemetryStore.getConversationCompactionTelemetry(
1804
+ params.conversationId,
1805
+ );
1806
+ if (updated) {
1807
+ this.deps.log.debug(
1808
+ `[lcm] compaction telemetry updated: conversation=${params.conversationId} cacheState=${updated.cacheState} cacheRead=${updated.lastObservedCacheRead ?? "null"} cacheWrite=${updated.lastObservedCacheWrite ?? "null"} retention=${updated.retention ?? "null"} turnsSinceLeafCompaction=${updated.turnsSinceLeafCompaction} tokensSinceLeafCompaction=${updated.tokensAccumulatedSinceLeafCompaction} activityBand=${updated.lastActivityBand} rawTokensOutsideTail=${params.rawTokensOutsideTail ?? "null"} tokenBudget=${params.tokenBudget ?? "null"}`,
1809
+ );
1810
+ }
1811
+ return updated;
1812
+ }
1813
+
1814
+ /** Reset refill counters after any successful leaf-producing compaction. */
1815
+ private async markLeafCompactionTelemetrySuccess(params: {
1816
+ conversationId: number;
1817
+ activityBand?: ActivityBand;
1818
+ }): Promise<void> {
1819
+ const existing = await this.compactionTelemetryStore.getConversationCompactionTelemetry(
1820
+ params.conversationId,
1821
+ );
1822
+ await this.compactionTelemetryStore.upsertConversationCompactionTelemetry({
1823
+ conversationId: params.conversationId,
1824
+ lastObservedCacheRead: existing?.lastObservedCacheRead ?? null,
1825
+ lastObservedCacheWrite: existing?.lastObservedCacheWrite ?? null,
1826
+ lastObservedCacheHitAt: existing?.lastObservedCacheHitAt ?? null,
1827
+ lastObservedCacheBreakAt: existing?.lastObservedCacheBreakAt ?? null,
1828
+ cacheState: existing?.cacheState ?? "unknown",
1829
+ retention: existing?.retention ?? null,
1830
+ lastLeafCompactionAt: new Date(),
1831
+ turnsSinceLeafCompaction: 0,
1832
+ tokensAccumulatedSinceLeafCompaction: 0,
1833
+ lastActivityBand: params.activityBand ?? existing?.lastActivityBand ?? "low",
1834
+ });
1835
+ this.deps.log.debug(
1836
+ `[lcm] compaction telemetry reset after leaf compaction: conversation=${params.conversationId} cacheState=${existing?.cacheState ?? "unknown"} activityBand=${params.activityBand ?? existing?.lastActivityBand ?? "low"}`,
1837
+ );
1838
+ }
1839
+
1840
+ /** Emit an operational trace for the incremental compaction policy decision. */
1841
+ private logIncrementalCompactionDecision(params: {
1842
+ conversationId: number;
1843
+ cacheState: CacheState;
1844
+ activityBand: ActivityBand;
1845
+ triggerLeafChunkTokens: number;
1846
+ preferredLeafChunkTokens: number;
1847
+ fallbackLeafChunkTokens: number[];
1848
+ rawTokensOutsideTail: number;
1849
+ threshold: number;
1850
+ shouldCompact: boolean;
1851
+ maxPasses: number;
1852
+ allowCondensedPasses: boolean;
1853
+ reason: string;
1854
+ }): IncrementalCompactionDecision {
1855
+ this.deps.log.info(
1856
+ `[lcm] incremental compaction decision: conversation=${params.conversationId} cacheState=${params.cacheState} activityBand=${params.activityBand} triggerLeafChunkTokens=${params.triggerLeafChunkTokens} preferredLeafChunkTokens=${params.preferredLeafChunkTokens} fallbackLeafChunkTokens=${params.fallbackLeafChunkTokens.join(",")} rawTokensOutsideTail=${params.rawTokensOutsideTail} threshold=${params.threshold} shouldCompact=${params.shouldCompact} maxPasses=${params.maxPasses} allowCondensedPasses=${params.allowCondensedPasses} reason=${params.reason}`,
1857
+ );
1858
+ return {
1859
+ shouldCompact: params.shouldCompact,
1860
+ cacheState: params.cacheState,
1861
+ maxPasses: params.maxPasses,
1862
+ rawTokensOutsideTail: params.rawTokensOutsideTail,
1863
+ threshold: params.threshold,
1864
+ leafChunkTokens: params.preferredLeafChunkTokens,
1865
+ fallbackLeafChunkTokens: params.fallbackLeafChunkTokens,
1866
+ activityBand: params.activityBand,
1867
+ allowCondensedPasses: params.allowCondensedPasses,
1868
+ };
1869
+ }
1870
+
1871
+ /** Resolve the cache-aware incremental-compaction policy for the current session. */
1872
+ private async evaluateIncrementalCompaction(params: {
1873
+ conversationId: number;
1874
+ tokenBudget: number;
1875
+ currentTokenCount?: number;
1876
+ }): Promise<IncrementalCompactionDecision> {
1877
+ const telemetry = await this.compactionTelemetryStore.getConversationCompactionTelemetry(
1878
+ params.conversationId,
1879
+ );
1880
+ const cacheState =
1881
+ this.config.cacheAwareCompaction.enabled
1882
+ ? this.resolveCacheAwareState(telemetry)
1883
+ : "unknown";
1884
+ const bounds = this.resolveDynamicLeafChunkBounds(params.tokenBudget);
1885
+ const activityBand =
1886
+ this.config.dynamicLeafChunkTokens.enabled
1887
+ ? this.classifyDynamicLeafActivityBand({
1888
+ lastActivityBand: telemetry?.lastActivityBand,
1889
+ tokensAccumulatedSinceLeafCompaction:
1890
+ telemetry?.tokensAccumulatedSinceLeafCompaction ?? 0,
1891
+ turnsSinceLeafCompaction: telemetry?.turnsSinceLeafCompaction ?? 0,
1892
+ floor: bounds.floor,
1893
+ })
1894
+ : "low";
1895
+ const triggerLeafChunkTokens =
1896
+ this.config.dynamicLeafChunkTokens.enabled && cacheState === "hot"
1897
+ ? bounds.max
1898
+ : this.config.dynamicLeafChunkTokens.enabled
1899
+ ? this.resolveLeafChunkTokensForBand(activityBand, bounds)
1900
+ : bounds.floor;
1901
+ const preferredLeafChunkTokens =
1902
+ this.config.cacheAwareCompaction.enabled && (cacheState === "cold" || cacheState === "hot")
1903
+ ? bounds.max
1904
+ : triggerLeafChunkTokens;
1905
+ const fallbackLeafChunkTokens = this.buildLeafChunkFallbacks({
1906
+ preferred: preferredLeafChunkTokens,
1907
+ bounds,
1908
+ });
1909
+ const leafTrigger = await this.compaction.evaluateLeafTrigger(
1910
+ params.conversationId,
1911
+ triggerLeafChunkTokens,
1912
+ );
1913
+ if (!leafTrigger.shouldCompact) {
1914
+ return this.logIncrementalCompactionDecision({
1915
+ conversationId: params.conversationId,
1916
+ cacheState,
1917
+ activityBand,
1918
+ triggerLeafChunkTokens,
1919
+ preferredLeafChunkTokens,
1920
+ fallbackLeafChunkTokens,
1921
+ rawTokensOutsideTail: leafTrigger.rawTokensOutsideTail,
1922
+ threshold: leafTrigger.threshold,
1923
+ shouldCompact: false,
1924
+ maxPasses: 1,
1925
+ allowCondensedPasses: false,
1926
+ reason: "below-leaf-trigger",
1927
+ });
1928
+ }
1929
+
1930
+ const budgetDecision = await this.compaction.evaluate(
1931
+ params.conversationId,
1932
+ params.tokenBudget,
1933
+ params.currentTokenCount,
1934
+ );
1935
+ if (budgetDecision.shouldCompact) {
1936
+ return this.logIncrementalCompactionDecision({
1937
+ conversationId: params.conversationId,
1938
+ cacheState,
1939
+ activityBand,
1940
+ triggerLeafChunkTokens,
1941
+ preferredLeafChunkTokens,
1942
+ fallbackLeafChunkTokens,
1943
+ rawTokensOutsideTail: leafTrigger.rawTokensOutsideTail,
1944
+ threshold: leafTrigger.threshold,
1945
+ shouldCompact: true,
1946
+ maxPasses: 1,
1947
+ allowCondensedPasses: true,
1948
+ reason: "budget-trigger",
1949
+ });
1950
+ }
1951
+
1952
+ if (
1953
+ cacheState === "hot"
1954
+ && this.isComfortablyUnderTokenBudget({
1955
+ currentTokenCount: params.currentTokenCount,
1956
+ tokenBudget: params.tokenBudget,
1957
+ })
1958
+ ) {
1959
+ return this.logIncrementalCompactionDecision({
1960
+ conversationId: params.conversationId,
1961
+ cacheState,
1962
+ activityBand,
1963
+ triggerLeafChunkTokens,
1964
+ preferredLeafChunkTokens,
1965
+ fallbackLeafChunkTokens,
1966
+ rawTokensOutsideTail: leafTrigger.rawTokensOutsideTail,
1967
+ threshold: leafTrigger.threshold,
1968
+ shouldCompact: false,
1969
+ maxPasses: 1,
1970
+ allowCondensedPasses: false,
1971
+ reason: "hot-cache-budget-headroom",
1972
+ });
1973
+ }
1974
+
1975
+ if (
1976
+ cacheState === "hot"
1977
+ && leafTrigger.rawTokensOutsideTail
1978
+ < Math.floor(
1979
+ leafTrigger.threshold * this.config.cacheAwareCompaction.hotCachePressureFactor,
1980
+ )
1981
+ ) {
1982
+ return this.logIncrementalCompactionDecision({
1983
+ conversationId: params.conversationId,
1984
+ cacheState,
1985
+ activityBand,
1986
+ triggerLeafChunkTokens,
1987
+ preferredLeafChunkTokens,
1988
+ fallbackLeafChunkTokens,
1989
+ rawTokensOutsideTail: leafTrigger.rawTokensOutsideTail,
1990
+ threshold: leafTrigger.threshold,
1991
+ shouldCompact: false,
1992
+ maxPasses: 1,
1993
+ allowCondensedPasses: false,
1994
+ reason: "hot-cache-defer",
1995
+ });
1996
+ }
1997
+
1998
+ const maxPasses =
1999
+ cacheState === "cold"
2000
+ ? Math.max(1, this.config.cacheAwareCompaction.maxColdCacheCatchupPasses)
2001
+ : 1;
2002
+ return this.logIncrementalCompactionDecision({
2003
+ conversationId: params.conversationId,
2004
+ cacheState,
2005
+ activityBand,
2006
+ triggerLeafChunkTokens,
2007
+ preferredLeafChunkTokens,
2008
+ fallbackLeafChunkTokens,
2009
+ rawTokensOutsideTail: leafTrigger.rawTokensOutsideTail,
2010
+ threshold: leafTrigger.threshold,
2011
+ shouldCompact: true,
2012
+ maxPasses,
2013
+ allowCondensedPasses: cacheState !== "hot",
2014
+ reason: cacheState === "cold" ? "cold-cache-catchup" : "leaf-trigger",
2015
+ });
2016
+ }
2017
+
1449
2018
  /** Resolve an LCM conversation id from a session key via the session store. */
1450
2019
  private async resolveConversationIdForSessionKey(
1451
2020
  sessionKey: string,
@@ -1475,6 +2044,23 @@ export class LcmContextEngine implements ContextEngine {
1475
2044
  }
1476
2045
  }
1477
2046
 
2047
+ /** Format stable session identifiers for LCM diagnostic logs. */
2048
+ private formatSessionLogContext(params: {
2049
+ conversationId: number;
2050
+ sessionId: string;
2051
+ sessionKey?: string;
2052
+ }): string {
2053
+ const parts = [
2054
+ `conversation=${params.conversationId}`,
2055
+ `session=${params.sessionId}`,
2056
+ ];
2057
+ const trimmedSessionKey = params.sessionKey?.trim();
2058
+ if (trimmedSessionKey) {
2059
+ parts.push(`sessionKey=${trimmedSessionKey}`);
2060
+ }
2061
+ return parts.join(" ");
2062
+ }
2063
+
1478
2064
  /** Build a summarize callback with runtime provider fallback handling. */
1479
2065
  private async resolveSummarize(params: {
1480
2066
  legacyParams?: Record<string, unknown>;
@@ -1510,11 +2096,13 @@ export class LcmContextEngine implements ContextEngine {
1510
2096
  breakerKey: runtimeSummarizer.breakerKey,
1511
2097
  };
1512
2098
  }
1513
- console.error(`[lcm] resolveSummarize: createLcmSummarizeFromLegacyParams returned undefined`);
2099
+ this.deps.log.error(`[lcm] resolveSummarize: createLcmSummarizeFromLegacyParams returned undefined`);
1514
2100
  } catch (err) {
1515
- console.error(`[lcm] resolveSummarize failed, using emergency fallback:`, err instanceof Error ? err.message : err);
2101
+ this.deps.log.error(
2102
+ `[lcm] resolveSummarize failed, using emergency fallback: ${describeLogError(err)}`,
2103
+ );
1516
2104
  }
1517
- console.error(`[lcm] resolveSummarize: FALLING BACK TO EMERGENCY TRUNCATION`);
2105
+ this.deps.log.error(`[lcm] resolveSummarize: FALLING BACK TO EMERGENCY TRUNCATION`);
1518
2106
  return { summarize: createEmergencyFallbackSummarize(), summaryModel: "unknown" };
1519
2107
  }
1520
2108
 
@@ -1858,12 +2446,24 @@ export class LcmContextEngine implements ContextEngine {
1858
2446
  hasOverlap: boolean;
1859
2447
  }> {
1860
2448
  const { sessionId, conversationId, historicalMessages } = params;
2449
+ const startedAt = Date.now();
2450
+ const sessionContext = this.formatSessionLogContext({
2451
+ conversationId,
2452
+ sessionId,
2453
+ sessionKey: params.sessionKey,
2454
+ });
1861
2455
  if (historicalMessages.length === 0) {
2456
+ this.deps.log.info(
2457
+ `[lcm] reconcileSessionTail: skipped for ${sessionContext} duration=${formatDurationMs(Date.now() - startedAt)} historicalMessages=0 reason=empty-history`,
2458
+ );
1862
2459
  return { blockedByImportCap: false, importedMessages: 0, hasOverlap: false };
1863
2460
  }
1864
2461
 
1865
2462
  const latestDbMessage = await this.conversationStore.getLastMessage(conversationId);
1866
2463
  if (!latestDbMessage) {
2464
+ this.deps.log.info(
2465
+ `[lcm] reconcileSessionTail: skipped for ${sessionContext} duration=${formatDurationMs(Date.now() - startedAt)} historicalMessages=${historicalMessages.length} reason=no-db-tail`,
2466
+ );
1867
2467
  return { blockedByImportCap: false, importedMessages: 0, hasOverlap: false };
1868
2468
  }
1869
2469
 
@@ -1885,6 +2485,9 @@ export class LcmContextEngine implements ContextEngine {
1885
2485
  }
1886
2486
  }
1887
2487
  if (dbOccurrences === historicalOccurrences) {
2488
+ this.deps.log.info(
2489
+ `[lcm] reconcileSessionTail: fast path for ${sessionContext} duration=${formatDurationMs(Date.now() - startedAt)} historicalMessages=${historicalMessages.length} importedMessages=0 overlap=true`,
2490
+ );
1888
2491
  return { blockedByImportCap: false, importedMessages: 0, hasOverlap: true };
1889
2492
  }
1890
2493
  }
@@ -1937,9 +2540,15 @@ export class LcmContextEngine implements ContextEngine {
1937
2540
  }
1938
2541
 
1939
2542
  if (anchorIndex < 0) {
2543
+ this.deps.log.info(
2544
+ `[lcm] reconcileSessionTail: no anchor for ${sessionContext} duration=${formatDurationMs(Date.now() - startedAt)} historicalMessages=${historicalMessages.length} importedMessages=0 overlap=false`,
2545
+ );
1940
2546
  return { blockedByImportCap: false, importedMessages: 0, hasOverlap: false };
1941
2547
  }
1942
2548
  if (anchorIndex >= historicalMessages.length - 1) {
2549
+ this.deps.log.info(
2550
+ `[lcm] reconcileSessionTail: anchor at tip for ${sessionContext} duration=${formatDurationMs(Date.now() - startedAt)} historicalMessages=${historicalMessages.length} importedMessages=0 overlap=true`,
2551
+ );
1943
2552
  return { blockedByImportCap: false, importedMessages: 0, hasOverlap: true };
1944
2553
  }
1945
2554
 
@@ -1947,7 +2556,12 @@ export class LcmContextEngine implements ContextEngine {
1947
2556
 
1948
2557
  const existingDbCount = await this.conversationStore.getMessageCount(conversationId);
1949
2558
  if (existingDbCount > 0 && missingTail.length > Math.max(existingDbCount * 0.2, 50)) {
1950
- console.error(`[lcm] reconcileSessionTail: import cap exceeded — would import ${missingTail.length} messages (existing: ${existingDbCount}). Aborting to prevent flood.`);
2559
+ this.deps.log.warn(
2560
+ `[lcm] reconcileSessionTail: import cap exceeded for ${sessionContext} — would import ${missingTail.length} messages (existing: ${existingDbCount}). Aborting to prevent flood.`,
2561
+ );
2562
+ this.deps.log.info(
2563
+ `[lcm] reconcileSessionTail: blocked for ${sessionContext} duration=${formatDurationMs(Date.now() - startedAt)} historicalMessages=${historicalMessages.length} missingTail=${missingTail.length} existingDbCount=${existingDbCount}`,
2564
+ );
1951
2565
  return { blockedByImportCap: true, importedMessages: 0, hasOverlap: true };
1952
2566
  }
1953
2567
 
@@ -1959,9 +2573,43 @@ export class LcmContextEngine implements ContextEngine {
1959
2573
  }
1960
2574
  }
1961
2575
 
2576
+ this.deps.log.info(
2577
+ `[lcm] reconcileSessionTail: slow path for ${sessionContext} duration=${formatDurationMs(Date.now() - startedAt)} historicalMessages=${historicalMessages.length} anchorIndex=${anchorIndex} missingTail=${missingTail.length} importedMessages=${importedMessages}`,
2578
+ );
1962
2579
  return { blockedByImportCap: false, importedMessages, hasOverlap: true };
1963
2580
  }
1964
2581
 
2582
+ /**
2583
+ * Persist bootstrap checkpoint metadata anchored to the current DB frontier.
2584
+ *
2585
+ * We intentionally checkpoint the session file's current EOF while hashing the
2586
+ * latest persisted DB message. This keeps append-only recovery aligned with the
2587
+ * canonical LCM frontier even when trailing transcript entries are pruned or
2588
+ * otherwise noncanonical.
2589
+ */
2590
+ private async refreshBootstrapState(params: {
2591
+ conversationId: number;
2592
+ sessionFile: string;
2593
+ fileStats?: { size: number; mtimeMs: number };
2594
+ }): Promise<void> {
2595
+ const latestDbMessage = await this.conversationStore.getLastMessage(params.conversationId);
2596
+ const fileStats = params.fileStats ?? statSync(params.sessionFile);
2597
+ await this.summaryStore.upsertConversationBootstrapState({
2598
+ conversationId: params.conversationId,
2599
+ sessionFilePath: params.sessionFile,
2600
+ lastSeenSize: fileStats.size,
2601
+ lastSeenMtimeMs: Math.trunc(fileStats.mtimeMs),
2602
+ lastProcessedOffset: fileStats.size,
2603
+ lastProcessedEntryHash: latestDbMessage
2604
+ ? createBootstrapEntryHash({
2605
+ role: latestDbMessage.role,
2606
+ content: latestDbMessage.content,
2607
+ tokenCount: latestDbMessage.tokenCount,
2608
+ })
2609
+ : null,
2610
+ });
2611
+ }
2612
+
1965
2613
  async bootstrap(params: {
1966
2614
  sessionId: string;
1967
2615
  sessionFile: string;
@@ -1982,6 +2630,11 @@ export class LcmContextEngine implements ContextEngine {
1982
2630
  };
1983
2631
  }
1984
2632
  this.ensureMigrated();
2633
+ const startedAt = Date.now();
2634
+ const sessionLabel = [
2635
+ `session=${params.sessionId}`,
2636
+ ...(params.sessionKey?.trim() ? [`sessionKey=${params.sessionKey.trim()}`] : []),
2637
+ ].join(" ");
1985
2638
  const sessionFileStats = statSync(params.sessionFile);
1986
2639
  const sessionFileSize = sessionFileStats.size;
1987
2640
  const sessionFileMtimeMs = Math.trunc(sessionFileStats.mtimeMs);
@@ -1992,19 +2645,14 @@ export class LcmContextEngine implements ContextEngine {
1992
2645
  this.conversationStore.withTransaction(async () => {
1993
2646
  const persistBootstrapState = async (
1994
2647
  conversationId: number,
1995
- historicalMessages: AgentMessage[],
1996
2648
  ): Promise<void> => {
1997
- const lastMessage =
1998
- historicalMessages.length > 0
1999
- ? toStoredMessage(historicalMessages[historicalMessages.length - 1]!)
2000
- : null;
2001
- await this.summaryStore.upsertConversationBootstrapState({
2649
+ await this.refreshBootstrapState({
2002
2650
  conversationId,
2003
- sessionFilePath: params.sessionFile,
2004
- lastSeenSize: sessionFileSize,
2005
- lastSeenMtimeMs: sessionFileMtimeMs,
2006
- lastProcessedOffset: sessionFileSize,
2007
- lastProcessedEntryHash: createBootstrapEntryHash(lastMessage),
2651
+ sessionFile: params.sessionFile,
2652
+ fileStats: {
2653
+ size: sessionFileSize,
2654
+ mtimeMs: sessionFileMtimeMs,
2655
+ },
2008
2656
  });
2009
2657
  };
2010
2658
 
@@ -2029,6 +2677,9 @@ export class LcmContextEngine implements ContextEngine {
2029
2677
  if (!conversation.bootstrappedAt) {
2030
2678
  await this.conversationStore.markConversationBootstrapped(conversationId);
2031
2679
  }
2680
+ this.deps.log.info(
2681
+ `[lcm] bootstrap: checkpoint hit conversation=${conversationId} ${sessionLabel} existingCount=${existingCount} duration=${formatDurationMs(Date.now() - startedAt)}`,
2682
+ );
2032
2683
  return {
2033
2684
  bootstrapped: false,
2034
2685
  importedMessages: 0,
@@ -2055,6 +2706,7 @@ export class LcmContextEngine implements ContextEngine {
2055
2706
  params.sessionFile,
2056
2707
  bootstrapState.lastProcessedOffset,
2057
2708
  true,
2709
+ (message) => createBootstrapEntryHash(toStoredMessage(message)) === latestDbHash,
2058
2710
  );
2059
2711
  const tailEntryMessage = readBootstrapMessageFromJsonLine(tailEntryRaw);
2060
2712
  const tailEntryHash = tailEntryMessage
@@ -2088,13 +2740,9 @@ export class LcmContextEngine implements ContextEngine {
2088
2740
  }
2089
2741
  }
2090
2742
 
2091
- const lastAppendedMessage =
2092
- appended.messages.length > 0
2093
- ? appended.messages[appended.messages.length - 1]!
2094
- : tailEntryMessage;
2095
- await persistBootstrapState(
2096
- conversationId,
2097
- lastAppendedMessage ? [lastAppendedMessage] : [],
2743
+ await persistBootstrapState(conversationId);
2744
+ this.deps.log.info(
2745
+ `[lcm] bootstrap: append-only conversation=${conversationId} ${sessionLabel} existingCount=${existingCount} appendedMessages=${appended.messages.length} importedMessages=${importedMessages} duration=${formatDurationMs(Date.now() - startedAt)}`,
2098
2746
  );
2099
2747
 
2100
2748
  if (importedMessages > 0) {
@@ -2115,6 +2763,9 @@ export class LcmContextEngine implements ContextEngine {
2115
2763
  }
2116
2764
 
2117
2765
  const historicalMessages = await readLeafPathMessages(params.sessionFile);
2766
+ this.deps.log.info(
2767
+ `[lcm] bootstrap: full transcript read conversation=${conversationId} ${sessionLabel} existingCount=${existingCount} historicalMessages=${historicalMessages.length} duration=${formatDurationMs(Date.now() - startedAt)}`,
2768
+ );
2118
2769
 
2119
2770
  // First-time import path: no LCM rows yet, so seed directly from the
2120
2771
  // active leaf context snapshot.
@@ -2126,7 +2777,7 @@ export class LcmContextEngine implements ContextEngine {
2126
2777
 
2127
2778
  if (bootstrapMessages.length === 0) {
2128
2779
  await this.conversationStore.markConversationBootstrapped(conversationId);
2129
- await persistBootstrapState(conversationId, historicalMessages);
2780
+ await persistBootstrapState(conversationId);
2130
2781
  return {
2131
2782
  bootstrapped: false,
2132
2783
  importedMessages: 0,
@@ -2152,18 +2803,22 @@ export class LcmContextEngine implements ContextEngine {
2152
2803
  inserted.map((record) => record.messageId),
2153
2804
  );
2154
2805
  await this.conversationStore.markConversationBootstrapped(conversationId);
2155
- await persistBootstrapState(conversationId, historicalMessages);
2156
2806
 
2157
2807
  // Prune HEARTBEAT_OK turns from the freshly imported data
2158
2808
  if (this.config.pruneHeartbeatOk) {
2159
2809
  const pruned = await this.pruneHeartbeatOkTurns(conversationId);
2160
2810
  if (pruned > 0) {
2161
- console.error(
2811
+ this.deps.log.info(
2162
2812
  `[lcm] bootstrap: pruned ${pruned} HEARTBEAT_OK messages from conversation ${conversationId}`,
2163
2813
  );
2164
2814
  }
2165
2815
  }
2166
2816
 
2817
+ await persistBootstrapState(conversationId);
2818
+ this.deps.log.info(
2819
+ `[lcm] bootstrap: initial import conversation=${conversationId} ${sessionLabel} importedMessages=${inserted.length} sourceMessages=${historicalMessages.length} duration=${formatDurationMs(Date.now() - startedAt)}`,
2820
+ );
2821
+
2167
2822
  return {
2168
2823
  bootstrapped: true,
2169
2824
  importedMessages: inserted.length,
@@ -2178,6 +2833,9 @@ export class LcmContextEngine implements ContextEngine {
2178
2833
  conversationId,
2179
2834
  historicalMessages,
2180
2835
  });
2836
+ this.deps.log.info(
2837
+ `[lcm] bootstrap: reconcile finished conversation=${conversationId} ${sessionLabel} importedMessages=${reconcile.importedMessages} overlap=${reconcile.hasOverlap} blockedByImportCap=${reconcile.blockedByImportCap} duration=${formatDurationMs(Date.now() - startedAt)}`,
2838
+ );
2181
2839
 
2182
2840
  if (reconcile.blockedByImportCap) {
2183
2841
  return {
@@ -2192,7 +2850,7 @@ export class LcmContextEngine implements ContextEngine {
2192
2850
  }
2193
2851
 
2194
2852
  if (reconcile.importedMessages > 0) {
2195
- await persistBootstrapState(conversationId, historicalMessages);
2853
+ await persistBootstrapState(conversationId);
2196
2854
  return {
2197
2855
  bootstrapped: true,
2198
2856
  importedMessages: reconcile.importedMessages,
@@ -2201,7 +2859,7 @@ export class LcmContextEngine implements ContextEngine {
2201
2859
  }
2202
2860
 
2203
2861
  if (reconcile.hasOverlap) {
2204
- await persistBootstrapState(conversationId, historicalMessages);
2862
+ await persistBootstrapState(conversationId);
2205
2863
  }
2206
2864
 
2207
2865
  if (conversation.bootstrappedAt) {
@@ -2220,6 +2878,7 @@ export class LcmContextEngine implements ContextEngine {
2220
2878
  : "conversation already has messages",
2221
2879
  };
2222
2880
  }),
2881
+ { operationName: "bootstrap", context: sessionLabel },
2223
2882
  );
2224
2883
 
2225
2884
  // Post-bootstrap pruning: clean HEARTBEAT_OK turns that were already
@@ -2233,19 +2892,25 @@ export class LcmContextEngine implements ContextEngine {
2233
2892
  if (conversation) {
2234
2893
  const pruned = await this.pruneHeartbeatOkTurns(conversation.conversationId);
2235
2894
  if (pruned > 0) {
2236
- console.error(
2895
+ await this.refreshBootstrapState({
2896
+ conversationId: conversation.conversationId,
2897
+ sessionFile: params.sessionFile,
2898
+ });
2899
+ this.deps.log.info(
2237
2900
  `[lcm] bootstrap: retroactively pruned ${pruned} HEARTBEAT_OK messages from conversation ${conversation.conversationId}`,
2238
2901
  );
2239
2902
  }
2240
2903
  }
2241
2904
  } catch (err) {
2242
- console.error(
2243
- `[lcm] bootstrap: heartbeat pruning failed:`,
2244
- err instanceof Error ? err.message : err,
2905
+ this.deps.log.warn(
2906
+ `[lcm] bootstrap: heartbeat pruning failed: ${describeLogError(err)}`,
2245
2907
  );
2246
2908
  }
2247
2909
  }
2248
2910
 
2911
+ this.deps.log.info(
2912
+ `[lcm] bootstrap: done ${sessionLabel} bootstrapped=${result.bootstrapped} importedMessages=${result.importedMessages} reason=${result.reason ?? "none"} duration=${formatDurationMs(Date.now() - startedAt)}`,
2913
+ );
2249
2914
  return result;
2250
2915
  }
2251
2916
 
@@ -2385,6 +3050,12 @@ export class LcmContextEngine implements ContextEngine {
2385
3050
  };
2386
3051
  }
2387
3052
 
3053
+ const rewriteTranscriptEntries = params.runtimeContext.rewriteTranscriptEntries;
3054
+ const startedAt = Date.now();
3055
+ const sessionLabel = [
3056
+ `session=${params.sessionId}`,
3057
+ ...(params.sessionKey?.trim() ? [`sessionKey=${params.sessionKey.trim()}`] : []),
3058
+ ].join(" ");
2388
3059
  return this.withSessionQueue(
2389
3060
  this.resolveSessionQueueKey(params.sessionId, params.sessionKey),
2390
3061
  async () => {
@@ -2406,6 +3077,9 @@ export class LcmContextEngine implements ContextEngine {
2406
3077
  { limit: TRANSCRIPT_GC_BATCH_SIZE },
2407
3078
  );
2408
3079
  if (candidates.length === 0) {
3080
+ this.deps.log.info(
3081
+ `[lcm] maintain: no transcript GC candidates conversation=${conversation.conversationId} ${sessionLabel} duration=${formatDurationMs(Date.now() - startedAt)}`,
3082
+ );
2409
3083
  return {
2410
3084
  changed: false,
2411
3085
  bytesFreed: 0,
@@ -2441,6 +3115,9 @@ export class LcmContextEngine implements ContextEngine {
2441
3115
  }
2442
3116
 
2443
3117
  if (replacements.length === 0) {
3118
+ this.deps.log.info(
3119
+ `[lcm] maintain: no matching transcript entries conversation=${conversation.conversationId} ${sessionLabel} candidates=${candidates.length} duration=${formatDurationMs(Date.now() - startedAt)}`,
3120
+ );
2444
3121
  return {
2445
3122
  changed: false,
2446
3123
  bytesFreed: 0,
@@ -2449,35 +3126,29 @@ export class LcmContextEngine implements ContextEngine {
2449
3126
  };
2450
3127
  }
2451
3128
 
2452
- const result = await params.runtimeContext.rewriteTranscriptEntries({
3129
+ const result = await rewriteTranscriptEntries({
2453
3130
  replacements,
2454
3131
  });
2455
3132
 
2456
3133
  if (result.changed) {
2457
3134
  try {
2458
- const fileStat = statSync(params.sessionFile);
2459
- const newSize = fileStat.size;
2460
- const newMtimeMs = Math.trunc(fileStat.mtimeMs);
2461
- const lastEntryRaw = readLastJsonlEntryBeforeOffset(params.sessionFile, newSize, true);
2462
- const lastEntryMsg = readBootstrapMessageFromJsonLine(lastEntryRaw);
2463
- const lastEntryHash = lastEntryMsg ? createBootstrapEntryHash(toStoredMessage(lastEntryMsg)) : null;
2464
- if (lastEntryHash) {
2465
- await this.summaryStore.upsertConversationBootstrapState({
2466
- conversationId: conversation.conversationId,
2467
- sessionFilePath: params.sessionFile,
2468
- lastSeenSize: newSize,
2469
- lastSeenMtimeMs: newMtimeMs,
2470
- lastProcessedOffset: newSize,
2471
- lastProcessedEntryHash: lastEntryHash,
2472
- });
2473
- }
3135
+ await this.refreshBootstrapState({
3136
+ conversationId: conversation.conversationId,
3137
+ sessionFile: params.sessionFile,
3138
+ });
2474
3139
  } catch (e) {
2475
- console.error("[lcm] Failed to update bootstrap checkpoint after maintain:", e);
3140
+ this.deps.log.warn(
3141
+ `[lcm] Failed to update bootstrap checkpoint after maintain: ${describeLogError(e)}`,
3142
+ );
2476
3143
  }
2477
3144
  }
2478
3145
 
3146
+ this.deps.log.info(
3147
+ `[lcm] maintain: done conversation=${conversation.conversationId} ${sessionLabel} candidates=${candidates.length} replacements=${replacements.length} changed=${result.changed} rewrittenEntries=${result.rewrittenEntries} bytesFreed=${result.bytesFreed} duration=${formatDurationMs(Date.now() - startedAt)}`,
3148
+ );
2479
3149
  return result;
2480
3150
  },
3151
+ { operationName: "maintain", context: sessionLabel },
2481
3152
  );
2482
3153
  }
2483
3154
  private async ingestSingle(params: {
@@ -2490,6 +3161,34 @@ export class LcmContextEngine implements ContextEngine {
2490
3161
  if (isHeartbeat) {
2491
3162
  return { ingested: false };
2492
3163
  }
3164
+
3165
+ // Skip assistant messages that failed with an error and have no useful content.
3166
+ // These occur when an API call returns a 500 or similar transient error.
3167
+ // Ingesting them pollutes the LCM database: on retry, the error messages
3168
+ // accumulate and get assembled into context, creating a positive feedback
3169
+ // loop where each retry sends an increasingly large (and malformed) payload
3170
+ // that continues to fail.
3171
+ if (message.role === "assistant") {
3172
+ const topLevel = message as unknown as Record<string, unknown>;
3173
+ const stopReason =
3174
+ typeof topLevel.stopReason === "string"
3175
+ ? topLevel.stopReason
3176
+ : typeof topLevel.stop_reason === "string"
3177
+ ? topLevel.stop_reason
3178
+ : undefined;
3179
+ if (stopReason === "error" || stopReason === "aborted") {
3180
+ const content = topLevel.content;
3181
+ const isEmpty =
3182
+ content === undefined ||
3183
+ content === null ||
3184
+ content === "" ||
3185
+ (Array.isArray(content) && content.length === 0);
3186
+ if (isEmpty) {
3187
+ return { ingested: false };
3188
+ }
3189
+ }
3190
+ }
3191
+
2493
3192
  const stored = toStoredMessage(message);
2494
3193
 
2495
3194
  // Get or create conversation for this session
@@ -2570,6 +3269,13 @@ export class LcmContextEngine implements ContextEngine {
2570
3269
  return this.withSessionQueue(
2571
3270
  this.resolveSessionQueueKey(params.sessionId, params.sessionKey),
2572
3271
  () => this.ingestSingle(params),
3272
+ {
3273
+ operationName: "ingest",
3274
+ context: [
3275
+ `session=${params.sessionId}`,
3276
+ ...(params.sessionKey?.trim() ? [`sessionKey=${params.sessionKey.trim()}`] : []),
3277
+ ].join(" "),
3278
+ },
2573
3279
  );
2574
3280
  }
2575
3281
 
@@ -2606,6 +3312,14 @@ export class LcmContextEngine implements ContextEngine {
2606
3312
  }
2607
3313
  return { ingestedCount };
2608
3314
  },
3315
+ {
3316
+ operationName: "ingestBatch",
3317
+ context: [
3318
+ `session=${params.sessionId}`,
3319
+ ...(params.sessionKey?.trim() ? [`sessionKey=${params.sessionKey.trim()}`] : []),
3320
+ `messages=${params.messages.length}`,
3321
+ ].join(" "),
3322
+ },
2609
3323
  );
2610
3324
  }
2611
3325
 
@@ -2630,6 +3344,11 @@ export class LcmContextEngine implements ContextEngine {
2630
3344
  return;
2631
3345
  }
2632
3346
  this.ensureMigrated();
3347
+ const startedAt = Date.now();
3348
+ const sessionLabel = [
3349
+ `session=${params.sessionId}`,
3350
+ ...(params.sessionKey?.trim() ? [`sessionKey=${params.sessionKey.trim()}`] : []),
3351
+ ].join(" ");
2633
3352
 
2634
3353
  // Dedup guard: prevent duplicate ingestion when gateway restart replays
2635
3354
  // full history. Run on newMessages BEFORE prepending autoCompactionSummary
@@ -2651,6 +3370,9 @@ export class LcmContextEngine implements ContextEngine {
2651
3370
 
2652
3371
  ingestBatch.push(...dedupedNewMessages);
2653
3372
  if (ingestBatch.length === 0) {
3373
+ this.deps.log.info(
3374
+ `[lcm] afterTurn: nothing to ingest ${sessionLabel} newMessages=${newMessages.length} duration=${formatDurationMs(Date.now() - startedAt)}`,
3375
+ );
2654
3376
  return;
2655
3377
  }
2656
3378
 
@@ -2663,9 +3385,8 @@ export class LcmContextEngine implements ContextEngine {
2663
3385
  });
2664
3386
  } catch (err) {
2665
3387
  // Never compact a stale or partially ingested frontier.
2666
- console.error(
2667
- `[lcm] afterTurn: ingest failed, skipping compaction:`,
2668
- err instanceof Error ? err.message : err,
3388
+ this.deps.log.error(
3389
+ `[lcm] afterTurn: ingest failed, skipping compaction: ${describeLogError(err)}`,
2669
3390
  );
2670
3391
  return;
2671
3392
  }
@@ -2679,16 +3400,30 @@ export class LcmContextEngine implements ContextEngine {
2679
3400
  if (conversation) {
2680
3401
  const pruned = await this.pruneHeartbeatOkTurns(conversation.conversationId);
2681
3402
  if (pruned > 0) {
2682
- console.error(
2683
- `[lcm] afterTurn: pruned ${pruned} heartbeat ack messages from conversation ${conversation.conversationId}`,
3403
+ const sessionContext = this.formatSessionLogContext({
3404
+ conversationId: conversation.conversationId,
3405
+ sessionId: params.sessionId,
3406
+ sessionKey: params.sessionKey,
3407
+ });
3408
+ try {
3409
+ await this.refreshBootstrapState({
3410
+ conversationId: conversation.conversationId,
3411
+ sessionFile: params.sessionFile,
3412
+ });
3413
+ } catch (err) {
3414
+ this.deps.log.warn(
3415
+ `[lcm] afterTurn: heartbeat pruning checkpoint refresh failed for ${sessionContext}: ${describeLogError(err)}`,
3416
+ );
3417
+ }
3418
+ this.deps.log.info(
3419
+ `[lcm] afterTurn: pruned ${pruned} heartbeat ack messages for ${sessionContext}`,
2684
3420
  );
2685
3421
  return;
2686
3422
  }
2687
3423
  }
2688
3424
  } catch (err) {
2689
- console.error(
2690
- `[lcm] afterTurn: heartbeat pruning failed:`,
2691
- err instanceof Error ? err.message : err,
3425
+ this.deps.log.warn(
3426
+ `[lcm] afterTurn: heartbeat pruning failed: ${describeLogError(err)}`,
2692
3427
  );
2693
3428
  }
2694
3429
  }
@@ -2702,16 +3437,44 @@ export class LcmContextEngine implements ContextEngine {
2702
3437
  });
2703
3438
  const tokenBudget = this.applyAssemblyBudgetCap(resolvedTokenBudget ?? DEFAULT_AFTER_TURN_TOKEN_BUDGET);
2704
3439
  if (resolvedTokenBudget === undefined) {
2705
- console.warn(
3440
+ this.deps.log.warn(
2706
3441
  `[lcm] afterTurn: tokenBudget not provided; using default ${DEFAULT_AFTER_TURN_TOKEN_BUDGET}`,
2707
3442
  );
2708
3443
  }
2709
3444
 
2710
3445
  const liveContextTokens = estimateSessionTokenCountForAfterTurn(params.messages);
3446
+ const conversation = await this.conversationStore.getConversationForSession({
3447
+ sessionId: params.sessionId,
3448
+ sessionKey: params.sessionKey,
3449
+ });
3450
+ if (!conversation) {
3451
+ this.deps.log.info(
3452
+ `[lcm] afterTurn: conversation lookup missed ${sessionLabel} ingestBatch=${ingestBatch.length} duration=${formatDurationMs(Date.now() - startedAt)}`,
3453
+ );
3454
+ return;
3455
+ }
3456
+
3457
+ try {
3458
+ const rawLeafTrigger = await this.compaction.evaluateLeafTrigger(conversation.conversationId);
3459
+ await this.updateCompactionTelemetry({
3460
+ conversationId: conversation.conversationId,
3461
+ runtimeContext: asRecord(params.runtimeContext),
3462
+ tokenBudget,
3463
+ rawTokensOutsideTail: rawLeafTrigger.rawTokensOutsideTail,
3464
+ });
3465
+ } catch (err) {
3466
+ this.deps.log.warn(
3467
+ `[lcm] afterTurn: compaction telemetry update failed: ${describeLogError(err)}`,
3468
+ );
3469
+ }
2711
3470
 
2712
3471
  try {
2713
- const leafTrigger = await this.evaluateLeafTrigger(params.sessionId, params.sessionKey);
2714
- if (leafTrigger.shouldCompact) {
3472
+ const leafDecision = await this.evaluateIncrementalCompaction({
3473
+ conversationId: conversation.conversationId,
3474
+ tokenBudget,
3475
+ currentTokenCount: liveContextTokens,
3476
+ });
3477
+ if (leafDecision.shouldCompact) {
2715
3478
  this.compactLeafAsync({
2716
3479
  sessionId: params.sessionId,
2717
3480
  sessionKey: params.sessionKey,
@@ -2719,6 +3482,11 @@ export class LcmContextEngine implements ContextEngine {
2719
3482
  tokenBudget,
2720
3483
  currentTokenCount: liveContextTokens,
2721
3484
  legacyParams,
3485
+ maxPasses: leafDecision.maxPasses,
3486
+ leafChunkTokens: leafDecision.leafChunkTokens,
3487
+ fallbackLeafChunkTokens: leafDecision.fallbackLeafChunkTokens,
3488
+ activityBand: leafDecision.activityBand,
3489
+ allowCondensedPasses: leafDecision.allowCondensedPasses,
2722
3490
  }).catch(() => {
2723
3491
  // Leaf compaction is best-effort and should not fail the caller.
2724
3492
  });
@@ -2740,6 +3508,10 @@ export class LcmContextEngine implements ContextEngine {
2740
3508
  } catch {
2741
3509
  // Proactive compaction is best-effort in the post-turn lifecycle.
2742
3510
  }
3511
+
3512
+ this.deps.log.info(
3513
+ `[lcm] afterTurn: done conversation=${conversation.conversationId} ${sessionLabel} newMessages=${newMessages.length} dedupedMessages=${dedupedNewMessages.length} ingestedMessages=${ingestBatch.length} duration=${formatDurationMs(Date.now() - startedAt)}`,
3514
+ );
2743
3515
  }
2744
3516
 
2745
3517
  async assemble(params: {
@@ -2758,12 +3530,20 @@ export class LcmContextEngine implements ContextEngine {
2758
3530
  }
2759
3531
  try {
2760
3532
  this.ensureMigrated();
3533
+ const startedAt = Date.now();
3534
+ const sessionLabel = [
3535
+ `session=${params.sessionId}`,
3536
+ ...(params.sessionKey?.trim() ? [`sessionKey=${params.sessionKey.trim()}`] : []),
3537
+ ].join(" ");
2761
3538
 
2762
3539
  const conversation = await this.conversationStore.getConversationForSession({
2763
3540
  sessionId: params.sessionId,
2764
3541
  sessionKey: params.sessionKey,
2765
3542
  });
2766
3543
  if (!conversation) {
3544
+ this.deps.log.info(
3545
+ `[lcm] assemble: conversation lookup missed ${sessionLabel} duration=${formatDurationMs(Date.now() - startedAt)}`,
3546
+ );
2767
3547
  return {
2768
3548
  messages: params.messages,
2769
3549
  estimatedTokens: 0,
@@ -2772,6 +3552,9 @@ export class LcmContextEngine implements ContextEngine {
2772
3552
 
2773
3553
  const contextItems = await this.summaryStore.getContextItems(conversation.conversationId);
2774
3554
  if (contextItems.length === 0) {
3555
+ this.deps.log.info(
3556
+ `[lcm] assemble: no context items conversation=${conversation.conversationId} ${sessionLabel} duration=${formatDurationMs(Date.now() - startedAt)}`,
3557
+ );
2775
3558
  return {
2776
3559
  messages: params.messages,
2777
3560
  estimatedTokens: 0,
@@ -2783,6 +3566,9 @@ export class LcmContextEngine implements ContextEngine {
2783
3566
  // the live path to avoid dropping prompt context.
2784
3567
  const hasSummaryItems = contextItems.some((item) => item.itemType === "summary");
2785
3568
  if (!hasSummaryItems && contextItems.length < params.messages.length) {
3569
+ this.deps.log.info(
3570
+ `[lcm] assemble: falling back to live context conversation=${conversation.conversationId} ${sessionLabel} contextItems=${contextItems.length} liveMessages=${params.messages.length} duration=${formatDurationMs(Date.now() - startedAt)}`,
3571
+ );
2786
3572
  return {
2787
3573
  messages: params.messages,
2788
3574
  estimatedTokens: 0,
@@ -2807,12 +3593,19 @@ export class LcmContextEngine implements ContextEngine {
2807
3593
  // If assembly produced no messages for a non-empty live session,
2808
3594
  // fail safe to the live context.
2809
3595
  if (assembled.messages.length === 0 && params.messages.length > 0) {
3596
+ this.deps.log.info(
3597
+ `[lcm] assemble: empty assembled output, using live context conversation=${conversation.conversationId} ${sessionLabel} contextItems=${contextItems.length} tokenBudget=${tokenBudget} duration=${formatDurationMs(Date.now() - startedAt)}`,
3598
+ );
2810
3599
  return {
2811
3600
  messages: params.messages,
2812
3601
  estimatedTokens: 0,
2813
3602
  };
2814
3603
  }
2815
3604
 
3605
+ this.deps.log.info(
3606
+ `[lcm] assemble: done conversation=${conversation.conversationId} ${sessionLabel} contextItems=${contextItems.length} hasSummaryItems=${hasSummaryItems} inputMessages=${params.messages.length} outputMessages=${assembled.messages.length} tokenBudget=${tokenBudget} estimatedTokens=${assembled.estimatedTokens} duration=${formatDurationMs(Date.now() - startedAt)}`,
3607
+ );
3608
+
2816
3609
  const result: AssembleResultWithSystemPrompt = {
2817
3610
  messages: assembled.messages,
2818
3611
  estimatedTokens: assembled.estimatedTokens,
@@ -2821,7 +3614,10 @@ export class LcmContextEngine implements ContextEngine {
2821
3614
  : {}),
2822
3615
  };
2823
3616
  return result;
2824
- } catch {
3617
+ } catch (err) {
3618
+ this.deps.log.info(
3619
+ `[lcm] assemble: failed for session=${params.sessionId}${params.sessionKey?.trim() ? ` sessionKey=${params.sessionKey.trim()}` : ""} error=${describeLogError(err)}`,
3620
+ );
2825
3621
  return {
2826
3622
  messages: params.messages,
2827
3623
  estimatedTokens: 0,
@@ -2856,7 +3652,7 @@ export class LcmContextEngine implements ContextEngine {
2856
3652
  return this.compaction.evaluateLeafTrigger(conversation.conversationId);
2857
3653
  }
2858
3654
 
2859
- /** Run one incremental leaf compaction pass in the per-session queue. */
3655
+ /** Run one or more incremental leaf compaction passes in the per-session queue. */
2860
3656
  async compactLeafAsync(params: {
2861
3657
  sessionId: string;
2862
3658
  sessionKey?: string;
@@ -2870,6 +3666,11 @@ export class LcmContextEngine implements ContextEngine {
2870
3666
  legacyParams?: Record<string, unknown>;
2871
3667
  force?: boolean;
2872
3668
  previousSummaryContent?: string;
3669
+ maxPasses?: number;
3670
+ leafChunkTokens?: number;
3671
+ fallbackLeafChunkTokens?: number[];
3672
+ activityBand?: ActivityBand;
3673
+ allowCondensedPasses?: boolean;
2873
3674
  }): Promise<CompactResult> {
2874
3675
  if (this.isStatelessSession(params.sessionKey)) {
2875
3676
  return {
@@ -2933,38 +3734,114 @@ export class LcmContextEngine implements ContextEngine {
2933
3734
  };
2934
3735
  }
2935
3736
 
2936
- const leafResult = await this.compaction.compactLeaf({
2937
- conversationId: conversation.conversationId,
2938
- tokenBudget,
2939
- summarize,
2940
- force: params.force,
2941
- previousSummaryContent: params.previousSummaryContent,
2942
- summaryModel,
2943
- });
3737
+ const storedTokensBefore = await this.summaryStore.getContextTokenCount(
3738
+ conversation.conversationId,
3739
+ );
3740
+ const maxPasses =
3741
+ typeof params.maxPasses === "number" &&
3742
+ Number.isFinite(params.maxPasses) &&
3743
+ params.maxPasses > 0
3744
+ ? Math.floor(params.maxPasses)
3745
+ : 1;
3746
+ const fallbackLeafChunkTokens = Array.isArray(params.fallbackLeafChunkTokens)
3747
+ ? [...new Set(params.fallbackLeafChunkTokens
3748
+ .filter((value): value is number => typeof value === "number" && Number.isFinite(value) && value > 0)
3749
+ .map((value) => Math.floor(value)))]
3750
+ .sort((a, b) => b - a)
3751
+ : [];
3752
+ let activeLeafChunkTokens =
3753
+ typeof params.leafChunkTokens === "number" &&
3754
+ Number.isFinite(params.leafChunkTokens) &&
3755
+ params.leafChunkTokens > 0
3756
+ ? Math.floor(params.leafChunkTokens)
3757
+ : fallbackLeafChunkTokens[0];
3758
+ this.deps.log.info(
3759
+ `[lcm] compactLeafAsync start: conversation=${conversation.conversationId} session=${params.sessionId} leafChunkTokens=${activeLeafChunkTokens ?? "null"} fallbackLeafChunkTokens=${fallbackLeafChunkTokens.join(",")} maxPasses=${maxPasses} activityBand=${params.activityBand ?? "unknown"} allowCondensedPasses=${params.allowCondensedPasses !== false}`,
3760
+ );
3761
+
3762
+ let rounds = 0;
3763
+ let finalTokens = observedTokens ?? storedTokensBefore;
3764
+ let authFailure = false;
3765
+
3766
+ for (let pass = 0; pass < maxPasses; pass += 1) {
3767
+ let leafResult: Awaited<ReturnType<typeof this.compaction.compactLeaf>> | undefined;
3768
+ while (true) {
3769
+ try {
3770
+ leafResult = await this.compaction.compactLeaf({
3771
+ conversationId: conversation.conversationId,
3772
+ tokenBudget,
3773
+ summarize,
3774
+ ...(activeLeafChunkTokens !== undefined ? { leafChunkTokens: activeLeafChunkTokens } : {}),
3775
+ force: params.force,
3776
+ previousSummaryContent: pass === 0 ? params.previousSummaryContent : undefined,
3777
+ summaryModel,
3778
+ allowCondensedPasses: params.allowCondensedPasses,
3779
+ });
3780
+ break;
3781
+ } catch (err) {
3782
+ const nextLeafChunkTokens = fallbackLeafChunkTokens.find(
3783
+ (value) => activeLeafChunkTokens !== undefined && value < activeLeafChunkTokens,
3784
+ );
3785
+ if (!this.isRecoverableLeafChunkOverflowError(err) || nextLeafChunkTokens === undefined) {
3786
+ throw err;
3787
+ }
3788
+ this.deps.log.warn(
3789
+ `[lcm] compactLeafAsync: retrying with smaller leafChunkTokens=${nextLeafChunkTokens} after provider token-limit error: ${err instanceof Error ? err.message : String(err)}`,
3790
+ );
3791
+ activeLeafChunkTokens = nextLeafChunkTokens;
3792
+ }
3793
+ }
3794
+ if (!leafResult) {
3795
+ break;
3796
+ }
3797
+ finalTokens = leafResult.tokensAfter;
3798
+
3799
+ if (leafResult.authFailure) {
3800
+ authFailure = true;
3801
+ break;
3802
+ }
3803
+ if (!leafResult.actionTaken) {
3804
+ break;
3805
+ }
3806
+ rounds += 1;
3807
+ if (leafResult.tokensAfter >= leafResult.tokensBefore) {
3808
+ break;
3809
+ }
3810
+ }
2944
3811
 
2945
- if (leafResult.authFailure && breakerKey) {
3812
+ if (authFailure && breakerKey) {
2946
3813
  this.recordCompactionAuthFailure(breakerKey);
2947
- } else if (leafResult.actionTaken && breakerKey) {
3814
+ } else if (rounds > 0 && breakerKey) {
2948
3815
  this.recordCompactionSuccess(breakerKey);
2949
3816
  }
3817
+ if (rounds > 0) {
3818
+ await this.markLeafCompactionTelemetrySuccess({
3819
+ conversationId: conversation.conversationId,
3820
+ activityBand: params.activityBand,
3821
+ });
3822
+ }
2950
3823
 
2951
- const tokensBefore = observedTokens ?? leafResult.tokensBefore;
3824
+ const tokensBefore = observedTokens ?? storedTokensBefore;
3825
+ this.deps.log.debug(
3826
+ `[lcm] compactLeafAsync result: conversation=${conversation.conversationId} session=${params.sessionId} rounds=${rounds} compacted=${rounds > 0} authFailure=${authFailure} finalLeafChunkTokens=${activeLeafChunkTokens ?? "null"} finalTokens=${finalTokens}`,
3827
+ );
2952
3828
 
2953
3829
  return {
2954
3830
  ok: true,
2955
- compacted: leafResult.actionTaken,
2956
- reason: leafResult.authFailure
3831
+ compacted: rounds > 0,
3832
+ reason: authFailure
2957
3833
  ? "provider auth failure"
2958
- : leafResult.actionTaken
3834
+ : rounds > 0
2959
3835
  ? "compacted"
2960
3836
  : "below threshold",
2961
3837
  result: {
2962
3838
  tokensBefore,
2963
- tokensAfter: leafResult.tokensAfter,
3839
+ tokensAfter: finalTokens,
2964
3840
  details: {
2965
- rounds: leafResult.actionTaken ? 1 : 0,
3841
+ rounds,
2966
3842
  targetTokens: tokenBudget,
2967
3843
  mode: "leaf",
3844
+ maxPasses,
2968
3845
  },
2969
3846
  },
2970
3847
  };
@@ -3093,7 +3970,7 @@ export class LcmContextEngine implements ContextEngine {
3093
3970
  // overflow counts can drive recovery even when persisted context is already small.
3094
3971
  const useSweep = manualCompactionRequested || params.compactionTarget === "threshold";
3095
3972
  if (useSweep) {
3096
- const sweepResult = await this.compaction.compactFullSweep({
3973
+ const sweepResult = await this.compaction.compact({
3097
3974
  conversationId,
3098
3975
  tokenBudget,
3099
3976
  summarize,
@@ -3107,6 +3984,9 @@ export class LcmContextEngine implements ContextEngine {
3107
3984
  } else if (sweepResult.actionTaken && breakerKey) {
3108
3985
  this.recordCompactionSuccess(breakerKey);
3109
3986
  }
3987
+ if (sweepResult.actionTaken) {
3988
+ await this.markLeafCompactionTelemetrySuccess({ conversationId });
3989
+ }
3110
3990
 
3111
3991
  return {
3112
3992
  ok: !sweepResult.authFailure && (sweepResult.actionTaken || !liveContextStillExceedsTarget),
@@ -3140,11 +4020,21 @@ export class LcmContextEngine implements ContextEngine {
3140
4020
  ? decision.threshold
3141
4021
  : tokenBudget;
3142
4022
 
4023
+ // When forced (overflow recovery) and the caller did not supply an
4024
+ // observed token count, assume we are at least at the token budget so
4025
+ // compactUntilUnder does not bail with "already under target" while the
4026
+ // live context is actually overflowing.
4027
+ const effectiveCurrentTokens =
4028
+ observedTokens !== undefined
4029
+ ? observedTokens
4030
+ : forceCompaction
4031
+ ? tokenBudget
4032
+ : undefined;
3143
4033
  const compactResult = await this.compaction.compactUntilUnder({
3144
4034
  conversationId,
3145
4035
  tokenBudget,
3146
4036
  targetTokens: convergenceTargetTokens,
3147
- ...(observedTokens !== undefined ? { currentTokens: observedTokens } : {}),
4037
+ ...(effectiveCurrentTokens !== undefined ? { currentTokens: effectiveCurrentTokens } : {}),
3148
4038
  summarize,
3149
4039
  summaryModel,
3150
4040
  });
@@ -3156,6 +4046,9 @@ export class LcmContextEngine implements ContextEngine {
3156
4046
  }
3157
4047
 
3158
4048
  const didCompact = compactResult.rounds > 0;
4049
+ if (didCompact) {
4050
+ await this.markLeafCompactionTelemetrySuccess({ conversationId });
4051
+ }
3159
4052
 
3160
4053
  return {
3161
4054
  ok: compactResult.success,
@@ -3344,7 +4237,7 @@ export class LcmContextEngine implements ContextEngine {
3344
4237
  const nextSessionKey = params.nextSessionKey?.trim() || params.sessionKey?.trim() || current?.sessionKey;
3345
4238
  const freshConversation = await this.conversationStore.createConversation({
3346
4239
  sessionId: nextSessionId,
3347
- sessionKey: nextSessionKey,
4240
+ ...(nextSessionKey ? { sessionKey: nextSessionKey } : {}),
3348
4241
  });
3349
4242
  this.deps.log.info(
3350
4243
  `[lcm] ${params.reason} lifecycle archived prior conversation and created ${freshConversation.conversationId}`,
@@ -3455,6 +4348,10 @@ export class LcmContextEngine implements ContextEngine {
3455
4348
  return this.summaryStore;
3456
4349
  }
3457
4350
 
4351
+ getCompactionTelemetryStore(): CompactionTelemetryStore {
4352
+ return this.compactionTelemetryStore;
4353
+ }
4354
+
3458
4355
  // ── Heartbeat pruning ──────────────────────────────────────────────────
3459
4356
 
3460
4357
  /**