@evandu/super-pi 0.1.97 → 0.1.98
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/dist/client/.super-pi-client-sources.json +1 -1
- package/dist/server/index.js +44 -0
- package/dist/server/model-filter.js +5 -0
- package/dist/server/pi-command.d.ts +2 -1
- package/dist/server/pi-command.js +7 -2
- package/dist/server/pi-session-pool.d.ts +12 -0
- package/dist/server/pi-session-pool.js +54 -2
- package/dist/server/pi-session.d.ts +102 -6
- package/dist/server/pi-session.js +121 -5
- package/dist/server/runtime.d.ts +65 -3
- package/dist/server/runtime.js +201 -18
- package/package.json +2 -3
- package/scripts/patch-brace-expansion.mjs +155 -74
|
@@ -24,5 +24,5 @@
|
|
|
24
24
|
"src/client/types.ts": "6487df00c164370550b5977f9b23b17e22aff4bfb59dfe1e52200042340e3769",
|
|
25
25
|
"src/client/vite-env.d.ts": "65996936fbb042915f7b74a200fcdde7e410f32a669b1ab9597cfaa4b0faddb5"
|
|
26
26
|
},
|
|
27
|
-
"recordedAt": "2026-10-
|
|
27
|
+
"recordedAt": "2026-10-21T02:50:44.453Z"
|
|
28
28
|
}
|
package/dist/server/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { existsSync } from "node:fs";
|
|
|
6
6
|
import { mkdir } from "node:fs/promises";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import { join, resolve } from "node:path";
|
|
9
|
+
import { pathToFileURL } from "node:url";
|
|
9
10
|
import { openStateStore } from "./persistence/open-state-store.js";
|
|
10
11
|
import { PiRpcRuntime, WelcomeRuntime } from "./runtime.js";
|
|
11
12
|
import { resolveAgentDirectories } from "./agents.js";
|
|
@@ -30,6 +31,49 @@ import { ToolLlmService } from "./tool-llm.js";
|
|
|
30
31
|
// ------------------------------------------------------------- Environment & paths
|
|
31
32
|
const rootDir = resolve(import.meta.dirname, "../..");
|
|
32
33
|
initRunningVersion(rootDir);
|
|
34
|
+
// THE SECURITY PATCH, APPLIED WHERE IT CANNOT BREAK AN INSTALL.
|
|
35
|
+
//
|
|
36
|
+
// Pi's package pins a vulnerable `brace-expansion` in its own shrinkwrapped tree, and npm will not let
|
|
37
|
+
// anything move it - not `overrides`, not `npm audit fix`. So Super Pi replaces that one copy with the
|
|
38
|
+
// patched release itself.
|
|
39
|
+
//
|
|
40
|
+
// It runs HERE, at startup, and not as a `postinstall` script, because a postinstall that fails fails
|
|
41
|
+
// the INSTALL - and on 2026-10-21 it did exactly that on somebody else's machine, leaving them unable
|
|
42
|
+
// to install Super Pi at all. An install must not be breakable by a security patch.
|
|
43
|
+
//
|
|
44
|
+
// It is also deliberately ASYNCHRONOUS and off the startup path: the server is already serving by the
|
|
45
|
+
// time this finishes, and a registry that is slow or unreachable must not delay the panel. The copy is
|
|
46
|
+
// vulnerable for the few seconds until the patch lands, and the patched version is what every later
|
|
47
|
+
// turn uses.
|
|
48
|
+
void (async () => {
|
|
49
|
+
try {
|
|
50
|
+
// The specifier is built at runtime so the compiler does not need the file to exist: it lives in
|
|
51
|
+
// `scripts/`, which is not part of the compiled output.
|
|
52
|
+
const modulePath = resolve(rootDir, "scripts", "patch-brace-expansion.mjs");
|
|
53
|
+
const { patchBraceExpansion } = await import(pathToFileURL(modulePath).href);
|
|
54
|
+
await patchBraceExpansion();
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
console.warn(`[super-pi] the brace-expansion patch did not run: ${error instanceof Error ? error.message : String(error)}`);
|
|
58
|
+
}
|
|
59
|
+
})();
|
|
60
|
+
// PI'S SDK, LOADED WHILE THE SERVER IS STILL STARTING.
|
|
61
|
+
//
|
|
62
|
+
// Importing Pi's SDK costs about 1.5 seconds the first time and is free afterwards, and it was being
|
|
63
|
+
// paid by whichever user action happened to need it first - which, for most people, was opening the
|
|
64
|
+
// model picker and wondering why it stayed grey. Here it is paid during startup, in parallel with
|
|
65
|
+
// everything else the server does, so the first conversation does not pay for it.
|
|
66
|
+
//
|
|
67
|
+
// Warmup only: nothing here is required for the server to run, and a failure is one line.
|
|
68
|
+
void (async () => {
|
|
69
|
+
try {
|
|
70
|
+
const { warmPiSdk } = await import(pathToFileURL(resolve(rootDir, "dist", "server", "pi-session.js")).href);
|
|
71
|
+
await warmPiSdk();
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
console.warn(`[super-pi] Pi's SDK did not load during warmup: ${error instanceof Error ? error.message : String(error)}`);
|
|
75
|
+
}
|
|
76
|
+
})();
|
|
33
77
|
const cliArgs = parseServerArgs(process.argv);
|
|
34
78
|
// A non-numeric SUPER_PI_PORT would parse to NaN and die later in `listen` with a confusing error;
|
|
35
79
|
// like the other environment variables here, an unusable value falls back to the default port.
|
|
@@ -35,7 +35,12 @@ export async function validateConversationModel(store, runtime, conversationId,
|
|
|
35
35
|
const workspace = conversationWorkspace(state, conversationId);
|
|
36
36
|
if (!applicableRules(state.modelFilterRules, workspace).length)
|
|
37
37
|
return;
|
|
38
|
+
const startedAt = performance.now();
|
|
39
|
+
if (process.env.SUPER_PI_TRACE_LATENCY === "1")
|
|
40
|
+
console.warn(`[super-pi latency] conversation=${conversationId} phase=model.validate.begin selection=${selection ? `${selection.provider}/${selection.modelId}` : "default"}`);
|
|
38
41
|
const catalog = await runtime.catalog();
|
|
39
42
|
const model = selection ? catalog.models.find(item => item.provider === selection.provider && item.modelId === selection.modelId) ?? selection : catalog.defaultModel;
|
|
40
43
|
assertModelAllowed(store.snapshot().modelFilterRules, conversationWorkspace(store.snapshot(), conversationId), model);
|
|
44
|
+
if (process.env.SUPER_PI_TRACE_LATENCY === "1")
|
|
45
|
+
console.warn(`[super-pi latency] conversation=${conversationId} phase=model.validate.end elapsedMs=${Math.round(performance.now() - startedAt)}`);
|
|
41
46
|
}
|
|
@@ -21,7 +21,8 @@ export type PiCommandResolution = {
|
|
|
21
21
|
} | {
|
|
22
22
|
found: false;
|
|
23
23
|
reason: string;
|
|
24
|
-
searched: string[];
|
|
24
|
+
searched: string[]; /** Whether a command was NAMED and did not resolve. A named Pi is a statement about which Pi runs a turn, and it stays true when the file is missing. */
|
|
25
|
+
explicit?: boolean;
|
|
25
26
|
};
|
|
26
27
|
/** Overridable so the resolution can be exercised for both platforms and both outcomes without a Pi install. */
|
|
27
28
|
export interface PiCommandOptions {
|
|
@@ -113,8 +113,12 @@ export function resolvePiCommand(options = {}) {
|
|
|
113
113
|
return { found: true, command: full, via: label, explicit: true };
|
|
114
114
|
return {
|
|
115
115
|
found: false,
|
|
116
|
-
reason: `${label} points at ${full}, and no such file exists. Fix ${command ? "that argument" : "SUPER_PI_PI_COMMAND"} or unset it to fall back to PATH and npm's global bin directory. This is an environment problem, not a defect
|
|
116
|
+
reason: `${label} points at ${full}, and no such file exists. Fix ${command ? "that argument" : "SUPER_PI_PI_COMMAND"} or unset it to fall back to PATH and npm's global bin directory. This is an environment problem, not a defect of the agent mechanism.`,
|
|
117
117
|
searched: [full],
|
|
118
|
+
// STILL EXPLICIT. A Pi was NAMED, and naming one is a statement about which Pi runs a turn -
|
|
119
|
+
// so the fact survives the file being missing, and the honest answer to "that Pi cannot be
|
|
120
|
+
// asked" is "unavailable", never a list belonging to some other Pi.
|
|
121
|
+
explicit: true,
|
|
118
122
|
};
|
|
119
123
|
}
|
|
120
124
|
const hit = firstHit([explicit], [...onPath, ...globalDirs], exists, platform);
|
|
@@ -122,8 +126,9 @@ export function resolvePiCommand(options = {}) {
|
|
|
122
126
|
return { found: true, command: hit.command, via: `${label} (${hit.dir})`, explicit: true };
|
|
123
127
|
return {
|
|
124
128
|
found: false,
|
|
125
|
-
reason: `${label} names ${explicit}, which is on no PATH entry and in none of npm's global bin directories. This is an environment problem, not a defect
|
|
129
|
+
reason: `${label} names ${explicit}, which is on no PATH entry and in none of npm's global bin directories. This is an environment problem, not a defect of the agent mechanism.`,
|
|
126
130
|
searched: [...onPath, ...globalDirs],
|
|
131
|
+
explicit: true,
|
|
127
132
|
};
|
|
128
133
|
}
|
|
129
134
|
// THE PI THAT IS HOSTING SUPER PI, when Super Pi was started from inside Pi itself.
|
|
@@ -114,6 +114,18 @@ export declare class PiSessionPool {
|
|
|
114
114
|
*/
|
|
115
115
|
acquire(request: SessionRequest): Promise<PiSessionLike>;
|
|
116
116
|
private build;
|
|
117
|
+
/**
|
|
118
|
+
* Puts a live session on the model and level the conversation is asking for.
|
|
119
|
+
*
|
|
120
|
+
* Best-effort in the way that matters: a session that cannot change its model is still a working
|
|
121
|
+
* session, and refusing the message because Pi declined a model switch would be a worse answer than
|
|
122
|
+
* running on the model that is loaded. So the failure is a warning, and the turn goes on.
|
|
123
|
+
*
|
|
124
|
+
* The comparison is against what the session is ALREADY running, because Pi can report its model in
|
|
125
|
+
* two shapes (an id or a provider/id pair) and a mismatch on shape alone would mean re-setting the
|
|
126
|
+
* model on every single turn.
|
|
127
|
+
*/
|
|
128
|
+
private applyModelAndLevel;
|
|
117
129
|
private fingerprintOf;
|
|
118
130
|
/**
|
|
119
131
|
* The live session for a conversation, without building one.
|
|
@@ -94,18 +94,28 @@ export class PiSessionPool {
|
|
|
94
94
|
const existing = this.sessions.get(request.conversationId);
|
|
95
95
|
if (existing) {
|
|
96
96
|
if (sameFingerprint(existing.fingerprint, fingerprint)) {
|
|
97
|
+
if (process.env.SUPER_PI_TRACE_LATENCY === "1")
|
|
98
|
+
console.warn(`[super-pi latency] conversation=${request.conversationId} phase=session.reused`);
|
|
97
99
|
existing.lastUsedAt = Date.now();
|
|
98
100
|
// A session that is still being built is joined, not rebuilt: two sends arriving together must
|
|
99
101
|
// not produce two sessions, and the second one would be a duplicate model client talking to the
|
|
100
102
|
// same conversation.
|
|
101
103
|
if (existing.building)
|
|
102
104
|
return existing.building;
|
|
105
|
+
// THE MODEL AND THE LEVEL ARE APPLIED HERE, TO THE LIVE SESSION. They are not in the
|
|
106
|
+
// fingerprint because Pi can change them in place, and rebuilding the session to honour a
|
|
107
|
+
// change would replay the whole transcript - the single most expensive thing this runtime does.
|
|
108
|
+
// A person who picks a different model in the picker pays a few milliseconds here instead of
|
|
109
|
+
// waiting for their entire history to be re-read on the next message.
|
|
110
|
+
await this.applyModelAndLevel(existing.session, request);
|
|
103
111
|
return existing.session;
|
|
104
112
|
}
|
|
105
113
|
// STALE. The conversation's requirements changed underneath the live session, so it is thrown away
|
|
106
114
|
// BEFORE the new one is built, and not after - a session that is being disposed of while the new
|
|
107
115
|
// one starts would have two contexts for one conversation.
|
|
108
116
|
await this.evict(request.conversationId);
|
|
117
|
+
if (process.env.SUPER_PI_TRACE_LATENCY === "1")
|
|
118
|
+
console.warn(`[super-pi latency] conversation=${request.conversationId} phase=session.rebuild`);
|
|
109
119
|
}
|
|
110
120
|
// THE CREDENTIAL IS MINTED HERE, WHERE THE SESSION IS BORN. The capability travels in the
|
|
111
121
|
// extension's environment, and the extension reads its environment when it is LOADED - which happens
|
|
@@ -119,9 +129,13 @@ export class PiSessionPool {
|
|
|
119
129
|
const capability = this.mintCapability(request.conversationId);
|
|
120
130
|
const extensionEnv = { ...request.extensionEnv, [CAPABILITY_ENV]: capability, [TOOLS_CAPABILITY_ENV]: capability };
|
|
121
131
|
const building = this.build({ ...request, extensionEnv }, fingerprint);
|
|
132
|
+
if (process.env.SUPER_PI_TRACE_LATENCY === "1")
|
|
133
|
+
console.warn(`[super-pi latency] conversation=${request.conversationId} phase=session.build.begin`);
|
|
122
134
|
this.sessions.set(request.conversationId, { session: undefined, fingerprint, lastUsedAt: Date.now(), building });
|
|
123
135
|
try {
|
|
124
136
|
const session = await building;
|
|
137
|
+
if (process.env.SUPER_PI_TRACE_LATENCY === "1")
|
|
138
|
+
console.warn(`[super-pi latency] conversation=${request.conversationId} phase=session.build.end`);
|
|
125
139
|
const entry = this.sessions.get(request.conversationId);
|
|
126
140
|
if (entry) {
|
|
127
141
|
entry.session = session;
|
|
@@ -156,15 +170,53 @@ export class PiSessionPool {
|
|
|
156
170
|
};
|
|
157
171
|
return this.factory(creation);
|
|
158
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* Puts a live session on the model and level the conversation is asking for.
|
|
175
|
+
*
|
|
176
|
+
* Best-effort in the way that matters: a session that cannot change its model is still a working
|
|
177
|
+
* session, and refusing the message because Pi declined a model switch would be a worse answer than
|
|
178
|
+
* running on the model that is loaded. So the failure is a warning, and the turn goes on.
|
|
179
|
+
*
|
|
180
|
+
* The comparison is against what the session is ALREADY running, because Pi can report its model in
|
|
181
|
+
* two shapes (an id or a provider/id pair) and a mismatch on shape alone would mean re-setting the
|
|
182
|
+
* model on every single turn.
|
|
183
|
+
*/
|
|
184
|
+
async applyModelAndLevel(session, request) {
|
|
185
|
+
const wanted = request.model;
|
|
186
|
+
if (wanted && session.setModel) {
|
|
187
|
+
const running = session.model;
|
|
188
|
+
const same = running?.provider === wanted.provider && (running?.id ?? running?.id) === wanted.modelId;
|
|
189
|
+
if (!same) {
|
|
190
|
+
try {
|
|
191
|
+
await session.setModel({ provider: wanted.provider, id: wanted.modelId });
|
|
192
|
+
if (process.env.SUPER_PI_TRACE_LATENCY === "1")
|
|
193
|
+
console.warn(`[super-pi latency] conversation=${request.conversationId} phase=session.model-applied model=${wanted.provider}/${wanted.modelId}`);
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
console.warn(`PiSessionPool: conversation ${request.conversationId} asked for ${wanted.provider}/${wanted.modelId} and Pi declined: ${error instanceof Error ? error.message : String(error)}. The turn will run on the model already loaded.`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (request.thinkingLevel && session.setThinkingLevel) {
|
|
201
|
+
try {
|
|
202
|
+
session.setThinkingLevel(request.thinkingLevel);
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
console.warn(`PiSessionPool: conversation ${request.conversationId} asked for thinking level ${request.thinkingLevel} and Pi declined: ${error instanceof Error ? error.message : String(error)}.`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
159
209
|
fingerprintOf(request) {
|
|
210
|
+
// The model and the thinking level are NOT here, and that is deliberate: Pi changes both on a
|
|
211
|
+
// live session, so they are applied to it rather than forcing a rebuild that would replay the whole
|
|
212
|
+
// transcript. The session is still CREATED with them (see `build`) - it just is not thrown away
|
|
213
|
+
// when they change.
|
|
160
214
|
return {
|
|
161
215
|
cwd: request.cwd,
|
|
162
216
|
agentId: request.agentId,
|
|
163
217
|
tools: request.tools,
|
|
164
218
|
excludeTools: request.excludeTools,
|
|
165
219
|
extensions: superPiExtensionPaths(this.repositoryRoot),
|
|
166
|
-
model: request.model,
|
|
167
|
-
thinkingLevel: request.thinkingLevel,
|
|
168
220
|
};
|
|
169
221
|
}
|
|
170
222
|
/**
|
|
@@ -23,6 +23,83 @@
|
|
|
23
23
|
* the transcript on disk while the live session still holds the old context in memory, and nothing in Pi
|
|
24
24
|
* would notice the difference.
|
|
25
25
|
*/
|
|
26
|
+
/**
|
|
27
|
+
* Loads Pi's SDK while the server is still starting.
|
|
28
|
+
*
|
|
29
|
+
* Importing Pi costs about 1.5 seconds once and is free afterwards, and it used to be paid by whatever
|
|
30
|
+
* user action needed it FIRST - which for most people was opening the model picker and watching it stay
|
|
31
|
+
* grey. Paying it here, during startup, moves it off the path a person can see.
|
|
32
|
+
*
|
|
33
|
+
* Safe to call more than once, and safe to call on a server that never needs the SDK: it loads the
|
|
34
|
+
* module and returns, and the caller decides what to do with a failure.
|
|
35
|
+
*/
|
|
36
|
+
export declare function warmPiSdk(): Promise<boolean>;
|
|
37
|
+
/**
|
|
38
|
+
* A model catalog read from Pi's SDK, with NO PROCESS.
|
|
39
|
+
*
|
|
40
|
+
* THE PROBLEM THIS SOLVES. The model picker used to be answered by starting a `pi --mode rpc` child,
|
|
41
|
+
* asking it three questions and killing it. That is a process start - measured at 866ms on a fast
|
|
42
|
+
* machine, and seconds on a slow one - paid every time the cache missed, and the cache key is the
|
|
43
|
+
* SELECTED MODEL, so it missed again for every model a person picked. A user who opened the picker and
|
|
44
|
+
* then created a conversation waited for a process to start.
|
|
45
|
+
*
|
|
46
|
+
* WHY IT IS SAFE. Pi's SDK is the same code Pi's RPC mode is built on, so the answers are the same:
|
|
47
|
+
* `ModelRuntime.getAvailable()` returned exactly the model list a spawned Pi reports, and a session's
|
|
48
|
+
* `getAvailableThinkingLevels()` returned exactly the level list. Both are checked field by field in
|
|
49
|
+
* `scripts/probe-catalog-equivalence.mjs` and `scripts/probe-levels.mjs`.
|
|
50
|
+
*
|
|
51
|
+
* WHAT IS STILL NEEDED FROM PI ITSELF. The thinking levels are a property of the SELECTED model, and a
|
|
52
|
+
* model that is not running has no session to ask. So the levels come from a short-lived in-memory
|
|
53
|
+
* session rather than from the model list - which costs a session, not a process, and is only needed
|
|
54
|
+
* when the caller actually wants levels for a model.
|
|
55
|
+
*/
|
|
56
|
+
export interface SdkCatalog {
|
|
57
|
+
/** Pi's own model objects, for the caller to reduce with the same helpers the RPC path used. */
|
|
58
|
+
models: unknown[];
|
|
59
|
+
/** The model Pi would use by default, if settings name one. */
|
|
60
|
+
defaultModel?: {
|
|
61
|
+
provider: string;
|
|
62
|
+
modelId: string;
|
|
63
|
+
};
|
|
64
|
+
/** The thinking level Pi would use by default, if settings name one. */
|
|
65
|
+
defaultThinkingLevel?: string;
|
|
66
|
+
/**
|
|
67
|
+
* The levels a model supports, read from a session running that model.
|
|
68
|
+
*
|
|
69
|
+
* Separate from the models because it is the expensive half: it needs a session, so a caller that
|
|
70
|
+
* only wants the list does not pay for it.
|
|
71
|
+
*/
|
|
72
|
+
thinkingLevelsFor(model: {
|
|
73
|
+
provider: string;
|
|
74
|
+
modelId: string;
|
|
75
|
+
}): Promise<string[]>;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Builds a zero-process catalog reader.
|
|
79
|
+
*
|
|
80
|
+
* The ModelRuntime and the SettingsManager are created ONCE and reused: both read the auth store and
|
|
81
|
+
* the model store from disk, so a fresh one per call would put the same I/O back that this is here to
|
|
82
|
+
* remove. They are safe to share - they are read-mostly views of the same files.
|
|
83
|
+
*
|
|
84
|
+
* The MODEL LIST is read once, at build time, and the LEVELS are cached per model - both because
|
|
85
|
+
* re-reading them is the cost this exists to remove, and because the answer cannot change while the
|
|
86
|
+
* process runs: a person who edits `auth.json` or adds a model restarts Super Pi, exactly as they
|
|
87
|
+
* would for anything else in Pi's own configuration.
|
|
88
|
+
*/
|
|
89
|
+
export declare function createSdkCatalog(cwd: string): Promise<SdkCatalog>;
|
|
90
|
+
/**
|
|
91
|
+
* The transcript file a conversation's session writes to, found WITHOUT starting a Pi process.
|
|
92
|
+
*
|
|
93
|
+
* THIS REPLACES A SPAWN. Revert and fork need to know which file holds a conversation's history, and
|
|
94
|
+
* they used to start a child purely to be told. Pi's SDK answers it from the session
|
|
95
|
+
* manager's own state - and it answers it the same way Pi's RPC mode does, which is the only reason
|
|
96
|
+
* this is safe: Pi looks a session up BY ID and by WORKING DIRECTORY, so asking the library that
|
|
97
|
+
* resolves the session gives the same file the child would have named.
|
|
98
|
+
*
|
|
99
|
+
* Returns undefined when there is no such session, which is the caller's cue to fall back rather than
|
|
100
|
+
* to guess: a wrong path here would truncate somebody else's conversation.
|
|
101
|
+
*/
|
|
102
|
+
export declare function resolveTranscriptFile(cwd: string, conversationId: string, sessionDir: string): Promise<string | undefined>;
|
|
26
103
|
/** Whether the SDK could be loaded. Used by the server to report the reason instead of failing a turn. */
|
|
27
104
|
export declare function piSdkAvailable(): Promise<{
|
|
28
105
|
available: true;
|
|
@@ -48,12 +125,6 @@ export interface SessionFingerprint {
|
|
|
48
125
|
excludeTools: string[] | undefined;
|
|
49
126
|
/** Which of Super Pi's own extensions were mounted. */
|
|
50
127
|
extensions: string[];
|
|
51
|
-
/** The model and thinking level, when they were pinned at creation. */
|
|
52
|
-
model?: {
|
|
53
|
-
provider: string;
|
|
54
|
-
modelId: string;
|
|
55
|
-
};
|
|
56
|
-
thinkingLevel?: string;
|
|
57
128
|
}
|
|
58
129
|
/**
|
|
59
130
|
* Fingerprints are compared by VALUE, and this is where that comparison is spelled out.
|
|
@@ -113,6 +184,31 @@ export interface PiSessionLike {
|
|
|
113
184
|
readonly sessionFile?: string | undefined;
|
|
114
185
|
/** The id of the session. */
|
|
115
186
|
readonly sessionId?: string | undefined;
|
|
187
|
+
/**
|
|
188
|
+
* The model the session is running on.
|
|
189
|
+
*
|
|
190
|
+
* READ TO COMPARE, not to set: a model is chosen when the session is created, and a LATER change goes
|
|
191
|
+
* through `setModel`.
|
|
192
|
+
*/
|
|
193
|
+
readonly model?: {
|
|
194
|
+
provider?: string;
|
|
195
|
+
id?: string;
|
|
196
|
+
} | undefined;
|
|
197
|
+
/**
|
|
198
|
+
* Switches the model on a LIVE session.
|
|
199
|
+
*
|
|
200
|
+
* THIS IS WHY THE MODEL IS NOT PART OF THE CONFIGURATION FINGERPRINT. Pi can change the model of a
|
|
201
|
+
* running session, so a model change does not need a new session - and making one would cost a
|
|
202
|
+
* transcript replay, which is the most expensive thing this runtime does. A user who picks a different
|
|
203
|
+
* model in the picker would otherwise pay for the whole conversation's history to be re-read.
|
|
204
|
+
*/
|
|
205
|
+
setModel?(model: {
|
|
206
|
+
provider: string;
|
|
207
|
+
id: string;
|
|
208
|
+
}): Promise<void>;
|
|
209
|
+
/** The thinking levels the session's current model supports. Pi's own answer, not a derivation. */
|
|
210
|
+
getAvailableThinkingLevels?(): string[];
|
|
211
|
+
setThinkingLevel?(level: string): void;
|
|
116
212
|
/**
|
|
117
213
|
* The session's transcript, for naming a position in it.
|
|
118
214
|
*
|
|
@@ -23,8 +23,32 @@
|
|
|
23
23
|
* the transcript on disk while the live session still holds the old context in memory, and nothing in Pi
|
|
24
24
|
* would notice the difference.
|
|
25
25
|
*/
|
|
26
|
-
import { existsSync } from "node:fs";
|
|
26
|
+
import { existsSync, statSync } from "node:fs";
|
|
27
27
|
import { join } from "node:path";
|
|
28
|
+
const traceSession = (conversationId, phase, startedAt, extra = "") => {
|
|
29
|
+
if (process.env.SUPER_PI_TRACE_LATENCY !== "1")
|
|
30
|
+
return;
|
|
31
|
+
console.warn(`[super-pi latency] conversation=${conversationId} phase=${phase} elapsedMs=${Math.round(performance.now() - startedAt)}${extra ? ` ${extra}` : ""}`);
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Loads Pi's SDK while the server is still starting.
|
|
35
|
+
*
|
|
36
|
+
* Importing Pi costs about 1.5 seconds once and is free afterwards, and it used to be paid by whatever
|
|
37
|
+
* user action needed it FIRST - which for most people was opening the model picker and watching it stay
|
|
38
|
+
* grey. Paying it here, during startup, moves it off the path a person can see.
|
|
39
|
+
*
|
|
40
|
+
* Safe to call more than once, and safe to call on a server that never needs the SDK: it loads the
|
|
41
|
+
* module and returns, and the caller decides what to do with a failure.
|
|
42
|
+
*/
|
|
43
|
+
export async function warmPiSdk() {
|
|
44
|
+
try {
|
|
45
|
+
await loadSdk();
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
28
52
|
let sdkPromise;
|
|
29
53
|
let sdkFailure;
|
|
30
54
|
async function loadSdk() {
|
|
@@ -44,6 +68,84 @@ async function loadSdk() {
|
|
|
44
68
|
}
|
|
45
69
|
return sdkPromise;
|
|
46
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Builds a zero-process catalog reader.
|
|
73
|
+
*
|
|
74
|
+
* The ModelRuntime and the SettingsManager are created ONCE and reused: both read the auth store and
|
|
75
|
+
* the model store from disk, so a fresh one per call would put the same I/O back that this is here to
|
|
76
|
+
* remove. They are safe to share - they are read-mostly views of the same files.
|
|
77
|
+
*
|
|
78
|
+
* The MODEL LIST is read once, at build time, and the LEVELS are cached per model - both because
|
|
79
|
+
* re-reading them is the cost this exists to remove, and because the answer cannot change while the
|
|
80
|
+
* process runs: a person who edits `auth.json` or adds a model restarts Super Pi, exactly as they
|
|
81
|
+
* would for anything else in Pi's own configuration.
|
|
82
|
+
*/
|
|
83
|
+
export async function createSdkCatalog(cwd) {
|
|
84
|
+
const sdk = await loadSdk();
|
|
85
|
+
const agentDir = defaultAgentDir();
|
|
86
|
+
const modelRuntime = await sdk.ModelRuntime.create();
|
|
87
|
+
const settingsManager = sdk.SettingsManager.create(cwd, agentDir);
|
|
88
|
+
const models = (await modelRuntime.getAvailable?.()) ?? modelRuntime.getModels?.() ?? [];
|
|
89
|
+
const levelsByModel = new Map();
|
|
90
|
+
return {
|
|
91
|
+
models,
|
|
92
|
+
...(settingsManager.getDefaultProvider?.() && settingsManager.getDefaultModel?.()
|
|
93
|
+
? {
|
|
94
|
+
defaultModel: {
|
|
95
|
+
provider: settingsManager.getDefaultProvider(),
|
|
96
|
+
modelId: settingsManager.getDefaultModel(),
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
: {}),
|
|
100
|
+
...(settingsManager.getDefaultThinkingLevel?.() ? { defaultThinkingLevel: settingsManager.getDefaultThinkingLevel() } : {}),
|
|
101
|
+
thinkingLevelsFor: async (model) => {
|
|
102
|
+
const key = `${model.provider}/${model.modelId}`;
|
|
103
|
+
const cached = levelsByModel.get(key);
|
|
104
|
+
if (cached)
|
|
105
|
+
return cached;
|
|
106
|
+
// A model that is not running has no levels of its own, so a short-lived in-memory session is
|
|
107
|
+
// built to ask Pi. It costs a session - measured at ~30ms warm, and no process at all - and it is
|
|
108
|
+
// paid once per model rather than once per picker open.
|
|
109
|
+
const { session } = await sdk.createAgentSession({
|
|
110
|
+
cwd,
|
|
111
|
+
agentDir,
|
|
112
|
+
modelRuntime,
|
|
113
|
+
settingsManager,
|
|
114
|
+
sessionManager: sdk.SessionManager.inMemory(cwd, { id: "catalog-levels" }),
|
|
115
|
+
model: modelRuntime.getModel?.(model.provider, model.modelId),
|
|
116
|
+
});
|
|
117
|
+
try {
|
|
118
|
+
const levels = sessionLevels(session);
|
|
119
|
+
levelsByModel.set(key, levels);
|
|
120
|
+
return levels;
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
session.dispose?.();
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** Reads the thinking levels off a session, tolerating a session that does not implement the call. */
|
|
129
|
+
function sessionLevels(session) {
|
|
130
|
+
const levels = session.getAvailableThinkingLevels?.();
|
|
131
|
+
return Array.isArray(levels) ? levels.filter((level) => typeof level === "string") : [];
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The transcript file a conversation's session writes to, found WITHOUT starting a Pi process.
|
|
135
|
+
*
|
|
136
|
+
* THIS REPLACES A SPAWN. Revert and fork need to know which file holds a conversation's history, and
|
|
137
|
+
* they used to start a child purely to be told. Pi's SDK answers it from the session
|
|
138
|
+
* manager's own state - and it answers it the same way Pi's RPC mode does, which is the only reason
|
|
139
|
+
* this is safe: Pi looks a session up BY ID and by WORKING DIRECTORY, so asking the library that
|
|
140
|
+
* resolves the session gives the same file the child would have named.
|
|
141
|
+
*
|
|
142
|
+
* Returns undefined when there is no such session, which is the caller's cue to fall back rather than
|
|
143
|
+
* to guess: a wrong path here would truncate somebody else's conversation.
|
|
144
|
+
*/
|
|
145
|
+
export async function resolveTranscriptFile(cwd, conversationId, sessionDir) {
|
|
146
|
+
const sdk = await loadSdk();
|
|
147
|
+
return sdk.SessionManager.findById(cwd, conversationId, sessionDir);
|
|
148
|
+
}
|
|
47
149
|
/** Whether the SDK could be loaded. Used by the server to report the reason instead of failing a turn. */
|
|
48
150
|
export async function piSdkAvailable() {
|
|
49
151
|
try {
|
|
@@ -75,10 +177,6 @@ export function sameFingerprint(a, b) {
|
|
|
75
177
|
return false;
|
|
76
178
|
if (!sameSet(a.extensions, b.extensions))
|
|
77
179
|
return false;
|
|
78
|
-
if (a.model?.provider !== b.model?.provider || a.model?.modelId !== b.model?.modelId)
|
|
79
|
-
return false;
|
|
80
|
-
if (a.thinkingLevel !== b.thinkingLevel)
|
|
81
|
-
return false;
|
|
82
180
|
return true;
|
|
83
181
|
}
|
|
84
182
|
function sameSet(a, b) {
|
|
@@ -116,7 +214,10 @@ export async function withEnvironment(values, work) {
|
|
|
116
214
|
}
|
|
117
215
|
}
|
|
118
216
|
export async function createPiSession(creation) {
|
|
217
|
+
const startedAt = performance.now();
|
|
218
|
+
traceSession(creation.sessionId, "sdk.load.begin", startedAt);
|
|
119
219
|
const sdk = await loadSdk();
|
|
220
|
+
traceSession(creation.sessionId, "sdk.load.end", startedAt);
|
|
120
221
|
const agentDir = creation.agentDir ?? defaultAgentDir();
|
|
121
222
|
return withEnvironment(creation.extensionEnv, async () => {
|
|
122
223
|
// THE AGENT'S PROMPT. Pi's loader takes the same two shapes the command line took: a `systemPrompt`
|
|
@@ -136,10 +237,13 @@ export async function createPiSession(creation) {
|
|
|
136
237
|
: {}),
|
|
137
238
|
});
|
|
138
239
|
await loader.reload();
|
|
240
|
+
traceSession(creation.sessionId, "sdk.loader.reload", startedAt, `extensions=${creation.extensionPaths.length}`);
|
|
139
241
|
const sessionManager = creation.persist === false
|
|
140
242
|
? sdk.SessionManager.inMemory(creation.cwd, { id: creation.sessionId })
|
|
141
243
|
: await resolveSessionManager(sdk, creation);
|
|
244
|
+
traceSession(creation.sessionId, "sdk.transcript.ready", startedAt, `bytes=${transcriptBytes(sessionManager)}`);
|
|
142
245
|
const modelRuntime = await sdk.ModelRuntime.create();
|
|
246
|
+
traceSession(creation.sessionId, "sdk.model-runtime", startedAt);
|
|
143
247
|
// THE MODEL IS RESOLVED, NOT PASSED THROUGH. Pi's SDK takes a whole Model object - the provider's
|
|
144
248
|
// configuration, the endpoint, the capability list - and Super Pi only knows the pair
|
|
145
249
|
// (provider, modelId) that the picker shows. The runtime is what turns that pair into the object
|
|
@@ -164,9 +268,21 @@ export async function createPiSession(creation) {
|
|
|
164
268
|
...(creation.thinkingLevel ? { thinkingLevel: creation.thinkingLevel } : {}),
|
|
165
269
|
};
|
|
166
270
|
const { session } = await sdk.createAgentSession(options);
|
|
271
|
+
traceSession(creation.sessionId, "sdk.session.created", startedAt);
|
|
167
272
|
return session;
|
|
168
273
|
});
|
|
169
274
|
}
|
|
275
|
+
function transcriptBytes(sessionManager) {
|
|
276
|
+
const file = sessionManager.getSessionFile?.();
|
|
277
|
+
if (!file)
|
|
278
|
+
return 0;
|
|
279
|
+
try {
|
|
280
|
+
return existsSync(file) ? statSync(file).size : 0;
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return 0;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
170
286
|
/**
|
|
171
287
|
* Resolves the session manager for a conversation: the EXISTING transcript if there is one, a new one
|
|
172
288
|
* otherwise.
|
package/dist/server/runtime.d.ts
CHANGED
|
@@ -402,7 +402,18 @@ export declare function killTree(pid: number): Promise<void>;
|
|
|
402
402
|
* caller drop the message from the dock on the strength of a payload shape we did not recognise.
|
|
403
403
|
*/
|
|
404
404
|
export declare function describeSteerDisposition(data: unknown): SteerDisposition | undefined;
|
|
405
|
-
/**
|
|
405
|
+
/**
|
|
406
|
+
* Keys Pi's model answers by. Exported so the store and the tests agree with the runtime.
|
|
407
|
+
*
|
|
408
|
+
* THE MODEL IS NOT PART OF THE KEY, and that is the second half of why the picker no longer starts a
|
|
409
|
+
* process. The MODEL LIST is the same answer whichever model a conversation has selected - it is the
|
|
410
|
+
* list of models Pi offers, not the ones one conversation may use. The only thing that varies with the
|
|
411
|
+
* selection is the THINKING LEVELS, which belong to the model in use.
|
|
412
|
+
*
|
|
413
|
+
* So the key records just whether levels were asked for. Keying on the model (as this used to) meant a
|
|
414
|
+
* cache miss for every model a person ever picked, and every miss was a process start - so the picker
|
|
415
|
+
* went grey again after every model change, which is exactly when a person is moving between models.
|
|
416
|
+
*/
|
|
406
417
|
export declare const catalogKey: (selection?: ModelSelection) => string;
|
|
407
418
|
/**
|
|
408
419
|
* Where the `task` extension lives, as an ABSOLUTE path.
|
|
@@ -467,6 +478,8 @@ export declare class PiRpcRuntime implements AgentRuntime {
|
|
|
467
478
|
* in the pool, and a turn is a run ON that session rather than a process OF its own.
|
|
468
479
|
*/
|
|
469
480
|
private readonly sessionPool;
|
|
481
|
+
/** Where Super Pi itself is installed, which is also where its own extensions are found. */
|
|
482
|
+
private readonly repositoryRoot;
|
|
470
483
|
/**
|
|
471
484
|
* The turn currently running on each conversation's session.
|
|
472
485
|
*
|
|
@@ -509,8 +522,25 @@ export declare class PiRpcRuntime implements AgentRuntime {
|
|
|
509
522
|
/** Distinguishes steer command ids. Never reused, so a late response cannot settle a later steer. */
|
|
510
523
|
private steerSeq;
|
|
511
524
|
private readonly catalogs;
|
|
512
|
-
/**
|
|
525
|
+
/**
|
|
526
|
+
* In-flight reads, so N clients asking at once cost ONE read rather than N.
|
|
527
|
+
*
|
|
528
|
+
* "One read", not "one Pi spawn": the catalog is answered out of Pi's SDK and no process is started
|
|
529
|
+
* at all. The wording here used to promise a shared spawn, which is what the old implementation did.
|
|
530
|
+
*/
|
|
513
531
|
private readonly pendingCatalogs;
|
|
532
|
+
/**
|
|
533
|
+
* Pi's SDK catalog reader, built once and reused.
|
|
534
|
+
*
|
|
535
|
+
* The ModelRuntime and SettingsManager it holds read the auth store and the model store from disk, so
|
|
536
|
+
* building a new one per call would put back the I/O this exists to avoid. They are read-mostly
|
|
537
|
+
* views of files that change rarely, and Pi's own long-lived session holds the same objects for its
|
|
538
|
+
* whole life.
|
|
539
|
+
*
|
|
540
|
+
* A FAILED build is remembered as a failure rather than retried per call: a broken SDK would
|
|
541
|
+
* otherwise be re-imported on every picker open.
|
|
542
|
+
*/
|
|
543
|
+
private sdkCatalogPromise?;
|
|
514
544
|
/** Latches the "no extension on disk" warning, so it is said once rather than once per turn. */
|
|
515
545
|
private warnedMissingExtension;
|
|
516
546
|
/** Latches the "Pi's SDK is not installed" warning, for the same reason. */
|
|
@@ -946,15 +976,47 @@ export declare class PiRpcRuntime implements AgentRuntime {
|
|
|
946
976
|
* killed as soon as it has answered - no long-lived process to keep warm.
|
|
947
977
|
*/
|
|
948
978
|
catalog(selection?: ModelSelection): Promise<ModelCatalog>;
|
|
979
|
+
/**
|
|
980
|
+
* Reads the model catalog WITHOUT starting a Pi process, and falls back to a spawned Pi only when
|
|
981
|
+
* the SDK is not available.
|
|
982
|
+
*
|
|
983
|
+
* WHY THIS IS THE DEFAULT. The picker used to be answered by starting a `pi --mode rpc` child,
|
|
984
|
+
* asking it three questions and killing it: a process start, measured at 866ms on a fast machine and
|
|
985
|
+
* seconds on a slow one. And the cache key is the SELECTED MODEL, so it started again for every model
|
|
986
|
+
* a person picked - which is why "the picker stays grey for ten seconds" followed choosing a model,
|
|
987
|
+
* and not only the first time.
|
|
988
|
+
*
|
|
989
|
+
* Pi's SDK is the same code Pi's RPC mode is built on, so the answers are the same, and the two are
|
|
990
|
+
* compared field by field in `scripts/probe-catalog-equivalence.mjs`.
|
|
991
|
+
*/
|
|
992
|
+
private readCatalog;
|
|
993
|
+
/**
|
|
994
|
+
* The transcript file for a conversation, out of Pi's SDK, or undefined when the SDK cannot answer.
|
|
995
|
+
*
|
|
996
|
+
* Undefined means "ask someone else", not "no such file": a missing SDK and a missing transcript
|
|
997
|
+
* are different facts, and only the first one should send the caller to the spawning path.
|
|
998
|
+
*
|
|
999
|
+
* For the same reason as the catalog, a NAMED Pi is not asked here: when something else will run the
|
|
1000
|
+
* conversation, it is also the only one that knows which file that conversation's transcript is in.
|
|
1001
|
+
*/
|
|
1002
|
+
private resolveTranscriptFileWithoutSpawn;
|
|
1003
|
+
private sdkCatalog;
|
|
1004
|
+
/**
|
|
1005
|
+
* The catalog, out of Pi's SDK. Returns undefined when the SDK is not available, which is the only
|
|
1006
|
+
* case in which the spawning path is reached.
|
|
1007
|
+
*/
|
|
1008
|
+
private readCatalogFromSdk;
|
|
949
1009
|
/**
|
|
950
1010
|
* Asks one throwaway Pi for its model list, its thinking levels and its own current selection.
|
|
951
1011
|
*
|
|
1012
|
+
* Only reached when Pi's SDK is not available - which is a broken installation, not a normal one.
|
|
1013
|
+
*
|
|
952
1014
|
* The three commands are written back to back and only then awaited, so Pi works on them in
|
|
953
1015
|
* parallel - measured at 394ms for all three, against 714ms for the slowest one alone. A Pi
|
|
954
1016
|
* scoped to one conversation's model needs `set_model` FIRST and alone, because thinking levels are
|
|
955
1017
|
* a property of the selected model, not of Pi's default.
|
|
956
1018
|
*/
|
|
957
|
-
private
|
|
1019
|
+
private readCatalogBySpawningPi;
|
|
958
1020
|
}
|
|
959
1021
|
/**
|
|
960
1022
|
* Normalizes Pi's `Model[]`, keeping only entries that can actually be sent back as a `set_model`.
|