@martian-engineering/lossless-claw 0.7.0 → 0.8.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.
Files changed (54) hide show
  1. package/README.md +19 -3
  2. package/dist/index.js +19240 -0
  3. package/docs/agent-tools.md +9 -4
  4. package/docs/configuration.md +24 -5
  5. package/openclaw.plugin.json +27 -3
  6. package/package.json +7 -6
  7. package/skills/lossless-claw/SKILL.md +3 -2
  8. package/skills/lossless-claw/references/architecture.md +12 -0
  9. package/skills/lossless-claw/references/config.md +37 -0
  10. package/skills/lossless-claw/references/diagnostics.md +13 -0
  11. package/index.ts +0 -2
  12. package/src/assembler.ts +0 -1188
  13. package/src/compaction.ts +0 -1756
  14. package/src/db/config.ts +0 -345
  15. package/src/db/connection.ts +0 -141
  16. package/src/db/features.ts +0 -42
  17. package/src/db/migration.ts +0 -746
  18. package/src/engine.ts +0 -4306
  19. package/src/expansion-auth.ts +0 -365
  20. package/src/expansion-policy.ts +0 -303
  21. package/src/expansion.ts +0 -383
  22. package/src/integrity.ts +0 -600
  23. package/src/large-files.ts +0 -546
  24. package/src/lcm-log.ts +0 -37
  25. package/src/openclaw-bridge.ts +0 -22
  26. package/src/plugin/index.ts +0 -1960
  27. package/src/plugin/lcm-command.ts +0 -765
  28. package/src/plugin/lcm-doctor-apply.ts +0 -542
  29. package/src/plugin/lcm-doctor-shared.ts +0 -210
  30. package/src/plugin/shared-init.ts +0 -59
  31. package/src/prune.ts +0 -391
  32. package/src/retrieval.ts +0 -363
  33. package/src/session-patterns.ts +0 -23
  34. package/src/startup-banner-log.ts +0 -49
  35. package/src/store/compaction-telemetry-store.ts +0 -156
  36. package/src/store/conversation-store.ts +0 -929
  37. package/src/store/fts5-sanitize.ts +0 -50
  38. package/src/store/full-text-fallback.ts +0 -83
  39. package/src/store/full-text-sort.ts +0 -21
  40. package/src/store/index.ts +0 -39
  41. package/src/store/parse-utc-timestamp.ts +0 -25
  42. package/src/store/summary-store.ts +0 -1519
  43. package/src/summarize.ts +0 -1511
  44. package/src/tools/common.ts +0 -53
  45. package/src/tools/lcm-conversation-scope.ts +0 -127
  46. package/src/tools/lcm-describe-tool.ts +0 -245
  47. package/src/tools/lcm-expand-query-tool.ts +0 -831
  48. package/src/tools/lcm-expand-tool.delegation.ts +0 -580
  49. package/src/tools/lcm-expand-tool.ts +0 -453
  50. package/src/tools/lcm-expansion-recursion-guard.ts +0 -373
  51. package/src/tools/lcm-grep-tool.ts +0 -228
  52. package/src/transaction-mutex.ts +0 -136
  53. package/src/transcript-repair.ts +0 -301
  54. package/src/types.ts +0 -165
