@pi-unipi/background-tasks 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +87 -0
  2. package/extensions/anthropic-attribution.ts +1 -0
  3. package/extensions/delegate-child.ts +1 -0
  4. package/extensions/fusion-child.ts +1 -0
  5. package/package.json +40 -0
  6. package/src/__tests__/anthropic-attribution.test.ts +195 -0
  7. package/src/__tests__/config.test.ts +137 -0
  8. package/src/__tests__/core.test.ts +493 -0
  9. package/src/__tests__/delegate-artifacts.test.ts +528 -0
  10. package/src/__tests__/delegate-budget.test.ts +456 -0
  11. package/src/__tests__/delegate-launch.test.ts +676 -0
  12. package/src/__tests__/delegate-result-package.test.ts +350 -0
  13. package/src/__tests__/delegate-seed.test.ts +392 -0
  14. package/src/__tests__/durable-fs.test.ts +559 -0
  15. package/src/__tests__/extension-api.test.ts +579 -0
  16. package/src/__tests__/fusion-artifacts.test.ts +1039 -0
  17. package/src/__tests__/fusion-budget.test.ts +1356 -0
  18. package/src/__tests__/fusion-claude-cache.test.ts +320 -0
  19. package/src/__tests__/fusion-config.test.ts +335 -0
  20. package/src/__tests__/fusion-context-prompts.test.ts +670 -0
  21. package/src/__tests__/fusion-evaluation.test.ts +315 -0
  22. package/src/__tests__/fusion-extraction-equivalence.test.ts +58 -0
  23. package/src/__tests__/fusion-golden-bytes.test.ts +35 -0
  24. package/src/__tests__/fusion-high-cardinality.test.ts +192 -0
  25. package/src/__tests__/fusion-model-selector.test.ts +205 -0
  26. package/src/__tests__/fusion-orchestrator.test.ts +1194 -0
  27. package/src/__tests__/fusion-rpc.test.ts +369 -0
  28. package/src/__tests__/fusion-sdk.test.ts +1226 -0
  29. package/src/__tests__/fusion-v5-core.test.ts +219 -0
  30. package/src/__tests__/fusion-validate-orchestrator.test.ts +240 -0
  31. package/src/__tests__/fusion-web-fetch.test.ts +485 -0
  32. package/src/__tests__/fusion-workflows.test.ts +59 -0
  33. package/src/__tests__/helpers/delegate-deterministic-seed.ts +109 -0
  34. package/src/__tests__/helpers/delegate-seed-subprocess.ts +10 -0
  35. package/src/__tests__/helpers/fusion-canonical-subprocess.ts +21 -0
  36. package/src/__tests__/helpers/fusion-canonical.ts +140 -0
  37. package/src/__tests__/helpers/fusion-fake-pi.ts +279 -0
  38. package/src/__tests__/helpers/fusion-golden-corpus.ts +500 -0
  39. package/src/__tests__/helpers/fusion-high-cardinality.ts +140 -0
  40. package/src/__tests__/helpers/normalize.ts +22 -0
  41. package/src/__tests__/helpers/pi-hook-contract-evidence.json +18 -0
  42. package/src/__tests__/pi-launch.test.ts +202 -0
  43. package/src/__tests__/registry.test.ts +1580 -0
  44. package/src/__tests__/scripted-provider/delegate-ambient-provider.test.ts +130 -0
  45. package/src/__tests__/scripted-provider/delegate-child-guard.test.ts +631 -0
  46. package/src/__tests__/scripted-provider/delegate-guard-provider.ts +403 -0
  47. package/src/__tests__/scripted-provider/follow-up.test.ts +448 -0
  48. package/src/__tests__/scripted-provider/fusion-output-recovery.test.ts +132 -0
  49. package/src/__tests__/scripted-provider/fusion-reason.test.ts +310 -0
  50. package/src/__tests__/scripted-provider/fusion-runtime-guard.test.ts +163 -0
  51. package/src/__tests__/scripted-provider/hook-contract-provider.ts +179 -0
  52. package/src/__tests__/scripted-provider/hook-probe-a.ts +3 -0
  53. package/src/__tests__/scripted-provider/hook-probe-b.ts +3 -0
  54. package/src/__tests__/scripted-provider/hook-probe-extension.ts +126 -0
  55. package/src/__tests__/scripted-provider/output-recovery-provider.ts +153 -0
  56. package/src/__tests__/scripted-provider/pi-hook-contract-evidence.json +18 -0
  57. package/src/__tests__/scripted-provider/pi-hook-contract.test.ts +477 -0
  58. package/src/__tests__/scripted-provider/runtime-guard-probe.ts +28 -0
  59. package/src/__tests__/scripted-provider/runtime-guard-provider.ts +49 -0
  60. package/src/__tests__/scripted-provider/scripted-provider-extension.ts +408 -0
  61. package/src/__tests__/task-manager.test.ts +479 -0
  62. package/src/__tests__/windows-taskkill.test.ts +161 -0
  63. package/src/anthropic-attribution-path.ts +21 -0
  64. package/src/anthropic-attribution.ts +1983 -0
  65. package/src/attested-pi-run.ts +612 -0
  66. package/src/child-process.ts +55 -0
  67. package/src/common.ts +8 -0
  68. package/src/config.ts +292 -0
  69. package/src/context-parent-snapshot.ts +142 -0
  70. package/src/context-token-budget.ts +903 -0
  71. package/src/context-visible-conversation-v2.ts +551 -0
  72. package/src/delegate/artifacts.ts +487 -0
  73. package/src/delegate/budget.ts +415 -0
  74. package/src/delegate/hook-contract-evidence.json +18 -0
  75. package/src/delegate/hook-contract.ts +154 -0
  76. package/src/delegate/launch.ts +497 -0
  77. package/src/delegate/result-package.ts +459 -0
  78. package/src/delegate/runner.ts +449 -0
  79. package/src/delegate/seed.ts +423 -0
  80. package/src/delegate/types.ts +323 -0
  81. package/src/delegate-child-extension.ts +978 -0
  82. package/src/delegate-extension.ts +806 -0
  83. package/src/durable-fs.ts +386 -0
  84. package/src/extension-api.ts +548 -0
  85. package/src/fixtures/delegate-context-incident.json +17 -0
  86. package/src/fixtures/fusion-golden-bytes.json +310 -0
  87. package/src/fixtures/fusion-validate-golden-bytes.json +282 -0
  88. package/src/fusion/artifacts.ts +967 -0
  89. package/src/fusion/budget.ts +1162 -0
  90. package/src/fusion/child-protocol.ts +305 -0
  91. package/src/fusion/claude-cache.ts +207 -0
  92. package/src/fusion/clean-context.ts +91 -0
  93. package/src/fusion/config.ts +449 -0
  94. package/src/fusion/context.ts +265 -0
  95. package/src/fusion/evaluation.ts +800 -0
  96. package/src/fusion/orchestrator.ts +1288 -0
  97. package/src/fusion/output-contract.ts +34 -0
  98. package/src/fusion/pi-child.ts +2373 -0
  99. package/src/fusion/prompts.ts +345 -0
  100. package/src/fusion/result-package.ts +959 -0
  101. package/src/fusion/source-policy.ts +257 -0
  102. package/src/fusion/types.ts +1139 -0
  103. package/src/fusion/web-fetch.ts +1060 -0
  104. package/src/fusion/workflows.ts +184 -0
  105. package/src/fusion-child-extension.ts +1052 -0
  106. package/src/fusion-extension.ts +1293 -0
  107. package/src/index.ts +295 -0
  108. package/src/pi-launch.ts +225 -0
  109. package/src/registry.ts +2424 -0
  110. package/src/settings-overlay.ts +208 -0
  111. package/src/task-manager.ts +774 -0
  112. package/src/tools.ts +530 -0
  113. package/src/turndown.d.ts +15 -0
  114. package/src/types.ts +963 -0
  115. package/src/ui/fusion-model-selector.ts +322 -0
  116. package/src/windows-taskkill.ts +250 -0
