pi-background-tasks 0.7.3 → 0.7.6

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,10 +1,10 @@
1
- import { createHash, randomBytes } from 'node:crypto';
2
- import { closeSync, fsyncSync, openSync, renameSync } from 'node:fs';
3
- import { chmod, mkdir, open, readFile, rm, writeFile } from 'node:fs/promises';
1
+ import { createHash } from 'node:crypto';
2
+ import { chmod, mkdir, open, readFile, rm } from 'node:fs/promises';
4
3
  import { basename, dirname, join } from 'node:path';
5
4
  import { getAgentDir } from '@earendil-works/pi-coding-agent';
6
5
  import type { Api, Model } from '@earendil-works/pi-ai';
7
6
  import { isJsonObject, parseJsonText, type JsonObject } from '../common.js';
7
+ import { replaceFileDurable } from '../durable-fs.js';
8
8
  import {
9
9
  FUSION_MODEL_CONFIG_SCHEMA_VERSION,
10
10
  FusionError,
@@ -260,25 +260,6 @@ export function resolveFusionModels(input: ResolveFusionModelsInput): ResolvedFu
260
260
  };
261
261
  }
262
262
 
263
- async function fsyncFile(path: string): Promise<void> {
264
- const handle = await open(path, 'r');
265
- try {
266
- await handle.sync();
267
- } finally {
268
- await handle.close();
269
- }
270
- }
271
-
272
- async function fsyncDirectory(path: string): Promise<void> {
273
- if (process.platform === 'win32') return;
274
- const fd = openSync(path, 'r');
275
- try {
276
- fsyncSync(fd);
277
- } finally {
278
- closeSync(fd);
279
- }
280
- }
281
-
282
263
  async function delay(ms: number): Promise<void> {
283
264
  await new Promise((resolve) => setTimeout(resolve, ms));
284
265
  }
@@ -348,20 +329,7 @@ export async function saveFusionModelConfig(
348
329
  childCreated: false,
349
330
  });
350
331
  }
351
- const tmp = join(
352
- dir,
353
- `.${basename(path)}.${String(process.pid)}.${randomBytes(6).toString('hex')}.tmp`,
354
- );
355
- const text = prettyConfig(config);
356
- try {
357
- await writeFile(tmp, text, { encoding: 'utf8', mode: 0o600 });
358
- await fsyncFile(tmp);
359
- renameSync(tmp, path);
360
- await fsyncDirectory(dir);
361
- } catch (error) {
362
- await rm(tmp, { force: true });
363
- throw error;
364
- }
332
+ await replaceFileDurable(path, prettyConfig(config));
365
333
  return revisionForPath(path);
366
334
  });
367
335
  }
@@ -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 FusionCanonicalInputV3,
21
+ type FusionCanonicalRequestV3,
22
+ type FusionContextOmissionLedgerV2,
23
+ type FusionContextPolicyDescriptor,
24
+ type FusionContextProjectionMapEntry,
25
+ type FusionConversationProjectionV3,
26
+ type FusionOmittedEventKind,
27
+ type FusionOmittedEventRecord,
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: FusionCanonicalInputV3;
39
61
  serialized: string;
