pi-background-tasks 0.7.2 → 0.7.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.
- package/PUBLISHING.md +10 -7
- package/README.md +79 -7
- package/TESTING.md +15 -5
- package/TEST_PLAN.md +12 -7
- package/package.json +10 -8
- package/src/core/attested-pi-run.ts +19 -35
- package/src/core/common.ts +124 -8
- package/src/core/durable-fs.ts +400 -0
- package/src/core/fusion/artifacts.ts +27 -55
- package/src/core/fusion/budget.ts +372 -0
- package/src/core/fusion/config.ts +4 -36
- package/src/core/fusion/context.ts +508 -47
- package/src/core/fusion/orchestrator.ts +48 -22
- package/src/core/fusion/pi-child.ts +74 -48
- package/src/core/fusion/prompts.ts +21 -7
- package/src/core/fusion/types.ts +307 -14
- package/src/core/pi-launch.ts +225 -0
- package/src/core/registry.ts +507 -56
- package/src/core/windows-taskkill.ts +250 -0
- package/src/fusion-child-extension.ts +11 -20
- package/src/fusion-extension.ts +9 -5
package/src/core/fusion/types.ts
CHANGED
|
@@ -1,10 +1,31 @@
|
|
|
1
|
+
import type { Usage } from '@earendil-works/pi-ai';
|
|
2
|
+
|
|
1
3
|
export type FusionThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
|
|
2
4
|
|
|
3
5
|
export const FUSION_MODEL_CONFIG_SCHEMA_VERSION = 'pi-background-tasks.fusion-models.v1';
|
|
4
|
-
export const FUSION_INPUT_SCHEMA_VERSION = 'pi-background-tasks.fusion-input.
|
|
6
|
+
export const FUSION_INPUT_SCHEMA_VERSION = 'pi-background-tasks.fusion-input.v2';
|
|
5
7
|
export const FUSION_EVALUATION_SCHEMA_VERSION = 'pi-background-tasks.fusion-evaluation.v1';
|
|
6
|
-
export const FUSION_RESULT_SCHEMA_VERSION = 'pi-background-tasks.fusion-result.
|
|
7
|
-
export const FUSION_MANIFEST_SCHEMA_VERSION = 'pi-background-tasks.fusion-manifest.
|
|
8
|
+
export const FUSION_RESULT_SCHEMA_VERSION = 'pi-background-tasks.fusion-result.v2';
|
|
9
|
+
export const FUSION_MANIFEST_SCHEMA_VERSION = 'pi-background-tasks.fusion-manifest.v2';
|
|
10
|
+
export const FUSION_CONTEXT_LEDGER_SCHEMA_VERSION = 'pi-background-tasks.fusion-context-ledger.v1';
|
|
11
|
+
export const FUSION_BUDGET_PLAN_SCHEMA_VERSION = 'pi-background-tasks.fusion-budget-plan.v1';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Conversation-projection transform shared by every Fusion entry point.
|
|
15
|
+
*
|
|
16
|
+
* The transform keeps visible user/assistant conversational text verbatim and
|
|
17
|
+
* replaces assistant thinking plus all tool traffic with deterministic,
|
|
18
|
+
* hash-accounted omission receipts. It never truncates retained text and never
|
|
19
|
+
* forwards raw image bytes.
|
|
20
|
+
*/
|
|
21
|
+
export const FUSION_CONTEXT_TRANSFORM_ID = 'visible-conversation-ledger-v1';
|
|
22
|
+
export const FUSION_BRANCH_FILTER_ID = 'exclude-active-fusion-subtree-v1';
|
|
23
|
+
|
|
24
|
+
/** Entry-point specific context policies. Both use the same payload-exclusion transform. */
|
|
25
|
+
export const FUSION_TOOL_CONTEXT_POLICY_ID = 'fusion-tool-explicit-v1';
|
|
26
|
+
export const FUSION_COMMAND_CONTEXT_POLICY_ID = 'fusion-command-conversation-v1';
|
|
27
|
+
|
|
28
|
+
export const FUSION_IMAGE_OMISSION_PREFIX = '[Image omitted from fusion text transcript: ';
|
|
8
29
|
|
|
9
30
|
export const FUSION_CANDIDATE_IDS = ['A', 'B', 'C'] as const;
|
|
10
31
|
export type FusionCandidateId = (typeof FUSION_CANDIDATE_IDS)[number];
|
|
@@ -12,6 +33,18 @@ export type FusionCandidateId = (typeof FUSION_CANDIDATE_IDS)[number];
|
|
|
12
33
|
export const FUSION_STAGE_VALUES = ['candidate', 'evaluation', 'merge'] as const;
|
|
13
34
|
export type FusionStage = (typeof FUSION_STAGE_VALUES)[number];
|
|
14
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Prompt-expansion stages guarded by deterministic size accounting. `evaluation`
|
|
38
|
+
* and `evaluation_repair` share the evaluator model but render different prompts.
|
|
39
|
+
*/
|
|
40
|
+
export const FUSION_BUDGET_STAGE_VALUES = [
|
|
41
|
+
'candidate',
|
|
42
|
+
'evaluation',
|
|
43
|
+
'evaluation_repair',
|
|
44
|
+
'merge',
|
|
45
|
+
] as const;
|
|
46
|
+
export type FusionBudgetStage = (typeof FUSION_BUDGET_STAGE_VALUES)[number];
|
|
47
|
+
|
|
15
48
|
export const FUSION_SOURCE_VALUES = ['command', 'tool'] as const;
|
|
16
49
|
export type FusionSource = (typeof FUSION_SOURCE_VALUES)[number];
|
|
17
50
|
|
|
@@ -77,12 +110,149 @@ export interface ResolvedFusionModels {
|
|
|
77
110
|
merger: ResolvedFusionModel;
|
|
78
111
|
}
|
|
79
112
|
|
|
80
|
-
export
|
|
113
|
+
export type FusionRequestAuthority = 'explicit_text' | 'directive_over_projected_conversation';
|
|
114
|
+
|
|
115
|
+
export interface FusionCanonicalRequestV2 {
|
|
116
|
+
/** Entry point that produced this request. */
|
|
117
|
+
source: FusionSource;
|
|
118
|
+
/** How children must weigh `text` against the projected conversation. */
|
|
119
|
+
authority: FusionRequestAuthority;
|
|
120
|
+
/** Verbatim request text. Never clipped, never rewritten. */
|
|
121
|
+
text: string;
|
|
122
|
+
/** Lowercase SHA-256 of the UTF-8 request bytes. */
|
|
123
|
+
sha256: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const FUSION_OMITTED_EVENT_KINDS = [
|
|
127
|
+
'assistant_thinking',
|
|
128
|
+
'tool_call',
|
|
129
|
+
'tool_result_text',
|
|
130
|
+
'tool_result_image',
|
|
131
|
+
] as const;
|
|
132
|
+
export type FusionOmittedEventKind = (typeof FUSION_OMITTED_EVENT_KINDS)[number];
|
|
133
|
+
|
|
134
|
+
/** One omitted conversation event. Ledger rows never leave the local artifact directory. */
|
|
135
|
+
export interface FusionOmittedEventRecord {
|
|
136
|
+
index: number;
|
|
137
|
+
source_ordinal: number;
|
|
138
|
+
block_ordinal: number;
|
|
139
|
+
kind: FusionOmittedEventKind;
|
|
140
|
+
payload_bytes: number;
|
|
141
|
+
payload_sha256: string;
|
|
142
|
+
tool_name?: string;
|
|
143
|
+
tool_call_id?: string;
|
|
144
|
+
mime_type?: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface FusionContextOmissionLedgerV1 {
|
|
148
|
+
schema_version: typeof FUSION_CONTEXT_LEDGER_SCHEMA_VERSION;
|
|
149
|
+
policy_id: string;
|
|
150
|
+
transform: typeof FUSION_CONTEXT_TRANSFORM_ID;
|
|
151
|
+
entries: readonly FusionOmittedEventRecord[];
|
|
152
|
+
root_sha256: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface FusionProjectionTextEntry {
|
|
156
|
+
kind: 'text';
|
|
157
|
+
source_ordinal: number;
|
|
158
|
+
block_ordinal: number;
|
|
159
|
+
role: 'user' | 'assistant';
|
|
160
|
+
text: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Per-kind counts for one omitted run. Zero-valued kinds are omitted from the
|
|
165
|
+
* serialized receipt by a fixed policy rule so receipt size does not scale with
|
|
166
|
+
* the number of tracked kinds; absent means exactly zero.
|
|
167
|
+
*/
|
|
168
|
+
export interface FusionOmittedRunCounts {
|
|
169
|
+
assistant_thinking?: number;
|
|
170
|
+
tool_calls?: number;
|
|
171
|
+
tool_result_texts?: number;
|
|
172
|
+
tool_result_images?: number;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Byte totals for one omitted run, using the same omit-when-zero rule. */
|
|
176
|
+
export interface FusionOmittedRunBytes {
|
|
177
|
+
assistant_thinking?: number;
|
|
178
|
+
tool_call_arguments?: number;
|
|
179
|
+
tool_result_text?: number;
|
|
180
|
+
tool_result_image?: number;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface FusionProjectionOmissionEntry {
|
|
184
|
+
kind: 'omitted_activity';
|
|
185
|
+
source_ordinal_first: number;
|
|
186
|
+
source_ordinal_last: number;
|
|
187
|
+
ledger_index_first: number;
|
|
188
|
+
ledger_index_last: number;
|
|
189
|
+
counts: FusionOmittedRunCounts;
|
|
190
|
+
payload_bytes: FusionOmittedRunBytes;
|
|
191
|
+
ledger_run_sha256: string;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export type FusionProjectionEntry = FusionProjectionTextEntry | FusionProjectionOmissionEntry;
|
|
195
|
+
|
|
196
|
+
export interface FusionContextPolicyDescriptor {
|
|
197
|
+
id: string;
|
|
198
|
+
transform: typeof FUSION_CONTEXT_TRANSFORM_ID;
|
|
199
|
+
version: 1;
|
|
200
|
+
user_text: 'verbatim';
|
|
201
|
+
assistant_text: 'verbatim';
|
|
202
|
+
assistant_thinking: 'ledger_only';
|
|
203
|
+
tool_call_arguments: 'ledger_only';
|
|
204
|
+
tool_results: 'ledger_only';
|
|
205
|
+
tool_payload_preview_bytes: 0;
|
|
206
|
+
images: 'marker_or_ledger_only';
|
|
207
|
+
unknown_block_behavior: 'error';
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface FusionBranchFilterDescriptor {
|
|
211
|
+
id: typeof FUSION_BRANCH_FILTER_ID;
|
|
212
|
+
tool_name: string;
|
|
213
|
+
tool_call_id: string | null;
|
|
214
|
+
active_tool_call_leaf_excluded: boolean;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export interface FusionToolCallNameCount {
|
|
218
|
+
name: string;
|
|
219
|
+
calls: number;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export interface FusionProjectionAccounting {
|
|
223
|
+
message_count: number;
|
|
224
|
+
included_text_entry_count: number;
|
|
225
|
+
included_user_text_bytes: number;
|
|
226
|
+
included_assistant_text_bytes: number;
|
|
227
|
+
included_image_marker_count: number;
|
|
228
|
+
empty_text_block_count: number;
|
|
229
|
+
omitted_run_count: number;
|
|
230
|
+
omitted_event_count: number;
|
|
231
|
+
omitted_thinking_bytes: number;
|
|
232
|
+
omitted_tool_call_count: number;
|
|
233
|
+
omitted_tool_call_argument_bytes: number;
|
|
234
|
+
omitted_tool_result_text_count: number;
|
|
235
|
+
omitted_tool_result_text_bytes: number;
|
|
236
|
+
omitted_tool_result_image_count: number;
|
|
237
|
+
omitted_tool_result_image_bytes: number;
|
|
238
|
+
tool_call_names: readonly FusionToolCallNameCount[];
|
|
239
|
+
ledger_entry_count: number;
|
|
240
|
+
ledger_root_sha256: string;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export interface FusionConversationProjectionV2 {
|
|
244
|
+
policy: FusionContextPolicyDescriptor;
|
|
245
|
+
branch_filter: FusionBranchFilterDescriptor;
|
|
246
|
+
entries: readonly FusionProjectionEntry[];
|
|
247
|
+
accounting: FusionProjectionAccounting;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export interface FusionCanonicalInputV2 {
|
|
81
251
|
schema_version: typeof FUSION_INPUT_SCHEMA_VERSION;
|
|
82
252
|
cwd: string;
|
|
83
253
|
system_prompt: string;
|
|
84
|
-
|
|
85
|
-
|
|
254
|
+
request: FusionCanonicalRequestV2;
|
|
255
|
+
conversation_projection: FusionConversationProjectionV2;
|
|
86
256
|
}
|
|
87
257
|
|
|
88
258
|
export interface CandidateAssessment {
|
|
@@ -124,14 +294,16 @@ export interface FusionEvaluationV1 {
|
|
|
124
294
|
synthesis_plan: FusionSynthesisPlan;
|
|
125
295
|
}
|
|
126
296
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
297
|
+
/** Exact Pi usage contract used at the child, artifact, and host tool-result boundaries. */
|
|
298
|
+
export type FusionUsage = Usage;
|
|
299
|
+
|
|
300
|
+
const EMPTY_FUSION_COST: Usage['cost'] = Object.freeze({
|
|
301
|
+
input: 0,
|
|
302
|
+
output: 0,
|
|
303
|
+
cacheRead: 0,
|
|
304
|
+
cacheWrite: 0,
|
|
305
|
+
total: 0,
|
|
306
|
+
});
|
|
135
307
|
|
|
136
308
|
export const EMPTY_FUSION_USAGE: FusionUsage = Object.freeze({
|
|
137
309
|
input: 0,
|
|
@@ -139,8 +311,43 @@ export const EMPTY_FUSION_USAGE: FusionUsage = Object.freeze({
|
|
|
139
311
|
cacheRead: 0,
|
|
140
312
|
cacheWrite: 0,
|
|
141
313
|
totalTokens: 0,
|
|
314
|
+
cost: EMPTY_FUSION_COST,
|
|
142
315
|
});
|
|
143
316
|
|
|
317
|
+
export function createEmptyFusionUsage(): FusionUsage {
|
|
318
|
+
return cloneFusionUsage(EMPTY_FUSION_USAGE);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function cloneFusionUsage(usage: FusionUsage): FusionUsage {
|
|
322
|
+
return {
|
|
323
|
+
input: usage.input,
|
|
324
|
+
output: usage.output,
|
|
325
|
+
cacheRead: usage.cacheRead,
|
|
326
|
+
cacheWrite: usage.cacheWrite,
|
|
327
|
+
totalTokens: usage.totalTokens,
|
|
328
|
+
cost: {
|
|
329
|
+
input: usage.cost.input,
|
|
330
|
+
output: usage.cost.output,
|
|
331
|
+
cacheRead: usage.cost.cacheRead,
|
|
332
|
+
cacheWrite: usage.cost.cacheWrite,
|
|
333
|
+
total: usage.cost.total,
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function addFusionUsage(target: FusionUsage, delta: FusionUsage): void {
|
|
339
|
+
target.input += delta.input;
|
|
340
|
+
target.output += delta.output;
|
|
341
|
+
target.cacheRead += delta.cacheRead;
|
|
342
|
+
target.cacheWrite += delta.cacheWrite;
|
|
343
|
+
target.totalTokens += delta.totalTokens;
|
|
344
|
+
target.cost.input += delta.cost.input;
|
|
345
|
+
target.cost.output += delta.cost.output;
|
|
346
|
+
target.cost.cacheRead += delta.cost.cacheRead;
|
|
347
|
+
target.cost.cacheWrite += delta.cost.cacheWrite;
|
|
348
|
+
target.cost.total += delta.cost.total;
|
|
349
|
+
}
|
|
350
|
+
|
|
144
351
|
export interface FusionResultDetails {
|
|
145
352
|
schema_version: typeof FUSION_RESULT_SCHEMA_VERSION;
|
|
146
353
|
run_id: string;
|
|
@@ -173,6 +380,9 @@ export type FusionErrorCode =
|
|
|
173
380
|
| 'config_conflict'
|
|
174
381
|
| 'model_unavailable'
|
|
175
382
|
| 'context_capture_failed'
|
|
383
|
+
| 'context_policy_unsupported_block'
|
|
384
|
+
| 'prompt_budget_exceeded'
|
|
385
|
+
| 'model_capacity_unknown'
|
|
176
386
|
| 'child_spawn_failed'
|
|
177
387
|
| 'child_stdin_failed'
|
|
178
388
|
| 'child_event_invalid'
|
|
@@ -185,6 +395,27 @@ export type FusionErrorCode =
|
|
|
185
395
|
| 'state_transition_invalid'
|
|
186
396
|
| 'orchestration_failed';
|
|
187
397
|
|
|
398
|
+
/**
|
|
399
|
+
* Structured detail attached to a `prompt_budget_exceeded` failure so the caller
|
|
400
|
+
* can see exactly which stage, which measured size, which allowed size, and
|
|
401
|
+
* which configured model was the limiting participant.
|
|
402
|
+
*/
|
|
403
|
+
export interface FusionBudgetErrorDetail {
|
|
404
|
+
budget_stage: FusionBudgetStage;
|
|
405
|
+
measurement_kind: 'worst_case_envelope' | 'rendered_prompt';
|
|
406
|
+
measured_utf8_bytes: number;
|
|
407
|
+
measured_input_tokens_upper_bound: number;
|
|
408
|
+
allowed_input_tokens: number;
|
|
409
|
+
limiting_model: {
|
|
410
|
+
provider: string;
|
|
411
|
+
model: string;
|
|
412
|
+
qualified_id: string;
|
|
413
|
+
context_window_tokens: number;
|
|
414
|
+
};
|
|
415
|
+
context_policy_id: string;
|
|
416
|
+
remediation: readonly string[];
|
|
417
|
+
}
|
|
418
|
+
|
|
188
419
|
export interface FusionErrorDetails {
|
|
189
420
|
code: FusionErrorCode;
|
|
190
421
|
stage?: FusionStage;
|
|
@@ -193,6 +424,7 @@ export interface FusionErrorDetails {
|
|
|
193
424
|
artifactDir?: string;
|
|
194
425
|
transient?: boolean;
|
|
195
426
|
childCreated?: boolean;
|
|
427
|
+
budget?: FusionBudgetErrorDetail;
|
|
196
428
|
}
|
|
197
429
|
|
|
198
430
|
export class FusionError extends Error {
|
|
@@ -203,6 +435,7 @@ export class FusionError extends Error {
|
|
|
203
435
|
readonly artifactDir: string | undefined;
|
|
204
436
|
readonly transient: boolean;
|
|
205
437
|
readonly childCreated: boolean;
|
|
438
|
+
readonly budget: FusionBudgetErrorDetail | undefined;
|
|
206
439
|
|
|
207
440
|
constructor(message: string, details: FusionErrorDetails) {
|
|
208
441
|
super(message);
|
|
@@ -214,6 +447,7 @@ export class FusionError extends Error {
|
|
|
214
447
|
this.artifactDir = details.artifactDir;
|
|
215
448
|
this.transient = details.transient ?? false;
|
|
216
449
|
this.childCreated = details.childCreated ?? true;
|
|
450
|
+
this.budget = details.budget;
|
|
217
451
|
}
|
|
218
452
|
}
|
|
219
453
|
|
|
@@ -287,3 +521,62 @@ export interface FusionRunResult {
|
|
|
287
521
|
mergedText: string;
|
|
288
522
|
details: FusionResultDetails;
|
|
289
523
|
}
|
|
524
|
+
|
|
525
|
+
/** Snapshot of one configured route's verified input capacity for one stage. */
|
|
526
|
+
export interface FusionRouteCapacity {
|
|
527
|
+
role: 'candidate-1' | 'candidate-2' | 'candidate-3' | 'evaluator' | 'merger';
|
|
528
|
+
provider: string;
|
|
529
|
+
model: string;
|
|
530
|
+
qualified_id: string;
|
|
531
|
+
context_window_tokens: number;
|
|
532
|
+
reserved_output_tokens: number;
|
|
533
|
+
framing_reserve_tokens: number;
|
|
534
|
+
safety_reserve_tokens: number;
|
|
535
|
+
allowed_input_tokens: number;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export interface FusionStageBudgetPlanEntry {
|
|
539
|
+
budget_stage: FusionBudgetStage;
|
|
540
|
+
measurement_kind: 'worst_case_envelope';
|
|
541
|
+
measured_utf8_bytes: number;
|
|
542
|
+
measured_input_tokens_upper_bound: number;
|
|
543
|
+
allowed_input_tokens: number;
|
|
544
|
+
limiting_qualified_id: string;
|
|
545
|
+
slack_tokens: number;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
export interface FusionBudgetPlanV1 {
|
|
549
|
+
schema_version: typeof FUSION_BUDGET_PLAN_SCHEMA_VERSION;
|
|
550
|
+
policy: FusionBudgetPolicyDescriptor;
|
|
551
|
+
routes: readonly FusionRouteCapacity[];
|
|
552
|
+
limiting_qualified_id: string;
|
|
553
|
+
/** Base-context feasibility check performed before the first candidate spawns. */
|
|
554
|
+
base_context: FusionStageBudgetPlanEntry;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Documented, versioned budget policy.
|
|
559
|
+
*
|
|
560
|
+
* `bytes_per_token_divisor` is a conservative lower bound on UTF-8 bytes per
|
|
561
|
+
* token: token upper bound = ceil(utf8Bytes / divisor). It is deliberately far
|
|
562
|
+
* below the smallest ratio measured across real Fusion prompts so dense
|
|
563
|
+
* non-ASCII input cannot silently exceed a route's window.
|
|
564
|
+
*
|
|
565
|
+
* `downstream_reserve_bytes` is withheld from the canonical input so the
|
|
566
|
+
* evaluator, evaluation-repair, and merger prompts provably have room for the
|
|
567
|
+
* child outputs they embed. It is derived from the enforced per-stage output
|
|
568
|
+
* byte contracts, so it is a guarantee rather than an estimate: a response over
|
|
569
|
+
* its contract fails loudly instead of being embedded. `downstream_reserve_tokens`
|
|
570
|
+
* converts it with the same `bytes_per_token_divisor` used to measure prompts,
|
|
571
|
+
* because reserving output tokens directly would understate the cost of
|
|
572
|
+
* re-embedding those bytes. Neither value is adjusted to fit a particular input.
|
|
573
|
+
*/
|
|
574
|
+
export interface FusionBudgetPolicyDescriptor {
|
|
575
|
+
id: 'fusion-budget-policy-v1';
|
|
576
|
+
bytes_per_token_divisor: number;
|
|
577
|
+
reserved_output_tokens: number;
|
|
578
|
+
framing_reserve_tokens: number;
|
|
579
|
+
safety_reserve_tokens: number;
|
|
580
|
+
downstream_reserve_bytes: number;
|
|
581
|
+
downstream_reserve_tokens: number;
|
|
582
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import type { Stats } from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { dirname, extname, isAbsolute, join, relative, sep } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
export interface PiLaunchSpec {
|
|
8
|
+
readonly executable: string;
|
|
9
|
+
readonly argvPrefix: readonly string[];
|
|
10
|
+
readonly kind: 'path' | 'package-node-cli';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PiLaunchDependencies {
|
|
14
|
+
readonly platform?: NodeJS.Platform;
|
|
15
|
+
readonly execPath?: string;
|
|
16
|
+
readonly resolvePackageJson?: (specifier: string) => string;
|
|
17
|
+
readonly readFile?: (path: string) => string | Buffer;
|
|
18
|
+
readonly realpath?: (path: string) => string;
|
|
19
|
+
readonly stat?: (path: string) => Pick<Stats, 'isFile'>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class PiLaunchResolutionError extends Error {
|
|
23
|
+
readonly code = 'pi_executable_resolution_failed';
|
|
24
|
+
|
|
25
|
+
constructor(message: string) {
|
|
26
|
+
super(`pi_executable_resolution_failed: ${message}`);
|
|
27
|
+
this.name = 'PiLaunchResolutionError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class PiCommandLineLimitError extends Error {
|
|
32
|
+
readonly code = 'pi_command_line_too_long';
|
|
33
|
+
readonly stage: string;
|
|
34
|
+
readonly measuredLength: number;
|
|
35
|
+
readonly limit: number;
|
|
36
|
+
|
|
37
|
+
constructor(stage: string, measuredLength: number, limit: number) {
|
|
38
|
+
super(
|
|
39
|
+
`pi_command_line_too_long: ${stage} measured UTF-16 command line length ${String(measuredLength)} exceeds limit ${String(limit)}`,
|
|
40
|
+
);
|
|
41
|
+
this.name = 'PiCommandLineLimitError';
|
|
42
|
+
this.stage = stage;
|
|
43
|
+
this.measuredLength = measuredLength;
|
|
44
|
+
this.limit = limit;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const PI_PACKAGE_NAME = '@earendil-works/pi-coding-agent';
|
|
49
|
+
const PI_PACKAGE_MANIFEST = `${PI_PACKAGE_NAME}/package.json`;
|
|
50
|
+
const WINDOWS_COMMAND_LINE_LIMIT = 32767;
|
|
51
|
+
|
|
52
|
+
interface JsonRecord {
|
|
53
|
+
readonly [key: string]: unknown;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function defaultResolvePackageJson(specifier: string): string {
|
|
57
|
+
const requireForPi = createRequire(import.meta.url);
|
|
58
|
+
try {
|
|
59
|
+
return requireForPi.resolve(specifier);
|
|
60
|
+
} catch (manifestError) {
|
|
61
|
+
if (specifier !== PI_PACKAGE_MANIFEST) throw manifestError;
|
|
62
|
+
let packageEntry: string;
|
|
63
|
+
try {
|
|
64
|
+
packageEntry = fileURLToPath(import.meta.resolve(PI_PACKAGE_NAME));
|
|
65
|
+
} catch (entryError) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`${errorMessage(manifestError)}; package entry resolve failed: ${errorMessage(entryError)}`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
const diagnostics: string[] = [];
|
|
71
|
+
let dir = dirname(packageEntry);
|
|
72
|
+
for (;;) {
|
|
73
|
+
const candidate = join(dir, 'package.json');
|
|
74
|
+
try {
|
|
75
|
+
if (statSync(candidate).isFile()) return candidate;
|
|
76
|
+
diagnostics.push(`${candidate} is not a regular file`);
|
|
77
|
+
} catch (statError) {
|
|
78
|
+
diagnostics.push(`${candidate}: ${errorMessage(statError)}`);
|
|
79
|
+
}
|
|
80
|
+
const parent = dirname(dir);
|
|
81
|
+
if (parent === dir) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`${errorMessage(manifestError)}; package entry search failed: ${diagnostics.join('; ')}`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
dir = parent;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function failResolution(message: string): never {
|
|
92
|
+
throw new PiLaunchResolutionError(message);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function errorMessage(error: unknown): string {
|
|
96
|
+
return error instanceof Error ? error.message : String(error);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function readPath<T>(label: string, path: string, action: () => T): T {
|
|
100
|
+
try {
|
|
101
|
+
return action();
|
|
102
|
+
} catch (error) {
|
|
103
|
+
failResolution(`${label} failed for ${path}: ${errorMessage(error)}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isJsonRecord(value: unknown): value is JsonRecord {
|
|
108
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseManifest(raw: string | Buffer, manifestPath: string): JsonRecord {
|
|
112
|
+
let parsed: unknown;
|
|
113
|
+
try {
|
|
114
|
+
parsed = JSON.parse(Buffer.isBuffer(raw) ? raw.toString('utf8') : raw);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
failResolution(`manifest JSON is invalid at ${manifestPath}: ${errorMessage(error)}`);
|
|
117
|
+
}
|
|
118
|
+
if (!isJsonRecord(parsed)) failResolution(`manifest is not an object at ${manifestPath}`);
|
|
119
|
+
return parsed;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function readPiBin(manifest: JsonRecord, manifestPath: string): string {
|
|
123
|
+
const bin = manifest['bin'];
|
|
124
|
+
if (typeof bin === 'string' && bin.trim().length > 0) return bin;
|
|
125
|
+
if (isJsonRecord(bin)) {
|
|
126
|
+
const pi = bin['pi'];
|
|
127
|
+
if (typeof pi === 'string' && pi.trim().length > 0) return pi;
|
|
128
|
+
}
|
|
129
|
+
failResolution(`manifest bin.pi is missing or malformed at ${manifestPath}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function pathInside(parent: string, child: string): boolean {
|
|
133
|
+
const rel = relative(parent, child);
|
|
134
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel) && !rel.split(sep).includes('..'));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function resolvePiLaunch(deps: PiLaunchDependencies = {}): PiLaunchSpec {
|
|
138
|
+
const platform = deps.platform ?? process.platform;
|
|
139
|
+
if (platform !== 'win32') return { executable: 'pi', argvPrefix: [], kind: 'path' };
|
|
140
|
+
|
|
141
|
+
const resolvePackageJson = deps.resolvePackageJson ?? defaultResolvePackageJson;
|
|
142
|
+
const readFile = deps.readFile ?? readFileSync;
|
|
143
|
+
const realpath = deps.realpath ?? realpathSync;
|
|
144
|
+
const stat = deps.stat ?? statSync;
|
|
145
|
+
const execPath = deps.execPath ?? process.execPath;
|
|
146
|
+
|
|
147
|
+
let manifestPath: string;
|
|
148
|
+
try {
|
|
149
|
+
manifestPath = resolvePackageJson(PI_PACKAGE_MANIFEST);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
failResolution(`package manifest resolve failed for ${PI_PACKAGE_MANIFEST}: ${errorMessage(error)}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const packageRoot = dirname(manifestPath);
|
|
155
|
+
const packageRootReal = readPath('package root realpath', packageRoot, () => realpath(packageRoot));
|
|
156
|
+
const manifest = parseManifest(
|
|
157
|
+
readPath('manifest read', manifestPath, () => readFile(manifestPath)),
|
|
158
|
+
manifestPath,
|
|
159
|
+
);
|
|
160
|
+
const bin = readPiBin(manifest, manifestPath);
|
|
161
|
+
const targetCandidate = join(packageRoot, bin);
|
|
162
|
+
const targetReal = readPath('bin target realpath', targetCandidate, () => realpath(targetCandidate));
|
|
163
|
+
if (!pathInside(packageRootReal, targetReal)) {
|
|
164
|
+
failResolution('Pi package bin target resolves outside the package root');
|
|
165
|
+
}
|
|
166
|
+
const targetStat = readPath('bin target stat', targetReal, () => stat(targetReal));
|
|
167
|
+
if (!targetStat.isFile()) failResolution('Pi package bin target is not a regular file');
|
|
168
|
+
|
|
169
|
+
const extension = extname(targetReal).toLowerCase();
|
|
170
|
+
if (extension === '.js' || extension === '.cjs' || extension === '.mjs') {
|
|
171
|
+
return { executable: execPath, argvPrefix: [targetReal], kind: 'package-node-cli' };
|
|
172
|
+
}
|
|
173
|
+
if (extension === '.exe' || extension === '.com') {
|
|
174
|
+
return { executable: targetReal, argvPrefix: [], kind: 'package-node-cli' };
|
|
175
|
+
}
|
|
176
|
+
failResolution(`Pi package bin target extension is unsupported: ${extension || '<none>'}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function piLaunchArgv(launch: PiLaunchSpec, piArgs: readonly string[]): string[] {
|
|
180
|
+
return [...launch.argvPrefix, ...piArgs];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function renderWindowsArgument(value: string): string {
|
|
184
|
+
if (value.length > 0 && !/[ \t"]/.test(value)) return value;
|
|
185
|
+
let rendered = '"';
|
|
186
|
+
let backslashes = 0;
|
|
187
|
+
for (const char of value) {
|
|
188
|
+
if (char === '\\') {
|
|
189
|
+
backslashes += 1;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (char === '"') {
|
|
193
|
+
rendered += '\\'.repeat(backslashes * 2 + 1);
|
|
194
|
+
rendered += '"';
|
|
195
|
+
backslashes = 0;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (backslashes > 0) {
|
|
199
|
+
rendered += '\\'.repeat(backslashes);
|
|
200
|
+
backslashes = 0;
|
|
201
|
+
}
|
|
202
|
+
rendered += char;
|
|
203
|
+
}
|
|
204
|
+
if (backslashes > 0) rendered += '\\'.repeat(backslashes * 2);
|
|
205
|
+
rendered += '"';
|
|
206
|
+
return rendered;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function renderWindowsCommandLine(parts: readonly string[]): string {
|
|
210
|
+
return parts.map(renderWindowsArgument).join(' ');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function assertWindowsCommandLineWithinLimit(
|
|
214
|
+
launch: PiLaunchSpec,
|
|
215
|
+
piArgs: readonly string[],
|
|
216
|
+
platform: NodeJS.Platform,
|
|
217
|
+
stage: string,
|
|
218
|
+
): void {
|
|
219
|
+
if (platform !== 'win32') return;
|
|
220
|
+
const measuredLength =
|
|
221
|
+
renderWindowsCommandLine([launch.executable, ...launch.argvPrefix, ...piArgs]).length + 1;
|
|
222
|
+
if (measuredLength > WINDOWS_COMMAND_LINE_LIMIT) {
|
|
223
|
+
throw new PiCommandLineLimitError(stage, measuredLength, WINDOWS_COMMAND_LINE_LIMIT);
|
|
224
|
+
}
|
|
225
|
+
}
|