pi-background-tasks 0.7.6 → 0.9.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.
@@ -1,18 +1,24 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { canonicalJson } from '../attested-pi-run.js';
3
+ import {
4
+ projectVisibleConversationV2,
5
+ type OmittedRunCounts,
6
+ type ProjectedConversationV2,
7
+ type ProjectionEntry,
8
+ } from '../context/visible-conversation-v2.js';
2
9
  import {
3
- buildSessionContext,
4
- convertToLlm,
5
- type SessionEntry,
6
- } from '@earendil-works/pi-coding-agent';
10
+ snapshotParentConversation,
11
+ type ParentContextSource,
12
+ type ParentSnapshotOptions,
13
+ type ReadonlyParentSessionManager,
14
+ } from '../context/parent-snapshot.js';
7
15
  import type { Message } from '@earendil-works/pi-ai';
8
- import { canonicalJson } from '../attested-pi-run.js';
9
- import { isJsonObject, type JsonObject } from '../common.js';
16
+ import { FUSION_BRAINSTORM_TOOL_NAME as FUSION_BRAINSTORM_TOOL_NAME_VALUE } from './workflows.js';
10
17
  import {
11
18
  FUSION_BRANCH_FILTER_ID,
12
19
  FUSION_COMMAND_CONTEXT_POLICY_ID,
13
20
  FUSION_CONTEXT_LEDGER_SCHEMA_VERSION,
14
21
  FUSION_CONTEXT_TRANSFORM_ID,
15
- FUSION_IMAGE_OMISSION_PREFIX,
16
22
  FUSION_INPUT_SCHEMA_VERSION,
17
23
  FUSION_TOOL_CONTEXT_POLICY_ID,
18
24
  FusionError,
@@ -21,33 +27,22 @@ import {
21
27
  type FusionCanonicalRequestV3,
22
28
  type FusionContextOmissionLedgerV2,
23
29
  type FusionContextPolicyDescriptor,
24
- type FusionContextProjectionMapEntry,
25
30
  type FusionConversationProjectionV3,
26
- type FusionOmittedEventKind,
27
- type FusionOmittedEventRecord,
28
- type FusionOmittedRunCounts,
29
- type FusionProjectionAccounting,
30
31
  type FusionProjectionEntry,
31
- type FusionProjectionOmissionEntry,
32
- type FusionProjectionTextEntry,
32
+ type FusionProjectionOmissionCounts,
33
33
  type FusionRequestAuthority,
34
34
  type FusionSource,
35
- type FusionToolCallNameCount,
36
35
  } from './types.js';
37
36
 
38
- export const FUSION_BRAINSTORM_TOOL_NAME = 'fusion_brainstorm';
39
-
40
- export interface FusionReadonlySessionManager {
41
- getLeafId(): string | null;
42
- getLeafEntry(): SessionEntry | undefined;
43
- getEntries(): SessionEntry[];
44
- }
37
+ /**
38
+ * Re-exported from the workflow registry, which owns every workflow's tool name.
39
+ * Kept here so existing importers of this module keep working unchanged.
40
+ */
41
+ export { FUSION_BRAINSTORM_TOOL_NAME, FUSION_VALIDATE_TOOL_NAME } from './workflows.js';
45
42
 
46
- export interface FusionContextSource {
47
- cwd: string;
48
- sessionManager: FusionReadonlySessionManager;
49
- getSystemPrompt(): string;
50
- }
43
+ /** Retained for source compatibility; Fusion's session access is the shared adapter. */
44
+ export type FusionReadonlySessionManager = ReadonlyParentSessionManager;
45
+ export type FusionContextSource = ParentContextSource;
51
46
 
52
47
  export interface BuildFusionCanonicalInputOptions {
53
48
  source: FusionSource;
@@ -67,129 +62,8 @@ export function normalizeFusionCommandRequest(args: string): string {
67
62
  return args.trim();
68
63
  }
69
64
 
70
- function entriesById(entries: readonly SessionEntry[]): Map<string, SessionEntry> {
71
- const byId = new Map<string, SessionEntry>();
72
- for (const entry of entries) byId.set(entry.id, entry);
73
- return byId;
74
- }
75
-
76
- function readArray(record: JsonObject, key: string): readonly unknown[] | undefined {
77
- const value = record[key];
78
- return Array.isArray(value) ? value : undefined;
79
- }
80
-
81
- function recordOf(value: unknown): JsonObject | undefined {
82
- if (!isJsonObject(value) || Array.isArray(value)) return undefined;
83
- return value;
84
- }
85
-
86
- function entryMessage(entry: SessionEntry): JsonObject | undefined {
87
- if (entry.type !== 'message') return undefined;
88
- return recordOf(entry.message);
89
- }
90
-
91
- function toolCallPartMatches(
92
- part: unknown,
93
- toolCallId: string | undefined,
94
- toolName: string,
95
- ): boolean {
96
- const record = recordOf(part);
97
- if (record === undefined || record['type'] !== 'toolCall') return false;
98
- if (toolCallId !== undefined) return record['id'] === toolCallId;
99
- return record['name'] === toolName;
100
- }
101
-
102
- function messageContainsToolCall(
103
- message: JsonObject,
104
- toolCallId: string | undefined,
105
- toolName: string,
106
- ): boolean {
107
- if (message['role'] !== 'assistant') return false;
108
- const content = readArray(message, 'content');
109
- if (content === undefined) return false;
110
- for (const part of content) {
111
- if (toolCallPartMatches(part, toolCallId, toolName)) return true;
112
- }
113
- return false;
114
- }
115
-
116
- interface EffectiveLeaf {
117
- leafId: string | null;
118
- activeToolCallLeafExcluded: boolean;
119
- }
120
-
121
- function effectiveLeafForTool(
122
- sessionManager: FusionReadonlySessionManager,
123
- toolCallId: string | undefined,
124
- toolName: string,
125
- ): EffectiveLeaf {
126
- const leaf = sessionManager.getLeafEntry();
127
- if (leaf === undefined)
128
- return { leafId: sessionManager.getLeafId(), activeToolCallLeafExcluded: false };
129
- const message = entryMessage(leaf);
130
- if (message !== undefined && messageContainsToolCall(message, toolCallId, toolName)) {
131
- return { leafId: leaf.parentId, activeToolCallLeafExcluded: true };
132
- }
133
- return { leafId: sessionManager.getLeafId(), activeToolCallLeafExcluded: false };
134
- }
135
-
136
- function effectiveLeaf(
137
- sessionManager: FusionReadonlySessionManager,
138
- options: BuildFusionCanonicalInputOptions,
139
- ): EffectiveLeaf {
140
- if (options.source !== 'tool')
141
- return { leafId: sessionManager.getLeafId(), activeToolCallLeafExcluded: false };
142
- return effectiveLeafForTool(
143
- sessionManager,
144
- options.toolCallId,
145
- options.toolName ?? FUSION_BRAINSTORM_TOOL_NAME,
146
- );
147
- }
148
-
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
65
  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
- );
66
+ return createHash('sha256').update(Buffer.from(value, 'utf8')).digest('hex');
193
67
  }