package/src/assembler.ts DELETED
@@ -1,1188 +0,0 @@
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
- const TOOL_CALL_TYPES = new Set([
13
- "toolCall",
14
- "toolUse",
15
- "tool_use",
16
- "tool-use",
17
- "functionCall",
18
- "function_call",
19
- ]);
20
-
21
- // ── Public types ─────────────────────────────────────────────────────────────
22
-
23
- export interface AssembleContextInput {
24
- conversationId: number;
25
- tokenBudget: number;
26
- /** Number of most recent raw turns to always include (default: 8) */
27
- freshTailCount?: number;
28
- /** Optional user query for relevance-based eviction scoring (BM25-lite). When absent or unsearchable, falls back to chronological eviction. */
29
- prompt?: string;
30
- }
31
-
32
- export interface AssembleContextResult {
33
- /** Ordered messages ready for the model */
34
- messages: AgentMessage[];
35
- /** Total estimated tokens */
36
- estimatedTokens: number;
37
- /** Optional dynamic system prompt guidance derived from DAG state */
38
- systemPromptAddition?: string;
39
- /** Stats about what was assembled */
40
- stats: {
41
- rawMessageCount: number;
42
- summaryCount: number;
43
- totalContextItems: number;
44
- };
45
- }
46
-
47
- // ── Helpers ──────────────────────────────────────────────────────────────────
48
-
49
- /** Simple token estimate: ~4 chars per token, same as VoltCode's Token.estimate */
50
- function estimateTokens(text: string): number {
51
- return Math.ceil(text.length / 4);
52
- }
53
-
54
- type SummaryPromptSignal = Pick<SummaryRecord, "kind" | "depth" | "descendantCount">;
55
-
56
- /**
57
- * Build dynamic prompt guidance for compacted session context.
58
- *
59
- * Guidance is emitted only when summaries are present in assembled context.
60
- * Static recall policy lives in the plugin prompt hook so this addition
61
- * remains session-specific and reflects only the current compaction state.
62
- */
63
- function buildSystemPromptAddition(summarySignals: SummaryPromptSignal[]): string | undefined {
64
- if (summarySignals.length === 0) {
65
- return undefined;
66
- }
67
-
68
- const maxDepth = summarySignals.reduce((deepest, signal) => Math.max(deepest, signal.depth), 0);
69
- const condensedCount = summarySignals.filter((signal) => signal.kind === "condensed").length;
70
- const heavilyCompacted = maxDepth >= 2 || condensedCount >= 2;
71
-
72
- const sections: string[] = [];
73
-
74
- // Dynamic compaction reminder — always present when summaries exist.
75
- sections.push(
76
- "## Compacted Conversation Context",
77
- "",
78
- "Summaries above are compressed context, not full detail.",
79
- "",
80
- "Treat summaries as compressed recall cues rather than proof of exact wording or exact values.",
81
- "",
82
- "If a summary includes an \"Expand for details about:\" footer, use it as a cue to expand before asserting specifics.",
83
- );
84
-
85
- // Precision/evidence rules — always present but stronger when heavily compacted.
86
- if (heavilyCompacted) {
87
- sections.push(
88
- "",
89
- "**Deeply compacted context: expand before asserting specifics.**",
90
- "",
91
- "Before answering with exact commands, SHAs, paths, timestamps, config values, or causal chains, expand for the missing detail.",
92
- "",
93
- "Default recall flow for precision work:",
94
- "1) `lcm_grep` to locate relevant summary/message IDs",
95
- "2) `lcm_expand_query` with a focused prompt",
96
- "3) Answer directly from the retrieved evidence",
97
- "",
98
- "Keep raw summary IDs in tool context for follow-up; do not include them in the user-facing answer unless the user asks for sources or IDs.",
99
- "",
100
- "`lcm_grep` tips: prefer `mode: \"full_text\"` for keyword/topic lookup, quote exact multi-word phrases, use `sort: \"relevance\"` for older-topic retrieval, and use `sort: \"hybrid\"` when recency should still influence ranking.",
101
- "",
102
- "**Uncertainty checklist (run before answering):**",
103
- "- Am I making an exact factual claim from a compressed or condensed summary?",
104
- "- Could compaction have omitted a crucial detail?",
105
- "- Would I need an expansion step if the user asks for proof or the exact text?",
106
- "- Should I state uncertainty instead of asserting specifics until I expand?",
107
- "",
108
- "If yes to any item, expand first or explicitly say that you need to expand.",
109
- "",
110
- "Do not guess exact commands, SHAs, file paths, timestamps, config values, or causal claims from condensed summaries. Expand first or explicitly say that you need to expand.",
111
- );
112
- } else {
113
- sections.push(
114
- "",
115
- "For exact commands, SHAs, paths, timestamps, config values, or causal chains, expand for details before answering.",
116
- "State uncertainty instead of guessing from compressed summaries.",
117
- );
118
- }
119
-
120
- return sections.join("\n");
121
- }
122
-
123
- /**
124
- * Map a DB message role to an AgentMessage role.
125
- *
126
- * user -> user
127
- * assistant -> assistant
128
- * system -> user (system prompts presented as user messages)
129
- * tool -> assistant (tool results are part of assistant turns)
130
- */
131
- function parseJson(value: string | null): unknown {
132
- if (typeof value !== "string" || !value.trim()) {
133
- return undefined;
134
- }
135
- try {
136
- return JSON.parse(value);
137
- } catch {
138
- return undefined;
139
- }
140
- }
141
-
142
- function getOriginalRole(parts: MessagePartRecord[]): string | null {
143
- for (const part of parts) {
144
- const decoded = parseJson(part.metadata);
145
- if (!decoded || typeof decoded !== "object") {
146
- continue;
147
- }
148
- const role = (decoded as { originalRole?: unknown }).originalRole;
149
- if (typeof role === "string" && role.length > 0) {
150
- return role;
151
- }
152
- }
153
- return null;
154
- }
155
-
156
- function getPartMetadata(part: MessagePartRecord): {
157
- originalRole?: string;
158
- rawType?: string;
159
- raw?: unknown;
160
- } {
161
- const decoded = parseJson(part.metadata);
162
- if (!decoded || typeof decoded !== "object") {
163
- return {};
164
- }
165
-
166
- const record = decoded as {
167
- originalRole?: unknown;
168
- rawType?: unknown;
169
- raw?: unknown;
170
- };
171
- return {
172
- originalRole:
173
- typeof record.originalRole === "string" && record.originalRole.length > 0
174
- ? record.originalRole
175
- : undefined,
176
- rawType:
177
- typeof record.rawType === "string" && record.rawType.length > 0
178
- ? record.rawType
179
- : undefined,
180
- raw: record.raw,
181
- };
182
- }
183
-
184
- function parseStoredValue(value: string | null): unknown {
185
- if (typeof value !== "string" || value.length === 0) {
186
- return undefined;
187
- }
188
- const parsed = parseJson(value);
189
- return parsed !== undefined ? parsed : value;
190
- }
191
-
192
- function reasoningBlockFromPart(part: MessagePartRecord, rawType?: string): unknown {
193
- const type = rawType === "thinking" ? "thinking" : "reasoning";
194
- if (typeof part.textContent === "string" && part.textContent.length > 0) {
195
- return type === "thinking"
196
- ? { type, thinking: part.textContent }
197
- : { type, text: part.textContent };
198
- }
199
- return { type };
200
- }
201
-
202
- /**
203
- * Detect if a raw block is an OpenClaw-normalised OpenAI reasoning item.
204
- * OpenClaw converts OpenAI `{type:"reasoning", id:"rs_…", encrypted_content:"…"}`
205
- * into `{type:"thinking", thinking:"", thinkingSignature:"{…}"}`.
206
- * When we reassemble for the OpenAI provider we need the original back.
207
- */
208
- function tryRestoreOpenAIReasoning(raw: Record<string, unknown>): Record<string, unknown> | null {
209
- if (raw.type !== "thinking") return null;
210
- const sig = raw.thinkingSignature;
211
- if (typeof sig !== "string" || !sig.startsWith("{")) return null;
212
- try {
213
- const parsed = JSON.parse(sig) as Record<string, unknown>;
214
- if (parsed.type === "reasoning" && typeof parsed.id === "string") {
215
- return parsed;
216
- }
217
- } catch {
218
- // not valid JSON — leave as-is
219
- }
220
- return null;
221
- }
222
-
223
- /** @internal Exported for testing only. */
224
- export function toolCallBlockFromPart(part: MessagePartRecord, rawType?: string): unknown {
225
- const type =
226
- rawType === "function_call" ||
227
- rawType === "functionCall" ||
228
- rawType === "tool_use" ||
229
- rawType === "tool-use" ||
230
- rawType === "toolUse" ||
231
- rawType === "toolCall"
232
- ? rawType
233
- : "toolCall";
234
- const input = parseStoredValue(part.toolInput);
235
- const block: Record<string, unknown> = { type };
236
-
237
- if (type === "function_call") {
238
- if (typeof part.toolCallId === "string" && part.toolCallId.length > 0) {
239
- block.call_id = part.toolCallId;
240
- }
241
- if (typeof part.toolName === "string" && part.toolName.length > 0) {
242
- block.name = part.toolName;
243
- }
244
- if (input !== undefined) {
245
- block.arguments = input;
246
- }
247
- return block;
248
- }
249
-
250
- // Always set id — downstream providers (e.g. Anthropic) call
251
- // normalizeToolCallId(block.id) which crashes on undefined.
252
- block.id =
253
- typeof part.toolCallId === "string" && part.toolCallId.length > 0
254
- ? part.toolCallId
255
- : `toolu_lcm_${part.partId ?? "unknown"}`;
256
- if (typeof part.toolName === "string" && part.toolName.length > 0) {
257
- block.name = part.toolName;
258
- }
259
-
260
- if (input !== undefined) {
261
- // toolCall and functionCall use "arguments" (consumed by OpenAI/xAI Chat
262
- // Completions extractToolCalls and Responses API paths in OpenClaw).
263
- // tool_use and variants use "input" (Anthropic native format).
264
- if (type === "functionCall" || type === "toolCall") {
265
- block.arguments = input;
266
- } else {
267
- block.input = input;
268
- }
269
- }
270
- return block;
271
- }
272
-
273
- /** @internal Exported for testing only. */
274
- export function toolResultBlockFromPart(
275
- part: MessagePartRecord,
276
- rawType?: string,
277
- raw?: Record<string, unknown>,
278
- ): unknown {
279
- if (
280
- raw &&
281
- typeof raw.text === "string" &&
282
- raw.output === undefined &&
283
- raw.content === undefined &&
284
- (part.toolOutput == null || part.toolOutput === "") &&
285
- (part.textContent == null || part.textContent === raw.text)
286
- ) {
287
- return {
288
- type: "text",
289
- text: raw.text,
290
- };
291
- }
292
-
293
- const type =
294
- rawType === "function_call_output" || rawType === "toolResult" || rawType === "tool_result"
295
- ? rawType
296
- : "tool_result";
297
- const output = parseStoredValue(part.toolOutput);
298
- const block: Record<string, unknown> = { type };
299
-
300
- if (typeof part.toolName === "string" && part.toolName.length > 0) {
301
- block.name = part.toolName;
302
- }
303
-
304
- if (output !== undefined) {
305
- block.output = output;
306
- } else if (typeof part.textContent === "string") {
307
- block.output = part.textContent;
308
- } else if (raw && raw.output !== undefined) {
309
- block.output = raw.output;
310
- } else if (raw && raw.content !== undefined) {
311
- block.content = raw.content;
312
- } else {
313
- block.output = "";
314
- }
315
-
316
- if (raw && typeof raw.is_error === "boolean") {
317
- block.is_error = raw.is_error;
318
- } else if (raw && typeof raw.isError === "boolean") {
319
- block.isError = raw.isError;
320
- }
321
-
322
- if (type === "function_call_output") {
323
- if (typeof part.toolCallId === "string" && part.toolCallId.length > 0) {
324
- block.call_id = part.toolCallId;
325
- }
326
- return block;
327
- }
328
-
329
- if (typeof part.toolCallId === "string" && part.toolCallId.length > 0) {
330
- block.tool_use_id = part.toolCallId;
331
- }
332
- return block;
333
- }
334
-
335
- function toRuntimeRole(
336
- dbRole: MessageRole,
337
- parts: MessagePartRecord[],
338
- ): "user" | "assistant" | "toolResult" {
339
- const originalRole = getOriginalRole(parts);
340
- if (originalRole === "toolResult") {
341
- return "toolResult";
342
- }
343
- if (originalRole === "assistant") {
344
- return "assistant";
345
- }
346
- if (originalRole === "user") {
347
- return "user";
348
- }
349
- if (originalRole === "system") {
350
- // Runtime system prompts are managed via setSystemPrompt(), not message history.
351
- return "user";
352
- }
353
-
354
- if (dbRole === "tool") {
355
- return "toolResult";
356
- }
357
- if (dbRole === "assistant") {
358
- return "assistant";
359
- }
360
- return "user"; // user | system
361
- }
362
-
363
- /** @internal Exported for testing only. */
364
- export function blockFromPart(part: MessagePartRecord): unknown {
365
- const metadata = getPartMetadata(part);
366
- if (metadata.raw && typeof metadata.raw === "object") {
367
- // If this is an OpenClaw-normalised OpenAI reasoning block, restore the original
368
- // OpenAI format so the Responses API gets the {type:"reasoning", id:"rs_…"} it expects.
369
- const restored = tryRestoreOpenAIReasoning(metadata.raw as Record<string, unknown>);
370
- if (restored) return restored;
371
-
372
- // Don't return raw for tool call/result blocks — they need to go through
373
- // toolCallBlockFromPart/toolResultBlockFromPart which properly normalize
374
- // arguments (stringify if object) and format for the target provider.
375
- // Returning raw here causes arguments to be passed as a JS object instead
376
- // of a JSON string, which breaks xAI/OpenAI Chat Completions API (422).
377
- const rawType = (metadata.raw as Record<string, unknown>).type as string | undefined;
378
- const isToolBlock =
379
- rawType === "toolCall" ||
380
- rawType === "tool_use" ||
381
- rawType === "tool-use" ||
382
- rawType === "toolUse" ||
383
- rawType === "functionCall" ||
384
- rawType === "function_call" ||
385
- rawType === "function_call_output" ||
386
- rawType === "toolResult" ||
387
- rawType === "tool_result";
388
- if (!isToolBlock) {
389
- return metadata.raw;
390
- }
391
-
392
- // When tool blocks are routed through toolCallBlockFromPart (below) instead
393
- // of returning raw directly, the function reads part.toolCallId / part.toolName
394
- // from the DB columns. For rows stored as part_type='text' those columns are
395
- // often NULL — the values only live inside metadata.raw. Backfill them here
396
- // so the reconstructed block keeps the original id/name.
397
- const rawRecord = metadata.raw as Record<string, unknown>;
398
- const rawToolCallId =
399
- typeof rawRecord.id === "string" && rawRecord.id.length > 0
400
- ? rawRecord.id
401
- : typeof rawRecord.call_id === "string" && rawRecord.call_id.length > 0
402
- ? rawRecord.call_id
403
- : undefined;
404
- if (rawToolCallId) {
405
- if (typeof part.toolCallId !== "string" || part.toolCallId.length === 0) {
406
- part.toolCallId = rawToolCallId;
407
- }
408
- }
409
- if (typeof rawRecord.name === "string" && rawRecord.name.length > 0) {
410
- if (typeof part.toolName !== "string" || part.toolName.length === 0) {
411
- part.toolName = rawRecord.name;
412
- }
413
- }
414
- // Backfill toolInput from raw arguments/input so toolCallBlockFromPart
415
- // can reconstruct the full block.
416
- if (part.toolInput == null || part.toolInput === "") {
417
- const rawArgs = rawRecord.arguments ?? rawRecord.input;
418
- if (rawArgs !== undefined) {
419
- part.toolInput = typeof rawArgs === "string" ? rawArgs : JSON.stringify(rawArgs);
420
- }
421
- }
422
- }
423
-
424
- if (part.partType === "reasoning") {
425
- return reasoningBlockFromPart(part, metadata.rawType);
426
- }
427
- if (part.partType === "tool") {
428
- if (metadata.originalRole === "toolResult" || metadata.rawType === "function_call_output") {
429
- return toolResultBlockFromPart(
430
- part,
431
- metadata.rawType,
432
- metadata.raw && typeof metadata.raw === "object"
433
- ? (metadata.raw as Record<string, unknown>)
434
- : undefined,
435
- );
436
- }
437
- return toolCallBlockFromPart(part, metadata.rawType);
438
- }
439
- if (
440
- metadata.rawType === "function_call" ||
441
- metadata.rawType === "functionCall" ||
442
- metadata.rawType === "tool_use" ||
443
- metadata.rawType === "tool-use" ||
444
- metadata.rawType === "toolUse" ||
445
- metadata.rawType === "toolCall"
446
- ) {
447
- return toolCallBlockFromPart(part, metadata.rawType);
448
- }
449
- if (
450
- metadata.rawType === "function_call_output" ||
451
- metadata.rawType === "tool_result" ||
452
- metadata.rawType === "toolResult"
453
- ) {
454
- return toolResultBlockFromPart(
455
- part,
456
- metadata.rawType,
457
- metadata.raw && typeof metadata.raw === "object"
458
- ? (metadata.raw as Record<string, unknown>)
459
- : undefined,
460
- );
461
- }
462
- if (part.partType === "text") {
463
- return { type: "text", text: part.textContent ?? "" };
464
- }
465
-
466
- if (typeof part.textContent === "string" && part.textContent.length > 0) {
467
- return { type: "text", text: part.textContent };
468
- }
469
-
470
- const decodedFallback = parseJson(part.metadata);
471
- if (decodedFallback && typeof decodedFallback === "object") {
472
- return {
473
- type: "text",
474
- text: JSON.stringify(decodedFallback),
475
- };
476
- }
477
- return { type: "text", text: "" };
478
- }
479
-
480
- /** @internal Exported for transcript-maintenance reconstruction. */
481
- export function contentFromParts(
482
- parts: MessagePartRecord[],
483
- role: "user" | "assistant" | "toolResult",
484
- fallbackContent: string,
485
- ): unknown {
486
- if (parts.length === 0) {
487
- if (role === "assistant") {
488
- return fallbackContent ? [{ type: "text", text: fallbackContent }] : [];
489
- }
490
- if (role === "toolResult") {
491
- return [{ type: "text", text: fallbackContent }];
492
- }
493
- return fallbackContent;
494
- }
495
-
496
- const blocks = parts.map(blockFromPart);
497
- if (
498
- role === "user" &&
499
- blocks.length === 1 &&
500
- blocks[0] &&
501
- typeof blocks[0] === "object" &&
502
- (blocks[0] as { type?: unknown }).type === "text" &&
503
- typeof (blocks[0] as { text?: unknown }).text === "string"
504
- ) {
505
- return (blocks[0] as { text: string }).text;
506
- }
507
- return blocks;
508
- }
509
-
510
- /** @internal Exported for transcript-maintenance reconstruction. */
511
- export function pickToolCallId(parts: MessagePartRecord[]): string | undefined {
512
- for (const part of parts) {
513
- if (typeof part.toolCallId === "string" && part.toolCallId.length > 0) {
514
- return part.toolCallId;
515
- }
516
- const decoded = parseJson(part.metadata);
517
- if (!decoded || typeof decoded !== "object") {
518
- continue;
519
- }
520
- const metadataToolCallId = (decoded as { toolCallId?: unknown }).toolCallId;
521
- if (typeof metadataToolCallId === "string" && metadataToolCallId.length > 0) {
522
- return metadataToolCallId;
523
- }
524
- const raw = (decoded as { raw?: unknown }).raw;
525
- if (!raw || typeof raw !== "object") {
526
- continue;
527
- }
528
- const maybe = (raw as { toolCallId?: unknown; tool_call_id?: unknown }).toolCallId;
529
- if (typeof maybe === "string" && maybe.length > 0) {
530
- return maybe;
531
- }
532
- const maybeSnake = (raw as { tool_call_id?: unknown }).tool_call_id;
533
- if (typeof maybeSnake === "string" && maybeSnake.length > 0) {
534
- return maybeSnake;
535
- }
536
- }
537
- return undefined;
538
- }
539
-
540
- /** @internal Exported for transcript-maintenance reconstruction. */
541
- export function pickToolName(parts: MessagePartRecord[]): string | undefined {
542
- for (const part of parts) {
543
- if (typeof part.toolName === "string" && part.toolName.length > 0) {
544
- return part.toolName;
545
- }
546
- const decoded = parseJson(part.metadata);
547
- if (!decoded || typeof decoded !== "object") {
548
- continue;
549
- }
550
- const metadataToolName = (decoded as { toolName?: unknown }).toolName;
551
- if (typeof metadataToolName === "string" && metadataToolName.length > 0) {
552
- return metadataToolName;
553
- }
554
- const raw = (decoded as { raw?: unknown }).raw;
555
- if (!raw || typeof raw !== "object") {
556
- continue;
557
- }
558
- const maybe = (raw as { name?: unknown }).name;
559
- if (typeof maybe === "string" && maybe.length > 0) {
560
- return maybe;
561
- }
562
- const maybeCamel = (raw as { toolName?: unknown }).toolName;
563
- if (typeof maybeCamel === "string" && maybeCamel.length > 0) {
564
- return maybeCamel;
565
- }
566
- }
567
- return undefined;
568
- }
569
-
570
- /** @internal Exported for transcript-maintenance reconstruction. */
571
- export function pickToolIsError(parts: MessagePartRecord[]): boolean | undefined {
572
- for (const part of parts) {
573
- const decoded = parseJson(part.metadata);
574
- if (!decoded || typeof decoded !== "object") {
575
- continue;
576
- }
577
- const metadataIsError = (decoded as { isError?: unknown }).isError;
578
- if (typeof metadataIsError === "boolean") {
579
- return metadataIsError;
580
- }
581
- }
582
- return undefined;
583
- }
584
-
585
- function extractToolCallId(block: { id?: unknown; call_id?: unknown }): string | null {
586
- if (typeof block.id === "string" && block.id.length > 0) {
587
- return block.id;
588
- }
589
- if (typeof block.call_id === "string" && block.call_id.length > 0) {
590
- return block.call_id;
591
- }
592
- return null;
593
- }
594
-
595
- function extractToolCallIdsFromAssistant(message: AgentMessage): string[] {
596
- if (message?.role !== "assistant" || !Array.isArray(message.content)) {
597
- return [];
598
- }
599
-
600
- const ids: string[] = [];
601
- for (const block of message.content) {
602
- if (!block || typeof block !== "object") {
603
- continue;
604
- }
605
- const record = block as { type?: unknown; id?: unknown; call_id?: unknown };
606
- if (typeof record.type !== "string" || !TOOL_CALL_TYPES.has(record.type)) {
607
- continue;
608
- }
609
- const id = extractToolCallId(record);
610
- if (id) {
611
- ids.push(id);
612
- }
613
- }
614
- return ids;
615
- }
616
-
617
- function extractToolResultIdFromMessage(message: AgentMessage): string | null {
618
- if (!message || typeof message !== "object") {
619
- return null;
620
- }
621
- if (typeof message.toolCallId === "string" && message.toolCallId.length > 0) {
622
- return message.toolCallId;
623
- }
624
- if (typeof message.toolUseId === "string" && message.toolUseId.length > 0) {
625
- return message.toolUseId;
626
- }
627
- return null;
628
- }
629
-
630
- function collectAssistantToolCallIds(items: ResolvedItem[]): Set<string> {
631
- const ids = new Set<string>();
632
- for (const item of items) {
633
- for (const id of extractToolCallIdsFromAssistant(item.message)) {
634
- ids.add(id);
635
- }
636
- }
637
- return ids;
638
- }
639
-
640
- function mergeFreshTailWithMatchingToolResults(
641
- freshTail: ResolvedItem[],
642
- matchingToolResults: ResolvedItem[],
643
- ): ResolvedItem[] {
644
- if (matchingToolResults.length === 0) {
645
- return freshTail;
646
- }
647
-
648
- const resultsById = new Map<string, ResolvedItem[]>();
649
- for (const item of matchingToolResults) {
650
- const toolResultId = extractToolResultIdFromMessage(item.message);
651
- if (!toolResultId) {
652
- continue;
653
- }
654
- const existing = resultsById.get(toolResultId);
655
- if (existing) {
656
- existing.push(item);
657
- } else {
658
- resultsById.set(toolResultId, [item]);
659
- }
660
- }
661
-
662
- const merged: ResolvedItem[] = [];
663
- const usedOrdinals = new Set<number>();
664
-
665
- for (const item of freshTail) {
666
- merged.push(item);
667
-
668
- const toolCallIds = extractToolCallIdsFromAssistant(item.message);
669
- if (toolCallIds.length === 0) {
670
- continue;
671
- }
672
-
673
- for (const toolCallId of toolCallIds) {
674
- const matches = resultsById.get(toolCallId);
675
- if (!matches) {
676
- continue;
677
- }
678
- for (const match of matches) {
679
- if (usedOrdinals.has(match.ordinal)) {
680
- continue;
681
- }
682
- merged.push(match);
683
- usedOrdinals.add(match.ordinal);
684
- }
685
- }
686
- }
687
-
688
- for (const item of matchingToolResults) {
689
- if (!usedOrdinals.has(item.ordinal)) {
690
- merged.push(item);
691
- }
692
- }
693
-
694
- return merged;
695
- }
696
-
697
- function filterNonFreshAssistantToolCalls(
698
- items: ResolvedItem[],
699
- freshTailOrdinals: Set<number>,
700
- ): AgentMessage[] {
701
- const availableToolResultIds = new Set<string>();
702
- for (const item of items) {
703
- const toolResultId = extractToolResultIdFromMessage(item.message);
704
- if (toolResultId) {
705
- availableToolResultIds.add(toolResultId);
706
- }
707
- }
708
-
709
- const filteredMessages: AgentMessage[] = [];
710
- for (const item of items) {
711
- if (item.message?.role !== "assistant" || freshTailOrdinals.has(item.ordinal)) {
712
- filteredMessages.push(item.message);
713
- continue;
714
- }
715
-
716
- if (!Array.isArray(item.message.content)) {
717
- filteredMessages.push(item.message);
718
- continue;
719
- }
720
-
721
- let removedAny = false;
722
- const content = item.message.content.filter((block) => {
723
- if (!block || typeof block !== "object") {
724
- return true;
725
- }
726
- const record = block as { type?: unknown; id?: unknown; call_id?: unknown };
727
- if (typeof record.type !== "string" || !TOOL_CALL_TYPES.has(record.type)) {
728
- return true;
729
- }
730
- const toolCallId = extractToolCallId(record);
731
- if (!toolCallId || availableToolResultIds.has(toolCallId)) {
732
- return true;
733
- }
734
- removedAny = true;
735
- return false;
736
- });
737
-
738
- if (content.length === 0) {
739
- continue;
740
- }
741
- if (!removedAny) {
742
- filteredMessages.push(item.message);
743
- continue;
744
- }
745
- filteredMessages.push({
746
- ...item.message,
747
- content: content as typeof item.message.content,
748
- } as AgentMessage);
749
- }
750
- return filteredMessages;
751
- }
752
-
753
- /** Format a Date for XML attributes in the agent's timezone. */
754
- function formatDateForAttribute(date: Date, timezone?: string): string {
755
- const tz = timezone ?? "UTC";
756
- try {
757
- const fmt = new Intl.DateTimeFormat("en-CA", {
758
- timeZone: tz,
759
- year: "numeric",
760
- month: "2-digit",
761
- day: "2-digit",
762
- hour: "2-digit",
763
- minute: "2-digit",
764
- second: "2-digit",
765
- hour12: false,
766
- });
767
- const p = Object.fromEntries(
768
- fmt.formatToParts(date).map((part) => [part.type, part.value]),
769
- );
770
- return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}`;
771
- } catch {
772
- return date.toISOString();
773
- }
774
- }
775
-
776
- /**
777
- * Format a summary record into the XML payload string the model sees.
778
- */
779
- async function formatSummaryContent(
780
- summary: SummaryRecord,
781
- summaryStore: SummaryStore,
782
- timezone?: string,
783
- ): Promise<string> {
784
- const attributes = [
785
- `id="${summary.summaryId}"`,
786
- `kind="${summary.kind}"`,
787
- `depth="${summary.depth}"`,
788
- `descendant_count="${summary.descendantCount}"`,
789
- ];
790
- if (summary.earliestAt) {
791
- attributes.push(`earliest_at="${formatDateForAttribute(summary.earliestAt, timezone)}"`);
792
- }
793
- if (summary.latestAt) {
794
- attributes.push(`latest_at="${formatDateForAttribute(summary.latestAt, timezone)}"`);
795
- }
796
-
797
- const lines: string[] = [];
798
- lines.push(`<summary ${attributes.join(" ")}>`);
799
-
800
- // For condensed summaries, include parent references.
801
- if (summary.kind === "condensed") {
802
- const parents = await summaryStore.getSummaryParents(summary.summaryId);
803
- if (parents.length > 0) {
804
- lines.push(" <parents>");
805
- for (const parent of parents) {
806
- lines.push(` <summary_ref id="${parent.summaryId}" />`);
807
- }
808
- lines.push(" </parents>");
809
- }
810
- }
811
-
812
- lines.push(" <content>");
813
- lines.push(summary.content);
814
- lines.push(" </content>");
815
- lines.push("</summary>");
816
- return lines.join("\n");
817
- }
818
-
819
- // ── Resolved context item (after fetching underlying message/summary) ────────
820
-
821
- interface ResolvedItem {
822
- /** Original ordinal from context_items table */
823
- ordinal: number;
824
- /** The AgentMessage ready for the model */
825
- message: AgentMessage;
826
- /** Estimated token count for this item */
827
- tokens: number;
828
- /** Whether this came from a raw message (vs. a summary) */
829
- isMessage: boolean;
830
- /** Pre-extracted plain text used for relevance scoring */
831
- text: string;
832
- /** Summary metadata used for dynamic system prompt guidance */
833
- summarySignal?: SummaryPromptSignal;
834
- }
835
-
836
- // ── BM25-lite relevance scorer ────────────────────────────────────────────────
837
-
838
- /** @internal Exported for testing only. Tokenize text into lowercase alphanumeric terms. */
839
- export function tokenizeText(text: string): string[] {
840
- return text
841
- .toLowerCase()
842
- .split(/[^a-z0-9]+/)
843
- .filter((t) => t.length > 1);
844
- }
845
-
846
- /**
847
- * @internal Exported for testing only.
848
- * Score an item's text against a prompt using BM25-lite (term-frequency overlap).
849
- * Higher scores indicate stronger keyword overlap. Returns 0 when either input is empty.
850
- */
851
- export function scoreRelevance(itemText: string, prompt: string): number {
852
- const promptTerms = tokenizeText(prompt);
853
- if (promptTerms.length === 0) return 0;
854
-
855
- const itemTerms = tokenizeText(itemText);
856
- if (itemTerms.length === 0) return 0;
857
-
858
- // Build term-frequency map for the item
859
- const freq = new Map<string, number>();
860
- for (const term of itemTerms) {
861
- freq.set(term, (freq.get(term) ?? 0) + 1);
862
- }
863
-
864
- // Sum TF contribution for each unique prompt term
865
- const seen = new Set<string>();
866
- let score = 0;
867
- for (const term of promptTerms) {
868
- if (seen.has(term)) continue;
869
- seen.add(term);
870
- const tf = freq.get(term) ?? 0;
871
- if (tf > 0) {
872
- // Normalised TF: tf / itemLength (BM25-lite saturation skipped for simplicity)
873
- score += tf / itemTerms.length;
874
- }
875
- }
876
- return score;
877
- }
878
-
879
- /** Return true when a prompt contains at least one searchable term. */
880
- function hasSearchablePrompt(prompt?: string): prompt is string {
881
- return typeof prompt === "string" && tokenizeText(prompt).length > 0;
882
- }
883
-
884
- // ── ContextAssembler ─────────────────────────────────────────────────────────
885
-
886
- export class ContextAssembler {
887
- constructor(
888
- private conversationStore: ConversationStore,
889
- private summaryStore: SummaryStore,
890
- private timezone?: string,
891
- ) {}
892
-
893
- /**
894
- * Build model context under a token budget.
895
- *
896
- * 1. Fetch all context items for the conversation (ordered by ordinal).
897
- * 2. Resolve each item into an AgentMessage (fetching the underlying
898
- * message or summary record).
899
- * 3. Protect the "fresh tail" (last N items) from truncation.
900
- * 4. If over budget, drop oldest non-fresh items until we fit.
901
- * 5. Return the final ordered messages in chronological order.
902
- */
903
- async assemble(input: AssembleContextInput): Promise<AssembleContextResult> {
904
- const { conversationId, tokenBudget } = input;
905
- const freshTailCount = input.freshTailCount ?? 8;
906
-
907
- // Step 1: Get all context items ordered by ordinal
908
- const contextItems = await this.summaryStore.getContextItems(conversationId);
909
-
910
- if (contextItems.length === 0) {
911
- return {
912
- messages: [],
913
- estimatedTokens: 0,
914
- stats: { rawMessageCount: 0, summaryCount: 0, totalContextItems: 0 },
915
- };
916
- }
917
-
918
- // Step 2: Resolve each context item into a ResolvedItem
919
- const resolved = await this.resolveItems(contextItems);
920
-
921
- // Count stats from the full (pre-truncation) set
922
- let rawMessageCount = 0;
923
- let summaryCount = 0;
924
- const summarySignals: SummaryPromptSignal[] = [];
925
- for (const item of resolved) {
926
- if (item.isMessage) {
927
- rawMessageCount++;
928
- } else {
929
- summaryCount++;
930
- if (item.summarySignal) {
931
- summarySignals.push(item.summarySignal);
932
- }
933
- }
934
- }
935
-
936
- const systemPromptAddition = buildSystemPromptAddition(summarySignals);
937
-
938
- // Step 3: Split into evictable prefix and protected fresh tail
939
- const tailStart = Math.max(0, resolved.length - freshTailCount);
940
- const baseFreshTail = resolved.slice(tailStart);
941
- const initialEvictable = resolved.slice(0, tailStart);
942
- const freshTailOrdinals = new Set(baseFreshTail.map((item) => item.ordinal));
943
- const tailToolCallIds = collectAssistantToolCallIds(baseFreshTail);
944
- const tailPairToolResults = initialEvictable.filter((item) => {
945
- const toolResultId = extractToolResultIdFromMessage(item.message);
946
- return toolResultId !== null && tailToolCallIds.has(toolResultId);
947
- });
948
- const protectedEvictableOrdinals = new Set(tailPairToolResults.map((item) => item.ordinal));
949
- const evictable = initialEvictable.filter((item) => !protectedEvictableOrdinals.has(item.ordinal));
950
- const freshTail = mergeFreshTailWithMatchingToolResults(baseFreshTail, tailPairToolResults);
951
-
952
- // Step 4: Budget-aware selection
953
- // First, compute the token cost of the fresh tail (always included).
954
- let tailTokens = 0;
955
- for (const item of freshTail) {
956
- tailTokens += item.tokens;
957
- }
958
-
959
- // Fill remaining budget from evictable items, oldest first.
960
- // If the fresh tail alone exceeds the budget we still include it
961
- // (we never drop fresh items), but we skip all evictable items.
962
- const remainingBudget = Math.max(0, tokenBudget - tailTokens);
963
- const selected: ResolvedItem[] = [];
964
- let evictableTokens = 0;
965
-
966
- // Walk evictable items from oldest to newest. We want to keep as many
967
- // older items as the budget allows; once we exceed the budget we start
968
- // dropping the *oldest* items. To achieve this we first compute the
969
- // total, then trim from the front.
970
- const evictableTotalTokens = evictable.reduce((sum, it) => sum + it.tokens, 0);
971
-
972
- if (evictableTotalTokens <= remainingBudget) {
973
- // Everything fits
974
- selected.push(...evictable);
975
- evictableTokens = evictableTotalTokens;
976
- } else if (hasSearchablePrompt(input.prompt)) {
977
- // Prompt-aware eviction: score each evictable item by relevance to the
978
- // prompt, then greedily fill budget from highest-scoring items down.
979
- // Re-sort selected items by ordinal to restore chronological order.
980
- const scored = evictable.map((item, idx) => ({
981
- item,
982
- score: scoreRelevance(item.text, input.prompt),
983
- idx, // original index — higher = more recent, used as tiebreaker
984
- }));
985
- // Sort: highest relevance first; most recent (higher idx) breaks ties
986
- scored.sort((a, b) => b.score - a.score || b.idx - a.idx);
987
-
988
- const kept: ResolvedItem[] = [];
989
- let accum = 0;
990
- for (const { item } of scored) {
991
- if (accum + item.tokens <= remainingBudget) {
992
- kept.push(item);
993
- accum += item.tokens;
994
- }
995
- }
996
- // Restore chronological order by ordinal before appending freshTail
997
- kept.sort((a, b) => a.ordinal - b.ordinal);
998
- selected.push(...kept);
999
- evictableTokens = accum;
1000
- } else {
1001
- // Chronological eviction (default): drop oldest items until we fit.
1002
- // Walk from the END of evictable (newest first) accumulating tokens,
1003
- // then reverse to restore chronological order.
1004
- const kept: ResolvedItem[] = [];
1005
- let accum = 0;
1006
- for (let i = evictable.length - 1; i >= 0; i--) {
1007
- const item = evictable[i];
1008
- if (accum + item.tokens <= remainingBudget) {
1009
- kept.push(item);
1010
- accum += item.tokens;
1011
- } else {
1012
- // Once an item doesn't fit we stop — all older items are also dropped
1013
- break;
1014
- }
1015
- }
1016
- kept.reverse();
1017
- selected.push(...kept);
1018
- evictableTokens = accum;
1019
- }
1020
-
1021
- // Append fresh tail after the evictable prefix
1022
- selected.push(...freshTail);
1023
-
1024
- const estimatedTokens = evictableTokens + tailTokens;
1025
-
1026
- // Normalize assistant string content to array blocks (some providers return
1027
- // content as a plain string; Anthropic expects content block arrays).
1028
- const rawMessages = filterNonFreshAssistantToolCalls(selected, freshTailOrdinals);
1029
- for (let i = 0; i < rawMessages.length; i++) {
1030
- const msg = rawMessages[i];
1031
- if (msg?.role === "assistant" && typeof msg.content === "string") {
1032
- rawMessages[i] = {
1033
- ...msg,
1034
- content: [{ type: "text", text: msg.content }] as unknown as typeof msg.content,
1035
- } as typeof msg;
1036
- }
1037
- }
1038
-
1039
- // Filter out assistant messages with empty content — these can occur when
1040
- // tool-use-only turns are stored with content="" and zero message_parts,
1041
- // or when filterNonFreshAssistantToolCalls strips all tool_use blocks.
1042
- // Anthropic (and other providers) reject empty content arrays/strings.
1043
- const cleaned = rawMessages.filter(
1044
- (m) =>
1045
- !(
1046
- m?.role === "assistant" &&
1047
- (Array.isArray(m.content) ? m.content.length === 0 : !m.content)
1048
- ),
1049
- );
1050
- return {
1051
- messages: sanitizeToolUseResultPairing(cleaned) as AgentMessage[],
1052
- estimatedTokens,
1053
- systemPromptAddition,
1054
- stats: {
1055
- rawMessageCount,
1056
- summaryCount,
1057
- totalContextItems: resolved.length,
1058
- },
1059
- };
1060
- }
1061
-
1062
- // ── Private helpers ──────────────────────────────────────────────────────
1063
-
1064
- /**
1065
- * Resolve a list of context items into ResolvedItems by fetching the
1066
- * underlying message or summary record for each.
1067
- *
1068
- * Items that cannot be resolved (e.g. deleted message) are silently skipped.
1069
- */
1070
- private async resolveItems(contextItems: ContextItemRecord[]): Promise<ResolvedItem[]> {
1071
- const resolved: ResolvedItem[] = [];
1072
-
1073
- for (const item of contextItems) {
1074
- const result = await this.resolveItem(item);
1075
- if (result) {
1076
- resolved.push(result);
1077
- }
1078
- }
1079
-
1080
- return resolved;
1081
- }
1082
-
1083
- /**
1084
- * Resolve a single context item.
1085
- */
1086
- private async resolveItem(item: ContextItemRecord): Promise<ResolvedItem | null> {
1087
- if (item.itemType === "message" && item.messageId != null) {
1088
- return this.resolveMessageItem(item);
1089
- }
1090
-
1091
- if (item.itemType === "summary" && item.summaryId != null) {
1092
- return this.resolveSummaryItem(item);
1093
- }
1094
-
1095
- // Malformed item — skip
1096
- return null;
1097
- }
1098
-
1099
- /**
1100
- * Resolve a context item that references a raw message.
1101
- */
1102
- private async resolveMessageItem(item: ContextItemRecord): Promise<ResolvedItem | null> {
1103
- const msg = await this.conversationStore.getMessageById(item.messageId!);
1104
- if (!msg) {
1105
- return null;
1106
- }
1107
-
1108
- const parts = await this.conversationStore.getMessageParts(msg.messageId);
1109
- const roleFromStore = toRuntimeRole(msg.role, parts);
1110
- const isToolResult = roleFromStore === "toolResult";
1111
- const toolCallId = isToolResult ? pickToolCallId(parts) : undefined;
1112
- const toolName = isToolResult ? (pickToolName(parts) ?? "unknown") : undefined;
1113
- const toolIsError = isToolResult ? pickToolIsError(parts) : undefined;
1114
- // Tool results without a call id cannot be serialized for Anthropic-compatible APIs.
1115
- // This happens for legacy/bootstrap rows that have role=tool but no message_parts.
1116
- // Preserve the text by degrading to assistant content instead of emitting invalid toolResult.
1117
- const role: "user" | "assistant" | "toolResult" =
1118
- isToolResult && !toolCallId ? "assistant" : roleFromStore;
1119
- const content = contentFromParts(parts, role, msg.content);
1120
- const contentText =
1121
- typeof content === "string" ? content : (JSON.stringify(content) ?? msg.content);
1122
- const tokenCount = estimateTokens(contentText);
1123
-
1124
- // Cast: these are reconstructed from DB storage, not live agent messages,
1125
- // so they won't carry the full AgentMessage metadata (timestamp, usage, etc.)
1126
- return {
1127
- ordinal: item.ordinal,
1128
- message:
1129
- role === "assistant"
1130
- ? ({
1131
- role,
1132
- content,
1133
- usage: {
1134
- input: 0,
1135
- output: tokenCount,
1136
- cacheRead: 0,
1137
- cacheWrite: 0,
1138
- totalTokens: tokenCount,
1139
- cost: {
1140
- input: 0,
1141
- output: 0,
1142
- cacheRead: 0,
1143
- cacheWrite: 0,
1144
- total: 0,
1145
- },
1146
- },
1147
- } as AgentMessage)
1148
- : ({
1149
- role,
1150
- content,
1151
- ...(toolCallId ? { toolCallId } : {}),
1152
- ...(toolName ? { toolName } : {}),
1153
- ...(role === "toolResult" && toolIsError !== undefined ? { isError: toolIsError } : {}),
1154
- } as AgentMessage),
1155
- tokens: tokenCount,
1156
- isMessage: true,
1157
- text: contentText,
1158
- };
1159
- }
1160
-
1161
- /**
1162
- * Resolve a context item that references a summary.
1163
- * Summaries are presented as user messages with a structured XML wrapper.
1164
- */
1165
- private async resolveSummaryItem(item: ContextItemRecord): Promise<ResolvedItem | null> {
1166
- const summary = await this.summaryStore.getSummary(item.summaryId!);
1167
- if (!summary) {
1168
- return null;
1169
- }
1170
-
1171
- const content = await formatSummaryContent(summary, this.summaryStore, this.timezone);
1172
- const tokens = estimateTokens(content);
1173
-
1174
- // Cast: summaries are synthetic user messages without full AgentMessage metadata
1175
- return {
1176
- ordinal: item.ordinal,
1177
- message: { role: "user" as const, content } as AgentMessage,
1178
- tokens,
1179
- isMessage: false,
1180
- text: summary.content,
1181
- summarySignal: {
1182
- kind: summary.kind,
1183
- depth: summary.depth,
1184
- descendantCount: summary.descendantCount,
1185
- },
1186
- };
1187
- }
1188
- }