pi-background-tasks 0.7.4 → 0.7.7

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,53 +1,43 @@
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';
10
16
  import {
11
17
  FUSION_BRANCH_FILTER_ID,
12
18
  FUSION_COMMAND_CONTEXT_POLICY_ID,
13
19
  FUSION_CONTEXT_LEDGER_SCHEMA_VERSION,
14
20
  FUSION_CONTEXT_TRANSFORM_ID,
15
- FUSION_IMAGE_OMISSION_PREFIX,
16
21
  FUSION_INPUT_SCHEMA_VERSION,
17
22
  FUSION_TOOL_CONTEXT_POLICY_ID,
18
23
  FusionError,
19
24
  type FusionBranchFilterDescriptor,
20
- type FusionCanonicalInputV2,
21
- type FusionCanonicalRequestV2,
22
- type FusionContextOmissionLedgerV1,
25
+ type FusionCanonicalInputV3,
26
+ type FusionCanonicalRequestV3,
27
+ type FusionContextOmissionLedgerV2,
23
28
  type FusionContextPolicyDescriptor,
24
- type FusionConversationProjectionV2,
25
- type FusionOmittedEventKind,
26
- type FusionOmittedEventRecord,
27
- type FusionOmittedRunBytes,
28
- type FusionOmittedRunCounts,
29
- type FusionProjectionAccounting,
29
+ type FusionConversationProjectionV3,
30
30
  type FusionProjectionEntry,
31
- type FusionProjectionOmissionEntry,
32
- type FusionProjectionTextEntry,
31
+ type FusionProjectionOmissionCounts,
33
32
  type FusionRequestAuthority,
34
33
  type FusionSource,
35
- type FusionToolCallNameCount,
36
34
  } from './types.js';
37
35
 
38
36
  export const FUSION_BRAINSTORM_TOOL_NAME = 'fusion_brainstorm';
39
37
 
40
- export interface FusionReadonlySessionManager {
41
- getLeafId(): string | null;
42
- getLeafEntry(): SessionEntry | undefined;
43
- getEntries(): SessionEntry[];
44
- }
45
-
46
- export interface FusionContextSource {
47
- cwd: string;
48
- sessionManager: FusionReadonlySessionManager;
49
- getSystemPrompt(): string;
50
- }
38
+ /** Retained for source compatibility; Fusion's session access is the shared adapter. */
39
+ export type FusionReadonlySessionManager = ReadonlyParentSessionManager;
40
+ export type FusionContextSource = ParentContextSource;
51
41
 
52
42
  export interface BuildFusionCanonicalInputOptions {
53
43
  source: FusionSource;
@@ -57,9 +47,9 @@ export interface BuildFusionCanonicalInputOptions {
57
47
  }
58
48
 
59
49
  export interface BuiltFusionCanonicalInput {
60
- input: FusionCanonicalInputV2;
50
+ input: FusionCanonicalInputV3;
61
51
  serialized: string;
62
- ledger: FusionContextOmissionLedgerV1;
52
+ ledger: FusionContextOmissionLedgerV2;
63
53
  transcriptLeafId: string | null;
64
54
  }
65
55
 
@@ -67,138 +57,8 @@ export function normalizeFusionCommandRequest(args: string): string {
67
57
  return args.trim();
68
58
  }
69
59
 
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
60
  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
- );
61
+ return createHash('sha256').update(Buffer.from(value, 'utf8')).digest('hex');
202
62
  }
203
63
 