62
+ ledger: FusionContextOmissionLedgerV2;
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,453 @@ 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 ledgerRootHash(leaves: readonly Buffer[]): string {
181
+ const hash = createHash('sha256')
182
+ .update(Buffer.from('pi-fusion-ledger-root-v1\0', 'utf8'))
183
+ .update(uint64be(leaves.length));
184
+ for (const leaf of leaves) hash.update(leaf);
185
+ return hash.digest('hex');
186
+ }
187
+
188
+ function unsupportedBlock(label: string): FusionError {
189
+ return new FusionError(
190
+ `fusion context projection encountered an unsupported conversation block: ${label}`,
191
+ { code: 'context_policy_unsupported_block', childCreated: false },
192
+ );
193
+ }
194
+
195
+ function contextPolicyId(source: FusionSource): string {
196
+ return source === 'tool' ? FUSION_TOOL_CONTEXT_POLICY_ID : FUSION_COMMAND_CONTEXT_POLICY_ID;
197
+ }
198
+
199
+ function requestAuthority(source: FusionSource): FusionRequestAuthority {
200
+ return source === 'tool' ? 'explicit_text' : 'directive_over_projected_conversation';
201
+ }
202
+
203
+ function policyDescriptor(source: FusionSource): FusionContextPolicyDescriptor {
204
+ return {
205
+ id: contextPolicyId(source),
206
+ transform: FUSION_CONTEXT_TRANSFORM_ID,
207
+ version: 2,
208
+ receipt_format: 'omitted_activity.v2',
209
+ user_text: 'verbatim',
210
+ assistant_text: 'verbatim',
211
+ assistant_thinking: 'ledger_only',
212
+ tool_call_arguments: 'ledger_only',
213
+ tool_results: 'ledger_only',
214
+ tool_payload_preview_bytes: 0,
215
+ images: 'marker_or_ledger_only',
216
+ unknown_block_behavior: 'error',
217
+ };
218
+ }
219
+
220
+ /**
221
+ * Accumulates omitted-event ledger rows and turns maximal contiguous non-image
222
+ * omission runs into compact, source-ordered receipts inside the canonical input.
223
+ */
224
+ class ProjectionBuilder {
225
+ private readonly entries: FusionProjectionEntry[] = [];
226
+ private readonly ledger: FusionOmittedEventRecord[] = [];
227
+ private readonly leaves: Buffer[] = [];
228
+ private readonly projectionMap: FusionContextProjectionMapEntry[] = [];
229
+ private readonly toolCallNames = new Map<string, number>();
230
+ private pendingCounts: Required<FusionOmittedRunCounts> | undefined;
231
+ private pendingBytes = 0;
232
+ private pendingLedgerFirst = 0;
233
+ private pendingLedgerLast = 0;
234
+ private pendingSourceFirst = 0;
235
+ private pendingSourceLast = 0;
236
+
237
+ includedUserTextBytes = 0;
238
+ includedAssistantTextBytes = 0;
239
+ includedImageMarkers = 0;
240
+ emptyTextBlocks = 0;
241
+
242
+ addText(entry: FusionProjectionTextEntry): void {
243
+ this.flush();
244
+ this.entries.push(entry);
245
+ if (entry.role === 'user') this.includedUserTextBytes += utf8Bytes(entry.text);
246
+ else this.includedAssistantTextBytes += utf8Bytes(entry.text);
247
+ }
248
+
249
+ addOmission(
250
+ sourceOrdinal: number,
251
+ blockOrdinal: number,
252
+ kind: FusionOmittedEventKind,
253
+ payload: Buffer,
254
+ extra: { toolName?: string; toolCallId?: string; mimeType?: string } = {},
255
+ ): void {
256
+ const index = this.ledger.length;
257
+ const record: FusionOmittedEventRecord = {
258
+ index,
259
+ source_ordinal: sourceOrdinal,
260
+ block_ordinal: blockOrdinal,
261
+ kind,
262
+ payload_bytes: payload.length,
263
+ payload_sha256: sha256Hex(payload),
264
+ };
265
+ if (extra.toolName !== undefined) record.tool_name = extra.toolName;
266
+ if (extra.toolCallId !== undefined) record.tool_call_id = extra.toolCallId;
267
+ if (extra.mimeType !== undefined) record.mime_type = extra.mimeType;
268
+ this.ledger.push(record);
269
+ this.leaves.push(ledgerLeafHash(index, record));
270
+
271
+ if (extra.toolName !== undefined && kind === 'tool_call') {
272
+ this.toolCallNames.set(extra.toolName, (this.toolCallNames.get(extra.toolName) ?? 0) + 1);
273
+ }
274
+
275
+ if (kind === 'tool_result_image') {
276
+ this.flush();
277
+ this.addLedgerOnlyImageMap(index);
278
+ return;
279
+ }
280
+
281
+ if (this.pendingCounts === undefined) {
282
+ this.pendingCounts = {
283
+ assistant_thinking: 0,
284
+ tool_calls: 0,
285
+ tool_result_texts: 0,
286
+ };
287
+ this.pendingBytes = 0;
288
+ this.pendingLedgerFirst = index;
289
+ this.pendingSourceFirst = sourceOrdinal;
290
+ }
291
+ this.pendingLedgerLast = index;
292
+ this.pendingSourceLast = sourceOrdinal;
293
+ this.pendingBytes += payload.length;
294
+ if (kind === 'assistant_thinking') this.pendingCounts.assistant_thinking += 1;
295
+ else if (kind === 'tool_call') this.pendingCounts.tool_calls += 1;
296
+ else this.pendingCounts.tool_result_texts += 1;
297
+ }
298
+
299
+ private addLedgerOnlyImageMap(index: number): void {
300
+ const previous = this.projectionMap[this.projectionMap.length - 1];
301
+ if (
302
+ previous !== undefined &&
303
+ previous.entry_kind === 'ledger_only_tool_result_image' &&
304
+ previous.ledger_index_last + 1 === index
305
+ ) {
306
+ previous.ledger_index_last = index;
307
+ return;
308
+ }
309
+ this.projectionMap.push({
310
+ entry_kind: 'ledger_only_tool_result_image',
311
+ ledger_index_first: index,
312
+ ledger_index_last: index,
313
+ });
314
+ }
315
+
316
+ private flush(): void {
317
+ const counts = this.pendingCounts;
318
+ if (counts === undefined) return;
319
+ const canonicalEntryIndex = this.entries.length;
320
+ const entry: FusionProjectionOmissionEntry = {
321
+ at: [this.pendingSourceFirst, this.pendingSourceLast],
322
+ bytes: this.pendingBytes,
323
+ counts: compactCounts(counts),
324
+ kind: 'omitted_activity',
325
+ };
326
+ this.entries.push(entry);
327
+ this.projectionMap.push({
328
+ canonical_entry_index: canonicalEntryIndex,
329
+ entry_kind: 'omitted_activity',
330
+ ledger_index_first: this.pendingLedgerFirst,
331
+ ledger_index_last: this.pendingLedgerLast,
332
+ });
333
+ this.pendingCounts = undefined;
334
+ this.pendingBytes = 0;
335
+ }
336
+
337
+ finish(
338
+ source: FusionSource,
339
+ branchFilter: FusionBranchFilterDescriptor,
340
+ messageCount: number,
341
+ ): { projection: FusionConversationProjectionV3; ledger: FusionContextOmissionLedgerV2 } {
342
+ this.flush();
343
+ const rootSha256 = ledgerRootHash(this.leaves);
344
+ let omittedRunCount = 0;
345
+ let includedTextEntries = 0;
346
+ let receiptBytes = 0;
347
+ for (const entry of this.entries) {
348
+ if (entry.kind === 'text') includedTextEntries += 1;
349
+ else {
350
+ omittedRunCount += 1;
351
+ receiptBytes += utf8Bytes(canonicalJson(entry));
352
+ }
353
+ }
354
+ let thinkingBytes = 0;
355
+ let toolCallCount = 0;
356
+ let toolArgumentBytes = 0;
357
+ let toolResultTextCount = 0;
358
+ let toolResultTextBytes = 0;
359
+ let toolResultImageCount = 0;
360
+ let toolResultImageBytes = 0;
361
+ for (const row of this.ledger) {
362
+ if (row.kind === 'assistant_thinking') thinkingBytes += row.payload_bytes;
363
+ else if (row.kind === 'tool_call') {
364
+ toolCallCount += 1;
365
+ toolArgumentBytes += row.payload_bytes;
366
+ } else if (row.kind === 'tool_result_text') {
367
+ toolResultTextCount += 1;
368
+ toolResultTextBytes += row.payload_bytes;
369
+ } else {
370
+ toolResultImageCount += 1;
371
+ toolResultImageBytes += row.payload_bytes;
372
+ }
373
+ }
374
+ const toolCallNames: FusionToolCallNameCount[] = [...this.toolCallNames.entries()]
375
+ .map(([name, calls]) => ({ name, calls }))
376
+ .sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
377
+ const accounting: FusionProjectionAccounting = {
378
+ message_count: messageCount,
379
+ included_text_entry_count: includedTextEntries,
380
+ included_user_text_bytes: this.includedUserTextBytes,
381
+ included_assistant_text_bytes: this.includedAssistantTextBytes,
382
+ included_image_marker_count: this.includedImageMarkers,
383
+ empty_text_block_count: this.emptyTextBlocks,
384
+ omitted_run_count: omittedRunCount,
385
+ omitted_event_count: this.ledger.length,
386
+ omitted_thinking_bytes: thinkingBytes,
387
+ omitted_tool_call_count: toolCallCount,
388
+ omitted_tool_call_argument_bytes: toolArgumentBytes,
389
+ omitted_tool_result_text_count: toolResultTextCount,
390
+ omitted_tool_result_text_bytes: toolResultTextBytes,
391
+ omitted_tool_result_image_count: toolResultImageCount,
392
+ omitted_tool_result_image_bytes: toolResultImageBytes,
393
+ tool_call_names: toolCallNames,
394
+ ledger_entry_count: this.ledger.length,
395
+ ledger_root_sha256: rootSha256,
396
+ omission_receipt_utf8_bytes: receiptBytes,
397
+ };
398
+ return {
399
+ projection: {
400
+ policy: policyDescriptor(source),
401
+ branch_filter: branchFilter,
402
+ entries: this.entries,
403
+ accounting,
404
+ },
405
+ ledger: {
406
+ schema_version: FUSION_CONTEXT_LEDGER_SCHEMA_VERSION,
407
+ policy_id: contextPolicyId(source),
408
+ transform: FUSION_CONTEXT_TRANSFORM_ID,
409
+ entries: this.ledger,
410
+ projection_map: this.projectionMap,
411
+ root_sha256: rootSha256,
412
+ },
413
+ };
414
+ }
415
+ }
416
+
417
+ function imageMarker(mimeType: string): string {
418
+ return `${FUSION_IMAGE_OMISSION_PREFIX}${mimeType}]`;
419
+ }
420
+
421
+ /**
422
+ * Fixed policy rule: a zero-valued kind is absent from the receipt rather than
423
+ * serialized as `0`. This keeps receipts compact without losing information —
424
+ * absent is defined by the policy version to mean exactly zero — and it is
425
+ * applied unconditionally, never adaptively because an input happens to be large.
426
+ */
427
+ function compactCounts(counts: Required<FusionOmittedRunCounts>): FusionOmittedRunCounts {
428
+ const out: FusionOmittedRunCounts = {};
429
+ if (counts.assistant_thinking > 0) out.assistant_thinking = counts.assistant_thinking;
430
+ if (counts.tool_calls > 0) out.tool_calls = counts.tool_calls;
431
+ if (counts.tool_result_texts > 0) out.tool_result_texts = counts.tool_result_texts;
432
+ return out;
433
+ }
434
+
435
+ /**
436
+ * Projects one user-role message. User text is retained verbatim; images stay
437
+ * marker-only so the child never receives raw bytes.
438
+ */
439
+ function projectUserMessage(
440
+ builder: ProjectionBuilder,
441
+ content: Message['content'],
442
+ sourceOrdinal: number,
443
+ ): void {
444
+ if (typeof content === 'string') {
445
+ if (content.length === 0) {
446
+ builder.emptyTextBlocks += 1;
447
+ return;
448
+ }
449
+ builder.addText({
450
+ kind: 'text',
451
+ source_ordinal: sourceOrdinal,
452
+ block_ordinal: 0,
453
+ role: 'user',
454
+ text: content,
455
+ });
456
+ return;
126
457
  }
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)})`);
458
+ for (const [blockOrdinal, block] of content.entries()) {
459
+ if (block.type === 'text') {
460
+ if (block.text.length === 0) {
461
+ builder.emptyTextBlocks += 1;
462
+ continue;
145
463
  }
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('; ')}`);
149
- } else {
150
- const content = textContentForTranscript(message.content);
151
- if (content.length > 0) parts.push(`[Tool result]: ${content}`);
464
+ builder.addText({
465
+ kind: 'text',
466
+ source_ordinal: sourceOrdinal,
467
+ block_ordinal: blockOrdinal,
468
+ role: 'user',
469
+ text: block.text,
470
+ });
471
+ continue;
472
+ }
473
+ if (block.type === 'image') {
474
+ builder.includedImageMarkers += 1;
475
+ builder.addText({
476
+ kind: 'text',
477
+ source_ordinal: sourceOrdinal,
478
+ block_ordinal: blockOrdinal,
479
+ role: 'user',
480
+ text: imageMarker(block.mimeType),
481
+ });
482
+ continue;
152
483
  }
