pi-system-one 1.2.4 → 1.3.1

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/README.md CHANGED
@@ -6,6 +6,28 @@ System One decisions for Pi. Plug in TypeSafe Jev, Reflex, or your own provider.
6
6
  pi install npm:pi-system-one
7
7
  ```
8
8
 
9
+ ## Pi built-in classifier vs `pi-system-one` (as of Pi v1.0.3)
10
+
11
+ Pi already supports Jev through `codemode`. It can classify one question or combine classification with other tool calls in a script. This extension exposes decisions as a dedicated `system_one` tool, with a `/judge` prompt and a skill that guides when to use it. Choose based on your workflow, not on a claim that either route is more accurate.
12
+
13
+ | Need | Pi classifier through `codemode` | `pi-system-one` today |
14
+ |---|---|---|
15
+ | Setup | Enable `codemode`; sign in with `/login typesafe` or set `TYPESAFE_API_KEY`. | Install extension and sign in with `/login typesafe` or set `TYPESAFE_API_KEY`. Jev uses `jev-latest` by default. |
16
+ | Agent interface | Script calls `models.classify()`; you choose model, state, and questions. | Model calls `system_one` directly. `/judge` and bundled skill provide usage guidance. |
17
+ | One-off decisions | Write a `codemode` script even for a standalone classification. Useful if you already use scripts. | Call `system_one` without writing a script or enabling `codemode`. The same tool works mid-conversation when no other tool calls need orchestration. |
18
+ | Multiple tool calls | A script can gather data, call a classifier, and filter results before they reach the model. | The tool batches decision questions over supplied state, but does not replace `codemode` for scripting other tools. |
19
+ | Host availability | Requires a Pi host with `codemode` enabled and classifier models exposed to its scripts. | Loads as an extension without `codemode`; package also declares an OMP extension entry. Requires a host that supports this extension's APIs. |
20
+ | Endpoints | Pi's registered classifier models, including Jev across supported providers. | Any backend compatible with `POST /v1/systemone`, including local Reflex and custom endpoints. |
21
+ | Inputs | Pi classifier schema: object state, string question fields, `bool` for yes/no. | Accepts string or JSON state, `noul` for yes/no, and normalizes common argument aliases. |
22
+ | Score output | Score and confidence. Choice answers still include probabilities. | Score, confidence, per-level probabilities, and legend when backend supplies them. |
23
+ | Credentials | Pi resolves provider credentials, including saved `/login typesafe` key. | TypeSafe endpoint reuses Pi credentials per call. Explicit `SYSTEM_ONE_API_KEY` or session key takes precedence; custom endpoints never receive Pi's TypeSafe key. |
24
+
25
+ Pi's [v1.0.3 Codemode guide](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/codemode.md) documents scripts, model calls, and orchestration. Its [CLI guide](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/cli.md#enable-codemode) explains how to enable `codemode`. Pi extensions can also [call classifiers without Codemode](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/models.md#use-classifier-models); the table compares the agent-facing Codemode route with this extension's agent-facing tool, not every way to write a Pi extension.
26
+
27
+ Pi v1.0.3 [defines its Score answer](https://github.com/earendil-works/pi/blob/v1.0.3/packages/ai/src/types.ts#L660-L678) with only `score` and `confidence`. Its [response parser](https://github.com/earendil-works/pi/blob/v1.0.3/packages/ai/src/api/system-one-shared.ts#L80-L119) copies only those two Score fields. [TypeSafe's HTTP API](https://docs.typesafe.ai/api#score-answer) returns `probabilities` and `legend` too. Pi's [classifier input types](https://github.com/earendil-works/pi/blob/v1.0.3/packages/ai/src/types.ts#L635-L658) and [codemode docs](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/codemode.md#classify) document the narrower inputs shown above. These references describe Pi v1.0.3, not future releases.
28
+
29
+ If you already use `codemode` to combine tools and only need Pi's classifier response, use Pi's built-in route. For standalone decisions, choose this extension when you want a direct tool without a script or Codemode setup. It also provides prompt guidance, compatible custom endpoints, and the full Score distribution when the backend supplies it. The extension can run on a host without `codemode` if that host supports its extension APIs. Its OMP package entry makes it installable in OMP, but it does not guarantee support for every Pi or OMP version. `/so config` can save non-secret settings or select Pi's native classifier. Native mode rejects Score questions because Pi v1.0.3 does not expose their probability distribution. See [Pi classifier docs for v1.0.3](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/models.md#use-classifier-models).
30
+
9
31
  For Oh My Pi (OMP), install the published package with:
10
32
 
11
33
  ```bash
