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/README.md +7 -5
- package/package.json +2 -1
- package/src/commands.ts +40 -7
- package/src/config.ts +71 -20
- package/src/debug-ui.ts +125 -0
- package/src/debug.ts +159 -0
- package/src/persistence.ts +2 -2
- package/src/provider.ts +149 -25
- package/tests/commands.test.ts +138 -4
- package/tests/config.test.ts +64 -8
- package/tests/debug.test.ts +345 -0
- package/tests/extension.test.ts +79 -11
- package/tests/persistence.test.ts +15 -0
- package/tests/provider.test.ts +209 -0
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ Pi's [v1.0.3 Codemode guide](https://github.com/earendil-works/pi/blob/v1.0.3/pa
|
|
|
26
26
|
|
|
27
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
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).
|
|
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. Optional `auto` mode routes native-compatible Choice/Noul requests to Pi's classifier when registered and other requests to TypeSafe HTTP; it does not retry a failed call through another backend. 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
30
|
|
|
31
31
|
For Oh My Pi (OMP), install the published package with:
|
|
32
32
|
|
|
@@ -61,14 +61,14 @@ export SYSTEM_ONE_MODEL=reflex
|
|
|
61
61
|
# Set SYSTEM_ONE_API_KEY only when your endpoint requires a key.
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
`SYSTEM_ONE_BASE_URL` selects a custom endpoint when no mode is saved. A saved mode and custom endpoint win on restart. TypeSafe defaults to `jev-latest`; other endpoints may require `SYSTEM_ONE_MODEL`.
|
|
65
65
|
|
|
66
66
|
|
|
67
67
|
---
|
|
68
68
|
|
|
69
69
|
## Configuration
|
|
70
70
|
|
|
71
|
-
Run `/so config` to
|
|
71
|
+
Run `/so` or `/so config` to select `typesafe` (default HTTP path), `custom` (a configured System One endpoint), `native` (Pi classifier), or `auto` (per-request routing). `/so config auto`, `/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.
|
|
72
72
|
|
|
73
73
|
For example, a saved custom endpoint looks like this:
|
|
74
74
|
|
|
@@ -81,9 +81,11 @@ For example, a saved custom endpoint looks like this:
|
|
|
81
81
|
}
|
|
82
82
|
```
|
|
83
83
|
|
|
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
|
|
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`, `custom`, or `auto`. Auto uses native only when the complete request is compatible and Pi has a registered classifier. Otherwise, it uses canonical TypeSafe HTTP, not a custom endpoint. A failed native call does not trigger an HTTP retry. Registered models may still fail without valid credentials.
|
|
85
85
|
|
|
86
|
-
|
|
86
|
+
Use `/so debug` to inspect up to 20 recent `system_one` calls in a terminal overlay. It shows selected provider (`pi-native`, `typesafe`, or `configured`), route reason, sanitized HTTP POST target, outcome, timing, and question-type counts. Failed calls distinguish provider selection from an attempted request, and show fixed native failure categories (unsupported Score, classifier exception/error, invalid response, or abort) without provider-controlled error text. Diagnostics stay in memory for the current Pi session; they never store request state, criteria, credentials, raw URLs, or raw provider error text. Target display retains origin and common API path segments but redacts URL userinfo, queries, fragments, and other path segments. In RPC mode, `/so debug` sends a brief notification instead; JSON and print modes have no UI. `/so status` remains focused on settings.
|
|
87
|
+
|
|
88
|
+
A current-session selection takes effect immediately. Saved mode and saved custom endpoint win on restart. `SYSTEM_ONE_BASE_URL` selects a custom endpoint only when no mode is saved; without either, TypeSafe HTTP remains the default. Environment keys and models are used only with their matching endpoint, while `SYSTEM_ONE_TIMEOUT_MS` can override a saved timeout. `/so status` shows the mode source and notes when a saved choice ignores an environment endpoint. To return to environment-only routing, remove the saved non-secret settings file. When `/so config` changes a custom URL, blank model input clears the old endpoint's model; enter a replacement when needed.
|
|
87
89
|
|
|
88
90
|
---
|
|
89
91
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-system-one",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.2",
|
|
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",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"coding-agent"
|
|
28
28
|
],
|
|
29
29
|
"dependencies": {
|
|
30
|
+
"@earendil-works/pi-tui": "^1.0.4",
|
|
30
31
|
"system-one-core": "^0.2.1"
|
|
31
32
|
},
|
|
32
33
|
"peerDependencies": {
|
package/src/commands.ts
CHANGED
|
@@ -7,12 +7,14 @@ import {
|
|
|
7
7
|
type ConfigMode,
|
|
8
8
|
createSessionConfig,
|
|
9
9
|
describeConfig,
|
|
10
|
+
isTypeSafeEndpoint,
|
|
10
11
|
type SessionConfig,
|
|
11
12
|
TYPESAFE_BASE_URL,
|
|
12
13
|
TYPESAFE_MODEL,
|
|
13
14
|
} from "./config.ts";
|
|
15
|
+
import { DebugHistory, observedProvider } from "./debug.ts";
|
|
16
|
+
import { DebugOverlay, debugSummary } from "./debug-ui.ts";
|
|
14
17
|
import { saveStoredConfig } from "./persistence.ts";
|
|
15
|
-
import { isTypeSafeEndpoint, resolveSessionProvider } from "./provider.ts";
|
|
16
18
|
import { buildSystemOneTool } from "./tool.ts";
|
|
17
19
|
|
|
18
20
|
export interface SessionStore {
|
|
@@ -25,13 +27,19 @@ export function registerSystemOneCommands(
|
|
|
25
27
|
configPath?: string,
|
|
26
28
|
) {
|
|
27
29
|
let registered = false;
|
|
30
|
+
const history = new DebugHistory();
|
|
31
|
+
pi.on("session_start", () => history.clear());
|
|
28
32
|
function applyProvider() {
|
|
29
33
|
if (registered) return;
|
|
30
34
|
registered = true;
|
|
31
35
|
pi.registerTool(
|
|
32
36
|
buildSystemOneTool({
|
|
33
|
-
resolveProvider: (ctx) =>
|
|
34
|
-
|
|
37
|
+
resolveProvider: async (ctx) =>
|
|
38
|
+
observedProvider(
|
|
39
|
+
store.session ?? createSessionConfig(),
|
|
40
|
+
ctx,
|
|
41
|
+
history,
|
|
42
|
+
),
|
|
35
43
|
}),
|
|
36
44
|
);
|
|
37
45
|
}
|
|
@@ -43,7 +51,7 @@ export function registerSystemOneCommands(
|
|
|
43
51
|
const current = store.session ?? createSessionConfig();
|
|
44
52
|
const modes: ConfigMode[] = [
|
|
45
53
|
current.mode,
|
|
46
|
-
...(["typesafe", "custom", "native"] as ConfigMode[]).filter(
|
|
54
|
+
...(["typesafe", "custom", "native", "auto"] as ConfigMode[]).filter(
|
|
47
55
|
(mode) => mode !== current.mode,
|
|
48
56
|
),
|
|
49
57
|
];
|
|
@@ -51,6 +59,7 @@ export function registerSystemOneCommands(
|
|
|
51
59
|
typesafe: "TypeSafe Jev (HTTP, full Score)",
|
|
52
60
|
custom: "Custom System One endpoint",
|
|
53
61
|
native: "Pi classifier (Choice and Noul only)",
|
|
62
|
+
auto: "Native when compatible, TypeSafe HTTP otherwise",
|
|
54
63
|
};
|
|
55
64
|
const options = modes.map(
|
|
56
65
|
(mode) =>
|
|
@@ -68,15 +77,17 @@ export function registerSystemOneCommands(
|
|
|
68
77
|
? modes[options.indexOf(modeRaw)]
|
|
69
78
|
: (modeRaw.trim().toLowerCase() as ConfigMode);
|
|
70
79
|
if (!mode || !modes.includes(mode)) {
|
|
71
|
-
ctx.ui.notify("Choose typesafe, custom, or
|
|
80
|
+
ctx.ui.notify("Choose typesafe, custom, native, or auto.", "error");
|
|
72
81
|
return;
|
|
73
82
|
}
|
|
74
83
|
const next: SessionConfig = {
|
|
75
84
|
current: { ...current.current },
|
|
76
85
|
mode,
|
|
86
|
+
modeSource: "session",
|
|
87
|
+
ignoredEnvironmentEndpoint: false,
|
|
77
88
|
keyInMemory: current.keyInMemory,
|
|
78
89
|
};
|
|
79
|
-
if (mode === "typesafe") {
|
|
90
|
+
if (mode === "typesafe" || mode === "auto") {
|
|
80
91
|
next.current.baseUrl = TYPESAFE_BASE_URL;
|
|
81
92
|
next.current.model = TYPESAFE_MODEL;
|
|
82
93
|
// A key exported for a custom endpoint must not follow a mode switch.
|
|
@@ -148,6 +159,10 @@ export function registerSystemOneCommands(
|
|
|
148
159
|
});
|
|
149
160
|
}
|
|
150
161
|
}
|
|
162
|
+
next.ignoredEnvironmentEndpoint = Boolean(
|
|
163
|
+
process.env.SYSTEM_ONE_BASE_URL &&
|
|
164
|
+
process.env.SYSTEM_ONE_BASE_URL !== next.current.baseUrl,
|
|
165
|
+
);
|
|
151
166
|
if (configPath) {
|
|
152
167
|
try {
|
|
153
168
|
saveStoredConfig(configPath, {
|
|
@@ -198,15 +213,33 @@ export function registerSystemOneCommands(
|
|
|
198
213
|
ctx.ui.notify(summary, "info");
|
|
199
214
|
}
|
|
200
215
|
|
|
216
|
+
async function cmdDebug(ctx: ExtensionCommandContext): Promise<void> {
|
|
217
|
+
if (ctx.mode !== "tui") {
|
|
218
|
+
ctx.ui.notify(debugSummary(history.entries), "info");
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
await ctx.ui.custom<void>(
|
|
222
|
+
(tui, theme, _keybindings, done) =>
|
|
223
|
+
new DebugOverlay([...history.entries], theme, done, () =>
|
|
224
|
+
tui.requestRender(),
|
|
225
|
+
),
|
|
226
|
+
{
|
|
227
|
+
overlay: true,
|
|
228
|
+
overlayOptions: { anchor: "center", width: 70, maxHeight: 24 },
|
|
229
|
+
},
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
201
233
|
pi.registerCommand("so", {
|
|
202
234
|
description: "System One config (/so status for current)",
|
|
203
235
|
getArgumentCompletions: (prefix: string) =>
|
|
204
|
-
["config", "status"]
|
|
236
|
+
["config", "status", "debug"]
|
|
205
237
|
.filter((c) => c.startsWith(prefix.trim().toLowerCase()))
|
|
206
238
|
.map((c) => ({ value: c, label: c })),
|
|
207
239
|
handler: async (args, ctx) => {
|
|
208
240
|
const [cmd, mode] = args.trim().toLowerCase().split(/\s+/);
|
|
209
241
|
if (cmd === "status") await cmdStatus(ctx);
|
|
242
|
+
else if (cmd === "debug") await cmdDebug(ctx);
|
|
210
243
|
else await cmdConfig(ctx, cmd === "config" ? mode : undefined);
|
|
211
244
|
},
|
|
212
245
|
});
|
package/src/config.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// pi-system-one/src/config.ts
|
|
2
2
|
//
|
|
3
|
-
// Non-secret settings
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// Non-secret settings persist in the user agent directory. A saved mode and
|
|
4
|
+
// endpoint override an inherited environment URL; environment-only setups
|
|
5
|
+
// still select their URL. API keys are never written by this extension:
|
|
6
|
+
// /so config keys live only in memory; Pi login or endpoint-bound environment
|
|
7
|
+
// credentials provide persistent authentication.
|
|
7
8
|
|
|
8
9
|
export interface SystemOneEnv {
|
|
9
10
|
SYSTEM_ONE_BASE_URL?: string;
|
|
@@ -22,7 +23,24 @@ export interface SystemOneExtConfig {
|
|
|
22
23
|
export const DEFAULT_TIMEOUT_MS = 10_000;
|
|
23
24
|
export const TYPESAFE_BASE_URL = "https://api.typesafe.ai";
|
|
24
25
|
export const TYPESAFE_MODEL = "jev-latest";
|
|
25
|
-
|
|
26
|
+
|
|
27
|
+
export function isTypeSafeEndpoint(baseUrl: string): boolean {
|
|
28
|
+
try {
|
|
29
|
+
const url = new URL(baseUrl);
|
|
30
|
+
return (
|
|
31
|
+
url.origin === TYPESAFE_BASE_URL &&
|
|
32
|
+
url.pathname === "/" &&
|
|
33
|
+
!url.search &&
|
|
34
|
+
!url.hash &&
|
|
35
|
+
!url.username &&
|
|
36
|
+
!url.password
|
|
37
|
+
);
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type ConfigMode = "typesafe" | "custom" | "native" | "auto";
|
|
26
44
|
|
|
27
45
|
/** Public settings only. Credentials never belong in this file. */
|
|
28
46
|
export interface StoredConfig {
|
|
@@ -59,20 +77,24 @@ export interface SessionConfig {
|
|
|
59
77
|
/** True when the key came from `/so config` (memory-only). */
|
|
60
78
|
keyInMemory: boolean;
|
|
61
79
|
mode: ConfigMode;
|
|
80
|
+
modeSource: "session" | "saved" | "environment" | "default";
|
|
81
|
+
ignoredEnvironmentEndpoint: boolean;
|
|
62
82
|
}
|
|
63
83
|
|
|
64
84
|
export function createSessionConfig(
|
|
65
85
|
env: SystemOneEnv = process.env,
|
|
66
86
|
stored: StoredConfig = {},
|
|
67
87
|
): SessionConfig {
|
|
68
|
-
const mode: ConfigMode =
|
|
69
|
-
? "custom"
|
|
70
|
-
: (stored.mode ?? "typesafe");
|
|
88
|
+
const mode: ConfigMode =
|
|
89
|
+
stored.mode ?? (env.SYSTEM_ONE_BASE_URL ? "custom" : "typesafe");
|
|
71
90
|
const baseUrl =
|
|
72
91
|
mode === "native"
|
|
73
92
|
? ""
|
|
74
|
-
:
|
|
75
|
-
|
|
93
|
+
: mode === "typesafe" || mode === "auto"
|
|
94
|
+
? TYPESAFE_BASE_URL
|
|
95
|
+
: stored.mode === "custom"
|
|
96
|
+
? (stored.baseUrl ?? "")
|
|
97
|
+
: (env.SYSTEM_ONE_BASE_URL ?? "");
|
|
76
98
|
const rawTimeout = env.SYSTEM_ONE_TIMEOUT_MS
|
|
77
99
|
? Number(env.SYSTEM_ONE_TIMEOUT_MS)
|
|
78
100
|
: (stored.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
@@ -80,23 +102,42 @@ export function createSessionConfig(
|
|
|
80
102
|
throw new Error("SYSTEM_ONE_TIMEOUT_MS must be a positive number");
|
|
81
103
|
return {
|
|
82
104
|
mode,
|
|
105
|
+
modeSource: stored.mode
|
|
106
|
+
? "saved"
|
|
107
|
+
: env.SYSTEM_ONE_BASE_URL
|
|
108
|
+
? "environment"
|
|
109
|
+
: "default",
|
|
110
|
+
ignoredEnvironmentEndpoint: Boolean(
|
|
111
|
+
stored.mode &&
|
|
112
|
+
env.SYSTEM_ONE_BASE_URL &&
|
|
113
|
+
env.SYSTEM_ONE_BASE_URL !== baseUrl,
|
|
114
|
+
),
|
|
83
115
|
current: {
|
|
84
116
|
baseUrl,
|
|
85
|
-
//
|
|
117
|
+
// Bind environment credentials and model to their configured endpoint.
|
|
86
118
|
apiKey:
|
|
87
119
|
mode === "native" ||
|
|
88
|
-
(
|
|
120
|
+
(mode === "custom" &&
|
|
121
|
+
stored.mode === "custom" &&
|
|
122
|
+
env.SYSTEM_ONE_BASE_URL !== baseUrl) ||
|
|
123
|
+
((mode === "typesafe" || mode === "auto") &&
|
|
124
|
+
env.SYSTEM_ONE_BASE_URL &&
|
|
125
|
+
!isTypeSafeEndpoint(env.SYSTEM_ONE_BASE_URL))
|
|
89
126
|
? undefined
|
|
90
127
|
: env.SYSTEM_ONE_API_KEY || undefined,
|
|
91
128
|
model:
|
|
92
129
|
mode === "native"
|
|
93
130
|
? TYPESAFE_MODEL
|
|
94
|
-
:
|
|
95
|
-
(env.SYSTEM_ONE_BASE_URL
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
131
|
+
: mode === "typesafe"
|
|
132
|
+
? ((!env.SYSTEM_ONE_BASE_URL ||
|
|
133
|
+
isTypeSafeEndpoint(env.SYSTEM_ONE_BASE_URL)) &&
|
|
134
|
+
env.SYSTEM_ONE_MODEL) ||
|
|
135
|
+
TYPESAFE_MODEL
|
|
136
|
+
: mode === "auto"
|
|
137
|
+
? TYPESAFE_MODEL
|
|
138
|
+
: (env.SYSTEM_ONE_BASE_URL === baseUrl
|
|
139
|
+
? env.SYSTEM_ONE_MODEL
|
|
140
|
+
: undefined) || stored.model,
|
|
100
141
|
timeoutMs: rawTimeout,
|
|
101
142
|
},
|
|
102
143
|
keyInMemory: false,
|
|
@@ -114,6 +155,8 @@ export function blankSession(): SessionConfig {
|
|
|
114
155
|
},
|
|
115
156
|
keyInMemory: false,
|
|
116
157
|
mode: "custom",
|
|
158
|
+
modeSource: "default",
|
|
159
|
+
ignoredEnvironmentEndpoint: false,
|
|
117
160
|
};
|
|
118
161
|
}
|
|
119
162
|
|
|
@@ -164,8 +207,16 @@ export function describeConfig(session: SessionConfig): string {
|
|
|
164
207
|
: " api key: from environment";
|
|
165
208
|
return [
|
|
166
209
|
"System One:",
|
|
167
|
-
` mode: ${session.mode}`,
|
|
168
|
-
|
|
210
|
+
` mode: ${session.mode} (${session.modeSource})`,
|
|
211
|
+
...(session.mode === "auto"
|
|
212
|
+
? [
|
|
213
|
+
" routing: per request (native when compatible; TypeSafe HTTP otherwise)",
|
|
214
|
+
]
|
|
215
|
+
: []),
|
|
216
|
+
...(session.ignoredEnvironmentEndpoint
|
|
217
|
+
? [" environment endpoint ignored by selected mode"]
|
|
218
|
+
: []),
|
|
219
|
+
` ${session.mode === "auto" ? "HTTP fallback endpoint" : "endpoint"}: ${c.baseUrl || "none"}`,
|
|
169
220
|
keyLine,
|
|
170
221
|
` model: ${c.model ?? "server default"}`,
|
|
171
222
|
` timeout: ${c.timeoutMs}ms`,
|
package/src/debug-ui.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
matchesKey,
|
|
4
|
+
truncateToWidth,
|
|
5
|
+
visibleWidth,
|
|
6
|
+
} from "@earendil-works/pi-tui";
|
|
7
|
+
import type { DebugEntry } from "./debug.ts";
|
|
8
|
+
|
|
9
|
+
function counts(entry: DebugEntry): string {
|
|
10
|
+
const { choice, noul, score } = entry.questions;
|
|
11
|
+
return `${choice} choice · ${noul} noul · ${score} score`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function rowCounts(entry: DebugEntry): string {
|
|
15
|
+
const { choice, noul, score } = entry.questions;
|
|
16
|
+
return [
|
|
17
|
+
choice ? `${choice} choice` : "",
|
|
18
|
+
noul ? `${noul} noul` : "",
|
|
19
|
+
score ? `${score} score` : "",
|
|
20
|
+
]
|
|
21
|
+
.filter(Boolean)
|
|
22
|
+
.join(" · ");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Only render fields assembled by DebugHistory, never tool input or raw provider text. */
|
|
26
|
+
export function debugSummary(entries: readonly DebugEntry[]): string {
|
|
27
|
+
const last = entries[0];
|
|
28
|
+
if (!last) return "System One debug: No system_one calls yet.";
|
|
29
|
+
return `System One debug: ${last.provider} · ${last.outcome} · ${last.durationMs ?? "running"} ms · ${counts(last)}. Session-only; ${entries.length} call(s) recorded.`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class DebugOverlay {
|
|
33
|
+
private selected = 0;
|
|
34
|
+
private readonly entries: readonly DebugEntry[];
|
|
35
|
+
private readonly theme: Theme;
|
|
36
|
+
private readonly done: () => void;
|
|
37
|
+
private readonly requestRender: () => void;
|
|
38
|
+
constructor(
|
|
39
|
+
entries: readonly DebugEntry[],
|
|
40
|
+
theme: Theme,
|
|
41
|
+
done: () => void,
|
|
42
|
+
requestRender: () => void,
|
|
43
|
+
) {
|
|
44
|
+
this.entries = entries;
|
|
45
|
+
this.theme = theme;
|
|
46
|
+
this.done = done;
|
|
47
|
+
this.requestRender = requestRender;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
handleInput(data: string): void {
|
|
51
|
+
if (
|
|
52
|
+
matchesKey(data, "escape") ||
|
|
53
|
+
matchesKey(data, "q") ||
|
|
54
|
+
matchesKey(data, "ctrl+c")
|
|
55
|
+
)
|
|
56
|
+
this.done();
|
|
57
|
+
else if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
58
|
+
this.selected = Math.max(0, this.selected - 1);
|
|
59
|
+
this.requestRender();
|
|
60
|
+
} else if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
61
|
+
this.selected = Math.min(
|
|
62
|
+
Math.max(0, this.entries.length - 1),
|
|
63
|
+
this.selected + 1,
|
|
64
|
+
);
|
|
65
|
+
this.requestRender();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
render(width: number): string[] {
|
|
70
|
+
if (width < 3) return [" ".repeat(Math.max(0, width))];
|
|
71
|
+
const inner = width - 2;
|
|
72
|
+
const th = this.theme;
|
|
73
|
+
const page = Math.floor(this.selected / 7) * 7;
|
|
74
|
+
const pageEnd = Math.min(page + 7, this.entries.length);
|
|
75
|
+
const lines = [
|
|
76
|
+
th.fg("accent", " System One · Debug"),
|
|
77
|
+
th.fg(
|
|
78
|
+
"text",
|
|
79
|
+
this.entries.length
|
|
80
|
+
? ` ${page + 1}-${pageEnd} of ${this.entries.length} recent calls · selected #${this.selected + 1} · session-only`
|
|
81
|
+
: " Session-only · no request content saved",
|
|
82
|
+
),
|
|
83
|
+
"",
|
|
84
|
+
];
|
|
85
|
+
if (this.entries.length === 0) lines.push(" No system_one calls yet.");
|
|
86
|
+
else {
|
|
87
|
+
for (let index = page; index < pageEnd; index++) {
|
|
88
|
+
const entry = this.entries[index];
|
|
89
|
+
const row = `${index === this.selected ? ">" : " "} #${index + 1} ${entry.startedAt.slice(11, 19)} ${entry.provider.padEnd(10)} ${entry.outcome.padEnd(7)} ${rowCounts(entry)}`;
|
|
90
|
+
lines.push(index === this.selected ? th.fg("accent", row) : row);
|
|
91
|
+
}
|
|
92
|
+
const entry = this.entries[this.selected];
|
|
93
|
+
lines.push(
|
|
94
|
+
"",
|
|
95
|
+
th.fg("accent", " Selected call"),
|
|
96
|
+
` Mode: ${entry.mode} · Provider: ${entry.provider}`,
|
|
97
|
+
` Route: ${entry.reason}`,
|
|
98
|
+
...(entry.requestTarget ? [` Target: ${entry.requestTarget}`] : []),
|
|
99
|
+
` Questions: ${counts(entry)}`,
|
|
100
|
+
` Outcome: ${entry.outcome} · Duration: ${entry.durationMs ?? "running"} ms`,
|
|
101
|
+
` Dispatch: ${entry.attempted ? "attempted (delivery not verified)" : "not attempted"}`,
|
|
102
|
+
...(entry.failure
|
|
103
|
+
? [
|
|
104
|
+
` Failure: ${entry.failure}${entry.httpStatus ? ` (HTTP ${entry.httpStatus})` : ""}`,
|
|
105
|
+
]
|
|
106
|
+
: []),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
lines.push("", th.fg("text", " ↑/↓ browse · q/Esc close"));
|
|
110
|
+
const output = [th.fg("border", `╭${"─".repeat(inner)}╮`)];
|
|
111
|
+
for (const line of lines) {
|
|
112
|
+
const clipped = truncateToWidth(line, inner, "…", true);
|
|
113
|
+
output.push(
|
|
114
|
+
th.fg("border", "│") +
|
|
115
|
+
clipped +
|
|
116
|
+
" ".repeat(Math.max(0, inner - visibleWidth(clipped))) +
|
|
117
|
+
th.fg("border", "│"),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
output.push(th.fg("border", `╰${"─".repeat(inner)}╯`));
|
|
121
|
+
return output;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
invalidate(): void {}
|
|
125
|
+
}
|
package/src/debug.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { ExtensionToolContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
type QuestionMap,
|
|
4
|
+
type SystemOneCallOptions,
|
|
5
|
+
SystemOneHttpError,
|
|
6
|
+
type SystemOneProvider,
|
|
7
|
+
type SystemOneRequest,
|
|
8
|
+
type SystemOneResponse,
|
|
9
|
+
SystemOneTimeoutError,
|
|
10
|
+
SystemOneTransportError,
|
|
11
|
+
} from "system-one-core";
|
|
12
|
+
import { type SessionConfig, TYPESAFE_BASE_URL } from "./config.ts";
|
|
13
|
+
import {
|
|
14
|
+
type NativeFailureCategory,
|
|
15
|
+
PiNativeDiagnosticError,
|
|
16
|
+
type RouteObserver,
|
|
17
|
+
resolveSessionProvider,
|
|
18
|
+
} from "./provider.ts";
|
|
19
|
+
|
|
20
|
+
const SAFE_PATH_SEGMENTS = new Set([
|
|
21
|
+
"api",
|
|
22
|
+
"v1",
|
|
23
|
+
"v2",
|
|
24
|
+
"systemone",
|
|
25
|
+
"system-one",
|
|
26
|
+
"classify",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
/** Mirror HTTP provider's POST target, but never retain userinfo, query, fragment, or arbitrary path segments. */
|
|
30
|
+
export function safeRequestTarget(baseUrl: string): string {
|
|
31
|
+
try {
|
|
32
|
+
// HttpSystemOneProvider appends /v1/systemone after trimming one trailing slash.
|
|
33
|
+
const url = new URL(`${baseUrl.replace(/\/$/, "")}/v1/systemone`);
|
|
34
|
+
if (url.protocol !== "https:" && url.protocol !== "http:")
|
|
35
|
+
return "POST invalid HTTP endpoint";
|
|
36
|
+
const path = url.pathname
|
|
37
|
+
.split("/")
|
|
38
|
+
.map((segment) =>
|
|
39
|
+
SAFE_PATH_SEGMENTS.has(segment) ? segment : segment ? "[redacted]" : "",
|
|
40
|
+
)
|
|
41
|
+
.join("/");
|
|
42
|
+
const note =
|
|
43
|
+
url.search || url.hash
|
|
44
|
+
? " (base URL contains query/fragment; check path)"
|
|
45
|
+
: "";
|
|
46
|
+
return `POST ${url.origin}${path}${note}`;
|
|
47
|
+
} catch {
|
|
48
|
+
return "POST invalid HTTP endpoint";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface DebugEntry {
|
|
53
|
+
startedAt: string;
|
|
54
|
+
mode: SessionConfig["mode"];
|
|
55
|
+
provider: "pi-native" | "typesafe" | "configured" | "not selected";
|
|
56
|
+
reason: string;
|
|
57
|
+
requestTarget?: string;
|
|
58
|
+
outcome: "pending" | "success" | "failure";
|
|
59
|
+
attempted: boolean;
|
|
60
|
+
durationMs?: number;
|
|
61
|
+
failure?:
|
|
62
|
+
| NativeFailureCategory
|
|
63
|
+
| "HTTP error"
|
|
64
|
+
| "timeout"
|
|
65
|
+
| "transport error"
|
|
66
|
+
| "request failed";
|
|
67
|
+
httpStatus?: number;
|
|
68
|
+
questions: { choice: number; noul: number; score: number };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Bounded, in-memory diagnostics. Never retain input, credentials, raw URLs or raw errors. */
|
|
72
|
+
export class DebugHistory {
|
|
73
|
+
readonly entries: DebugEntry[] = [];
|
|
74
|
+
|
|
75
|
+
clear(): void {
|
|
76
|
+
this.entries.length = 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
start(mode: SessionConfig["mode"], questions: QuestionMap): DebugEntry {
|
|
80
|
+
const counts = { choice: 0, noul: 0, score: 0 };
|
|
81
|
+
for (const question of Object.values(questions)) counts[question.type]++;
|
|
82
|
+
const entry: DebugEntry = {
|
|
83
|
+
startedAt: new Date().toISOString(),
|
|
84
|
+
mode,
|
|
85
|
+
provider: "not selected",
|
|
86
|
+
reason: "route not selected",
|
|
87
|
+
outcome: "pending",
|
|
88
|
+
attempted: false,
|
|
89
|
+
questions: counts,
|
|
90
|
+
};
|
|
91
|
+
this.entries.unshift(entry);
|
|
92
|
+
if (this.entries.length > 20) this.entries.length = 20;
|
|
93
|
+
return entry;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function observedProvider(
|
|
98
|
+
session: SessionConfig,
|
|
99
|
+
ctx: ExtensionToolContext,
|
|
100
|
+
history: DebugHistory,
|
|
101
|
+
): SystemOneProvider {
|
|
102
|
+
return {
|
|
103
|
+
id: "diagnostic-wrapper",
|
|
104
|
+
async evaluate<Q extends QuestionMap>(
|
|
105
|
+
request: SystemOneRequest<Q>,
|
|
106
|
+
options?: SystemOneCallOptions,
|
|
107
|
+
): Promise<SystemOneResponse<Q>> {
|
|
108
|
+
const entry = history.start(session.mode, request.questions);
|
|
109
|
+
const started = Date.now();
|
|
110
|
+
const onRoute: RouteObserver = (provider, reason) => {
|
|
111
|
+
entry.provider = provider;
|
|
112
|
+
entry.reason = reason;
|
|
113
|
+
if (provider !== "pi-native")
|
|
114
|
+
entry.requestTarget = safeRequestTarget(
|
|
115
|
+
provider === "typesafe"
|
|
116
|
+
? TYPESAFE_BASE_URL
|
|
117
|
+
: session.current.baseUrl,
|
|
118
|
+
);
|
|
119
|
+
};
|
|
120
|
+
try {
|
|
121
|
+
const provider = await resolveSessionProvider(
|
|
122
|
+
session,
|
|
123
|
+
ctx,
|
|
124
|
+
onRoute,
|
|
125
|
+
() => {
|
|
126
|
+
entry.attempted = true;
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
if (provider.id === "typesafe" || provider.id === "configured")
|
|
130
|
+
entry.attempted = true;
|
|
131
|
+
const response = await provider.evaluate(request, options);
|
|
132
|
+
entry.outcome = "success";
|
|
133
|
+
return response;
|
|
134
|
+
} catch (error) {
|
|
135
|
+
entry.outcome = "failure";
|
|
136
|
+
entry.failure =
|
|
137
|
+
error instanceof PiNativeDiagnosticError
|
|
138
|
+
? error.category
|
|
139
|
+
: error instanceof SystemOneHttpError
|
|
140
|
+
? "HTTP error"
|
|
141
|
+
: error instanceof SystemOneTimeoutError
|
|
142
|
+
? "timeout"
|
|
143
|
+
: error instanceof SystemOneTransportError
|
|
144
|
+
? "transport error"
|
|
145
|
+
: "request failed";
|
|
146
|
+
if (
|
|
147
|
+
error instanceof SystemOneHttpError &&
|
|
148
|
+
Number.isInteger(error.status) &&
|
|
149
|
+
error.status >= 100 &&
|
|
150
|
+
error.status <= 599
|
|
151
|
+
)
|
|
152
|
+
entry.httpStatus = error.status;
|
|
153
|
+
throw error;
|
|
154
|
+
} finally {
|
|
155
|
+
entry.durationMs = Math.max(0, Date.now() - started);
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
package/src/persistence.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
import { dirname } from "node:path";
|
|
9
9
|
import type { ConfigMode, StoredConfig } from "./config.ts";
|
|
10
10
|
|
|
11
|
-
const MODES: ConfigMode[] = ["typesafe", "custom", "native"];
|
|
11
|
+
const MODES: ConfigMode[] = ["typesafe", "custom", "native", "auto"];
|
|
12
12
|
|
|
13
13
|
function validate(value: unknown, path: string): StoredConfig {
|
|
14
14
|
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
@@ -24,7 +24,7 @@ function validate(value: unknown, path: string): StoredConfig {
|
|
|
24
24
|
}
|
|
25
25
|
if (v.mode !== undefined && !MODES.includes(v.mode as ConfigMode))
|
|
26
26
|
throw new Error(
|
|
27
|
-
`Invalid System One settings in ${path}: mode must be typesafe, custom, or
|
|
27
|
+
`Invalid System One settings in ${path}: mode must be typesafe, custom, native, or auto`,
|
|
28
28
|
);
|
|
29
29
|
if (
|
|
30
30
|
v.baseUrl !== undefined &&
|