@martian-engineering/lossless-claw 0.6.3 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,
@@ -63,6 +70,30 @@ type CircuitBreakerState = {
63
70
  failures: number;
64
71
  openSince: number | null;
65
72
  };
73
+ type PromptCacheSnapshot = {
74
+ lastObservedCacheRead?: number;
75
+ lastObservedCacheWrite?: number;
76
+ cacheState: CacheState;
77
+ retention?: string;
78
+ sawExplicitBreak: boolean;
79
+ };
80
+ type IncrementalCompactionDecision = {
81
+ shouldCompact: boolean;
82
+ cacheState: CacheState;
83
+ maxPasses: number;
84
+ rawTokensOutsideTail: number;
85
+ threshold: number;
86
+ leafChunkTokens: number;
87
+ fallbackLeafChunkTokens: number[];
88
+ activityBand: ActivityBand;
89
+ allowCondensedPasses: boolean;
90
+ };
91
+ type DynamicLeafChunkBounds = {
92
+ floor: number;
93
+ medium: number;
94
+ high: number;
95
+ max: number;
96
+ };
66
97
  type TranscriptRewriteReplacement = {
67
98
  entryId: string;
68
99
  message: AgentMessage;
@@ -83,6 +114,13 @@ type ContextEngineMaintenanceRuntimeContext = Record<string, unknown> & {
83
114
  };
84
115
 
85
116
  const TRANSCRIPT_GC_BATCH_SIZE = 12;
117
+ const HOT_CACHE_HYSTERESIS_TURNS = 2;
118
+ const DYNAMIC_LEAF_CHUNK_MEDIUM_MULTIPLIER = 1.5;
119
+ const DYNAMIC_LEAF_CHUNK_HIGH_MULTIPLIER = 2;
120
+ const DYNAMIC_ACTIVITY_MEDIUM_UPSHIFT_FACTOR = 0.5;
121
+ const DYNAMIC_ACTIVITY_MEDIUM_DOWNSHIFT_FACTOR = 0.35;
122
+ const DYNAMIC_ACTIVITY_HIGH_UPSHIFT_FACTOR = 1.0;
123
+ const DYNAMIC_ACTIVITY_HIGH_DOWNSHIFT_FACTOR = 0.75;
86
124
 
87
125
  // ── Helpers ──────────────────────────────────────────────────────────────────
88
126
 
@@ -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;
@@ -1224,6 +1278,7 @@ export class LcmContextEngine implements ContextEngine {
1224
1278
  fts5Available: this.fts5Available,
1225
1279
  });
1226
1280
  this.summaryStore = new SummaryStore(this.db, { fts5Available: this.fts5Available });
1281
+ this.compactionTelemetryStore = new CompactionTelemetryStore(this.db);
1227
1282
 
