pi-background-tasks 0.6.0 → 0.7.2

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.
@@ -0,0 +1,371 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { closeSync, fsyncSync, openSync, renameSync } from 'node:fs';
3
+ import { chmod, mkdir, open, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { basename, dirname, join } from 'node:path';
5
+ import { getAgentDir } from '@earendil-works/pi-coding-agent';
6
+ import type { Api, Model } from '@earendil-works/pi-ai';
7
+ import { isJsonObject, parseJsonText, type JsonObject } from '../common.js';
8
+ import {
9
+ FUSION_MODEL_CONFIG_SCHEMA_VERSION,
10
+ FusionError,
11
+ type FusionModelConfigRevision,
12
+ type FusionModelConfigV1,
13
+ type FusionModelSelection,
14
+ type FusionThinkingLevel,
15
+ type LoadedFusionModelConfig,
16
+ type ResolvedFusionModel,
17
+ type ResolvedFusionModels,
18
+ } from './types.js';
19
+
20
+ export const FUSION_MODEL_CONFIG_FILE = 'fusion-models.json';
21
+ export const CURRENT_MODEL_SELECTION = '$current';
22
+
23
+ export interface FusionModelRegistry {
24
+ getAll(): Model<Api>[];
25
+ getAvailable(): Model<Api>[];
26
+ find?(provider: string, modelId: string): Model<Api> | undefined;
27
+ }
28
+
29
+ export interface ResolveFusionModelsInput {
30
+ config: FusionModelConfigV1;
31
+ modelRegistry: FusionModelRegistry;
32
+ currentModel: Model<Api> | undefined;
33
+ thinkingLevel: FusionThinkingLevel;
34
+ }
35
+
36
+ export function defaultFusionModelConfig(): FusionModelConfigV1 {
37
+ return {
38
+ schema_version: FUSION_MODEL_CONFIG_SCHEMA_VERSION,
39
+ candidates: [CURRENT_MODEL_SELECTION, CURRENT_MODEL_SELECTION, CURRENT_MODEL_SELECTION],
40
+ evaluator: CURRENT_MODEL_SELECTION,
41
+ merger: CURRENT_MODEL_SELECTION,
42
+ };
43
+ }
44
+
45
+ export function fusionModelConfigPath(agentDir = getAgentDir()): string {
46
+ return join(agentDir, FUSION_MODEL_CONFIG_FILE);
47
+ }
48
+
49
+ function sha256Hex(bytes: Buffer): string {
50
+ return createHash('sha256').update(bytes).digest('hex');
51
+ }
52
+
53
+ async function revisionForPath(path: string): Promise<FusionModelConfigRevision> {
54
+ try {
55
+ const bytes = await readFile(path);
56
+ return { path, exists: true, sha256: sha256Hex(bytes) };
57
+ } catch (error) {
58
+ if (errorHasCode(error, 'ENOENT')) return { path, exists: false, sha256: null };
59
+ throw error;
60
+ }
61
+ }
62
+
63
+ function errorHasCode(error: unknown, code: string): boolean {
64
+ return isJsonObject(error) && error['code'] === code;
65
+ }
66
+
67
+ function keysOf(value: object): string[] {
68
+ return Object.keys(value).sort();
69
+ }
70
+
71
+ function assertClosed(record: JsonObject, expected: readonly string[], label: string): void {
72
+ const expectedSet = new Set(expected);
73
+ for (const key of Object.keys(record)) {
74
+ if (!expectedSet.has(key)) throw configError(`${label} contains unknown key ${key}`);
75
+ }
76
+ for (const key of expected) {
77
+ if (!Object.prototype.hasOwnProperty.call(record, key)) {
78
+ throw configError(`${label} is missing key ${key}`);
79
+ }
80
+ }
81
+ }
82
+
83
+ function configError(message: string): FusionError {
84
+ return new FusionError(message, { code: 'config_invalid', childCreated: false });
85
+ }
86
+
87
+ function requireSelection(value: unknown, label: string): FusionModelSelection {
88
+ if (typeof value !== 'string') throw configError(`${label} must be a string`);
89
+ if (value === CURRENT_MODEL_SELECTION) return value;
90
+ const trimmed = value.trim();
91
+ if (trimmed.length === 0) throw configError(`${label} must not be blank`);
92
+ if (trimmed !== value) throw configError(`${label} must not have surrounding whitespace`);
93
+ if (!trimmed.includes('/')) throw configError(`${label} must be a qualified provider/model key`);
94
+ return trimmed;
95
+ }
96
+
97
+ function requireCandidateSelections(
98
+ value: unknown,
99
+ ): [FusionModelSelection, FusionModelSelection, FusionModelSelection] {
100
+ if (!Array.isArray(value)) throw configError('candidates must be an array');
101
+ if (value.length !== 3) throw configError('candidates must contain exactly three entries');
102
+ const first = requireSelection(value[0], 'candidates[0]');
103
+ const second = requireSelection(value[1], 'candidates[1]');
104
+ const third = requireSelection(value[2], 'candidates[2]');
105
+ return [first, second, third];
106
+ }
107
+
108
+ export function parseFusionModelConfig(value: unknown): FusionModelConfigV1 {
109
+ if (!isJsonObject(value) || Array.isArray(value))
110
+ throw configError('fusion model config must be an object');
111
+ const record: JsonObject = value;
112
+ assertClosed(
113
+ record,
114
+ ['schema_version', 'candidates', 'evaluator', 'merger'],
115
+ 'fusion model config',
116
+ );
117
+ if (record['schema_version'] !== FUSION_MODEL_CONFIG_SCHEMA_VERSION) {
118
+ throw configError('fusion model config schema_version mismatch');
119
+ }
120
+ return {
121
+ schema_version: FUSION_MODEL_CONFIG_SCHEMA_VERSION,
122
+ candidates: requireCandidateSelections(record['candidates']),
123
+ evaluator: requireSelection(record['evaluator'], 'evaluator'),
124
+ merger: requireSelection(record['merger'], 'merger'),
125
+ };
126
+ }
127
+
128
+ export async function loadFusionModelConfig(
129
+ path = fusionModelConfigPath(),
130
+ ): Promise<LoadedFusionModelConfig> {
131
+ const revision = await revisionForPath(path);
132
+ if (!revision.exists) return { config: defaultFusionModelConfig(), revision };
133
+ let parsed: unknown;
134
+ try {
135
+ parsed = parseJsonText(await readFile(path, 'utf8'));
136
+ } catch (error) {
137
+ throw configError(
138
+ `fusion model config is not valid JSON at ${path}: ${error instanceof Error ? error.message : String(error)}`,
139
+ );
140
+ }
141
+ const config = parseFusionModelConfig(parsed);
142
+ return { config, revision };
143
+ }
144
+
145
+ function qualifiedModelKey(model: Pick<Model<Api>, 'provider' | 'id'>): string {
146
+ return `${model.provider}/${model.id}`;
147
+ }
148
+
149
+ function requireContextWindow(model: Model<Api>, label: string): number {
150
+ const value = model.contextWindow;
151
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
152
+ throw new FusionError(`${label} has no positive context window`, {
153
+ code: 'model_unavailable',
154
+ childCreated: false,
155
+ });
156
+ }
157
+ return Math.floor(value);
158
+ }
159
+
160
+ function modelIndex(models: readonly Model<Api>[]): Map<string, Model<Api>> {
161
+ const out = new Map<string, Model<Api>>();
162
+ for (const model of models) out.set(qualifiedModelKey(model), model);
163
+ return out;
164
+ }
165
+
166
+ function resolveSelection(
167
+ selection: FusionModelSelection,
168
+ slotLabel: string,
169
+ availableByKey: Map<string, Model<Api>>,
170
+ currentModel: Model<Api> | undefined,
171
+ thinkingLevel: FusionThinkingLevel,
172
+ ): ResolvedFusionModel {
173
+ if (selection === CURRENT_MODEL_SELECTION) {
174
+ if (currentModel === undefined) {
175
+ throw new FusionError(`${slotLabel} uses $current but Pi has no current model`, {
176
+ code: 'model_unavailable',
177
+ childCreated: false,
178
+ });
179
+ }
180
+ const qualifiedId = qualifiedModelKey(currentModel);
181
+ const available = availableByKey.get(qualifiedId);
182
+ if (available === undefined) {
183
+ throw new FusionError(
184
+ `${slotLabel} current model is not available to child Pi: ${qualifiedId}`,
185
+ {
186
+ code: 'model_unavailable',
187
+ childCreated: false,
188
+ },
189
+ );
190
+ }
191
+ return {
192
+ selection,
193
+ source: 'current',
194
+ provider: available.provider,
195
+ model: available.id,
196
+ qualifiedId,
197
+ thinkingLevel,
198
+ contextWindow: requireContextWindow(available, slotLabel),
199
+ };
200
+ }
201
+ const model = availableByKey.get(selection);
202
+ if (model === undefined) {
203
+ throw new FusionError(`${slotLabel} configured model is unavailable: ${selection}`, {
204
+ code: 'model_unavailable',
205
+ childCreated: false,
206
+ });
207
+ }
208
+ return {
209
+ selection,
210
+ source: 'configured',
211
+ provider: model.provider,
212
+ model: model.id,
213
+ qualifiedId: selection,
214
+ thinkingLevel,
215
+ contextWindow: requireContextWindow(model, slotLabel),
216
+ };
217
+ }
218
+
219
+ export function resolveFusionModels(input: ResolveFusionModelsInput): ResolvedFusionModels {
220
+ const availableByKey = modelIndex(input.modelRegistry.getAvailable());
221
+ 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',
256
+ availableByKey,
257
+ input.currentModel,
258
+ input.thinkingLevel,
259
+ ),
260
+ };
261
+ }
262
+
263
+ async function fsyncFile(path: string): Promise<void> {
264
+ const handle = await open(path, 'r');
265
+ try {
266
+ await handle.sync();
267
+ } finally {
268
+ await handle.close();
269
+ }
270
+ }
271
+
272
+ async function fsyncDirectory(path: string): Promise<void> {
273
+ if (process.platform === 'win32') return;
274
+ const fd = openSync(path, 'r');
275
+ try {
276
+ fsyncSync(fd);
277
+ } finally {
278
+ closeSync(fd);
279
+ }
280
+ }
281
+
282
+ async function delay(ms: number): Promise<void> {
283
+ await new Promise((resolve) => setTimeout(resolve, ms));
284
+ }
285
+
286
+ async function withConfigLock<T>(path: string, fn: () => Promise<T>): Promise<T> {
287
+ const dir = dirname(path);
288
+ const lockPath = join(dir, `.${basename(path)}.lock`);
289
+ const started = Date.now();
290
+ let handle: Awaited<ReturnType<typeof open>> | undefined;
291
+ while (handle === undefined) {
292
+ try {
293
+ handle = await open(lockPath, 'wx', 0o600);
294
+ } catch (error) {
295
+ if (!errorHasCode(error, 'EEXIST')) throw error;
296
+ if (Date.now() - started > 10_000) {
297
+ throw new FusionError(`timed out waiting for fusion model config lock: ${path}`, {
298
+ code: 'config_conflict',
299
+ childCreated: false,
300
+ });
301
+ }
302
+ await delay(25);
303
+ }
304
+ }
305
+ try {
306
+ await handle.writeFile(`${String(process.pid)}\n`);
307
+ await handle.sync();
308
+ return await fn();
309
+ } finally {
310
+ await handle.close();
311
+ await rm(lockPath, { force: true });
312
+ }
313
+ }
314
+
315
+ function prettyConfig(config: FusionModelConfigV1): string {
316
+ const sorted = {
317
+ schema_version: config.schema_version,
318
+ candidates: [...config.candidates],
319
+ evaluator: config.evaluator,
320
+ merger: config.merger,
321
+ };
322
+ return `${JSON.stringify(sorted, null, 2)}\n`;
323
+ }
324
+
325
+ function revisionsMatch(
326
+ expected: FusionModelConfigRevision,
327
+ current: FusionModelConfigRevision,
328
+ ): boolean {
329
+ if (expected.path !== current.path) return false;
330
+ if (expected.exists !== current.exists) return false;
331
+ return expected.sha256 === current.sha256;
332
+ }
333
+
334
+ export async function saveFusionModelConfig(
335
+ path: string,
336
+ config: FusionModelConfigV1,
337
+ expectedRevision: FusionModelConfigRevision,
338
+ ): Promise<FusionModelConfigRevision> {
339
+ parseFusionModelConfig(config);
340
+ const dir = dirname(path);
341
+ await mkdir(dir, { recursive: true, mode: 0o700 });
342
+ await chmod(dir, 0o700);
343
+ return withConfigLock(path, async () => {
344
+ const current = await revisionForPath(path);
345
+ if (!revisionsMatch(expectedRevision, current)) {
346
+ throw new FusionError(`fusion model config changed on disk: ${path}`, {
347
+ code: 'config_conflict',
348
+ childCreated: false,
349
+ });
350
+ }
351
+ const tmp = join(
352
+ dir,
353
+ `.${basename(path)}.${String(process.pid)}.${randomBytes(6).toString('hex')}.tmp`,
354
+ );
355
+ const text = prettyConfig(config);
356
+ try {
357
+ await writeFile(tmp, text, { encoding: 'utf8', mode: 0o600 });
358
+ await fsyncFile(tmp);
359
+ renameSync(tmp, path);
360
+ await fsyncDirectory(dir);
361
+ } catch (error) {
362
+ await rm(tmp, { force: true });
363
+ throw error;
364
+ }
365
+ return revisionForPath(path);
366
+ });
367
+ }
368
+
369
+ export function describeFusionModelConfig(config: FusionModelConfigV1): string {
370
+ return keysOf(config).join(', ');
371
+ }
@@ -0,0 +1,179 @@
1
+ import {
2
+ buildSessionContext,
3
+ convertToLlm,
4
+ type SessionEntry,
5
+ } from '@earendil-works/pi-coding-agent';
6
+ import type { Message } from '@earendil-works/pi-ai';
7
+ import { canonicalJson } from '../attested-pi-run.js';
8
+ import { isJsonObject, type JsonObject } from '../common.js';
9
+ import {
10
+ FUSION_INPUT_SCHEMA_VERSION,
11
+ FusionError,
12
+ type FusionCanonicalInputV1,
13
+ type FusionSource,
14
+ } from './types.js';
15
+
16
+ export const FUSION_BRAINSTORM_TOOL_NAME = 'fusion_brainstorm';
17
+
18
+ export interface FusionReadonlySessionManager {
19
+ getLeafId(): string | null;
20
+ getLeafEntry(): SessionEntry | undefined;
21
+ getEntries(): SessionEntry[];
22
+ }
23
+
24
+ export interface FusionContextSource {
25
+ cwd: string;
26
+ sessionManager: FusionReadonlySessionManager;
27
+ getSystemPrompt(): string;
28
+ }
29
+
30
+ export interface BuildFusionCanonicalInputOptions {
31
+ source: FusionSource;
32
+ request: string;
33
+ toolCallId?: string;
34
+ toolName?: string;
35
+ }
36
+
37
+ export interface BuiltFusionCanonicalInput {
38
+ input: FusionCanonicalInputV1;
39
+ serialized: string;
40
+ transcriptLeafId: string | null;
41
+ }
42
+
43
+ export function normalizeFusionCommandRequest(args: string): string {
44
+ return args.trim();
45
+ }
46
+
47
+ function entriesById(entries: readonly SessionEntry[]): Map<string, SessionEntry> {
48
+ const byId = new Map<string, SessionEntry>();
49
+ for (const entry of entries) byId.set(entry.id, entry);
50
+ return byId;
51
+ }
52
+
53
+ function readArray(record: JsonObject, key: string): readonly unknown[] | undefined {
54
+ const value = record[key];
55
+ return Array.isArray(value) ? value : undefined;
56
+ }
57
+
58
+ function recordOf(value: unknown): JsonObject | undefined {
59
+ if (!isJsonObject(value) || Array.isArray(value)) return undefined;
60
+ return value;
61
+ }
62
+
63
+ function entryMessage(entry: SessionEntry): JsonObject | undefined {
64
+ if (entry.type !== 'message') return undefined;
65
+ return recordOf(entry.message);
66
+ }
67
+
68
+ function toolCallPartMatches(
69
+ part: unknown,
70
+ toolCallId: string | undefined,
71
+ toolName: string,
72
+ ): boolean {
73
+ const record = recordOf(part);
74
+ if (record === undefined || record['type'] !== 'toolCall') return false;
75
+ if (toolCallId !== undefined) return record['id'] === toolCallId;
76
+ return record['name'] === toolName;
77
+ }
78
+
79
+ function messageContainsToolCall(
80
+ message: JsonObject,
81
+ toolCallId: string | undefined,
82
+ toolName: string,
83
+ ): boolean {
84
+ if (message['role'] !== 'assistant') return false;
85
+ const content = readArray(message, 'content');
86
+ if (content === undefined) return false;
87
+ for (const part of content) {
88
+ if (toolCallPartMatches(part, toolCallId, toolName)) return true;
89
+ }
90
+ return false;
91
+ }
92
+
93
+ function effectiveLeafForTool(
94
+ sessionManager: FusionReadonlySessionManager,
95
+ toolCallId: string | undefined,
96
+ toolName: string,
97
+ ): string | null {
98
+ const leaf = sessionManager.getLeafEntry();
99
+ if (leaf === undefined) return sessionManager.getLeafId();
100
+ const message = entryMessage(leaf);
101
+ if (message !== undefined && messageContainsToolCall(message, toolCallId, toolName)) {
102
+ return leaf.parentId;
103
+ }
104
+ return sessionManager.getLeafId();
105
+ }
106
+
107
+ function effectiveLeafId(
108
+ sessionManager: FusionReadonlySessionManager,
109
+ options: BuildFusionCanonicalInputOptions,
110
+ ): string | null {
111
+ if (options.source !== 'tool') return sessionManager.getLeafId();
112
+ return effectiveLeafForTool(
113
+ sessionManager,
114
+ options.toolCallId,
115
+ options.toolName ?? FUSION_BRAINSTORM_TOOL_NAME,
116
+ );
117
+ }
118
+
119
+ function textContentForTranscript(content: Message['content']): string {
120
+ if (typeof content === 'string') return content;
121
+ const parts: string[] = [];
122
+ for (const block of content) {
123
+ if (block.type === 'text') parts.push(block.text);
124
+ else if (block.type === 'image')
125
+ parts.push(`[Image omitted from fusion text transcript: ${block.mimeType}]`);
126
+ }
127
+ return parts.join('');
128
+ }
129
+
130
+ function serializeFusionConversation(messages: readonly Message[]): string {
131
+ const parts: string[] = [];
132
+ for (const message of messages) {
133
+ if (message.role === 'user') {
134
+ const content = textContentForTranscript(message.content);
135
+ if (content.length > 0) parts.push(`[User]: ${content}`);
136
+ } else if (message.role === 'assistant') {
137
+ const thinkingParts: string[] = [];
138
+ const textParts: string[] = [];
139
+ const toolCalls: string[] = [];
140
+ for (const block of message.content) {
141
+ if (block.type === 'thinking') thinkingParts.push(block.thinking);
142
+ else if (block.type === 'text') textParts.push(block.text);
143
+ else if (block.type === 'toolCall')
144
+ toolCalls.push(`${block.name}(${canonicalJson(block.arguments)})`);
145
+ }
146
+ if (thinkingParts.length > 0) parts.push(`[Assistant thinking]: ${thinkingParts.join('\n')}`);
147
+ if (textParts.length > 0) parts.push(`[Assistant]: ${textParts.join('\n')}`);
148
+ if (toolCalls.length > 0) parts.push(`[Assistant tool calls]: ${toolCalls.join('; ')}`);
149
+ } else {
150
+ const content = textContentForTranscript(message.content);
151
+ if (content.length > 0) parts.push(`[Tool result]: ${content}`);
152
+ }
153
+ }
154
+ return parts.join('\n\n');
155
+ }
156
+
157
+ export function buildFusionCanonicalInput(
158
+ ctx: FusionContextSource,
159
+ options: BuildFusionCanonicalInputOptions,
160
+ ): BuiltFusionCanonicalInput {
161
+ if (options.request.trim().length === 0) {
162
+ throw new FusionError('fusion request must not be blank', {
163
+ code: 'context_capture_failed',
164
+ childCreated: false,
165
+ });
166
+ }
167
+ const entries = ctx.sessionManager.getEntries();
168
+ const leafId = effectiveLeafId(ctx.sessionManager, options);
169
+ const sessionContext = buildSessionContext(entries, leafId, entriesById(entries));
170
+ const llmMessages = convertToLlm(sessionContext.messages);
171
+ const input: FusionCanonicalInputV1 = {
172
+ schema_version: FUSION_INPUT_SCHEMA_VERSION,
173
+ cwd: ctx.cwd,
174
+ system_prompt: ctx.getSystemPrompt(),
175
+ conversation_transcript: serializeFusionConversation(llmMessages),
176
+ request: options.request,
177
+ };
178
+ return { input, serialized: canonicalJson(input), transcriptLeafId: leafId };
179
+ }