204
64
  function contextPolicyId(source: FusionSource): string {
@@ -213,7 +73,8 @@ function policyDescriptor(source: FusionSource): FusionContextPolicyDescriptor {
213
73
  return {
214
74
  id: contextPolicyId(source),
215
75
  transform: FUSION_CONTEXT_TRANSFORM_ID,
216
- version: 1,
76
+ version: 2,
77
+ receipt_format: 'omitted_activity.v2',
217
78
  user_text: 'verbatim',
218
79
  assistant_text: 'verbatim',
219
80
  assistant_thinking: 'ledger_only',
@@ -225,375 +86,110 @@ function policyDescriptor(source: FusionSource): FusionContextPolicyDescriptor {
225
86
  };
226
87
  }
227
88
 
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);
254
- }
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;
311
- } else {
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
- }
89
+ function compactOmissionCounts(counts: OmittedRunCounts): FusionProjectionOmissionCounts {
90
+ return [
91
+ counts.assistant_thinking ?? 0,
92
+ counts.tool_calls ?? 0,
93
+ counts.tool_result_texts ?? 0,
94
+ ];
406
95
  }
407
96
 
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;
97
+ function expandOmissionCounts(counts: FusionProjectionOmissionCounts): OmittedRunCounts {
98
+ const out: OmittedRunCounts = {};
99
+ const [assistantThinking, toolCalls, toolResults] = counts;
100
+ if (assistantThinking > 0) out.assistant_thinking = assistantThinking;
101
+ if (toolCalls > 0) out.tool_calls = toolCalls;
102
+ if (toolResults > 0) out.tool_result_texts = toolResults;
424
103
  return out;
425
104
  }
426
105
 
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;
106
+ export function compactFusionProjectionEntry(entry: ProjectionEntry): FusionProjectionEntry {
107
+ if (entry.kind === 'text') {
108
+ return [
109
+ 't',
110
+ entry.role === 'user' ? 'u' : 'a',
111
+ entry.source_ordinal,
112
+ entry.block_ordinal,
113
+ entry.text,
114
+ ];
115
+ }
116
+ return ['o', [entry.at[0], entry.at[1]], entry.bytes, compactOmissionCounts(entry.counts)];
434
117
  }
435
118
 
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({
119
+ export function expandFusionProjectionEntry(entry: FusionProjectionEntry): ProjectionEntry {
120
+ if (entry[0] === 't') {
121
+ return {
451
122
  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;
484
- }
485
- throw unsupportedBlock(`user block ${String(Reflect.get(block, 'type'))}`);
123
+ source_ordinal: entry[2],
124
+ block_ordinal: entry[3],
125
+ role: entry[1] === 'u' ? 'user' : 'assistant',
126
+ text: entry[4],
127
+ };
486
128
  }
129
+ return {
130
+ kind: 'omitted_activity',
131
+ at: [entry[1][0], entry[1][1]],
132
+ bytes: entry[2],
133
+ counts: expandOmissionCounts(entry[3]),
134
+ };
487
135
  }
488
136
 
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
- }
137
+ function compactFusionProjectionEntries(
138
+ entries: readonly ProjectionEntry[],
139
+ ): readonly FusionProjectionEntry[] {
140
+ return entries.map(compactFusionProjectionEntry);
530
141
  }
531
142
 
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'))}`);
143
+ function compactOmissionReceiptBytes(entries: readonly FusionProjectionEntry[]): number {
144
+ let total = 0;
145
+ for (const entry of entries) {
146
+ if (entry[0] === 'o') total += Buffer.byteLength(canonicalJson(entry), 'utf8');
574
147
  }
148
+ return total;
575
149
  }
576
150
 
577
151
  /**
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.
152
+ * Seal the shared transform output into Fusion's versioned envelopes.
153
+ *
154
+ * Fusion v4 compacts only the child-facing projection entries. Ledger rows are
155
+ * carried through unchanged, so the ledger root commits to exactly the same
156
+ * omitted payload records before and after tuple encoding. Golden tests pin the
157
+ * new canonical-input bytes and the unchanged ledger bytes.
581
158
  */