1228
1283
  if (!this.fts5Available) {
1229
1284
  this.deps.log.warn(
@@ -1269,6 +1324,7 @@ export class LcmContextEngine implements ContextEngine {
1269
1324
  this.conversationStore,
1270
1325
  this.summaryStore,
1271
1326
  compactionConfig,
1327
+ this.deps.log,
1272
1328
  );
1273
1329
 
1274
1330
  this.retrieval = new RetrievalEngine(this.conversationStore, this.summaryStore);
@@ -1335,10 +1391,17 @@ export class LcmContextEngine implements ContextEngine {
1335
1391
  private recordCompactionAuthFailure(key: string): void {
1336
1392
  const state = this.getCircuitBreakerState(key);
1337
1393
  state.failures++;
1394
+ const halfThreshold = Math.ceil(this.config.circuitBreakerThreshold / 2);
1395
+ if (state.failures === halfThreshold && state.failures < this.config.circuitBreakerThreshold) {
1396
+ this.deps.log.warn(
1397
+ `[lcm] WARNING: compaction degraded — ${state.failures}/${this.config.circuitBreakerThreshold} consecutive auth failures for ${key}`,
1398
+ );
1399
+ }
1338
1400
  if (state.failures >= this.config.circuitBreakerThreshold) {
1339
1401
  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.`,
1402
+ const cooldownMin = Math.round(this.config.circuitBreakerCooldownMs / 60000);
1403
+ this.deps.log.warn(
1404
+ `[lcm] CIRCUIT BREAKER OPEN: compaction disabled for ${key}. Auto-retry in ${cooldownMin}m. LCM is operating in degraded mode.`,
1342
1405
  );
1343
1406
  }
1344
1407
  }
@@ -1349,7 +1412,7 @@ export class LcmContextEngine implements ContextEngine {
1349
1412
  return;
1350
1413
  }
1351
1414
  if (state.failures > 0 || state.openSince !== null) {
1352
- console.error(
1415
+ this.deps.log.info(
1353
1416
  `[lcm] compaction circuit breaker CLOSED: successful compaction for ${key} after ${state.failures} prior failures.`,
1354
1417
  );
1355
1418
  }
@@ -1446,6 +1509,486 @@ export class LcmContextEngine implements ContextEngine {
1446
1509
  return cap != null && cap > 0 ? Math.min(budget, cap) : budget;
1447
1510
  }
1448
1511
 
1512
+ /** Normalize token counters that may legitimately be zero. */
1513
+ private normalizeOptionalCount(value: unknown): number | undefined {
1514
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
1515
+ return undefined;
1516
+ }
1517
+ return Math.floor(value);
1518
+ }
1519
+
1520
+ /** Treat a recent cache hit as still-hot for a couple of turns unless telemetry observed a later break. */
1521
+ private shouldApplyHotCacheHysteresis(
1522
+ telemetry: ConversationCompactionTelemetryRecord | null,
1523
+ ): boolean {
1524
+ if (!telemetry?.lastObservedCacheHitAt) {
1525
+ return false;
1526
+ }
1527
+ if (
1528
+ telemetry.lastObservedCacheBreakAt
1529
+ && telemetry.lastObservedCacheBreakAt >= telemetry.lastObservedCacheHitAt
1530
+ ) {
1531
+ return false;
1532
+ }
1533
+ return telemetry.turnsSinceLeafCompaction <= HOT_CACHE_HYSTERESIS_TURNS;
1534
+ }
1535
+
1536
+ /** Resolve the effective cache state the incremental compaction policy should react to. */
1537
+ private resolveCacheAwareState(
1538
+ telemetry: ConversationCompactionTelemetryRecord | null,
1539
+ ): CacheState {
1540
+ if (!telemetry) {
1541
+ return "unknown";
1542
+ }
1543
+ if (telemetry.cacheState === "hot") {
1544
+ return "hot";
1545
+ }
1546
+ if (this.shouldApplyHotCacheHysteresis(telemetry)) {
1547
+ return "hot";
1548
+ }
1549
+ return telemetry.cacheState;
1550
+ }
1551
+
1552
+ /** Decide whether a hot cache still has enough real token-budget headroom to skip incremental maintenance. */
1553
+ private isComfortablyUnderTokenBudget(params: {
1554
+ currentTokenCount?: number;
1555
+ tokenBudget: number;
1556
+ }): boolean {
1557
+ if (
1558
+ typeof params.currentTokenCount !== "number"
1559
+ || !Number.isFinite(params.currentTokenCount)
1560
+ || params.currentTokenCount < 0
1561
+ ) {
1562
+ return false;
1563
+ }
1564
+ const budget = Math.max(1, Math.floor(params.tokenBudget));
1565
+ const safeBudget = Math.floor(
1566
+ budget * (1 - this.config.cacheAwareCompaction.hotCacheBudgetHeadroomRatio),
1567
+ );
1568
+ return params.currentTokenCount <= safeBudget;
1569
+ }
1570
+
1571
+ /** Resolve bounded dynamic leaf chunk sizes from config and the active token budget. */
1572
+ private resolveDynamicLeafChunkBounds(tokenBudget?: number): DynamicLeafChunkBounds {
1573
+ const floor = Math.max(1, Math.floor(this.config.leafChunkTokens));
1574
+ const configuredMax = this.config.dynamicLeafChunkTokens.enabled
1575
+ ? Math.max(floor, Math.floor(this.config.dynamicLeafChunkTokens.max))
1576
+ : floor;
1577
+ const budgetCap =
1578
+ typeof tokenBudget === "number" &&
1579
+ Number.isFinite(tokenBudget) &&
1580
+ tokenBudget > 0
1581
+ ? Math.max(floor, Math.floor(tokenBudget * this.config.contextThreshold))
1582
+ : configuredMax;
1583
+ const max = Math.max(floor, Math.min(configuredMax, budgetCap));
1584
+ const medium = Math.max(
1585
+ floor,
1586
+ Math.min(max, Math.floor(floor * DYNAMIC_LEAF_CHUNK_MEDIUM_MULTIPLIER)),
1587
+ );
1588
+ const high = Math.max(
1589
+ floor,
1590
+ Math.min(max, Math.floor(floor * DYNAMIC_LEAF_CHUNK_HIGH_MULTIPLIER)),
1591
+ );
1592
+ return { floor, medium, high, max };
1593
+ }
1594
+
1595
+ /** Classify the current refill rate into a simple step band with downshift hysteresis. */
1596
+ private classifyDynamicLeafActivityBand(params: {
1597
+ lastActivityBand?: ActivityBand;
1598
+ tokensAccumulatedSinceLeafCompaction: number;
1599
+ turnsSinceLeafCompaction: number;
1600
+ floor: number;
1601
+ }): ActivityBand {
1602
+ const turns = Math.max(1, params.turnsSinceLeafCompaction);
1603
+ const tokensPerTurn = params.tokensAccumulatedSinceLeafCompaction / turns;
1604
+ const mediumUpshift = params.floor * DYNAMIC_ACTIVITY_MEDIUM_UPSHIFT_FACTOR;
1605
+ const mediumDownshift = params.floor * DYNAMIC_ACTIVITY_MEDIUM_DOWNSHIFT_FACTOR;
1606
+ const highUpshift = params.floor * DYNAMIC_ACTIVITY_HIGH_UPSHIFT_FACTOR;
1607
+ const highDownshift = params.floor * DYNAMIC_ACTIVITY_HIGH_DOWNSHIFT_FACTOR;
1608
+ const lastBand = params.lastActivityBand ?? "low";
1609
+
1610
+ if (lastBand === "high") {
1611
+ if (tokensPerTurn >= highDownshift) {
1612
+ return "high";
1613
+ }
1614
+ return tokensPerTurn >= mediumDownshift ? "medium" : "low";
1615
+ }
1616
+ if (lastBand === "medium") {
1617
+ if (tokensPerTurn >= highUpshift) {
1618
+ return "high";
1619
+ }
1620
+ if (tokensPerTurn < mediumDownshift) {
1621
+ return "low";
1622
+ }
1623
+ return "medium";
1624
+ }
1625
+ if (tokensPerTurn >= highUpshift) {
1626
+ return "high";
1627
+ }
1628
+ if (tokensPerTurn >= mediumUpshift) {
1629
+ return "medium";
1630
+ }
1631
+ return "low";
1632
+ }
1633
+
1634
+ /** Map an activity band to the corresponding working leaf chunk size. */
1635
+ private resolveLeafChunkTokensForBand(
1636
+ band: ActivityBand,
1637
+ bounds: DynamicLeafChunkBounds,
1638
+ ): number {
1639
+ switch (band) {
1640
+ case "high":
1641
+ return bounds.high;
1642
+ case "medium":
1643
+ return bounds.medium;
1644
+ default:
1645
+ return bounds.floor;
1646
+ }
1647
+ }
1648
+
1649
+ /** Build descending fallback chunk sizes used when a provider rejects a larger chunk. */
1650
+ private buildLeafChunkFallbacks(params: {
1651
+ preferred: number;
1652
+ bounds: DynamicLeafChunkBounds;
1653
+ }): number[] {
1654
+ const ordered = [params.preferred, params.bounds.max, params.bounds.high, params.bounds.medium, params.bounds.floor];
1655
+ const seen = new Set<number>();
1656
+ const fallbacks: number[] = [];
1657
+ for (const value of ordered) {
1658
+ const normalized = Math.max(params.bounds.floor, Math.floor(value));
1659
+ if (seen.has(normalized)) {
1660
+ continue;
1661
+ }
1662
+ seen.add(normalized);
1663
+ fallbacks.push(normalized);
1664
+ }
1665
+ return fallbacks.sort((a, b) => b - a);
1666
+ }
1667
+
1668
+ /** Detect provider/model token-limit failures that should trigger a lower chunk retry. */
1669
+ private isRecoverableLeafChunkOverflowError(error: unknown): boolean {
1670
+ const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
1671
+ if (!message) {
1672
+ return false;
1673
+ }
1674
+ return [
1675
+ "context length",
1676
+ "context window",
1677
+ "maximum context",
1678
+ "max context",
1679
+ "too many tokens",
1680
+ "too many input tokens",
1681
+ "input tokens",
1682
+ "token limit",
1683
+ "context limit",
1684
+ "input is too large",
1685
+ "input too large",
1686
+ "prompt is too long",
1687
+ "request too large",
1688
+ "exceeds the model",
1689
+ "exceeds context",
1690
+ ].some((fragment) => message.includes(fragment));
1691
+ }
1692
+
1693
+ /** Extract the current prompt-cache snapshot from runtime context, if present. */
1694
+ private readPromptCacheSnapshot(runtimeContext?: Record<string, unknown>): PromptCacheSnapshot | null {
1695
+ const promptCache = asRecord(runtimeContext?.promptCache);
1696
+ if (!promptCache) {
1697
+ return null;
1698
+ }
1699
+
1700
+ const lastCallUsage = asRecord(promptCache.lastCallUsage);
1701
+ const observation = asRecord(promptCache.observation);
1702
+ const cacheRead = this.normalizeOptionalCount(lastCallUsage?.cacheRead);
1703
+ const cacheWrite = this.normalizeOptionalCount(lastCallUsage?.cacheWrite);
1704
+ const sawExplicitBreak = safeBoolean(observation?.broke) === true;
1705
+ const retention = safeString(promptCache.retention)?.trim();
1706
+ const hasUsageSignal = cacheRead !== undefined || cacheWrite !== undefined;
1707
+ const hasObservationSignal =
1708
+ typeof observation?.cacheRead === "number"
1709
+ || typeof observation?.previousCacheRead === "number"
1710
+ || sawExplicitBreak;
1711
+
1712
+ let cacheState: CacheState = "unknown";
1713
+ if (sawExplicitBreak) {
1714
+ cacheState = "cold";
1715
+ } else if (typeof cacheRead === "number" && cacheRead > 0) {
1716
+ cacheState = "hot";
1717
+ } else if (hasUsageSignal || hasObservationSignal) {
1718
+ cacheState = "cold";
1719
+ }
1720
+
1721
+ return {
1722
+ ...(cacheRead !== undefined ? { lastObservedCacheRead: cacheRead } : {}),
1723
+ ...(cacheWrite !== undefined ? { lastObservedCacheWrite: cacheWrite } : {}),
1724
+ cacheState,
1725
+ ...(retention ? { retention } : {}),
1726
+ sawExplicitBreak,
1727
+ };
1728
+ }
1729
+
1730
+ /** Persist the current turn's compaction telemetry for later policy decisions. */
1731
+ private async updateCompactionTelemetry(params: {
1732
+ conversationId: number;
1733
+ runtimeContext?: Record<string, unknown>;
1734
+ tokenBudget?: number;
1735
+ rawTokensOutsideTail?: number;
1736
+ }): Promise<ConversationCompactionTelemetryRecord | null> {
1737
+ const snapshot = this.readPromptCacheSnapshot(params.runtimeContext);
1738
+ const existing = await this.compactionTelemetryStore.getConversationCompactionTelemetry(
1739
+ params.conversationId,
1740
+ );
1741
+ if (!snapshot && params.rawTokensOutsideTail === undefined) {
1742
+ return existing;
1743
+ }
1744
+
1745
+ const now = new Date();
1746
+ const bounds = this.resolveDynamicLeafChunkBounds(params.tokenBudget);
1747
+ const turnsSinceLeafCompaction =
1748
+ (existing?.turnsSinceLeafCompaction ?? 0) + 1;
1749
+ const tokensAccumulatedSinceLeafCompaction =
1750
+ params.rawTokensOutsideTail ?? existing?.tokensAccumulatedSinceLeafCompaction ?? 0;
1751
+ const lastActivityBand = this.classifyDynamicLeafActivityBand({
1752
+ lastActivityBand: existing?.lastActivityBand,
1753
+ tokensAccumulatedSinceLeafCompaction,
1754
+ turnsSinceLeafCompaction,
1755
+ floor: bounds.floor,
1756
+ });
1757
+ await this.compactionTelemetryStore.upsertConversationCompactionTelemetry({
1758
+ conversationId: params.conversationId,
1759
+ lastObservedCacheRead: snapshot?.lastObservedCacheRead ?? existing?.lastObservedCacheRead ?? null,
1760
+ lastObservedCacheWrite:
1761
+ snapshot?.lastObservedCacheWrite ?? existing?.lastObservedCacheWrite ?? null,
1762
+ lastObservedCacheHitAt:
1763
+ snapshot?.cacheState === "hot"
1764
+ ? now
1765
+ : existing?.lastObservedCacheHitAt ?? null,
1766
+ lastObservedCacheBreakAt:
1767
+ snapshot?.sawExplicitBreak
1768
+ ? now
1769
+ : existing?.lastObservedCacheBreakAt ?? null,
1770
+ cacheState: snapshot?.cacheState ?? existing?.cacheState ?? "unknown",
1771
+ retention: snapshot?.retention ?? existing?.retention ?? null,
1772
+ lastLeafCompactionAt: existing?.lastLeafCompactionAt ?? null,
1773
+ turnsSinceLeafCompaction,
1774
+ tokensAccumulatedSinceLeafCompaction,
1775
+ lastActivityBand,
1776
+ });
1777
+ const updated = await this.compactionTelemetryStore.getConversationCompactionTelemetry(
1778
+ params.conversationId,
1779
+ );
1780
+ if (updated) {
1781
+ this.deps.log.debug(
1782
+ `[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"}`,
1783
+ );
1784
+ }
1785
+ return updated;
1786
+ }
1787
+
1788
+ /** Reset refill counters after any successful leaf-producing compaction. */
1789
+ private async markLeafCompactionTelemetrySuccess(params: {
1790
+ conversationId: number;
1791
+ activityBand?: ActivityBand;
1792
+ }): Promise<void> {
1793
+ const existing = await this.compactionTelemetryStore.getConversationCompactionTelemetry(
1794
+ params.conversationId,
1795
+ );
1796
+ await this.compactionTelemetryStore.upsertConversationCompactionTelemetry({
1797
+ conversationId: params.conversationId,
1798
+ lastObservedCacheRead: existing?.lastObservedCacheRead ?? null,
1799
+ lastObservedCacheWrite: existing?.lastObservedCacheWrite ?? null,
1800
+ lastObservedCacheHitAt: existing?.lastObservedCacheHitAt ?? null,
1801
+ lastObservedCacheBreakAt: existing?.lastObservedCacheBreakAt ?? null,
1802
+ cacheState: existing?.cacheState ?? "unknown",
1803
+ retention: existing?.retention ?? null,
1804
+ lastLeafCompactionAt: new Date(),
1805
+ turnsSinceLeafCompaction: 0,
1806
+ tokensAccumulatedSinceLeafCompaction: 0,
1807
+ lastActivityBand: params.activityBand ?? existing?.lastActivityBand ?? "low",
1808
+ });
1809
+ this.deps.log.debug(
1810
+ `[lcm] compaction telemetry reset after leaf compaction: conversation=${params.conversationId} cacheState=${existing?.cacheState ?? "unknown"} activityBand=${params.activityBand ?? existing?.lastActivityBand ?? "low"}`,
1811
+ );
1812
+ }
1813
+
1814
+ /** Emit an operational trace for the incremental compaction policy decision. */
1815
+ private logIncrementalCompactionDecision(params: {
1816
+ conversationId: number;
1817
+ cacheState: CacheState;
1818
+ activityBand: ActivityBand;
1819
+ triggerLeafChunkTokens: number;
1820
+ preferredLeafChunkTokens: number;
1821
+ fallbackLeafChunkTokens: number[];
1822
+ rawTokensOutsideTail: number;
1823
+ threshold: number;
1824
+ shouldCompact: boolean;
1825
+ maxPasses: number;
1826
+ allowCondensedPasses: boolean;
1827
+ reason: string;
1828
+ }): IncrementalCompactionDecision {
1829
+ this.deps.log.info(
1830
+ `[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}`,
1831
+ );
1832
+ return {
1833
+ shouldCompact: params.shouldCompact,
1834
+ cacheState: params.cacheState,
1835
+ maxPasses: params.maxPasses,
1836
+ rawTokensOutsideTail: params.rawTokensOutsideTail,
1837
+ threshold: params.threshold,
1838
+ leafChunkTokens: params.preferredLeafChunkTokens,
1839
+ fallbackLeafChunkTokens: params.fallbackLeafChunkTokens,
1840
+ activityBand: params.activityBand,
1841
+ allowCondensedPasses: params.allowCondensedPasses,
1842
+ };
1843
+ }
1844
+
1845
+ /** Resolve the cache-aware incremental-compaction policy for the current session. */
1846
+ private async evaluateIncrementalCompaction(params: {
1847
+ conversationId: number;
1848
+ tokenBudget: number;
1849
+ currentTokenCount?: number;
1850
+ }): Promise<IncrementalCompactionDecision> {
1851
+ const telemetry = await this.compactionTelemetryStore.getConversationCompactionTelemetry(
1852
+ params.conversationId,
1853
+ );
1854
+ const cacheState =
1855
+ this.config.cacheAwareCompaction.enabled
1856
+ ? this.resolveCacheAwareState(telemetry)
1857
+ : "unknown";
1858
+ const bounds = this.resolveDynamicLeafChunkBounds(params.tokenBudget);
1859
+ const activityBand =
1860
+ this.config.dynamicLeafChunkTokens.enabled
1861
+ ? this.classifyDynamicLeafActivityBand({
1862
+ lastActivityBand: telemetry?.lastActivityBand,
1863
+ tokensAccumulatedSinceLeafCompaction:
1864
+ telemetry?.tokensAccumulatedSinceLeafCompaction ?? 0,
1865
+ turnsSinceLeafCompaction: telemetry?.turnsSinceLeafCompaction ?? 0,
1866
+ floor: bounds.floor,
1867
+ })
1868
+ : "low";
1869
+ const triggerLeafChunkTokens =
1870
+ this.config.dynamicLeafChunkTokens.enabled && cacheState === "hot"
1871
+ ? bounds.max
1872
+ : this.config.dynamicLeafChunkTokens.enabled
1873
+ ? this.resolveLeafChunkTokensForBand(activityBand, bounds)
1874
+ : bounds.floor;
1875
+ const preferredLeafChunkTokens =
1876
+ this.config.cacheAwareCompaction.enabled && (cacheState === "cold" || cacheState === "hot")
1877
+ ? bounds.max
1878
+ : triggerLeafChunkTokens;
1879
+ const fallbackLeafChunkTokens = this.buildLeafChunkFallbacks({
1880
+ preferred: preferredLeafChunkTokens,
1881
+ bounds,
1882
+ });
1883
+ const leafTrigger = await this.compaction.evaluateLeafTrigger(
1884
+ params.conversationId,
1885
+ triggerLeafChunkTokens,
1886
+ );
1887
+ if (!leafTrigger.shouldCompact) {
1888
+ return this.logIncrementalCompactionDecision({
1889
+ conversationId: params.conversationId,
1890
+ cacheState,
1891
+ activityBand,
1892
+ triggerLeafChunkTokens,
1893
+ preferredLeafChunkTokens,
1894
+ fallbackLeafChunkTokens,
1895
+ rawTokensOutsideTail: leafTrigger.rawTokensOutsideTail,
1896
+ threshold: leafTrigger.threshold,
1897
+ shouldCompact: false,
1898
+ maxPasses: 1,
1899
+ allowCondensedPasses: false,
1900
+ reason: "below-leaf-trigger",
1901
+ });
1902
+ }
1903
+
1904
+ const budgetDecision = await this.compaction.evaluate(
1905
+ params.conversationId,
1906
+ params.tokenBudget,
1907
+ params.currentTokenCount,
1908
+ );
1909
+ if (budgetDecision.shouldCompact) {
1910
+ return this.logIncrementalCompactionDecision({
1911
+ conversationId: params.conversationId,
1912
+ cacheState,
1913
+ activityBand,
1914
+ triggerLeafChunkTokens,
1915
+ preferredLeafChunkTokens,
1916
+ fallbackLeafChunkTokens,
1917
+ rawTokensOutsideTail: leafTrigger.rawTokensOutsideTail,
1918
+ threshold: leafTrigger.threshold,
1919
+ shouldCompact: true,
1920
+ maxPasses: 1,
1921
+ allowCondensedPasses: true,
1922
+ reason: "budget-trigger",
1923
+ });
1924
+ }
1925
+
1926
+ if (
1927
+ cacheState === "hot"
1928
+ && this.isComfortablyUnderTokenBudget({
1929
+ currentTokenCount: params.currentTokenCount,
1930
+ tokenBudget: params.tokenBudget,
1931
+ })
1932
+ ) {
1933
+ return this.logIncrementalCompactionDecision({
1934
+ conversationId: params.conversationId,
1935
+ cacheState,
1936
+ activityBand,
1937
+ triggerLeafChunkTokens,
1938
+ preferredLeafChunkTokens,
1939
+ fallbackLeafChunkTokens,
1940
+ rawTokensOutsideTail: leafTrigger.rawTokensOutsideTail,
1941
+ threshold: leafTrigger.threshold,
1942
+ shouldCompact: false,
1943
+ maxPasses: 1,
1944
+ allowCondensedPasses: false,
1945
+ reason: "hot-cache-budget-headroom",
1946
+ });
1947
+ }
1948
+
1949
+ if (
1950
+ cacheState === "hot"
1951
+ && leafTrigger.rawTokensOutsideTail
1952
+ < Math.floor(
1953
+ leafTrigger.threshold * this.config.cacheAwareCompaction.hotCachePressureFactor,
1954
+ )
1955
+ ) {
1956
+ return this.logIncrementalCompactionDecision({
1957
+ conversationId: params.conversationId,
1958
+ cacheState,
1959
+ activityBand,
1960
+ triggerLeafChunkTokens,
1961
+ preferredLeafChunkTokens,
1962
+ fallbackLeafChunkTokens,
1963
+ rawTokensOutsideTail: leafTrigger.rawTokensOutsideTail,
1964
+ threshold: leafTrigger.threshold,
1965
+ shouldCompact: false,
1966
+ maxPasses: 1,
1967
+ allowCondensedPasses: false,
1968
+ reason: "hot-cache-defer",
1969
+ });
1970
+ }
1971
+
1972
+ const maxPasses =
1973
+ cacheState === "cold"
1974
+ ? Math.max(1, this.config.cacheAwareCompaction.maxColdCacheCatchupPasses)
1975
+ : 1;
1976
+ return this.logIncrementalCompactionDecision({
1977
+ conversationId: params.conversationId,
1978
+ cacheState,
1979
+ activityBand,
1980
+ triggerLeafChunkTokens,
1981
+ preferredLeafChunkTokens,
1982
+ fallbackLeafChunkTokens,
1983
+ rawTokensOutsideTail: leafTrigger.rawTokensOutsideTail,
1984
+ threshold: leafTrigger.threshold,
1985
+ shouldCompact: true,
1986
+ maxPasses,
1987
+ allowCondensedPasses: cacheState !== "hot",
1988
+ reason: cacheState === "cold" ? "cold-cache-catchup" : "leaf-trigger",
1989
+ });
1990
+ }
1991
+
1449
1992
  /** Resolve an LCM conversation id from a session key via the session store. */
1450
1993
  private async resolveConversationIdForSessionKey(
1451
1994
  sessionKey: string,
@@ -1475,6 +2018,23 @@ export class LcmContextEngine implements ContextEngine {
1475
2018
  }
1476
2019
  }
1477
2020
 
2021
+ /** Format stable session identifiers for LCM diagnostic logs. */
2022
+ private formatSessionLogContext(params: {
2023
+ conversationId: number;
2024
+ sessionId: string;
2025
+ sessionKey?: string;
2026
+ }): string {
2027
+ const parts = [
2028
+ `conversation=${params.conversationId}`,
2029
+ `session=${params.sessionId}`,
2030
+ ];
2031
+ const trimmedSessionKey = params.sessionKey?.trim();
2032
+ if (trimmedSessionKey) {
2033
+ parts.push(`sessionKey=${trimmedSessionKey}`);
2034
+ }
2035
+ return parts.join(" ");
2036
+ }
2037
+
1478
2038
  /** Build a summarize callback with runtime provider fallback handling. */
1479
2039
  private async resolveSummarize(params: {
1480
2040
  legacyParams?: Record<string, unknown>;
@@ -1510,11 +2070,13 @@ export class LcmContextEngine implements ContextEngine {
1510
2070
  breakerKey: runtimeSummarizer.breakerKey,
1511
2071
  };
1512
2072
  }
1513
- console.error(`[lcm] resolveSummarize: createLcmSummarizeFromLegacyParams returned undefined`);
2073
+ this.deps.log.error(`[lcm] resolveSummarize: createLcmSummarizeFromLegacyParams returned undefined`);
1514
2074
  } catch (err) {
1515
- console.error(`[lcm] resolveSummarize failed, using emergency fallback:`, err instanceof Error ? err.message : err);
2075
+ this.deps.log.error(
2076
+ `[lcm] resolveSummarize failed, using emergency fallback: ${describeLogError(err)}`,
2077
+ );
1516
2078
  }
1517
- console.error(`[lcm] resolveSummarize: FALLING BACK TO EMERGENCY TRUNCATION`);
2079
+ this.deps.log.error(`[lcm] resolveSummarize: FALLING BACK TO EMERGENCY TRUNCATION`);
1518
2080
  return { summarize: createEmergencyFallbackSummarize(), summaryModel: "unknown" };
1519
2081
  }
1520
2082
 
@@ -1947,7 +2509,14 @@ export class LcmContextEngine implements ContextEngine {
1947
2509
 
1948
2510
  const existingDbCount = await this.conversationStore.getMessageCount(conversationId);
1949
2511
  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.`);
2512
+ const sessionContext = this.formatSessionLogContext({
2513
+ conversationId,
2514
+ sessionId,
2515
+ sessionKey: params.sessionKey,
2516
+ });
2517
+ this.deps.log.warn(
2518
+ `[lcm] reconcileSessionTail: import cap exceeded for ${sessionContext} — would import ${missingTail.length} messages (existing: ${existingDbCount}). Aborting to prevent flood.`,
2519
+ );
1951
2520
  return { blockedByImportCap: true, importedMessages: 0, hasOverlap: true };
1952
2521
  }
1953
2522
 
@@ -1962,6 +2531,37 @@ export class LcmContextEngine implements ContextEngine {
1962
2531
  return { blockedByImportCap: false, importedMessages, hasOverlap: true };
1963
2532
  }
1964
2533
 
2534
+ /**
2535
+ * Persist bootstrap checkpoint metadata anchored to the current DB frontier.
2536
+ *
2537
+ * We intentionally checkpoint the session file's current EOF while hashing the
2538
+ * latest persisted DB message. This keeps append-only recovery aligned with the
2539
+ * canonical LCM frontier even when trailing transcript entries are pruned or
2540
+ * otherwise noncanonical.
2541
+ */
2542
+ private async refreshBootstrapState(params: {
2543
+ conversationId: number;
2544
+ sessionFile: string;
2545
+ fileStats?: { size: number; mtimeMs: number };
2546
+ }): Promise<void> {
2547
+ const latestDbMessage = await this.conversationStore.getLastMessage(params.conversationId);
2548
+ const fileStats = params.fileStats ?? statSync(params.sessionFile);
2549
+ await this.summaryStore.upsertConversationBootstrapState({
2550
+ conversationId: params.conversationId,
2551
+ sessionFilePath: params.sessionFile,
2552
+ lastSeenSize: fileStats.size,
2553
+ lastSeenMtimeMs: Math.trunc(fileStats.mtimeMs),
2554
+ lastProcessedOffset: fileStats.size,
2555
+ lastProcessedEntryHash: latestDbMessage
2556
+ ? createBootstrapEntryHash({
2557
+ role: latestDbMessage.role,
2558
+ content: latestDbMessage.content,
2559
+ tokenCount: latestDbMessage.tokenCount,
2560
+ })
2561
+ : null,
2562
+ });
2563
+ }
2564
+
1965
2565
  async bootstrap(params: {
1966
2566
  sessionId: string;
1967
2567
  sessionFile: string;
@@ -1992,19 +2592,14 @@ export class LcmContextEngine implements ContextEngine {
1992
2592
  this.conversationStore.withTransaction(async () => {
1993
2593
  const persistBootstrapState = async (
1994
2594
  conversationId: number,
1995
- historicalMessages: AgentMessage[],
1996
2595
  ): Promise<void> => {
1997
- const lastMessage =
1998
- historicalMessages.length > 0
1999
- ? toStoredMessage(historicalMessages[historicalMessages.length - 1]!)
2000
- : null;
2001
- await this.summaryStore.upsertConversationBootstrapState({
2596
+ await this.refreshBootstrapState({
2002
2597
  conversationId,
2003
- sessionFilePath: params.sessionFile,
2004
- lastSeenSize: sessionFileSize,
2005
- lastSeenMtimeMs: sessionFileMtimeMs,
2006
- lastProcessedOffset: sessionFileSize,
2007
- lastProcessedEntryHash: createBootstrapEntryHash(lastMessage),
2598
+ sessionFile: params.sessionFile,
2599
+ fileStats: {
2600
+ size: sessionFileSize,
2601
+ mtimeMs: sessionFileMtimeMs,
2602
+ },
2008
2603
  });
2009
2604
  };
2010
2605
 
@@ -2055,6 +2650,7 @@ export class LcmContextEngine implements ContextEngine {
2055
2650
  params.sessionFile,
2056
2651
  bootstrapState.lastProcessedOffset,
2057
2652
  true,
2653
+ (message) => createBootstrapEntryHash(toStoredMessage(message)) === latestDbHash,
2058
2654
  );
2059
2655
  const tailEntryMessage = readBootstrapMessageFromJsonLine(tailEntryRaw);
2060
2656
  const tailEntryHash = tailEntryMessage
@@ -2088,14 +2684,7 @@ export class LcmContextEngine implements ContextEngine {
2088
2684
  }
2089
2685
  }
2090
2686
 
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] : [],
2098
- );
2687
+ await persistBootstrapState(conversationId);
2099
2688
 
2100
2689
  if (importedMessages > 0) {
2101
2690
  return {
@@ -2126,7 +2715,7 @@ export class LcmContextEngine implements ContextEngine {
2126
2715
 
2127
2716
  if (bootstrapMessages.length === 0) {
2128
2717
  await this.conversationStore.markConversationBootstrapped(conversationId);
2129
- await persistBootstrapState(conversationId, historicalMessages);
2718
+ await persistBootstrapState(conversationId);
2130
2719
  return {
2131
2720
  bootstrapped: false,
2132
2721
  importedMessages: 0,
@@ -2152,18 +2741,19 @@ export class LcmContextEngine implements ContextEngine {
2152
2741
  inserted.map((record) => record.messageId),
2153
2742
  );
2154
2743
  await this.conversationStore.markConversationBootstrapped(conversationId);
2155
- await persistBootstrapState(conversationId, historicalMessages);
2156
2744
 
2157
2745
  // Prune HEARTBEAT_OK turns from the freshly imported data
2158
2746
  if (this.config.pruneHeartbeatOk) {
2159
2747
  const pruned = await this.pruneHeartbeatOkTurns(conversationId);
2160
2748
  if (pruned > 0) {
2161
- console.error(
2749
+ this.deps.log.info(
2162
2750
  `[lcm] bootstrap: pruned ${pruned} HEARTBEAT_OK messages from conversation ${conversationId}`,
2163
2751
  );
2164
2752
  }
2165
2753
  }
2166
2754
 
2755
+ await persistBootstrapState(conversationId);
2756
+
2167
2757
  return {
2168
2758
  bootstrapped: true,
2169
2759
  importedMessages: inserted.length,
@@ -2192,7 +2782,7 @@ export class LcmContextEngine implements ContextEngine {
2192
2782
  }
2193
2783
 
2194
2784
  if (reconcile.importedMessages > 0) {
2195
- await persistBootstrapState(conversationId, historicalMessages);
2785
+ await persistBootstrapState(conversationId);
2196
2786
  return {
2197
2787
  bootstrapped: true,
2198
2788
  importedMessages: reconcile.importedMessages,
@@ -2201,7 +2791,7 @@ export class LcmContextEngine implements ContextEngine {
2201
2791
  }
2202
2792
 
2203
2793
  if (reconcile.hasOverlap) {
2204
- await persistBootstrapState(conversationId, historicalMessages);
2794
+ await persistBootstrapState(conversationId);
2205
2795
  }
2206
2796
 
2207
2797
  if (conversation.bootstrappedAt) {
@@ -2233,15 +2823,18 @@ export class LcmContextEngine implements ContextEngine {
2233
2823
  if (conversation) {
2234
2824
  const pruned = await this.pruneHeartbeatOkTurns(conversation.conversationId);
2235
2825
  if (pruned > 0) {
2236
- console.error(
2826
+ await this.refreshBootstrapState({
2827
+ conversationId: conversation.conversationId,
2828
+ sessionFile: params.sessionFile,
2829
+ });
2830
+ this.deps.log.info(
2237
2831
  `[lcm] bootstrap: retroactively pruned ${pruned} HEARTBEAT_OK messages from conversation ${conversation.conversationId}`,
2238
2832
  );
2239
2833
  }
2240
2834
  }
2241
2835
  } catch (err) {
2242
- console.error(
2243
- `[lcm] bootstrap: heartbeat pruning failed:`,
2244
- err instanceof Error ? err.message : err,
2836
+ this.deps.log.warn(
2837
+ `[lcm] bootstrap: heartbeat pruning failed: ${describeLogError(err)}`,
2245
2838
  );
2246
2839
  }
2247
2840
  }
@@ -2455,24 +3048,14 @@ export class LcmContextEngine implements ContextEngine {
2455
3048
 
2456
3049
  if (result.changed) {
2457
3050
  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
- }
3051
+ await this.refreshBootstrapState({
3052
+ conversationId: conversation.conversationId,
3053
+ sessionFile: params.sessionFile,
3054
+ });
2474
3055
  } catch (e) {
2475
- console.error("[lcm] Failed to update bootstrap checkpoint after maintain:", e);
3056
+ this.deps.log.warn(
3057
+ `[lcm] Failed to update bootstrap checkpoint after maintain: ${describeLogError(e)}`,
3058
+ );
2476
3059
  }
2477
3060
  }
2478
3061
 
@@ -2663,9 +3246,8 @@ export class LcmContextEngine implements ContextEngine {
2663
3246
  });
2664
3247
  } catch (err) {
2665
3248
  // 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,
3249
+ this.deps.log.error(
3250
+ `[lcm] afterTurn: ingest failed, skipping compaction: ${describeLogError(err)}`,
2669
3251
  );
2670
3252
  return;
2671
3253
  }
@@ -2679,16 +3261,30 @@ export class LcmContextEngine implements ContextEngine {
2679
3261
  if (conversation) {
2680
3262
  const pruned = await this.pruneHeartbeatOkTurns(conversation.conversationId);
2681
3263
  if (pruned > 0) {
2682
- console.error(
2683
- `[lcm] afterTurn: pruned ${pruned} heartbeat ack messages from conversation ${conversation.conversationId}`,
3264
+ const sessionContext = this.formatSessionLogContext({
3265
+ conversationId: conversation.conversationId,
3266
+ sessionId: params.sessionId,
3267
+ sessionKey: params.sessionKey,
3268
+ });
3269
+ try {
3270
+ await this.refreshBootstrapState({
3271
+ conversationId: conversation.conversationId,
3272
+ sessionFile: params.sessionFile,
3273
+ });
3274
+ } catch (err) {
3275
+ this.deps.log.warn(
3276
+ `[lcm] afterTurn: heartbeat pruning checkpoint refresh failed for ${sessionContext}: ${describeLogError(err)}`,
3277
+ );
3278
+ }
3279
+ this.deps.log.info(
3280
+ `[lcm] afterTurn: pruned ${pruned} heartbeat ack messages for ${sessionContext}`,
2684
3281
  );
2685
3282
  return;
2686
3283
  }
2687
3284
  }
2688
3285
  } catch (err) {
2689
- console.error(
2690
- `[lcm] afterTurn: heartbeat pruning failed:`,
2691
- err instanceof Error ? err.message : err,
3286
+ this.deps.log.warn(
3287
+ `[lcm] afterTurn: heartbeat pruning failed: ${describeLogError(err)}`,
2692
3288
  );
2693
3289
  }
2694
3290
  }
@@ -2702,16 +3298,41 @@ export class LcmContextEngine implements ContextEngine {
2702
3298
  });
2703
3299
  const tokenBudget = this.applyAssemblyBudgetCap(resolvedTokenBudget ?? DEFAULT_AFTER_TURN_TOKEN_BUDGET);
2704
3300
  if (resolvedTokenBudget === undefined) {
2705
- console.warn(
3301
+ this.deps.log.warn(
2706
3302
  `[lcm] afterTurn: tokenBudget not provided; using default ${DEFAULT_AFTER_TURN_TOKEN_BUDGET}`,
2707
3303
  );
2708
3304
  }
2709
3305
 
2710
3306
  const liveContextTokens = estimateSessionTokenCountForAfterTurn(params.messages);
3307
+ const conversation = await this.conversationStore.getConversationForSession({
3308
+ sessionId: params.sessionId,
3309
+ sessionKey: params.sessionKey,
3310
+ });
3311
+ if (!conversation) {
3312
+ return;
3313
+ }
2711
3314
 
2712
3315
  try {
2713
- const leafTrigger = await this.evaluateLeafTrigger(params.sessionId, params.sessionKey);
2714
- if (leafTrigger.shouldCompact) {
3316
+ const rawLeafTrigger = await this.compaction.evaluateLeafTrigger(conversation.conversationId);
3317
+ await this.updateCompactionTelemetry({
3318
+ conversationId: conversation.conversationId,
3319
+ runtimeContext: asRecord(params.runtimeContext),
3320
+ tokenBudget,
3321
+ rawTokensOutsideTail: rawLeafTrigger.rawTokensOutsideTail,
3322
+ });
3323
+ } catch (err) {
3324
+ this.deps.log.warn(
3325
+ `[lcm] afterTurn: compaction telemetry update failed: ${describeLogError(err)}`,
3326
+ );
3327
+ }
3328
+
3329
+ try {
3330
+ const leafDecision = await this.evaluateIncrementalCompaction({
3331
+ conversationId: conversation.conversationId,
3332
+ tokenBudget,
3333
+ currentTokenCount: liveContextTokens,
3334
+ });
3335
+ if (leafDecision.shouldCompact) {
2715
3336
  this.compactLeafAsync({
2716
3337
  sessionId: params.sessionId,
2717
3338
  sessionKey: params.sessionKey,
@@ -2719,6 +3340,11 @@ export class LcmContextEngine implements ContextEngine {
2719
3340
  tokenBudget,
2720
3341
  currentTokenCount: liveContextTokens,
2721
3342
  legacyParams,
3343
+ maxPasses: leafDecision.maxPasses,
3344
+ leafChunkTokens: leafDecision.leafChunkTokens,
3345
+ fallbackLeafChunkTokens: leafDecision.fallbackLeafChunkTokens,
3346
+ activityBand: leafDecision.activityBand,
3347
+ allowCondensedPasses: leafDecision.allowCondensedPasses,
2722
3348
  }).catch(() => {
2723
3349
  // Leaf compaction is best-effort and should not fail the caller.
2724
3350
  });
@@ -2856,7 +3482,7 @@ export class LcmContextEngine implements ContextEngine {
2856
3482
  return this.compaction.evaluateLeafTrigger(conversation.conversationId);
2857
3483
  }
2858
3484
 
2859
- /** Run one incremental leaf compaction pass in the per-session queue. */
3485
+ /** Run one or more incremental leaf compaction passes in the per-session queue. */
2860
3486
  async compactLeafAsync(params: {
2861
3487
  sessionId: string;
2862
3488
  sessionKey?: string;
@@ -2870,6 +3496,11 @@ export class LcmContextEngine implements ContextEngine {
2870
3496
  legacyParams?: Record<string, unknown>;
2871
3497
  force?: boolean;
2872
3498
  previousSummaryContent?: string;
3499
+ maxPasses?: number;
3500
+ leafChunkTokens?: number;
3501
+ fallbackLeafChunkTokens?: number[];
3502
+ activityBand?: ActivityBand;
3503
+ allowCondensedPasses?: boolean;
2873
3504
  }): Promise<CompactResult> {
2874
3505
  if (this.isStatelessSession(params.sessionKey)) {
2875
3506
  return {
@@ -2933,38 +3564,114 @@ export class LcmContextEngine implements ContextEngine {
2933
3564
  };
2934
3565
  }
2935
3566
 
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
- });
3567
+ const storedTokensBefore = await this.summaryStore.getContextTokenCount(
3568
+ conversation.conversationId,
3569
+ );
3570
+ const maxPasses =
3571
+ typeof params.maxPasses === "number" &&
3572
+ Number.isFinite(params.maxPasses) &&
3573
+ params.maxPasses > 0
3574
+ ? Math.floor(params.maxPasses)
3575
+ : 1;
3576
+ const fallbackLeafChunkTokens = Array.isArray(params.fallbackLeafChunkTokens)
3577
+ ? [...new Set(params.fallbackLeafChunkTokens
3578
+ .filter((value): value is number => typeof value === "number" && Number.isFinite(value) && value > 0)
3579
+ .map((value) => Math.floor(value)))]
3580
+ .sort((a, b) => b - a)
3581
+ : [];
3582
+ let activeLeafChunkTokens =
3583
+ typeof params.leafChunkTokens === "number" &&
3584
+ Number.isFinite(params.leafChunkTokens) &&
3585
+ params.leafChunkTokens > 0
3586
+ ? Math.floor(params.leafChunkTokens)
3587
+ : fallbackLeafChunkTokens[0];
3588
+ this.deps.log.info(
3589
+ `[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}`,
3590
+ );
3591
+
3592
+ let rounds = 0;
3593
+ let finalTokens = observedTokens ?? storedTokensBefore;
3594
+ let authFailure = false;
2944
3595
 
2945
- if (leafResult.authFailure && breakerKey) {
3596
+ for (let pass = 0; pass < maxPasses; pass += 1) {
3597
+ let leafResult: Awaited<ReturnType<typeof this.compaction.compactLeaf>> | undefined;
3598
+ while (true) {
3599
+ try {
3600
+ leafResult = await this.compaction.compactLeaf({
3601
+ conversationId: conversation.conversationId,
3602
+ tokenBudget,
3603
+ summarize,
3604
+ ...(activeLeafChunkTokens !== undefined ? { leafChunkTokens: activeLeafChunkTokens } : {}),
3605
+ force: params.force,
3606
+ previousSummaryContent: pass === 0 ? params.previousSummaryContent : undefined,
3607
+ summaryModel,
3608
+ allowCondensedPasses: params.allowCondensedPasses,
3609
+ });
3610
+ break;
3611
+ } catch (err) {
3612
+ const nextLeafChunkTokens = fallbackLeafChunkTokens.find(
3613
+ (value) => activeLeafChunkTokens !== undefined && value < activeLeafChunkTokens,
3614
+ );
3615
+ if (!this.isRecoverableLeafChunkOverflowError(err) || nextLeafChunkTokens === undefined) {
3616
+ throw err;
3617
+ }
3618
+ this.deps.log.warn(
3619
+ `[lcm] compactLeafAsync: retrying with smaller leafChunkTokens=${nextLeafChunkTokens} after provider token-limit error: ${err instanceof Error ? err.message : String(err)}`,
3620
+ );
3621
+ activeLeafChunkTokens = nextLeafChunkTokens;
3622
+ }
3623
+ }
3624
+ if (!leafResult) {
3625
+ break;
3626
+ }
3627
+ finalTokens = leafResult.tokensAfter;
3628
+
3629
+ if (leafResult.authFailure) {
3630
+ authFailure = true;
3631
+ break;
3632
+ }
3633
+ if (!leafResult.actionTaken) {
3634
+ break;
3635
+ }
3636
+ rounds += 1;
3637
+ if (leafResult.tokensAfter >= leafResult.tokensBefore) {
3638
+ break;
3639
+ }
3640
+ }
3641
+
3642
+ if (authFailure && breakerKey) {
2946
3643
  this.recordCompactionAuthFailure(breakerKey);
2947
- } else if (leafResult.actionTaken && breakerKey) {
3644
+ } else if (rounds > 0 && breakerKey) {
2948
3645
  this.recordCompactionSuccess(breakerKey);
2949
3646
  }
3647
+ if (rounds > 0) {
3648
+ await this.markLeafCompactionTelemetrySuccess({
3649
+ conversationId: conversation.conversationId,
3650
+ activityBand: params.activityBand,
3651
+ });
3652
+ }
2950
3653
 
2951
- const tokensBefore = observedTokens ?? leafResult.tokensBefore;
3654
+ const tokensBefore = observedTokens ?? storedTokensBefore;
3655
+ this.deps.log.debug(
3656
+ `[lcm] compactLeafAsync result: conversation=${conversation.conversationId} session=${params.sessionId} rounds=${rounds} compacted=${rounds > 0} authFailure=${authFailure} finalLeafChunkTokens=${activeLeafChunkTokens ?? "null"} finalTokens=${finalTokens}`,
3657
+ );
2952
3658
 
2953
3659
  return {
2954
3660
  ok: true,
2955
- compacted: leafResult.actionTaken,
2956
- reason: leafResult.authFailure
3661
+ compacted: rounds > 0,
3662
+ reason: authFailure
2957
3663
  ? "provider auth failure"
2958
- : leafResult.actionTaken
3664
+ : rounds > 0
2959
3665
  ? "compacted"
2960
3666
  : "below threshold",
2961
3667
  result: {
2962
3668
  tokensBefore,
2963
- tokensAfter: leafResult.tokensAfter,
3669
+ tokensAfter: finalTokens,
2964
3670
  details: {
2965
- rounds: leafResult.actionTaken ? 1 : 0,
3671
+ rounds,
2966
3672
  targetTokens: tokenBudget,
2967
3673
  mode: "leaf",
3674
+ maxPasses,
2968
3675
  },
2969
3676
  },
2970
3677
  };
@@ -3093,7 +3800,7 @@ export class LcmContextEngine implements ContextEngine {
3093
3800
  // overflow counts can drive recovery even when persisted context is already small.
3094
3801
  const useSweep = manualCompactionRequested || params.compactionTarget === "threshold";
3095
3802
  if (useSweep) {
3096
- const sweepResult = await this.compaction.compactFullSweep({
3803
+ const sweepResult = await this.compaction.compact({
3097
3804
  conversationId,
3098
3805
  tokenBudget,
3099
3806
  summarize,
@@ -3107,6 +3814,9 @@ export class LcmContextEngine implements ContextEngine {
3107
3814
  } else if (sweepResult.actionTaken && breakerKey) {
3108
3815
  this.recordCompactionSuccess(breakerKey);
3109
3816
  }
3817
+ if (sweepResult.actionTaken) {
3818
+ await this.markLeafCompactionTelemetrySuccess({ conversationId });
3819
+ }
3110
3820
 
3111
3821
  return {
3112
3822
  ok: !sweepResult.authFailure && (sweepResult.actionTaken || !liveContextStillExceedsTarget),
@@ -3156,6 +3866,9 @@ export class LcmContextEngine implements ContextEngine {
3156
3866
  }
3157
3867
 
3158
3868
  const didCompact = compactResult.rounds > 0;
3869
+ if (didCompact) {
3870
+ await this.markLeafCompactionTelemetrySuccess({ conversationId });
3871
+ }
3159
3872
 
3160
3873
  return {
3161
3874
  ok: compactResult.success,
@@ -3344,7 +4057,7 @@ export class LcmContextEngine implements ContextEngine {
3344
4057
  const nextSessionKey = params.nextSessionKey?.trim() || params.sessionKey?.trim() || current?.sessionKey;
3345
4058
  const freshConversation = await this.conversationStore.createConversation({
3346
4059
  sessionId: nextSessionId,
3347
- sessionKey: nextSessionKey,
4060
+ ...(nextSessionKey ? { sessionKey: nextSessionKey } : {}),
3348
4061
  });
3349
4062
  this.deps.log.info(
3350
4063
  `[lcm] ${params.reason} lifecycle archived prior conversation and created ${freshConversation.conversationId}`,
@@ -3455,6 +4168,10 @@ export class LcmContextEngine implements ContextEngine {
3455
4168
  return this.summaryStore;
3456
4169
  }
3457
4170
 
4171
+ getCompactionTelemetryStore(): CompactionTelemetryStore {
4172
+ return this.compactionTelemetryStore;
4173
+ }
4174
+
3458
4175
  // ── Heartbeat pruning ──────────────────────────────────────────────────
3459
4176
 
3460
4177
  /**