@martian-engineering/lossless-claw 0.5.3 → 0.6.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
@@ -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,208 @@ 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
+ batch: AgentMessage[],
2223
+ ): Promise<AgentMessage[]> {
2224
+ if (batch.length === 0) return batch;
2225
+
2226
+ const conversation = await this.conversationStore.getConversationBySessionId(sessionId);
2227
+ if (!conversation) return batch;
2228
+
2229
+ const conversationId = conversation.conversationId;
2230
+ const storedMessageCount = await this.conversationStore.getMessageCount(conversationId);
2231
+ if (storedMessageCount === 0 || storedMessageCount > batch.length) {
2232
+ return batch;
2233
+ }
2234
+
2235
+ // Aligned-tail check: DB's last message must match the message at the
2236
+ // exact replay boundary in the incoming batch. This replaces the
2237
+ // hasMessage() check which could false-positive on any repeated content.
2238
+ const lastDbMessage = await this.conversationStore.getLastMessage(conversationId);
2239
+ if (!lastDbMessage) return batch;
2240
+
2241
+ const storedBatch = batch.map((m) => toStoredMessage(m));
2242
+ const batchAtBoundary = storedBatch[storedMessageCount - 1]!;
2243
+ if (
2244
+ messageIdentity(lastDbMessage.role, lastDbMessage.content) !==
2245
+ messageIdentity(batchAtBoundary.role, batchAtBoundary.content)
2246
+ ) {
2247
+ return batch;
2248
+ }
2249
+
2250
+ // Full proof: incoming batch must start with the entire stored transcript
2251
+ // in exact order before we trim anything.
2252
+ const storedMessages = await this.conversationStore.getMessages(conversationId, {
2253
+ limit: storedMessageCount,
2254
+ });
2255
+ if (storedMessages.length !== storedMessageCount) {
2256
+ return batch;
2257
+ }
2258
+ for (let i = 0; i < storedMessageCount; i += 1) {
2259
+ const storedConversationMessage = storedMessages[i]!;
2260
+ const incomingMessage = storedBatch[i]!;
2261
+ if (
2262
+ messageIdentity(storedConversationMessage.role, storedConversationMessage.content) !==
2263
+ messageIdentity(incomingMessage.role, incomingMessage.content)
2264
+ ) {
2265
+ return batch;
2266
+ }
2267
+ }
2268
+
2269
+ return batch.slice(storedMessageCount);
2270
+ }
2271
+ /**
2272
+ * Rebuild a compact tool-result message from stored message parts.
2273
+ *
2274
+ * The first transcript-GC pass only rewrites tool results that were already
2275
+ * externalized into large_files during ingest, so the stored placeholder is
2276
+ * the canonical replacement content.
2277
+ */
2278
+ private async buildTranscriptGcReplacementMessage(
2279
+ messageId: number,
2280
+ ): Promise<AgentMessage | null> {
2281
+ const message = await this.conversationStore.getMessageById(messageId);
2282
+ if (!message) {
2283
+ return null;
2284
+ }
2285
+
2286
+ const parts = await this.conversationStore.getMessageParts(messageId);
2287
+ const toolCallId = pickToolCallId(parts);
2288
+ if (!toolCallId) {
2289
+ return null;
2290
+ }
2291
+
2292
+ const content = contentFromParts(parts, "toolResult", message.content);
2293
+ const toolName = pickToolName(parts) ?? "unknown";
2294
+ const isError = pickToolIsError(parts);
2295
+
2296
+ return {
2297
+ role: "toolResult",
2298
+ toolCallId,
2299
+ toolName,
2300
+ content,
2301
+ ...(isError !== undefined ? { isError } : {}),
2302
+ } as AgentMessage;
2303
+ }
2304
+
2305
+ /**
2306
+ * Run transcript GC for summarized tool-result messages that already have a
2307
+ * large_files-backed placeholder stored in LCM.
2308
+ */
2309
+ async maintain(params: {
2310
+ sessionId: string;
2311
+ sessionFile: string;
2312
+ sessionKey?: string;
2313
+ runtimeContext?: ContextEngineMaintenanceRuntimeContext;
2314
+ }): Promise<ContextEngineMaintenanceResult> {
2315
+ if (this.shouldIgnoreSession({ sessionId: params.sessionId, sessionKey: params.sessionKey })) {
2316
+ return {
2317
+ changed: false,
2318
+ bytesFreed: 0,
2319
+ rewrittenEntries: 0,
2320
+ reason: "session excluded by pattern",
2321
+ };
2322
+ }
2323
+ if (this.isStatelessSession(params.sessionKey)) {
2324
+ return {
2325
+ changed: false,
2326
+ bytesFreed: 0,
2327
+ rewrittenEntries: 0,
2328
+ reason: "stateless session",
2329
+ };
2330
+ }
2331
+ if (typeof params.runtimeContext?.rewriteTranscriptEntries !== "function") {
2332
+ return {
2333
+ changed: false,
2334
+ bytesFreed: 0,
2335
+ rewrittenEntries: 0,
2336
+ reason: "runtime rewrite helper unavailable",
2337
+ };
2338
+ }
2339
+
2340
+ return this.withSessionQueue(
2341
+ this.resolveSessionQueueKey(params.sessionId, params.sessionKey),
2342
+ async () => {
2343
+ const conversation = await this.conversationStore.getConversationForSession({
2344
+ sessionId: params.sessionId,
2345
+ sessionKey: params.sessionKey,
2346
+ });
2347
+ if (!conversation) {
2348
+ return {
2349
+ changed: false,
2350
+ bytesFreed: 0,
2351
+ rewrittenEntries: 0,
2352
+ reason: "conversation not found",
2353
+ };
2354
+ }
2355
+
2356
+ const candidates = await this.summaryStore.listTranscriptGcCandidates(
2357
+ conversation.conversationId,
2358
+ { limit: TRANSCRIPT_GC_BATCH_SIZE },
2359
+ );
2360
+ if (candidates.length === 0) {
2361
+ return {
2362
+ changed: false,
2363
+ bytesFreed: 0,
2364
+ rewrittenEntries: 0,
2365
+ reason: "no transcript GC candidates",
2366
+ };
2367
+ }
2368
+
2369
+ const transcriptEntryIdsByCallId = listTranscriptToolResultEntryIdsByCallId(
2370
+ params.sessionFile,
2371
+ );
2372
+ const replacements: TranscriptRewriteReplacement[] = [];
2373
+ const seenEntryIds = new Set<string>();
2374
+
2375
+ for (const candidate of candidates) {
2376
+ const entryId = transcriptEntryIdsByCallId.get(candidate.toolCallId);
2377
+ if (!entryId || seenEntryIds.has(entryId)) {
2378
+ continue;
2379
+ }
2380
+
2381
+ const replacementMessage = await this.buildTranscriptGcReplacementMessage(
2382
+ candidate.messageId,
2383
+ );
2384
+ if (!replacementMessage) {
2385
+ continue;
2386
+ }
2387
+
2388
+ seenEntryIds.add(entryId);
2389
+ replacements.push({
2390
+ entryId,
2391
+ message: replacementMessage,
2392
+ });
2393
+ }
2394
+
2395
+ if (replacements.length === 0) {
2396
+ return {
2397
+ changed: false,
2398
+ bytesFreed: 0,
2399
+ rewrittenEntries: 0,
2400
+ reason: "no matching transcript entries",
2401
+ };
2402
+ }
2403
+
2404
+ return params.runtimeContext.rewriteTranscriptEntries({
2405
+ replacements,
2406
+ });
2407
+ },
2408
+ );
2409
+ }
1960
2410
  private async ingestSingle(params: {
1961
2411
  sessionId: string;
1962
2412
  sessionKey?: string;
@@ -2108,6 +2558,12 @@ export class LcmContextEngine implements ContextEngine {
2108
2558
  }
2109
2559
  this.ensureMigrated();
2110
2560
 
2561
+ // Dedup guard: prevent duplicate ingestion when gateway restart replays
2562
+ // full history. Run on newMessages BEFORE prepending autoCompactionSummary
2563
+ // so synthetic summaries cannot interfere with replay detection.
2564
+ const newMessages = params.messages.slice(params.prePromptMessageCount);
2565
+ const dedupedNewMessages = await this.deduplicateAfterTurnBatch(params.sessionId, newMessages);
2566
+
2111
2567
  const ingestBatch: AgentMessage[] = [];
2112
2568
  if (params.autoCompactionSummary) {
2113
2569
  ingestBatch.push({
@@ -2116,8 +2572,7 @@ export class LcmContextEngine implements ContextEngine {
2116
2572
  } as AgentMessage);
2117
2573
  }
2118
2574
 
2119
- const newMessages = params.messages.slice(params.prePromptMessageCount);
2120
- ingestBatch.push(...newMessages);
2575
+ ingestBatch.push(...dedupedNewMessages);
2121
2576
  if (ingestBatch.length === 0) {
2122
2577
  return;
2123
2578
  }
@@ -2192,6 +2647,8 @@ export class LcmContextEngine implements ContextEngine {
2192
2647
  sessionKey?: string;
2193
2648
  messages: AgentMessage[];
2194
2649
  tokenBudget?: number;
2650
+ /** Optional user query for relevance-based eviction (BM25-lite). When absent or unsearchable, falls back to chronological eviction. */
2651
+ prompt?: string;
2195
2652
  }): Promise<AssembleResult> {
2196
2653
  if (this.shouldIgnoreSession({ sessionId: params.sessionId, sessionKey: params.sessionKey })) {
2197
2654
  return {
@@ -2244,6 +2701,7 @@ export class LcmContextEngine implements ContextEngine {
2244
2701
  conversationId: conversation.conversationId,
2245
2702
  tokenBudget,
2246
2703
  freshTailCount: this.config.freshTailCount,
2704
+ prompt: params.prompt,
2247
2705
  });
2248
2706
 
2249
2707
  // If assembly produced no messages for a non-empty live session,
@@ -2362,10 +2820,18 @@ export class LcmContextEngine implements ContextEngine {
2362
2820
  }
2363
2821
  ).currentTokenCount,
2364
2822
  );
2365
- const { summarize, summaryModel } = await this.resolveSummarize({
2823
+ const { summarize, summaryModel, breakerKey } = await this.resolveSummarize({
2366
2824
  legacyParams,
2367
2825
  customInstructions: params.customInstructions,
2826
+ breakerScope: this.resolveSessionQueueKey(params.sessionId, params.sessionKey),
2368
2827
  });
2828
+ if (breakerKey && this.isCircuitBreakerOpen(breakerKey)) {
2829
+ return {
2830
+ ok: true,
2831
+ compacted: false,
2832
+ reason: "circuit breaker open",
2833
+ };
2834
+ }
2369
2835
 
2370
2836
  const leafResult = await this.compaction.compactLeaf({
2371
2837
  conversationId: conversation.conversationId,
@@ -2375,12 +2841,23 @@ export class LcmContextEngine implements ContextEngine {
2375
2841
  previousSummaryContent: params.previousSummaryContent,
2376
2842
  summaryModel,
2377
2843
  });
2844
+
2845
+ if (leafResult.authFailure && breakerKey) {
2846
+ this.recordCompactionAuthFailure(breakerKey);
2847
+ } else if (leafResult.actionTaken && breakerKey) {
2848
+ this.recordCompactionSuccess(breakerKey);
2849
+ }
2850
+
2378
2851
  const tokensBefore = observedTokens ?? leafResult.tokensBefore;
2379
2852
 
2380
2853
  return {
2381
2854
  ok: true,
2382
2855
  compacted: leafResult.actionTaken,
2383
- reason: leafResult.actionTaken ? "compacted" : "below threshold",
2856
+ reason: leafResult.authFailure
2857
+ ? "provider auth failure"
2858
+ : leafResult.actionTaken
2859
+ ? "compacted"
2860
+ : "below threshold",
2384
2861
  result: {
2385
2862
  tokensBefore,
2386
2863
  tokensAfter: leafResult.tokensAfter,
@@ -2445,132 +2922,161 @@ export class LcmContextEngine implements ContextEngine {
2445
2922
 
2446
2923
  const conversationId = conversation.conversationId;
2447
2924
 
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 ??
2925
+ const legacyParams = asRecord(params.runtimeContext) ?? params.legacyParams;
2926
+ const lp = legacyParams ?? {};
2927
+ const manualCompactionRequested =
2481
2928
  (
2482
2929
  lp as {
2483
- currentTokenCount?: unknown;
2930
+ manualCompaction?: unknown;
2484
2931
  }
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
- }
2932
+ ).manualCompaction === true;
2933
+ const forceCompaction = force || manualCompactionRequested;
2934
+ const resolvedTokenBudget = this.resolveTokenBudget({
2935
+ tokenBudget: params.tokenBudget,
2936
+ runtimeContext: params.runtimeContext,
2937
+ legacyParams,
2938
+ });
2939
+ const tokenBudget = resolvedTokenBudget
2940
+ ? this.applyAssemblyBudgetCap(resolvedTokenBudget)
2941
+ : resolvedTokenBudget;
2942
+ if (!tokenBudget) {
2943
+ return {
2944
+ ok: false,
2945
+ compacted: false,
2946
+ reason: "missing token budget in compact params",
2947
+ };
2948
+ }
2506
2949
 
2507
- const useSweep =
2508
- manualCompactionRequested || forceCompaction || params.compactionTarget === "threshold";
2509
- if (useSweep) {
2510
- const sweepResult = await this.compaction.compactFullSweep({
2950
+ const { summarize, summaryModel, breakerKey } = await this.resolveSummarize({
2951
+ legacyParams,
2952
+ customInstructions: params.customInstructions,
2953
+ breakerScope: this.resolveSessionQueueKey(params.sessionId, params.sessionKey),
2954
+ });
2955
+ if (breakerKey && this.isCircuitBreakerOpen(breakerKey)) {
2956
+ return {
2957
+ ok: true,
2958
+ compacted: false,
2959
+ reason: "circuit breaker open",
2960
+ };
2961
+ }
2962
+
2963
+ // Evaluate whether compaction is needed (unless forced)
2964
+ const observedTokens = this.normalizeObservedTokenCount(
2965
+ params.currentTokenCount ??
2966
+ (
2967
+ lp as {
2968
+ currentTokenCount?: unknown;
2969
+ }
2970
+ ).currentTokenCount,
2971
+ );
2972
+ const decision =
2973
+ observedTokens !== undefined
2974
+ ? await this.compaction.evaluate(conversationId, tokenBudget, observedTokens)
2975
+ : await this.compaction.evaluate(conversationId, tokenBudget);
2976
+ const targetTokens =
2977
+ params.compactionTarget === "threshold" ? decision.threshold : tokenBudget;
2978
+ const liveContextStillExceedsTarget =
2979
+ observedTokens !== undefined && observedTokens >= targetTokens;
2980
+
2981
+ if (!forceCompaction && !decision.shouldCompact) {
2982
+ return {
2983
+ ok: true,
2984
+ compacted: false,
2985
+ reason: "below threshold",
2986
+ result: {
2987
+ tokensBefore: decision.currentTokens,
2988
+ },
2989
+ };
2990
+ }
2991
+
2992
+ const useSweep =
2993
+ manualCompactionRequested || forceCompaction || params.compactionTarget === "threshold";
2994
+ if (useSweep) {
2995
+ const sweepResult = await this.compaction.compactFullSweep({
2996
+ conversationId,
2997
+ tokenBudget,
2998
+ summarize,
2999
+ force: forceCompaction,
3000
+ hardTrigger: false,
3001
+ summaryModel,
3002
+ });
3003
+
3004
+ if (sweepResult.authFailure && breakerKey) {
3005
+ this.recordCompactionAuthFailure(breakerKey);
3006
+ } else if (sweepResult.actionTaken && breakerKey) {
3007
+ this.recordCompactionSuccess(breakerKey);
3008
+ }
3009
+
3010
+ return {
3011
+ ok: !sweepResult.authFailure && (sweepResult.actionTaken || !liveContextStillExceedsTarget),
3012
+ compacted: sweepResult.actionTaken,
3013
+ reason: sweepResult.authFailure
3014
+ ? (sweepResult.actionTaken
3015
+ ? "provider auth failure after partial compaction"
3016
+ : "provider auth failure")
3017
+ : sweepResult.actionTaken
3018
+ ? "compacted"
3019
+ : manualCompactionRequested
3020
+ ? "nothing to compact"
3021
+ : liveContextStillExceedsTarget
3022
+ ? "live context still exceeds target"
3023
+ : "already under target",
3024
+ result: {
3025
+ tokensBefore: decision.currentTokens,
3026
+ tokensAfter: sweepResult.tokensAfter,
3027
+ details: {
3028
+ rounds: sweepResult.actionTaken ? 1 : 0,
3029
+ targetTokens,
3030
+ },
3031
+ },
3032
+ };
3033
+ }
3034
+
3035
+ // When forced, use the token budget as target
3036
+ const convergenceTargetTokens = forceCompaction
3037
+ ? tokenBudget
3038
+ : params.compactionTarget === "threshold"
3039
+ ? decision.threshold
3040
+ : tokenBudget;
3041
+
3042
+ const compactResult = await this.compaction.compactUntilUnder({
2511
3043
  conversationId,
2512
3044
  tokenBudget,
3045
+ targetTokens: convergenceTargetTokens,
3046
+ ...(observedTokens !== undefined ? { currentTokens: observedTokens } : {}),
2513
3047
  summarize,
2514
- force: forceCompaction,
2515
- hardTrigger: false,
2516
3048
  summaryModel,
2517
3049
  });
2518
3050
 
3051
+ if (compactResult.authFailure && breakerKey) {
3052
+ this.recordCompactionAuthFailure(breakerKey);
3053
+ } else if (compactResult.rounds > 0 && breakerKey) {
3054
+ this.recordCompactionSuccess(breakerKey);
3055
+ }
3056
+
3057
+ const didCompact = compactResult.rounds > 0;
3058
+
2519
3059
  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",
3060
+ ok: compactResult.success,
3061
+ compacted: didCompact,
3062
+ reason: compactResult.authFailure
3063
+ ? (didCompact
3064
+ ? "provider auth failure after partial compaction"
3065
+ : "provider auth failure")
3066
+ : compactResult.success
3067
+ ? didCompact
3068
+ ? "compacted"
3069
+ : "already under target"
3070
+ : "could not reach target",
2529
3071
  result: {
2530
3072
  tokensBefore: decision.currentTokens,
2531
- tokensAfter: sweepResult.tokensAfter,
3073
+ tokensAfter: compactResult.finalTokens,
2532
3074
  details: {
2533
- rounds: sweepResult.actionTaken ? 1 : 0,
2534
- targetTokens,
3075
+ rounds: compactResult.rounds,
3076
+ targetTokens: convergenceTargetTokens,
2535
3077
  },
2536
3078
  },
2537
3079
  };
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
3080
  },
2575
3081
  );
2576
3082
  }
@@ -2681,6 +3187,90 @@ export class LcmContextEngine implements ContextEngine {
2681
3187
  // The shared connection is managed for the lifetime of the plugin process.
2682
3188
  }
2683
3189
 
3190
+ /** Apply LCM lifecycle semantics for OpenClaw's /new and /reset commands. */
3191
+ async handleBeforeReset(params: {
3192
+ reason?: string;
3193
+ sessionId?: string;
3194
+ sessionKey?: string;
3195
+ }): Promise<void> {
3196
+ const reason = params.reason?.trim();
3197
+ if (reason !== "new" && reason !== "reset") {
3198
+ return;
3199
+ }
3200
+ if (this.shouldIgnoreSession({ sessionId: params.sessionId, sessionKey: params.sessionKey })) {
3201
+ return;
3202
+ }
3203
+ if (this.isStatelessSession(params.sessionKey)) {
3204
+ return;
3205
+ }
3206
+
3207
+ this.ensureMigrated();
3208
+ await this.withSessionQueue(
3209
+ this.resolveSessionQueueKey(params.sessionId, params.sessionKey),
3210
+ async () =>
3211
+ this.conversationStore.withTransaction(async () => {
3212
+ if (reason === "new") {
3213
+ const conversation = await this.conversationStore.getConversationForSession({
3214
+ sessionId: params.sessionId,
3215
+ sessionKey: params.sessionKey,
3216
+ });
3217
+ if (!conversation) {
3218
+ return;
3219
+ }
3220
+
3221
+ const retainDepth =
3222
+ typeof this.config.newSessionRetainDepth === "number"
3223
+ && Number.isFinite(this.config.newSessionRetainDepth)
3224
+ ? this.config.newSessionRetainDepth
3225
+ : 2;
3226
+ await this.summaryStore.pruneForNewSession(conversation.conversationId, retainDepth);
3227
+ this.deps.log.info(
3228
+ `[lcm] /new pruned conversation ${conversation.conversationId} to retain depth ${retainDepth}`,
3229
+ );
3230
+ return;
3231
+ }
3232
+
3233
+ const current = await this.conversationStore.getConversationForSession({
3234
+ sessionId: params.sessionId,
3235
+ sessionKey: params.sessionKey,
3236
+ });
3237
+ if (current?.active) {
3238
+ const currentMessageCount = await this.conversationStore.getMessageCount(
3239
+ current.conversationId,
3240
+ );
3241
+ const currentContextItems = await this.summaryStore.getContextItems(
3242
+ current.conversationId,
3243
+ );
3244
+ if (
3245
+ currentMessageCount === 0
3246
+ && currentContextItems.length === 0
3247
+ && !current.bootstrappedAt
3248
+ ) {
3249
+ this.deps.log.info(
3250
+ `[lcm] /reset no-op for already fresh conversation ${current.conversationId}`,
3251
+ );
3252
+ return;
3253
+ }
3254
+ await this.conversationStore.archiveConversation(current.conversationId);
3255
+ }
3256
+
3257
+ const nextSessionId = params.sessionId?.trim() || current?.sessionId;
3258
+ if (!nextSessionId) {
3259
+ this.deps.log.warn("[lcm] /reset skipped: no session identity available");
3260
+ return;
3261
+ }
3262
+
3263
+ const freshConversation = await this.conversationStore.createConversation({
3264
+ sessionId: nextSessionId,
3265
+ sessionKey: params.sessionKey?.trim(),
3266
+ });
3267
+ this.deps.log.info(
3268
+ `[lcm] /reset archived prior conversation and created ${freshConversation.conversationId}`,
3269
+ );
3270
+ }),
3271
+ );
3272
+ }
3273
+
2684
3274
  // ── Public accessors for retrieval (used by subagent expansion) ─────────
2685
3275
 
2686
3276
  getRetrieval(): RetrievalEngine {