@martian-engineering/lossless-claw 0.6.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@martian-engineering/lossless-claw",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Lossless Context Management plugin for OpenClaw — DAG-based conversation summarization with incremental compaction",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/src/compaction.ts CHANGED
@@ -1558,12 +1558,7 @@ export class CompactionEngine {
1558
1558
  return { summaryId, level: condensed.level };
1559
1559
  }
1560
1560
 
1561
- /**
1562
- * Persist durable compaction events into canonical history as message parts.
1563
- *
1564
- * Event persistence is best-effort: failures are swallowed to avoid
1565
- * compromising the core compaction path.
1566
- */
1561
+ /** Emit compaction telemetry without mutating canonical conversation history. */
1567
1562
  private async persistCompactionEvents(input: {
1568
1563
  conversationId: number;
1569
1564
  tokensBefore: number;
@@ -1624,7 +1619,7 @@ export class CompactionEngine {
1624
1619
  }
1625
1620
  }
1626
1621
 
1627
- /** Write one compaction event message + part atomically where possible. */
1622
+ /** Log one compaction event without appending a synthetic chat message. */
1628
1623
  private async persistCompactionEvent(input: {
1629
1624
  conversationId: number;
1630
1625
  sessionId: string;
@@ -1637,43 +1632,8 @@ export class CompactionEngine {
1637
1632
  condensedPassOccurred: boolean;
1638
1633
  }): Promise<void> {
1639
1634
  const content = `LCM compaction ${input.pass} pass (${input.level}): ${input.tokensBefore} -> ${input.tokensAfter}`;
1640
- const metadata = JSON.stringify({
1641
- conversationId: input.conversationId,
1642
- pass: input.pass,
1643
- level: input.level,
1644
- tokensBefore: input.tokensBefore,
1645
- tokensAfter: input.tokensAfter,
1646
- createdSummaryId: input.createdSummaryId,
1647
- createdSummaryIds: input.createdSummaryIds,
1648
- condensedPassOccurred: input.condensedPassOccurred,
1649
- });
1650
-
1651
- const writeEvent = async (): Promise<void> => {
1652
- const seq = (await this.conversationStore.getMaxSeq(input.conversationId)) + 1;
1653
- const eventMessage = await this.conversationStore.createMessage({
1654
- conversationId: input.conversationId,
1655
- seq,
1656
- role: "system",
1657
- content,
1658
- tokenCount: estimateTokens(content),
1659
- });
1660
-
1661
- const parts: CreateMessagePartInput[] = [
1662
- {
1663
- sessionId: input.sessionId,
1664
- partType: "compaction",
1665
- ordinal: 0,
1666
- textContent: content,
1667
- metadata,
1668
- },
1669
- ];
1670
- await this.conversationStore.createMessageParts(eventMessage.messageId, parts);
1671
- };
1672
-
1673
- try {
1674
- await this.conversationStore.withTransaction(() => writeEvent());
1675
- } catch {
1676
- // Compaction should still succeed if event persistence fails.
1677
- }
1635
+ console.info(
1636
+ `[lcm] ${content} conversation=${input.conversationId} summary=${input.createdSummaryId}`,
1637
+ );
1678
1638
  }
1679
1639
  }
package/src/engine.ts CHANGED
@@ -2219,11 +2219,15 @@ export class LcmContextEngine implements ContextEngine {
2219
2219
  */
2220
2220
  private async deduplicateAfterTurnBatch(
2221
2221
  sessionId: string,
2222
+ sessionKey: string | undefined,
2222
2223
  batch: AgentMessage[],
2223
2224
  ): Promise<AgentMessage[]> {
2224
2225
  if (batch.length === 0) return batch;
2225
2226
 
2226
- const conversation = await this.conversationStore.getConversationBySessionId(sessionId);
2227
+ const conversation = await this.conversationStore.getConversationForSession({
2228
+ sessionId,
2229
+ sessionKey,
2230
+ });
2227
2231
  if (!conversation) return batch;
2228
2232
 
2229
2233
  const conversationId = conversation.conversationId;
@@ -2562,7 +2566,11 @@ export class LcmContextEngine implements ContextEngine {
2562
2566
  // full history. Run on newMessages BEFORE prepending autoCompactionSummary
2563
2567
  // so synthetic summaries cannot interfere with replay detection.
2564
2568
  const newMessages = params.messages.slice(params.prePromptMessageCount);
2565
- const dedupedNewMessages = await this.deduplicateAfterTurnBatch(params.sessionId, newMessages);
2569
+ const dedupedNewMessages = await this.deduplicateAfterTurnBatch(
2570
+ params.sessionId,
2571
+ params.sessionKey,
2572
+ newMessages,
2573
+ );
2566
2574
 
2567
2575
  const ingestBatch: AgentMessage[] = [];
2568
2576
  if (params.autoCompactionSummary) {
@@ -2593,6 +2601,29 @@ export class LcmContextEngine implements ContextEngine {
2593
2601
  return;
2594
2602
  }
2595
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
+
2596
2627
  const legacyParams = asRecord(params.runtimeContext) ?? asRecord(params.legacyCompactionParams);
2597
2628
  const DEFAULT_AFTER_TURN_TOKEN_BUDGET = 128_000;
2598
2629
  const resolvedTokenBudget = this.resolveTokenBudget({
@@ -3334,16 +3365,11 @@ export class LcmContextEngine implements ContextEngine {
3334
3365
  if (!turnMessages.some((record) => record.role === "user")) {
3335
3366
  continue;
3336
3367
  }
3337
- if (turnMessages.some((record) => record.role === "tool")) {
3368
+ if (!turnLooksLikeHeartbeatTurn(turnMessages)) {
3338
3369
  continue;
3339
3370
  }
3340
3371
 
3341
- const messageIds = turnMessages.map((record) => record.messageId);
3342
- const hasToolParts = await this.turnHasToolInteractions(messageIds);
3343
- if (hasToolParts) {
3344
- continue;
3345
- }
3346
- toDelete.push(...messageIds);
3372
+ toDelete.push(...turnMessages.map((record) => record.messageId));
3347
3373
  }
3348
3374
 
3349
3375
  if (toDelete.length === 0) {
@@ -3354,45 +3380,12 @@ export class LcmContextEngine implements ContextEngine {
3354
3380
  const uniqueIds = [...new Set(toDelete)];
3355
3381
  return this.conversationStore.deleteMessages(uniqueIds);
3356
3382
  }
3357
-
3358
- private async turnHasToolInteractions(messageIds: number[]): Promise<boolean> {
3359
- for (const messageId of messageIds) {
3360
- const parts = await this.conversationStore.getMessageParts(messageId);
3361
- if (parts.some(messagePartIndicatesToolUsage)) {
3362
- return true;
3363
- }
3364
- }
3365
- return false;
3366
- }
3367
- }
3368
-
3369
- // ── Tool-part detection ──────────────────────────────────────────────────────
3370
-
3371
- const TOOL_PART_TYPES: ReadonlySet<string> = new Set(["tool"]);
3372
-
3373
- function messagePartIndicatesToolUsage(part: MessagePartRecord): boolean {
3374
- if (TOOL_PART_TYPES.has(part.partType)) {
3375
- return true;
3376
- }
3377
- if (part.toolCallId || part.toolName || part.toolInput || part.toolOutput) {
3378
- return true;
3379
- }
3380
- if (typeof part.metadata === "string" && part.metadata.length > 0) {
3381
- try {
3382
- const meta = JSON.parse(part.metadata) as Record<string, unknown>;
3383
- if (typeof meta.rawType === "string" && TOOL_RAW_TYPES.has(meta.rawType)) {
3384
- return true;
3385
- }
3386
- } catch {
3387
- // ignore
3388
- }
3389
- }
3390
- return false;
3391
3383
  }
3392
3384
 
3393
3385
  // ── Heartbeat detection ─────────────────────────────────────────────────────
3394
3386
 
3395
3387
  const HEARTBEAT_OK_TOKEN = "heartbeat_ok";
3388
+ const HEARTBEAT_TURN_MARKER = "heartbeat.md";
3396
3389
 
3397
3390
  /**
3398
3391
  * Detect whether an assistant message is a heartbeat ack.
@@ -3404,6 +3397,32 @@ function isHeartbeatOkContent(content: string): boolean {
3404
3397
  return content.trim().toLowerCase() === HEARTBEAT_OK_TOKEN;
3405
3398
  }
3406
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
+
3407
3426
  // ── Emergency fallback summarization ────────────────────────────────────────
3408
3427
 
3409
3428
  /**