pi-background-tasks 0.9.0 → 1.0.4

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 (72) hide show
  1. package/BACKGROUND-TASKS-INSTRUCTIONS.md +63 -0
  2. package/PUBLISHING.md +43 -29
  3. package/README.md +233 -441
  4. package/TESTING.md +16 -10
  5. package/TEST_PLAN.md +43 -17
  6. package/docs/INDEX.md +157 -0
  7. package/docs/api/eventbus-v1.md +166 -0
  8. package/docs/assets/architecture.svg +78 -0
  9. package/docs/assets/footer-dock.svg +47 -0
  10. package/docs/assets/logo.svg +49 -0
  11. package/docs/attestations.json +189 -0
  12. package/docs/choose-a-workflow.md +98 -0
  13. package/docs/commands/bg-clear.md +70 -0
  14. package/docs/commands/bg-update.md +82 -0
  15. package/docs/commands/bg.md +90 -0
  16. package/docs/commands/fusion-models.md +70 -0
  17. package/docs/commands/fusion.md +69 -0
  18. package/docs/commands/jobs.md +74 -0
  19. package/docs/commands/kill.md +82 -0
  20. package/docs/commands/logs.md +90 -0
  21. package/docs/commands/task-manager.md +109 -0
  22. package/docs/concepts/completion-delivery.md +66 -0
  23. package/docs/concepts/context-projection-and-budgeting.md +79 -0
  24. package/docs/getting-started.md +122 -0
  25. package/docs/manifest.json +1825 -0
  26. package/docs/operations/configuration.md +110 -0
  27. package/docs/operations/releasing.md +67 -0
  28. package/docs/operations/testing.md +101 -0
  29. package/docs/operations/troubleshooting.md +38 -0
  30. package/docs/read-before-edit.md +94 -0
  31. package/docs/reference/runtime-contracts.md +213 -0
  32. package/docs/reference/shortcuts-and-dock.md +70 -0
  33. package/docs/subsystems/attested-pi-runs.md +141 -0
  34. package/docs/subsystems/background-task-runtime.md +85 -0
  35. package/docs/subsystems/child-launch-durability-and-safety.md +57 -0
  36. package/docs/subsystems/delegation.md +190 -0
  37. package/docs/subsystems/docs-freshness-gate.md +26 -0
  38. package/docs/subsystems/fusion.md +123 -0
  39. package/docs/subsystems/host-ui-and-telemetry.md +83 -0
  40. package/docs/tools/bg_delegate.md +193 -0
  41. package/docs/tools/bg_kill.md +114 -0
  42. package/docs/tools/bg_logs.md +133 -0
  43. package/docs/tools/bg_result.md +120 -0
  44. package/docs/tools/bg_run.md +168 -0
  45. package/docs/tools/bg_run_pi_attested.md +170 -0
  46. package/docs/tools/bg_status.md +111 -0
  47. package/docs/tools/fusion_investigate.md +116 -0
  48. package/docs/tools/fusion_reason.md +75 -0
  49. package/docs/tools/fusion_research.md +162 -0
  50. package/docs/tools/fusion_validate.md +206 -0
  51. package/logo.png +0 -0
  52. package/package.json +27 -9
  53. package/src/core/delegate/budget.ts +1 -1
  54. package/src/core/delegate/launch.ts +5 -0
  55. package/src/core/fusion/artifacts.ts +34 -4
  56. package/src/core/fusion/budget.ts +112 -20
  57. package/src/core/fusion/child-protocol.ts +82 -0
  58. package/src/core/fusion/clean-context.ts +91 -0
  59. package/src/core/fusion/config.ts +124 -35
  60. package/src/core/fusion/context.ts +29 -7
  61. package/src/core/fusion/evaluation.ts +392 -15
  62. package/src/core/fusion/orchestrator.ts +217 -23
  63. package/src/core/fusion/pi-child.ts +227 -24
  64. package/src/core/fusion/prompts.ts +39 -26
  65. package/src/core/fusion/source-policy.ts +257 -0
  66. package/src/core/fusion/types.ts +156 -11
  67. package/src/core/fusion/web-fetch.ts +104 -15
  68. package/src/core/fusion/workflows.ts +119 -65
  69. package/src/extension.ts +3 -3
  70. package/src/fusion-child-extension.ts +375 -141
  71. package/src/fusion-extension.ts +585 -240
  72. package/src/testing/normalize.ts +0 -22
@@ -27,13 +27,13 @@ import {
27
27
  type FusionWorkflowId,
28
28
  type ResolvedFusionModels,
29
29
  } from './types.js';
30
- import { FUSION_BRAINSTORM_WORKFLOW, type FusionWorkflowProfile } from './workflows.js';
30
+ import { fusionWorkflowProfile, type FusionWorkflowProfile } from './workflows.js';
31
31
 
32
32
  /**
33
33
  * Run ids are prefixed by workflow so an artifact directory is self-describing.
34
34
  * The prefix set is closed: an unknown prefix must fail rather than be accepted.
35
35
  */
