@martian-engineering/lossless-claw 0.5.2 → 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;
@@ -1064,6 +1236,7 @@ export class LcmContextEngine implements ContextEngine {
1064
1236
  condensedTargetTokens: this.config.condensedTargetTokens,
1065
1237
  maxRounds: 10,
1066
1238
  timezone: this.config.timezone,
1239
+ summaryMaxOverageFactor: this.config.summaryMaxOverageFactor,
1067
1240
  };
1068
1241
  this.compaction = new CompactionEngine(
1069
1242
  this.conversationStore,
@@ -1110,6 +1283,56 @@ export class LcmContextEngine implements ContextEngine {
1110
1283
  return matchesSessionPattern(trimmedKey, this.statelessSessionPatterns);
1111
1284
  }
1112
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
+
1113
1336
  /** Ensure DB schema is up-to-date. Called lazily on first bootstrap/ingest/assemble/compact. */
1114
1337
  private ensureMigrated(): void {
1115
1338
  if (this.migrated) {
@@ -1152,9 +1375,10 @@ export class LcmContextEngine implements ContextEngine {
1152
1375
  }
1153
1376
 
1154
1377
  /** Prefer stable session keys for queue serialization when available. */
1155
- private resolveSessionQueueKey(sessionId: string, sessionKey?: string): string {
1378
+ private resolveSessionQueueKey(sessionId?: string, sessionKey?: string): string {
1156
1379
  const normalizedSessionKey = sessionKey?.trim();
1157
- return normalizedSessionKey || sessionId;
1380
+ const normalizedSessionId = sessionId?.trim();
1381
+ return normalizedSessionKey || normalizedSessionId || "__lcm__";
1158
1382
  }
1159
1383
 
1160
1384
  /** Normalize optional live token estimates supplied by runtime callers. */
@@ -1189,6 +1413,12 @@ export class LcmContextEngine implements ContextEngine {
1189
1413
  return undefined;
1190
1414
  }
1191
1415
 
1416
+ /** Cap a resolved token budget against the configured maxAssemblyTokenBudget. */
1417
+ private applyAssemblyBudgetCap(budget: number): number {
1418
+ const cap = this.config.maxAssemblyTokenBudget;
1419
+ return cap != null && cap > 0 ? Math.min(budget, cap) : budget;
1420
+ }
1421
+
1192
1422
  /** Resolve an LCM conversation id from a session key via the session store. */
1193
1423
  private async resolveConversationIdForSessionKey(
1194
1424
  sessionKey: string,
@@ -1222,22 +1452,36 @@ export class LcmContextEngine implements ContextEngine {
1222
1452
  private async resolveSummarize(params: {
1223
1453
  legacyParams?: Record<string, unknown>;
1224
1454
  customInstructions?: string;
1225
- }): 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
+ }> {
1226
1461
  const lp = params.legacyParams ?? {};
1227
1462
  if (typeof lp.summarize === "function") {
1228
1463
  return {
1229
1464
  summarize: lp.summarize as (text: string, aggressive?: boolean) => Promise<string>,
1230
1465
  summaryModel: "unknown",
1466
+ breakerKey: `custom:${params.breakerScope}`,
1231
1467
  };
1232
1468
  }
1233
1469
  try {
1470
+ const customInstructions =
1471
+ params.customInstructions !== undefined
1472
+ ? params.customInstructions
1473
+ : (this.config.customInstructions || undefined);
1234
1474
  const runtimeSummarizer = await createLcmSummarizeFromLegacyParams({
1235
1475
  deps: this.deps,
1236
1476
  legacyParams: lp,
1237
- customInstructions: params.customInstructions,
1477
+ customInstructions,
1238
1478
  });
1239
1479
  if (runtimeSummarizer) {
1240
- return { summarize: runtimeSummarizer.fn, summaryModel: runtimeSummarizer.model };
1480
+ return {
1481
+ summarize: runtimeSummarizer.fn,
1482
+ summaryModel: runtimeSummarizer.model,
1483
+ breakerKey: runtimeSummarizer.breakerKey,
1484
+ };
1241
1485
  }
1242
1486
  console.error(`[lcm] resolveSummarize: createLcmSummarizeFromLegacyParams returned undefined`);
1243
1487
  } catch (err) {
@@ -1271,6 +1515,7 @@ export class LcmContextEngine implements ContextEngine {
1271
1515
  const result = await createLcmSummarizeFromLegacyParams({
1272
1516
  deps: this.deps,
1273
1517
  legacyParams: { provider, model },
1518
+ customInstructions: this.config.customInstructions || undefined,
1274
1519
  });
1275
1520
  if (!result) {
1276
1521
  return undefined;
@@ -1508,14 +1753,24 @@ export class LcmContextEngine implements ContextEngine {
1508
1753
 
1509
1754
  const normalizedRawType =
1510
1755
  rawType === "function_call_output" ? "function_call_output" : "tool_result";
1511
- const compactBlock: Record<string, unknown> = {
1512
- type: normalizedRawType,
1513
- output: externalized.reference,
1514
- externalizedFileId: externalized.fileId,
1515
- originalByteSize: externalized.byteSize,
1516
- toolOutputExternalized: true,
1517
- externalizationReason: "large_tool_result",
1518
- };
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
+ };
1519
1774
  const callId =
1520
1775
  safeString(record.tool_use_id) ??
1521
1776
  safeString(record.toolUseId) ??
@@ -1828,7 +2083,12 @@ export class LcmContextEngine implements ContextEngine {
1828
2083
  // First-time import path: no LCM rows yet, so seed directly from the
1829
2084
  // active leaf context snapshot.
1830
2085
  if (existingCount === 0) {
1831
- if (historicalMessages.length === 0) {
2086
+ const bootstrapMessages = trimBootstrapMessagesToBudget(
2087
+ historicalMessages,
2088
+ resolveBootstrapMaxTokens(this.config),
2089
+ );
2090
+
2091
+ if (bootstrapMessages.length === 0) {
1832
2092
  await this.conversationStore.markConversationBootstrapped(conversationId);
1833
2093
  await persistBootstrapState(conversationId, historicalMessages);
1834
2094
  return {
@@ -1839,7 +2099,7 @@ export class LcmContextEngine implements ContextEngine {
1839
2099
  }
1840
2100
 
1841
2101
  const nextSeq = (await this.conversationStore.getMaxSeq(conversationId)) + 1;
1842
- const bulkInput = historicalMessages.map((message, index) => {
2102
+ const bulkInput = bootstrapMessages.map((message, index) => {
1843
2103
  const stored = toStoredMessage(message);
1844
2104
  return {
1845
2105
  conversationId,
@@ -1945,6 +2205,208 @@ export class LcmContextEngine implements ContextEngine {
1945
2205
  return result;
1946
2206
  }
1947
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
+ }
1948
2410
  private async ingestSingle(params: {
1949
2411
  sessionId: string;
1950
2412
  sessionKey?: string;
@@ -2096,6 +2558,12 @@ export class LcmContextEngine implements ContextEngine {
2096
2558
  }
2097
2559
  this.ensureMigrated();
2098
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
+
2099
2567
  const ingestBatch: AgentMessage[] = [];
2100
2568
  if (params.autoCompactionSummary) {
2101
2569
  ingestBatch.push({
@@ -2104,8 +2572,7 @@ export class LcmContextEngine implements ContextEngine {
2104
2572
  } as AgentMessage);
2105
2573
  }
2106
2574
 
2107
- const newMessages = params.messages.slice(params.prePromptMessageCount);
2108
- ingestBatch.push(...newMessages);
2575
+ ingestBatch.push(...dedupedNewMessages);
2109
2576
  if (ingestBatch.length === 0) {
2110
2577
  return;
2111
2578
  }
@@ -2133,7 +2600,7 @@ export class LcmContextEngine implements ContextEngine {
2133
2600
  runtimeContext: params.runtimeContext,
2134
2601
  legacyParams,
2135
2602
  });
2136
- const tokenBudget = resolvedTokenBudget ?? DEFAULT_AFTER_TURN_TOKEN_BUDGET;
2603
+ const tokenBudget = this.applyAssemblyBudgetCap(resolvedTokenBudget ?? DEFAULT_AFTER_TURN_TOKEN_BUDGET);
2137
2604
  if (resolvedTokenBudget === undefined) {
2138
2605
  console.warn(
2139
2606
  `[lcm] afterTurn: tokenBudget not provided; using default ${DEFAULT_AFTER_TURN_TOKEN_BUDGET}`,
@@ -2180,6 +2647,8 @@ export class LcmContextEngine implements ContextEngine {
2180
2647
  sessionKey?: string;
2181
2648
  messages: AgentMessage[];
2182
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;
2183
2652
  }): Promise<AssembleResult> {
2184
2653
  if (this.shouldIgnoreSession({ sessionId: params.sessionId, sessionKey: params.sessionKey })) {
2185
2654
  return {
@@ -2220,17 +2689,19 @@ export class LcmContextEngine implements ContextEngine {
2220
2689
  };
2221
2690
  }
2222
2691
 
2223
- const tokenBudget =
2692
+ const tokenBudget = this.applyAssemblyBudgetCap(
2224
2693
  typeof params.tokenBudget === "number" &&
2225
2694
  Number.isFinite(params.tokenBudget) &&
2226
2695
  params.tokenBudget > 0
2227
2696
  ? Math.floor(params.tokenBudget)
2228
- : 128_000;
2697
+ : 128_000,
2698
+ );
2229
2699
 
2230
2700
  const assembled = await this.assembler.assemble({
2231
2701
  conversationId: conversation.conversationId,
2232
2702
  tokenBudget,
2233
2703
  freshTailCount: this.config.freshTailCount,
2704
+ prompt: params.prompt,
2234
2705
  });
2235
2706
 
2236
2707
  // If assembly produced no messages for a non-empty live session,
@@ -2324,11 +2795,14 @@ export class LcmContextEngine implements ContextEngine {
2324
2795
  }
2325
2796
 
2326
2797
  const legacyParams = asRecord(params.runtimeContext) ?? params.legacyParams;
2327
- const tokenBudget = this.resolveTokenBudget({
2798
+ const resolvedTokenBudget = this.resolveTokenBudget({
2328
2799
  tokenBudget: params.tokenBudget,
2329
2800
  runtimeContext: params.runtimeContext,
2330
2801
  legacyParams,
2331
2802
  });
2803
+ const tokenBudget = resolvedTokenBudget
2804
+ ? this.applyAssemblyBudgetCap(resolvedTokenBudget)
2805
+ : resolvedTokenBudget;
2332
2806
  if (!tokenBudget) {
2333
2807
  return {
2334
2808
  ok: false,
@@ -2346,10 +2820,18 @@ export class LcmContextEngine implements ContextEngine {
2346
2820
  }
2347
2821
  ).currentTokenCount,
2348
2822
  );
2349
- const { summarize, summaryModel } = await this.resolveSummarize({
2823
+ const { summarize, summaryModel, breakerKey } = await this.resolveSummarize({
2350
2824
  legacyParams,
2351
2825
  customInstructions: params.customInstructions,
2826
+ breakerScope: this.resolveSessionQueueKey(params.sessionId, params.sessionKey),
2352
2827
  });
2828
+ if (breakerKey && this.isCircuitBreakerOpen(breakerKey)) {
2829
+ return {
2830
+ ok: true,
2831
+ compacted: false,
2832
+ reason: "circuit breaker open",
2833
+ };
2834
+ }
2353
2835
 
2354
2836
  const leafResult = await this.compaction.compactLeaf({
2355
2837
  conversationId: conversation.conversationId,
@@ -2359,12 +2841,23 @@ export class LcmContextEngine implements ContextEngine {
2359
2841
  previousSummaryContent: params.previousSummaryContent,
2360
2842
  summaryModel,
2361
2843
  });
2844
+
2845
+ if (leafResult.authFailure && breakerKey) {
2846
+ this.recordCompactionAuthFailure(breakerKey);
2847
+ } else if (leafResult.actionTaken && breakerKey) {
2848
+ this.recordCompactionSuccess(breakerKey);
2849
+ }
2850
+
2362
2851
  const tokensBefore = observedTokens ?? leafResult.tokensBefore;
2363
2852
 
2364
2853
  return {
2365
2854
  ok: true,
2366
2855
  compacted: leafResult.actionTaken,
2367
- reason: leafResult.actionTaken ? "compacted" : "below threshold",
2856
+ reason: leafResult.authFailure
2857
+ ? "provider auth failure"
2858
+ : leafResult.actionTaken
2859
+ ? "compacted"
2860
+ : "below threshold",
2368
2861
  result: {
2369
2862
  tokensBefore,
2370
2863
  tokensAfter: leafResult.tokensAfter,
@@ -2429,129 +2922,161 @@ export class LcmContextEngine implements ContextEngine {
2429
2922
 
2430
2923
  const conversationId = conversation.conversationId;
2431
2924
 
2432
- const legacyParams = asRecord(params.runtimeContext) ?? params.legacyParams;
2433
- const lp = legacyParams ?? {};
2434
- const manualCompactionRequested =
2435
- (
2436
- lp as {
2437
- manualCompaction?: unknown;
2438
- }
2439
- ).manualCompaction === true;
2440
- const forceCompaction = force || manualCompactionRequested;
2441
- const tokenBudget = this.resolveTokenBudget({
2442
- tokenBudget: params.tokenBudget,
2443
- runtimeContext: params.runtimeContext,
2444
- legacyParams,
2445
- });
2446
- if (!tokenBudget) {
2447
- return {
2448
- ok: false,
2449
- compacted: false,
2450
- reason: "missing token budget in compact params",
2451
- };
2452
- }
2453
-
2454
- const { summarize, summaryModel } = await this.resolveSummarize({
2455
- legacyParams,
2456
- customInstructions: params.customInstructions,
2457
- });
2458
-
2459
- // Evaluate whether compaction is needed (unless forced)
2460
- const observedTokens = this.normalizeObservedTokenCount(
2461
- params.currentTokenCount ??
2925
+ const legacyParams = asRecord(params.runtimeContext) ?? params.legacyParams;
2926
+ const lp = legacyParams ?? {};
2927
+ const manualCompactionRequested =
2462
2928
  (
2463
2929
  lp as {
2464
- currentTokenCount?: unknown;
2930
+ manualCompaction?: unknown;
2465
2931
  }
2466
- ).currentTokenCount,
2467
- );
2468
- const decision =
2469
- observedTokens !== undefined
2470
- ? await this.compaction.evaluate(conversationId, tokenBudget, observedTokens)
2471
- : await this.compaction.evaluate(conversationId, tokenBudget);
2472
- const targetTokens =
2473
- params.compactionTarget === "threshold" ? decision.threshold : tokenBudget;
2474
- const liveContextStillExceedsTarget =
2475
- observedTokens !== undefined && observedTokens >= targetTokens;
2476
-
2477
- if (!forceCompaction && !decision.shouldCompact) {
2478
- return {
2479
- ok: true,
2480
- compacted: false,
2481
- reason: "below threshold",
2482
- result: {
2483
- tokensBefore: decision.currentTokens,
2484
- },
2485
- };
2486
- }
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
+ }
2949
+
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
+ }
2487
2991
 
2488
- const useSweep =
2489
- manualCompactionRequested || forceCompaction || params.compactionTarget === "threshold";
2490
- if (useSweep) {
2491
- const sweepResult = await this.compaction.compactFullSweep({
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({
2492
3043
  conversationId,
2493
3044
  tokenBudget,
3045
+ targetTokens: convergenceTargetTokens,
3046
+ ...(observedTokens !== undefined ? { currentTokens: observedTokens } : {}),
2494
3047
  summarize,
2495
- force: forceCompaction,
2496
- hardTrigger: false,
2497
3048
  summaryModel,
2498
3049
  });
2499
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
+
2500
3059
  return {
2501
- ok: sweepResult.actionTaken || !liveContextStillExceedsTarget,
2502
- compacted: sweepResult.actionTaken,
2503
- reason: sweepResult.actionTaken
2504
- ? "compacted"
2505
- : manualCompactionRequested
2506
- ? "nothing to compact"
2507
- : liveContextStillExceedsTarget
2508
- ? "live context still exceeds target"
2509
- : "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",
2510
3071
  result: {
2511
3072
  tokensBefore: decision.currentTokens,
2512
- tokensAfter: sweepResult.tokensAfter,
3073
+ tokensAfter: compactResult.finalTokens,
2513
3074
  details: {
2514
- rounds: sweepResult.actionTaken ? 1 : 0,
2515
- targetTokens,
3075
+ rounds: compactResult.rounds,
3076
+ targetTokens: convergenceTargetTokens,
2516
3077
  },
2517
3078
  },
2518
3079
  };
2519
- }
2520
-
2521
- // When forced, use the token budget as target
2522
- const convergenceTargetTokens = forceCompaction
2523
- ? tokenBudget
2524
- : params.compactionTarget === "threshold"
2525
- ? decision.threshold
2526
- : tokenBudget;
2527
-
2528
- const compactResult = await this.compaction.compactUntilUnder({
2529
- conversationId,
2530
- tokenBudget,
2531
- targetTokens: convergenceTargetTokens,
2532
- ...(observedTokens !== undefined ? { currentTokens: observedTokens } : {}),
2533
- summarize,
2534
- summaryModel,
2535
- });
2536
- const didCompact = compactResult.rounds > 0;
2537
-
2538
- return {
2539
- ok: compactResult.success,
2540
- compacted: didCompact,
2541
- reason: compactResult.success
2542
- ? didCompact
2543
- ? "compacted"
2544
- : "already under target"
2545
- : "could not reach target",
2546
- result: {
2547
- tokensBefore: decision.currentTokens,
2548
- tokensAfter: compactResult.finalTokens,
2549
- details: {
2550
- rounds: compactResult.rounds,
2551
- targetTokens: convergenceTargetTokens,
2552
- },
2553
- },
2554
- };
2555
3080
  },
2556
3081
  );
2557
3082
  }
@@ -2662,6 +3187,90 @@ export class LcmContextEngine implements ContextEngine {
2662
3187
  // The shared connection is managed for the lifetime of the plugin process.
2663
3188
  }
2664
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
+
2665
3274
  // ── Public accessors for retrieval (used by subagent expansion) ─────────
2666
3275
 
2667
3276
  getRetrieval(): RetrievalEngine {