194
68
 
195
69
  function contextPolicyId(source: FusionSource): string {
@@ -217,382 +91,110 @@ function policyDescriptor(source: FusionSource): FusionContextPolicyDescriptor {
217
91
  };
218
92
  }
219
93
 
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
- }
94
+ function compactOmissionCounts(counts: OmittedRunCounts): FusionProjectionOmissionCounts {
95
+ return [
96
+ counts.assistant_thinking ?? 0,
97
+ counts.tool_calls ?? 0,
98
+ counts.tool_result_texts ?? 0,
99
+ ];
415
100
  }
416
101
 
417
- function imageMarker(mimeType: string): string {
418
- return `${FUSION_IMAGE_OMISSION_PREFIX}${mimeType}]`;
102
+ function expandOmissionCounts(counts: FusionProjectionOmissionCounts): OmittedRunCounts {
103
+ const out: OmittedRunCounts = {};
104
+ const [assistantThinking, toolCalls, toolResults] = counts;
105
+ if (assistantThinking > 0) out.assistant_thinking = assistantThinking;
106
+ if (toolCalls > 0) out.tool_calls = toolCalls;
107
+ if (toolResults > 0) out.tool_result_texts = toolResults;
108
+ return out;
419
109
  }
420
110
 
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;
111
+ export function compactFusionProjectionEntry(entry: ProjectionEntry): FusionProjectionEntry {
112
+ if (entry.kind === 'text') {
113
+ return [
114
+ 't',
115
+ entry.role === 'user' ? 'u' : 'a',
116
+ entry.source_ordinal,
117
+ entry.block_ordinal,
118
+ entry.text,
119
+ ];
120
+ }
121
+ return ['o', [entry.at[0], entry.at[1]], entry.bytes, compactOmissionCounts(entry.counts)];
433
122
  }
434
123
 
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({
124
+ export function expandFusionProjectionEntry(entry: FusionProjectionEntry): ProjectionEntry {
125
+ if (entry[0] === 't') {
126
+ return {
450
127
  kind: 'text',
451
- source_ordinal: sourceOrdinal,
452
- block_ordinal: 0,
453
- role: 'user',
454
- text: content,
455
- });
456
- return;
457
- }
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;
463
- }
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;
483
- }
484
- throw unsupportedBlock(`user block ${String(Reflect.get(block, 'type'))}`);
128
+ source_ordinal: entry[2],
129
+ block_ordinal: entry[3],
130
+ role: entry[1] === 'u' ? 'user' : 'assistant',
131
+ text: entry[4],
132
+ };
485
133
  }
134
+ return {
135
+ kind: 'omitted_activity',
136
+ at: [entry[1][0], entry[1][1]],
137
+ bytes: entry[2],
138
+ counts: expandOmissionCounts(entry[3]),
139
+ };
486
140
  }
