@herbertgao/sol-pi 0.1.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 (35) hide show
  1. package/LICENSE +19 -0
  2. package/README.md +159 -0
  3. package/SECURITY.md +26 -0
  4. package/THIRD_PARTY_NOTICES.md +19 -0
  5. package/agents-install.md +150 -0
  6. package/assets/sol-pi-hero.png +0 -0
  7. package/docs/compatibility.md +67 -0
  8. package/docs/configuration.md +75 -0
  9. package/package.json +76 -0
  10. package/scripts/check-pi-compat.mjs +32 -0
  11. package/scripts/check-sol-pi-config.mjs +120 -0
  12. package/sol-pi.example.json +10 -0
  13. package/src/sol-pi/config.ts +135 -0
  14. package/src/sol-pi/extensions/action-fusion/file-queue.ts +71 -0
  15. package/src/sol-pi/extensions/action-fusion/index.ts +185 -0
  16. package/src/sol-pi/extensions/action-fusion/then-run.ts +128 -0
  17. package/src/sol-pi/extensions/evidence-preserving-reducer/archive.ts +53 -0
  18. package/src/sol-pi/extensions/evidence-preserving-reducer/candidate.ts +101 -0
  19. package/src/sol-pi/extensions/evidence-preserving-reducer/config.ts +71 -0
  20. package/src/sol-pi/extensions/evidence-preserving-reducer/index.ts +220 -0
  21. package/src/sol-pi/extensions/evidence-preserving-reducer/journal.ts +25 -0
  22. package/src/sol-pi/extensions/evidence-preserving-reducer/provider.ts +164 -0
  23. package/src/sol-pi/extensions/evidence-preserving-reducer/receipt.ts +177 -0
  24. package/src/sol-pi/extensions/observation-pack/index.ts +227 -0
  25. package/src/sol-pi/extensions/observation-pack/ledger.ts +20 -0
  26. package/src/sol-pi/extensions/observation-pack/observation.ts +252 -0
  27. package/src/sol-pi/extensions/online-context-compact/economics.ts +237 -0
  28. package/src/sol-pi/extensions/online-context-compact/extension.ts +455 -0
  29. package/src/sol-pi/extensions/online-context-compact/index.ts +49 -0
  30. package/src/sol-pi/extensions/online-context-compact/plan.ts +79 -0
  31. package/src/sol-pi/extensions/online-context-compact/state.ts +208 -0
  32. package/src/sol-pi/extensions/online-context-compact/tools.ts +100 -0
  33. package/src/sol-pi/index.ts +42 -0
  34. package/src/sol-pi/runtime-paths.ts +17 -0
  35. package/src/sol-pi/tui.ts +71 -0
