@pi-unipi/background-tasks 2.16.0 → 2.17.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.
Files changed (46) hide show
  1. package/README.md +21 -27
  2. package/package.json +3 -4
  3. package/src/cards.ts +76 -0
  4. package/src/child-process.ts +1 -1
  5. package/src/config.ts +0 -42
  6. package/src/context-visible-conversation-v2.ts +1 -1
  7. package/src/delegate/artifacts.ts +1 -1
  8. package/src/delegate/launch.ts +17 -30
  9. package/src/delegate/result-package.ts +1 -1
  10. package/src/delegate/runner.ts +1 -20
  11. package/src/delegate/seed.ts +1 -1
  12. package/src/delegate-extension.ts +16 -168
  13. package/src/index.ts +53 -25
  14. package/src/json-utils.ts +56 -0
  15. package/src/package-assets.ts +51 -0
  16. package/src/registry.ts +8 -459
  17. package/src/task-manager.ts +13 -2
  18. package/src/tools.ts +4 -189
  19. package/src/types.ts +17 -70
  20. package/extensions/anthropic-attribution.ts +0 -1
  21. package/extensions/fusion-child.ts +0 -1
  22. package/src/anthropic-attribution-path.ts +0 -21
  23. package/src/anthropic-attribution.ts +0 -1983
  24. package/src/attested-pi-run.ts +0 -612
  25. package/src/fixtures/fusion-golden-bytes.json +0 -310
  26. package/src/fixtures/fusion-validate-golden-bytes.json +0 -282
  27. package/src/fusion/artifacts.ts +0 -967
  28. package/src/fusion/budget.ts +0 -1162
  29. package/src/fusion/child-protocol.ts +0 -305
  30. package/src/fusion/claude-cache.ts +0 -207
  31. package/src/fusion/clean-context.ts +0 -91
  32. package/src/fusion/config.ts +0 -449
  33. package/src/fusion/context.ts +0 -265
  34. package/src/fusion/evaluation.ts +0 -800
  35. package/src/fusion/orchestrator.ts +0 -1288
  36. package/src/fusion/output-contract.ts +0 -34
  37. package/src/fusion/pi-child.ts +0 -2373
  38. package/src/fusion/prompts.ts +0 -345
  39. package/src/fusion/result-package.ts +0 -959
  40. package/src/fusion/source-policy.ts +0 -257
  41. package/src/fusion/types.ts +0 -1139
  42. package/src/fusion/web-fetch.ts +0 -1060
  43. package/src/fusion/workflows.ts +0 -184
  44. package/src/fusion-child-extension.ts +0 -1052
  45. package/src/fusion-extension.ts +0 -1293
  46. package/src/ui/fusion-model-selector.ts +0 -322
@@ -1,449 +0,0 @@
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
- }