pi-background-tasks 0.7.3 → 0.7.4

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.
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import {
2
3
  buildSessionContext,
3
4
  convertToLlm,
@@ -7,10 +8,31 @@ import type { Message } from '@earendil-works/pi-ai';
7
8
  import { canonicalJson } from '../attested-pi-run.js';
8
9
  import { isJsonObject, type JsonObject } from '../common.js';
9
10
  import {
11
+ FUSION_BRANCH_FILTER_ID,
12
+ FUSION_COMMAND_CONTEXT_POLICY_ID,
13
+ FUSION_CONTEXT_LEDGER_SCHEMA_VERSION,
14
+ FUSION_CONTEXT_TRANSFORM_ID,
15
+ FUSION_IMAGE_OMISSION_PREFIX,
10
16
  FUSION_INPUT_SCHEMA_VERSION,
17
+ FUSION_TOOL_CONTEXT_POLICY_ID,
11
18
  FusionError,
12
- type FusionCanonicalInputV1,
19
+ type FusionBranchFilterDescriptor,
20
+ type FusionCanonicalInputV2,
21
+ type FusionCanonicalRequestV2,
22
+ type FusionContextOmissionLedgerV1,
23
+ type FusionContextPolicyDescriptor,
24
+ type FusionConversationProjectionV2,
25
+ type FusionOmittedEventKind,
26
+ type FusionOmittedEventRecord,
27
+ type FusionOmittedRunBytes,
28
+ type FusionOmittedRunCounts,
29
+ type FusionProjectionAccounting,
30
+ type FusionProjectionEntry,
31
+ type FusionProjectionOmissionEntry,
32
+ type FusionProjectionTextEntry,
33
+ type FusionRequestAuthority,
13
34
  type FusionSource,
35
+ type FusionToolCallNameCount,
14
36
  } from './types.js';
15
37
 
16
38
  export const FUSION_BRAINSTORM_TOOL_NAME = 'fusion_brainstorm';
@@ -35,8 +57,9 @@ export interface BuildFusionCanonicalInputOptions {
35
57
  }
36
58
 
37
59
  export interface BuiltFusionCanonicalInput {
38
- input: FusionCanonicalInputV1;
60
+ input: FusionCanonicalInputV2;
39
61
  serialized: string;
62
+ ledger: FusionContextOmissionLedgerV1;
40
63
  transcriptLeafId: string | null;
41
64
  }
42
65
 
@@ -90,25 +113,32 @@ function messageContainsToolCall(
90
113
  return false;
91
114
  }
92
115
 
116
+ interface EffectiveLeaf {
117
+ leafId: string | null;
118
+ activeToolCallLeafExcluded: boolean;
119
+ }
120
+
93
121
  function effectiveLeafForTool(
94
122
  sessionManager: FusionReadonlySessionManager,
95
123
  toolCallId: string | undefined,
96
124
  toolName: string,
97
- ): string | null {
125
+ ): EffectiveLeaf {
98
126
  const leaf = sessionManager.getLeafEntry();
99
- if (leaf === undefined) return sessionManager.getLeafId();
127
+ if (leaf === undefined)
128
+ return { leafId: sessionManager.getLeafId(), activeToolCallLeafExcluded: false };
100
129
  const message = entryMessage(leaf);
101
130
  if (message !== undefined && messageContainsToolCall(message, toolCallId, toolName)) {
102
- return leaf.parentId;
131
+ return { leafId: leaf.parentId, activeToolCallLeafExcluded: true };
103
132
  }
104
- return sessionManager.getLeafId();
133
+ return { leafId: sessionManager.getLeafId(), activeToolCallLeafExcluded: false };
105
134
  }
106
135
 
107
- function effectiveLeafId(
136
+ function effectiveLeaf(
108
137
  sessionManager: FusionReadonlySessionManager,
109
138
  options: BuildFusionCanonicalInputOptions,
110
- ): string | null {
111
- if (options.source !== 'tool') return sessionManager.getLeafId();
139
+ ): EffectiveLeaf {
140
+ if (options.source !== 'tool')
141
+ return { leafId: sessionManager.getLeafId(), activeToolCallLeafExcluded: false };
112
142
  return effectiveLeafForTool(
113
143
  sessionManager,
114
144
  options.toolCallId,
@@ -116,42 +146,454 @@ function effectiveLeafId(
116
146
  );
117
147
  }
118
148
 
119
- function textContentForTranscript(content: Message['content']): string {
120
- if (typeof content === 'string') return content;
121
- const parts: string[] = [];
122
- for (const block of content) {
123
- if (block.type === 'text') parts.push(block.text);
124
- else if (block.type === 'image')
125
- parts.push(`[Image omitted from fusion text transcript: ${block.mimeType}]`);
149
+ function utf8Bytes(value: string): number {
150
+ return Buffer.byteLength(value, 'utf8');
151
+ }
152
+
153
+ function sha256Hex(bytes: Buffer): string {
154
+ return createHash('sha256').update(bytes).digest('hex');
155
+ }
156
+
157
+ function sha256Text(value: string): string {
158
+ return sha256Hex(Buffer.from(value, 'utf8'));
159
+ }
160
+
161
+ function uint64be(value: number): Buffer {
162
+ const out = Buffer.alloc(8);
163
+ out.writeBigUInt64BE(BigInt(value));
164
+ return out;
165
+ }
166
+
167
+ /** Length-prefixed framing so concatenated fields cannot collide across boundaries. */
168
+ function lengthPrefixed(bytes: Buffer): Buffer {
169
+ return Buffer.concat([uint64be(bytes.length), bytes]);
170
+ }
171
+
172
+ function ledgerLeafHash(index: number, record: FusionOmittedEventRecord): Buffer {
173
+ return createHash('sha256')
174
+ .update(Buffer.from('pi-fusion-ledger-leaf-v1\0', 'utf8'))
175
+ .update(uint64be(index))
176
+ .update(lengthPrefixed(Buffer.from(canonicalJson(record), 'utf8')))
177
+ .digest();
178
+ }
179
+
180
+ function ledgerRunHash(leaves: readonly Buffer[], first: number): string {
181
+ const hash = createHash('sha256')
182
+ .update(Buffer.from('pi-fusion-ledger-run-v1\0', 'utf8'))
183
+ .update(uint64be(first))
184
+ .update(uint64be(leaves.length));
185
+ for (const leaf of leaves) hash.update(leaf);
186
+ return hash.digest('hex');
187
+ }
188
+
189
+ function ledgerRootHash(leaves: readonly Buffer[]): string {
190
+ const hash = createHash('sha256')
191
+ .update(Buffer.from('pi-fusion-ledger-root-v1\0', 'utf8'))
192
+ .update(uint64be(leaves.length));
193
+ for (const leaf of leaves) hash.update(leaf);
194
+ return hash.digest('hex');
195
+ }
196
+
197
+ function unsupportedBlock(label: string): FusionError {
198
+ return new FusionError(
199
+ `fusion context projection encountered an unsupported conversation block: ${label}`,
200
+ { code: 'context_policy_unsupported_block', childCreated: false },
201
+ );
202
+ }
203
+
204
+ function contextPolicyId(source: FusionSource): string {
205
+ return source === 'tool' ? FUSION_TOOL_CONTEXT_POLICY_ID : FUSION_COMMAND_CONTEXT_POLICY_ID;
206
+ }
207
+
208
+ function requestAuthority(source: FusionSource): FusionRequestAuthority {
209
+ return source === 'tool' ? 'explicit_text' : 'directive_over_projected_conversation';
210
+ }
211
+
212
+ function policyDescriptor(source: FusionSource): FusionContextPolicyDescriptor {
213
+ return {
214
+ id: contextPolicyId(source),
215
+ transform: FUSION_CONTEXT_TRANSFORM_ID,
216
+ version: 1,
217
+ user_text: 'verbatim',
218
+ assistant_text: 'verbatim',
219
+ assistant_thinking: 'ledger_only',
220
+ tool_call_arguments: 'ledger_only',
221
+ tool_results: 'ledger_only',
222
+ tool_payload_preview_bytes: 0,
223
+ images: 'marker_or_ledger_only',
224
+ unknown_block_behavior: 'error',
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Accumulates omitted-event ledger rows and turns maximal contiguous omitted
230
+ * runs into compact, source-ordered receipts inside the canonical input.
231
+ */
232
+ class ProjectionBuilder {
233
+ private readonly entries: FusionProjectionEntry[] = [];
234
+ private readonly ledger: FusionOmittedEventRecord[] = [];
235
+ private readonly leaves: Buffer[] = [];
236
+ private readonly toolCallNames = new Map<string, number>();
237
+ private pendingCounts: Required<FusionOmittedRunCounts> | undefined;
238
+ private pendingBytes: Required<FusionOmittedRunBytes> | undefined;
239
+ private pendingLeaves: Buffer[] = [];
240
+ private pendingLedgerFirst = 0;
241
+ private pendingSourceFirst = 0;
242
+ private pendingSourceLast = 0;
243
+
244
+ includedUserTextBytes = 0;
245
+ includedAssistantTextBytes = 0;
246
+ includedImageMarkers = 0;
247
+ emptyTextBlocks = 0;
248
+
249
+ addText(entry: FusionProjectionTextEntry): void {
250
+ this.flush();
251
+ this.entries.push(entry);
252
+ if (entry.role === 'user') this.includedUserTextBytes += utf8Bytes(entry.text);
253
+ else this.includedAssistantTextBytes += utf8Bytes(entry.text);
126
254
  }
127
- return parts.join('');
128
- }
129
-
130
- function serializeFusionConversation(messages: readonly Message[]): string {
131
- const parts: string[] = [];
132
- for (const message of messages) {
133
- if (message.role === 'user') {
134
- const content = textContentForTranscript(message.content);
135
- if (content.length > 0) parts.push(`[User]: ${content}`);
136
- } else if (message.role === 'assistant') {
137
- const thinkingParts: string[] = [];
138
- const textParts: string[] = [];
139
- const toolCalls: string[] = [];
140
- for (const block of message.content) {
141
- if (block.type === 'thinking') thinkingParts.push(block.thinking);
142
- else if (block.type === 'text') textParts.push(block.text);
143
- else if (block.type === 'toolCall')
144
- toolCalls.push(`${block.name}(${canonicalJson(block.arguments)})`);
145
- }
146
- if (thinkingParts.length > 0) parts.push(`[Assistant thinking]: ${thinkingParts.join('\n')}`);
147
- if (textParts.length > 0) parts.push(`[Assistant]: ${textParts.join('\n')}`);
148
- if (toolCalls.length > 0) parts.push(`[Assistant tool calls]: ${toolCalls.join('; ')}`);
255
+
256
+ addOmission(
257
+ sourceOrdinal: number,
258
+ blockOrdinal: number,
259
+ kind: FusionOmittedEventKind,
260
+ payload: Buffer,
261
+ extra: { toolName?: string; toolCallId?: string; mimeType?: string } = {},
262
+ ): void {
263
+ const index = this.ledger.length;
264
+ const record: FusionOmittedEventRecord = {
265
+ index,
266
+ source_ordinal: sourceOrdinal,
267
+ block_ordinal: blockOrdinal,
268
+ kind,
269
+ payload_bytes: payload.length,
270
+ payload_sha256: sha256Hex(payload),
271
+ };
272
+ if (extra.toolName !== undefined) record.tool_name = extra.toolName;
273
+ if (extra.toolCallId !== undefined) record.tool_call_id = extra.toolCallId;
274
+ if (extra.mimeType !== undefined) record.mime_type = extra.mimeType;
275
+ this.ledger.push(record);
276
+ const leaf = ledgerLeafHash(index, record);
277
+ this.leaves.push(leaf);
278
+
279
+ if (extra.toolName !== undefined && kind === 'tool_call') {
280
+ this.toolCallNames.set(extra.toolName, (this.toolCallNames.get(extra.toolName) ?? 0) + 1);
281
+ }
282
+
283
+ if (this.pendingCounts === undefined || this.pendingBytes === undefined) {
284
+ this.pendingCounts = {
285
+ assistant_thinking: 0,
286
+ tool_calls: 0,
287
+ tool_result_texts: 0,
288
+ tool_result_images: 0,
289
+ };
290
+ this.pendingBytes = {
291
+ assistant_thinking: 0,
292
+ tool_call_arguments: 0,
293
+ tool_result_text: 0,
294
+ tool_result_image: 0,
295
+ };
296
+ this.pendingLeaves = [];
297
+ this.pendingLedgerFirst = index;
298
+ this.pendingSourceFirst = sourceOrdinal;
299
+ }
300
+ this.pendingSourceLast = sourceOrdinal;
301
+ this.pendingLeaves.push(leaf);
302
+ if (kind === 'assistant_thinking') {
303
+ this.pendingCounts.assistant_thinking += 1;
304
+ this.pendingBytes.assistant_thinking += payload.length;
305
+ } else if (kind === 'tool_call') {
306
+ this.pendingCounts.tool_calls += 1;
307
+ this.pendingBytes.tool_call_arguments += payload.length;
308
+ } else if (kind === 'tool_result_text') {
309
+ this.pendingCounts.tool_result_texts += 1;
310
+ this.pendingBytes.tool_result_text += payload.length;
149
311
  } else {
150
- const content = textContentForTranscript(message.content);
151
- if (content.length > 0) parts.push(`[Tool result]: ${content}`);
312
+ this.pendingCounts.tool_result_images += 1;
313
+ this.pendingBytes.tool_result_image += payload.length;
314
+ }
315
+ }
316
+
317
+ private flush(): void {
318
+ const counts = this.pendingCounts;
319
+ const bytes = this.pendingBytes;
320
+ if (counts === undefined || bytes === undefined) return;
321
+ const entry: FusionProjectionOmissionEntry = {
322
+ kind: 'omitted_activity',
323
+ source_ordinal_first: this.pendingSourceFirst,
324
+ source_ordinal_last: this.pendingSourceLast,
325
+ ledger_index_first: this.pendingLedgerFirst,
326
+ ledger_index_last: this.pendingLedgerFirst + this.pendingLeaves.length - 1,
327
+ counts: compactCounts(counts),
328
+ payload_bytes: compactBytes(bytes),
329
+ ledger_run_sha256: ledgerRunHash(this.pendingLeaves, this.pendingLedgerFirst),
330
+ };
331
+ this.entries.push(entry);
332
+ this.pendingCounts = undefined;
333
+ this.pendingBytes = undefined;
334
+ this.pendingLeaves = [];
335
+ }
336
+
337
+ finish(
338
+ source: FusionSource,
339
+ branchFilter: FusionBranchFilterDescriptor,
340
+ messageCount: number,
341
+ ): { projection: FusionConversationProjectionV2; ledger: FusionContextOmissionLedgerV1 } {
342
+ this.flush();
343
+ const rootSha256 = ledgerRootHash(this.leaves);
344
+ let omittedRunCount = 0;
345
+ let includedTextEntries = 0;
346
+ let thinkingBytes = 0;
347
+ let toolCallCount = 0;
348
+ let toolArgumentBytes = 0;
349
+ let toolResultTextCount = 0;
350
+ let toolResultTextBytes = 0;
351
+ let toolResultImageCount = 0;
352
+ let toolResultImageBytes = 0;
353
+ for (const entry of this.entries) {
354
+ if (entry.kind === 'text') {
355
+ includedTextEntries += 1;
356
+ continue;
357
+ }
358
+ omittedRunCount += 1;
359
+ thinkingBytes += entry.payload_bytes.assistant_thinking ?? 0;
360
+ toolCallCount += entry.counts.tool_calls ?? 0;
361
+ toolArgumentBytes += entry.payload_bytes.tool_call_arguments ?? 0;
362
+ toolResultTextCount += entry.counts.tool_result_texts ?? 0;
363
+ toolResultTextBytes += entry.payload_bytes.tool_result_text ?? 0;
364
+ toolResultImageCount += entry.counts.tool_result_images ?? 0;
365
+ toolResultImageBytes += entry.payload_bytes.tool_result_image ?? 0;
366
+ }
367
+ const toolCallNames: FusionToolCallNameCount[] = [...this.toolCallNames.entries()]
368
+ .map(([name, calls]) => ({ name, calls }))
369
+ .sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
370
+ const accounting: FusionProjectionAccounting = {
371
+ message_count: messageCount,
372
+ included_text_entry_count: includedTextEntries,
373
+ included_user_text_bytes: this.includedUserTextBytes,
374
+ included_assistant_text_bytes: this.includedAssistantTextBytes,
375
+ included_image_marker_count: this.includedImageMarkers,
376
+ empty_text_block_count: this.emptyTextBlocks,
377
+ omitted_run_count: omittedRunCount,
378
+ omitted_event_count: this.ledger.length,
379
+ omitted_thinking_bytes: thinkingBytes,
380
+ omitted_tool_call_count: toolCallCount,
381
+ omitted_tool_call_argument_bytes: toolArgumentBytes,
382
+ omitted_tool_result_text_count: toolResultTextCount,
383
+ omitted_tool_result_text_bytes: toolResultTextBytes,
384
+ omitted_tool_result_image_count: toolResultImageCount,
385
+ omitted_tool_result_image_bytes: toolResultImageBytes,
386
+ tool_call_names: toolCallNames,
387
+ ledger_entry_count: this.ledger.length,
388
+ ledger_root_sha256: rootSha256,
389
+ };
390
+ return {
391
+ projection: {
392
+ policy: policyDescriptor(source),
393
+ branch_filter: branchFilter,
394
+ entries: this.entries,
395
+ accounting,
396
+ },
397
+ ledger: {
398
+ schema_version: FUSION_CONTEXT_LEDGER_SCHEMA_VERSION,
399
+ policy_id: contextPolicyId(source),
400
+ transform: FUSION_CONTEXT_TRANSFORM_ID,
401
+ entries: this.ledger,
402
+ root_sha256: rootSha256,
403
+ },
404
+ };
405
+ }
406
+ }
407
+
408
+ function imageMarker(mimeType: string): string {
409
+ return `${FUSION_IMAGE_OMISSION_PREFIX}${mimeType}]`;
410
+ }
411
+
412
+ /**
413
+ * Fixed policy rule: a zero-valued kind is absent from the receipt rather than
414
+ * serialized as `0`. This keeps receipts compact without losing information —
415
+ * absent is defined by the policy version to mean exactly zero — and it is
416
+ * applied unconditionally, never adaptively because an input happens to be large.
417
+ */
418
+ function compactCounts(counts: Required<FusionOmittedRunCounts>): FusionOmittedRunCounts {
419
+ const out: FusionOmittedRunCounts = {};
420
+ if (counts.assistant_thinking > 0) out.assistant_thinking = counts.assistant_thinking;
421
+ if (counts.tool_calls > 0) out.tool_calls = counts.tool_calls;
422
+ if (counts.tool_result_texts > 0) out.tool_result_texts = counts.tool_result_texts;
423
+ if (counts.tool_result_images > 0) out.tool_result_images = counts.tool_result_images;
424
+ return out;
425
+ }
426
+
427
+ function compactBytes(bytes: Required<FusionOmittedRunBytes>): FusionOmittedRunBytes {
428
+ const out: FusionOmittedRunBytes = {};
429
+ if (bytes.assistant_thinking > 0) out.assistant_thinking = bytes.assistant_thinking;
430
+ if (bytes.tool_call_arguments > 0) out.tool_call_arguments = bytes.tool_call_arguments;
431
+ if (bytes.tool_result_text > 0) out.tool_result_text = bytes.tool_result_text;
432
+ if (bytes.tool_result_image > 0) out.tool_result_image = bytes.tool_result_image;
433
+ return out;
434
+ }
435
+
436
+ /**
437
+ * Projects one user-role message. User text is retained verbatim; images stay
438
+ * marker-only so the child never receives raw bytes.
439
+ */
440
+ function projectUserMessage(
441
+ builder: ProjectionBuilder,
442
+ content: Message['content'],
443
+ sourceOrdinal: number,
444
+ ): void {
445
+ if (typeof content === 'string') {
446
+ if (content.length === 0) {
447
+ builder.emptyTextBlocks += 1;
448
+ return;
449
+ }
450
+ builder.addText({
451
+ kind: 'text',
452
+ source_ordinal: sourceOrdinal,
453
+ block_ordinal: 0,
454
+ role: 'user',
455
+ text: content,
456
+ });
457
+ return;
458
+ }
459
+ for (const [blockOrdinal, block] of content.entries()) {
460
+ if (block.type === 'text') {
461
+ if (block.text.length === 0) {
462
+ builder.emptyTextBlocks += 1;
463
+ continue;
464
+ }
465
+ builder.addText({
466
+ kind: 'text',
467
+ source_ordinal: sourceOrdinal,
468
+ block_ordinal: blockOrdinal,
469
+ role: 'user',
470
+ text: block.text,
471
+ });
472
+ continue;
473
+ }
474
+ if (block.type === 'image') {
475
+ builder.includedImageMarkers += 1;
476
+ builder.addText({
477
+ kind: 'text',
478
+ source_ordinal: sourceOrdinal,
479
+ block_ordinal: blockOrdinal,
480
+ role: 'user',
481
+ text: imageMarker(block.mimeType),
482
+ });
483
+ continue;
152
484
  }
485
+ throw unsupportedBlock(`user block ${String(Reflect.get(block, 'type'))}`);
153
486
  }
154
- return parts.join('\n\n');
487
+ }
488
+
489
+ function projectAssistantMessage(
490
+ builder: ProjectionBuilder,
491
+ content: Extract<Message, { role: 'assistant' }>['content'],
492
+ sourceOrdinal: number,
493
+ ): void {
494
+ for (const [blockOrdinal, block] of content.entries()) {
495
+ if (block.type === 'text') {
496
+ if (block.text.length === 0) {
497
+ builder.emptyTextBlocks += 1;
498
+ continue;
499
+ }
500
+ builder.addText({
501
+ kind: 'text',
502
+ source_ordinal: sourceOrdinal,
503
+ block_ordinal: blockOrdinal,
504
+ role: 'assistant',
505
+ text: block.text,
506
+ });
507
+ continue;
508
+ }
509
+ if (block.type === 'thinking') {
510
+ builder.addOmission(
511
+ sourceOrdinal,
512
+ blockOrdinal,
513
+ 'assistant_thinking',
514
+ Buffer.from(block.thinking, 'utf8'),
515
+ );
516
+ continue;
517
+ }
518
+ if (block.type === 'toolCall') {
519
+ builder.addOmission(
520
+ sourceOrdinal,
521
+ blockOrdinal,
522
+ 'tool_call',
523
+ Buffer.from(canonicalJson(block.arguments), 'utf8'),
524
+ { toolName: block.name, toolCallId: block.id },
525
+ );
526
+ continue;
527
+ }
528
+ throw unsupportedBlock(`assistant block ${String(Reflect.get(block, 'type'))}`);
529
+ }
530
+ }
531
+
532
+ function projectToolResultMessage(
533
+ builder: ProjectionBuilder,
534
+ message: Extract<Message, { role: 'toolResult' }>,
535
+ sourceOrdinal: number,
536
+ ): void {
537
+ const content = message.content;
538
+ if (typeof content === 'string') {
539
+ builder.addOmission(
540
+ sourceOrdinal,
541
+ 0,
542
+ 'tool_result_text',
543
+ Buffer.from(content, 'utf8'),
544
+ { toolName: message.toolName, toolCallId: message.toolCallId },
545
+ );
546
+ return;
547
+ }
548
+ for (const [blockOrdinal, block] of content.entries()) {
549
+ if (block.type === 'text') {
550
+ builder.addOmission(
551
+ sourceOrdinal,
552
+ blockOrdinal,
553
+ 'tool_result_text',
554
+ Buffer.from(block.text, 'utf8'),
555
+ { toolName: message.toolName, toolCallId: message.toolCallId },
556
+ );
557
+ continue;
558
+ }
559
+ if (block.type === 'image') {
560
+ builder.addOmission(
561
+ sourceOrdinal,
562
+ blockOrdinal,
563
+ 'tool_result_image',
564
+ Buffer.from(block.data, 'utf8'),
565
+ {
566
+ toolName: message.toolName,
567
+ toolCallId: message.toolCallId,
568
+ mimeType: block.mimeType,
569
+ },
570
+ );
571
+ continue;
572
+ }
573
+ throw unsupportedBlock(`tool result block ${String(Reflect.get(block, 'type'))}`);
574
+ }
575
+ }
576
+
577
+ /**
578
+ * Deterministic conversation projection. Every retained source block receives
579
+ * exactly one disposition: included verbatim, or represented as an omission
580
+ * ledger row. Unknown block types fail loudly instead of disappearing.
581
+ */
582
+ export function projectFusionConversation(
583
+ messages: readonly Message[],
584
+ source: FusionSource,
585
+ branchFilter: FusionBranchFilterDescriptor,
586
+ ): { projection: FusionConversationProjectionV2; ledger: FusionContextOmissionLedgerV1 } {
587
+ const builder = new ProjectionBuilder();
588
+ for (const [sourceOrdinal, message] of messages.entries()) {
589
+ if (message.role === 'user') projectUserMessage(builder, message.content, sourceOrdinal);
590
+ else if (message.role === 'assistant')
591
+ projectAssistantMessage(builder, message.content, sourceOrdinal);
592
+ else if (message.role === 'toolResult')
593
+ projectToolResultMessage(builder, message, sourceOrdinal);
594
+ else throw unsupportedBlock(`message role ${String(Reflect.get(message, 'role'))}`);
595
+ }
596
+ return builder.finish(source, branchFilter, messages.length);
155
597
  }
156
598
 
157
599
  export function buildFusionCanonicalInput(
@@ -165,15 +607,34 @@ export function buildFusionCanonicalInput(
165
607
  });
166
608
  }
167
609
  const entries = ctx.sessionManager.getEntries();
168
- const leafId = effectiveLeafId(ctx.sessionManager, options);
169
- const sessionContext = buildSessionContext(entries, leafId, entriesById(entries));
610
+ const leaf = effectiveLeaf(ctx.sessionManager, options);
611
+ const sessionContext = buildSessionContext(entries, leaf.leafId, entriesById(entries));
170
612
  const llmMessages = convertToLlm(sessionContext.messages);
171
- const input: FusionCanonicalInputV1 = {
613
+ const toolName = options.toolName ?? FUSION_BRAINSTORM_TOOL_NAME;
614
+ const branchFilter: FusionBranchFilterDescriptor = {
615
+ id: FUSION_BRANCH_FILTER_ID,
616
+ tool_name: toolName,
617
+ tool_call_id: options.source === 'tool' ? (options.toolCallId ?? null) : null,
618
+ active_tool_call_leaf_excluded: leaf.activeToolCallLeafExcluded,
619
+ };
620
+ const projected = projectFusionConversation(llmMessages, options.source, branchFilter);
621
+ const request: FusionCanonicalRequestV2 = {
622
+ source: options.source,
623
+ authority: requestAuthority(options.source),
624
+ text: options.request,
625
+ sha256: sha256Text(options.request),
626
+ };
627
+ const input: FusionCanonicalInputV2 = {
172
628
  schema_version: FUSION_INPUT_SCHEMA_VERSION,
173
629
  cwd: ctx.cwd,
174
630
  system_prompt: ctx.getSystemPrompt(),
175
- conversation_transcript: serializeFusionConversation(llmMessages),
176
- request: options.request,
631
+ request,
632
+ conversation_projection: projected.projection,
633
+ };
634
+ return {
635
+ input,
636
+ serialized: canonicalJson(input),
637
+ ledger: projected.ledger,
638
+ transcriptLeafId: leaf.leafId,
177
639
  };
178
- return { input, serialized: canonicalJson(input), transcriptLeafId: leafId };
179
640
  }