159
+ function sealFusionProjection(
160
+ projected: ProjectedConversationV2,
161
+ source: FusionSource,
162
+ branchFilter: FusionBranchFilterDescriptor,
163
+ ): { projection: FusionConversationProjectionV3; ledger: FusionContextOmissionLedgerV2 } {
164
+ const entries = compactFusionProjectionEntries(projected.entries);
165
+ return {
166
+ projection: {
167
+ policy: policyDescriptor(source),
168
+ branch_filter: branchFilter,
169
+ entries,
170
+ accounting: {
171
+ ...projected.accounting,
172
+ omission_receipt_utf8_bytes: compactOmissionReceiptBytes(entries),
173
+ },
174
+ },
175
+ ledger: {
176
+ schema_version: FUSION_CONTEXT_LEDGER_SCHEMA_VERSION,
177
+ policy_id: contextPolicyId(source),
178
+ transform: FUSION_CONTEXT_TRANSFORM_ID,
179
+ entries: projected.ledger.entries,
180
+ projection_map: projected.ledger.projection_map,
181
+ root_sha256: projected.ledger.root_sha256,
182
+ },
183
+ };
184
+ }
185
+
186
+ /** Preserved public entry point; delegates to the shared frozen transform. */
582
187
  export function projectFusionConversation(
583
188
  messages: readonly Message[],
584
189
  source: FusionSource,
585
190
  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);
191
+ ): { projection: FusionConversationProjectionV3; ledger: FusionContextOmissionLedgerV2 } {
192
+ return sealFusionProjection(projectVisibleConversationV2(messages), source, branchFilter);
597
193
  }
598
194
 
599
195
  export function buildFusionCanonicalInput(
@@ -606,25 +202,27 @@ export function buildFusionCanonicalInput(
606
202
  childCreated: false,
607
203
  });
608
204
  }
609
- const entries = ctx.sessionManager.getEntries();
610
- const leaf = effectiveLeaf(ctx.sessionManager, options);
611
- const sessionContext = buildSessionContext(entries, leaf.leafId, entriesById(entries));
612
- const llmMessages = convertToLlm(sessionContext.messages);
613
205
  const toolName = options.toolName ?? FUSION_BRAINSTORM_TOOL_NAME;
206
+ const snapshotOptions: ParentSnapshotOptions = {
207
+ toolName,
208
+ excludeActiveToolCallLeaf: options.source === 'tool',
209
+ };
210
+ if (options.toolCallId !== undefined) snapshotOptions.toolCallId = options.toolCallId;
211
+ const snapshot = snapshotParentConversation(ctx, snapshotOptions);
614
212
  const branchFilter: FusionBranchFilterDescriptor = {
615
213
  id: FUSION_BRANCH_FILTER_ID,
616
214
  tool_name: toolName,
617
215
  tool_call_id: options.source === 'tool' ? (options.toolCallId ?? null) : null,
618
- active_tool_call_leaf_excluded: leaf.activeToolCallLeafExcluded,
216
+ active_tool_call_leaf_excluded: snapshot.activeToolCallLeafExcluded,
619
217
  };
620
- const projected = projectFusionConversation(llmMessages, options.source, branchFilter);
621
- const request: FusionCanonicalRequestV2 = {
218
+ const projected = projectFusionConversation(snapshot.messages, options.source, branchFilter);
219
+ const request: FusionCanonicalRequestV3 = {
622
220
  source: options.source,
623
221
  authority: requestAuthority(options.source),
624
222
  text: options.request,
625
223
  sha256: sha256Text(options.request),
626
224
  };
627
- const input: FusionCanonicalInputV2 = {
225
+ const input: FusionCanonicalInputV3 = {
628
226
  schema_version: FUSION_INPUT_SCHEMA_VERSION,
629
227
  cwd: ctx.cwd,
630
228
  system_prompt: ctx.getSystemPrompt(),
@@ -635,6 +233,6 @@ export function buildFusionCanonicalInput(
635
233
  input,
636
234
  serialized: canonicalJson(input),
637
235
  ledger: projected.ledger,
638
- transcriptLeafId: leaf.leafId,
236
+ transcriptLeafId: snapshot.leafId,
639
237
  };
640
238
  }