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