@@ -0,0 +1,53 @@
1
+ /*
2
+ * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+ import { isRecord, type ReducerConfig, sha256 } from "./config.ts";
8
+
9
+ export interface ArchiveObject {
10
+ readonly hash: string;
11
+ readonly bytes: number;
12
+ readonly chars: number;
13
+ readonly lines: number;
14
+ readonly path: string;
15
+ }
16
+
17
+ /**
18
+ * Archived logs live under SoL-Pi's session-derived runtime directory.
19
+ */
20
+ export function archiveRoot(config: ReducerConfig): string {
21
+ return config.storeRoot;
22
+ }
23
+
24
+ /**
25
+ * Store the raw log under its own content hash.
26
+ *
27
+ * Every quote in a receipt is checked against this archive, and the receipt
28
+ * points the frontier agent back at this path for exact readback. An existing
29
+ * object with the same name but different bytes is an integrity failure, not a
30
+ * cache hit.
31
+ */
32
+ export async function archiveBody(root: string, body: string): Promise<ArchiveObject> {
33
+ const hash = sha256(body);
34
+ const objectDir = join(root, "objects", hash.slice(0, 2));
35
+ const path = join(objectDir, `${hash}.txt`);
36
+ await mkdir(objectDir, { recursive: true, mode: 0o700 });
37
+ try {
38
+ await writeFile(path, body, { encoding: "utf8", flag: "wx", mode: 0o600 });
39
+ } catch (error) {
40
+ if (!isRecord(error) || error.code !== "EEXIST") throw error;
41
+ const existing = await readFile(path, "utf8");
42
+ if (existing !== body || sha256(existing) !== hash) {
43
+ throw new Error(`Reducer archive integrity failure: ${path}`, { cause: error });
44
+ }
45
+ }
46
+ return {
47
+ hash,
48
+ bytes: Buffer.byteLength(body, "utf8"),
49
+ chars: body.length,
50
+ lines: body.length === 0 ? 0 : body.split("\n").length,
51
+ path,
52
+ };
53
+ }
@@ -0,0 +1,101 @@
1
+ /*
2
+ * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+ import { lstat, readFile, realpath } from "node:fs/promises";
6
+ import { tmpdir } from "node:os";
7
+ import { basename, dirname } from "node:path";
8
+ import type { ToolResultEvent } from "@earendil-works/pi-coding-agent";
9
+ import { recordValue } from "./config.ts";
10
+
11
+ /** Markers written by the action-fusion extension around a fused command's output. */
12
+ const THEN_RUN_SUCCEEDED = "[then_run:succeeded]";
13
+ const THEN_RUN_FAILED = "[then_run:failed]";
14
+
15
+ export interface ReducibleToolResult {
16
+ readonly command: string;
17
+ readonly body: string;
18
+ /** Put the receipt back where the raw output was, leaving the rest of the result alone. */
19
+ readonly projectReceipt: (receipt: string) => ToolResultEvent["content"];
20
+ }
21
+
22
+ function textContent(event: ToolResultEvent): string {
23
+ return event.content
24
+ .filter((item): item is { type: "text"; text: string } => item.type === "text")
25
+ .map((item) => item.text)
26
+ .join("\n");
27
+ }
28
+
29
+ export function detailsFullOutputPath(details: unknown): string | undefined {
30
+ const value = recordValue(details, "fullOutputPath");
31
+ return typeof value === "string" ? value : undefined;
32
+ }
33
+
34
+ async function safePiBashTempPath(path: string | undefined): Promise<boolean> {
35
+ if (!path || !/^pi-bash-[^/\\]+\.log$/u.test(basename(path))) return false;
36
+ try {
37
+ const [candidate, root, status] = await Promise.all([realpath(path), realpath(tmpdir()), lstat(path)]);
38
+ return status.isFile() && !status.isSymbolicLink() && dirname(candidate) === root;
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Prefer the untruncated file pi wrote for a large bash result, so evidence is
46
+ * checked against the exact bytes the command produced rather than a preview.
47
+ */
48
+ async function exactBodyFromInline(inline: string, details: unknown): Promise<string> {
49
+ const detailsPath = detailsFullOutputPath(details);
50
+ const inlineMatch = inline.match(/Full output:\s*([^\]\r\n]+)/u);
51
+ const candidate = detailsPath ?? inlineMatch?.[1]?.trim();
52
+ if (!candidate || !(await safePiBashTempPath(candidate))) return inline;
53
+ try {
54
+ return await readFile(candidate, "utf8");
55
+ } catch {
56
+ return inline;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Identify the log inside a tool result: either a plain bash result, or the
62
+ * command output appended by a fused `edit`/`write` call.
63
+ */
64
+ export async function reducibleToolResult(event: ToolResultEvent): Promise<ReducibleToolResult | undefined> {
65
+ if (event.toolName === "bash") {
66
+ const command = typeof event.input.command === "string" ? event.input.command : "";
67
+ if (!command) return undefined;
68
+ const inline = textContent(event);
69
+ return {
70
+ command,
71
+ body: await exactBodyFromInline(inline, event.details),
72
+ projectReceipt: (receipt) => [{ type: "text", text: receipt }],
73
+ };
74
+ }
75
+ if (event.toolName !== "write" && event.toolName !== "edit") return undefined;
76
+ const thenRun = recordValue(event.input, "then_run");
77
+ const commandValue = recordValue(thenRun, "command");
78
+ if (typeof commandValue !== "string" || !commandValue) return undefined;
79
+ const marker = event.isError ? THEN_RUN_FAILED : THEN_RUN_SUCCEEDED;
80
+ for (let index = 0; index < event.content.length; index++) {
81
+ const block = event.content[index];
82
+ if (!block || block.type !== "text") continue;
83
+ const markerIndex = block.text.indexOf(marker);
84
+ if (markerIndex < 0) continue;
85
+ const suffixStart = markerIndex + marker.length;
86
+ const suffix = block.text.slice(suffixStart);
87
+ const separator = suffix.match(/^(?:\r?\n)+/u)?.[0] ?? "\n";
88
+ const inline = suffix.slice(separator === "\n" && !suffix.startsWith("\n") ? 0 : separator.length);
89
+ return {
90
+ command: commandValue,
91
+ body: await exactBodyFromInline(inline, event.details),
92
+ projectReceipt: (receipt) =>
93
+ event.content.map((content, contentIndex) =>
94
+ contentIndex === index && content.type === "text"
95
+ ? { ...content, text: `${content.text.slice(0, suffixStart)}${separator}${receipt}` }
96
+ : content,
97
+ ),
98
+ };
99
+ }
100
+ return undefined;
101
+ }
@@ -0,0 +1,71 @@
1
+ /*
2
+ * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+
6
+ import { createHash } from "node:crypto";
7
+ import { join } from "node:path";
8
+
9
+ export const REDUCER_EVENT_TYPE = "sol-pi-evidence-preserving-reducer-v1" as const;
10
+ export const REDUCER_EVENT_SCHEMA = "sol-pi-evidence-preserving-reducer/1" as const;
11
+ export const REDUCER_RECEIPT_SCHEMA = "sol-pi-evidence-receipt/1" as const;
12
+ export const REDUCER_RECEIPT_PREFIX = "sol_pi_evidence_receipt_v1" as const;
13
+
14
+ export const MAX_EVIDENCE_ITEMS = 12;
15
+ export const MAX_QUOTE_CHARS = 600;
16
+
17
+ const DEFAULT_MIN_BYTES = 4_096;
18
+ const DEFAULT_MAX_CHARS = 600_000;
19
+ const DEFAULT_MAX_OUTPUT_TOKENS = 2_048;
20
+ const DEFAULT_TIMEOUT_MS = 90_000;
21
+
22
+ export const DEFAULT_REDUCER_PROVIDER = ["openai", "codex"].join("-");
23
+ export const DEFAULT_REDUCER_MODEL = ["gpt-5.6", "luna"].join("-");
24
+
25
+ export const DIAGNOSTIC_COMMAND =
26
+ /(?:^|[;&|()\s])(?:lake\s+build|lake\s+env\s+lean|lean|coq|cargo(?:\s+(?:build|test|check))?|zig\s+build|pytest|python(?:3)?\s+-m\s+(?:pytest|unittest|py_compile)|ctest|cmake\s+--build|ninja|make|npm\s+test|pnpm\s+test|yarn\s+test|go\s+test|bazel\s+test)(?:\s|$)/i;
27
+
28
+ export const FAILURE_SIGNAL = /error|failed|failure|fatal|exception|panic|timeout|unsolved|type mismatch|assert/i;
29
+ export const LIKELY_SECRET = /(?:api[_-]?key|authorization|bearer|access[_-]?token|secret)[^\n]{0,32}[=:][^\n]+/i;
30
+
31
+ export interface ReducerConfig {
32
+ readonly maxChars: number;
33
+ readonly maxOutputTokens: number;
34
+ readonly minBytes: number;
35
+ readonly reducerModel: string;
36
+ readonly reducerProvider: string;
37
+ readonly runId: string;
38
+ readonly storeRoot: string;
39
+ readonly timeoutMs: number;
40
+ }
41
+
42
+ export interface ReducerConfigOptions {
43
+ readonly reducerModel?: string;
44
+ readonly reducerProvider?: string;
45
+ }
46
+
47
+ export function sha256(value: string): string {
48
+ return createHash("sha256").update(value, "utf8").digest("hex");
49
+ }
50
+
51
+ export function isRecord(value: unknown): value is Record<string, unknown> {
52
+ return typeof value === "object" && value !== null && !Array.isArray(value);
53
+ }
54
+
55
+ // pi-lens-ignore: no-unknown-returns
56
+ export function recordValue(value: unknown, key: string): unknown {
57
+ return isRecord(value) ? value[key] : undefined;
58
+ }
59
+
60
+ export function loadReducerConfig(runtimeDirectory: string, options: ReducerConfigOptions = {}): ReducerConfig {
61
+ return Object.freeze({
62
+ maxChars: DEFAULT_MAX_CHARS,
63
+ maxOutputTokens: DEFAULT_MAX_OUTPUT_TOKENS,
64
+ minBytes: DEFAULT_MIN_BYTES,
65
+ reducerModel: options.reducerModel ?? DEFAULT_REDUCER_MODEL,
66
+ reducerProvider: options.reducerProvider ?? DEFAULT_REDUCER_PROVIDER,
67
+ runId: sha256(runtimeDirectory).slice(0, 16),
68
+ storeRoot: join(runtimeDirectory, "evidence-preserving-reducer"),
69
+ timeoutMs: DEFAULT_TIMEOUT_MS,
70
+ });
71
+ }
@@ -0,0 +1,220 @@
1
+ /*
2
+ * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+ /**
6
+ * Evidence-Preserving Reducer - delegate the first read of a long build or test
7
+ * log to the configured reducer model, then verify what comes back.
8
+ *
9
+ * In build and test trajectories only a few lines of a long log change the next
10
+ * decision. This extension archives the raw log, sends it through the reducer
11
+ * provider/model selected by the top-level SoL-Pi config, and accepts the
12
+ * resulting receipt only when every quoted line is found byte for byte in the
13
+ * archive. A receipt that cannot be checked is discarded and the original
14
+ * output reaches the frontier agent untouched.
15
+ *
16
+ * Delegation therefore never requires trusting a fluent summary.
17
+ *
18
+ * The top-level SoL-Pi config enables this mechanism. Provider selection and
19
+ * authentication remain with Pi; storage and run identity come from the session.
20
+ */
21
+
22
+ import type {
23
+ ExtensionAPI,
24
+ ExtensionContext,
25
+ ExtensionFactory,
26
+ ToolResultEvent,
27
+ } from "@earendil-works/pi-coding-agent";
28
+ import { runtimeRoot } from "../../runtime-paths.ts";
29
+ import { formatSavingsBytes, showSolPiSavings } from "../../tui.ts";
30
+ import { archiveBody, archiveRoot } from "./archive.ts";
31
+ import { reducibleToolResult } from "./candidate.ts";
32
+ import {
33
+ DIAGNOSTIC_COMMAND,
34
+ isRecord,
35
+ LIKELY_SECRET,
36
+ loadReducerConfig,
37
+ REDUCER_RECEIPT_SCHEMA,
38
+ type ReducerConfig,
39
+ type ReducerConfigOptions,
40
+ sha256,
41
+ } from "./config.ts";
42
+ import { createJournal, type Journal } from "./journal.ts";
43
+ import { callReducer, type ProviderResult } from "./provider.ts";
44
+ import { receiptText, validateReceipt } from "./receipt.ts";
45
+
46
+ export interface ReducedToolResult {
47
+ readonly content: ToolResultEvent["content"];
48
+ readonly details: Record<string, unknown>;
49
+ readonly isError: boolean;
50
+ }
51
+
52
+ export type EvidencePreservingReducerOptions = ReducerConfigOptions;
53
+
54
+ function errorName(error: unknown): string | undefined {
55
+ return isRecord(error) && typeof error.name === "string" ? error.name : undefined;
56
+ }
57
+
58
+ export async function reduceToolResult(
59
+ journal: Journal,
60
+ config: ReducerConfig,
61
+ event: ToolResultEvent,
62
+ context: ExtensionContext,
63
+ ): Promise<ReducedToolResult | undefined> {
64
+ const reducible = await reducibleToolResult(event);
65
+ if (!reducible || !DIAGNOSTIC_COMMAND.test(reducible.command)) return undefined;
66
+ const { body, command } = reducible;
67
+ if (Buffer.byteLength(body, "utf8") < config.minBytes) return undefined;
68
+ if (body.length > config.maxChars) {
69
+ journal("fallback", { reason: "source-over-max-chars", sourceChars: body.length, maxChars: config.maxChars });
70
+ return undefined;
71
+ }
72
+ if (LIKELY_SECRET.test(body)) {
73
+ journal("fallback", { reason: "likely-secret" });
74
+ return undefined;
75
+ }
76
+
77
+ const archive = await archiveBody(archiveRoot(config), body);
78
+ journal("candidate", {
79
+ toolCallId: event.toolCallId,
80
+ commandSha256: sha256(command),
81
+ isError: event.isError,
82
+ sourceSha256: archive.hash,
83
+ sourceBytes: archive.bytes,
84
+ sourceLines: archive.lines,
85
+ sourcePath: archive.path,
86
+ });
87
+
88
+ let provider: ProviderResult;
89
+ try {
90
+ provider = await callReducer(config, command, event.isError, archive, body, context);
91
+ } catch (error) {
92
+ const name = errorName(error);
93
+ journal("fallback", {
94
+ toolCallId: event.toolCallId,
95
+ sourceSha256: archive.hash,
96
+ reason:
97
+ name === "AbortError"
98
+ ? "model-call-timeout"
99
+ : name === "ReducerModelUnavailableError"
100
+ ? "reducer-model-unavailable"
101
+ : "model-call-exception",
102
+ });
103
+ return undefined;
104
+ }
105
+
106
+ journal("provider_response", {
107
+ toolCallId: event.toolCallId,
108
+ sourceSha256: archive.hash,
109
+ provider: provider.provider,
110
+ model: provider.model,
111
+ stopReason: provider.stopReason,
112
+ errorMessage: provider.errorMessage,
113
+ usage: provider.usage,
114
+ });
115
+ if (!provider.ok) {
116
+ journal("fallback", {
117
+ toolCallId: event.toolCallId,
118
+ sourceSha256: archive.hash,
119
+ reason: "model-response-error",
120
+ stopReason: provider.stopReason,
121
+ errorMessage: provider.errorMessage,
122
+ });
123
+ return undefined;
124
+ }
125
+
126
+ const checked = validateReceipt(provider.outputText, archive, body, event.isError);
127
+ if (!checked.ok) {
128
+ journal("fallback", {
129
+ toolCallId: event.toolCallId,
130
+ sourceSha256: archive.hash,
131
+ reason: checked.reason,
132
+ usage: provider.usage,
133
+ });
134
+ return undefined;
135
+ }
136
+ const receipt = receiptText(command, archive, checked.value, provider);
137
+ const receiptBytes = Buffer.byteLength(receipt, "utf8");
138
+ if (receiptBytes >= archive.bytes) {
139
+ journal("fallback", {
140
+ toolCallId: event.toolCallId,
141
+ sourceSha256: archive.hash,
142
+ reason: "receipt-not-smaller",
143
+ receiptBytes,
144
+ sourceBytes: archive.bytes,
145
+ usage: provider.usage,
146
+ });
147
+ return undefined;
148
+ }
149
+ journal("applied", {
150
+ toolCallId: event.toolCallId,
151
+ commandSha256: sha256(command),
152
+ sourceSha256: archive.hash,
153
+ sourceBytes: archive.bytes,
154
+ receiptSha256: sha256(receipt),
155
+ receiptBytes,
156
+ evidenceCount: checked.value.evidence.length,
157
+ uncertain: checked.value.uncertain,
158
+ usage: provider.usage,
159
+ });
160
+ showSolPiSavings(context, "Luna Delegating", formatSavingsBytes(archive.bytes - receiptBytes));
161
+ return {
162
+ content: reducible.projectReceipt(receipt),
163
+ isError: event.isError,
164
+ details: {
165
+ ...(isRecord(event.details) ? event.details : {}),
166
+ evidencePreservingReducer: {
167
+ schema: REDUCER_RECEIPT_SCHEMA,
168
+ sourceSha256: archive.hash,
169
+ sourceBytes: archive.bytes,
170
+ receiptSha256: sha256(receipt),
171
+ receiptBytes,
172
+ evidenceCount: checked.value.evidence.length,
173
+ uncertain: checked.value.uncertain,
174
+ },
175
+ },
176
+ };
177
+ }
178
+
179
+ export function createEvidencePreservingReducerExtension(options: EvidencePreservingReducerOptions = {}): ExtensionFactory {
180
+ return (pi: ExtensionAPI) => {
181
+ const states = new Map<string, { config: ReducerConfig; journal: Journal }>();
182
+ pi.on("tool_result", (event, context) => {
183
+ let root: string;
184
+ try {
185
+ root = runtimeRoot(context);
186
+ } catch {
187
+ return undefined;
188
+ }
189
+ let state = states.get(root);
190
+ if (!state) {
191
+ const config = loadReducerConfig(root, options);
192
+ state = { config, journal: createJournal(pi, config) };
193
+ states.set(root, state);
194
+ }
195
+ return reduceToolResult(state.journal, state.config, event, context);
196
+ });
197
+ };
198
+ }
199
+
200
+ export type { ArchiveObject } from "./archive.ts";
201
+ export {
202
+ DIAGNOSTIC_COMMAND,
203
+ loadReducerConfig,
204
+ REDUCER_EVENT_SCHEMA,
205
+ REDUCER_EVENT_TYPE,
206
+ REDUCER_RECEIPT_PREFIX,
207
+ REDUCER_RECEIPT_SCHEMA,
208
+ type ReducerConfigOptions,
209
+ type ReducerConfig,
210
+ } from "./config.ts";
211
+ export { validateReceipt } from "./receipt.ts";
212
+
213
+ export function registerEvidencePreservingReducer(
214
+ pi: ExtensionAPI,
215
+ options: EvidencePreservingReducerOptions = {},
216
+ ): void {
217
+ createEvidencePreservingReducerExtension(options)(pi);
218
+ }
219
+
220
+ export default registerEvidencePreservingReducer;
@@ -0,0 +1,25 @@
1
+ /*
2
+ * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+ import { REDUCER_EVENT_SCHEMA, REDUCER_EVENT_TYPE, type ReducerConfig } from "./config.ts";
7
+
8
+ /**
9
+ * Append one non-context session entry per decision the reducer made.
10
+ *
11
+ * The entries never enter the LLM context. They record which results were
12
+ * candidates, which were delegated, and why each fallback happened.
13
+ */
14
+ export type Journal = (kind: string, data?: Record<string, unknown>) => void;
15
+
16
+ export function createJournal(pi: ExtensionAPI, config: ReducerConfig): Journal {
17
+ return (kind, data = {}) => {
18
+ pi.appendEntry(REDUCER_EVENT_TYPE, {
19
+ schema: REDUCER_EVENT_SCHEMA,
20
+ runId: config.runId,
21
+ kind,
22
+ ...data,
23
+ });
24
+ };
25
+ }
@@ -0,0 +1,164 @@
1
+ /*
2
+ * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+
6
+ import type { Api, AssistantMessage, Context, Model, ProviderStreamOptions } from "@earendil-works/pi-ai";
7
+ import { complete as completeCompat } from "@earendil-works/pi-ai/compat";
8
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
9
+ import type { ArchiveObject } from "./archive.ts";
10
+ import type { ReducerConfig } from "./config.ts";
11
+ import { reducerInput, reducerInstructions } from "./receipt.ts";
12
+
13
+ export type CompatComplete = typeof completeCompat;
14
+ type ResolvedCompatAuth =
15
+ | {
16
+ readonly ok: true;
17
+ readonly apiKey?: string;
18
+ readonly baseUrl?: string;
19
+ readonly env?: Record<string, string>;
20
+ readonly headers?: Record<string, string | null>;
21
+ }
22
+ | { readonly ok: false; readonly error: string };
23
+ type CompatibleModelRegistry = {
24
+ readonly find?: (provider: string, modelId: string) => Model<Api> | undefined;
25
+ readonly complete?: (
26
+ model: Model<Api>,
27
+ context: Context,
28
+ options?: ProviderStreamOptions,
29
+ ) => Promise<AssistantMessage>;
30
+ readonly getApiKeyAndHeaders: (model: Model<Api>) => Promise<ResolvedCompatAuth>;
31
+ };
32
+
33
+ export interface NormalizedUsage {
34
+ readonly input: number;
35
+ readonly output: number;
36
+ readonly cacheRead: number;
37
+ readonly cacheWrite: number;
38
+ readonly totalTokens: number;
39
+ }
40
+
41
+ export interface ProviderResult {
42
+ readonly errorMessage: string | undefined;
43
+ readonly model: string;
44
+ readonly ok: boolean;
45
+ readonly outputText: string;
46
+ readonly provider: string;
47
+ readonly stopReason: AssistantMessage["stopReason"];
48
+ readonly usage: NormalizedUsage;
49
+ }
50
+
51
+ export class ReducerModelUnavailableError extends Error {
52
+ override readonly name = "ReducerModelUnavailableError";
53
+ }
54
+
55
+ function responseOutputText(response: AssistantMessage): string {
56
+ return response.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("");
57
+ }
58
+
59
+ function normalizedUsage(response: AssistantMessage): NormalizedUsage {
60
+ return {
61
+ input: response.usage.input,
62
+ output: response.usage.output,
63
+ cacheRead: response.usage.cacheRead,
64
+ cacheWrite: response.usage.cacheWrite,
65
+ totalTokens: response.usage.totalTokens,
66
+ };
67
+ }
68
+
69
+ function stringHeaders(headers: Record<string, string | null> | undefined): Record<string, string> | undefined {
70
+ if (headers === undefined) return undefined;
71
+ return Object.fromEntries(Object.entries(headers).filter((entry): entry is [string, string] => entry[1] !== null));
72
+ }
73
+
74
+ function operationSignal(parent: AbortSignal | undefined, timeoutMs: number): {
75
+ readonly cleanup: () => void;
76
+ readonly signal: AbortSignal;
77
+ } {
78
+ const controller = new AbortController();
79
+ const relayAbort = () => controller.abort(parent?.reason);
80
+ if (parent?.aborted) relayAbort();
81
+ else parent?.addEventListener("abort", relayAbort, { once: true });
82
+ const timer = setTimeout(
83
+ () => controller.abort(new DOMException("Reducer model call timed out", "AbortError")),
84
+ timeoutMs,
85
+ );
86
+ return {
87
+ signal: controller.signal,
88
+ cleanup: () => {
89
+ clearTimeout(timer);
90
+ parent?.removeEventListener("abort", relayAbort);
91
+ },
92
+ };
93
+ }
94
+
95
+ function resolveReducerModel(config: ReducerConfig, registry: CompatibleModelRegistry): Model<Api> {
96
+ const model = registry.find?.(config.reducerProvider, config.reducerModel);
97
+ if (!model) {
98
+ throw new ReducerModelUnavailableError(
99
+ `Reducer model is unavailable: ${config.reducerProvider}/${config.reducerModel}`,
100
+ );
101
+ }
102
+ return model;
103
+ }
104
+
105
+ /** Use the configured reducer model and Pi-managed authentication for the reducer call. */
106
+ export async function callReducer(
107
+ config: ReducerConfig,
108
+ command: string,
109
+ isError: boolean,
110
+ archive: ArchiveObject,
111
+ body: string,
112
+ context: ExtensionContext,
113
+ compatComplete: CompatComplete = completeCompat,
114
+ ): Promise<ProviderResult> {
115
+ // SAFETY: Pi's public model registry is narrowed to the optional compatibility methods used below.
116
+ const registry = context.modelRegistry as unknown as CompatibleModelRegistry;
117
+ const model = resolveReducerModel(config, registry);
118
+ const operation = operationSignal(context.signal, config.timeoutMs);
119
+ try {
120
+ const requestContext = {
121
+ systemPrompt: reducerInstructions(),
122
+ messages: [
123
+ {
124
+ role: "user" as const,
125
+ content: [{ type: "text" as const, text: reducerInput(command, isError, archive, body) }],
126
+ timestamp: Date.now(),
127
+ },
128
+ ],
129
+ };
130
+ const requestOptions = {
131
+ cacheRetention: "none" as const,
132
+ maxTokens: Math.min(config.maxOutputTokens, model.maxTokens),
133
+ sessionId: config.runId,
134
+ signal: operation.signal,
135
+ timeoutMs: config.timeoutMs,
136
+ };
137
+ let response: AssistantMessage;
138
+ if (typeof registry.complete === "function") {
139
+ response = await registry.complete(model, requestContext, requestOptions);
140
+ } else {
141
+ const auth = await registry.getApiKeyAndHeaders(model);
142
+ if (!auth.ok) throw new Error(auth.error);
143
+ const legacyModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;
144
+ const headers = stringHeaders(auth.headers);
145
+ response = await compatComplete(legacyModel, requestContext, {
146
+ ...requestOptions,
147
+ ...(auth.apiKey === undefined ? {} : { apiKey: auth.apiKey }),
148
+ ...(headers === undefined ? {} : { headers }),
149
+ ...(auth.env === undefined ? {} : { env: auth.env }),
150
+ });
151
+ }
152
+ return {
153
+ errorMessage: response.errorMessage,
154
+ model: response.model,
155
+ ok: response.stopReason === "stop" || response.stopReason === "length",
156
+ outputText: responseOutputText(response),
157
+ provider: response.provider,
158
+ stopReason: response.stopReason,
159
+ usage: normalizedUsage(response),
160
+ };
161
+ } finally {
162
+ operation.cleanup();
163
+ }
164
+ }