@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 +1 -1
- package/src/compaction.ts +5 -45
- package/src/engine.ts +62 -43
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@martian-engineering/lossless-claw",
|
|
3
|
-
"version": "0.6.
|
|
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
|
-
/**
|
|
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
|
-
|
|
1641
|
-
conversationId
|
|
1642
|
-
|
|
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.
|
|
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(
|
|
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
|
|
3368
|
+
if (!turnLooksLikeHeartbeatTurn(turnMessages)) {
|
|
3338
3369
|
continue;
|
|
3339
3370
|
}
|
|
3340
3371
|
|
|
3341
|
-
|
|
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
|
/**
|