pi-system-one 1.3.1 → 1.3.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.
package/src/provider.ts CHANGED
@@ -9,29 +9,36 @@ import {
9
9
  validateResponse,
10
10
  } from "system-one-core";
11
11
  import {
12
+ isTypeSafeEndpoint,
12
13
  type SessionConfig,
13
14
  TYPESAFE_BASE_URL,
14
15
  TYPESAFE_MODEL,
15
16
  } from "./config.ts";
16
17
 
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;
18
+ export type NativeFailureCategory =
19
+ | "native model unavailable"
20
+ | "native Score unsupported"
21
+ | "native input incompatible"
22
+ | "native classify threw"
23
+ | "native classifier returned error"
24
+ | "invalid native response"
25
+ | "native request aborted";
26
+
27
+ /** Fixed diagnostic category only; never include Pi/provider error text. */
28
+ export class PiNativeDiagnosticError extends Error {
29
+ readonly category: NativeFailureCategory;
30
+ constructor(category: NativeFailureCategory, message: string) {
31
+ super(message);
32
+ this.category = category;
30
33
  }
31
34
  }
32
35
 
33
- function nativeFailure(aborted: boolean): Error {
34
- return new Error(
36
+ function nativeFailure(
37
+ category: NativeFailureCategory,
38
+ aborted: boolean,
39
+ ): PiNativeDiagnosticError {
40
+ return new PiNativeDiagnosticError(
41
+ aborted ? "native request aborted" : category,
35
42
  aborted
36
43
  ? "Pi native classifier request aborted."
37
44
  : "Pi native classifier failed. Check /login typesafe and Pi logs.",
@@ -42,9 +49,15 @@ class PiNativeProvider implements SystemOneProvider {
42
49
  readonly id = "pi-native";
43
50
  private readonly ctx: ExtensionToolContext;
44
51
  private readonly timeoutMs: number;
45
- constructor(ctx: ExtensionToolContext, timeoutMs: number) {
52
+ private readonly onDispatch?: () => void;
53
+ constructor(
54
+ ctx: ExtensionToolContext,
55
+ timeoutMs: number,
56
+ onDispatch?: () => void,
57
+ ) {
46
58
  this.ctx = ctx;
47
59
  this.timeoutMs = timeoutMs;
60
+ this.onDispatch = onDispatch;
48
61
  }
49
62
 
50
63
  async evaluate<Q extends QuestionMap>(
@@ -57,24 +70,28 @@ class PiNativeProvider implements SystemOneProvider {
57
70
  "jev-latest",
58
71
  );
59
72
  if (!model)
60
- throw new Error(
73
+ throw new PiNativeDiagnosticError(
74
+ "native model unavailable",
61
75
  "Pi classifier jev-latest unavailable. Use /so config to select TypeSafe HTTP instead.",
62
76
  );
63
77
  const questions: Record<string, unknown> = Object.create(null);
64
78
  for (const [id, question] of Object.entries(request.questions)) {
65
79
  if (question.type === "score")
66
- throw new Error(
80
+ throw new PiNativeDiagnosticError(
81
+ "native Score unsupported",
67
82
  "Pi native classifier omits Score probabilities. Use /so config to select TypeSafe HTTP for Score questions.",
68
83
  );
69
84
  if (typeof question.instructions !== "string")
70
- throw new Error(
85
+ throw new PiNativeDiagnosticError(
86
+ "native input incompatible",
71
87
  "Pi native classifier requires string instructions. Use TypeSafe HTTP for structured instructions.",
72
88
  );
73
89
  if (question.type === "choice") {
74
90
  const criteria: Record<string, string> = Object.create(null);
75
91
  for (const [key, meaning] of Object.entries(question.criteria)) {
76
92
  if (meaning !== null && typeof meaning !== "string")
77
- throw new Error(
93
+ throw new PiNativeDiagnosticError(
94
+ "native input incompatible",
78
95
  "Pi native classifier requires string Choice criteria. Use TypeSafe HTTP for structured criteria.",
79
96
  );
80
97
  criteria[key] = meaning ?? key;
@@ -90,7 +107,8 @@ class PiNativeProvider implements SystemOneProvider {
90
107
  values &&
91
108
  (typeof values.true !== "string" || typeof values.false !== "string")
92
109
  )
93
- throw new Error(
110
+ throw new PiNativeDiagnosticError(
111
+ "native input incompatible",
94
112
  "Pi native classifier requires string yes/no criteria. Use TypeSafe HTTP for structured criteria.",
95
113
  );
96
114
  questions[id] = {
@@ -113,6 +131,7 @@ class PiNativeProvider implements SystemOneProvider {
113
131
  ReturnType<ExtensionToolContext["modelRegistry"]["classify"]>
114
132
  >;
115
133
  try {
134
+ this.onDispatch?.();
116
135
  result = await this.ctx.modelRegistry.classify(
117
136
  model,
118
137
  { state, questions } as never,
@@ -120,10 +139,14 @@ class PiNativeProvider implements SystemOneProvider {
120
139
  );
121
140
  } catch {
122
141
  // Pi and its provider may include untrusted response bodies in errors.
123
- throw nativeFailure(options?.signal?.aborted === true);
142
+ throw nativeFailure(
143
+ "native classify threw",
144
+ options?.signal?.aborted === true,
145
+ );
124
146
  }
125
147
  if (result.stopReason !== "stop")
126
148
  throw nativeFailure(
149
+ "native classifier returned error",
127
150
  result.stopReason === "aborted" || options?.signal?.aborted === true,
128
151
  );
129
152
  try {
@@ -149,22 +172,123 @@ class PiNativeProvider implements SystemOneProvider {
149
172
  );
150
173
  } catch {
151
174
  // Core validation can quote provider-controlled values; hide its detail.
152
- throw nativeFailure(false);
175
+ throw nativeFailure("invalid native response", false);
153
176
  }
154
177
  }
155
178
  }
156
179
 
180
+ function supportsNative(request: SystemOneRequest<QuestionMap>): boolean {
181
+ return Object.values(request.questions).every((question) => {
182
+ if (question.type === "score" || typeof question.instructions !== "string")
183
+ return false;
184
+ if (question.type === "choice")
185
+ return Object.values(question.criteria).every(
186
+ (value) => value === null || typeof value === "string",
187
+ );
188
+ return (
189
+ !question.criteria ||
190
+ (Object.keys(question.criteria).every(
191
+ (key) => key === "true" || key === "false",
192
+ ) &&
193
+ typeof question.criteria.true === "string" &&
194
+ typeof question.criteria.false === "string")
195
+ );
196
+ });
197
+ }
198
+
199
+ export type RouteObserver = (
200
+ provider: "pi-native" | "typesafe" | "configured",
201
+ reason:
202
+ | "compatible request"
203
+ | "contains Score"
204
+ | "native-incompatible input"
205
+ | "classifier unavailable"
206
+ | "selected mode",
207
+ ) => void;
208
+
209
+ class AutoSystemOneProvider implements SystemOneProvider {
210
+ readonly id = "auto";
211
+ private readonly session: SessionConfig;
212
+ private readonly ctx: ExtensionToolContext;
213
+ private readonly onRoute?: RouteObserver;
214
+ private readonly onDispatch?: () => void;
215
+ constructor(
216
+ session: SessionConfig,
217
+ ctx: ExtensionToolContext,
218
+ onRoute?: RouteObserver,
219
+ onDispatch?: () => void,
220
+ ) {
221
+ this.session = session;
222
+ this.ctx = ctx;
223
+ this.onRoute = onRoute;
224
+ this.onDispatch = onDispatch;
225
+ }
226
+
227
+ async evaluate<Q extends QuestionMap>(
228
+ request: SystemOneRequest<Q>,
229
+ options?: SystemOneCallOptions,
230
+ ): Promise<SystemOneResponse<Q>> {
231
+ const compatible = supportsNative(request);
232
+ if (
233
+ compatible &&
234
+ this.ctx.modelRegistry?.findOfType?.(
235
+ "classifier",
236
+ "typesafe",
237
+ "jev-latest",
238
+ )
239
+ ) {
240
+ this.onRoute?.("pi-native", "compatible request");
241
+ return new PiNativeProvider(
242
+ this.ctx,
243
+ this.session.current.timeoutMs,
244
+ this.onDispatch,
245
+ ).evaluate(request, options);
246
+ }
247
+ this.onRoute?.(
248
+ "typesafe",
249
+ compatible
250
+ ? "classifier unavailable"
251
+ : Object.values(request.questions).some(
252
+ (question) => question.type === "score",
253
+ )
254
+ ? "contains Score"
255
+ : "native-incompatible input",
256
+ );
257
+ const http = await resolveSessionProvider(
258
+ {
259
+ ...this.session,
260
+ mode: "typesafe",
261
+ current: {
262
+ ...this.session.current,
263
+ baseUrl: TYPESAFE_BASE_URL,
264
+ model: TYPESAFE_MODEL,
265
+ },
266
+ },
267
+ this.ctx,
268
+ );
269
+ this.onDispatch?.();
270
+ return http.evaluate(request, options);
271
+ }
272
+ }
273
+
157
274
  /** Resolve credentials at call time. Never forward Pi's TypeSafe key to a custom origin. */
158
275
  export async function resolveSessionProvider(
159
276
  session: SessionConfig,
160
277
  ctx: ExtensionToolContext,
278
+ onRoute?: RouteObserver,
279
+ onDispatch?: () => void,
161
280
  ): Promise<SystemOneProvider> {
162
- if (session.mode === "native")
163
- return new PiNativeProvider(ctx, session.current.timeoutMs);
281
+ if (session.mode === "native") {
282
+ onRoute?.("pi-native", "selected mode");
283
+ return new PiNativeProvider(ctx, session.current.timeoutMs, onDispatch);
284
+ }
285
+ if (session.mode === "auto")
286
+ return new AutoSystemOneProvider(session, ctx, onRoute, onDispatch);
164
287
  const config = session.current;
165
288
  if (!config.baseUrl)
166
289
  throw new Error("Custom endpoint missing. Run /so config.");
167
290
  const typeSafe = isTypeSafeEndpoint(config.baseUrl);
291
+ onRoute?.(typeSafe ? "typesafe" : "configured", "selected mode");
168
292
  const key =
169
293
  config.apiKey ??
170
294
  (typeSafe
@@ -11,9 +11,21 @@ import {
11
11
  } from "../src/commands.ts";
12
12
  import { createSessionConfig } from "../src/config.ts";
13
13
  import { resolveSessionProvider } from "../src/provider.ts";
14
+ import type { buildSystemOneTool } from "../src/tool.ts";
15
+
16
+ type OverlayFactory = (
17
+ tui: { requestRender: () => void },
18
+ theme: { fg: (key: string, text: string) => string },
19
+ keys: unknown,
20
+ done: () => void,
21
+ ) => {
22
+ render: (width: number) => string[];
23
+ handleInput: (data: string) => void;
24
+ };
14
25
 
15
26
  interface Captured {
16
27
  tools: string[];
28
+ tool?: ReturnType<typeof buildSystemOneTool>;
17
29
  handler: ((args: string, ctx: never) => Promise<void>) | undefined;
18
30
  completions: ((prefix: string) => { value: string }[]) | undefined;
19
31
  notices: string[];
@@ -29,8 +41,9 @@ function harness() {
29
41
  selections: [],
30
42
  };
31
43
  const pi = {
32
- registerTool: (t: { name: string }) => {
44
+ registerTool: (t: ReturnType<typeof buildSystemOneTool>) => {
33
45
  cap.tools.push(t.name);
46
+ cap.tool = t;
34
47
  },
35
48
  registerCommand: (
36
49
  _n: string,
@@ -72,6 +85,93 @@ function ctxFor(
72
85
  }
73
86
 
74
87
  describe("so command", () => {
88
+ it("opens /so debug as a dismissible TUI overlay without changing settings", async () => {
89
+ const { pi, cap } = harness();
90
+ const store: SessionStore = { session: createSessionConfig({}) };
91
+ registerSystemOneCommands(pi, store);
92
+ let overlay = false;
93
+ let closed = false;
94
+ await cap.handler?.("debug", {
95
+ mode: "tui",
96
+ ui: {
97
+ custom: async (
98
+ factory: OverlayFactory,
99
+ options: { overlay: boolean },
100
+ ) => {
101
+ overlay = options.overlay;
102
+ const component = factory(
103
+ { requestRender: () => {} },
104
+ { fg: (_color: string, text: string) => text },
105
+ {},
106
+ () => {
107
+ closed = true;
108
+ },
109
+ );
110
+ assert.match(
111
+ component.render(72).join("\n"),
112
+ /No system_one calls yet/,
113
+ );
114
+ component.handleInput("\u001b");
115
+ },
116
+ notify: () => {},
117
+ },
118
+ } as never);
119
+ assert.ok(overlay);
120
+ assert.ok(closed);
121
+ assert.equal(store.session?.mode, "typesafe");
122
+ assert.ok(cap.completions?.("de").some((item) => item.value === "debug"));
123
+ });
124
+ it("shows actual provider for a completed tool call in /so debug overlay", async () => {
125
+ const { pi, cap } = harness();
126
+ registerSystemOneCommands(pi, {
127
+ session: createSessionConfig({}, { mode: "native" }),
128
+ }).applyProvider();
129
+ assert.ok(cap.tool);
130
+ const result = await cap.tool.execute(
131
+ "native-debug",
132
+ {
133
+ state: "private-state",
134
+ questions: { q: { type: "noul", instructions: "private-instruction" } },
135
+ },
136
+ undefined,
137
+ undefined,
138
+ {
139
+ modelRegistry: {
140
+ findOfType: () => ({ id: "jev-latest" }),
141
+ classify: async () => ({
142
+ stopReason: "stop",
143
+ answers: { q: { type: "bool", probability: 0.7 } },
144
+ }),
145
+ },
146
+ } as never,
147
+ );
148
+ assert.equal(result.details.metadata.provider, "pi-native");
149
+ await cap.handler?.("debug", {
150
+ mode: "tui",
151
+ ui: {
152
+ custom: async (factory: OverlayFactory) => {
153
+ const component = factory(
154
+ { requestRender: () => {} },
155
+ { fg: (_key: string, text: string) => text },
156
+ {},
157
+ () => {},
158
+ );
159
+ const text = component.render(76).join("\n");
160
+ assert.match(text, /Provider: pi-native/);
161
+ assert.doesNotMatch(text, /private-state|private-instruction/);
162
+ },
163
+ },
164
+ } as never);
165
+ });
166
+ it("summarizes debug calls without custom UI in RPC mode", async () => {
167
+ const { pi, cap } = harness();
168
+ registerSystemOneCommands(pi, { session: createSessionConfig({}) });
169
+ await cap.handler?.("debug", {
170
+ mode: "rpc",
171
+ ui: { notify: (message: string) => cap.notices.push(message) },
172
+ } as never);
173
+ assert.match(cap.notices.at(-1) ?? "", /No system_one calls yet/);
174
+ });
75
175
  it("shows selectable provider modes for /so and /so config", async () => {
76
176
  const { pi, cap } = harness();
77
177
  const store: SessionStore = { session: createSessionConfig({}) };
@@ -81,11 +181,45 @@ describe("so command", () => {
81
181
  await cap.handler?.("config", ctxFor(["typesafe"], cap));
82
182
  assert.equal(store.session?.mode, "typesafe");
83
183
  assert.equal(cap.selections.length, 2);
84
- assert.equal(cap.selections[0].options.length, 3);
184
+ assert.equal(cap.selections[0].options.length, 4);
85
185
  assert.match(cap.selections[0].options[0], /typesafe.*current/i);
86
186
  assert.match(cap.selections[1].options[0], /native.*current/i);
87
187
  });
88
188
 
189
+ it("selects auto and persists policy without an inherited custom endpoint", async () => {
190
+ const dir = mkdtempSync(join(tmpdir(), "so-auto-command-"));
191
+ const previousUrl = process.env.SYSTEM_ONE_BASE_URL;
192
+ const previousKey = process.env.SYSTEM_ONE_API_KEY;
193
+ try {
194
+ process.env.SYSTEM_ONE_BASE_URL = "https://custom.example";
195
+ process.env.SYSTEM_ONE_API_KEY = "custom-secret";
196
+ const path = join(dir, "pi-system-one.json");
197
+ const { pi, cap } = harness();
198
+ const store: SessionStore = {
199
+ session: createSessionConfig({
200
+ SYSTEM_ONE_BASE_URL: "https://custom.example",
201
+ SYSTEM_ONE_API_KEY: "custom-secret",
202
+ }),
203
+ };
204
+ registerSystemOneCommands(pi, store, path);
205
+ await cap.handler?.("config", ctxFor(["auto"], cap));
206
+ assert.equal(store.session?.mode, "auto");
207
+ assert.equal(store.session?.current.baseUrl, "https://api.typesafe.ai");
208
+ assert.equal(store.session?.current.apiKey, undefined);
209
+ assert.equal(JSON.parse(readFileSync(path, "utf8")).mode, "auto");
210
+ assert.doesNotMatch(readFileSync(path, "utf8"), /custom.example|secret/);
211
+ assert.ok(
212
+ cap.selections[0].options.some((option) => option.startsWith("auto")),
213
+ );
214
+ } finally {
215
+ rmSync(dir, { recursive: true, force: true });
216
+ if (previousUrl === undefined) delete process.env.SYSTEM_ONE_BASE_URL;
217
+ else process.env.SYSTEM_ONE_BASE_URL = previousUrl;
218
+ if (previousKey === undefined) delete process.env.SYSTEM_ONE_API_KEY;
219
+ else process.env.SYSTEM_ONE_API_KEY = previousKey;
220
+ }
221
+ });
222
+
89
223
  it("cancels selection without changing mode or saved settings", async () => {
90
224
  const dir = mkdtempSync(join(tmpdir(), "so-select-"));
91
225
  try {
@@ -268,12 +402,12 @@ describe("so command", () => {
268
402
  rmSync(dir, { recursive: true, force: true });
269
403
  }
270
404
  });
271
- it("completes config + status, case-insensitively", () => {
405
+ it("completes config + status + debug, case-insensitively", () => {
272
406
  const { pi, cap } = harness();
273
407
  registerSystemOneCommands(pi, {});
274
408
  assert.deepEqual(
275
409
  cap.completions?.("").map((c) => c.value),
276
- ["config", "status"],
410
+ ["config", "status", "debug"],
277
411
  );
278
412
  assert.deepEqual(
279
413
  cap.completions?.("S").map((c) => c.value),
@@ -22,6 +22,28 @@ describe("config", () => {
22
22
  assert.equal(s.current.model, "jev-latest");
23
23
  assert.match(describeConfig(s), /api key: managed by Pi/);
24
24
  });
25
+ it("status identifies saved native mode and ignored endpoint without exposing keys", () => {
26
+ const s = createSessionConfig(
27
+ {
28
+ SYSTEM_ONE_BASE_URL: "https://custom.example",
29
+ SYSTEM_ONE_API_KEY: "secret",
30
+ },
31
+ { mode: "native" },
32
+ );
33
+ const status = describeConfig(s);
34
+ assert.match(status, /mode: native \(saved\)/);
35
+ assert.match(status, /environment endpoint ignored/i);
36
+ assert.doesNotMatch(status, /custom.example|secret/);
37
+ });
38
+ it("status explains auto policy rather than claiming a fixed provider", () => {
39
+ const s = createSessionConfig({}, { mode: "auto" });
40
+ assert.match(describeConfig(s), /mode: auto \(saved\)/);
41
+ assert.match(describeConfig(s), /per request/i);
42
+ assert.match(
43
+ describeConfig(s),
44
+ /HTTP fallback endpoint: https:\/\/api\.typesafe\.ai/,
45
+ );
46
+ });
25
47
  it("defaults to TypeSafe Jev without environment configuration", () => {
26
48
  const s = createSessionConfig({});
27
49
  assert.equal(s.mode, "typesafe");
@@ -29,23 +51,57 @@ describe("config", () => {
29
51
  assert.equal(s.current.model, "jev-latest");
30
52
  assert.equal(s.current.apiKey, undefined);
31
53
  });
32
- it("keeps environment endpoint ahead of saved custom config", () => {
54
+ it("keeps a TypeSafe environment model bound to the canonical endpoint", () => {
55
+ for (const stored of [{}, { mode: "typesafe" as const }]) {
56
+ const s = createSessionConfig(
57
+ { SYSTEM_ONE_MODEL: "jev-custom", SYSTEM_ONE_API_KEY: "typesafe-key" },
58
+ stored,
59
+ );
60
+ assert.equal(s.mode, "typesafe");
61
+ assert.equal(s.current.model, "jev-custom");
62
+ assert.equal(s.current.apiKey, "typesafe-key");
63
+ }
64
+ });
65
+ it("keeps saved native mode despite inherited HTTP environment", () => {
33
66
  const s = createSessionConfig(
34
67
  { SYSTEM_ONE_BASE_URL: "http://existing/", SYSTEM_ONE_MODEL: "old" },
35
68
  { mode: "native", model: "new", timeoutMs: 5000 },
36
69
  );
37
- assert.equal(s.mode, "custom");
38
- assert.equal(s.current.baseUrl, "http://existing/");
39
- assert.equal(s.current.model, "old");
70
+ assert.equal(s.mode, "native");
71
+ assert.equal(s.current.baseUrl, "");
72
+ assert.equal(s.current.model, "jev-latest");
40
73
  assert.equal(s.current.timeoutMs, 5000);
41
74
  });
42
- it("does not carry a saved model to a different environment endpoint", () => {
75
+ it("saved TypeSafe and auto ignore custom endpoint keys and models", () => {
76
+ for (const mode of ["typesafe", "auto"] as const) {
77
+ const s = createSessionConfig(
78
+ {
79
+ SYSTEM_ONE_BASE_URL: "http://custom/",
80
+ SYSTEM_ONE_API_KEY: "custom-secret",
81
+ SYSTEM_ONE_MODEL: "custom-model",
82
+ },
83
+ { mode },
84
+ );
85
+ assert.equal(s.mode, mode);
86
+ assert.equal(s.current.baseUrl, "https://api.typesafe.ai");
87
+ assert.equal(s.current.model, "jev-latest");
88
+ assert.equal(s.current.apiKey, undefined);
89
+ assert.equal(s.ignoredEnvironmentEndpoint, true);
90
+ }
91
+ });
92
+ it("keeps saved custom endpoint and model, not unrelated environment credentials", () => {
43
93
  const s = createSessionConfig(
44
- { SYSTEM_ONE_BASE_URL: "http://new/" },
94
+ {
95
+ SYSTEM_ONE_BASE_URL: "http://new/",
96
+ SYSTEM_ONE_MODEL: "new-model",
97
+ SYSTEM_ONE_API_KEY: "key-for-new",
98
+ },
45
99
  { mode: "custom", baseUrl: "http://old/", model: "old-model" },
46
100
  );
47
- assert.equal(s.current.model, undefined);
48
- assert.equal(s.current.baseUrl, "http://new/");
101
+ assert.equal(s.mode, "custom");
102
+ assert.equal(s.current.baseUrl, "http://old/");
103
+ assert.equal(s.current.model, "old-model");
104
+ assert.equal(s.current.apiKey, undefined);
49
105
  });
50
106
  it("keeps saved model when environment endpoint matches saved endpoint", () => {
51
107
  const s = createSessionConfig(