36
- const RUN_ID_PATTERN = /^[fv][0-9a-f]{32}$/;
36
+ const RUN_ID_PATTERN = /^(reason|investigate|research|validate)-[0-9a-f]{32}$/;
37
37
 
38
38
  interface MutableFusionArtifactManifest {
39
39
  schema_version: typeof FUSION_MANIFEST_SCHEMA_VERSION;
@@ -56,6 +56,8 @@ interface MutableFusionArtifactManifest {
56
56
  evaluation: FusionCapability;
57
57
  merge: FusionCapability;
58
58
  };
59
+ context: { kind: import('./types.js').FusionContextKind; policy_id: string; ledger_artifact?: string; source_policy_artifact?: string };
60
+ tool_policy: { candidate_tools: readonly string[]; evaluation_tools: readonly []; merge_tools: readonly [] };
59
61
  usage: FusionUsage;
60
62
  attempts: FusionAttemptArtifactRecord[];
61
63
  artifacts: Record<string, FusionArtifactRef>;
@@ -177,6 +179,8 @@ function publicManifest(manifest: MutableFusionArtifactManifest): FusionArtifact
177
179
  config: manifest.config,
178
180
  models: manifest.models,
179
181
  capabilities: manifest.capabilities,
182
+ context: { ...manifest.context },
183
+ tool_policy: { candidate_tools: [...manifest.tool_policy.candidate_tools], evaluation_tools: [], merge_tools: [] },
180
184
  usage: cloneFusionUsage(manifest.usage),
181
185
  attempts: [...manifest.attempts],
182
186
  artifacts: { ...manifest.artifacts },
@@ -204,6 +208,14 @@ function calibrationViolationName(prefix: string): string {
204
208
  return `${prefix}.calibration-violation.json`;
205
209
  }
206
210
 
211
+ function artifactRefSha256Hex(value: string): string {
212
+ const hex = value.startsWith('sha256:') ? value.slice('sha256:'.length) : value;
213
+ if (!/^[0-9a-f]{64}$/u.test(hex)) {
214
+ throw errorForArtifact(`fusion artifact sha256 is not a lowercase hex digest: ${value}`);
215
+ }
216
+ return hex;
217
+ }
218
+
207
219
  export class FusionArtifactStore {
208
220
  private readonly runDirAbs: string;
209
221
  private readonly runDirDisplay: string;
@@ -224,7 +236,7 @@ export class FusionArtifactStore {
224
236
  }
225
237
 
226
238
  static async create(options: CreateFusionArtifactStoreOptions): Promise<FusionArtifactStore> {
227
- const profile = options.profile ?? FUSION_BRAINSTORM_WORKFLOW;
239
+ const profile = fusionWorkflowProfile(options.profile?.id ?? 'reason');
228
240
  const runId = options.runId ?? makeRunId(profile);
229
241
  if (!RUN_ID_PATTERN.test(runId)) throw errorForArtifact(`invalid fusion run id: ${runId}`);
230
242
  if (!runId.startsWith(profile.runIdPrefix)) {
@@ -257,6 +269,8 @@ export class FusionArtifactStore {
257
269
  evaluation: 'reason',
258
270
  merge: 'reason',
259
271
  },
272
+ context: { kind: profile.contextKind, policy_id: profile.contextKind === 'session_projection' ? 'fusion-session-projection-v1' : 'fusion-clean-task-v1' },
273
+ tool_policy: { candidate_tools: profile.candidateTools, evaluation_tools: [], merge_tools: [] },
260
274
  usage: cloneFusionUsage(EMPTY_FUSION_USAGE),
261
275
  attempts: [],
262
276
  artifacts: {},
@@ -331,7 +345,23 @@ export class FusionArtifactStore {
331
345
  * while the full omission accounting stays locally auditable.
332
346
  */
333
347
  async writeContextLedger(ledger: FusionContextOmissionLedgerV2): Promise<void> {
334
- await this.writeArtifact('context-omission-ledger.json', canonicalJson(ledger));
348
+ const ref = await this.writeArtifact('context-omission-ledger.json', canonicalJson(ledger));
349
+ await this.updateManifest((manifest) => {
350
+ manifest.context.ledger_artifact = ref.path;
351
+ });
352
+ }
353
+
354
+ async writeSourcePolicy(serialized: string): Promise<void> {
355
+ const ref = await this.writeArtifact('source-policy.private.json', serialized);
356
+ await this.updateManifest((manifest) => {
357
+ manifest.context.source_policy_artifact = ref.path;
358
+ });
359
+ }
360
+
361
+ sourcePolicyLaunchReference(): { path: string; sha256: string } {
362
+ const ref = this.manifest.artifacts['source-policy.private.json'];
363
+ if (ref === undefined) throw errorForArtifact('research source policy has not been written');
364
+ return { path: this.artifactPath(ref.path), sha256: artifactRefSha256Hex(ref.sha256) };
335
365
  }
336
366
 
337
367
  /** Route capacities and the pre-candidate whole-workflow feasibility decision. */
@@ -22,12 +22,11 @@ import {
22
22
  buildMergePrompt,
23
23
  type AnonymousFusionCandidate,
24
24
  } from './prompts.js';
25
- import { FUSION_BRAINSTORM_WORKFLOW, type FusionWorkflowProfile } from './workflows.js';
25
+ import { FUSION_REASON_WORKFLOW, type FusionWorkflowProfile } from './workflows.js';
26
26
  import {
27
27
  FUSION_BUDGET_PLAN_SCHEMA_VERSION,
28
28
  FUSION_CALIBRATION_VIOLATION_SCHEMA_VERSION,
29
29
  FUSION_EVALUATION_SCHEMA_VERSION,
30
- FUSION_DEFAULT_CAPABILITY,
31
30
  FusionError,
32
31
  type FusionBudgetBlocker,
33
32
  type FusionBudgetCheckKind,
@@ -102,18 +101,61 @@ export const FUSION_BUDGET_POLICY: FusionBudgetPolicyDescriptor = {
102
101
  utilization_warning_threshold_basis_points: FUSION_UTILIZATION_WARNING_THRESHOLD_BASIS_POINTS,
103
102
  };
104
103
 
105
- const EMPTY_REMEDIATION: readonly string[] = Object.freeze([
106
- 'Start a fresh Pi conversation, or run Fusion earlier in the session.',
107
- "Raise the route's context window with a larger-context model via /fusion-models.",
108
- 'Restate only the required prior findings as visible conversation text.',
104
+ const REASON_EMPTY_REMEDIATION: readonly string[] = Object.freeze([
105
+ 'Start a fresh Pi conversation, or run fusion_reason earlier in the session.',
106
+ "Raise the route's context window with a larger-context subscription model via /fusion-models.",
107
+ 'Restate only the required prior findings in the fusion_reason prompt.',
109
108
  ]);
110
109
 
111
- const REQUEST_REMEDIATION: readonly string[] = Object.freeze([
112
- 'Provide a shorter, self-contained fusion_brainstorm prompt.',
113
- 'Start a fresh Pi conversation, or run Fusion earlier in the session.',
114
- "Raise the route's context window with a larger-context model via /fusion-models.",
110
+ const REASON_REQUEST_REMEDIATION: readonly string[] = Object.freeze([
111
+ 'Provide a shorter fusion_reason prompt.',
112
+ 'Start a fresh Pi conversation, or run fusion_reason earlier in the session.',
113
+ "Raise the route's context window with a larger-context subscription model via /fusion-models.",
115
114
  ]);
116
115
 
116
+ const INVESTIGATE_EMPTY_REMEDIATION: readonly string[] = Object.freeze([
117
+ 'Split the repository investigation into smaller independently complete path or subsystem scopes.',
118
+ "Raise the route's context window with a larger-context subscription model via /fusion-models.",
119
+ ]);
120
+ const INVESTIGATE_REQUEST_REMEDIATION: readonly string[] = Object.freeze([
121
+ 'Narrow the fusion_investigate objective, repository scope, or required evidence.',
122
+ "Raise the route's context window with a larger-context subscription model via /fusion-models.",
123
+ ]);
124
+ const RESEARCH_EMPTY_REMEDIATION: readonly string[] = Object.freeze([
125
+ 'Split the research into smaller independently complete source sets.',
126
+ "Raise the route's context window with a larger-context subscription model via /fusion-models.",
127
+ ]);
128
+ const RESEARCH_REQUEST_REMEDIATION: readonly string[] = Object.freeze([
129
+ 'Narrow the fusion_research question or split large declared-source sets across independent runs.',
130
+ "Raise the route's context window with a larger-context subscription model via /fusion-models.",
131
+ ]);
132
+ const VALIDATE_EMPTY_REMEDIATION: readonly string[] = Object.freeze([
133
+ 'Split validation into smaller independently complete change or acceptance-criterion scopes.',
134
+ "Raise the route's context window with a larger-context subscription model via /fusion-models.",
135
+ ]);
136
+ const VALIDATE_REQUEST_REMEDIATION: readonly string[] = Object.freeze([
137
+ 'Narrow the fusion_validate scope, acceptance criteria, or supplied verification evidence.',
138
+ "Raise the route's context window with a larger-context subscription model via /fusion-models.",
139
+ ]);
140
+
141
+ function cleanRemediation(
142
+ profile: FusionWorkflowProfile,
143
+ requestDeterminesFeasibility: boolean,
144
+ ): readonly string[] {
145
+ if (profile.id === 'investigate') {
146
+ return requestDeterminesFeasibility
147
+ ? INVESTIGATE_REQUEST_REMEDIATION
148
+ : INVESTIGATE_EMPTY_REMEDIATION;
149
+ }
150
+ if (profile.id === 'research') {
151
+ return requestDeterminesFeasibility ? RESEARCH_REQUEST_REMEDIATION : RESEARCH_EMPTY_REMEDIATION;
152
+ }
153
+ if (profile.id === 'validate') {
154
+ return requestDeterminesFeasibility ? VALIDATE_REQUEST_REMEDIATION : VALIDATE_EMPTY_REMEDIATION;
155
+ }
156
+ return requestDeterminesFeasibility ? REASON_REQUEST_REMEDIATION : REASON_EMPTY_REMEDIATION;
157
+ }
158
+
117
159
  const RESERVATION_REMEDIATION: readonly string[] = Object.freeze([
118
160
  'Route the blocking stage to a model with larger byte capacity.',
119
161
  'Keep producer output contracts intact; do not shrink or truncate child answers.',
@@ -455,16 +497,28 @@ function replaceRequestText(input: FusionCanonicalInputV3, text: string): Fusion
455
497
  }
456
498
 
457
499
  function visibleTextBytes(input: FusionCanonicalInputV3): number {
500
+ const projection = input.context?.kind === 'session_projection'
501
+ ? input.context.conversation_projection
502
+ : 'conversation_projection' in input
503
+ ? input.conversation_projection
504
+ : undefined;
505
+ if (projection === undefined) return 0;
458
506
  let total = 0;
459
- for (const entry of input.conversation_projection.entries) {
507
+ for (const entry of projection.entries) {
460
508
  if (entry[0] === 't') total += utf8Bytes(JSON.stringify(entry[4]));
461
509
  }
462
510
  return total;
463
511
  }
464
512
 
465
513
  function omissionReceiptBytes(input: FusionCanonicalInputV3): number {
514
+ const projection = input.context?.kind === 'session_projection'
515
+ ? input.context.conversation_projection
516
+ : 'conversation_projection' in input
517
+ ? input.conversation_projection
518
+ : undefined;
519
+ if (projection === undefined) return 0;
466
520
  let total = 0;
467
- for (const entry of input.conversation_projection.entries) {
521
+ for (const entry of projection.entries) {
468
522
  if (entry[0] === 'o') total += utf8Bytes(JSON.stringify(entry));
469
523
  }
470
524
  return total;
@@ -525,10 +579,11 @@ function dominantRemediation(
525
579
  verdict: FusionBudgetEmptyRequestVerdict,
526
580
  composition: FusionBudgetStageComposition | undefined,
527
581
  dominantByteClass: string,
582
+ profile: FusionWorkflowProfile,
528
583
  ): readonly string[] {
529
584
  if (dominantByteClass === 'dense_ascii') return DENSE_REMEDIATION;
530
585
  if (dominantByteClass === 'multibyte') return MULTIBYTE_REMEDIATION;
531
- if (composition === undefined) return remediationFor(verdict);
586
+ if (composition === undefined) return remediationFor(verdict, profile);
532
587
  const entries = [
533
588
  { name: 'visible', bytes: composition.visible_text_bytes },
534
589
  { name: 'request', bytes: composition.request_bytes },
@@ -537,13 +592,24 @@ function dominantRemediation(
537
592
  const dominant = entries[0];
538
593
  if (dominant?.name === 'reservation') return RESERVATION_REMEDIATION;
539
594
  if (dominant?.name === 'request' && !verdict.still_fails_with_empty_request) {
540
- return REQUEST_REMEDIATION;
595
+ return profile.contextKind === 'clean_task'
596
+ ? cleanRemediation(profile, true)
597
+ : REASON_REQUEST_REMEDIATION;
541
598
  }
542
- return EMPTY_REMEDIATION;
599
+ if (profile.contextKind === 'clean_task') return cleanRemediation(profile, false);
600
+ return REASON_EMPTY_REMEDIATION;
543
601
  }
544
602
 
545
- function remediationFor(verdict: FusionBudgetEmptyRequestVerdict): readonly string[] {
546
- return verdict.still_fails_with_empty_request ? EMPTY_REMEDIATION : REQUEST_REMEDIATION;
603
+ function remediationFor(
604
+ verdict: FusionBudgetEmptyRequestVerdict,
605
+ profile: FusionWorkflowProfile,
606
+ ): readonly string[] {
607
+ if (profile.contextKind === 'clean_task') {
608
+ return cleanRemediation(profile, !verdict.still_fails_with_empty_request);
609
+ }
610
+ return verdict.still_fails_with_empty_request
611
+ ? REASON_EMPTY_REMEDIATION
612
+ : REASON_REQUEST_REMEDIATION;
547
613
  }
548
614
 
549
615
  function formatEmptyRequestVerdict(verdict: FusionBudgetEmptyRequestVerdict): string {
@@ -682,8 +748,8 @@ export class FusionBudget {
682
748
  constructor(
683
749
  models: ResolvedFusionModels,
684
750
  contextPolicyId: string,
685
- candidateCapability: FusionCapability = FUSION_DEFAULT_CAPABILITY,
686
- profile: FusionWorkflowProfile = FUSION_BRAINSTORM_WORKFLOW,
751
+ candidateCapability: FusionCapability = FUSION_REASON_WORKFLOW.candidateCapability,
752
+ profile: FusionWorkflowProfile = FUSION_REASON_WORKFLOW,
687
753
  ) {
688
754
  this.routes = fusionRouteCapacities(models);
689
755
  this.limiting = fusionLimitingRoute(this.routes);
@@ -851,7 +917,12 @@ export class FusionBudget {
851
917
  ): FusionError {
852
918
  const composition = plan.primary_blocker_composition;
853
919
  const dominantByteClass = primary.input_only_estimate.rateSource.dominant_byte_class;
854
- const remediation = dominantRemediation(plan.empty_request, composition, dominantByteClass);
920
+ const remediation = dominantRemediation(
921
+ plan.empty_request,
922
+ composition,
923
+ dominantByteClass,
924
+ this.profile,
925
+ );
855
926
  const tokensOver = primary.input_only_input_tokens_upper_bound - primary.allowed_input_tokens;
856
927
  const budget: FusionBudgetErrorDetail = {
857
928
  budget_stage: primary.budget_stage,
@@ -925,6 +996,25 @@ export class FusionBudget {
925
996
  return new FusionError(message, details);
926
997
  }
927
998
 
999
+ private planMetadata(input?: FusionCanonicalInputV3): Pick<FusionBudgetPlanV1, 'workflow' | 'context' | 'fixed_candidate_policy' | 'tool_policy'> {
1000
+ return {
1001
+ workflow: this.profile.id,
1002
+ context: {
1003
+ kind: this.profile.contextKind,
1004
+ policy_id: input?.context?.policy_id ?? this.contextPolicyId,
1005
+ },
1006
+ fixed_candidate_policy: {
1007
+ capability: this.candidateCapability,
1008
+ tools: this.profile.candidateTools,
1009
+ },
1010
+ tool_policy: {
1011
+ candidate_tools: this.profile.candidateTools,
1012
+ evaluation_tools: [] as readonly [],
1013
+ merge_tools: [] as readonly [],
1014
+ },
1015
+ };
1016
+ }
1017
+
928
1018
  plan(input: FusionCanonicalInputV3): FusionBudgetPlanV1 {
929
1019
  const stages = this.entries(input);
930
1020
  const blockers = selectBlockers(stages);
@@ -932,6 +1022,7 @@ export class FusionBudget {
932
1022
  const emptyRequest = this.emptyRequestVerdict(input, stages);
933
1023
  const base: FusionBudgetPlanV1 = {
934
1024
  schema_version: FUSION_BUDGET_PLAN_SCHEMA_VERSION,
1025
+ ...this.planMetadata(input),
935
1026
  policy: FUSION_BUDGET_POLICY,
936
1027
  routes: this.routes,
937
1028
  stages,
@@ -994,6 +1085,7 @@ export class FusionBudget {
994
1085
  const blocker = blockerFromEntry(entry);
995
1086
  const plan: FusionBudgetPlanV1 = {
996
1087
  schema_version: FUSION_BUDGET_PLAN_SCHEMA_VERSION,
1088
+ ...this.planMetadata(),
997
1089
  policy: FUSION_BUDGET_POLICY,
998
1090
  routes: this.routes,
999
1091
  stages: [entry],
@@ -0,0 +1,82 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { Usage } from '@earendil-works/pi-ai';
3
+
4
+ export const FUSION_CHILD_RESULT_SCHEMA_VERSION =
5
+ 'pi-background-tasks.fusion-child-result.v2' as const;
6
+ export const FUSION_CHILD_RESULT_PREFIX = '\u001ePI_FUSION_CHILD_RESULT ';
7
+ export const FUSION_TOOL_CALL_LOG_PATH_ENV = 'PI_FUSION_TOOL_CALL_LOG_PATH';
8
+ export const FUSION_RESEARCH_ENABLED_ENV = 'PI_FUSION_RESEARCH_ENABLED';
9
+ export const FUSION_SOURCE_POLICY_PATH_ENV = 'PI_FUSION_SOURCE_POLICY_PATH';
10
+ export const FUSION_SOURCE_POLICY_SHA256_ENV = 'PI_FUSION_SOURCE_POLICY_SHA256';
11
+ export const FUSION_TOOL_CALL_SEAL_SCHEMA_VERSION =
12
+ 'pi-background-tasks.fusion-tool-call-seal.v1' as const;
13
+ export const FUSION_TOOL_CALL_SEAL_SUFFIX = '.seal.json';
14
+
15
+ /**
16
+ * Aggregate ceiling on tool-result bytes a single candidate child may accumulate.
17
+ *
18
+ * v1 deliberately has no tool-call-count cap, so this byte budget is the only bound on
19
+ * how much a read-only candidate can pull into its context. 8 MiB is generous for
20
+ * targeted grep/read investigation while still preventing an unbounded read loop from
21
+ * degrading into an opaque provider-side context failure.
22
+ */
23
+ export const FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES = 8 * 1024 * 1024;
24
+
25
+ export interface FusionChildTextBlockMetadata {
26
+ utf8_bytes: number;
27
+ sha256: string;
28
+ }
29
+
30
+ export type FusionChildResultUsageMetadata = Usage;
31
+
32
+ export interface FusionChildResultMetadata {
33
+ schema_version: typeof FUSION_CHILD_RESULT_SCHEMA_VERSION;
34
+ provider: string;
35
+ model: string;
36
+ stop_reason: string;
37
+ text_blocks: FusionChildTextBlockMetadata[];
38
+ text_sha256: string;
39
+ usage: FusionChildResultUsageMetadata;
40
+ }
41
+
42
+ function protocolSha256(value: string | Buffer): string {
43
+ return createHash('sha256').update(value).digest('hex');
44
+ }
45
+
46
+ export function buildFusionChildResultMetadata(message: {
47
+ provider: string;
48
+ model: string;
49
+ stopReason: string;
50
+ content: ReadonlyArray<{ type: string; text?: string }>;
51
+ usage: Usage;
52
+ }): FusionChildResultMetadata {
53
+ const textBlocks = message.content.flatMap((part) =>
54
+ part.type === 'text' && typeof part.text === 'string' ? [part.text] : [],
55
+ );
56
+ const usage: FusionChildResultUsageMetadata = {
57
+ input: message.usage.input,
58
+ output: message.usage.output,
59
+ cacheRead: message.usage.cacheRead,
60
+ cacheWrite: message.usage.cacheWrite,
61
+ totalTokens: message.usage.totalTokens,
62
+ cost: {
63
+ input: message.usage.cost.input,
64
+ output: message.usage.cost.output,
65
+ cacheRead: message.usage.cost.cacheRead,
66
+ cacheWrite: message.usage.cost.cacheWrite,
67
+ total: message.usage.cost.total,
68
+ },
69
+ };
70
+ return {
71
+ schema_version: FUSION_CHILD_RESULT_SCHEMA_VERSION,
72
+ provider: message.provider,
73
+ model: message.model,
74
+ stop_reason: message.stopReason,
75
+ text_blocks: textBlocks.map((text) => ({
76
+ utf8_bytes: Buffer.byteLength(text, 'utf8'),
77
+ sha256: protocolSha256(text),
78
+ })),
79
+ text_sha256: protocolSha256(textBlocks.join('')),
80
+ usage,
81
+ };
82
+ }
@@ -0,0 +1,91 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { canonicalJson } from '../attested-pi-run.js';
3
+ import { normalizeFusionDeclaredSources, type DeclaredFusionSourceInput } from './source-policy.js';
4
+ import {
5
+ FUSION_INPUT_SCHEMA_VERSION,
6
+ FusionError,
7
+ type FusionCanonicalRequestV3,
8
+ type FusionCleanTaskCanonicalInputV5,
9
+ type FusionDeclaredSourceV1,
10
+ type FusionSource,
11
+ type FusionWorkflowId,
12
+ } from './types.js';
13
+
14
+ export interface BuildFusionCleanTaskInputOptions {
15
+ cwd: string;
16
+ source: FusionSource;
17
+ request: string;
18
+ workflow: Exclude<FusionWorkflowId, 'reason'>;
19
+ declaredSources?: readonly DeclaredFusionSourceInput[] | undefined;
20
+ }
21
+
22
+ /**
23
+ * Public v1 clean builder. It is deliberately pure: callers provide cwd and
24
+ * normalized request text explicitly, and this module has no dependency on Pi
25
+ * session, snapshot, parent-context, or visible-conversation APIs.
26
+ */
27
+ export const buildCleanFusionCanonicalInput = buildFusionCleanTaskCanonicalInput;
28
+
29
+ export interface BuiltFusionCleanTaskCanonicalInput {
30
+ input: FusionCleanTaskCanonicalInputV5;
31
+ serialized: string;
32
+ declaredSources: readonly FusionDeclaredSourceV1[];
33
+ transcriptLeafId: null;
34
+ }
35
+
36
+ function sha256Text(value: string): string {
37
+ return createHash('sha256').update(Buffer.from(value, 'utf8')).digest('hex');
38
+ }
39
+
40
+ export function buildFusionCleanTaskCanonicalInput(
41
+ options: BuildFusionCleanTaskInputOptions,
42
+ ): BuiltFusionCleanTaskCanonicalInput {
43
+ if (options.request.trim().length === 0) {
44
+ throw new FusionError('fusion request must not be blank', {
45
+ code: 'context_capture_failed',
46
+ childCreated: false,
47
+ });
48
+ }
49
+ if (!['investigate', 'research', 'validate'].includes(options.workflow)) {
50
+ throw new FusionError('clean-task fusion input is available only to investigate, research, and validate workflows', {
51
+ code: 'context_capture_failed',
52
+ childCreated: false,
53
+ });
54
+ }
55
+ const declaredSources = normalizeFusionDeclaredSources(options.declaredSources ?? []);
56
+ if (options.workflow === 'research' && declaredSources.length === 0) {
57
+ throw new FusionError('fusion research requires at least one declared source URL and purpose', {
58
+ code: 'context_capture_failed',
59
+ childCreated: false,
60
+ });
61
+ }
62
+ if (options.workflow !== 'research' && declaredSources.length > 0) {
63
+ throw new FusionError('declared sources are accepted only by the research workflow', {
64
+ code: 'context_capture_failed',
65
+ childCreated: false,
66
+ });
67
+ }
68
+ const request: FusionCanonicalRequestV3 = {
69
+ source: options.source,
70
+ authority: 'explicit_text',
71
+ text: options.request,
72
+ sha256: sha256Text(options.request),
73
+ };
74
+ const input: FusionCleanTaskCanonicalInputV5 = {
75
+ schema_version: FUSION_INPUT_SCHEMA_VERSION,
76
+ workflow: options.workflow,
77
+ cwd: options.cwd,
78
+ request,
79
+ context: {
80
+ kind: 'clean_task',
81
+ policy_id: 'fusion-clean-task-v1',
82
+ declared_sources: declaredSources,
83
+ },
84
+ };
85
+ return {
86
+ input,
87
+ serialized: canonicalJson(input),
88
+ declaredSources,
89
+ transcriptLeafId: null,
90
+ };
91
+ }
@@ -24,6 +24,7 @@ export interface FusionModelRegistry {
24
24
  getAll(): Model<Api>[];
25
25
  getAvailable(): Model<Api>[];
26
26
  find?(provider: string, modelId: string): Model<Api> | undefined;
27
+ isUsingOAuth?(model: Model<Api>): boolean;
27
28
  }
28
29
 
29
30
  export interface ResolveFusionModelsInput {
@@ -163,12 +164,119 @@ function modelIndex(models: readonly Model<Api>[]): Map<string, Model<Api>> {
163
164
  return out;
164
165
  }
165
166
 
167
+ const FRONTIER_MODEL_PATTERN =
168
+ /(?:^|[-_/])(?:gpt|codex|claude|opus|sonnet|o[134](?:-[a-z0-9.]+)*)(?:[-_/]|$)/iu;
169
+ const TRUSTED_SUBSCRIPTION_ENDPOINTS = Object.freeze({
170
+ anthropic: 'https://api.anthropic.com',
171
+ 'openai-codex': 'https://chatgpt.com/backend-api',
172
+ } as const);
173
+ const AUTH_HEADER_NAMES = new Set(['authorization', 'proxy-authorization', 'x-api-key', 'api-key']);
174
+
175
+ function isKnownFrontierEndpoint(baseUrl: string | undefined): boolean {
176
+ if (baseUrl === undefined || baseUrl.trim().length === 0) return false;
177
+ try {
178
+ const hostname = new URL(baseUrl).hostname.toLowerCase().replace(/\.+$/u, '');
179
+ return (
180
+ hostname === 'api.openai.com' ||
181
+ hostname === 'api.anthropic.com' ||
182
+ hostname === 'openrouter.ai' ||
183
+ hostname === 'chatgpt.com' ||
184
+ hostname.endsWith('.openai.azure.com') ||
185
+ hostname.endsWith('.cognitiveservices.azure.com') ||
186
+ hostname.endsWith('.ai.azure.com')
187
+ );
188
+ } catch {
189
+ return false;
190
+ }
191
+ }
192
+
193
+ function assertTrustedSubscriptionEndpoint(
194
+ model: Model<Api>,
195
+ slotLabel: string,
196
+ provider: keyof typeof TRUSTED_SUBSCRIPTION_ENDPOINTS,
197
+ ): void {
198
+ const expectedText = TRUSTED_SUBSCRIPTION_ENDPOINTS[provider];
199
+ const effectiveText = model.baseUrl?.trim() || expectedText;
200
+ let effective: URL;
201
+ try {
202
+ effective = new URL(effectiveText);
203
+ } catch {
204
+ throw new FusionError(
205
+ `${slotLabel} route ${model.provider}/${model.id} has a malformed subscription endpoint`,
206
+ { code: 'model_unavailable', childCreated: false },
207
+ );
208
+ }
209
+ const expected = new URL(expectedText);
210
+ const effectivePath = effective.href.slice(effective.origin.length).replace(/\/+$/u, '');
211
+ const expectedPath = expected.href.slice(expected.origin.length).replace(/\/+$/u, '');
212
+ if (
213
+ effective.protocol !== 'https:' ||
214
+ effective.username !== '' ||
215
+ effective.password !== '' ||
216
+ effective.search !== '' ||
217
+ effective.hash !== '' ||
218
+ effective.origin !== expected.origin ||
219
+ effectivePath !== expectedPath
220
+ ) {
221
+ throw new FusionError(
222
+ `${slotLabel} route ${model.provider}/${model.id} does not use the trusted Pi subscription endpoint ${expectedText}`,
223
+ { code: 'model_unavailable', childCreated: false },
224
+ );
225
+ }
226
+ const unsafeHeader = Object.keys(model.headers ?? {}).find((name) =>
227
+ AUTH_HEADER_NAMES.has(name.toLowerCase()),
228
+ );
229
+ if (unsafeHeader !== undefined) {
230
+ throw new FusionError(
231
+ `${slotLabel} route ${model.provider}/${model.id} overrides subscription authentication header ${unsafeHeader}`,
232
+ { code: 'model_unavailable', childCreated: false },
233
+ );
234
+ }
235
+ }
236
+
237
+ function assertSubscriptionRoute(
238
+ model: Model<Api>,
239
+ slotLabel: string,
240
+ registry: FusionModelRegistry,
241
+ ): void {
242
+ const provider = model.provider.toLowerCase();
243
+ const frontier =
244
+ provider === 'openai' ||
245
+ provider === 'openrouter' ||
246
+ provider === 'anthropic' ||
247
+ provider === 'openai-codex' ||
248
+ provider.includes('azure') ||
249
+ FRONTIER_MODEL_PATTERN.test(`${provider}/${model.id}`) ||
250
+ isKnownFrontierEndpoint(model.baseUrl);
251
+ if (!frontier) return;
252
+ if (provider !== 'anthropic' && provider !== 'openai-codex') {
253
+ throw new FusionError(
254
+ `${slotLabel} route ${model.provider}/${model.id} is a frontier-model API channel; Fusion requires the Pi Anthropic or Codex subscription route`,
255
+ { code: 'model_unavailable', childCreated: false },
256
+ );
257
+ }
258
+ assertTrustedSubscriptionEndpoint(model, slotLabel, provider);
259
+ if (registry.isUsingOAuth === undefined) {
260
+ throw new FusionError(
261
+ `${slotLabel} route ${model.provider}/${model.id} cannot be admitted because ModelRegistry OAuth observation is unavailable`,
262
+ { code: 'model_unavailable', childCreated: false },
263
+ );
264
+ }
265
+ if (!registry.isUsingOAuth(model)) {
266
+ throw new FusionError(
267
+ `${slotLabel} route ${model.provider}/${model.id} is not using subscription OAuth; metered API credentials are forbidden for Fusion`,
268
+ { code: 'model_unavailable', childCreated: false },
269
+ );
270
+ }
271
+ }
272
+
166
273
  function resolveSelection(
167
274
  selection: FusionModelSelection,
168
275
  slotLabel: string,
169
276
  availableByKey: Map<string, Model<Api>>,
170
277
  currentModel: Model<Api> | undefined,
171
278
  thinkingLevel: FusionThinkingLevel,
279
+ registry: FusionModelRegistry,
172
280
  ): ResolvedFusionModel {
173
281
  if (selection === CURRENT_MODEL_SELECTION) {
174
282
  if (currentModel === undefined) {
@@ -188,6 +296,7 @@ function resolveSelection(
188
296
  },
189
297
  );
190
298
  }
299
+ assertSubscriptionRoute(available, slotLabel, registry);
191
300
  return {
192
301
  selection,
193
302
  source: 'current',
@@ -205,6 +314,7 @@ function resolveSelection(
205
314
  childCreated: false,
206
315
  });
207
316
  }
317
+ assertSubscriptionRoute(model, slotLabel, registry);
208
318
  return {
209
319
  selection,
210
320
  source: 'configured',
@@ -219,44 +329,23 @@ function resolveSelection(
219
329
  export function resolveFusionModels(input: ResolveFusionModelsInput): ResolvedFusionModels {
220
330
  const availableByKey = modelIndex(input.modelRegistry.getAvailable());
221
331
  const [first, second, third] = input.config.candidates;
222
- return {
223
- candidates: [
224
- resolveSelection(
225
- first,
226
- 'candidate 1',
227
- availableByKey,
228
- input.currentModel,
229
- input.thinkingLevel,
230
- ),
231
- resolveSelection(
232
- second,
233
- 'candidate 2',
234
- availableByKey,
235
- input.currentModel,
236
- input.thinkingLevel,
237
- ),
238
- resolveSelection(
239
- third,
240
- 'candidate 3',
241
- availableByKey,
242
- input.currentModel,
243
- input.thinkingLevel,
244
- ),
245
- ],
246
- evaluator: resolveSelection(
247
- input.config.evaluator,
248
- 'evaluator',
249
- availableByKey,
250
- input.currentModel,
251
- input.thinkingLevel,
252
- ),
253
- merger: resolveSelection(
254
- input.config.merger,
255
- 'merger',
332
+ const resolve = (selection: FusionModelSelection, slot: string): ResolvedFusionModel =>
333
+ resolveSelection(
334
+ selection,
335
+ slot,
256
336
  availableByKey,
257
337
  input.currentModel,
258
338
  input.thinkingLevel,
259
- ),
339
+ input.modelRegistry,
340
+ );
341
+ return {
342
+ candidates: [
343
+ resolve(first, 'candidate 1'),
344
+ resolve(second, 'candidate 2'),
345
+ resolve(third, 'candidate 3'),
346
+ ],
347
+ evaluator: resolve(input.config.evaluator, 'evaluator'),
348
+ merger: resolve(input.config.merger, 'merger'),
260
349
  };
261
350
  }
262
351