pi-system-one 1.2.4 → 1.3.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.
@@ -0,0 +1,111 @@
1
+ import {
2
+ mkdirSync,
3
+ readFileSync,
4
+ renameSync,
5
+ unlinkSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { dirname } from "node:path";
9
+ import type { ConfigMode, StoredConfig } from "./config.ts";
10
+
11
+ const MODES: ConfigMode[] = ["typesafe", "custom", "native"];
12
+
13
+ function validate(value: unknown, path: string): StoredConfig {
14
+ if (typeof value !== "object" || value === null || Array.isArray(value))
15
+ throw new Error(`Invalid System One settings in ${path}: expected object`);
16
+ const v = value as Record<string, unknown>;
17
+ if (Object.hasOwn(v, "apiKey") || Object.hasOwn(v, "key"))
18
+ throw new Error(
19
+ `Invalid System One settings in ${path}: apiKey must stay in Pi credentials or environment`,
20
+ );
21
+ for (const key of Object.keys(v)) {
22
+ if (!["mode", "baseUrl", "model", "timeoutMs"].includes(key))
23
+ throw new Error(`Invalid System One settings in ${path}: unknown field`);
24
+ }
25
+ if (v.mode !== undefined && !MODES.includes(v.mode as ConfigMode))
26
+ throw new Error(
27
+ `Invalid System One settings in ${path}: mode must be typesafe, custom, or native`,
28
+ );
29
+ if (
30
+ v.baseUrl !== undefined &&
31
+ (typeof v.baseUrl !== "string" || !v.baseUrl.trim())
32
+ )
33
+ throw new Error(
34
+ `Invalid System One settings in ${path}: baseUrl must be a nonempty string`,
35
+ );
36
+ if (typeof v.baseUrl === "string") {
37
+ let url: URL;
38
+ try {
39
+ url = new URL(v.baseUrl);
40
+ } catch {
41
+ throw new Error(
42
+ `Invalid System One settings in ${path}: baseUrl must be an HTTP(S) URL`,
43
+ );
44
+ }
45
+ if (
46
+ !["http:", "https:"].includes(url.protocol) ||
47
+ url.username ||
48
+ url.password ||
49
+ url.search ||
50
+ url.hash
51
+ )
52
+ throw new Error(
53
+ `Invalid System One settings in ${path}: baseUrl must be an HTTP(S) URL without credentials, query, or fragment`,
54
+ );
55
+ }
56
+ if (v.model !== undefined && (typeof v.model !== "string" || !v.model.trim()))
57
+ throw new Error(
58
+ `Invalid System One settings in ${path}: model must be a nonempty string`,
59
+ );
60
+ if (
61
+ v.timeoutMs !== undefined &&
62
+ (typeof v.timeoutMs !== "number" ||
63
+ !Number.isFinite(v.timeoutMs) ||
64
+ v.timeoutMs <= 0)
65
+ )
66
+ throw new Error(
67
+ `Invalid System One settings in ${path}: timeoutMs must be positive`,
68
+ );
69
+ if (v.mode === "custom" && !v.baseUrl)
70
+ throw new Error(
71
+ `Invalid System One settings in ${path}: custom mode needs baseUrl`,
72
+ );
73
+ return v as StoredConfig;
74
+ }
75
+
76
+ export function loadStoredConfig(path: string): StoredConfig {
77
+ let raw: string;
78
+ try {
79
+ raw = readFileSync(path, "utf8");
80
+ } catch (error) {
81
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
82
+ throw error;
83
+ }
84
+ let parsed: unknown;
85
+ try {
86
+ parsed = JSON.parse(raw);
87
+ } catch {
88
+ throw new Error(`Invalid JSON in ${path}`);
89
+ }
90
+ return validate(parsed, path);
91
+ }
92
+
93
+ export function saveStoredConfig(path: string, config: StoredConfig): void {
94
+ const safe = validate(config, path);
95
+ mkdirSync(dirname(path), { recursive: true });
96
+ const temp = `${path}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
97
+ try {
98
+ writeFileSync(temp, `${JSON.stringify(safe, null, 2)}\n`, {
99
+ mode: 0o600,
100
+ flag: "wx",
101
+ });
102
+ renameSync(temp, path);
103
+ } catch (error) {
104
+ try {
105
+ unlinkSync(temp);
106
+ } catch {
107
+ /* no temporary file */
108
+ }
109
+ throw error;
110
+ }
111
+ }
@@ -0,0 +1,184 @@
1
+ import type { ExtensionToolContext } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ HttpSystemOneProvider,
4
+ type QuestionMap,
5
+ type SystemOneCallOptions,
6
+ type SystemOneProvider,
7
+ type SystemOneRequest,
8
+ type SystemOneResponse,
9
+ validateResponse,
10
+ } from "system-one-core";
11
+ import {
12
+ type SessionConfig,
13
+ TYPESAFE_BASE_URL,
14
+ TYPESAFE_MODEL,
15
+ } from "./config.ts";
16
+
17
+ export function isTypeSafeEndpoint(baseUrl: string): boolean {
18
+ try {
19
+ const url = new URL(baseUrl);
20
+ return (
21
+ url.origin === TYPESAFE_BASE_URL &&
22
+ url.pathname === "/" &&
23
+ !url.search &&
24
+ !url.hash &&
25
+ !url.username &&
26
+ !url.password
27
+ );
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ function nativeFailure(aborted: boolean): Error {
34
+ return new Error(
35
+ aborted
36
+ ? "Pi native classifier request aborted."
37
+ : "Pi native classifier failed. Check /login typesafe and Pi logs.",
38
+ );
39
+ }
40
+
41
+ class PiNativeProvider implements SystemOneProvider {
42
+ readonly id = "pi-native";
43
+ private readonly ctx: ExtensionToolContext;
44
+ private readonly timeoutMs: number;
45
+ constructor(ctx: ExtensionToolContext, timeoutMs: number) {
46
+ this.ctx = ctx;
47
+ this.timeoutMs = timeoutMs;
48
+ }
49
+
50
+ async evaluate<Q extends QuestionMap>(
51
+ request: SystemOneRequest<Q>,
52
+ options?: SystemOneCallOptions,
53
+ ): Promise<SystemOneResponse<Q>> {
54
+ const model = this.ctx.modelRegistry?.findOfType?.(
55
+ "classifier",
56
+ "typesafe",
57
+ "jev-latest",
58
+ );
59
+ if (!model)
60
+ throw new Error(
61
+ "Pi classifier jev-latest unavailable. Use /so config to select TypeSafe HTTP instead.",
62
+ );
63
+ const questions: Record<string, unknown> = Object.create(null);
64
+ for (const [id, question] of Object.entries(request.questions)) {
65
+ if (question.type === "score")
66
+ throw new Error(
67
+ "Pi native classifier omits Score probabilities. Use /so config to select TypeSafe HTTP for Score questions.",
68
+ );
69
+ if (typeof question.instructions !== "string")
70
+ throw new Error(
71
+ "Pi native classifier requires string instructions. Use TypeSafe HTTP for structured instructions.",
72
+ );
73
+ if (question.type === "choice") {
74
+ const criteria: Record<string, string> = Object.create(null);
75
+ for (const [key, meaning] of Object.entries(question.criteria)) {
76
+ if (meaning !== null && typeof meaning !== "string")
77
+ throw new Error(
78
+ "Pi native classifier requires string Choice criteria. Use TypeSafe HTTP for structured criteria.",
79
+ );
80
+ criteria[key] = meaning ?? key;
81
+ }
82
+ questions[id] = {
83
+ type: "choice",
84
+ instructions: question.instructions,
85
+ criteria,
86
+ };
87
+ } else {
88
+ const values = question.criteria;
89
+ if (
90
+ values &&
91
+ (typeof values.true !== "string" || typeof values.false !== "string")
92
+ )
93
+ throw new Error(
94
+ "Pi native classifier requires string yes/no criteria. Use TypeSafe HTTP for structured criteria.",
95
+ );
96
+ questions[id] = {
97
+ type: "bool",
98
+ instructions: question.instructions,
99
+ criteria: {
100
+ true: values?.true ?? "Yes",
101
+ false: values?.false ?? "No",
102
+ },
103
+ };
104
+ }
105
+ }
106
+ const state =
107
+ typeof request.state === "object" &&
108
+ request.state !== null &&
109
+ !Array.isArray(request.state)
110
+ ? request.state
111
+ : { state: request.state };
112
+ let result: Awaited<
113
+ ReturnType<ExtensionToolContext["modelRegistry"]["classify"]>
114
+ >;
115
+ try {
116
+ result = await this.ctx.modelRegistry.classify(
117
+ model,
118
+ { state, questions } as never,
119
+ { signal: options?.signal, timeoutMs: this.timeoutMs },
120
+ );
121
+ } catch {
122
+ // Pi and its provider may include untrusted response bodies in errors.
123
+ throw nativeFailure(options?.signal?.aborted === true);
124
+ }
125
+ if (result.stopReason !== "stop")
126
+ throw nativeFailure(
127
+ result.stopReason === "aborted" || options?.signal?.aborted === true,
128
+ );
129
+ try {
130
+ const answers: Record<string, unknown> = Object.create(null);
131
+ for (const [id, answer] of Object.entries(result.answers))
132
+ answers[id] =
133
+ answer.type === "bool"
134
+ ? { type: "noul", noul: answer.probability }
135
+ : answer;
136
+ return validateResponse(
137
+ request.questions,
138
+ {
139
+ model: result.model,
140
+ answers,
141
+ usage: result.usage
142
+ ? {
143
+ inputTokens: result.usage.input,
144
+ outputTokens: result.usage.output,
145
+ }
146
+ : undefined,
147
+ },
148
+ this.id,
149
+ );
150
+ } catch {
151
+ // Core validation can quote provider-controlled values; hide its detail.
152
+ throw nativeFailure(false);
153
+ }
154
+ }
155
+ }
156
+
157
+ /** Resolve credentials at call time. Never forward Pi's TypeSafe key to a custom origin. */
158
+ export async function resolveSessionProvider(
159
+ session: SessionConfig,
160
+ ctx: ExtensionToolContext,
161
+ ): Promise<SystemOneProvider> {
162
+ if (session.mode === "native")
163
+ return new PiNativeProvider(ctx, session.current.timeoutMs);
164
+ const config = session.current;
165
+ if (!config.baseUrl)
166
+ throw new Error("Custom endpoint missing. Run /so config.");
167
+ const typeSafe = isTypeSafeEndpoint(config.baseUrl);
168
+ const key =
169
+ config.apiKey ??
170
+ (typeSafe
171
+ ? await ctx.modelRegistry?.getApiKeyForProvider?.("typesafe")
172
+ : undefined);
173
+ if (typeSafe && !key)
174
+ throw new Error(
175
+ "TypeSafe key missing. Run /login typesafe or set SYSTEM_ONE_API_KEY.",
176
+ );
177
+ return new HttpSystemOneProvider({
178
+ id: typeSafe ? "typesafe" : "configured",
179
+ baseUrl: config.baseUrl,
180
+ apiKey: key,
181
+ defaultModel: config.model ?? (typeSafe ? TYPESAFE_MODEL : undefined),
182
+ timeoutMs: config.timeoutMs,
183
+ });
184
+ }
package/src/tool.ts CHANGED
@@ -1,4 +1,7 @@
1
- import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
1
+ import type {
2
+ ExtensionToolContext,
3
+ ToolDefinition,
4
+ } from "@earendil-works/pi-coding-agent";
2
5
  import {
3
6
  type QuestionMap,
4
7
  SystemOne,
@@ -982,6 +985,42 @@ function normalizeArgs(args: unknown, drops?: string[]): SystemOneParams {
982
985
  return out as SystemOneParams;
983
986
  }
984
987
 
988
+ // Keep codemode's result small and question-name keyed; each answer carries
989
+ // its own kind-specific fields without requiring a per-kind schema union.
990
+ const systemOneOutput = Type.Object({
991
+ answers: Type.Object(
992
+ {},
993
+ {
994
+ additionalProperties: Type.Object(
995
+ {
996
+ type: Type.String(),
997
+ noul: Type.Optional(Type.Number()),
998
+ choice: Type.Optional(Type.String()),
999
+ score: Type.Optional(Type.Number()),
1000
+ probabilities: Type.Optional(
1001
+ Type.Object({}, { additionalProperties: Type.Number() }),
1002
+ ),
1003
+ legend: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
1004
+ confidence: Type.Optional(Type.Number()),
1005
+ },
1006
+ { additionalProperties: true },
1007
+ ),
1008
+ },
1009
+ ),
1010
+ model: Type.Optional(Type.String()),
1011
+ usage: Type.Optional(
1012
+ Type.Object({
1013
+ inputTokens: Type.Optional(Type.Number()),
1014
+ outputTokens: Type.Optional(Type.Number()),
1015
+ }),
1016
+ ),
1017
+ requestId: Type.Optional(Type.String()),
1018
+ metadata: Type.Object({
1019
+ provider: Type.String(),
1020
+ latencyMs: Type.Optional(Type.Number()),
1021
+ }),
1022
+ });
1023
+
985
1024
  export interface SystemOneDetails {
986
1025
  answers: Record<string, unknown>;
987
1026
  model?: string;
@@ -1066,11 +1105,11 @@ function assertAnswerableQuestions(questions: unknown): void {
1066
1105
  }
1067
1106
 
1068
1107
  export function buildSystemOneTool(deps: {
1069
- provider: SystemOneProvider;
1108
+ provider?: SystemOneProvider;
1109
+ resolveProvider?: (ctx: ExtensionToolContext) => Promise<SystemOneProvider>;
1070
1110
  }): ToolDefinition<typeof systemOneParams, SystemOneDetails> {
1071
- const systemOne = new SystemOne({
1072
- provider: deps.provider,
1073
- });
1111
+ if (!deps.provider && !deps.resolveProvider)
1112
+ throw new Error("system_one requires a provider resolver");
1074
1113
 
1075
1114
  return {
1076
1115
  name: "system_one",
@@ -1088,6 +1127,7 @@ export function buildSystemOneTool(deps: {
1088
1127
  "score criteria is an ordered array of at least two levels.",
1089
1128
 
1090
1129
  parameters: systemOneParams,
1130
+ outputSchema: systemOneOutput,
1091
1131
 
1092
1132
  promptSnippet:
1093
1133
  "Use system_one for bounded judgments over supplied evidence, including classification, routing, and selecting among plausible tools, skills, strategies, implementations, models, or providers",
@@ -1114,7 +1154,7 @@ export function buildSystemOneTool(deps: {
1114
1154
 
1115
1155
  prepareArguments: (args) => prepareSystemOneArgs(args),
1116
1156
 
1117
- async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
1157
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
1118
1158
  /*
1119
1159
  * The LLM-facing TypeBox schema is intentionally separate from the
1120
1160
  * provider protocol types. Cast only at this boundary instead of
@@ -1146,7 +1186,11 @@ export function buildSystemOneTool(deps: {
1146
1186
 
1147
1187
  assertAnswerableQuestions(normalized.questions);
1148
1188
 
1149
- const response = await systemOne.evaluate(
1189
+ const provider = deps.resolveProvider
1190
+ ? await deps.resolveProvider(ctx)
1191
+ : deps.provider;
1192
+ if (!provider) throw new Error("system_one provider unavailable");
1193
+ const response = await new SystemOne({ provider }).evaluate(
1150
1194
  {
1151
1195
  state: normalized.state as SystemOneState,
1152
1196
  questions: normalized.questions as QuestionMap,
@@ -1165,6 +1209,18 @@ export function buildSystemOneTool(deps: {
1165
1209
  },
1166
1210
  ],
1167
1211
 
1212
+ // Core responses can contain undefined optional fields. Omit those
1213
+ // from the script-facing JSON value without changing direct details.
1214
+ structuredContent: {
1215
+ answers: response.answers,
1216
+ metadata: response.metadata,
1217
+ ...(response.model !== undefined ? { model: response.model } : {}),
1218
+ ...(response.usage !== undefined ? { usage: response.usage } : {}),
1219
+ ...(response.requestId !== undefined
1220
+ ? { requestId: response.requestId }
1221
+ : {}),
1222
+ } as unknown as SystemOneState,
1223
+
1168
1224
  details: {
1169
1225
  answers: response.answers,
1170
1226
  model: response.model,