@@ -20,7 +42,7 @@ omp install /path/to/pi-system-one
20
42
  omp --extension /path/to/pi-system-one
21
43
  ```
22
44
 
23
- Set `SYSTEM_ONE_BASE_URL` before launch, or run `/so config`; the `system_one` tool is registered after the endpoint is configured.
45
+ The `system_one` tool registers at startup. Without settings, it uses TypeSafe Jev through this extension's HTTP provider and asks Pi for your TypeSafe credential at call time. Run `/login typesafe` first, or set `TYPESAFE_API_KEY`.
24
46
 
25
47
  <!-- <img src="./demo.gif" alt="Description" width="640" > -->
26
48
 
@@ -29,41 +51,39 @@ Set `SYSTEM_ONE_BASE_URL` before launch, or run `/so config`; the `system_one` t
29
51
  <img width="1836" height="1092" alt="demo" src="https://github.com/user-attachments/assets/341c0e4a-d0e5-4df0-85d2-74ca2f316db5" />
30
52
 
31
53
 
32
- Setup Provider:
33
- ##### locally hosted model e.g Reflex, von, laya etc, any model that supports `POST /v1/systemone`
54
+ ### Custom endpoint
55
+
56
+ For Reflex or another backend that supports `POST /v1/systemone`, run `/so config` and choose `custom`, or retain your existing environment setup:
57
+
34
58
  ```bash
35
59
  export SYSTEM_ONE_BASE_URL=http://localhost:8008