@@ -0,0 +1,449 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { chmod, mkdir, open, readFile, rm } from 'node:fs/promises';
3
+ import { basename, dirname, join } from 'node:path';
4
+ import { getAgentDir } from '@earendil-works/pi-coding-agent';
5
+ import type { Api, Model } from '@earendil-works/pi-ai';
6
+ import { isJsonObject, parseJsonText, type JsonObject } from '../types.js';
7
+ import { replaceFileDurable } from '../durable-fs.js';
8
+ import { CLAUDE_CODE_200K_SUBSCRIPTION_CONTEXT_WINDOW } from '../anthropic-attribution.js';
9
+ import {
10
+ FUSION_MODEL_CONFIG_SCHEMA_VERSION,
11
+ FusionError,
12
+ type FusionModelConfigRevision,
13
+ type FusionModelConfigV1,
14
+ type FusionModelSelection,
15
+ type FusionThinkingLevel,
16
+ type LoadedFusionModelConfig,
17
+ type ResolvedFusionModel,
18
+ type ResolvedFusionModels,
19
+ } from './types.js';
20
+
21
+ export const FUSION_MODEL_CONFIG_FILE = 'fusion-models.json';
22
+ export const CURRENT_MODEL_SELECTION = '$current';
23
+
24
+ export interface FusionModelRegistry {
25
+ getAll(): Model<Api>[];
26
+ getAvailable(): Model<Api>[];
27
+ find?(provider: string, modelId: string): Model<Api> | undefined;
28
+ isUsingOAuth?(model: Model<Api>): boolean;
29
+ }
30
+
31
+ export interface ResolveFusionModelsInput {
32
+ config: FusionModelConfigV1;
33
+ modelRegistry: FusionModelRegistry;
34
+ currentModel: Model<Api> | undefined;
35
+ thinkingLevel: FusionThinkingLevel;
36
+ }
37
+
38
+ export function defaultFusionModelConfig(): FusionModelConfigV1 {
39
+ return {
40
+ schema_version: FUSION_MODEL_CONFIG_SCHEMA_VERSION,
41
+ candidates: [CURRENT_MODEL_SELECTION, CURRENT_MODEL_SELECTION, CURRENT_MODEL_SELECTION],
42
+ evaluator: CURRENT_MODEL_SELECTION,
43
+ merger: CURRENT_MODEL_SELECTION,
44
+ };
45
+ }
46
+
47
+ export function fusionModelConfigPath(agentDir = getAgentDir()): string {
48
+ return join(agentDir, FUSION_MODEL_CONFIG_FILE);
49
+ }
50
+
51
+ function sha256Hex(bytes: Buffer): string {
52
+ return createHash('sha256').update(bytes).digest('hex');
53
+ }
54
+
55
+ async function revisionForPath(path: string): Promise<FusionModelConfigRevision> {
56
+ try {
57
+ const bytes = await readFile(path);
58
+ return { path, exists: true, sha256: sha256Hex(bytes) };
59
+ } catch (error) {
60
+ if (errorHasCode(error, 'ENOENT')) return { path, exists: false, sha256: null };
61
+ throw error;
62
+ }
63
+ }
64
+
65
+ function errorHasCode(error: unknown, code: string): boolean {
66
+ return isJsonObject(error) && error['code'] === code;
67
+ }
68
+
69
+ function keysOf(value: object): string[] {
70
+ return Object.keys(value).sort();
71
+ }
72
+
73
+ function assertClosed(record: JsonObject, expected: readonly string[], label: string): void {
74
+ const expectedSet = new Set(expected);
75
+ for (const key of Object.keys(record)) {
76
+ if (!expectedSet.has(key)) throw configError(`${label} contains unknown key ${key}`);
77
+ }
78
+ for (const key of expected) {
79
+ if (!Object.prototype.hasOwnProperty.call(record, key)) {
80
+ throw configError(`${label} is missing key ${key}`);
81
+ }
82
+ }
83
+ }
84
+
85
+ function configError(message: string): FusionError {
86
+ return new FusionError(message, { code: 'config_invalid', childCreated: false });
87
+ }
88
+
89
+ function requireSelection(value: unknown, label: string): FusionModelSelection {
90
+ if (typeof value !== 'string') throw configError(`${label} must be a string`);
91
+ if (value === CURRENT_MODEL_SELECTION) return value;
92
+ const trimmed = value.trim();
93
+ if (trimmed.length === 0) throw configError(`${label} must not be blank`);
94
+ if (trimmed !== value) throw configError(`${label} must not have surrounding whitespace`);
95
+ if (!trimmed.includes('/')) throw configError(`${label} must be a qualified provider/model key`);
96
+ return trimmed;
97
+ }
98
+
99
+ function requireCandidateSelections(
100
+ value: unknown,
101
+ ): [FusionModelSelection, FusionModelSelection, FusionModelSelection] {
102
+ if (!Array.isArray(value)) throw configError('candidates must be an array');
103
+ if (value.length !== 3) throw configError('candidates must contain exactly three entries');
104
+ const first = requireSelection(value[0], 'candidates[0]');
105
+ const second = requireSelection(value[1], 'candidates[1]');
106
+ const third = requireSelection(value[2], 'candidates[2]');
107
+ return [first, second, third];
108
+ }
109
+
110
+ export function parseFusionModelConfig(value: unknown): FusionModelConfigV1 {
111
+ if (!isJsonObject(value) || Array.isArray(value))
112
+ throw configError('fusion model config must be an object');
113
+ const record: JsonObject = value;
114
+ assertClosed(
115
+ record,
116
+ ['schema_version', 'candidates', 'evaluator', 'merger'],
117
+ 'fusion model config',
118
+ );
119
+ if (record['schema_version'] !== FUSION_MODEL_CONFIG_SCHEMA_VERSION) {
120
+ throw configError('fusion model config schema_version mismatch');
121
+ }
122
+ return {
123
+ schema_version: FUSION_MODEL_CONFIG_SCHEMA_VERSION,
124
+ candidates: requireCandidateSelections(record['candidates']),
125
+ evaluator: requireSelection(record['evaluator'], 'evaluator'),
126
+ merger: requireSelection(record['merger'], 'merger'),
127
+ };
128
+ }
129
+
130
+ export async function loadFusionModelConfig(
131
+ path = fusionModelConfigPath(),
132
+ ): Promise<LoadedFusionModelConfig> {
133
+ const revision = await revisionForPath(path);
134
+ if (!revision.exists) return { config: defaultFusionModelConfig(), revision };
135
+ let parsed: unknown;
136
+ try {
137
+ parsed = parseJsonText(await readFile(path, 'utf8'));
138
+ } catch (error) {
139
+ throw configError(
140
+ `fusion model config is not valid JSON at ${path}: ${error instanceof Error ? error.message : String(error)}`,
141
+ );
142
+ }
143
+ const config = parseFusionModelConfig(parsed);
144
+ return { config, revision };
145
+ }
146
+
147
+ function qualifiedModelKey(model: Pick<Model<Api>, 'provider' | 'id'>): string {
148
+ return `${model.provider}/${model.id}`;
149
+ }
150
+
151
+ function requireContextWindow(model: Model<Api>, label: string): number {
152
+ const value = model.contextWindow;
153
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
154
+ throw new FusionError(`${label} has no positive context window`, {
155
+ code: 'model_unavailable',
156
+ childCreated: false,
157
+ });
158
+ }
159
+ return Math.floor(value);
160
+ }
161
+
162
+ function transportContextWindow(model: Model<Api>, label: string): number {
163
+ const advertised = requireContextWindow(model, label);
164
+ return model.provider === 'anthropic'
165
+ ? Math.min(advertised, CLAUDE_CODE_200K_SUBSCRIPTION_CONTEXT_WINDOW)
166
+ : advertised;
167
+ }
168
+
169
+ function requireMaxOutputTokens(model: Model<Api>, label: string): number {
170
+ const value = model.maxTokens;
171
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
172
+ throw new FusionError(`${label} has no positive maximum output token capacity`, {
173
+ code: 'model_unavailable',
174
+ childCreated: false,
175
+ });
176
+ }
177
+ return Math.floor(value);
178
+ }
179
+
180
+ function modelIndex(models: readonly Model<Api>[]): Map<string, Model<Api>> {
181
+ const out = new Map<string, Model<Api>>();
182
+ for (const model of models) out.set(qualifiedModelKey(model), model);
183
+ return out;
184
+ }
185
+
186
+ const FRONTIER_MODEL_PATTERN =
187
+ /(?:^|[-_/])(?:gpt|codex|claude|opus|sonnet|o[134](?:-[a-z0-9.]+)*)(?:[-_/]|$)/iu;
188
+ const TRUSTED_SUBSCRIPTION_ENDPOINTS = Object.freeze({
189
+ anthropic: 'https://api.anthropic.com',
190
+ 'openai-codex': 'https://chatgpt.com/backend-api',
191
+ } as const);
192
+ const AUTH_HEADER_NAMES = new Set(['authorization', 'proxy-authorization', 'x-api-key', 'api-key']);
193
+
194
+ function isKnownFrontierEndpoint(baseUrl: string | undefined): boolean {
195
+ if (baseUrl === undefined || baseUrl.trim().length === 0) return false;
196
+ try {
197
+ const hostname = new URL(baseUrl).hostname.toLowerCase().replace(/\.+$/u, '');
198
+ return (
199
+ hostname === 'api.openai.com' ||
200
+ hostname === 'api.anthropic.com' ||
201
+ hostname === 'openrouter.ai' ||
202
+ hostname === 'chatgpt.com' ||
203
+ hostname.endsWith('.openai.azure.com') ||
204
+ hostname.endsWith('.cognitiveservices.azure.com') ||
205
+ hostname.endsWith('.ai.azure.com')
206
+ );
207
+ } catch {
208
+ return false;
209
+ }
210
+ }
211
+
212
+ function assertTrustedSubscriptionEndpoint(
213
+ model: Model<Api>,
214
+ slotLabel: string,
215
+ provider: keyof typeof TRUSTED_SUBSCRIPTION_ENDPOINTS,
216
+ ): void {
217
+ const expectedText = TRUSTED_SUBSCRIPTION_ENDPOINTS[provider];
218
+ const effectiveText = model.baseUrl?.trim() || expectedText;
219
+ let effective: URL;
220
+ try {
221
+ effective = new URL(effectiveText);
222
+ } catch {
223
+ throw new FusionError(
224
+ `${slotLabel} route ${model.provider}/${model.id} has a malformed subscription endpoint`,
225
+ { code: 'model_unavailable', childCreated: false },
226
+ );
227
+ }
228
+ const expected = new URL(expectedText);
229
+ const effectivePath = effective.href.slice(effective.origin.length).replace(/\/+$/u, '');
230
+ const expectedPath = expected.href.slice(expected.origin.length).replace(/\/+$/u, '');
231
+ if (
232
+ effective.protocol !== 'https:' ||
233
+ effective.username !== '' ||
234
+ effective.password !== '' ||
235
+ effective.search !== '' ||
236
+ effective.hash !== '' ||
237
+ effective.origin !== expected.origin ||
238
+ effectivePath !== expectedPath
239
+ ) {
240
+ throw new FusionError(
241
+ `${slotLabel} route ${model.provider}/${model.id} does not use the trusted Pi subscription endpoint ${expectedText}`,
242
+ { code: 'model_unavailable', childCreated: false },
243
+ );
244
+ }
245
+ const unsafeHeader = Object.keys(model.headers ?? {}).find((name) =>
246
+ AUTH_HEADER_NAMES.has(name.toLowerCase()),
247
+ );
248
+ if (unsafeHeader !== undefined) {
249
+ throw new FusionError(
250
+ `${slotLabel} route ${model.provider}/${model.id} overrides subscription authentication header ${unsafeHeader}`,
251
+ { code: 'model_unavailable', childCreated: false },
252
+ );
253
+ }
254
+ }
255
+
256
+ function assertSubscriptionRoute(
257
+ model: Model<Api>,
258
+ slotLabel: string,
259
+ registry: FusionModelRegistry,
260
+ ): void {
261
+ const provider = model.provider.toLowerCase();
262
+ const frontier =
263
+ provider === 'openai' ||
264
+ provider === 'openrouter' ||
265
+ provider === 'anthropic' ||
266
+ provider === 'openai-codex' ||
267
+ provider.includes('azure') ||
268
+ FRONTIER_MODEL_PATTERN.test(`${provider}/${model.id}`) ||
269
+ isKnownFrontierEndpoint(model.baseUrl);
270
+ if (!frontier) return;
271
+ if (provider !== 'anthropic' && provider !== 'openai-codex') {
272
+ throw new FusionError(
273
+ `${slotLabel} route ${model.provider}/${model.id} is a frontier-model API channel; Fusion requires the Pi Anthropic or Codex subscription route`,
274
+ { code: 'model_unavailable', childCreated: false },
275
+ );
276
+ }
277
+ assertTrustedSubscriptionEndpoint(model, slotLabel, provider);
278
+ if (registry.isUsingOAuth === undefined) {
279
+ throw new FusionError(
280
+ `${slotLabel} route ${model.provider}/${model.id} cannot be admitted because ModelRegistry OAuth observation is unavailable`,
281
+ { code: 'model_unavailable', childCreated: false },
282
+ );
283
+ }
284
+ if (!registry.isUsingOAuth(model)) {
285
+ throw new FusionError(
286
+ `${slotLabel} route ${model.provider}/${model.id} is not using subscription OAuth; metered API credentials are forbidden for Fusion`,
287
+ { code: 'model_unavailable', childCreated: false },
288
+ );
289
+ }
290
+ }
291
+
292
+ function resolveSelection(
293
+ selection: FusionModelSelection,
294
+ slotLabel: string,
295
+ availableByKey: Map<string, Model<Api>>,
296
+ currentModel: Model<Api> | undefined,
297
+ thinkingLevel: FusionThinkingLevel,
298
+ registry: FusionModelRegistry,
299
+ ): ResolvedFusionModel {
300
+ if (selection === CURRENT_MODEL_SELECTION) {
301
+ if (currentModel === undefined) {
302
+ throw new FusionError(`${slotLabel} uses $current but Pi has no current model`, {
303
+ code: 'model_unavailable',
304
+ childCreated: false,
305
+ });
306
+ }
307
+ const qualifiedId = qualifiedModelKey(currentModel);
308
+ const available = availableByKey.get(qualifiedId);
309
+ if (available === undefined) {
310
+ throw new FusionError(
311
+ `${slotLabel} current model is not available to child Pi: ${qualifiedId}`,
312
+ {
313
+ code: 'model_unavailable',
314
+ childCreated: false,
315
+ },
316
+ );
317
+ }
318
+ assertSubscriptionRoute(available, slotLabel, registry);
319
+ return {
320
+ selection,
321
+ source: 'current',
322
+ provider: available.provider,
323
+ model: available.id,
324
+ qualifiedId,
325
+ thinkingLevel,
326
+ contextWindow: transportContextWindow(available, slotLabel),
327
+ maxOutputTokens: requireMaxOutputTokens(available, slotLabel),
328
+ };
329
+ }
330
+ const model = availableByKey.get(selection);
331
+ if (model === undefined) {
332
+ throw new FusionError(`${slotLabel} configured model is unavailable: ${selection}`, {
333
+ code: 'model_unavailable',
334
+ childCreated: false,
335
+ });
336
+ }
337
+ assertSubscriptionRoute(model, slotLabel, registry);
338
+ return {
339
+ selection,
340
+ source: 'configured',
341
+ provider: model.provider,
342
+ model: model.id,
343
+ qualifiedId: selection,
344
+ thinkingLevel,
345
+ contextWindow: transportContextWindow(model, slotLabel),
346
+ maxOutputTokens: requireMaxOutputTokens(model, slotLabel),
347
+ };
348
+ }
349
+
350
+ export function resolveFusionModels(input: ResolveFusionModelsInput): ResolvedFusionModels {
351
+ const availableByKey = modelIndex(input.modelRegistry.getAvailable());
352
+ const [first, second, third] = input.config.candidates;
353
+ const resolve = (selection: FusionModelSelection, slot: string): ResolvedFusionModel =>
354
+ resolveSelection(
355
+ selection,
356
+ slot,
357
+ availableByKey,
358
+ input.currentModel,
359
+ input.thinkingLevel,
360
+ input.modelRegistry,
361
+ );
362
+ return {
363
+ candidates: [
364
+ resolve(first, 'candidate 1'),
365
+ resolve(second, 'candidate 2'),
366
+ resolve(third, 'candidate 3'),
367
+ ],
368
+ evaluator: resolve(input.config.evaluator, 'evaluator'),
369
+ merger: resolve(input.config.merger, 'merger'),
370
+ };
371
+ }
372
+
373
+ async function delay(ms: number): Promise<void> {
374
+ await new Promise((resolve) => setTimeout(resolve, ms));
375
+ }
376
+
377
+ async function withConfigLock<T>(path: string, fn: () => Promise<T>): Promise<T> {
378
+ const dir = dirname(path);
379
+ const lockPath = join(dir, `.${basename(path)}.lock`);
380
+ const started = Date.now();
381
+ let handle: Awaited<ReturnType<typeof open>> | undefined;
382
+ while (handle === undefined) {
383
+ try {
384
+ handle = await open(lockPath, 'wx', 0o600);
385
+ } catch (error) {
386
+ if (!errorHasCode(error, 'EEXIST')) throw error;
387
+ if (Date.now() - started > 10_000) {
388
+ throw new FusionError(`timed out waiting for fusion model config lock: ${path}`, {
389
+ code: 'config_conflict',
390
+ childCreated: false,
391
+ });
392
+ }
393
+ await delay(25);
394
+ }
395
+ }
396
+ try {
397
+ await handle.writeFile(`${String(process.pid)}\n`);
398
+ await handle.sync();
399
+ return await fn();
400
+ } finally {
401
+ await handle.close();
402
+ await rm(lockPath, { force: true });
403
+ }
404
+ }
405
+
406
+ function prettyConfig(config: FusionModelConfigV1): string {
407
+ const sorted = {
408
+ schema_version: config.schema_version,
409
+ candidates: [...config.candidates],
410
+ evaluator: config.evaluator,
411
+ merger: config.merger,
412
+ };
413
+ return `${JSON.stringify(sorted, null, 2)}\n`;
414
+ }
415
+
416
+ function revisionsMatch(
417
+ expected: FusionModelConfigRevision,
418
+ current: FusionModelConfigRevision,
419
+ ): boolean {
420
+ if (expected.path !== current.path) return false;
421
+ if (expected.exists !== current.exists) return false;
422
+ return expected.sha256 === current.sha256;
423
+ }
424
+
425
+ export async function saveFusionModelConfig(
426
+ path: string,
427
+ config: FusionModelConfigV1,
428
+ expectedRevision: FusionModelConfigRevision,
429
+ ): Promise<FusionModelConfigRevision> {
430
+ parseFusionModelConfig(config);
431
+ const dir = dirname(path);
432
+ await mkdir(dir, { recursive: true, mode: 0o700 });
433
+ await chmod(dir, 0o700);
434
+ return withConfigLock(path, async () => {
435
+ const current = await revisionForPath(path);
436
+ if (!revisionsMatch(expectedRevision, current)) {
437
+ throw new FusionError(`fusion model config changed on disk: ${path}`, {
438
+ code: 'config_conflict',
439
+ childCreated: false,
440
+ });
441
+ }
442
+ await replaceFileDurable(path, prettyConfig(config));
443
+ return revisionForPath(path);
444
+ });
445
+ }
446
+
447
+ export function describeFusionModelConfig(config: FusionModelConfigV1): string {
448
+ return keysOf(config).join(', ');
449
+ }
@@ -0,0 +1,265 @@
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';
9
+ import {
10
+ snapshotParentConversation,
11
+ type ParentContextSource,
12
+ type ParentSnapshotOptions,
13
+ type ReadonlyParentSessionManager,
14
+ } from '../context-parent-snapshot.js';
15
+ import type { Message } from '@earendil-works/pi-ai';
16
+ import { FUSION_REASON_TOOL_NAME } from './workflows.js';
17
+ import {
18
+ FUSION_BRANCH_FILTER_ID,
19
+ FUSION_COMMAND_CONTEXT_POLICY_ID,
20
+ FUSION_CONTEXT_LEDGER_SCHEMA_VERSION,
21
+ FUSION_CONTEXT_TRANSFORM_ID,
22
+ FUSION_INPUT_SCHEMA_VERSION,
23
+ FUSION_TOOL_CONTEXT_POLICY_ID,
24
+ FusionError,
25
+ type FusionBranchFilterDescriptor,
26
+ type FusionCanonicalRequestV3,
27
+ type FusionSessionProjectionCanonicalInputV5,
28
+ type FusionWorkflowId,
29
+ type FusionContextOmissionLedgerV2,
30
+ type FusionContextPolicyDescriptor,
31
+ type FusionConversationProjectionV3,
32
+ type FusionProjectionEntry,
33
+ type FusionProjectionOmissionCounts,
34
+ type FusionRequestAuthority,
35
+ type FusionSource,
36
+ } from './types.js';
37
+
38
+ /**
39
+ * Re-exported from the workflow registry, which owns every workflow's tool name.
40
+ * Kept here so existing importers of this module keep working unchanged.
41
+ */
42
+ export {
43
+ FUSION_BRAINSTORM_TOOL_NAME,
44
+ FUSION_REASON_TOOL_NAME,
45
+ FUSION_INVESTIGATE_TOOL_NAME,
46
+ FUSION_RESEARCH_TOOL_NAME,
47
+ FUSION_VALIDATE_TOOL_NAME,
48
+ } from './workflows.js';
49
+
50
+ /** Retained for source compatibility; Fusion's session access is the shared adapter. */
51
+ export type FusionReadonlySessionManager = ReadonlyParentSessionManager;
52
+ export type FusionContextSource = ParentContextSource;
53
+
54
+ export interface BuildFusionCanonicalInputOptions {
55
+ source: FusionSource;
56
+ request: string;
57
+ toolCallId?: string;
58
+ toolName?: string;
59
+ workflow?: FusionWorkflowId;
60
+ }
61
+
62
+ export interface BuiltFusionCanonicalInput {
63
+ input: FusionSessionProjectionCanonicalInputV5;
64
+ serialized: string;
65
+ ledger: FusionContextOmissionLedgerV2;
66
+ transcriptLeafId: string | null;
67
+ }
68
+
69
+ export function normalizeFusionCommandRequest(args: string): string {
70
+ return args.trim();
71
+ }
72
+
73
+ function sha256Text(value: string): string {
74
+ return createHash('sha256').update(Buffer.from(value, 'utf8')).digest('hex');
75
+ }
76
+
77
+ function contextPolicyId(source: FusionSource): string {
78
+ return source === 'tool' ? FUSION_TOOL_CONTEXT_POLICY_ID : FUSION_COMMAND_CONTEXT_POLICY_ID;
79
+ }
80
+
81
+ function requestAuthority(source: FusionSource): FusionRequestAuthority {
82
+ return source === 'tool' ? 'explicit_text' : 'directive_over_projected_conversation';
83
+ }
84
+
85
+ function policyDescriptor(source: FusionSource): FusionContextPolicyDescriptor {
86
+ return {
87
+ id: contextPolicyId(source),
88
+ transform: FUSION_CONTEXT_TRANSFORM_ID,
89
+ version: 2,
90
+ receipt_format: 'omitted_activity.v2',
91
+ user_text: 'verbatim',
92
+ assistant_text: 'verbatim',
93
+ assistant_thinking: 'ledger_only',
94
+ tool_call_arguments: 'ledger_only',
95
+ tool_results: 'ledger_only',
96
+ tool_payload_preview_bytes: 0,
97
+ images: 'marker_or_ledger_only',
98
+ unknown_block_behavior: 'error',
99
+ };
100
+ }
101
+
102
+ function compactOmissionCounts(counts: OmittedRunCounts): FusionProjectionOmissionCounts {
103
+ return [
104
+ counts.assistant_thinking ?? 0,
105
+ counts.tool_calls ?? 0,
106
+ counts.tool_result_texts ?? 0,
107
+ ];
108
+ }
109
+
110
+ function expandOmissionCounts(counts: FusionProjectionOmissionCounts): OmittedRunCounts {
111
+ const out: OmittedRunCounts = {};
112
+ const [assistantThinking, toolCalls, toolResults] = counts;
113
+ if (assistantThinking > 0) out.assistant_thinking = assistantThinking;
114
+ if (toolCalls > 0) out.tool_calls = toolCalls;
115
+ if (toolResults > 0) out.tool_result_texts = toolResults;
116
+ return out;
117
+ }
118
+
119
+ export function compactFusionProjectionEntry(entry: ProjectionEntry): FusionProjectionEntry {
120
+ if (entry.kind === 'text') {
121
+ return [
122
+ 't',
123
+ entry.role === 'user' ? 'u' : 'a',
124
+ entry.source_ordinal,
125
+ entry.block_ordinal,
126
+ entry.text,
127
+ ];
128
+ }
129
+ return ['o', [entry.at[0], entry.at[1]], entry.bytes, compactOmissionCounts(entry.counts)];
130
+ }
131
+
132
+ export function expandFusionProjectionEntry(entry: FusionProjectionEntry): ProjectionEntry {
133
+ if (entry[0] === 't') {
134
+ return {
135
+ kind: 'text',
136
+ source_ordinal: entry[2],
137
+ block_ordinal: entry[3],
138
+ role: entry[1] === 'u' ? 'user' : 'assistant',
139
+ text: entry[4],
140
+ };
141
+ }
142
+ return {
143
+ kind: 'omitted_activity',
144
+ at: [entry[1][0], entry[1][1]],
145
+ bytes: entry[2],
146
+ counts: expandOmissionCounts(entry[3]),
147
+ };
148
+ }
149
+
150
+ function compactFusionProjectionEntries(
151
+ entries: readonly ProjectionEntry[],
152
+ ): readonly FusionProjectionEntry[] {
153
+ return entries.map(compactFusionProjectionEntry);
154
+ }
155
+
156
+ function compactOmissionReceiptBytes(entries: readonly FusionProjectionEntry[]): number {
157
+ let total = 0;
158
+ for (const entry of entries) {
159
+ if (entry[0] === 'o') total += Buffer.byteLength(canonicalJson(entry), 'utf8');
160
+ }
161
+ return total;
162
+ }
163
+
164
+ /**
165
+ * Seal the shared transform output into Fusion's versioned envelopes.
166
+ *
167
+ * Fusion v4 compacts only the child-facing projection entries. Ledger rows are
168
+ * carried through unchanged, so the ledger root commits to exactly the same
169
+ * omitted payload records before and after tuple encoding. Golden tests pin the
170
+ * new canonical-input bytes and the unchanged ledger bytes.
171
+ */
172
+ function sealFusionProjection(
173
+ projected: ProjectedConversationV2,
174
+ source: FusionSource,
175
+ branchFilter: FusionBranchFilterDescriptor,
176
+ ): { projection: FusionConversationProjectionV3; ledger: FusionContextOmissionLedgerV2 } {
177
+ const entries = compactFusionProjectionEntries(projected.entries);
178
+ return {
179
+ projection: {
180
+ policy: policyDescriptor(source),
181
+ branch_filter: branchFilter,
182
+ entries,
183
+ accounting: {
184
+ ...projected.accounting,
185
+ omission_receipt_utf8_bytes: compactOmissionReceiptBytes(entries),
186
+ },
187
+ },
188
+ ledger: {
189
+ schema_version: FUSION_CONTEXT_LEDGER_SCHEMA_VERSION,
190
+ policy_id: contextPolicyId(source),
191
+ transform: FUSION_CONTEXT_TRANSFORM_ID,
192
+ entries: projected.ledger.entries,
193
+ projection_map: projected.ledger.projection_map,
194
+ root_sha256: projected.ledger.root_sha256,
195
+ },
196
+ };
197
+ }
198
+
199
+ /** Preserved public entry point; delegates to the shared frozen transform. */
200
+ export function projectFusionConversation(
201
+ messages: readonly Message[],
202
+ source: FusionSource,
203
+ branchFilter: FusionBranchFilterDescriptor,
204
+ ): { projection: FusionConversationProjectionV3; ledger: FusionContextOmissionLedgerV2 } {
205
+ return sealFusionProjection(projectVisibleConversationV2(messages), source, branchFilter);
206
+ }
207
+
208
+ export function buildFusionCanonicalInput(
209
+ ctx: FusionContextSource,
210
+ options: BuildFusionCanonicalInputOptions,
211
+ ): BuiltFusionCanonicalInput {
212
+ if (options.request.trim().length === 0) {
213
+ throw new FusionError('fusion request must not be blank', {
214
+ code: 'context_capture_failed',
215
+ childCreated: false,
216
+ });
217
+ }
218
+ const workflow = options.workflow ?? 'reason';
219
+ if (workflow !== 'reason') {
220
+ throw new FusionError('parent session projection is available only to the reason workflow', {
221
+ code: 'context_capture_failed',
222
+ childCreated: false,
223
+ });
224
+ }
225
+ const toolName = options.toolName ?? FUSION_REASON_TOOL_NAME;
226
+ const snapshotOptions: ParentSnapshotOptions = {
227
+ toolName,
228
+ excludeActiveToolCallLeaf: options.source === 'tool',
229
+ };
230
+ if (options.toolCallId !== undefined) snapshotOptions.toolCallId = options.toolCallId;
231
+ const snapshot = snapshotParentConversation(ctx, snapshotOptions);
232
+ const branchFilter: FusionBranchFilterDescriptor = {
233
+ id: FUSION_BRANCH_FILTER_ID,
234
+ tool_name: toolName,
235
+ tool_call_id: options.source === 'tool' ? (options.toolCallId ?? null) : null,
236
+ active_tool_call_leaf_excluded: snapshot.activeToolCallLeafExcluded,
237
+ };
238
+ const projected = projectFusionConversation(snapshot.messages, options.source, branchFilter);
239
+ const request: FusionCanonicalRequestV3 = {
240
+ source: options.source,
241
+ authority: requestAuthority(options.source),
242
+ text: options.request,
243
+ sha256: sha256Text(options.request),
244
+ };
245
+ const input: FusionSessionProjectionCanonicalInputV5 = {
246
+ schema_version: FUSION_INPUT_SCHEMA_VERSION,
247
+ workflow: 'reason',
248
+ cwd: ctx.cwd,
249
+ request,
250
+ system_prompt: ctx.getSystemPrompt(),
251
+ conversation_projection: projected.projection,
252
+ context: {
253
+ kind: 'session_projection',
254
+ policy_id: 'fusion-session-projection-v1',
255
+ system_prompt: ctx.getSystemPrompt(),
256
+ conversation_projection: projected.projection,
257
+ },
258
+ };
259
+ return {
260
+ input,
261
+ serialized: canonicalJson(input),
262
+ ledger: projected.ledger,
263
+ transcriptLeafId: snapshot.leafId,
264
+ };
265
+ }