@jonathangu/openclawbrain 0.3.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.
Files changed (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +412 -0
  3. package/bin/openclawbrain.js +15 -0
  4. package/docs/END_STATE.md +244 -0
  5. package/docs/EVIDENCE.md +128 -0
  6. package/docs/RELEASE_CONTRACT.md +91 -0
  7. package/docs/agent-tools.md +106 -0
  8. package/docs/architecture.md +224 -0
  9. package/docs/configuration.md +178 -0
  10. package/docs/evidence/2026-03-16/3188b50c4ed30f07dea111e35ce52aabefaced63/brain-teach-session-bound/status.json +87 -0
  11. package/docs/evidence/2026-03-16/3188b50c4ed30f07dea111e35ce52aabefaced63/brain-teach-session-bound/summary.md +16 -0
  12. package/docs/evidence/2026-03-16/3188b50c4ed30f07dea111e35ce52aabefaced63/brain-teach-session-bound/trace.json +273 -0
  13. package/docs/evidence/2026-03-16/3188b50c4ed30f07dea111e35ce52aabefaced63/brain-teach-session-bound/validation-report.json +652 -0
  14. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/channels-status.txt +31 -0
  15. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/config-snapshot.json +66 -0
  16. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/doctor.json +14 -0
  17. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/gateway-probe.txt +34 -0
  18. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/gateway-status.txt +41 -0
  19. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/logs.txt +428 -0
  20. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/status-all.txt +60 -0
  21. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/status.json +223 -0
  22. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/summary.md +13 -0
  23. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/trace.json +4 -0
  24. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/validation-report.json +334 -0
  25. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/channels-status.txt +25 -0
  26. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/config-snapshot.json +91 -0
  27. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/doctor.json +14 -0
  28. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/gateway-probe.txt +36 -0
  29. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/gateway-status.txt +44 -0
  30. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/logs.txt +428 -0
  31. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/short-static-classification/preflight-doctor.json +10 -0
  32. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/short-static-classification/preflight-sdk-probe.json +11 -0
  33. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/short-static-classification/preflight-setup-only.json +12 -0
  34. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/short-static-classification/summary.md +30 -0
  35. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/short-static-classification/validation-report.json +72 -0
  36. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/status-all.txt +63 -0
  37. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/status.json +200 -0
  38. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/summary.md +13 -0
  39. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/trace.json +4 -0
  40. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/validation-report.json +311 -0
  41. package/docs/evidence/README.md +16 -0
  42. package/docs/fts5.md +161 -0
  43. package/docs/tui.md +506 -0
  44. package/index.ts +1372 -0
  45. package/openclaw.plugin.json +136 -0
  46. package/package.json +66 -0
  47. package/src/assembler.ts +804 -0
  48. package/src/brain-cli.ts +316 -0
  49. package/src/brain-core/decay.ts +35 -0
  50. package/src/brain-core/episode.ts +82 -0
  51. package/src/brain-core/graph.ts +321 -0
  52. package/src/brain-core/health.ts +116 -0
  53. package/src/brain-core/mutator.ts +281 -0
  54. package/src/brain-core/pack.ts +117 -0
  55. package/src/brain-core/policy.ts +153 -0
  56. package/src/brain-core/replay.ts +1 -0
  57. package/src/brain-core/teacher.ts +105 -0
  58. package/src/brain-core/trace.ts +40 -0
  59. package/src/brain-core/traverse.ts +230 -0
  60. package/src/brain-core/types.ts +405 -0
  61. package/src/brain-core/update.ts +123 -0
  62. package/src/brain-harvest/human.ts +46 -0
  63. package/src/brain-harvest/scanner.ts +98 -0
  64. package/src/brain-harvest/self.ts +147 -0
  65. package/src/brain-runtime/assembler-extension.ts +230 -0
  66. package/src/brain-runtime/evidence-detectors.ts +68 -0
  67. package/src/brain-runtime/graph-io.ts +72 -0
  68. package/src/brain-runtime/harvester-extension.ts +98 -0
  69. package/src/brain-runtime/service.ts +659 -0
  70. package/src/brain-runtime/tools.ts +109 -0
  71. package/src/brain-runtime/worker-state.ts +106 -0
  72. package/src/brain-runtime/worker-supervisor.ts +169 -0
  73. package/src/brain-store/embedding.ts +179 -0
  74. package/src/brain-store/init.ts +347 -0
  75. package/src/brain-store/migrations.ts +188 -0
  76. package/src/brain-store/store.ts +816 -0
  77. package/src/brain-worker/child-runner.ts +321 -0
  78. package/src/brain-worker/jobs.ts +12 -0
  79. package/src/brain-worker/mutation-job.ts +5 -0
  80. package/src/brain-worker/promotion-job.ts +5 -0
  81. package/src/brain-worker/protocol.ts +79 -0
  82. package/src/brain-worker/teacher-job.ts +5 -0
  83. package/src/brain-worker/update-job.ts +5 -0
  84. package/src/brain-worker/worker.ts +422 -0
  85. package/src/compaction.ts +1332 -0
  86. package/src/db/config.ts +265 -0
  87. package/src/db/connection.ts +72 -0
  88. package/src/db/features.ts +42 -0
  89. package/src/db/migration.ts +561 -0
  90. package/src/engine.ts +1995 -0
  91. package/src/expansion-auth.ts +351 -0
  92. package/src/expansion-policy.ts +303 -0
  93. package/src/expansion.ts +383 -0
  94. package/src/integrity.ts +600 -0
  95. package/src/large-files.ts +527 -0
  96. package/src/openclaw-bridge.ts +22 -0
  97. package/src/retrieval.ts +357 -0
  98. package/src/store/conversation-store.ts +748 -0
  99. package/src/store/fts5-sanitize.ts +29 -0
  100. package/src/store/full-text-fallback.ts +74 -0
  101. package/src/store/index.ts +29 -0
  102. package/src/store/summary-store.ts +918 -0
  103. package/src/summarize.ts +847 -0
  104. package/src/tools/common.ts +53 -0
  105. package/src/tools/lcm-conversation-scope.ts +76 -0
  106. package/src/tools/lcm-describe-tool.ts +234 -0
  107. package/src/tools/lcm-expand-query-tool.ts +594 -0
  108. package/src/tools/lcm-expand-tool.delegation.ts +556 -0
  109. package/src/tools/lcm-expand-tool.ts +448 -0
  110. package/src/tools/lcm-expansion-recursion-guard.ts +286 -0
  111. package/src/tools/lcm-grep-tool.ts +200 -0
  112. package/src/transcript-repair.ts +301 -0
  113. package/src/types.ts +149 -0
@@ -0,0 +1,804 @@
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
+ /** Optional dynamic system prompt guidance derived from DAG state */
27
+ systemPromptAddition?: string;
28
+ /** Stats about what was assembled */
29
+ stats: {
30
+ rawMessageCount: number;
31
+ summaryCount: number;
32
+ totalContextItems: number;
33
+ };
34
+ }
35
+
36
+ // ── Helpers ──────────────────────────────────────────────────────────────────
37
+
38
+ /** Simple token estimate: ~4 chars per token, same as VoltCode's Token.estimate */
39
+ function estimateTokens(text: string): number {
40
+ return Math.ceil(text.length / 4);
41
+ }
42
+
43
+ type SummaryPromptSignal = Pick<SummaryRecord, "kind" | "depth" | "descendantCount">;
44
+
45
+ /**
46
+ * Build LCM usage guidance for the runtime system prompt.
47
+ *
48
+ * Guidance is emitted only when summaries are present in assembled context.
49
+ * Depth-aware: minimal for shallow compaction, full guidance for deep trees.
50
+ */
51
+ function buildSystemPromptAddition(summarySignals: SummaryPromptSignal[]): string | undefined {
52
+ if (summarySignals.length === 0) {
53
+ return undefined;
54
+ }
55
+
56
+ const maxDepth = summarySignals.reduce((deepest, signal) => Math.max(deepest, signal.depth), 0);
57
+ const condensedCount = summarySignals.filter((signal) => signal.kind === "condensed").length;
58
+ const heavilyCompacted = maxDepth >= 2 || condensedCount >= 2;
59
+
60
+ const sections: string[] = [];
61
+
62
+ // Core recall workflow — always present when summaries exist
63
+ sections.push(
64
+ "## LCM Recall",
65
+ "",
66
+ "Summaries above are compressed context — maps to details, not the details themselves.",
67
+ "",
68
+ "**Recall priority:** LCM tools first, then qmd (for Granola/Limitless/pre-LCM data), then memory_search as last resort.",
69
+ "",
70
+ "**Tool escalation:**",
71
+ "1. `lcm_grep` — search by regex or full-text across messages and summaries",
72
+ "2. `lcm_describe` — inspect a specific summary (cheap, no sub-agent)",
73
+ "3. `lcm_expand_query` — deep recall: spawns bounded sub-agent, expands DAG, returns answer with cited summary IDs (~120s, don't ration it)",
74
+ "",
75
+ "**`lcm_expand_query` usage** — two patterns (always requires `prompt`):",
76
+ "- With IDs: `lcm_expand_query(summaryIds: [\"sum_xxx\"], prompt: \"What config changes were discussed?\")`",
77
+ "- With search: `lcm_expand_query(query: \"database migration\", prompt: \"What strategy was decided?\")`",
78
+ "- Optional: `maxTokens` (default 2000), `conversationId`, `allConversations: true`",
79
+ "",
80
+ "**Summaries include \"Expand for details about:\" footers** listing compressed specifics. Use `lcm_expand_query` with that summary's ID to retrieve them.",
81
+ );
82
+
83
+ // Precision/evidence rules — always present but stronger when heavily compacted
84
+ if (heavilyCompacted) {
85
+ sections.push(
86
+ "",
87
+ "**\u26a0 Deeply compacted context — expand before asserting specifics.**",
88
+ "",
89
+ "Default recall flow for precision work:",
90
+ "1) `lcm_grep` to locate relevant summary/message IDs",
91
+ "2) `lcm_expand_query` with a focused prompt",
92
+ "3) Answer with citations to summary IDs used",
93
+ "",
94
+ "**Uncertainty checklist (run before answering):**",
95
+ "- Am I making exact factual claims from a condensed summary?",
96
+ "- Could compaction have omitted a crucial detail?",
97
+ "- Would this answer fail if the user asks for proof?",
98
+ "",
99
+ "If yes to any \u2192 expand first.",
100
+ "",
101
+ "**Do not guess** exact commands, SHAs, file paths, timestamps, config values, or causal claims from condensed summaries. Expand first or state that you need to expand.",
102
+ );
103
+ } else {
104
+ sections.push(
105
+ "",
106
+ "**For precision/evidence questions** (exact commands, SHAs, paths, timestamps, config values, root-cause chains): expand before answering.",
107
+ "Do not guess from condensed summaries — expand first or state uncertainty.",
108
+ );
109
+ }
110
+
111
+ return sections.join("\n");
112
+ }
113
+
114
+ /**
115
+ * Map a DB message role to an AgentMessage role.
116
+ *
117
+ * user -> user
118
+ * assistant -> assistant
119
+ * system -> user (system prompts presented as user messages)
120
+ * tool -> assistant (tool results are part of assistant turns)
121
+ */
122
+ function parseJson(value: string | null): unknown {
123
+ if (typeof value !== "string" || !value.trim()) {
124
+ return undefined;
125
+ }
126
+ try {
127
+ return JSON.parse(value);
128
+ } catch {
129
+ return undefined;
130
+ }
131
+ }
132
+
133
+ function getOriginalRole(parts: MessagePartRecord[]): string | null {
134
+ for (const part of parts) {
135
+ const decoded = parseJson(part.metadata);
136
+ if (!decoded || typeof decoded !== "object") {
137
+ continue;
138
+ }
139
+ const role = (decoded as { originalRole?: unknown }).originalRole;
140
+ if (typeof role === "string" && role.length > 0) {
141
+ return role;
142
+ }
143
+ }
144
+ return null;
145
+ }
146
+
147
+ function getPartMetadata(part: MessagePartRecord): {
148
+ originalRole?: string;
149
+ rawType?: string;
150
+ raw?: unknown;
151
+ } {
152
+ const decoded = parseJson(part.metadata);
153
+ if (!decoded || typeof decoded !== "object") {
154
+ return {};
155
+ }
156
+
157
+ const record = decoded as {
158
+ originalRole?: unknown;
159
+ rawType?: unknown;
160
+ raw?: unknown;
161
+ };
162
+ return {
163
+ originalRole:
164
+ typeof record.originalRole === "string" && record.originalRole.length > 0
165
+ ? record.originalRole
166
+ : undefined,
167
+ rawType:
168
+ typeof record.rawType === "string" && record.rawType.length > 0
169
+ ? record.rawType
170
+ : undefined,
171
+ raw: record.raw,
172
+ };
173
+ }
174
+
175
+ function parseStoredValue(value: string | null): unknown {
176
+ if (typeof value !== "string" || value.length === 0) {
177
+ return undefined;
178
+ }
179
+ const parsed = parseJson(value);
180
+ return parsed !== undefined ? parsed : value;
181
+ }
182
+
183
+ function reasoningBlockFromPart(part: MessagePartRecord, rawType?: string): unknown {
184
+ const type = rawType === "thinking" ? "thinking" : "reasoning";
185
+ if (typeof part.textContent === "string" && part.textContent.length > 0) {
186
+ return type === "thinking"
187
+ ? { type, thinking: part.textContent }
188
+ : { type, text: part.textContent };
189
+ }
190
+ return { type };
191
+ }
192
+
193
+ /**
194
+ * Detect if a raw block is an OpenClaw-normalised OpenAI reasoning item.
195
+ * OpenClaw converts OpenAI `{type:"reasoning", id:"rs_…", encrypted_content:"…"}`
196
+ * into `{type:"thinking", thinking:"", thinkingSignature:"{…}"}`.
197
+ * When we reassemble for the OpenAI provider we need the original back.
198
+ */
199
+ function tryRestoreOpenAIReasoning(raw: Record<string, unknown>): Record<string, unknown> | null {
200
+ if (raw.type !== "thinking") return null;
201
+ const sig = raw.thinkingSignature;
202
+ if (typeof sig !== "string" || !sig.startsWith("{")) return null;
203
+ try {
204
+ const parsed = JSON.parse(sig) as Record<string, unknown>;
205
+ if (parsed.type === "reasoning" && typeof parsed.id === "string") {
206
+ return parsed;
207
+ }
208
+ } catch {
209
+ // not valid JSON — leave as-is
210
+ }
211
+ return null;
212
+ }
213
+
214
+ function toolCallBlockFromPart(part: MessagePartRecord, rawType?: string): unknown {
215
+ const type =
216
+ rawType === "function_call" ||
217
+ rawType === "functionCall" ||
218
+ rawType === "tool_use" ||
219
+ rawType === "tool-use" ||
220
+ rawType === "toolUse" ||
221
+ rawType === "toolCall"
222
+ ? rawType
223
+ : "toolCall";
224
+ const input = parseStoredValue(part.toolInput);
225
+ const block: Record<string, unknown> = { type };
226
+
227
+ if (type === "function_call") {
228
+ if (typeof part.toolCallId === "string" && part.toolCallId.length > 0) {
229
+ block.call_id = part.toolCallId;
230
+ }
231
+ if (typeof part.toolName === "string" && part.toolName.length > 0) {
232
+ block.name = part.toolName;
233
+ }
234
+ if (input !== undefined) {
235
+ block.arguments = input;
236
+ }
237
+ return block;
238
+ }
239
+
240
+ if (typeof part.toolCallId === "string" && part.toolCallId.length > 0) {
241
+ block.id = part.toolCallId;
242
+ }
243
+ if (typeof part.toolName === "string" && part.toolName.length > 0) {
244
+ block.name = part.toolName;
245
+ }
246
+
247
+ if (input !== undefined) {
248
+ if (type === "functionCall") {
249
+ block.arguments = input;
250
+ } else {
251
+ block.input = input;
252
+ }
253
+ }
254
+ return block;
255
+ }
256
+
257
+ function toolResultBlockFromPart(part: MessagePartRecord, rawType?: string): unknown {
258
+ const type =
259
+ rawType === "function_call_output" || rawType === "toolResult" || rawType === "tool_result"
260
+ ? rawType
261
+ : "tool_result";
262
+ const output = parseStoredValue(part.toolOutput) ?? part.textContent ?? "";
263
+ const block: Record<string, unknown> = { type, output };
264
+
265
+ if (typeof part.toolName === "string" && part.toolName.length > 0) {
266
+ block.name = part.toolName;
267
+ }
268
+
269
+ if (type === "function_call_output") {
270
+ if (typeof part.toolCallId === "string" && part.toolCallId.length > 0) {
271
+ block.call_id = part.toolCallId;
272
+ }
273
+ return block;
274
+ }
275
+
276
+ if (typeof part.toolCallId === "string" && part.toolCallId.length > 0) {
277
+ block.tool_use_id = part.toolCallId;
278
+ }
279
+ return block;
280
+ }
281
+
282
+ function toRuntimeRole(
283
+ dbRole: MessageRole,
284
+ parts: MessagePartRecord[],
285
+ ): "user" | "assistant" | "toolResult" {
286
+ const originalRole = getOriginalRole(parts);
287
+ if (originalRole === "toolResult") {
288
+ return "toolResult";
289
+ }
290
+ if (originalRole === "assistant") {
291
+ return "assistant";
292
+ }
293
+ if (originalRole === "user") {
294
+ return "user";
295
+ }
296
+ if (originalRole === "system") {
297
+ // Runtime system prompts are managed via setSystemPrompt(), not message history.
298
+ return "user";
299
+ }
300
+
301
+ if (dbRole === "tool") {
302
+ return "toolResult";
303
+ }
304
+ if (dbRole === "assistant") {
305
+ return "assistant";
306
+ }
307
+ return "user"; // user | system
308
+ }
309
+
310
+ function blockFromPart(part: MessagePartRecord): unknown {
311
+ const metadata = getPartMetadata(part);
312
+ if (metadata.raw && typeof metadata.raw === "object") {
313
+ // If this is an OpenClaw-normalised OpenAI reasoning block, restore the original
314
+ // OpenAI format so the Responses API gets the {type:"reasoning", id:"rs_…"} it expects.
315
+ const restored = tryRestoreOpenAIReasoning(metadata.raw as Record<string, unknown>);
316
+ if (restored) return restored;
317
+ return metadata.raw;
318
+ }
319
+
320
+ if (part.partType === "reasoning") {
321
+ return reasoningBlockFromPart(part, metadata.rawType);
322
+ }
323
+ if (part.partType === "tool") {
324
+ if (metadata.originalRole === "toolResult" || metadata.rawType === "function_call_output") {
325
+ return toolResultBlockFromPart(part, metadata.rawType);
326
+ }
327
+ return toolCallBlockFromPart(part, metadata.rawType);
328
+ }
329
+ if (
330
+ metadata.rawType === "function_call" ||
331
+ metadata.rawType === "functionCall" ||
332
+ metadata.rawType === "tool_use" ||
333
+ metadata.rawType === "tool-use" ||
334
+ metadata.rawType === "toolUse" ||
335
+ metadata.rawType === "toolCall"
336
+ ) {
337
+ return toolCallBlockFromPart(part, metadata.rawType);
338
+ }
339
+ if (
340
+ metadata.rawType === "function_call_output" ||
341
+ metadata.rawType === "tool_result" ||
342
+ metadata.rawType === "toolResult"
343
+ ) {
344
+ return toolResultBlockFromPart(part, metadata.rawType);
345
+ }
346
+ if (part.partType === "text") {
347
+ return { type: "text", text: part.textContent ?? "" };
348
+ }
349
+
350
+ if (typeof part.textContent === "string" && part.textContent.length > 0) {
351
+ return { type: "text", text: part.textContent };
352
+ }
353
+
354
+ const decodedFallback = parseJson(part.metadata);
355
+ if (decodedFallback && typeof decodedFallback === "object") {
356
+ return {
357
+ type: "text",
358
+ text: JSON.stringify(decodedFallback),
359
+ };
360
+ }
361
+ return { type: "text", text: "" };
362
+ }
363
+
364
+ function contentFromParts(
365
+ parts: MessagePartRecord[],
366
+ role: "user" | "assistant" | "toolResult",
367
+ fallbackContent: string,
368
+ ): unknown {
369
+ if (parts.length === 0) {
370
+ if (role === "assistant") {
371
+ return fallbackContent ? [{ type: "text", text: fallbackContent }] : [];
372
+ }
373
+ if (role === "toolResult") {
374
+ return [{ type: "text", text: fallbackContent }];
375
+ }
376
+ return fallbackContent;
377
+ }
378
+
379
+ const blocks = parts.map(blockFromPart);
380
+ if (
381
+ role === "user" &&
382
+ blocks.length === 1 &&
383
+ blocks[0] &&
384
+ typeof blocks[0] === "object" &&
385
+ (blocks[0] as { type?: unknown }).type === "text" &&
386
+ typeof (blocks[0] as { text?: unknown }).text === "string"
387
+ ) {
388
+ return (blocks[0] as { text: string }).text;
389
+ }
390
+ return blocks;
391
+ }
392
+
393
+ function pickToolCallId(parts: MessagePartRecord[]): string | undefined {
394
+ for (const part of parts) {
395
+ if (typeof part.toolCallId === "string" && part.toolCallId.length > 0) {
396
+ return part.toolCallId;
397
+ }
398
+ const decoded = parseJson(part.metadata);
399
+ if (!decoded || typeof decoded !== "object") {
400
+ continue;
401
+ }
402
+ const metadataToolCallId = (decoded as { toolCallId?: unknown }).toolCallId;
403
+ if (typeof metadataToolCallId === "string" && metadataToolCallId.length > 0) {
404
+ return metadataToolCallId;
405
+ }
406
+ const raw = (decoded as { raw?: unknown }).raw;
407
+ if (!raw || typeof raw !== "object") {
408
+ continue;
409
+ }
410
+ const maybe = (raw as { toolCallId?: unknown; tool_call_id?: unknown }).toolCallId;
411
+ if (typeof maybe === "string" && maybe.length > 0) {
412
+ return maybe;
413
+ }
414
+ const maybeSnake = (raw as { tool_call_id?: unknown }).tool_call_id;
415
+ if (typeof maybeSnake === "string" && maybeSnake.length > 0) {
416
+ return maybeSnake;
417
+ }
418
+ }
419
+ return undefined;
420
+ }
421
+
422
+ function pickToolName(parts: MessagePartRecord[]): string | undefined {
423
+ for (const part of parts) {
424
+ if (typeof part.toolName === "string" && part.toolName.length > 0) {
425
+ return part.toolName;
426
+ }
427
+ const decoded = parseJson(part.metadata);
428
+ if (!decoded || typeof decoded !== "object") {
429
+ continue;
430
+ }
431
+ const metadataToolName = (decoded as { toolName?: unknown }).toolName;
432
+ if (typeof metadataToolName === "string" && metadataToolName.length > 0) {
433
+ return metadataToolName;
434
+ }
435
+ const raw = (decoded as { raw?: unknown }).raw;
436
+ if (!raw || typeof raw !== "object") {
437
+ continue;
438
+ }
439
+ const maybe = (raw as { name?: unknown }).name;
440
+ if (typeof maybe === "string" && maybe.length > 0) {
441
+ return maybe;
442
+ }
443
+ const maybeCamel = (raw as { toolName?: unknown }).toolName;
444
+ if (typeof maybeCamel === "string" && maybeCamel.length > 0) {
445
+ return maybeCamel;
446
+ }
447
+ }
448
+ return undefined;
449
+ }
450
+
451
+ function pickToolIsError(parts: MessagePartRecord[]): boolean | undefined {
452
+ for (const part of parts) {
453
+ const decoded = parseJson(part.metadata);
454
+ if (!decoded || typeof decoded !== "object") {
455
+ continue;
456
+ }
457
+ const metadataIsError = (decoded as { isError?: unknown }).isError;
458
+ if (typeof metadataIsError === "boolean") {
459
+ return metadataIsError;
460
+ }
461
+ }
462
+ return undefined;
463
+ }
464
+
465
+ /** Format a Date for XML attributes in the agent's timezone. */
466
+ function formatDateForAttribute(date: Date, timezone?: string): string {
467
+ const tz = timezone ?? "UTC";
468
+ try {
469
+ const fmt = new Intl.DateTimeFormat("en-CA", {
470
+ timeZone: tz,
471
+ year: "numeric",
472
+ month: "2-digit",
473
+ day: "2-digit",
474
+ hour: "2-digit",
475
+ minute: "2-digit",
476
+ second: "2-digit",
477
+ hour12: false,
478
+ });
479
+ const p = Object.fromEntries(
480
+ fmt.formatToParts(date).map((part) => [part.type, part.value]),
481
+ );
482
+ return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}`;
483
+ } catch {
484
+ return date.toISOString();
485
+ }
486
+ }
487
+
488
+ /**
489
+ * Format a summary record into the XML payload string the model sees.
490
+ */
491
+ async function formatSummaryContent(
492
+ summary: SummaryRecord,
493
+ summaryStore: SummaryStore,
494
+ timezone?: string,
495
+ ): Promise<string> {
496
+ const attributes = [
497
+ `id="${summary.summaryId}"`,
498
+ `kind="${summary.kind}"`,
499
+ `depth="${summary.depth}"`,
500
+ `descendant_count="${summary.descendantCount}"`,
501
+ ];
502
+ if (summary.earliestAt) {
503
+ attributes.push(`earliest_at="${formatDateForAttribute(summary.earliestAt, timezone)}"`);
504
+ }
505
+ if (summary.latestAt) {
506
+ attributes.push(`latest_at="${formatDateForAttribute(summary.latestAt, timezone)}"`);
507
+ }
508
+
509
+ const lines: string[] = [];
510
+ lines.push(`<summary ${attributes.join(" ")}>`);
511
+
512
+ // For condensed summaries, include parent references.
513
+ if (summary.kind === "condensed") {
514
+ const parents = await summaryStore.getSummaryParents(summary.summaryId);
515
+ if (parents.length > 0) {
516
+ lines.push(" <parents>");
517
+ for (const parent of parents) {
518
+ lines.push(` <summary_ref id="${parent.summaryId}" />`);
519
+ }
520
+ lines.push(" </parents>");
521
+ }
522
+ }
523
+
524
+ lines.push(" <content>");
525
+ lines.push(summary.content);
526
+ lines.push(" </content>");
527
+ lines.push("</summary>");
528
+ return lines.join("\n");
529
+ }
530
+
531
+ // ── Resolved context item (after fetching underlying message/summary) ────────
532
+
533
+ interface ResolvedItem {
534
+ /** Original ordinal from context_items table */
535
+ ordinal: number;
536
+ /** The AgentMessage ready for the model */
537
+ message: AgentMessage;
538
+ /** Estimated token count for this item */
539
+ tokens: number;
540
+ /** Whether this came from a raw message (vs. a summary) */
541
+ isMessage: boolean;
542
+ /** Summary metadata used for dynamic system prompt guidance */
543
+ summarySignal?: SummaryPromptSignal;
544
+ }
545
+
546
+ // ── ContextAssembler ─────────────────────────────────────────────────────────
547
+
548
+ export class ContextAssembler {
549
+ constructor(
550
+ private conversationStore: ConversationStore,
551
+ private summaryStore: SummaryStore,
552
+ private timezone?: string,
553
+ ) {}
554
+
555
+ /**
556
+ * Build model context under a token budget.
557
+ *
558
+ * 1. Fetch all context items for the conversation (ordered by ordinal).
559
+ * 2. Resolve each item into an AgentMessage (fetching the underlying
560
+ * message or summary record).
561
+ * 3. Protect the "fresh tail" (last N items) from truncation.
562
+ * 4. If over budget, drop oldest non-fresh items until we fit.
563
+ * 5. Return the final ordered messages in chronological order.
564
+ */
565
+ async assemble(input: AssembleContextInput): Promise<AssembleContextResult> {
566
+ const { conversationId, tokenBudget } = input;
567
+ const freshTailCount = input.freshTailCount ?? 8;
568
+
569
+ // Step 1: Get all context items ordered by ordinal
570
+ const contextItems = await this.summaryStore.getContextItems(conversationId);
571
+
572
+ if (contextItems.length === 0) {
573
+ return {
574
+ messages: [],
575
+ estimatedTokens: 0,
576
+ stats: { rawMessageCount: 0, summaryCount: 0, totalContextItems: 0 },
577
+ };
578
+ }
579
+
580
+ // Step 2: Resolve each context item into a ResolvedItem
581
+ const resolved = await this.resolveItems(contextItems);
582
+
583
+ // Count stats from the full (pre-truncation) set
584
+ let rawMessageCount = 0;
585
+ let summaryCount = 0;
586
+ const summarySignals: SummaryPromptSignal[] = [];
587
+ for (const item of resolved) {
588
+ if (item.isMessage) {
589
+ rawMessageCount++;
590
+ } else {
591
+ summaryCount++;
592
+ if (item.summarySignal) {
593
+ summarySignals.push(item.summarySignal);
594
+ }
595
+ }
596
+ }
597
+
598
+ const systemPromptAddition = buildSystemPromptAddition(summarySignals);
599
+
600
+ // Step 3: Split into evictable prefix and protected fresh tail
601
+ const tailStart = Math.max(0, resolved.length - freshTailCount);
602
+ const freshTail = resolved.slice(tailStart);
603
+ const evictable = resolved.slice(0, tailStart);
604
+
605
+ // Step 4: Budget-aware selection
606
+ // First, compute the token cost of the fresh tail (always included).
607
+ let tailTokens = 0;
608
+ for (const item of freshTail) {
609
+ tailTokens += item.tokens;
610
+ }
611
+
612
+ // Fill remaining budget from evictable items, oldest first.
613
+ // If the fresh tail alone exceeds the budget we still include it
614
+ // (we never drop fresh items), but we skip all evictable items.
615
+ const remainingBudget = Math.max(0, tokenBudget - tailTokens);
616
+ const selected: ResolvedItem[] = [];
617
+ let evictableTokens = 0;
618
+
619
+ // Walk evictable items from oldest to newest. We want to keep as many
620
+ // older items as the budget allows; once we exceed the budget we start
621
+ // dropping the *oldest* items. To achieve this we first compute the
622
+ // total, then trim from the front.
623
+ const evictableTotalTokens = evictable.reduce((sum, it) => sum + it.tokens, 0);
624
+
625
+ if (evictableTotalTokens <= remainingBudget) {
626
+ // Everything fits
627
+ selected.push(...evictable);
628
+ evictableTokens = evictableTotalTokens;
629
+ } else {
630
+ // Need to drop oldest items until we fit.
631
+ // Walk from the END of evictable (newest first) accumulating tokens,
632
+ // then reverse to restore chronological order.
633
+ const kept: ResolvedItem[] = [];
634
+ let accum = 0;
635
+ for (let i = evictable.length - 1; i >= 0; i--) {
636
+ const item = evictable[i];
637
+ if (accum + item.tokens <= remainingBudget) {
638
+ kept.push(item);
639
+ accum += item.tokens;
640
+ } else {
641
+ // Once an item doesn't fit we stop — all older items are also dropped
642
+ break;
643
+ }
644
+ }
645
+ kept.reverse();
646
+ selected.push(...kept);
647
+ evictableTokens = accum;
648
+ }
649
+
650
+ // Append fresh tail after the evictable prefix
651
+ selected.push(...freshTail);
652
+
653
+ const estimatedTokens = evictableTokens + tailTokens;
654
+
655
+ // Normalize assistant string content to array blocks (some providers return
656
+ // content as a plain string; Anthropic expects content block arrays).
657
+ const rawMessages = selected.map((item) => item.message);
658
+ for (let i = 0; i < rawMessages.length; i++) {
659
+ const msg = rawMessages[i];
660
+ if (msg?.role === "assistant" && typeof msg.content === "string") {
661
+ rawMessages[i] = {
662
+ ...msg,
663
+ content: [{ type: "text", text: msg.content }] as unknown as typeof msg.content,
664
+ } as typeof msg;
665
+ }
666
+ }
667
+
668
+ return {
669
+ messages: sanitizeToolUseResultPairing(rawMessages) as AgentMessage[],
670
+ estimatedTokens,
671
+ systemPromptAddition,
672
+ stats: {
673
+ rawMessageCount,
674
+ summaryCount,
675
+ totalContextItems: resolved.length,
676
+ },
677
+ };
678
+ }
679
+
680
+ // ── Private helpers ──────────────────────────────────────────────────────
681
+
682
+ /**
683
+ * Resolve a list of context items into ResolvedItems by fetching the
684
+ * underlying message or summary record for each.
685
+ *
686
+ * Items that cannot be resolved (e.g. deleted message) are silently skipped.
687
+ */
688
+ private async resolveItems(contextItems: ContextItemRecord[]): Promise<ResolvedItem[]> {
689
+ const resolved: ResolvedItem[] = [];
690
+
691
+ for (const item of contextItems) {
692
+ const result = await this.resolveItem(item);
693
+ if (result) {
694
+ resolved.push(result);
695
+ }
696
+ }
697
+
698
+ return resolved;
699
+ }
700
+
701
+ /**
702
+ * Resolve a single context item.
703
+ */
704
+ private async resolveItem(item: ContextItemRecord): Promise<ResolvedItem | null> {
705
+ if (item.itemType === "message" && item.messageId != null) {
706
+ return this.resolveMessageItem(item);
707
+ }
708
+
709
+ if (item.itemType === "summary" && item.summaryId != null) {
710
+ return this.resolveSummaryItem(item);
711
+ }
712
+
713
+ // Malformed item — skip
714
+ return null;
715
+ }
716
+
717
+ /**
718
+ * Resolve a context item that references a raw message.
719
+ */
720
+ private async resolveMessageItem(item: ContextItemRecord): Promise<ResolvedItem | null> {
721
+ const msg = await this.conversationStore.getMessageById(item.messageId!);
722
+ if (!msg) {
723
+ return null;
724
+ }
725
+
726
+ const parts = await this.conversationStore.getMessageParts(msg.messageId);
727
+ const roleFromStore = toRuntimeRole(msg.role, parts);
728
+ const isToolResult = roleFromStore === "toolResult";
729
+ const toolCallId = isToolResult ? pickToolCallId(parts) : undefined;
730
+ const toolName = isToolResult ? (pickToolName(parts) ?? "unknown") : undefined;
731
+ const toolIsError = isToolResult ? pickToolIsError(parts) : undefined;
732
+ // Tool results without a call id cannot be serialized for Anthropic-compatible APIs.
733
+ // This happens for legacy/bootstrap rows that have role=tool but no message_parts.
734
+ // Preserve the text by degrading to assistant content instead of emitting invalid toolResult.
735
+ const role: "user" | "assistant" | "toolResult" =
736
+ isToolResult && !toolCallId ? "assistant" : roleFromStore;
737
+ const content = contentFromParts(parts, role, msg.content);
738
+ const contentText =
739
+ typeof content === "string" ? content : (JSON.stringify(content) ?? msg.content);
740
+ const tokenCount = msg.tokenCount > 0 ? msg.tokenCount : estimateTokens(contentText);
741
+
742
+ // Cast: these are reconstructed from DB storage, not live agent messages,
743
+ // so they won't carry the full AgentMessage metadata (timestamp, usage, etc.)
744
+ return {
745
+ ordinal: item.ordinal,
746
+ message:
747
+ role === "assistant"
748
+ ? ({
749
+ role,
750
+ content,
751
+ usage: {
752
+ input: 0,
753
+ output: tokenCount,
754
+ cacheRead: 0,
755
+ cacheWrite: 0,
756
+ totalTokens: tokenCount,
757
+ cost: {
758
+ input: 0,
759
+ output: 0,
760
+ cacheRead: 0,
761
+ cacheWrite: 0,
762
+ total: 0,
763
+ },
764
+ },
765
+ } as AgentMessage)
766
+ : ({
767
+ role,
768
+ content,
769
+ ...(toolCallId ? { toolCallId } : {}),
770
+ ...(toolName ? { toolName } : {}),
771
+ ...(role === "toolResult" && toolIsError !== undefined ? { isError: toolIsError } : {}),
772
+ } as AgentMessage),
773
+ tokens: tokenCount,
774
+ isMessage: true,
775
+ };
776
+ }
777
+
778
+ /**
779
+ * Resolve a context item that references a summary.
780
+ * Summaries are presented as user messages with a structured XML wrapper.
781
+ */
782
+ private async resolveSummaryItem(item: ContextItemRecord): Promise<ResolvedItem | null> {
783
+ const summary = await this.summaryStore.getSummary(item.summaryId!);
784
+ if (!summary) {
785
+ return null;
786
+ }
787
+
788
+ const content = await formatSummaryContent(summary, this.summaryStore, this.timezone);
789
+ const tokens = estimateTokens(content);
790
+
791
+ // Cast: summaries are synthetic user messages without full AgentMessage metadata
792
+ return {
793
+ ordinal: item.ordinal,
794
+ message: { role: "user" as const, content } as AgentMessage,
795
+ tokens,
796
+ isMessage: false,
797
+ summarySignal: {
798
+ kind: summary.kind,
799
+ depth: summary.depth,
800
+ descendantCount: summary.descendantCount,
801
+ },
802
+ };
803
+ }
804
+ }