60
+ export SYSTEM_ONE_MODEL=reflex
61
+ # Set SYSTEM_ONE_API_KEY only when your endpoint requires a key.
36
62
  ```
37
- or
38
63
 
39
- ##### typesafe/JEV
40
- ```bash
41
- export SYSTEM_ONE_BASE_URL=https://api.typesafe.ai
42
- export SYSTEM_ONE_MODEL=jev-latest
43
- # Option 1: paste the key directly
44
- export SYSTEM_ONE_API_KEY="..."
45
- # Option 2: reuse an existing variable
46
- export SYSTEM_ONE_API_KEY="$TYPESAFE_API_KEY"
47
- ```
64
+ Existing `SYSTEM_ONE_BASE_URL` takes precedence over saved settings. For TypeSafe, `jev-latest` is supplied automatically when no model is set. Other endpoints may require `SYSTEM_ONE_MODEL`.
48
65
 
49
66
 
50
67
  ---
51
68
 
52
- **Configuration**
69
+ ## Configuration
53
70
 
54
- The extension provides a `/so` command for configuring the System One client:
71
+ Run `/so config` to choose `typesafe` (default HTTP path), `custom` (any System One endpoint), or `native` (Pi classifier). `/so config native` and `/so config typesafe` switch directly. Non-secret settings are saved to `<agent-dir>/pi-system-one.json`, where `<agent-dir>` is Pi's agent directory (`~/.pi/agent` by default or `PI_CODING_AGENT_DIR`). Terminal Pi and Paseo must use the same agent directory to share that file and Pi's saved login. Restart or reload an already-running client after the other client changes settings. Use `/so status` to inspect current mode and credential availability.
55
72
 
56
- - `SYSTEM_ONE_BASE_URL` – required endpoint URL.
57
- - `SYSTEM_ONE_MODEL` – optional default model name.
58
- - `SYSTEM_ONE_API_KEY` – optional API key.
73
+ For example, a saved custom endpoint looks like this:
59
74
 
60
- When you run `/so config`, you can:
75
+ ```json
76
+ {
77
+ "mode": "custom",
78
+ "baseUrl": "http://localhost:8008",
79
+ "model": "reflex",
80
+ "timeoutMs": 10000
81
+ }
82
+ ```
61
83
 
62
- - **Enter a new API key** – it is stored in memory only and will be lost when the session ends.
63
- - **Enter `-`** – forget the memory‑only key. If a `SYSTEM_ONE_API_KEY` environment variable is present, the client will fall back to it; otherwise the key becomes absent.
64
- - **Leave the input empty** – keep the existing value.
84
+ Never store a key in JSON. A key entered into `/so config` stays in this session only. Leave key input blank to keep it; enter `-` to forget it. For custom endpoints, set `SYSTEM_ONE_BASE_URL` with `SYSTEM_ONE_API_KEY` if using environment credentials. An environment key without an environment endpoint never follows a saved custom URL. Custom URLs saved by `/so config` must be HTTP(S), without URL credentials, query, or fragment; put authentication in the key input or environment instead. Native mode supports Choice and Noul but rejects Score questions rather than hide missing probabilities. For full Score results, select `typesafe` or `custom`.
65
85
 
66
- The current configuration can be inspected with `/so status`.
86
+ Environment endpoint and model values override saved settings at startup. A saved model is not reused when an environment endpoint points elsewhere. When `/so config` changes a custom URL, blank model input clears the previous endpoint's model; enter a model to keep using one. An explicit `/so config` choice takes effect immediately in this session. If both are set, the environment endpoint wins again on next startup.
67
87
 
68
88
  ---
69
89
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-system-one",
3
- "version": "1.2.4",
3
+ "version": "1.3.1",
4
4
  "description": "System One decisions for Pi. Plug in TypeSafe Jev, Reflex, or your own provider.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -42,5 +42,8 @@
42
42
  "extensions": [
43
43
  "./src/extension.ts"
44
44
  ]
45
+ },
46
+ "devDependencies": {
47
+ "@earendil-works/pi-coding-agent": "1.0.1"
45
48
  }
46
49
  }
package/src/commands.ts CHANGED
@@ -1,19 +1,20 @@
1
- // pi-system-one/src/commands.ts
2
1
  import type {
3
2
  ExtensionAPI,
4
3
  ExtensionCommandContext,
5
4
  } from "@earendil-works/pi-coding-agent";
6
- import { HttpSystemOneProvider } from "system-one-core";
7
5
  import {
8
6
  applyConfigAnswers,
9
- blankSession,
10
- type ConfigAnswers,
7
+ type ConfigMode,
8
+ createSessionConfig,
11
9
  describeConfig,
12
10
  type SessionConfig,
11
+ TYPESAFE_BASE_URL,
12
+ TYPESAFE_MODEL,
13
13
  } from "./config.ts";
14
+ import { saveStoredConfig } from "./persistence.ts";
15
+ import { isTypeSafeEndpoint, resolveSessionProvider } from "./provider.ts";
14
16
  import { buildSystemOneTool } from "./tool.ts";
15
17
 
16
- /** Mutable holder: empty until env or `/so config` provides a base URL. */
17
18
  export interface SessionStore {
18
19
  session?: SessionConfig;
19
20
  }
@@ -21,106 +22,193 @@ export interface SessionStore {
21
22
  export function registerSystemOneCommands(
22
23
  pi: ExtensionAPI,
23
24
  store: SessionStore,
25
+ configPath?: string,
24
26
  ) {
27
+ let registered = false;
25
28
  function applyProvider() {
26
- if (!store.session?.current.baseUrl) return;
27
- const ext = store.session.current;
28
- const provider = new HttpSystemOneProvider({
29
- id: "configured",
30
- baseUrl: ext.baseUrl,
31
- apiKey: ext.apiKey,
32
- defaultModel: ext.model,
33
- timeoutMs: ext.timeoutMs,
34
- });
35
- pi.registerTool(buildSystemOneTool({ provider }));
29
+ if (registered) return;
30
+ registered = true;
31
+ pi.registerTool(
32
+ buildSystemOneTool({
33
+ resolveProvider: (ctx) =>
34
+ resolveSessionProvider(store.session ?? createSessionConfig(), ctx),
35
+ }),
36
+ );
36
37
  }
37
38
 
38
- async function cmdConfig(ctx: ExtensionCommandContext): Promise<void> {
39
- const cur = store.session?.current;
40
- const baseUrlRaw = await ctx.ui.input(
41
- `SYSTEM_ONE_BASE_URL (current: ${cur?.baseUrl || "unset"}):`,
42
- cur?.baseUrl ?? "",
43
- );
44
- if (baseUrlRaw === undefined) {
45
- ctx.ui.notify("Cancelled.", "info");
46
- return;
47
- }
48
- const baseUrl = baseUrlRaw.trim();
49
- if (!baseUrl && !cur?.baseUrl) {
50
- ctx.ui.notify("Cancelled (base URL is required).", "info");
51
- return;
52
- }
53
- const modelRaw = await ctx.ui.input(
54
- `SYSTEM_ONE_MODEL (current: ${cur?.model ?? "server default"}):`,
55
- cur?.model ?? "",
39
+ async function cmdConfig(
40
+ ctx: ExtensionCommandContext,
41
+ requestedMode?: string,
42
+ ): Promise<void> {
43
+ const current = store.session ?? createSessionConfig();
44
+ const modes: ConfigMode[] = [
45
+ current.mode,
46
+ ...(["typesafe", "custom", "native"] as ConfigMode[]).filter(
47
+ (mode) => mode !== current.mode,
48
+ ),
49
+ ];
50
+ const descriptions: Record<ConfigMode, string> = {
51
+ typesafe: "TypeSafe Jev (HTTP, full Score)",
52
+ custom: "Custom System One endpoint",
53
+ native: "Pi classifier (Choice and Noul only)",
54
+ };
55
+ const options = modes.map(
56
+ (mode) =>
57
+ `${mode} — ${descriptions[mode]}${mode === current.mode ? " (current)" : ""}`,
56
58
  );
57
- if (modelRaw === undefined) {
59
+ const modeRaw =
60
+ requestedMode ??
61
+ (await ctx.ui.select("System One provider mode", options));
62
+ if (modeRaw === undefined) {
58
63
  ctx.ui.notify("Cancelled.", "info");
59
64
  return;
60
65
  }
61
- const apiKeyRaw = await ctx.ui.input(
62
- "SYSTEM_ONE_API_KEY (empty = keep, - = forget memory key (reverts to environment variable if set, otherwise becomes absent), memory-only, gone when the session closes):",
63
- "",
64
- );
65
- if (apiKeyRaw === undefined) {
66
- ctx.ui.notify("Cancelled.", "info");
66
+ const mode =
67
+ requestedMode === undefined
68
+ ? modes[options.indexOf(modeRaw)]
69
+ : (modeRaw.trim().toLowerCase() as ConfigMode);
70
+ if (!mode || !modes.includes(mode)) {
71
+ ctx.ui.notify("Choose typesafe, custom, or native.", "error");
67
72
  return;
68
73
  }
69
- const answers: ConfigAnswers = {
70
- baseUrl,
71
- model: modelRaw.trim(),
72
- apiKey: apiKeyRaw.trim(),
73
- timeoutMs: "",
74
+ const next: SessionConfig = {
75
+ current: { ...current.current },
76
+ mode,
77
+ keyInMemory: current.keyInMemory,
74
78
  };
75
- if (!store.session) {
76
- store.session = blankSession();
79
+ if (mode === "typesafe") {
80
+ next.current.baseUrl = TYPESAFE_BASE_URL;
81
+ next.current.model = TYPESAFE_MODEL;
82
+ // A key exported for a custom endpoint must not follow a mode switch.
83
+ next.current.apiKey =
84
+ !process.env.SYSTEM_ONE_BASE_URL ||
85
+ isTypeSafeEndpoint(process.env.SYSTEM_ONE_BASE_URL)
86
+ ? process.env.SYSTEM_ONE_API_KEY || undefined
87
+ : undefined;
88
+ next.keyInMemory = false;
89
+ } else if (mode === "native") {
90
+ next.current.baseUrl = "";
91
+ next.current.model = TYPESAFE_MODEL;
92
+ next.current.apiKey = undefined;
93
+ next.keyInMemory = false;
94
+ } else {
95
+ const baseUrlRaw = await ctx.ui.input(
96
+ `SYSTEM_ONE_BASE_URL (current: ${current.mode === "custom" ? current.current.baseUrl : "unset"}):`,
97
+ current.mode === "custom" ? current.current.baseUrl : "",
98
+ );
99
+ if (baseUrlRaw === undefined) {
100
+ ctx.ui.notify("Cancelled.", "info");
101
+ return;
102
+ }
103
+ const baseUrl =
104
+ baseUrlRaw.trim() ||
105
+ (current.mode === "custom" ? current.current.baseUrl : "");
106
+ if (!baseUrl) {
107
+ ctx.ui.notify("Cancelled (base URL is required).", "info");
108
+ return;
109
+ }
110
+ const sameEndpoint =
111
+ current.mode === "custom" && baseUrl === current.current.baseUrl;
112
+ const modelRaw = await ctx.ui.input(
113
+ `SYSTEM_ONE_MODEL (current: ${sameEndpoint ? (current.current.model ?? "server default") : "server default"}):`,
114
+ sameEndpoint ? (current.current.model ?? "") : "",
115
+ );
116
+ if (modelRaw === undefined) {
117
+ ctx.ui.notify("Cancelled.", "info");
118
+ return;
119
+ }
120
+ const apiKeyRaw = await ctx.ui.input(
121
+ "SYSTEM_ONE_API_KEY (empty = keep, - = forget session key; never saved to JSON):",
122
+ "",
123
+ );
124
+ if (apiKeyRaw === undefined) {
125
+ ctx.ui.notify("Cancelled.", "info");
126
+ return;
127
+ }
128
+ next.current.baseUrl = baseUrl;
129
+ next.current.model =
130
+ modelRaw.trim() || (sameEndpoint ? current.current.model : undefined);
131
+ if (!sameEndpoint) {
132
+ // Never carry a key entered for one endpoint to another endpoint.
133
+ next.current.apiKey = undefined;
134
+ next.keyInMemory = false;
135
+ }
136
+ if (apiKeyRaw.trim() === "-") {
137
+ next.current.apiKey =
138
+ sameEndpoint && process.env.SYSTEM_ONE_BASE_URL === baseUrl
139
+ ? process.env.SYSTEM_ONE_API_KEY || undefined
140
+ : undefined;
141
+ next.keyInMemory = false;
142
+ } else if (apiKeyRaw.trim()) {
143
+ applyConfigAnswers(next, {
144
+ baseUrl: "",
145
+ model: "",
146
+ apiKey: apiKeyRaw.trim(),
147
+ timeoutMs: "",
148
+ });
149
+ }
77
150
  }
78
- const memoryKey = applyConfigAnswers(store.session, answers);
79
- applyProvider();
151
+ if (configPath) {
152
+ try {
153
+ saveStoredConfig(configPath, {
154
+ mode,
155
+ ...(mode === "custom"
156
+ ? {
157
+ baseUrl: next.current.baseUrl,
158
+ ...(next.current.model ? { model: next.current.model } : {}),
159
+ }
160
+ : {}),
161
+ timeoutMs: next.current.timeoutMs,
162
+ });
163
+ } catch (error) {
164
+ ctx.ui.notify(
165
+ `Could not save settings: ${error instanceof Error ? error.message : String(error)}`,
166
+ "error",
167
+ );
168
+ return;
169
+ }
170
+ }
171
+ store.session = next;
80
172
  ctx.ui.notify(
81
- memoryKey
82
- ? "Updated for this session. API key is in memory only and will be gone when the session closes; export SYSTEM_ONE_API_KEY to persist it."
83
- : "Updated for this session.",
173
+ next.keyInMemory
174
+ ? "Updated. API key stays in memory only; other settings were saved."
175
+ : configPath
176
+ ? "Settings saved."
177
+ : "Updated for this session.",
84
178
  "info",
85
179
  );
86
180
  }
87
181
 
88
182
  async function cmdStatus(ctx: ExtensionCommandContext): Promise<void> {
89
- if (!store.session) {
90
- ctx.ui.notify(
91
- "System One is not configured. Export SYSTEM_ONE_BASE_URL or run /so config.",
92
- "info",
183
+ const session = store.session ?? createSessionConfig();
184
+ let summary = describeConfig(session);
185
+ if (
186
+ session.mode !== "native" &&
187
+ !session.current.apiKey &&
188
+ isTypeSafeEndpoint(session.current.baseUrl)
189
+ ) {
190
+ const key = await ctx.modelRegistry?.getApiKeyForProvider?.("typesafe");
191
+ summary = summary.replace(
192
+ "api key: absent",
193
+ key
194
+ ? "api key: Pi TypeSafe credentials available"
195
+ : "api key: absent (run /login typesafe)",
93
196
  );
94
- return;
95
197
  }
96
- ctx.ui.notify(describeConfig(store.session), "info");
198
+ ctx.ui.notify(summary, "info");
97
199
  }
98
200
 
99
- const completions = [
100
- {
101
- value: "config",
102
- label: "config - change endpoint, model, or key",
103
- description: "Reconfigure System One for this session",
104
- },
105
- {
106
- value: "status",
107
- label: "status - show current config",
108
- description: "Show current endpoint, model, and key source",
109
- },
110
- ];
111
-
112
201
  pi.registerCommand("so", {
113
202
  description: "System One config (/so status for current)",
114
- getArgumentCompletions: (prefix: string) => {
115
- const p = prefix.trim().toLowerCase();
116
- return completions.filter((c) => c.value.startsWith(p));
117
- },
203
+ getArgumentCompletions: (prefix: string) =>
204
+ ["config", "status"]
205
+ .filter((c) => c.startsWith(prefix.trim().toLowerCase()))
206
+ .map((c) => ({ value: c, label: c })),
118
207
  handler: async (args, ctx) => {
119
- const [cmd] = args.trim().toLowerCase().split(/\s+/);
208
+ const [cmd, mode] = args.trim().toLowerCase().split(/\s+/);
120
209
  if (cmd === "status") await cmdStatus(ctx);
121
- else await cmdConfig(ctx);
210
+ else await cmdConfig(ctx, cmd === "config" ? mode : undefined);
122
211
  },
123
212
  });
124
-
125
213
  return { applyProvider, store };
126
214
  }
package/src/config.ts CHANGED
@@ -1,10 +1,9 @@
1
1
  // pi-system-one/src/config.ts
2
2
  //
3
- // Configuration is environment variables. `/so config` applies overrides
4
- // in memory for the current session only. API keys are never written to
5
- // disk by this extension: a key typed into `/so config` lives in memory
6
- // and is gone when the session closes. To persist, export it
7
- // (e.g. SYSTEM_ONE_API_KEY) in the shell.
3
+ // Non-secret settings can persist in the user agent directory. Environment
4
+ // variables retain precedence at startup. API keys are never written by this
5
+ // extension: /so config keys live only in memory; Pi login or explicit
6
+ // environment credentials provide persistent authentication.
8
7
 
9
8
  export interface SystemOneEnv {
10
9
  SYSTEM_ONE_BASE_URL?: string;
@@ -21,6 +20,17 @@ export interface SystemOneExtConfig {
21
20
  }
22
21
 
23
22
  export const DEFAULT_TIMEOUT_MS = 10_000;
23
+ export const TYPESAFE_BASE_URL = "https://api.typesafe.ai";
24
+ export const TYPESAFE_MODEL = "jev-latest";
25
+ export type ConfigMode = "typesafe" | "custom" | "native";
26
+
27
+ /** Public settings only. Credentials never belong in this file. */
28
+ export interface StoredConfig {
29
+ mode?: ConfigMode;
30
+ baseUrl?: string;
31
+ model?: string;
32
+ timeoutMs?: number;
33
+ }
24
34
 
25
35
  export function loadSystemOneConfig(
26
36
  env: SystemOneEnv = process.env,
@@ -48,12 +58,49 @@ export interface SessionConfig {
48
58
  current: SystemOneExtConfig;
49
59
  /** True when the key came from `/so config` (memory-only). */
50
60
  keyInMemory: boolean;
61
+ mode: ConfigMode;
51
62
  }
52
63
 
53
64
  export function createSessionConfig(
54
65
  env: SystemOneEnv = process.env,
66
+ stored: StoredConfig = {},
55
67
  ): SessionConfig {
56
- return { current: loadSystemOneConfig(env), keyInMemory: false };
68
+ const mode: ConfigMode = env.SYSTEM_ONE_BASE_URL
69
+ ? "custom"
70
+ : (stored.mode ?? "typesafe");
71
+ const baseUrl =
72
+ mode === "native"
73
+ ? ""
74
+ : (env.SYSTEM_ONE_BASE_URL ??
75
+ (mode === "typesafe" ? TYPESAFE_BASE_URL : (stored.baseUrl ?? "")));
76
+ const rawTimeout = env.SYSTEM_ONE_TIMEOUT_MS
77
+ ? Number(env.SYSTEM_ONE_TIMEOUT_MS)
78
+ : (stored.timeoutMs ?? DEFAULT_TIMEOUT_MS);
79
+ if (!Number.isFinite(rawTimeout) || rawTimeout <= 0)
80
+ throw new Error("SYSTEM_ONE_TIMEOUT_MS must be a positive number");
81
+ return {
82
+ mode,
83
+ current: {
84
+ baseUrl,
85
+ // An env key without an env endpoint must not follow a saved custom URL.
86
+ apiKey:
87
+ mode === "native" ||
88
+ (stored.mode === "custom" && !env.SYSTEM_ONE_BASE_URL)
89
+ ? undefined
90
+ : env.SYSTEM_ONE_API_KEY || undefined,
91
+ model:
92
+ mode === "native"
93
+ ? TYPESAFE_MODEL
94
+ : env.SYSTEM_ONE_MODEL ||
95
+ (env.SYSTEM_ONE_BASE_URL &&
96
+ env.SYSTEM_ONE_BASE_URL !== stored.baseUrl
97
+ ? undefined
98
+ : stored.model) ||
99
+ (mode === "typesafe" ? TYPESAFE_MODEL : undefined),
100
+ timeoutMs: rawTimeout,
101
+ },
102
+ keyInMemory: false,
103
+ };
57
104
  }
58
105
 
59
106
  /** Empty session for `/so config` when no env is present. */
@@ -66,6 +113,7 @@ export function blankSession(): SessionConfig {
66
113
  timeoutMs: DEFAULT_TIMEOUT_MS,
67
114
  },
68
115
  keyInMemory: false,
116
+ mode: "custom",
69
117
  };
70
118
  }
71
119
 
@@ -106,14 +154,18 @@ export function applyConfigAnswers(
106
154
  /** Human-readable summary. Shows whether a key exists, never the key. */
107
155
  export function describeConfig(session: SessionConfig): string {
108
156
  const c = session.current;
109
- const keyLine = !c.apiKey
110
- ? " api key: absent"
111
- : session.keyInMemory
112
- ? " api key: in memory only (gone when the session closes; export SYSTEM_ONE_API_KEY to persist)"
113
- : " api key: from environment";
157
+ const keyLine =
158
+ session.mode === "native"
159
+ ? " api key: managed by Pi (native classifier)"
160
+ : !c.apiKey
161
+ ? " api key: absent"
162
+ : session.keyInMemory
163
+ ? " api key: in memory only (gone when the session closes; export SYSTEM_ONE_API_KEY to persist)"
164
+ : " api key: from environment";
114
165
  return [
115
166
  "System One:",
116
- ` endpoint: ${c.baseUrl}`,
167
+ ` mode: ${session.mode}`,
168
+ ` endpoint: ${c.baseUrl || "none"}`,
117
169
  keyLine,
118
170
  ` model: ${c.model ?? "server default"}`,
119
171
  ` timeout: ${c.timeoutMs}ms`,
package/src/extension.ts CHANGED
@@ -2,9 +2,13 @@
2
2
 
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ type ExtensionAPI,
7
+ getAgentDir,
8
+ } from "@earendil-works/pi-coding-agent";
6
9
  import { registerSystemOneCommands, type SessionStore } from "./commands.ts";
7
10
  import { createSessionConfig } from "./config.ts";
11
+ import { loadStoredConfig } from "./persistence.ts";
8
12
 
9
13
  /**
10
14
  * Directory holding the bundled `system-one` skill (use-case catalog).
@@ -22,23 +26,29 @@ export function systemOnePromptsDir(): string {
22
26
  }
23
27
 
24
28
  export default function piSystemOneExtension(pi: ExtensionAPI) {
25
- // The /so command is always registered so config stays reachable with
26
- // no env set. The tool appears once a base URL exists (env or /so config).
29
+ const configPath = join(getAgentDir(), "pi-system-one.json");
27
30
  const store: SessionStore = {};
28
31
  try {
29
- store.session = createSessionConfig();
30
- } catch {
31
- pi.on("session_start", (event, ctx) => {
32
- if (event.reason === "startup" || event.reason === "new") {
33
- ctx.ui.notify(
34
- "System One: SYSTEM_ONE_BASE_URL is not set. Export it or run /so config.",
35
- "warning",
36
- );
37
- }
32
+ store.session = createSessionConfig(
33
+ process.env,
34
+ loadStoredConfig(configPath),
35
+ );
36
+ } catch (error) {
37
+ const message = error instanceof Error ? error.message : String(error);
38
+ try {
39
+ store.session = createSessionConfig(process.env);
40
+ } catch {
41
+ store.session = createSessionConfig({});
42
+ }
43
+ pi.on("session_start", (_event, ctx) => {
44
+ ctx.ui.notify(
45
+ `System One: ${message}. Ignoring saved settings; fix them or run /so config.`,
46
+ "warning",
47
+ );
38
48
  });
39
49
  }
40
50
 
41
- const { applyProvider } = registerSystemOneCommands(pi, store);
51
+ const { applyProvider } = registerSystemOneCommands(pi, store, configPath);
42
52
  applyProvider();
43
53
 
44
54
  // Contribute the bundled `system-one` skill (use-case catalog) and the