@martian-engineering/lossless-claw 0.6.2 → 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,11 +43,19 @@ 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,
58
+ type ConversationRecord,
51
59
  type CreateMessagePartInput,
52
60
  type MessagePartRecord,
53
61
  type MessagePartType,
@@ -62,6 +70,30 @@ type CircuitBreakerState = {
62
70
  failures: number;
63
71
  openSince: number | null;
64
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
+ };
65
97
  type TranscriptRewriteReplacement = {
66
98
  entryId: string;
67
99
  message: AgentMessage;
@@ -82,6 +114,13 @@ type ContextEngineMaintenanceRuntimeContext = Record<string, unknown> & {
82
114
  };
83
115
 
84
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;
85
124
 
86
125
  // ── Helpers ──────────────────────────────────────────────────────────────────
87
126
 
@@ -834,12 +873,25 @@ function isBootstrapMessage(value: unknown): value is AgentMessage {
834
873
  return "content" in msg || ("command" in msg && "output" in msg);
835
874
  }
836
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
+
837
893
  function extractBootstrapMessageCandidate(value: unknown): AgentMessage | null {
838
- const candidate =
839
- value && typeof value === "object" && "message" in value
840
- ? (value as { message?: unknown }).message
841
- : value;
842
- return isBootstrapMessage(candidate) ? candidate : null;
894
+ return extractCanonicalBootstrapMessage(value);
843
895
  }
844
896
 
845
897
  function parseBootstrapJsonl(raw: string, options?: {
@@ -862,9 +914,6 @@ function parseBootstrapJsonl(raw: string, options?: {
862
914
  messages.push(candidate);
863
915
  continue;
864
916
  }
865
- if (options?.strict) {
866
- hadMalformedLine = true;
867
- }
868
917
  } catch {
869
918
  if (options?.strict) {
870
919
  hadMalformedLine = true;
@@ -1017,7 +1066,12 @@ function readFileSegment(sessionFile: string, offset: number): string | null {
1017
1066
  }
1018
1067
  }
1019
1068
 
1020
- function readLastJsonlEntryBeforeOffset(sessionFile: string, offset: number): string | null {
1069
+ function readLastJsonlEntryBeforeOffset(
1070
+ sessionFile: string,
1071
+ offset: number,
1072
+ messageOnly = false,
1073
+ matcher?: (message: AgentMessage) => boolean,
1074
+ ): string | null {
1021
1075
  const chunkSize = 16_384;
1022
1076
  let fd: number | null = null;
1023
1077
  try {
@@ -1029,16 +1083,23 @@ function readLastJsonlEntryBeforeOffset(sessionFile: string, offset: number): st
1029
1083
  fd = openSync(sessionFile, "r");
1030
1084
  let cursor = safeOffset;
1031
1085
  let carry = "";
1032
- while (cursor > 0) {
1033
- const start = Math.max(0, cursor - chunkSize);
1034
- const length = cursor - start;
1035
- const buffer = Buffer.alloc(length);
1036
- readSync(fd, buffer, 0, length, start);
1037
- carry = buffer.toString("utf8") + carry;
1086
+ let reachedStart = false;
1087
+ while (cursor > 0 || (reachedStart && carry.length > 0)) {
1088
+ if (!reachedStart) {
1089
+ const start = Math.max(0, cursor - chunkSize);
1090
+ const length = cursor - start;
1091
+ const buffer = Buffer.alloc(length);
1092
+ readSync(fd, buffer, 0, length, start);
1093
+ carry = buffer.toString("utf8") + carry;
1094
+ cursor = start;
1095
+ if (start === 0) {
1096
+ reachedStart = true;
1097
+ }
1098
+ }
1038
1099
 
1039
1100
  const trimmedEnd = carry.replace(/\s+$/u, "");
1040
1101
  if (!trimmedEnd) {
1041
- cursor = start;
1102
+ if (reachedStart) break;
1042
1103
  carry = "";
1043
1104
  continue;
1044
1105
  }
@@ -1047,17 +1108,36 @@ function readLastJsonlEntryBeforeOffset(sessionFile: string, offset: number): st
1047
1108
  if (newlineIndex >= 0) {
1048
1109
  const candidate = trimmedEnd.slice(newlineIndex + 1).trim();
1049
1110
  if (candidate) {
1111
+ if (messageOnly) {
1112
+ let matchedMessage: AgentMessage | null = null;
1113
+ try {
1114
+ matchedMessage = extractBootstrapMessageCandidate(JSON.parse(candidate));
1115
+ } catch { /* not valid JSON, skip */ }
1116
+ if (!matchedMessage || (matcher && !matcher(matchedMessage))) {
1117
+ carry = trimmedEnd.slice(0, newlineIndex);
1118
+ continue;
1119
+ }
1120
+ }
1050
1121
  return candidate;
1051
1122
  }
1052
1123
  carry = trimmedEnd.slice(0, newlineIndex);
1053
- cursor = start;
1054
1124
  continue;
1055
1125
  }
1056
1126
 
1057
- if (start === 0) {
1058
- return trimmedEnd.trim() || null;
1127
+ // No newline found — entire trimmedEnd is one line
1128
+ if (reachedStart) {
1129
+ const firstLine = trimmedEnd.trim() || null;
1130
+ if (firstLine && messageOnly) {
1131
+ let matchedMessage: AgentMessage | null = null;
1132
+ try {
1133
+ matchedMessage = extractBootstrapMessageCandidate(JSON.parse(firstLine));
1134
+ } catch { /* not valid JSON */ }
1135
+ if (!matchedMessage || (matcher && !matcher(matchedMessage))) return null;
1136
+ }
1137
+ return firstLine;
1059
1138
  }
1060
- cursor = start;
1139
+ // Need more data from earlier in the file
1140
+ continue;
1061
1141
  }
1062
1142
  return null;
1063
1143
  } catch {
@@ -1128,6 +1208,7 @@ export class LcmContextEngine implements ContextEngine {
1128
1208
 
1129
1209
  private conversationStore: ConversationStore;
1130
1210
  private summaryStore: SummaryStore;
1211
+ private compactionTelemetryStore: CompactionTelemetryStore;
1131
1212
  private assembler: ContextAssembler;
1132
1213
  private compaction: CompactionEngine;
1133
1214
  private retrieval: RetrievalEngine;
@@ -1197,6 +1278,7 @@ export class LcmContextEngine implements ContextEngine {
1197
1278
  fts5Available: this.fts5Available,
1198
1279
  });
1199
1280
  this.summaryStore = new SummaryStore(this.db, { fts5Available: this.fts5Available });
1281
+ this.compactionTelemetryStore = new CompactionTelemetryStore(this.db);
1200
1282
 
1201
1283
  if (!this.fts5Available) {
1202
1284
  this.deps.log.warn(
@@ -1242,6 +1324,7 @@ export class LcmContextEngine implements ContextEngine {
1242
1324
  this.conversationStore,
1243
1325
  this.summaryStore,
1244
1326
  compactionConfig,
1327
+ this.deps.log,
1245
1328
  );
1246
1329
 
1247
1330
  this.retrieval = new RetrievalEngine(this.conversationStore, this.summaryStore);
@@ -1308,10 +1391,17 @@ export class LcmContextEngine implements ContextEngine {
1308
1391
  private recordCompactionAuthFailure(key: string): void {
1309
1392
  const state = this.getCircuitBreakerState(key);
1310
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
+ }
1311
1400
  if (state.failures >= this.config.circuitBreakerThreshold) {
1312
1401
  state.openSince = Date.now();
1313
- console.error(
1314
- `[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.`,
1315
1405
  );
1316
1406
  }
1317
1407
  }
@@ -1322,7 +1412,7 @@ export class LcmContextEngine implements ContextEngine {
1322
1412
  return;
1323
1413
  }
1324
1414
  if (state.failures > 0 || state.openSince !== null) {
1325
- console.error(
1415
+ this.deps.log.info(
1326
1416
  `[lcm] compaction circuit breaker CLOSED: successful compaction for ${key} after ${state.failures} prior failures.`,
1327
1417
  );
1328
1418
  }
@@ -1419,6 +1509,486 @@ export class LcmContextEngine implements ContextEngine {
1419
1509
  return cap != null && cap > 0 ? Math.min(budget, cap) : budget;
1420
1510
  }
1421
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
+
1422
1992
  /** Resolve an LCM conversation id from a session key via the session store. */
1423
1993
  private async resolveConversationIdForSessionKey(
1424
1994
  sessionKey: string,
@@ -1448,6 +2018,23 @@ export class LcmContextEngine implements ContextEngine {
1448
2018
  }
1449
2019
  }
1450
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
+
1451
2038
  /** Build a summarize callback with runtime provider fallback handling. */
1452
2039
  private async resolveSummarize(params: {
1453
2040
  legacyParams?: Record<string, unknown>;
@@ -1483,11 +2070,13 @@ export class LcmContextEngine implements ContextEngine {
1483
2070
  breakerKey: runtimeSummarizer.breakerKey,
1484
2071
  };
1485
2072
  }
1486
- console.error(`[lcm] resolveSummarize: createLcmSummarizeFromLegacyParams returned undefined`);
2073
+ this.deps.log.error(`[lcm] resolveSummarize: createLcmSummarizeFromLegacyParams returned undefined`);
1487
2074
  } catch (err) {
1488
- 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
+ );
1489
2078
  }
1490
- console.error(`[lcm] resolveSummarize: FALLING BACK TO EMERGENCY TRUNCATION`);
2079
+ this.deps.log.error(`[lcm] resolveSummarize: FALLING BACK TO EMERGENCY TRUNCATION`);
1491
2080
  return { summarize: createEmergencyFallbackSummarize(), summaryModel: "unknown" };
1492
2081
  }
1493
2082
 
@@ -1826,17 +2415,18 @@ export class LcmContextEngine implements ContextEngine {
1826
2415
  conversationId: number;
1827
2416
  historicalMessages: AgentMessage[];
1828
2417
  }): Promise<{
2418
+ blockedByImportCap: boolean;
1829
2419
  importedMessages: number;
1830
2420
  hasOverlap: boolean;
1831
2421
  }> {
1832
2422
  const { sessionId, conversationId, historicalMessages } = params;
1833
2423
  if (historicalMessages.length === 0) {
1834
- return { importedMessages: 0, hasOverlap: false };
2424
+ return { blockedByImportCap: false, importedMessages: 0, hasOverlap: false };
1835
2425
  }
1836
2426
 
1837
2427
  const latestDbMessage = await this.conversationStore.getLastMessage(conversationId);
1838
2428
  if (!latestDbMessage) {
1839
- return { importedMessages: 0, hasOverlap: false };
2429
+ return { blockedByImportCap: false, importedMessages: 0, hasOverlap: false };
1840
2430
  }
1841
2431
 
1842
2432
  const storedHistoricalMessages = historicalMessages.map((message) => toStoredMessage(message));
@@ -1857,7 +2447,7 @@ export class LcmContextEngine implements ContextEngine {
1857
2447
  }
1858
2448
  }
1859
2449
  if (dbOccurrences === historicalOccurrences) {
1860
- return { importedMessages: 0, hasOverlap: true };
2450
+ return { blockedByImportCap: false, importedMessages: 0, hasOverlap: true };
1861
2451
  }
1862
2452
  }
1863
2453
 
@@ -1909,13 +2499,27 @@ export class LcmContextEngine implements ContextEngine {
1909
2499
  }
1910
2500
 
1911
2501
  if (anchorIndex < 0) {
1912
- return { importedMessages: 0, hasOverlap: false };
2502
+ return { blockedByImportCap: false, importedMessages: 0, hasOverlap: false };
1913
2503
  }
1914
2504
  if (anchorIndex >= historicalMessages.length - 1) {
1915
- return { importedMessages: 0, hasOverlap: true };
2505
+ return { blockedByImportCap: false, importedMessages: 0, hasOverlap: true };
1916
2506
  }
1917
2507
 
1918
2508
  const missingTail = historicalMessages.slice(anchorIndex + 1);
2509
+
2510
+ const existingDbCount = await this.conversationStore.getMessageCount(conversationId);
2511
+ if (existingDbCount > 0 && missingTail.length > Math.max(existingDbCount * 0.2, 50)) {
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
+ );
2520
+ return { blockedByImportCap: true, importedMessages: 0, hasOverlap: true };
2521
+ }
2522
+
1919
2523
  let importedMessages = 0;
1920
2524
  for (const message of missingTail) {
1921
2525
  const result = await this.ingestSingle({ sessionId, sessionKey: params.sessionKey, message });
@@ -1924,7 +2528,38 @@ export class LcmContextEngine implements ContextEngine {
1924
2528
  }
1925
2529
  }
1926
2530
 
1927
- return { importedMessages, hasOverlap: true };
2531
+ return { blockedByImportCap: false, importedMessages, hasOverlap: true };
2532
+ }
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
+ });
1928
2563
  }
1929
2564
 
1930
2565
  async bootstrap(params: {
@@ -1957,19 +2592,14 @@ export class LcmContextEngine implements ContextEngine {
1957
2592
  this.conversationStore.withTransaction(async () => {
1958
2593
  const persistBootstrapState = async (
1959
2594
  conversationId: number,
1960
- historicalMessages: AgentMessage[],
1961
2595
  ): Promise<void> => {
1962
- const lastMessage =
1963
- historicalMessages.length > 0
1964
- ? toStoredMessage(historicalMessages[historicalMessages.length - 1]!)
1965
- : null;
1966
- await this.summaryStore.upsertConversationBootstrapState({
2596
+ await this.refreshBootstrapState({
1967
2597
  conversationId,
1968
- sessionFilePath: params.sessionFile,
1969
- lastSeenSize: sessionFileSize,
1970
- lastSeenMtimeMs: sessionFileMtimeMs,
1971
- lastProcessedOffset: sessionFileSize,
1972
- lastProcessedEntryHash: createBootstrapEntryHash(lastMessage),
2598
+ sessionFile: params.sessionFile,
2599
+ fileStats: {
2600
+ size: sessionFileSize,
2601
+ mtimeMs: sessionFileMtimeMs,
2602
+ },
1973
2603
  });
1974
2604
  };
1975
2605
 
@@ -2019,6 +2649,8 @@ export class LcmContextEngine implements ContextEngine {
2019
2649
  const tailEntryRaw = readLastJsonlEntryBeforeOffset(
2020
2650
  params.sessionFile,
2021
2651
  bootstrapState.lastProcessedOffset,
2652
+ true,
2653
+ (message) => createBootstrapEntryHash(toStoredMessage(message)) === latestDbHash,
2022
2654
  );
2023
2655
  const tailEntryMessage = readBootstrapMessageFromJsonLine(tailEntryRaw);
2024
2656
  const tailEntryHash = tailEntryMessage
@@ -2052,14 +2684,7 @@ export class LcmContextEngine implements ContextEngine {
2052
2684
  }
2053
2685
  }
2054
2686
 
2055
- const lastAppendedMessage =
2056
- appended.messages.length > 0
2057
- ? appended.messages[appended.messages.length - 1]!
2058
- : tailEntryMessage;
2059
- await persistBootstrapState(
2060
- conversationId,
2061
- lastAppendedMessage ? [lastAppendedMessage] : [],
2062
- );
2687
+ await persistBootstrapState(conversationId);
2063
2688
 
2064
2689
  if (importedMessages > 0) {
2065
2690
  return {
@@ -2090,7 +2715,7 @@ export class LcmContextEngine implements ContextEngine {
2090
2715
 
2091
2716
  if (bootstrapMessages.length === 0) {
2092
2717
  await this.conversationStore.markConversationBootstrapped(conversationId);
2093
- await persistBootstrapState(conversationId, historicalMessages);
2718
+ await persistBootstrapState(conversationId);
2094
2719
  return {
2095
2720
  bootstrapped: false,
2096
2721
  importedMessages: 0,
@@ -2116,18 +2741,19 @@ export class LcmContextEngine implements ContextEngine {
2116
2741
  inserted.map((record) => record.messageId),
2117
2742
  );
2118
2743
  await this.conversationStore.markConversationBootstrapped(conversationId);
2119
- await persistBootstrapState(conversationId, historicalMessages);
2120
2744
 
2121
2745
  // Prune HEARTBEAT_OK turns from the freshly imported data
2122
2746
  if (this.config.pruneHeartbeatOk) {
2123
2747
  const pruned = await this.pruneHeartbeatOkTurns(conversationId);
2124
2748
  if (pruned > 0) {
2125
- console.error(
2749
+ this.deps.log.info(
2126
2750
  `[lcm] bootstrap: pruned ${pruned} HEARTBEAT_OK messages from conversation ${conversationId}`,
2127
2751
  );
2128
2752
  }
2129
2753
  }
2130
2754
 
2755
+ await persistBootstrapState(conversationId);
2756
+
2131
2757
  return {
2132
2758
  bootstrapped: true,
2133
2759
  importedMessages: inserted.length,
@@ -2143,12 +2769,20 @@ export class LcmContextEngine implements ContextEngine {
2143
2769
  historicalMessages,
2144
2770
  });
2145
2771
 
2772
+ if (reconcile.blockedByImportCap) {
2773
+ return {
2774
+ bootstrapped: false,
2775
+ importedMessages: 0,
2776
+ reason: "reconcile import capped",
2777
+ };
2778
+ }
2779
+
2146
2780
  if (!conversation.bootstrappedAt) {
2147
2781
  await this.conversationStore.markConversationBootstrapped(conversationId);
2148
2782
  }
2149
2783
 
2150
2784
  if (reconcile.importedMessages > 0) {
2151
- await persistBootstrapState(conversationId, historicalMessages);
2785
+ await persistBootstrapState(conversationId);
2152
2786
  return {
2153
2787
  bootstrapped: true,
2154
2788
  importedMessages: reconcile.importedMessages,
@@ -2157,7 +2791,7 @@ export class LcmContextEngine implements ContextEngine {
2157
2791
  }
2158
2792
 
2159
2793
  if (reconcile.hasOverlap) {
2160
- await persistBootstrapState(conversationId, historicalMessages);
2794
+ await persistBootstrapState(conversationId);
2161
2795
  }
2162
2796
 
2163
2797
  if (conversation.bootstrappedAt) {
@@ -2189,15 +2823,18 @@ export class LcmContextEngine implements ContextEngine {
2189
2823
  if (conversation) {
2190
2824
  const pruned = await this.pruneHeartbeatOkTurns(conversation.conversationId);
2191
2825
  if (pruned > 0) {
2192
- console.error(
2826
+ await this.refreshBootstrapState({
2827
+ conversationId: conversation.conversationId,
2828
+ sessionFile: params.sessionFile,
2829
+ });
2830
+ this.deps.log.info(
2193
2831
  `[lcm] bootstrap: retroactively pruned ${pruned} HEARTBEAT_OK messages from conversation ${conversation.conversationId}`,
2194
2832
  );
2195
2833
  }
2196
2834
  }
2197
2835
  } catch (err) {
2198
- console.error(
2199
- `[lcm] bootstrap: heartbeat pruning failed:`,
2200
- err instanceof Error ? err.message : err,
2836
+ this.deps.log.warn(
2837
+ `[lcm] bootstrap: heartbeat pruning failed: ${describeLogError(err)}`,
2201
2838
  );
2202
2839
  }
2203
2840
  }
@@ -2405,9 +3042,24 @@ export class LcmContextEngine implements ContextEngine {
2405
3042
  };
2406
3043
  }
2407
3044
 
2408
- return params.runtimeContext.rewriteTranscriptEntries({
3045
+ const result = await params.runtimeContext.rewriteTranscriptEntries({
2409
3046
  replacements,
2410
3047
  });
3048
+
3049
+ if (result.changed) {
3050
+ try {
3051
+ await this.refreshBootstrapState({
3052
+ conversationId: conversation.conversationId,
3053
+ sessionFile: params.sessionFile,
3054
+ });
3055
+ } catch (e) {
3056
+ this.deps.log.warn(
3057
+ `[lcm] Failed to update bootstrap checkpoint after maintain: ${describeLogError(e)}`,
3058
+ );
3059
+ }
3060
+ }
3061
+
3062
+ return result;
2411
3063
  },
2412
3064
  );
2413
3065
  }
@@ -2594,9 +3246,8 @@ export class LcmContextEngine implements ContextEngine {
2594
3246
  });
2595
3247
  } catch (err) {
2596
3248
  // Never compact a stale or partially ingested frontier.
2597
- console.error(
2598
- `[lcm] afterTurn: ingest failed, skipping compaction:`,
2599
- err instanceof Error ? err.message : err,
3249
+ this.deps.log.error(
3250
+ `[lcm] afterTurn: ingest failed, skipping compaction: ${describeLogError(err)}`,
2600
3251
  );
2601
3252
  return;
2602
3253
  }
@@ -2610,16 +3261,30 @@ export class LcmContextEngine implements ContextEngine {
2610
3261
  if (conversation) {
2611
3262
  const pruned = await this.pruneHeartbeatOkTurns(conversation.conversationId);
2612
3263
  if (pruned > 0) {
2613
- console.error(
2614
- `[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}`,
2615
3281
  );
2616
3282
  return;
2617
3283
  }
2618
3284
  }
2619
3285
  } catch (err) {
2620
- console.error(
2621
- `[lcm] afterTurn: heartbeat pruning failed:`,
2622
- err instanceof Error ? err.message : err,
3286
+ this.deps.log.warn(
3287
+ `[lcm] afterTurn: heartbeat pruning failed: ${describeLogError(err)}`,
2623
3288
  );
2624
3289
  }
2625
3290
  }
@@ -2633,16 +3298,41 @@ export class LcmContextEngine implements ContextEngine {
2633
3298
  });
2634
3299
  const tokenBudget = this.applyAssemblyBudgetCap(resolvedTokenBudget ?? DEFAULT_AFTER_TURN_TOKEN_BUDGET);
2635
3300
  if (resolvedTokenBudget === undefined) {
2636
- console.warn(
3301
+ this.deps.log.warn(
2637
3302
  `[lcm] afterTurn: tokenBudget not provided; using default ${DEFAULT_AFTER_TURN_TOKEN_BUDGET}`,
2638
3303
  );
2639
3304
  }
2640
3305
 
2641
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
+ }
3314
+
3315
+ try {
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
+ }
2642
3328
 
2643
3329
  try {
2644
- const leafTrigger = await this.evaluateLeafTrigger(params.sessionId, params.sessionKey);
2645
- if (leafTrigger.shouldCompact) {
3330
+ const leafDecision = await this.evaluateIncrementalCompaction({
3331
+ conversationId: conversation.conversationId,
3332
+ tokenBudget,
3333
+ currentTokenCount: liveContextTokens,
3334
+ });
3335
+ if (leafDecision.shouldCompact) {
2646
3336
  this.compactLeafAsync({
2647
3337
  sessionId: params.sessionId,
2648
3338
  sessionKey: params.sessionKey,
@@ -2650,6 +3340,11 @@ export class LcmContextEngine implements ContextEngine {
2650
3340
  tokenBudget,
2651
3341
  currentTokenCount: liveContextTokens,
2652
3342
  legacyParams,
3343
+ maxPasses: leafDecision.maxPasses,
3344
+ leafChunkTokens: leafDecision.leafChunkTokens,
3345
+ fallbackLeafChunkTokens: leafDecision.fallbackLeafChunkTokens,
3346
+ activityBand: leafDecision.activityBand,
3347
+ allowCondensedPasses: leafDecision.allowCondensedPasses,
2653
3348
  }).catch(() => {
2654
3349
  // Leaf compaction is best-effort and should not fail the caller.
2655
3350
  });
@@ -2787,7 +3482,7 @@ export class LcmContextEngine implements ContextEngine {
2787
3482
  return this.compaction.evaluateLeafTrigger(conversation.conversationId);
2788
3483
  }
2789
3484
 
2790
- /** 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. */
2791
3486
  async compactLeafAsync(params: {
2792
3487
  sessionId: string;
2793
3488
  sessionKey?: string;
@@ -2801,6 +3496,11 @@ export class LcmContextEngine implements ContextEngine {
2801
3496
  legacyParams?: Record<string, unknown>;
2802
3497
  force?: boolean;
2803
3498
  previousSummaryContent?: string;
3499
+ maxPasses?: number;
3500
+ leafChunkTokens?: number;
3501
+ fallbackLeafChunkTokens?: number[];
3502
+ activityBand?: ActivityBand;
3503
+ allowCondensedPasses?: boolean;
2804
3504
  }): Promise<CompactResult> {
2805
3505
  if (this.isStatelessSession(params.sessionKey)) {
2806
3506
  return {
@@ -2864,38 +3564,114 @@ export class LcmContextEngine implements ContextEngine {
2864
3564
  };
2865
3565
  }
2866
3566
 
2867
- const leafResult = await this.compaction.compactLeaf({
2868
- conversationId: conversation.conversationId,
2869
- tokenBudget,
2870
- summarize,
2871
- force: params.force,
2872
- previousSummaryContent: params.previousSummaryContent,
2873
- summaryModel,
2874
- });
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;
3595
+
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
+ }
2875
3641
 
2876
- if (leafResult.authFailure && breakerKey) {
3642
+ if (authFailure && breakerKey) {
2877
3643
  this.recordCompactionAuthFailure(breakerKey);
2878
- } else if (leafResult.actionTaken && breakerKey) {
3644
+ } else if (rounds > 0 && breakerKey) {
2879
3645
  this.recordCompactionSuccess(breakerKey);
2880
3646
  }
3647
+ if (rounds > 0) {
3648
+ await this.markLeafCompactionTelemetrySuccess({
3649
+ conversationId: conversation.conversationId,
3650
+ activityBand: params.activityBand,
3651
+ });
3652
+ }
2881
3653
 
2882
- 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
+ );
2883
3658
 
2884
3659
  return {
2885
3660
  ok: true,
2886
- compacted: leafResult.actionTaken,
2887
- reason: leafResult.authFailure
3661
+ compacted: rounds > 0,
3662
+ reason: authFailure
2888
3663
  ? "provider auth failure"
2889
- : leafResult.actionTaken
3664
+ : rounds > 0
2890
3665
  ? "compacted"
2891
3666
  : "below threshold",
2892
3667
  result: {
2893
3668
  tokensBefore,
2894
- tokensAfter: leafResult.tokensAfter,
3669
+ tokensAfter: finalTokens,
2895
3670
  details: {
2896
- rounds: leafResult.actionTaken ? 1 : 0,
3671
+ rounds,
2897
3672
  targetTokens: tokenBudget,
2898
3673
  mode: "leaf",
3674
+ maxPasses,
2899
3675
  },
2900
3676
  },
2901
3677
  };
@@ -3024,7 +3800,7 @@ export class LcmContextEngine implements ContextEngine {
3024
3800
  // overflow counts can drive recovery even when persisted context is already small.
3025
3801
  const useSweep = manualCompactionRequested || params.compactionTarget === "threshold";
3026
3802
  if (useSweep) {
3027
- const sweepResult = await this.compaction.compactFullSweep({
3803
+ const sweepResult = await this.compaction.compact({
3028
3804
  conversationId,
3029
3805
  tokenBudget,
3030
3806
  summarize,
@@ -3038,6 +3814,9 @@ export class LcmContextEngine implements ContextEngine {
3038
3814
  } else if (sweepResult.actionTaken && breakerKey) {
3039
3815
  this.recordCompactionSuccess(breakerKey);
3040
3816
  }
3817
+ if (sweepResult.actionTaken) {
3818
+ await this.markLeafCompactionTelemetrySuccess({ conversationId });
3819
+ }
3041
3820
 
3042
3821
  return {
3043
3822
  ok: !sweepResult.authFailure && (sweepResult.actionTaken || !liveContextStillExceedsTarget),
@@ -3087,6 +3866,9 @@ export class LcmContextEngine implements ContextEngine {
3087
3866
  }
3088
3867
 
3089
3868
  const didCompact = compactResult.rounds > 0;
3869
+ if (didCompact) {
3870
+ await this.markLeafCompactionTelemetrySuccess({ conversationId });
3871
+ }
3090
3872
 
3091
3873
  return {
3092
3874
  ok: compactResult.success,
@@ -3219,6 +4001,69 @@ export class LcmContextEngine implements ContextEngine {
3219
4001
  // The shared connection is managed for the lifetime of the plugin process.
3220
4002
  }
3221
4003
 
4004
+ /** Detect the empty replacement row created during a prior lifecycle rollover. */
4005
+ private async isFreshLifecycleConversation(conversation: ConversationRecord): Promise<boolean> {
4006
+ const currentMessageCount = await this.conversationStore.getMessageCount(conversation.conversationId);
4007
+ if (currentMessageCount !== 0) {
4008
+ return false;
4009
+ }
4010
+ const currentContextItems = await this.summaryStore.getContextItems(conversation.conversationId);
4011
+ return currentContextItems.length === 0 && !conversation.bootstrappedAt;
4012
+ }
4013
+
4014
+ /**
4015
+ * Archive the current active conversation and optionally create the replacement
4016
+ * row that bootstrap should attach to for the next session transcript.
4017
+ */
4018
+ private async applySessionReplacement(params: {
4019
+ reason: string;
4020
+ sessionId?: string;
4021
+ sessionKey?: string;
4022
+ nextSessionId?: string;
4023
+ nextSessionKey?: string;
4024
+ createReplacement: boolean;
4025
+ createReplacementWhenMissing?: boolean;
4026
+ }): Promise<void> {
4027
+ const current = await this.conversationStore.getConversationForSession({
4028
+ sessionId: params.sessionId,
4029
+ sessionKey: params.sessionKey,
4030
+ });
4031
+ if (!current && !params.createReplacementWhenMissing) {
4032
+ return;
4033
+ }
4034
+
4035
+ if (current?.active) {
4036
+ if (params.createReplacement && await this.isFreshLifecycleConversation(current)) {
4037
+ this.deps.log.info(
4038
+ `[lcm] ${params.reason} lifecycle no-op for already fresh conversation ${current.conversationId}`,
4039
+ );
4040
+ return;
4041
+ }
4042
+ await this.conversationStore.archiveConversation(current.conversationId);
4043
+ }
4044
+
4045
+ if (!params.createReplacement) {
4046
+ this.deps.log.info(
4047
+ `[lcm] ${params.reason} lifecycle archived conversation ${current?.conversationId ?? "(none)"}`,
4048
+ );
4049
+ return;
4050
+ }
4051
+
4052
+ const nextSessionId = params.nextSessionId?.trim() || params.sessionId?.trim() || current?.sessionId;
4053
+ if (!nextSessionId) {
4054
+ this.deps.log.warn(`[lcm] ${params.reason} lifecycle skipped: no session identity available`);
4055
+ return;
4056
+ }
4057
+ const nextSessionKey = params.nextSessionKey?.trim() || params.sessionKey?.trim() || current?.sessionKey;
4058
+ const freshConversation = await this.conversationStore.createConversation({
4059
+ sessionId: nextSessionId,
4060
+ ...(nextSessionKey ? { sessionKey: nextSessionKey } : {}),
4061
+ });
4062
+ this.deps.log.info(
4063
+ `[lcm] ${params.reason} lifecycle archived prior conversation and created ${freshConversation.conversationId}`,
4064
+ );
4065
+ }
4066
+
3222
4067
  /** Apply LCM lifecycle semantics for OpenClaw's /new and /reset commands. */
3223
4068
  async handleBeforeReset(params: {
3224
4069
  reason?: string;
@@ -3261,44 +4106,50 @@ export class LcmContextEngine implements ContextEngine {
3261
4106
  );
3262
4107
  return;
3263
4108
  }
3264
-
3265
- const current = await this.conversationStore.getConversationForSession({
4109
+ await this.applySessionReplacement({
4110
+ reason: "/reset",
3266
4111
  sessionId: params.sessionId,
3267
4112
  sessionKey: params.sessionKey,
4113
+ createReplacement: true,
4114
+ createReplacementWhenMissing: true,
3268
4115
  });
3269
- if (current?.active) {
3270
- const currentMessageCount = await this.conversationStore.getMessageCount(
3271
- current.conversationId,
3272
- );
3273
- const currentContextItems = await this.summaryStore.getContextItems(
3274
- current.conversationId,
3275
- );
3276
- if (
3277
- currentMessageCount === 0
3278
- && currentContextItems.length === 0
3279
- && !current.bootstrappedAt
3280
- ) {
3281
- this.deps.log.info(
3282
- `[lcm] /reset no-op for already fresh conversation ${current.conversationId}`,
3283
- );
3284
- return;
3285
- }
3286
- await this.conversationStore.archiveConversation(current.conversationId);
3287
- }
4116
+ }),
4117
+ );
4118
+ }
3288
4119
 
3289
- const nextSessionId = params.sessionId?.trim() || current?.sessionId;
3290
- if (!nextSessionId) {
3291
- this.deps.log.warn("[lcm] /reset skipped: no session identity available");
3292
- return;
3293
- }
4120
+ /** Apply generic lifecycle semantics for session rollover and deletion hooks. */
4121
+ async handleSessionEnd(params: {
4122
+ reason?: string;
4123
+ sessionId?: string;
4124
+ sessionKey?: string;
4125
+ nextSessionId?: string;
4126
+ nextSessionKey?: string;
4127
+ }): Promise<void> {
4128
+ const reason = params.reason?.trim();
4129
+ if (!reason || reason === "new" || reason === "unknown") {
4130
+ return;
4131
+ }
4132
+ if (this.shouldIgnoreSession({ sessionId: params.sessionId, sessionKey: params.sessionKey })) {
4133
+ return;
4134
+ }
4135
+ if (this.isStatelessSession(params.sessionKey ?? params.nextSessionKey)) {
4136
+ return;
4137
+ }
3294
4138
 
3295
- const freshConversation = await this.conversationStore.createConversation({
3296
- sessionId: nextSessionId,
3297
- sessionKey: params.sessionKey?.trim(),
4139
+ const createReplacement = reason !== "deleted";
4140
+ this.ensureMigrated();
4141
+ await this.withSessionQueue(
4142
+ this.resolveSessionQueueKey(params.nextSessionId ?? params.sessionId, params.sessionKey ?? params.nextSessionKey),
4143
+ async () =>
4144
+ this.conversationStore.withTransaction(async () => {
4145
+ await this.applySessionReplacement({
4146
+ reason: `session_end:${reason}`,
4147
+ sessionId: params.sessionId,
4148
+ sessionKey: params.sessionKey ?? params.nextSessionKey,
4149
+ nextSessionId: params.nextSessionId,
4150
+ nextSessionKey: params.nextSessionKey,
4151
+ createReplacement,
3298
4152
  });
3299
- this.deps.log.info(
3300
- `[lcm] /reset archived prior conversation and created ${freshConversation.conversationId}`,
3301
- );
3302
4153
  }),
3303
4154
  );
3304
4155
  }
@@ -3317,6 +4168,10 @@ export class LcmContextEngine implements ContextEngine {
3317
4168
  return this.summaryStore;
3318
4169
  }
3319
4170
 
4171
+ getCompactionTelemetryStore(): CompactionTelemetryStore {
4172
+ return this.compactionTelemetryStore;
4173
+ }
4174
+
3320
4175
  // ── Heartbeat pruning ──────────────────────────────────────────────────
3321
4176
 
3322
4177
  /**
@@ -3446,3 +4301,6 @@ function createEmergencyFallbackSummarize(): (
3446
4301
  return text.slice(0, maxChars) + "\n[Truncated for context management]";
3447
4302
  };
3448
4303
  }
4304
+
4305
+ /** @internal Exposed for unit tests only. */
4306
+ export const __testing = { readLastJsonlEntryBeforeOffset };