@martian-engineering/lossless-claw 0.5.3 → 0.6.1

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
@@ -5,6 +5,7 @@ import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import type { DatabaseSync } from "node:sqlite";
7
7
  import { createInterface } from "node:readline";
8
+ import { SessionManager } from "@mariozechner/pi-coding-agent";
8
9
  import type {
9
10
  ContextEngine,
10
11
  ContextEngineInfo,
@@ -16,7 +17,14 @@ import type {
16
17
  SubagentEndReason,
17
18
  SubagentSpawnPreparation,
18
19
  } from "openclaw/plugin-sdk";
19
- import { blockFromPart, ContextAssembler } from "./assembler.js";
20
+ import {
21
+ blockFromPart,
22
+ contentFromParts,
23
+ ContextAssembler,
24
+ pickToolCallId,
25
+ pickToolIsError,
26
+ pickToolName,
27
+ } from "./assembler.js";
20
28
  import { CompactionEngine, type CompactionConfig } from "./compaction.js";
21
29
  import type { LcmConfig } from "./db/config.js";
22
30
  import { getLcmDbFeatures } from "./db/features.js";
@@ -50,6 +58,30 @@ import type { LcmDependencies } from "./types.js";
50
58
 
51
59
  type AgentMessage = Parameters<ContextEngine["ingest"]>[0]["message"];
52
60
  type AssembleResultWithSystemPrompt = AssembleResult & { systemPromptAddition?: string };
61
+ type CircuitBreakerState = {
62
+ failures: number;
63
+ openSince: number | null;
64
+ };
65
+ type TranscriptRewriteReplacement = {
66
+ entryId: string;
67
+ message: AgentMessage;
68
+ };
69
+ type TranscriptRewriteRequest = {
70
+ replacements: TranscriptRewriteReplacement[];
71
+ };
72
+ type ContextEngineMaintenanceResult = {
73
+ changed: boolean;
74
+ bytesFreed: number;
75
+ rewrittenEntries: number;
76
+ reason?: string;
77
+ };
78
+ type ContextEngineMaintenanceRuntimeContext = Record<string, unknown> & {
79
+ rewriteTranscriptEntries?: (
80
+ request: TranscriptRewriteRequest,
81
+ ) => Promise<ContextEngineMaintenanceResult>;
82
+ };
83
+
84
+ const TRANSCRIPT_GC_BATCH_SIZE = 12;
53
85
 
54
86
  // ── Helpers ──────────────────────────────────────────────────────────────────
55
87
 
@@ -77,6 +109,71 @@ function safeBoolean(value: unknown): boolean | undefined {
77
109
  return typeof value === "boolean" ? value : undefined;
78
110
  }
79
111
 
112
+ function extractTranscriptToolCallId(message: AgentMessage): string | undefined {
113
+ const topLevel = message as Record<string, unknown>;
114
+ const direct =
115
+ safeString(topLevel.toolCallId) ??
116
+ safeString(topLevel.tool_call_id) ??
117
+ safeString(topLevel.toolUseId) ??
118
+ safeString(topLevel.tool_use_id) ??
119
+ safeString(topLevel.call_id) ??
120
+ safeString(topLevel.id);
121
+ if (direct) {
122
+ return direct;
123
+ }
124
+
125
+ if (!Array.isArray(topLevel.content)) {
126
+ return undefined;
127
+ }
128
+
129
+ for (const item of topLevel.content) {
130
+ const record = asRecord(item);
131
+ if (!record) {
132
+ continue;
133
+ }
134
+ const nested =
135
+ safeString(record.toolCallId) ??
136
+ safeString(record.tool_call_id) ??
137
+ safeString(record.toolUseId) ??
138
+ safeString(record.tool_use_id) ??
139
+ safeString(record.call_id) ??
140
+ safeString(record.id);
141
+ if (nested) {
142
+ return nested;
143
+ }
144
+ }
145
+
146
+ return undefined;
147
+ }
148
+
149
+ function listTranscriptToolResultEntryIdsByCallId(sessionFile: string): Map<string, string> {
150
+ const sessionManager = SessionManager.open(sessionFile);
151
+ const branch = sessionManager.getBranch();
152
+ const entryIdsByCallId = new Map<string, string>();
153
+ const duplicateCallIds = new Set<string>();
154
+
155
+ for (const entry of branch) {
156
+ if (entry.type !== "message" || entry.message.role !== "toolResult") {
157
+ continue;
158
+ }
159
+ const toolCallId = extractTranscriptToolCallId(entry.message as AgentMessage);
160
+ if (!toolCallId) {
161
+ continue;
162
+ }
163
+ if (entryIdsByCallId.has(toolCallId)) {
164
+ duplicateCallIds.add(toolCallId);
165
+ continue;
166
+ }
167
+ entryIdsByCallId.set(toolCallId, entry.id);
168
+ }
169
+
170
+ for (const duplicateCallId of duplicateCallIds) {
171
+ entryIdsByCallId.delete(duplicateCallId);
172
+ }
173
+
174
+ return entryIdsByCallId;
175
+ }
176
+
80
177
  function appendTextValue(value: unknown, out: string[]): void {
81
178
  if (typeof value === "string") {
82
179
  out.push(value);
@@ -535,7 +632,15 @@ function buildMessageParts(params: {
535
632
  for (let ordinal = 0; ordinal < message.content.length; ordinal++) {
536
633
  const block = normalizeUnknownBlock(message.content[ordinal]);
537
634
  const metadataRecord = block.metadata.raw as Record<string, unknown> | undefined;
538
- const partType = toPartType(block.type);
635
+ const rawBlockType = safeString(metadataRecord?.rawType) ?? block.type;
636
+ const partType = toPartType(rawBlockType);
637
+ const rawBlock =
638
+ metadataRecord && rawBlockType !== block.type
639
+ ? {
640
+ ...metadataRecord,
641
+ type: rawBlockType,
642
+ }
643
+ : (metadataRecord ?? message.content[ordinal]);
539
644
  const toolCallId =
540
645
  safeString(metadataRecord?.toolCallId) ??
541
646
  safeString(metadataRecord?.tool_call_id) ??
@@ -582,8 +687,8 @@ function buildMessageParts(params: {
582
687
  : undefined,
583
688
  toolOutputExternalized: safeBoolean(metadataRecord?.toolOutputExternalized),
584
689
  externalizationReason: safeString(metadataRecord?.externalizationReason),
585
- rawType: block.type,
586
- raw: metadataRecord ?? message.content[ordinal],
690
+ rawType: rawBlockType,
691
+ raw: rawBlock,
587
692
  }),
588
693
  });
589
694
  }
@@ -826,6 +931,70 @@ async function readLeafPathMessages(sessionFile: string): Promise<AgentMessage[]
826
931
  }
827
932
  }
828
933
 
934
+ /**
935
+ * Resolve the first-time bootstrap token budget.
936
+ *
937
+ * When unset, bootstrap keeps a modest suffix of the parent session rather than
938
+ * inheriting the full raw history into a brand-new conversation.
939
+ */
940
+ function resolveBootstrapMaxTokens(config: Pick<LcmConfig, "bootstrapMaxTokens" | "leafChunkTokens">): number {
941
+ if (
942
+ typeof config.bootstrapMaxTokens === "number" &&
943
+ Number.isFinite(config.bootstrapMaxTokens) &&
944
+ config.bootstrapMaxTokens > 0
945
+ ) {
946
+ return Math.floor(config.bootstrapMaxTokens);
947
+ }
948
+
949
+ const leafChunkTokens =
950
+ typeof config.leafChunkTokens === "number" &&
951
+ Number.isFinite(config.leafChunkTokens) &&
952
+ config.leafChunkTokens > 0
953
+ ? Math.floor(config.leafChunkTokens)
954
+ : 20_000;
955
+ return Math.max(6000, Math.floor(leafChunkTokens * 0.3));
956
+ }
957
+
958
+ /**
959
+ * Keep only the newest bootstrap messages that fit within the token budget.
960
+ *
961
+ * The newest message is always preserved so a fork never starts empty when the
962
+ * parent transcript has any recoverable content at all.
963
+ */
964
+ function trimBootstrapMessagesToBudget(messages: AgentMessage[], maxTokens: number): AgentMessage[] {
965
+ if (messages.length === 0) {
966
+ return [];
967
+ }
968
+
969
+ const safeMaxTokens = Number.isFinite(maxTokens) ? Math.floor(maxTokens) : 0;
970
+ if (safeMaxTokens <= 0) {
971
+ return [messages[messages.length - 1]!];
972
+ }
973
+
974
+ const kept: AgentMessage[] = [];
975
+ let totalTokens = 0;
976
+
977
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
978
+ const message = messages[index]!;
979
+ const tokenCount = toStoredMessage(message).tokenCount;
980
+ if (kept.length > 0 && totalTokens + tokenCount > safeMaxTokens) {
981
+ break;
982
+ }
983
+ kept.push(message);
984
+ totalTokens += tokenCount;
985
+ }
986
+
987
+ // If a single oversized tail message exceeds the budget, return empty
988
+ // rather than silently bypassing the budget cap. An empty bootstrap is
989
+ // safer than an exploding one.
990
+ if (kept.length === 1 && totalTokens > safeMaxTokens) {
991
+ return [];
992
+ }
993
+
994
+ kept.reverse();
995
+ return kept;
996
+ }
997
+
829
998
  function readFileSegment(sessionFile: string, offset: number): string | null {
830
999
  let fd: number | null = null;
831
1000
  try {
@@ -975,6 +1144,9 @@ export class LcmContextEngine implements ContextEngine {
975
1144
  private largeFileTextSummarizer?: (prompt: string) => Promise<string | null>;
976
1145
  private deps: LcmDependencies;
977
1146
 
1147
+ // ── Circuit breaker for compaction auth failures ──
1148
+ private circuitBreakerStates = new Map<string, CircuitBreakerState>();
1149
+
978
1150
  constructor(deps: LcmDependencies, database: DatabaseSync) {
979
1151
  this.deps = deps;
980
1152
  this.config = deps.config;
@@ -1111,6 +1283,56 @@ export class LcmContextEngine implements ContextEngine {
1111
1283
  return matchesSessionPattern(trimmedKey, this.statelessSessionPatterns);
1112
1284
  }
1113
1285
 
1286
+ // ── Circuit breaker helpers ──────────────────────────────────────────────
1287
+
1288
+ private getCircuitBreakerState(key: string): CircuitBreakerState {
1289
+ let state = this.circuitBreakerStates.get(key);
1290
+ if (!state) {
1291
+ state = { failures: 0, openSince: null };
1292
+ this.circuitBreakerStates.set(key, state);
1293
+ }
1294
+ return state;
1295
+ }
1296
+
1297
+ private isCircuitBreakerOpen(key: string): boolean {
1298
+ const state = this.circuitBreakerStates.get(key);
1299
+ if (!state || state.openSince === null) return false;
1300
+ const elapsed = Date.now() - state.openSince;
1301
+ if (elapsed >= this.config.circuitBreakerCooldownMs) {
1302
+ this.resetCircuitBreaker(key);
1303
+ return false;
1304
+ }
1305
+ return true;
1306
+ }
1307
+
1308
+ private recordCompactionAuthFailure(key: string): void {
1309
+ const state = this.getCircuitBreakerState(key);
1310
+ state.failures++;
1311
+ if (state.failures >= this.config.circuitBreakerThreshold) {
1312
+ 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.`,
1315
+ );
1316
+ }
1317
+ }
1318
+
1319
+ private recordCompactionSuccess(key: string): void {
1320
+ const state = this.circuitBreakerStates.get(key);
1321
+ if (!state) {
1322
+ return;
1323
+ }
1324
+ if (state.failures > 0 || state.openSince !== null) {
1325
+ console.error(
1326
+ `[lcm] compaction circuit breaker CLOSED: successful compaction for ${key} after ${state.failures} prior failures.`,
1327
+ );
1328
+ }
1329
+ this.resetCircuitBreaker(key);
1330
+ }
1331
+
1332
+ private resetCircuitBreaker(key: string): void {
1333
+ this.circuitBreakerStates.delete(key);
1334
+ }
1335
+
1114
1336
  /** Ensure DB schema is up-to-date. Called lazily on first bootstrap/ingest/assemble/compact. */
1115
1337
  private ensureMigrated(): void {
1116
1338
  if (this.migrated) {
@@ -1153,9 +1375,10 @@ export class LcmContextEngine implements ContextEngine {
1153
1375
  }
1154
1376
 
1155
1377
  /** Prefer stable session keys for queue serialization when available. */
1156
- private resolveSessionQueueKey(sessionId: string, sessionKey?: string): string {
1378
+ private resolveSessionQueueKey(sessionId?: string, sessionKey?: string): string {
1157
1379
  const normalizedSessionKey = sessionKey?.trim();
1158
- return normalizedSessionKey || sessionId;
1380
+ const normalizedSessionId = sessionId?.trim();
1381
+ return normalizedSessionKey || normalizedSessionId || "__lcm__";
1159
1382
  }
1160
1383
 
1161
1384
  /** Normalize optional live token estimates supplied by runtime callers. */
@@ -1229,12 +1452,18 @@ export class LcmContextEngine implements ContextEngine {
1229
1452
  private async resolveSummarize(params: {
1230
1453
  legacyParams?: Record<string, unknown>;
1231
1454
  customInstructions?: string;
1232
- }): Promise<{ summarize: (text: string, aggressive?: boolean) => Promise<string>; summaryModel: string }> {
1455
+ breakerScope: string;
1456
+ }): Promise<{
1457
+ summarize: (text: string, aggressive?: boolean) => Promise<string>;
1458
+ summaryModel: string;
1459
+ breakerKey?: string;
1460
+ }> {
1233
1461
  const lp = params.legacyParams ?? {};
1234
1462
  if (typeof lp.summarize === "function") {
1235
1463
  return {
1236
1464
  summarize: lp.summarize as (text: string, aggressive?: boolean) => Promise<string>,
1237
1465
  summaryModel: "unknown",
1466
+ breakerKey: `custom:${params.breakerScope}`,
1238
1467
  };
1239
1468
  }
1240
1469
  try {
@@ -1248,7 +1477,11 @@ export class LcmContextEngine implements ContextEngine {
1248
1477
  customInstructions,
1249
1478
  });
1250
1479
  if (runtimeSummarizer) {
1251
- return { summarize: runtimeSummarizer.fn, summaryModel: runtimeSummarizer.model };
1480
+ return {
1481
+ summarize: runtimeSummarizer.fn,
1482
+ summaryModel: runtimeSummarizer.model,
1483
+ breakerKey: runtimeSummarizer.breakerKey,
1484
+ };
1252
1485
  }
1253
1486
  console.error(`[lcm] resolveSummarize: createLcmSummarizeFromLegacyParams returned undefined`);
1254
1487
  } catch (err) {
@@ -1520,14 +1753,24 @@ export class LcmContextEngine implements ContextEngine {
1520
1753
 
1521
1754
  const normalizedRawType =
1522
1755
  rawType === "function_call_output" ? "function_call_output" : "tool_result";
1523
- const compactBlock: Record<string, unknown> = {
1524
- type: normalizedRawType,
1525
- output: externalized.reference,
1526
- externalizedFileId: externalized.fileId,
1527
- originalByteSize: externalized.byteSize,
1528
- toolOutputExternalized: true,
1529
- externalizationReason: "large_tool_result",
1530
- };
1756
+ const compactBlock: Record<string, unknown> = isPlainTextToolResult
1757
+ ? {
1758
+ type: "text",
1759
+ text: externalized.reference,
1760
+ rawType: normalizedRawType,
1761
+ externalizedFileId: externalized.fileId,
1762
+ originalByteSize: externalized.byteSize,
1763
+ toolOutputExternalized: true,
1764
+ externalizationReason: "large_tool_result",
1765
+ }
1766
+ : {
1767
+ type: normalizedRawType,
1768
+ output: externalized.reference,
1769
+ externalizedFileId: externalized.fileId,
1770
+ originalByteSize: externalized.byteSize,
1771
+ toolOutputExternalized: true,
1772
+ externalizationReason: "large_tool_result",
1773
+ };
1531
1774
  const callId =
1532
1775
  safeString(record.tool_use_id) ??
1533
1776
  safeString(record.toolUseId) ??
@@ -1840,7 +2083,12 @@ export class LcmContextEngine implements ContextEngine {
1840
2083
  // First-time import path: no LCM rows yet, so seed directly from the
1841
2084
  // active leaf context snapshot.
1842
2085
  if (existingCount === 0) {
1843
- if (historicalMessages.length === 0) {
2086
+ const bootstrapMessages = trimBootstrapMessagesToBudget(
2087
+ historicalMessages,
2088
+ resolveBootstrapMaxTokens(this.config),
2089
+ );
2090
+
2091
+ if (bootstrapMessages.length === 0) {
1844
2092
  await this.conversationStore.markConversationBootstrapped(conversationId);
1845
2093
  await persistBootstrapState(conversationId, historicalMessages);
1846
2094
  return {
@@ -1851,7 +2099,7 @@ export class LcmContextEngine implements ContextEngine {
1851
2099
  }
1852
2100
 
1853
2101
  const nextSeq = (await this.conversationStore.getMaxSeq(conversationId)) + 1;
1854
- const bulkInput = historicalMessages.map((message, index) => {
2102
+ const bulkInput = bootstrapMessages.map((message, index) => {
1855
2103
  const stored = toStoredMessage(message);
1856
2104
  return {
1857
2105
  conversationId,
@@ -1957,6 +2205,212 @@ export class LcmContextEngine implements ContextEngine {
1957
2205
  return result;
1958
2206
  }
1959
2207
 
2208
+ /**
2209
+ * Remove messages from the batch that already exist in the DB for this session.
2210
+ * Conservative replay detection: only strip a prefix when the incoming
2211
+ * batch begins with the entire stored transcript for the session.
2212
+ *
2213
+ * Fixes two issues from #246:
2214
+ * 1. Replaced hasMessage() fast-path with aligned-tail check — the old
2215
+ * approach false-positives on legitimate repeated first messages
2216
+ * 2. Dedup now runs on newMessages only, before autoCompactionSummary
2217
+ * is prepended — synthetic summaries can no longer interfere with
2218
+ * replay detection
2219
+ */
2220
+ private async deduplicateAfterTurnBatch(
2221
+ sessionId: string,
2222
+ sessionKey: string | undefined,
2223
+ batch: AgentMessage[],
2224
+ ): Promise<AgentMessage[]> {
2225
+ if (batch.length === 0) return batch;
2226
+
2227
+ const conversation = await this.conversationStore.getConversationForSession({
2228
+ sessionId,
2229
+ sessionKey,
2230
+ });
2231
+ if (!conversation) return batch;
2232
+
2233
+ const conversationId = conversation.conversationId;
2234
+ const storedMessageCount = await this.conversationStore.getMessageCount(conversationId);
2235
+ if (storedMessageCount === 0 || storedMessageCount > batch.length) {
2236
+ return batch;
2237
+ }
2238
+
2239
+ // Aligned-tail check: DB's last message must match the message at the
2240
+ // exact replay boundary in the incoming batch. This replaces the
2241
+ // hasMessage() check which could false-positive on any repeated content.
2242
+ const lastDbMessage = await this.conversationStore.getLastMessage(conversationId);
2243
+ if (!lastDbMessage) return batch;
2244
+
2245
+ const storedBatch = batch.map((m) => toStoredMessage(m));
2246
+ const batchAtBoundary = storedBatch[storedMessageCount - 1]!;
2247
+ if (
2248
+ messageIdentity(lastDbMessage.role, lastDbMessage.content) !==
2249
+ messageIdentity(batchAtBoundary.role, batchAtBoundary.content)
2250
+ ) {
2251
+ return batch;
2252
+ }
2253
+
2254
+ // Full proof: incoming batch must start with the entire stored transcript
2255
+ // in exact order before we trim anything.
2256
+ const storedMessages = await this.conversationStore.getMessages(conversationId, {
2257
+ limit: storedMessageCount,
2258
+ });
2259
+ if (storedMessages.length !== storedMessageCount) {
2260
+ return batch;
2261
+ }
2262
+ for (let i = 0; i < storedMessageCount; i += 1) {
2263
+ const storedConversationMessage = storedMessages[i]!;
2264
+ const incomingMessage = storedBatch[i]!;
2265
+ if (
2266
+ messageIdentity(storedConversationMessage.role, storedConversationMessage.content) !==
2267
+ messageIdentity(incomingMessage.role, incomingMessage.content)
2268
+ ) {
2269
+ return batch;
2270
+ }
2271
+ }
2272
+
2273
+ return batch.slice(storedMessageCount);
2274
+ }
2275
+ /**
2276
+ * Rebuild a compact tool-result message from stored message parts.
2277
+ *
2278
+ * The first transcript-GC pass only rewrites tool results that were already
2279
+ * externalized into large_files during ingest, so the stored placeholder is
2280
+ * the canonical replacement content.
2281
+ */
2282
+ private async buildTranscriptGcReplacementMessage(
2283
+ messageId: number,
2284
+ ): Promise<AgentMessage | null> {
2285
+ const message = await this.conversationStore.getMessageById(messageId);
2286
+ if (!message) {
2287
+ return null;
2288
+ }
2289
+
2290
+ const parts = await this.conversationStore.getMessageParts(messageId);
2291
+ const toolCallId = pickToolCallId(parts);
2292
+ if (!toolCallId) {
2293
+ return null;
2294
+ }
2295
+
2296
+ const content = contentFromParts(parts, "toolResult", message.content);
2297
+ const toolName = pickToolName(parts) ?? "unknown";
2298
+ const isError = pickToolIsError(parts);
2299
+
2300
+ return {
2301
+ role: "toolResult",
2302
+ toolCallId,
2303
+ toolName,
2304
+ content,
2305
+ ...(isError !== undefined ? { isError } : {}),
2306
+ } as AgentMessage;
2307
+ }
2308
+
2309
+ /**
2310
+ * Run transcript GC for summarized tool-result messages that already have a
2311
+ * large_files-backed placeholder stored in LCM.
2312
+ */
2313
+ async maintain(params: {
2314
+ sessionId: string;
2315
+ sessionFile: string;
2316
+ sessionKey?: string;
2317
+ runtimeContext?: ContextEngineMaintenanceRuntimeContext;
2318
+ }): Promise<ContextEngineMaintenanceResult> {
2319
+ if (this.shouldIgnoreSession({ sessionId: params.sessionId, sessionKey: params.sessionKey })) {
2320
+ return {
2321
+ changed: false,
2322
+ bytesFreed: 0,
2323
+ rewrittenEntries: 0,
2324
+ reason: "session excluded by pattern",
2325
+ };
2326
+ }
2327
+ if (this.isStatelessSession(params.sessionKey)) {
2328
+ return {
2329
+ changed: false,
2330
+ bytesFreed: 0,
2331
+ rewrittenEntries: 0,
2332
+ reason: "stateless session",
2333
+ };
2334
+ }
2335
+ if (typeof params.runtimeContext?.rewriteTranscriptEntries !== "function") {
2336
+ return {
2337
+ changed: false,
2338
+ bytesFreed: 0,
2339
+ rewrittenEntries: 0,
2340
+ reason: "runtime rewrite helper unavailable",
2341
+ };
2342
+ }
2343
+
2344
+ return this.withSessionQueue(
2345
+ this.resolveSessionQueueKey(params.sessionId, params.sessionKey),
2346
+ async () => {
2347
+ const conversation = await this.conversationStore.getConversationForSession({
2348
+ sessionId: params.sessionId,
2349
+ sessionKey: params.sessionKey,
2350
+ });
2351
+ if (!conversation) {
2352
+ return {
2353
+ changed: false,
2354
+ bytesFreed: 0,
2355
+ rewrittenEntries: 0,
2356
+ reason: "conversation not found",
2357
+ };
2358
+ }
2359
+
2360
+ const candidates = await this.summaryStore.listTranscriptGcCandidates(
2361
+ conversation.conversationId,
2362
+ { limit: TRANSCRIPT_GC_BATCH_SIZE },
2363
+ );
2364
+ if (candidates.length === 0) {
2365
+ return {
2366
+ changed: false,
2367
+ bytesFreed: 0,
2368
+ rewrittenEntries: 0,
2369
+ reason: "no transcript GC candidates",
2370
+ };
2371
+ }
2372
+
2373
+ const transcriptEntryIdsByCallId = listTranscriptToolResultEntryIdsByCallId(
2374
+ params.sessionFile,
2375
+ );
2376
+ const replacements: TranscriptRewriteReplacement[] = [];
2377
+ const seenEntryIds = new Set<string>();
2378
+
2379
+ for (const candidate of candidates) {
2380
+ const entryId = transcriptEntryIdsByCallId.get(candidate.toolCallId);
2381
+ if (!entryId || seenEntryIds.has(entryId)) {
2382
+ continue;
2383
+ }
2384
+
2385
+ const replacementMessage = await this.buildTranscriptGcReplacementMessage(
2386
+ candidate.messageId,
2387
+ );
2388
+ if (!replacementMessage) {
2389
+ continue;
2390
+ }
2391
+
2392
+ seenEntryIds.add(entryId);
2393
+ replacements.push({
2394
+ entryId,
2395
+ message: replacementMessage,
2396
+ });
2397
+ }
2398
+
2399
+ if (replacements.length === 0) {
2400
+ return {
2401
+ changed: false,
2402
+ bytesFreed: 0,
2403
+ rewrittenEntries: 0,
2404
+ reason: "no matching transcript entries",
2405
+ };
2406
+ }
2407
+
2408
+ return params.runtimeContext.rewriteTranscriptEntries({
2409
+ replacements,
2410
+ });
2411
+ },
2412
+ );
2413
+ }
1960
2414
  private async ingestSingle(params: {
1961
2415
  sessionId: string;
1962
2416
  sessionKey?: string;
@@ -2108,6 +2562,16 @@ export class LcmContextEngine implements ContextEngine {
2108
2562
  }
2109
2563
  this.ensureMigrated();
2110
2564
 
2565
+ // Dedup guard: prevent duplicate ingestion when gateway restart replays
2566
+ // full history. Run on newMessages BEFORE prepending autoCompactionSummary
2567
+ // so synthetic summaries cannot interfere with replay detection.
2568
+ const newMessages = params.messages.slice(params.prePromptMessageCount);
2569
+ const dedupedNewMessages = await this.deduplicateAfterTurnBatch(
2570
+ params.sessionId,
2571
+ params.sessionKey,
2572
+ newMessages,
2573
+ );
2574
+
2111
2575
  const ingestBatch: AgentMessage[] = [];
2112
2576
  if (params.autoCompactionSummary) {
2113
2577
  ingestBatch.push({
@@ -2116,8 +2580,7 @@ export class LcmContextEngine implements ContextEngine {
2116
2580
  } as AgentMessage);
2117
2581
  }
2118
2582
 
2119
- const newMessages = params.messages.slice(params.prePromptMessageCount);
2120
- ingestBatch.push(...newMessages);
2583
+ ingestBatch.push(...dedupedNewMessages);
2121
2584
  if (ingestBatch.length === 0) {
2122
2585
  return;
2123
2586
  }
@@ -2138,6 +2601,29 @@ export class LcmContextEngine implements ContextEngine {
2138
2601
  return;
2139
2602
  }
2140
2603
 
2604
+ if (batchLooksLikeHeartbeatAckTurn(ingestBatch)) {
2605
+ try {
2606
+ const conversation = await this.conversationStore.getConversationForSession({
2607
+ sessionId: params.sessionId,
2608
+ sessionKey: params.sessionKey,
2609
+ });
2610
+ if (conversation) {
2611
+ const pruned = await this.pruneHeartbeatOkTurns(conversation.conversationId);
2612
+ if (pruned > 0) {
2613
+ console.error(
2614
+ `[lcm] afterTurn: pruned ${pruned} heartbeat ack messages from conversation ${conversation.conversationId}`,
2615
+ );
2616
+ return;
2617
+ }
2618
+ }
2619
+ } catch (err) {
2620
+ console.error(
2621
+ `[lcm] afterTurn: heartbeat pruning failed:`,
2622
+ err instanceof Error ? err.message : err,
2623
+ );
2624
+ }
2625
+ }
2626
+
2141
2627
  const legacyParams = asRecord(params.runtimeContext) ?? asRecord(params.legacyCompactionParams);
2142
2628
  const DEFAULT_AFTER_TURN_TOKEN_BUDGET = 128_000;
2143
2629
  const resolvedTokenBudget = this.resolveTokenBudget({
@@ -2192,6 +2678,8 @@ export class LcmContextEngine implements ContextEngine {
2192
2678
  sessionKey?: string;
2193
2679
  messages: AgentMessage[];
2194
2680
  tokenBudget?: number;
2681
+ /** Optional user query for relevance-based eviction (BM25-lite). When absent or unsearchable, falls back to chronological eviction. */
2682
+ prompt?: string;
2195
2683
  }): Promise<AssembleResult> {
2196
2684
  if (this.shouldIgnoreSession({ sessionId: params.sessionId, sessionKey: params.sessionKey })) {
2197
2685
  return {
@@ -2244,6 +2732,7 @@ export class LcmContextEngine implements ContextEngine {
2244
2732
  conversationId: conversation.conversationId,
2245
2733
  tokenBudget,
2246
2734
  freshTailCount: this.config.freshTailCount,
2735
+ prompt: params.prompt,
2247
2736
  });
2248
2737
 
2249
2738
  // If assembly produced no messages for a non-empty live session,
@@ -2362,10 +2851,18 @@ export class LcmContextEngine implements ContextEngine {
2362
2851
  }
2363
2852
  ).currentTokenCount,
2364
2853
  );
2365
- const { summarize, summaryModel } = await this.resolveSummarize({
2854
+ const { summarize, summaryModel, breakerKey } = await this.resolveSummarize({
2366
2855
  legacyParams,
2367
2856
  customInstructions: params.customInstructions,
2857
+ breakerScope: this.resolveSessionQueueKey(params.sessionId, params.sessionKey),
2368
2858
  });
2859
+ if (breakerKey && this.isCircuitBreakerOpen(breakerKey)) {
2860
+ return {
2861
+ ok: true,
2862
+ compacted: false,
2863
+ reason: "circuit breaker open",
2864
+ };
2865
+ }
2369
2866
 
2370
2867
  const leafResult = await this.compaction.compactLeaf({
2371
2868
  conversationId: conversation.conversationId,
@@ -2375,12 +2872,23 @@ export class LcmContextEngine implements ContextEngine {
2375
2872
  previousSummaryContent: params.previousSummaryContent,
2376
2873
  summaryModel,
2377
2874
  });
2875
+
2876
+ if (leafResult.authFailure && breakerKey) {
2877
+ this.recordCompactionAuthFailure(breakerKey);
2878
+ } else if (leafResult.actionTaken && breakerKey) {
2879
+ this.recordCompactionSuccess(breakerKey);
2880
+ }
2881
+
2378
2882
  const tokensBefore = observedTokens ?? leafResult.tokensBefore;
2379
2883
 
2380
2884
  return {
2381
2885
  ok: true,
2382
2886
  compacted: leafResult.actionTaken,
2383
- reason: leafResult.actionTaken ? "compacted" : "below threshold",
2887
+ reason: leafResult.authFailure
2888
+ ? "provider auth failure"
2889
+ : leafResult.actionTaken
2890
+ ? "compacted"
2891
+ : "below threshold",
2384
2892
  result: {
2385
2893
  tokensBefore,
2386
2894
  tokensAfter: leafResult.tokensAfter,
@@ -2445,132 +2953,161 @@ export class LcmContextEngine implements ContextEngine {
2445
2953
 
2446
2954
  const conversationId = conversation.conversationId;
2447
2955
 
2448
- const legacyParams = asRecord(params.runtimeContext) ?? params.legacyParams;
2449
- const lp = legacyParams ?? {};
2450
- const manualCompactionRequested =
2451
- (
2452
- lp as {
2453
- manualCompaction?: unknown;
2454
- }
2455
- ).manualCompaction === true;
2456
- const forceCompaction = force || manualCompactionRequested;
2457
- const resolvedTokenBudget = this.resolveTokenBudget({
2458
- tokenBudget: params.tokenBudget,
2459
- runtimeContext: params.runtimeContext,
2460
- legacyParams,
2461
- });
2462
- const tokenBudget = resolvedTokenBudget
2463
- ? this.applyAssemblyBudgetCap(resolvedTokenBudget)
2464
- : resolvedTokenBudget;
2465
- if (!tokenBudget) {
2466
- return {
2467
- ok: false,
2468
- compacted: false,
2469
- reason: "missing token budget in compact params",
2470
- };
2471
- }
2472
-
2473
- const { summarize, summaryModel } = await this.resolveSummarize({
2474
- legacyParams,
2475
- customInstructions: params.customInstructions,
2476
- });
2477
-
2478
- // Evaluate whether compaction is needed (unless forced)
2479
- const observedTokens = this.normalizeObservedTokenCount(
2480
- params.currentTokenCount ??
2956
+ const legacyParams = asRecord(params.runtimeContext) ?? params.legacyParams;
2957
+ const lp = legacyParams ?? {};
2958
+ const manualCompactionRequested =
2481
2959
  (
2482
2960
  lp as {
2483
- currentTokenCount?: unknown;
2961
+ manualCompaction?: unknown;
2484
2962
  }
2485
- ).currentTokenCount,
2486
- );
2487
- const decision =
2488
- observedTokens !== undefined
2489
- ? await this.compaction.evaluate(conversationId, tokenBudget, observedTokens)
2490
- : await this.compaction.evaluate(conversationId, tokenBudget);
2491
- const targetTokens =
2492
- params.compactionTarget === "threshold" ? decision.threshold : tokenBudget;
2493
- const liveContextStillExceedsTarget =
2494
- observedTokens !== undefined && observedTokens >= targetTokens;
2495
-
2496
- if (!forceCompaction && !decision.shouldCompact) {
2497
- return {
2498
- ok: true,
2499
- compacted: false,
2500
- reason: "below threshold",
2501
- result: {
2502
- tokensBefore: decision.currentTokens,
2503
- },
2504
- };
2505
- }
2963
+ ).manualCompaction === true;
2964
+ const forceCompaction = force || manualCompactionRequested;
2965
+ const resolvedTokenBudget = this.resolveTokenBudget({
2966
+ tokenBudget: params.tokenBudget,
2967
+ runtimeContext: params.runtimeContext,
2968
+ legacyParams,
2969
+ });
2970
+ const tokenBudget = resolvedTokenBudget
2971
+ ? this.applyAssemblyBudgetCap(resolvedTokenBudget)
2972
+ : resolvedTokenBudget;
2973
+ if (!tokenBudget) {
2974
+ return {
2975
+ ok: false,
2976
+ compacted: false,
2977
+ reason: "missing token budget in compact params",
2978
+ };
2979
+ }
2980
+
2981
+ const { summarize, summaryModel, breakerKey } = await this.resolveSummarize({
2982
+ legacyParams,
2983
+ customInstructions: params.customInstructions,
2984
+ breakerScope: this.resolveSessionQueueKey(params.sessionId, params.sessionKey),
2985
+ });
2986
+ if (breakerKey && this.isCircuitBreakerOpen(breakerKey)) {
2987
+ return {
2988
+ ok: true,
2989
+ compacted: false,
2990
+ reason: "circuit breaker open",
2991
+ };
2992
+ }
2993
+
2994
+ // Evaluate whether compaction is needed (unless forced)
2995
+ const observedTokens = this.normalizeObservedTokenCount(
2996
+ params.currentTokenCount ??
2997
+ (
2998
+ lp as {
2999
+ currentTokenCount?: unknown;
3000
+ }
3001
+ ).currentTokenCount,
3002
+ );
3003
+ const decision =
3004
+ observedTokens !== undefined
3005
+ ? await this.compaction.evaluate(conversationId, tokenBudget, observedTokens)
3006
+ : await this.compaction.evaluate(conversationId, tokenBudget);
3007
+ const targetTokens =
3008
+ params.compactionTarget === "threshold" ? decision.threshold : tokenBudget;
3009
+ const liveContextStillExceedsTarget =
3010
+ observedTokens !== undefined && observedTokens >= targetTokens;
3011
+
3012
+ if (!forceCompaction && !decision.shouldCompact) {
3013
+ return {
3014
+ ok: true,
3015
+ compacted: false,
3016
+ reason: "below threshold",
3017
+ result: {
3018
+ tokensBefore: decision.currentTokens,
3019
+ },
3020
+ };
3021
+ }
3022
+
3023
+ const useSweep =
3024
+ manualCompactionRequested || forceCompaction || params.compactionTarget === "threshold";
3025
+ if (useSweep) {
3026
+ const sweepResult = await this.compaction.compactFullSweep({
3027
+ conversationId,
3028
+ tokenBudget,
3029
+ summarize,
3030
+ force: forceCompaction,
3031
+ hardTrigger: false,
3032
+ summaryModel,
3033
+ });
3034
+
3035
+ if (sweepResult.authFailure && breakerKey) {
3036
+ this.recordCompactionAuthFailure(breakerKey);
3037
+ } else if (sweepResult.actionTaken && breakerKey) {
3038
+ this.recordCompactionSuccess(breakerKey);
3039
+ }
3040
+
3041
+ return {
3042
+ ok: !sweepResult.authFailure && (sweepResult.actionTaken || !liveContextStillExceedsTarget),
3043
+ compacted: sweepResult.actionTaken,
3044
+ reason: sweepResult.authFailure
3045
+ ? (sweepResult.actionTaken
3046
+ ? "provider auth failure after partial compaction"
3047
+ : "provider auth failure")
3048
+ : sweepResult.actionTaken
3049
+ ? "compacted"
3050
+ : manualCompactionRequested
3051
+ ? "nothing to compact"
3052
+ : liveContextStillExceedsTarget
3053
+ ? "live context still exceeds target"
3054
+ : "already under target",
3055
+ result: {
3056
+ tokensBefore: decision.currentTokens,
3057
+ tokensAfter: sweepResult.tokensAfter,
3058
+ details: {
3059
+ rounds: sweepResult.actionTaken ? 1 : 0,
3060
+ targetTokens,
3061
+ },
3062
+ },
3063
+ };
3064
+ }
2506
3065
 
2507
- const useSweep =
2508
- manualCompactionRequested || forceCompaction || params.compactionTarget === "threshold";
2509
- if (useSweep) {
2510
- const sweepResult = await this.compaction.compactFullSweep({
3066
+ // When forced, use the token budget as target
3067
+ const convergenceTargetTokens = forceCompaction
3068
+ ? tokenBudget
3069
+ : params.compactionTarget === "threshold"
3070
+ ? decision.threshold
3071
+ : tokenBudget;
3072
+
3073
+ const compactResult = await this.compaction.compactUntilUnder({
2511
3074
  conversationId,
2512
3075
  tokenBudget,
3076
+ targetTokens: convergenceTargetTokens,
3077
+ ...(observedTokens !== undefined ? { currentTokens: observedTokens } : {}),
2513
3078
  summarize,
2514
- force: forceCompaction,
2515
- hardTrigger: false,
2516
3079
  summaryModel,
2517
3080
  });
2518
3081
 
3082
+ if (compactResult.authFailure && breakerKey) {
3083
+ this.recordCompactionAuthFailure(breakerKey);
3084
+ } else if (compactResult.rounds > 0 && breakerKey) {
3085
+ this.recordCompactionSuccess(breakerKey);
3086
+ }
3087
+
3088
+ const didCompact = compactResult.rounds > 0;
3089
+
2519
3090
  return {
2520
- ok: sweepResult.actionTaken || !liveContextStillExceedsTarget,
2521
- compacted: sweepResult.actionTaken,
2522
- reason: sweepResult.actionTaken
2523
- ? "compacted"
2524
- : manualCompactionRequested
2525
- ? "nothing to compact"
2526
- : liveContextStillExceedsTarget
2527
- ? "live context still exceeds target"
2528
- : "already under target",
3091
+ ok: compactResult.success,
3092
+ compacted: didCompact,
3093
+ reason: compactResult.authFailure
3094
+ ? (didCompact
3095
+ ? "provider auth failure after partial compaction"
3096
+ : "provider auth failure")
3097
+ : compactResult.success
3098
+ ? didCompact
3099
+ ? "compacted"
3100
+ : "already under target"
3101
+ : "could not reach target",
2529
3102
  result: {
2530
3103
  tokensBefore: decision.currentTokens,
2531
- tokensAfter: sweepResult.tokensAfter,
3104
+ tokensAfter: compactResult.finalTokens,
2532
3105
  details: {
2533
- rounds: sweepResult.actionTaken ? 1 : 0,
2534
- targetTokens,
3106
+ rounds: compactResult.rounds,
3107
+ targetTokens: convergenceTargetTokens,
2535
3108
  },
2536
3109
  },
2537
3110
  };
2538
- }
2539
-
2540
- // When forced, use the token budget as target
2541
- const convergenceTargetTokens = forceCompaction
2542
- ? tokenBudget
2543
- : params.compactionTarget === "threshold"
2544
- ? decision.threshold
2545
- : tokenBudget;
2546
-
2547
- const compactResult = await this.compaction.compactUntilUnder({
2548
- conversationId,
2549
- tokenBudget,
2550
- targetTokens: convergenceTargetTokens,
2551
- ...(observedTokens !== undefined ? { currentTokens: observedTokens } : {}),
2552
- summarize,
2553
- summaryModel,
2554
- });
2555
- const didCompact = compactResult.rounds > 0;
2556
-
2557
- return {
2558
- ok: compactResult.success,
2559
- compacted: didCompact,
2560
- reason: compactResult.success
2561
- ? didCompact
2562
- ? "compacted"
2563
- : "already under target"
2564
- : "could not reach target",
2565
- result: {
2566
- tokensBefore: decision.currentTokens,
2567
- tokensAfter: compactResult.finalTokens,
2568
- details: {
2569
- rounds: compactResult.rounds,
2570
- targetTokens: convergenceTargetTokens,
2571
- },
2572
- },
2573
- };
2574
3111
  },
2575
3112
  );
2576
3113
  }
@@ -2681,6 +3218,90 @@ export class LcmContextEngine implements ContextEngine {
2681
3218
  // The shared connection is managed for the lifetime of the plugin process.
2682
3219
  }
2683
3220
 
3221
+ /** Apply LCM lifecycle semantics for OpenClaw's /new and /reset commands. */
3222
+ async handleBeforeReset(params: {
3223
+ reason?: string;
3224
+ sessionId?: string;
3225
+ sessionKey?: string;
3226
+ }): Promise<void> {
3227
+ const reason = params.reason?.trim();
3228
+ if (reason !== "new" && reason !== "reset") {
3229
+ return;
3230
+ }
3231
+ if (this.shouldIgnoreSession({ sessionId: params.sessionId, sessionKey: params.sessionKey })) {
3232
+ return;
3233
+ }
3234
+ if (this.isStatelessSession(params.sessionKey)) {
3235
+ return;
3236
+ }
3237
+
3238
+ this.ensureMigrated();
3239
+ await this.withSessionQueue(
3240
+ this.resolveSessionQueueKey(params.sessionId, params.sessionKey),
3241
+ async () =>
3242
+ this.conversationStore.withTransaction(async () => {
3243
+ if (reason === "new") {
3244
+ const conversation = await this.conversationStore.getConversationForSession({
3245
+ sessionId: params.sessionId,
3246
+ sessionKey: params.sessionKey,
3247
+ });
3248
+ if (!conversation) {
3249
+ return;
3250
+ }
3251
+
3252
+ const retainDepth =
3253
+ typeof this.config.newSessionRetainDepth === "number"
3254
+ && Number.isFinite(this.config.newSessionRetainDepth)
3255
+ ? this.config.newSessionRetainDepth
3256
+ : 2;
3257
+ await this.summaryStore.pruneForNewSession(conversation.conversationId, retainDepth);
3258
+ this.deps.log.info(
3259
+ `[lcm] /new pruned conversation ${conversation.conversationId} to retain depth ${retainDepth}`,
3260
+ );
3261
+ return;
3262
+ }
3263
+
3264
+ const current = await this.conversationStore.getConversationForSession({
3265
+ sessionId: params.sessionId,
3266
+ sessionKey: params.sessionKey,
3267
+ });
3268
+ if (current?.active) {
3269
+ const currentMessageCount = await this.conversationStore.getMessageCount(
3270
+ current.conversationId,
3271
+ );
3272
+ const currentContextItems = await this.summaryStore.getContextItems(
3273
+ current.conversationId,
3274
+ );
3275
+ if (
3276
+ currentMessageCount === 0
3277
+ && currentContextItems.length === 0
3278
+ && !current.bootstrappedAt
3279
+ ) {
3280
+ this.deps.log.info(
3281
+ `[lcm] /reset no-op for already fresh conversation ${current.conversationId}`,
3282
+ );
3283
+ return;
3284
+ }
3285
+ await this.conversationStore.archiveConversation(current.conversationId);
3286
+ }
3287
+
3288
+ const nextSessionId = params.sessionId?.trim() || current?.sessionId;
3289
+ if (!nextSessionId) {
3290
+ this.deps.log.warn("[lcm] /reset skipped: no session identity available");
3291
+ return;
3292
+ }
3293
+
3294
+ const freshConversation = await this.conversationStore.createConversation({
3295
+ sessionId: nextSessionId,
3296
+ sessionKey: params.sessionKey?.trim(),
3297
+ });
3298
+ this.deps.log.info(
3299
+ `[lcm] /reset archived prior conversation and created ${freshConversation.conversationId}`,
3300
+ );
3301
+ }),
3302
+ );
3303
+ }
3304
+
2684
3305
  // ── Public accessors for retrieval (used by subagent expansion) ─────────
2685
3306
 
2686
3307
  getRetrieval(): RetrievalEngine {
@@ -2744,16 +3365,11 @@ export class LcmContextEngine implements ContextEngine {
2744
3365
  if (!turnMessages.some((record) => record.role === "user")) {
2745
3366
  continue;
2746
3367
  }
2747
- if (turnMessages.some((record) => record.role === "tool")) {
3368
+ if (!turnLooksLikeHeartbeatTurn(turnMessages)) {
2748
3369
  continue;
2749
3370
  }
2750
3371
 
2751
- const messageIds = turnMessages.map((record) => record.messageId);
2752
- const hasToolParts = await this.turnHasToolInteractions(messageIds);
2753
- if (hasToolParts) {
2754
- continue;
2755
- }
2756
- toDelete.push(...messageIds);
3372
+ toDelete.push(...turnMessages.map((record) => record.messageId));
2757
3373
  }
2758
3374
 
2759
3375
  if (toDelete.length === 0) {
@@ -2764,45 +3380,12 @@ export class LcmContextEngine implements ContextEngine {
2764
3380
  const uniqueIds = [...new Set(toDelete)];
2765
3381
  return this.conversationStore.deleteMessages(uniqueIds);
2766
3382
  }
2767
-
2768
- private async turnHasToolInteractions(messageIds: number[]): Promise<boolean> {
2769
- for (const messageId of messageIds) {
2770
- const parts = await this.conversationStore.getMessageParts(messageId);
2771
- if (parts.some(messagePartIndicatesToolUsage)) {
2772
- return true;
2773
- }
2774
- }
2775
- return false;
2776
- }
2777
- }
2778
-
2779
- // ── Tool-part detection ──────────────────────────────────────────────────────
2780
-
2781
- const TOOL_PART_TYPES: ReadonlySet<string> = new Set(["tool"]);
2782
-
2783
- function messagePartIndicatesToolUsage(part: MessagePartRecord): boolean {
2784
- if (TOOL_PART_TYPES.has(part.partType)) {
2785
- return true;
2786
- }
2787
- if (part.toolCallId || part.toolName || part.toolInput || part.toolOutput) {
2788
- return true;
2789
- }
2790
- if (typeof part.metadata === "string" && part.metadata.length > 0) {
2791
- try {
2792
- const meta = JSON.parse(part.metadata) as Record<string, unknown>;
2793
- if (typeof meta.rawType === "string" && TOOL_RAW_TYPES.has(meta.rawType)) {
2794
- return true;
2795
- }
2796
- } catch {
2797
- // ignore
2798
- }
2799
- }
2800
- return false;
2801
3383
  }
2802
3384
 
2803
3385
  // ── Heartbeat detection ─────────────────────────────────────────────────────
2804
3386
 
2805
3387
  const HEARTBEAT_OK_TOKEN = "heartbeat_ok";
3388
+ const HEARTBEAT_TURN_MARKER = "heartbeat.md";
2806
3389
 
2807
3390
  /**
2808
3391
  * Detect whether an assistant message is a heartbeat ack.
@@ -2814,6 +3397,32 @@ function isHeartbeatOkContent(content: string): boolean {
2814
3397
  return content.trim().toLowerCase() === HEARTBEAT_OK_TOKEN;
2815
3398
  }
2816
3399
 
3400
+ function batchLooksLikeHeartbeatAckTurn(messages: AgentMessage[]): boolean {
3401
+ let sawHeartbeatMarker = false;
3402
+ let sawHeartbeatAck = false;
3403
+
3404
+ for (const message of messages) {
3405
+ const stored = toStoredMessage(message);
3406
+ if (!sawHeartbeatMarker && stored.content.toLowerCase().includes(HEARTBEAT_TURN_MARKER)) {
3407
+ sawHeartbeatMarker = true;
3408
+ }
3409
+ if (!sawHeartbeatAck && stored.role === "assistant" && isHeartbeatOkContent(stored.content)) {
3410
+ sawHeartbeatAck = true;
3411
+ }
3412
+ if (sawHeartbeatMarker && sawHeartbeatAck) {
3413
+ return true;
3414
+ }
3415
+ }
3416
+
3417
+ return false;
3418
+ }
3419
+
3420
+ function turnLooksLikeHeartbeatTurn(turnMessages: Array<{ content: string }>): boolean {
3421
+ return turnMessages.some((message) =>
3422
+ message.content.toLowerCase().includes(HEARTBEAT_TURN_MARKER),
3423
+ );
3424
+ }
3425
+
2817
3426
  // ── Emergency fallback summarization ────────────────────────────────────────
2818
3427
 
2819
3428
  /**