484
+ throw unsupportedBlock(`user block ${String(Reflect.get(block, 'type'))}`);
153
485
  }
154
- return parts.join('\n\n');
486
+ }
487
+
488
+ function projectAssistantMessage(
489
+ builder: ProjectionBuilder,
490
+ content: Extract<Message, { role: 'assistant' }>['content'],
491
+ sourceOrdinal: number,
492
+ ): void {
493
+ for (const [blockOrdinal, block] of content.entries()) {
494
+ if (block.type === 'text') {
495
+ if (block.text.length === 0) {
496
+ builder.emptyTextBlocks += 1;
497
+ continue;
498
+ }
499
+ builder.addText({
500
+ kind: 'text',
501
+ source_ordinal: sourceOrdinal,
502
+ block_ordinal: blockOrdinal,
503
+ role: 'assistant',
504
+ text: block.text,
505
+ });
506
+ continue;
507
+ }
508
+ if (block.type === 'thinking') {
509
+ builder.addOmission(
510
+ sourceOrdinal,
511
+ blockOrdinal,
512
+ 'assistant_thinking',
513
+ Buffer.from(block.thinking, 'utf8'),
514
+ );
515
+ continue;
516
+ }
517
+ if (block.type === 'toolCall') {
518
+ builder.addOmission(
519
+ sourceOrdinal,
520
+ blockOrdinal,
521
+ 'tool_call',
522
+ Buffer.from(canonicalJson(block.arguments), 'utf8'),
523
+ { toolName: block.name, toolCallId: block.id },
524
+ );
525
+ continue;
526
+ }
527
+ throw unsupportedBlock(`assistant block ${String(Reflect.get(block, 'type'))}`);
528
+ }
529
+ }
530
+
531
+ function projectToolResultMessage(
532
+ builder: ProjectionBuilder,
533
+ message: Extract<Message, { role: 'toolResult' }>,
534
+ sourceOrdinal: number,
535
+ ): void {
536
+ const content = message.content;
537
+ if (typeof content === 'string') {
538
+ builder.addOmission(
539
+ sourceOrdinal,
540
+ 0,
541
+ 'tool_result_text',
542
+ Buffer.from(content, 'utf8'),
543
+ { toolName: message.toolName, toolCallId: message.toolCallId },
544
+ );
545
+ return;
546
+ }
547
+ for (const [blockOrdinal, block] of content.entries()) {
548
+ if (block.type === 'text') {
549
+ builder.addOmission(
550
+ sourceOrdinal,
551
+ blockOrdinal,
552
+ 'tool_result_text',
553
+ Buffer.from(block.text, 'utf8'),
554
+ { toolName: message.toolName, toolCallId: message.toolCallId },
555
+ );
556
+ continue;
557
+ }
558
+ if (block.type === 'image') {
559
+ builder.addOmission(
560
+ sourceOrdinal,
561
+ blockOrdinal,
562
+ 'tool_result_image',
563
+ Buffer.from(block.data, 'utf8'),
564
+ {
565
+ toolName: message.toolName,
566
+ toolCallId: message.toolCallId,
567
+ mimeType: block.mimeType,
568
+ },
569
+ );
570
+ continue;
571
+ }
572
+ throw unsupportedBlock(`tool result block ${String(Reflect.get(block, 'type'))}`);
573
+ }
574
+ }
575
+
576
+ /**
577
+ * Deterministic conversation projection. Every retained source block receives
578
+ * exactly one disposition: included verbatim, or represented as an omission
579
+ * ledger row. Unknown block types fail loudly instead of disappearing.
580
+ */
581
+ export function projectFusionConversation(
582
+ messages: readonly Message[],
583
+ source: FusionSource,
584
+ branchFilter: FusionBranchFilterDescriptor,
585
+ ): { projection: FusionConversationProjectionV3; ledger: FusionContextOmissionLedgerV2 } {
586
+ const builder = new ProjectionBuilder();
587
+ for (const [sourceOrdinal, message] of messages.entries()) {
588
+ if (message.role === 'user') projectUserMessage(builder, message.content, sourceOrdinal);
589
+ else if (message.role === 'assistant')
590
+ projectAssistantMessage(builder, message.content, sourceOrdinal);
591
+ else if (message.role === 'toolResult')
592
+ projectToolResultMessage(builder, message, sourceOrdinal);
593
+ else throw unsupportedBlock(`message role ${String(Reflect.get(message, 'role'))}`);
594
+ }
595
+ return builder.finish(source, branchFilter, messages.length);
155
596
  }