487
141
 
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
- }
142
+ function compactFusionProjectionEntries(
143
+ entries: readonly ProjectionEntry[],
144
+ ): readonly FusionProjectionEntry[] {
145
+ return entries.map(compactFusionProjectionEntry);
529
146
  }
530
147
 
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'))}`);
148
+ function compactOmissionReceiptBytes(entries: readonly FusionProjectionEntry[]): number {
149
+ let total = 0;
150
+ for (const entry of entries) {
151
+ if (entry[0] === 'o') total += Buffer.byteLength(canonicalJson(entry), 'utf8');
573
152
  }
153
+ return total;
574
154
  }
575
155
 
576
156
  /**
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.
157
+ * Seal the shared transform output into Fusion's versioned envelopes.
158
+ *
159
+ * Fusion v4 compacts only the child-facing projection entries. Ledger rows are
160
+ * carried through unchanged, so the ledger root commits to exactly the same
161
+ * omitted payload records before and after tuple encoding. Golden tests pin the
162
+ * new canonical-input bytes and the unchanged ledger bytes.
580
163
  */
164
+ function sealFusionProjection(
165
+ projected: ProjectedConversationV2,
166
+ source: FusionSource,
167
+ branchFilter: FusionBranchFilterDescriptor,
168
+ ): { projection: FusionConversationProjectionV3; ledger: FusionContextOmissionLedgerV2 } {
169
+ const entries = compactFusionProjectionEntries(projected.entries);
170
+ return {
171
+ projection: {
172
+ policy: policyDescriptor(source),
173
+ branch_filter: branchFilter,
174
+ entries,
175
+ accounting: {
176
+ ...projected.accounting,
177
+ omission_receipt_utf8_bytes: compactOmissionReceiptBytes(entries),
178
+ },
179
+ },
180
+ ledger: {
181
+ schema_version: FUSION_CONTEXT_LEDGER_SCHEMA_VERSION,
182
+ policy_id: contextPolicyId(source),
183
+ transform: FUSION_CONTEXT_TRANSFORM_ID,
184
+ entries: projected.ledger.entries,
185
+ projection_map: projected.ledger.projection_map,
186
+ root_sha256: projected.ledger.root_sha256,
187
+ },
188
+ };
189
+ }
190
+
191
+ /** Preserved public entry point; delegates to the shared frozen transform. */
581
192
  export function projectFusionConversation(
582
193
  messages: readonly Message[],
583
194
  source: FusionSource,
584
195
  branchFilter: FusionBranchFilterDescriptor,
585
196
  ): { 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);
197
+ return sealFusionProjection(projectVisibleConversationV2(messages), source, branchFilter);
596
198
  }
597
199
 
598
200
  export function buildFusionCanonicalInput(
@@ -605,18 +207,20 @@ export function buildFusionCanonicalInput(
605
207
  childCreated: false,
606
208
  });
607
209
  }
608
- const entries = ctx.sessionManager.getEntries();
609
- const leaf = effectiveLeaf(ctx.sessionManager, options);
610
- const sessionContext = buildSessionContext(entries, leaf.leafId, entriesById(entries));
611
- const llmMessages = convertToLlm(sessionContext.messages);
612
- const toolName = options.toolName ?? FUSION_BRAINSTORM_TOOL_NAME;
210
+ const toolName = options.toolName ?? FUSION_BRAINSTORM_TOOL_NAME_VALUE;
211
+ const snapshotOptions: ParentSnapshotOptions = {
212
+ toolName,
213
+ excludeActiveToolCallLeaf: options.source === 'tool',
214
+ };
215
+ if (options.toolCallId !== undefined) snapshotOptions.toolCallId = options.toolCallId;
216
+ const snapshot = snapshotParentConversation(ctx, snapshotOptions);
613
217
  const branchFilter: FusionBranchFilterDescriptor = {
614
218
  id: FUSION_BRANCH_FILTER_ID,
615
219
  tool_name: toolName,
616
220
  tool_call_id: options.source === 'tool' ? (options.toolCallId ?? null) : null,
617
- active_tool_call_leaf_excluded: leaf.activeToolCallLeafExcluded,
221
+ active_tool_call_leaf_excluded: snapshot.activeToolCallLeafExcluded,
618
222
  };
619
- const projected = projectFusionConversation(llmMessages, options.source, branchFilter);
223
+ const projected = projectFusionConversation(snapshot.messages, options.source, branchFilter);
620
224
  const request: FusionCanonicalRequestV3 = {
621
225
  source: options.source,
622
226
  authority: requestAuthority(options.source),
@@ -634,6 +238,6 @@ export function buildFusionCanonicalInput(
634
238
  input,
635
239
  serialized: canonicalJson(input),
636
240
  ledger: projected.ledger,
637
- transcriptLeafId: leaf.leafId,
241
+ transcriptLeafId: snapshot.leafId,
638
242
  };
639
243
  }