@martian-engineering/lossless-claw 0.1.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/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@martian-engineering/lossless-claw",
3
+ "version": "0.1.0",
4
+ "description": "Lossless Context Management plugin for OpenClaw — DAG-based conversation summarization with incremental compaction",
5
+ "type": "module",
6
+ "main": "index.ts",
7
+ "license": "MIT",
8
+ "author": "Josh Lehman <josh@martian.engineering>",
9
+ "keywords": [
10
+ "openclaw",
11
+ "openclaw-plugin",
12
+ "context-management",
13
+ "llm",
14
+ "summarization",
15
+ "conversation-memory",
16
+ "dag"
17
+ ],
18
+ "files": [
19
+ "index.ts",
20
+ "src/**/*.ts",
21
+ "docs/",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "dependencies": {
26
+ "@sinclair/typebox": "0.34.48"
27
+ },
28
+ "devDependencies": {
29
+ "typescript": "^5.7.0",
30
+ "vitest": "^3.0.0"
31
+ },
32
+ "peerDependencies": {
33
+ "openclaw": "*",
34
+ "@mariozechner/pi-agent-core": "*",
35
+ "@mariozechner/pi-ai": "*"
36
+ },
37
+ "openclaw": {
38
+ "extensions": [
39
+ "./index.ts"
40
+ ]
41
+ },
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/Martian-Engineering/lossless-claw.git"
45
+ },
46
+ "homepage": "https://github.com/Martian-Engineering/lossless-claw#readme",
47
+ "bugs": {
48
+ "url": "https://github.com/Martian-Engineering/lossless-claw/issues"
49
+ }
50
+ }
@@ -0,0 +1,513 @@
1
+ import type { ContextEngine } from "openclaw/plugin-sdk";
2
+ import { sanitizeToolUseResultPairing } from "./transcript-repair.js";
3
+ import type {
4
+ ConversationStore,
5
+ MessagePartRecord,
6
+ MessageRole,
7
+ } from "./store/conversation-store.js";
8
+ import type { SummaryStore, ContextItemRecord, SummaryRecord } from "./store/summary-store.js";
9
+
10
+ type AgentMessage = Parameters<ContextEngine["ingest"]>[0]["message"];
11
+
12
+ // ── Public types ─────────────────────────────────────────────────────────────
13
+
14
+ export interface AssembleContextInput {
15
+ conversationId: number;
16
+ tokenBudget: number;
17
+ /** Number of most recent raw turns to always include (default: 8) */
18
+ freshTailCount?: number;
19
+ }
20
+
21
+ export interface AssembleContextResult {
22
+ /** Ordered messages ready for the model */
23
+ messages: AgentMessage[];
24
+ /** Total estimated tokens */
25
+ estimatedTokens: number;
26
+ /** Stats about what was assembled */
27
+ stats: {
28
+ rawMessageCount: number;
29
+ summaryCount: number;
30
+ totalContextItems: number;
31
+ };
32
+ }
33
+
34
+ // ── Helpers ──────────────────────────────────────────────────────────────────
35
+
36
+ /** Simple token estimate: ~4 chars per token, same as VoltCode's Token.estimate */
37
+ function estimateTokens(text: string): number {
38
+ return Math.ceil(text.length / 4);
39
+ }
40
+
41
+ /**
42
+ * Map a DB message role to an AgentMessage role.
43
+ *
44
+ * user -> user
45
+ * assistant -> assistant
46
+ * system -> user (system prompts presented as user messages)
47
+ * tool -> assistant (tool results are part of assistant turns)
48
+ */
49
+ function parseJson(value: string | null): unknown {
50
+ if (typeof value !== "string" || !value.trim()) {
51
+ return undefined;
52
+ }
53
+ try {
54
+ return JSON.parse(value);
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ }
59
+
60
+ function getOriginalRole(parts: MessagePartRecord[]): string | null {
61
+ for (const part of parts) {
62
+ const decoded = parseJson(part.metadata);
63
+ if (!decoded || typeof decoded !== "object") {
64
+ continue;
65
+ }
66
+ const role = (decoded as { originalRole?: unknown }).originalRole;
67
+ if (typeof role === "string" && role.length > 0) {
68
+ return role;
69
+ }
70
+ }
71
+ return null;
72
+ }
73
+
74
+ function toRuntimeRole(
75
+ dbRole: MessageRole,
76
+ parts: MessagePartRecord[],
77
+ ): "user" | "assistant" | "toolResult" {
78
+ const originalRole = getOriginalRole(parts);
79
+ if (originalRole === "toolResult") {
80
+ return "toolResult";
81
+ }
82
+ if (originalRole === "assistant") {
83
+ return "assistant";
84
+ }
85
+ if (originalRole === "user") {
86
+ return "user";
87
+ }
88
+ if (originalRole === "system") {
89
+ // Runtime system prompts are managed via setSystemPrompt(), not message history.
90
+ return "user";
91
+ }
92
+
93
+ if (dbRole === "tool") {
94
+ return "toolResult";
95
+ }
96
+ if (dbRole === "assistant") {
97
+ return "assistant";
98
+ }
99
+ return "user"; // user | system
100
+ }
101
+
102
+ function blockFromPart(part: MessagePartRecord): unknown {
103
+ const decoded = parseJson(part.metadata);
104
+ if (decoded && typeof decoded === "object") {
105
+ const raw = (decoded as { raw?: unknown }).raw;
106
+ if (raw && typeof raw === "object") {
107
+ return raw;
108
+ }
109
+ }
110
+
111
+ if (part.partType === "text" || part.partType === "reasoning") {
112
+ return { type: "text", text: part.textContent ?? "" };
113
+ }
114
+ if (part.partType === "tool") {
115
+ const toolOutput = parseJson(part.toolOutput);
116
+ if (toolOutput !== undefined) {
117
+ return toolOutput;
118
+ }
119
+ if (typeof part.textContent === "string") {
120
+ return { type: "text", text: part.textContent };
121
+ }
122
+ return { type: "text", text: part.toolOutput ?? part.toolInput ?? "" };
123
+ }
124
+
125
+ if (typeof part.textContent === "string" && part.textContent.length > 0) {
126
+ return { type: "text", text: part.textContent };
127
+ }
128
+
129
+ const decodedFallback = parseJson(part.metadata);
130
+ if (decodedFallback && typeof decodedFallback === "object") {
131
+ return {
132
+ type: "text",
133
+ text: JSON.stringify(decodedFallback),
134
+ };
135
+ }
136
+ return { type: "text", text: "" };
137
+ }
138
+
139
+ function contentFromParts(
140
+ parts: MessagePartRecord[],
141
+ role: "user" | "assistant" | "toolResult",
142
+ fallbackContent: string,
143
+ ): unknown {
144
+ if (parts.length === 0) {
145
+ if (role === "assistant") {
146
+ return fallbackContent ? [{ type: "text", text: fallbackContent }] : [];
147
+ }
148
+ if (role === "toolResult") {
149
+ return [{ type: "text", text: fallbackContent }];
150
+ }
151
+ return fallbackContent;
152
+ }
153
+
154
+ const blocks = parts.map(blockFromPart);
155
+ if (
156
+ role === "user" &&
157
+ blocks.length === 1 &&
158
+ blocks[0] &&
159
+ typeof blocks[0] === "object" &&
160
+ (blocks[0] as { type?: unknown }).type === "text" &&
161
+ typeof (blocks[0] as { text?: unknown }).text === "string"
162
+ ) {
163
+ return (blocks[0] as { text: string }).text;
164
+ }
165
+ return blocks;
166
+ }
167
+
168
+ function pickToolCallId(parts: MessagePartRecord[]): string | undefined {
169
+ for (const part of parts) {
170
+ if (typeof part.toolCallId === "string" && part.toolCallId.length > 0) {
171
+ return part.toolCallId;
172
+ }
173
+ const decoded = parseJson(part.metadata);
174
+ if (!decoded || typeof decoded !== "object") {
175
+ continue;
176
+ }
177
+ const raw = (decoded as { raw?: unknown }).raw;
178
+ if (!raw || typeof raw !== "object") {
179
+ continue;
180
+ }
181
+ const maybe = (raw as { toolCallId?: unknown; tool_call_id?: unknown }).toolCallId;
182
+ if (typeof maybe === "string" && maybe.length > 0) {
183
+ return maybe;
184
+ }
185
+ const maybeSnake = (raw as { tool_call_id?: unknown }).tool_call_id;
186
+ if (typeof maybeSnake === "string" && maybeSnake.length > 0) {
187
+ return maybeSnake;
188
+ }
189
+ }
190
+ return undefined;
191
+ }
192
+
193
+ /** Format a Date for XML attributes in the agent's timezone. */
194
+ function formatDateForAttribute(date: Date, timezone?: string): string {
195
+ const tz = timezone ?? "UTC";
196
+ try {
197
+ const fmt = new Intl.DateTimeFormat("en-CA", {
198
+ timeZone: tz,
199
+ year: "numeric",
200
+ month: "2-digit",
201
+ day: "2-digit",
202
+ hour: "2-digit",
203
+ minute: "2-digit",
204
+ second: "2-digit",
205
+ hour12: false,
206
+ });
207
+ const p = Object.fromEntries(
208
+ fmt.formatToParts(date).map((part) => [part.type, part.value]),
209
+ );
210
+ return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}`;
211
+ } catch {
212
+ return date.toISOString();
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Format a summary record into the XML payload string the model sees.
218
+ */
219
+ async function formatSummaryContent(
220
+ summary: SummaryRecord,
221
+ summaryStore: SummaryStore,
222
+ timezone?: string,
223
+ ): Promise<string> {
224
+ const attributes = [
225
+ `id="${summary.summaryId}"`,
226
+ `kind="${summary.kind}"`,
227
+ `depth="${summary.depth}"`,
228
+ `descendant_count="${summary.descendantCount}"`,
229
+ ];
230
+ if (summary.earliestAt) {
231
+ attributes.push(`earliest_at="${formatDateForAttribute(summary.earliestAt, timezone)}"`);
232
+ }
233
+ if (summary.latestAt) {
234
+ attributes.push(`latest_at="${formatDateForAttribute(summary.latestAt, timezone)}"`);
235
+ }
236
+
237
+ const lines: string[] = [];
238
+ lines.push(`<summary ${attributes.join(" ")}>`);
239
+
240
+ // For condensed summaries, include parent references.
241
+ if (summary.kind === "condensed") {
242
+ const parents = await summaryStore.getSummaryParents(summary.summaryId);
243
+ if (parents.length > 0) {
244
+ lines.push(" <parents>");
245
+ for (const parent of parents) {
246
+ lines.push(` <summary_ref id="${parent.summaryId}" />`);
247
+ }
248
+ lines.push(" </parents>");
249
+ }
250
+ }
251
+
252
+ lines.push(" <content>");
253
+ lines.push(summary.content);
254
+ lines.push(" </content>");
255
+ lines.push("</summary>");
256
+ return lines.join("\n");
257
+ }
258
+
259
+ // ── Resolved context item (after fetching underlying message/summary) ────────
260
+
261
+ interface ResolvedItem {
262
+ /** Original ordinal from context_items table */
263
+ ordinal: number;
264
+ /** The AgentMessage ready for the model */
265
+ message: AgentMessage;
266
+ /** Estimated token count for this item */
267
+ tokens: number;
268
+ /** Whether this came from a raw message (vs. a summary) */
269
+ isMessage: boolean;
270
+ }
271
+
272
+ // ── ContextAssembler ─────────────────────────────────────────────────────────
273
+
274
+ export class ContextAssembler {
275
+ constructor(
276
+ private conversationStore: ConversationStore,
277
+ private summaryStore: SummaryStore,
278
+ private timezone?: string,
279
+ ) {}
280
+
281
+ /**
282
+ * Build model context under a token budget.
283
+ *
284
+ * 1. Fetch all context items for the conversation (ordered by ordinal).
285
+ * 2. Resolve each item into an AgentMessage (fetching the underlying
286
+ * message or summary record).
287
+ * 3. Protect the "fresh tail" (last N items) from truncation.
288
+ * 4. If over budget, drop oldest non-fresh items until we fit.
289
+ * 5. Return the final ordered messages in chronological order.
290
+ */
291
+ async assemble(input: AssembleContextInput): Promise<AssembleContextResult> {
292
+ const { conversationId, tokenBudget } = input;
293
+ const freshTailCount = input.freshTailCount ?? 8;
294
+
295
+ // Step 1: Get all context items ordered by ordinal
296
+ const contextItems = await this.summaryStore.getContextItems(conversationId);
297
+
298
+ if (contextItems.length === 0) {
299
+ return {
300
+ messages: [],
301
+ estimatedTokens: 0,
302
+ stats: { rawMessageCount: 0, summaryCount: 0, totalContextItems: 0 },
303
+ };
304
+ }
305
+
306
+ // Step 2: Resolve each context item into a ResolvedItem
307
+ const resolved = await this.resolveItems(contextItems);
308
+
309
+ // Count stats from the full (pre-truncation) set
310
+ let rawMessageCount = 0;
311
+ let summaryCount = 0;
312
+ for (const item of resolved) {
313
+ if (item.isMessage) {
314
+ rawMessageCount++;
315
+ } else {
316
+ summaryCount++;
317
+ }
318
+ }
319
+
320
+ // Step 3: Split into evictable prefix and protected fresh tail
321
+ const tailStart = Math.max(0, resolved.length - freshTailCount);
322
+ const freshTail = resolved.slice(tailStart);
323
+ const evictable = resolved.slice(0, tailStart);
324
+
325
+ // Step 4: Budget-aware selection
326
+ // First, compute the token cost of the fresh tail (always included).
327
+ let tailTokens = 0;
328
+ for (const item of freshTail) {
329
+ tailTokens += item.tokens;
330
+ }
331
+
332
+ // Fill remaining budget from evictable items, oldest first.
333
+ // If the fresh tail alone exceeds the budget we still include it
334
+ // (we never drop fresh items), but we skip all evictable items.
335
+ const remainingBudget = Math.max(0, tokenBudget - tailTokens);
336
+ const selected: ResolvedItem[] = [];
337
+ let evictableTokens = 0;
338
+
339
+ // Walk evictable items from oldest to newest. We want to keep as many
340
+ // older items as the budget allows; once we exceed the budget we start
341
+ // dropping the *oldest* items. To achieve this we first compute the
342
+ // total, then trim from the front.
343
+ const evictableTotalTokens = evictable.reduce((sum, it) => sum + it.tokens, 0);
344
+
345
+ if (evictableTotalTokens <= remainingBudget) {
346
+ // Everything fits
347
+ selected.push(...evictable);
348
+ evictableTokens = evictableTotalTokens;
349
+ } else {
350
+ // Need to drop oldest items until we fit.
351
+ // Walk from the END of evictable (newest first) accumulating tokens,
352
+ // then reverse to restore chronological order.
353
+ const kept: ResolvedItem[] = [];
354
+ let accum = 0;
355
+ for (let i = evictable.length - 1; i >= 0; i--) {
356
+ const item = evictable[i];
357
+ if (accum + item.tokens <= remainingBudget) {
358
+ kept.push(item);
359
+ accum += item.tokens;
360
+ } else {
361
+ // Once an item doesn't fit we stop — all older items are also dropped
362
+ break;
363
+ }
364
+ }
365
+ kept.reverse();
366
+ selected.push(...kept);
367
+ evictableTokens = accum;
368
+ }
369
+
370
+ // Append fresh tail after the evictable prefix
371
+ selected.push(...freshTail);
372
+
373
+ const estimatedTokens = evictableTokens + tailTokens;
374
+
375
+ // Normalize assistant string content to array blocks (some providers return
376
+ // content as a plain string; Anthropic expects content block arrays).
377
+ const rawMessages = selected.map((item) => item.message);
378
+ for (let i = 0; i < rawMessages.length; i++) {
379
+ const msg = rawMessages[i];
380
+ if (msg?.role === "assistant" && typeof msg.content === "string") {
381
+ rawMessages[i] = {
382
+ ...msg,
383
+ content: [{ type: "text", text: msg.content }] as unknown as typeof msg.content,
384
+ } as typeof msg;
385
+ }
386
+ }
387
+
388
+ return {
389
+ messages: sanitizeToolUseResultPairing(rawMessages) as AgentMessage[],
390
+ estimatedTokens,
391
+ stats: {
392
+ rawMessageCount,
393
+ summaryCount,
394
+ totalContextItems: resolved.length,
395
+ },
396
+ };
397
+ }
398
+
399
+ // ── Private helpers ──────────────────────────────────────────────────────
400
+
401
+ /**
402
+ * Resolve a list of context items into ResolvedItems by fetching the
403
+ * underlying message or summary record for each.
404
+ *
405
+ * Items that cannot be resolved (e.g. deleted message) are silently skipped.
406
+ */
407
+ private async resolveItems(contextItems: ContextItemRecord[]): Promise<ResolvedItem[]> {
408
+ const resolved: ResolvedItem[] = [];
409
+
410
+ for (const item of contextItems) {
411
+ const result = await this.resolveItem(item);
412
+ if (result) {
413
+ resolved.push(result);
414
+ }
415
+ }
416
+
417
+ return resolved;
418
+ }
419
+
420
+ /**
421
+ * Resolve a single context item.
422
+ */
423
+ private async resolveItem(item: ContextItemRecord): Promise<ResolvedItem | null> {
424
+ if (item.itemType === "message" && item.messageId != null) {
425
+ return this.resolveMessageItem(item);
426
+ }
427
+
428
+ if (item.itemType === "summary" && item.summaryId != null) {
429
+ return this.resolveSummaryItem(item);
430
+ }
431
+
432
+ // Malformed item — skip
433
+ return null;
434
+ }
435
+
436
+ /**
437
+ * Resolve a context item that references a raw message.
438
+ */
439
+ private async resolveMessageItem(item: ContextItemRecord): Promise<ResolvedItem | null> {
440
+ const msg = await this.conversationStore.getMessageById(item.messageId!);
441
+ if (!msg) {
442
+ return null;
443
+ }
444
+
445
+ const parts = await this.conversationStore.getMessageParts(msg.messageId);
446
+ const roleFromStore = toRuntimeRole(msg.role, parts);
447
+ const toolCallId = roleFromStore === "toolResult" ? pickToolCallId(parts) : undefined;
448
+ // Tool results without a call id cannot be serialized for Anthropic-compatible APIs.
449
+ // This happens for legacy/bootstrap rows that have role=tool but no message_parts.
450
+ // Preserve the text by degrading to assistant content instead of emitting invalid toolResult.
451
+ const role: "user" | "assistant" | "toolResult" =
452
+ roleFromStore === "toolResult" && !toolCallId ? "assistant" : roleFromStore;
453
+ const content = contentFromParts(parts, role, msg.content);
454
+ const contentText =
455
+ typeof content === "string" ? content : (JSON.stringify(content) ?? msg.content);
456
+ const tokenCount = msg.tokenCount > 0 ? msg.tokenCount : estimateTokens(contentText);
457
+
458
+ // Cast: these are reconstructed from DB storage, not live agent messages,
459
+ // so they won't carry the full AgentMessage metadata (timestamp, usage, etc.)
460
+ return {
461
+ ordinal: item.ordinal,
462
+ message:
463
+ role === "assistant"
464
+ ? ({
465
+ role,
466
+ content,
467
+ usage: {
468
+ input: 0,
469
+ output: tokenCount,
470
+ cacheRead: 0,
471
+ cacheWrite: 0,
472
+ totalTokens: tokenCount,
473
+ cost: {
474
+ input: 0,
475
+ output: 0,
476
+ cacheRead: 0,
477
+ cacheWrite: 0,
478
+ total: 0,
479
+ },
480
+ },
481
+ } as AgentMessage)
482
+ : ({
483
+ role,
484
+ content,
485
+ ...(toolCallId ? { toolCallId } : {}),
486
+ } as AgentMessage),
487
+ tokens: tokenCount,
488
+ isMessage: true,
489
+ };
490
+ }
491
+
492
+ /**
493
+ * Resolve a context item that references a summary.
494
+ * Summaries are presented as user messages with a structured XML wrapper.
495
+ */
496
+ private async resolveSummaryItem(item: ContextItemRecord): Promise<ResolvedItem | null> {
497
+ const summary = await this.summaryStore.getSummary(item.summaryId!);
498
+ if (!summary) {
499
+ return null;
500
+ }
501
+
502
+ const content = await formatSummaryContent(summary, this.summaryStore, this.timezone);
503
+ const tokens = estimateTokens(content);
504
+
505
+ // Cast: summaries are synthetic user messages without full AgentMessage metadata
506
+ return {
507
+ ordinal: item.ordinal,
508
+ message: { role: "user" as const, content } as AgentMessage,
509
+ tokens,
510
+ isMessage: false,
511
+ };
512
+ }
513
+ }