156
597
 
157
598
  export function buildFusionCanonicalInput(
@@ -165,15 +606,34 @@ export function buildFusionCanonicalInput(
165
606
  });
166
607
  }
167
608
  const entries = ctx.sessionManager.getEntries();
168
- const leafId = effectiveLeafId(ctx.sessionManager, options);
169
- const sessionContext = buildSessionContext(entries, leafId, entriesById(entries));
609
+ const leaf = effectiveLeaf(ctx.sessionManager, options);
610
+ const sessionContext = buildSessionContext(entries, leaf.leafId, entriesById(entries));
170
611
  const llmMessages = convertToLlm(sessionContext.messages);
171
- const input: FusionCanonicalInputV1 = {
612
+ const toolName = options.toolName ?? FUSION_BRAINSTORM_TOOL_NAME;
613
+ const branchFilter: FusionBranchFilterDescriptor = {
614
+ id: FUSION_BRANCH_FILTER_ID,
615
+ tool_name: toolName,
616
+ tool_call_id: options.source === 'tool' ? (options.toolCallId ?? null) : null,
617
+ active_tool_call_leaf_excluded: leaf.activeToolCallLeafExcluded,
618
+ };
619
+ const projected = projectFusionConversation(llmMessages, options.source, branchFilter);
620
+ const request: FusionCanonicalRequestV3 = {
621
+ source: options.source,
622
+ authority: requestAuthority(options.source),
623
+ text: options.request,
624
+ sha256: sha256Text(options.request),
625
+ };
626
+ const input: FusionCanonicalInputV3 = {
172
627
  schema_version: FUSION_INPUT_SCHEMA_VERSION,
173
628
  cwd: ctx.cwd,
174
629
  system_prompt: ctx.getSystemPrompt(),
175
- conversation_transcript: serializeFusionConversation(llmMessages),
176
- request: options.request,
630
+ request,
631
+ conversation_projection: projected.projection,
632
+ };
633
+ return {
634
+ input,
635
+ serialized: canonicalJson(input),
636
+ ledger: projected.ledger,
637
+ transcriptLeafId: leaf.leafId,
177
638
  };
178
- return { input, serialized: canonicalJson(input), transcriptLeafId: leafId };
179
639
  }