@evandu/super-pi 0.1.93 → 0.1.95
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/agents/super-pi-task/core.ts +6 -5
- package/agents/super-pi-task/index.ts +2 -1
- package/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +4 -4
- package/dist/client/assets/{index-mdc5jJ6R.css → index-8olCbCbk.css} +1 -1
- package/dist/client/assets/{index-CjTv3vNA.js → index-gjLqMWYT.js} +3 -3
- package/dist/client/index.html +2 -2
- package/dist/server/agents.d.ts +18 -0
- package/dist/server/agents.js +39 -0
- package/dist/server/application/run-service.d.ts +47 -3
- package/dist/server/application/run-service.js +158 -88
- package/dist/server/capability.d.ts +28 -12
- package/dist/server/capability.js +54 -20
- package/dist/server/http-server.js +14 -6
- package/dist/server/index.js +10 -1
- package/dist/server/pi-command.d.ts +29 -1
- package/dist/server/pi-command.js +58 -5
- package/dist/server/pi-session-events.d.ts +111 -0
- package/dist/server/pi-session-events.js +291 -0
- package/dist/server/pi-session-pool.d.ts +166 -0
- package/dist/server/pi-session-pool.js +262 -0
- package/dist/server/pi-session.d.ts +204 -0
- package/dist/server/pi-session.js +226 -0
- package/dist/server/runtime.d.ts +243 -29
- package/dist/server/runtime.js +634 -60
- package/dist/server/store.js +4 -4
- package/dist/server/ws-server.js +5 -4
- package/package.json +3 -1
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE SESSION LAYER: one Pi session per Super Pi conversation, alive across turns.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS FILE EXISTS. Super Pi used to spawn a fresh `pi --mode rpc` process for every turn and kill
|
|
5
|
+
* it when the turn ended. That cost ~400ms of spawn plus ~380ms of handshake plus a full replay of the
|
|
6
|
+
* transcript on EVERY message, and it is why an interrupt had to kill a process and start another one:
|
|
7
|
+
* the turn and the process were the same thing. Pi's own `abort` takes 147ms and leaves the session
|
|
8
|
+
* usable, so the process churn was Super Pi's choice, not a property of interrupting.
|
|
9
|
+
*
|
|
10
|
+
* Pi's SDK (`@earendil-works/pi-coding-agent`) is the supported way to embed it - Pi's own RPC docs
|
|
11
|
+
* recommend it for Node applications. A session here is a Pi `AgentSession`: it keeps the conversation
|
|
12
|
+
* context in memory across turns, and `abort()` returns as soon as the agent is idle.
|
|
13
|
+
*
|
|
14
|
+
* WHAT THIS FILE IS NOT. It is not a second runtime. The one-shot paths (memory review, knowledge
|
|
15
|
+
* rerank, autotitle, tool LLM, catalog probe) keep spawning their own throwaway children, because their
|
|
16
|
+
* identity, tool set and lifecycle are deliberately different from the primary agent's and sharing a
|
|
17
|
+
* session with them would be a correctness bug, not an optimization. See `runtime.ts`.
|
|
18
|
+
*
|
|
19
|
+
* THE ONE HARD CONSTRAINT. Pi's agent prompt, its tool allowlist and the extensions it mounts are all
|
|
20
|
+
* decided when the session is CREATED. None of them can be changed on a live session. So a session carries
|
|
21
|
+
* a FINGERPRINT of everything that was decided at creation, and the caller compares fingerprints: a
|
|
22
|
+
* change means the session is stale and must be replaced. `revert` is the sharpest case - it truncates
|
|
23
|
+
* the transcript on disk while the live session still holds the old context in memory, and nothing in Pi
|
|
24
|
+
* would notice the difference.
|
|
25
|
+
*/
|
|
26
|
+
import { existsSync } from "node:fs";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
let sdkPromise;
|
|
29
|
+
let sdkFailure;
|
|
30
|
+
async function loadSdk() {
|
|
31
|
+
if (sdkFailure)
|
|
32
|
+
throw new Error(sdkFailure);
|
|
33
|
+
if (!sdkPromise) {
|
|
34
|
+
// The specifier is assembled at runtime on purpose: a static `import("...")` would be resolved by
|
|
35
|
+
// the compiler, which would turn a BROKEN INSTALL into a COMPILE ERROR - and the compiler runs
|
|
36
|
+
// before anything can report the real reason. The name is written out in full here so a reader
|
|
37
|
+
// can see exactly which package is being loaded.
|
|
38
|
+
const specifier = ["@earendil-works", "pi-coding-agent"].join("/");
|
|
39
|
+
sdkPromise = import(/* @vite-ignore */ specifier).then((mod) => mod, (error) => {
|
|
40
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
41
|
+
sdkFailure = `Super Pi could not load Pi's SDK (@earendil-works/pi-coding-agent): ${reason}. Reinstall Super Pi (npm i -g @evandu/super-pi) so that Pi comes with it, or install it next to Super Pi with \`npm install @earendil-works/pi-coding-agent\`.`;
|
|
42
|
+
throw new Error(sdkFailure);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return sdkPromise;
|
|
46
|
+
}
|
|
47
|
+
/** Whether the SDK could be loaded. Used by the server to report the reason instead of failing a turn. */
|
|
48
|
+
export async function piSdkAvailable() {
|
|
49
|
+
try {
|
|
50
|
+
await loadSdk();
|
|
51
|
+
return { available: true };
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
return { available: false, reason: error instanceof Error ? error.message : String(error) };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Fingerprints are compared by VALUE, and this is where that comparison is spelled out.
|
|
59
|
+
*
|
|
60
|
+
* The array fields are compared as SETS, not sequences: the tool allowlist is a set of names, and a
|
|
61
|
+
* caller that happens to list them in a different order is asking for the same session, not a new one.
|
|
62
|
+
* Getting this wrong in the strict direction would throw away a perfectly good session on every turn
|
|
63
|
+
* (a latency bug that looks like nothing at all), and getting it wrong in the loose direction would keep
|
|
64
|
+
* a session whose allowlist had genuinely changed (a permission bug). So: order-insensitive, and every
|
|
65
|
+
* element compared exactly.
|
|
66
|
+
*/
|
|
67
|
+
export function sameFingerprint(a, b) {
|
|
68
|
+
if (a.cwd !== b.cwd)
|
|
69
|
+
return false;
|
|
70
|
+
if (a.agentId !== b.agentId)
|
|
71
|
+
return false;
|
|
72
|
+
if (!sameSet(a.tools, b.tools))
|
|
73
|
+
return false;
|
|
74
|
+
if (!sameSet(a.excludeTools, b.excludeTools))
|
|
75
|
+
return false;
|
|
76
|
+
if (!sameSet(a.extensions, b.extensions))
|
|
77
|
+
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
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
function sameSet(a, b) {
|
|
85
|
+
if (a === undefined || b === undefined)
|
|
86
|
+
return a === b;
|
|
87
|
+
if (a.length !== b.length)
|
|
88
|
+
return false;
|
|
89
|
+
const left = [...a].sort();
|
|
90
|
+
const right = [...b].sort();
|
|
91
|
+
return left.every((item, index) => item === right[index]);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Runs `work` with the given environment applied, and restores the previous values afterwards.
|
|
95
|
+
*
|
|
96
|
+
* The values are applied SYNCHRONOUSLY and the whole of `work` is awaited inside the window, because
|
|
97
|
+
* the extension factory runs during the loader's reload and reads the environment at that moment. A
|
|
98
|
+
* concurrent session's environment would otherwise land in the middle of this one.
|
|
99
|
+
*/
|
|
100
|
+
export async function withEnvironment(values, work) {
|
|
101
|
+
const previous = new Map();
|
|
102
|
+
for (const [key, value] of Object.entries(values)) {
|
|
103
|
+
previous.set(key, process.env[key]);
|
|
104
|
+
process.env[key] = value;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
return await work();
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
for (const [key, value] of previous) {
|
|
111
|
+
if (value === undefined)
|
|
112
|
+
delete process.env[key];
|
|
113
|
+
else
|
|
114
|
+
process.env[key] = value;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
export async function createPiSession(creation) {
|
|
119
|
+
const sdk = await loadSdk();
|
|
120
|
+
const agentDir = creation.agentDir ?? defaultAgentDir();
|
|
121
|
+
return withEnvironment(creation.extensionEnv, async () => {
|
|
122
|
+
// THE AGENT'S PROMPT. Pi's loader takes the same two shapes the command line took: a `systemPrompt`
|
|
123
|
+
// that REPLACES Pi's own, and an `appendSystemPrompt` list of files that are ADDED to it. An agent
|
|
124
|
+
// that says `promptMode: replace` gets the first, and one that says nothing gets the second - the
|
|
125
|
+
// same two meanings the `--system-prompt` and `--append-system-prompt` flags carried, and the same
|
|
126
|
+
// distinction: an append keeps Pi's own coding-agent baseline (its tool guidance, its environment
|
|
127
|
+
// facts) and adds the role on top.
|
|
128
|
+
const loader = new sdk.DefaultResourceLoader({
|
|
129
|
+
cwd: creation.cwd,
|
|
130
|
+
agentDir,
|
|
131
|
+
...(creation.extensionPaths.length ? { additionalExtensionPaths: creation.extensionPaths } : {}),
|
|
132
|
+
...(creation.systemPromptFile && creation.systemPromptMode === "replace"
|
|
133
|
+
? { systemPrompt: creation.systemPromptFile }
|
|
134
|
+
: creation.systemPromptFile
|
|
135
|
+
? { appendSystemPrompt: [creation.systemPromptFile] }
|
|
136
|
+
: {}),
|
|
137
|
+
});
|
|
138
|
+
await loader.reload();
|
|
139
|
+
const sessionManager = creation.persist === false
|
|
140
|
+
? sdk.SessionManager.inMemory(creation.cwd, { id: creation.sessionId })
|
|
141
|
+
: await resolveSessionManager(sdk, creation);
|
|
142
|
+
const modelRuntime = await sdk.ModelRuntime.create();
|
|
143
|
+
// THE MODEL IS RESOLVED, NOT PASSED THROUGH. Pi's SDK takes a whole Model object - the provider's
|
|
144
|
+
// configuration, the endpoint, the capability list - and Super Pi only knows the pair
|
|
145
|
+
// (provider, modelId) that the picker shows. The runtime is what turns that pair into the object
|
|
146
|
+
// Pi needs, and it is the same runtime the catalog probe uses, so the model the session runs on is
|
|
147
|
+
// the model the picker listed.
|
|
148
|
+
//
|
|
149
|
+
// A model that cannot be resolved is NOT a reason to fail the session: Pi falls back to its own
|
|
150
|
+
// default, and the turn still runs. Failing here would turn "the picker showed a model this Pi no
|
|
151
|
+
// longer has" into a conversation that cannot be sent at all.
|
|
152
|
+
const model = creation.model
|
|
153
|
+
? modelRuntime.getModel?.(creation.model.provider, creation.model.modelId)
|
|
154
|
+
: undefined;
|
|
155
|
+
const options = {
|
|
156
|
+
cwd: creation.cwd,
|
|
157
|
+
agentDir,
|
|
158
|
+
modelRuntime,
|
|
159
|
+
resourceLoader: loader,
|
|
160
|
+
sessionManager,
|
|
161
|
+
...(creation.tools ? { tools: creation.tools } : {}),
|
|
162
|
+
...(creation.excludeTools ? { excludeTools: creation.excludeTools } : {}),
|
|
163
|
+
...(model ? { model } : {}),
|
|
164
|
+
...(creation.thinkingLevel ? { thinkingLevel: creation.thinkingLevel } : {}),
|
|
165
|
+
};
|
|
166
|
+
const { session } = await sdk.createAgentSession(options);
|
|
167
|
+
return session;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Resolves the session manager for a conversation: the EXISTING transcript if there is one, a new one
|
|
172
|
+
* otherwise.
|
|
173
|
+
*
|
|
174
|
+
* WHY THIS IS NOT JUST `SessionManager.create`. A conversation's transcript outlives its session - Pi
|
|
175
|
+
* writes it to a file, and a session that was evicted (idle, or thrown away by a revert) is rebuilt from
|
|
176
|
+
* that file. So building a session is "reopen the conversation's transcript", and the id is what says
|
|
177
|
+
* which transcript: Super Pi's conversation id IS the session id, which is what makes `revert` and
|
|
178
|
+
* `fork` able to find the file without guessing at a filename.
|
|
179
|
+
*
|
|
180
|
+
* The existing file is found by EXACT id, not by listing the directory and matching a name. Pi's own
|
|
181
|
+
* lookup (`SessionManager.findById`) is the authority for that, and it also enforces the rule that
|
|
182
|
+
* matters here: a session is only found when it was recorded for the SAME working directory. A
|
|
183
|
+
* conversation whose workspace changed therefore gets a fresh transcript rather than somebody else's.
|
|
184
|
+
*/
|
|
185
|
+
async function resolveSessionManager(sdk, creation) {
|
|
186
|
+
const existing = sdk.SessionManager.findById(creation.cwd, creation.sessionId, creation.sessionDir);
|
|
187
|
+
if (existing) {
|
|
188
|
+
try {
|
|
189
|
+
return sdk.SessionManager.open(existing, creation.sessionDir, creation.cwd);
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
// A file that exists but cannot be opened is not a reason to fail the turn: falling through
|
|
193
|
+
// creates a fresh session, and the reader sees an empty conversation rather than an error. The
|
|
194
|
+
// alternative - refusing - would make a corrupt file permanently break the conversation.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return sdk.SessionManager.create(creation.cwd, creation.sessionDir, { id: creation.sessionId });
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Pi's own global configuration directory, when it can be worked out without importing the SDK.
|
|
201
|
+
*
|
|
202
|
+
* The SDK's `getAgentDir()` is the authority, but asking it would mean importing the SDK - which this
|
|
203
|
+
* file goes out of its way not to do for a path it can derive. The derivation is Pi's own default:
|
|
204
|
+
* `~/.pi/agent`, with the home directory taken from the environment rather than from `os.homedir()`,
|
|
205
|
+
* because on Windows the two disagree often enough to matter.
|
|
206
|
+
*/
|
|
207
|
+
function defaultAgentDir() {
|
|
208
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
209
|
+
return home ? join(home, ".pi", "agent") : join(".pi", "agent");
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* The Super Pi extension files, as absolute paths.
|
|
213
|
+
*
|
|
214
|
+
* Resolved from the repository root, exactly as `runtime.ts` resolves the `--extension` argument it
|
|
215
|
+
* passes to a child process: Pi resolves a relative `--extension` against the CONVERSATION's workspace
|
|
216
|
+
* rather than against the server's directory, so a relative path would find a different file for every
|
|
217
|
+
* workspace. The SDK takes an absolute path, and this keeps the two paths of the same file.
|
|
218
|
+
*/
|
|
219
|
+
export function superPiExtensionPaths(repositoryRoot) {
|
|
220
|
+
const paths = [
|
|
221
|
+
join(repositoryRoot, "agents", "super-pi-task", "index.ts"),
|
|
222
|
+
join(repositoryRoot, "agents", "super-pi-memory", "index.ts"),
|
|
223
|
+
join(repositoryRoot, "agents", "super-pi-knowledge", "index.ts"),
|
|
224
|
+
];
|
|
225
|
+
return paths.filter((path) => existsSync(path));
|
|
226
|
+
}
|
package/dist/server/runtime.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ export { RpcChannel, type RpcRecord } from "./rpc-channel.js";
|
|
|
3
3
|
import type { ActiveMemory } from "./active-memory.js";
|
|
4
4
|
import { type KnowledgeBase } from "./knowledge.js";
|
|
5
5
|
export { forkSessionFile, truncateSessionFile } from "./session-repository.js";
|
|
6
|
+
export { describeDeliveredUserMessage, describePiQueue } from "./pi-session-events.js";
|
|
7
|
+
import { type PiSessionFactory } from "./pi-session.js";
|
|
6
8
|
import { type AgentDefinition, type Conversation, type ExtensionUiAnswer, type ExtensionUiRequest, type MessageImage, type ModelCatalog, type ModelOption, type ModelSelection, type PiQueueSnapshot, type ToolCallNotice, type ToolCallPreparing } from "./types.js";
|
|
7
9
|
/**
|
|
8
10
|
* Who answers Pi's Extension UI questions. The runtime never invents an answer: a blocking method
|
|
@@ -79,18 +81,24 @@ export interface AnswerInput {
|
|
|
79
81
|
/**
|
|
80
82
|
* Called ONCE per turn with the Pi entry id of the user message that started it, or never.
|
|
81
83
|
*
|
|
82
|
-
* This is the whole of Super Pi's ability to name a position inside Pi's transcript
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* `thinking_level_change`, `compaction`, ...) and only Pi knows
|
|
84
|
+
* This is the whole of Super Pi's ability to name a position inside Pi's transcript. The one-shot path
|
|
85
|
+
* reads it over RPC (`get_entries` before the prompt, `get_entries(since: leaf)` after the turn); the
|
|
86
|
+
* session path reads the same facts from the live session's own transcript (`getLeafId` before,
|
|
87
|
+
* `getBranch` after). Both read Pi's own entries rather than counting, because Pi writes entry types
|
|
88
|
+
* that are not messages (`model_change`, `thinking_level_change`, `compaction`, ...) and only Pi knows
|
|
89
|
+
* which line the user message is.
|
|
87
90
|
*
|
|
88
91
|
* Failing to produce one is NOT a turn failure - the answer has already streamed by then - so it is
|
|
89
92
|
* never awaited on the critical path and never thrown. A message with no entry id simply cannot be a
|
|
90
93
|
* revert boundary, which the revert handler reports in words.
|
|
91
94
|
*/
|
|
92
95
|
onUserEntry?: (entryId: string) => void | Promise<void>;
|
|
93
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* The session file Pi resolved for this conversation. A host caches it as a locator for a later revert
|
|
98
|
+
* or fork. The one-shot path learns it from `get_state`; the session path reads it off the live session
|
|
99
|
+
* and reports it when it CHANGES - a session rebuild (revert, eviction, fingerprint change) is exactly
|
|
100
|
+
* when the cached locator needs refreshing.
|
|
101
|
+
*/
|
|
94
102
|
onSessionFile?: (path: string) => void | Promise<void>;
|
|
95
103
|
/**
|
|
96
104
|
* Called once per `tool_execution_start`, with the tool's name and the bounded label naming what it was
|
|
@@ -276,6 +284,17 @@ export interface AgentRuntime {
|
|
|
276
284
|
* directory themselves, so the picker and the spawn can never disagree about which definition is live.
|
|
277
285
|
*/
|
|
278
286
|
agentFor?(conversation: Pick<Conversation, "id" | "agentId">): Promise<AgentDefinition | undefined>;
|
|
287
|
+
/**
|
|
288
|
+
* Throws away the live sessions this runtime holds.
|
|
289
|
+
*
|
|
290
|
+
* A runtime that keeps a session alive between turns has to be told, at shutdown, that those
|
|
291
|
+
* sessions should go: a stopped TURN is not a stopped SESSION, and a session nobody stops holds a
|
|
292
|
+
* model client and - on Windows - a lock on the conversation's working directory.
|
|
293
|
+
*
|
|
294
|
+
* Optional, because a runtime with no sessions behind it (the welcome runtime) has nothing to
|
|
295
|
+
* throw away.
|
|
296
|
+
*/
|
|
297
|
+
disposeSessions?(conversationId?: string): Promise<void>;
|
|
279
298
|
/**
|
|
280
299
|
* The model/thinking catalog a picker lists. `selection` scopes the answer to one model's
|
|
281
300
|
* capabilities, because Pi's thinking levels are per model (`getSupportedThinkingLevels`,
|
|
@@ -326,28 +345,6 @@ export declare class WelcomeRuntime implements AgentRuntime {
|
|
|
326
345
|
* through the same channel instead of writing a second JSONL reader. Every field below is optional, so
|
|
327
346
|
* a reader must check before using one.
|
|
328
347
|
*/
|
|
329
|
-
/**
|
|
330
|
-
* Reads Pi's `queue_update` payload, or returns undefined when the record is not one.
|
|
331
|
-
*
|
|
332
|
-
* Filters to real strings rather than passing the arrays through, so a malformed snapshot cannot put
|
|
333
|
-
* objects into the browser's DOM or into a log line that is supposed to be readable - the same rule
|
|
334
|
-
* `textList` already applies to Extension UI options. Returns undefined for the wrong `type` too, so the
|
|
335
|
-
* drain loop can keep using it as the recogniser.
|
|
336
|
-
*/
|
|
337
|
-
export declare function describePiQueue(record: RpcRecord): PiQueueSnapshot | undefined;
|
|
338
|
-
/**
|
|
339
|
-
* Reads the text of a steer Pi has just delivered, or undefined when this record is not one.
|
|
340
|
-
*
|
|
341
|
-
* A `message_start` carrying a `user` message is exactly that, and nothing else is: the prompt that
|
|
342
|
-
* opened the turn is never re-announced this way, and `assistant` messages are not deliveries.
|
|
343
|
-
*
|
|
344
|
-
* Content is joined with a newline rather than taken from the first block, so a multi-block steer comes
|
|
345
|
-
* back as the text the user actually wrote instead of its first paragraph. The text is a MATCHING HINT
|
|
346
|
-
* only - Pi identifies its own queued entry by `indexOf` on this string (`agent-session.ts:1123`) and so
|
|
347
|
-
* cannot tell two identical messages apart either, which is why the caller resolves a delivery by
|
|
348
|
-
* position and not by comparing this.
|
|
349
|
-
*/
|
|
350
|
-
export declare function describeDeliveredUserMessage(record: RpcRecord): string | undefined;
|
|
351
348
|
/** A line to write back to Pi's stdin. Mirrors Pi's RpcExtensionUIResponse union (rpc-types.ts:294-297). */
|
|
352
349
|
export type ExtensionUiResponse = {
|
|
353
350
|
type: "extension_ui_response";
|
|
@@ -450,6 +447,10 @@ export declare class PiRpcRuntime implements AgentRuntime {
|
|
|
450
447
|
* older checkout has no todo endpoint at all), and a derived path could not tell "not configured"
|
|
451
448
|
* from "configured wrong". When it is absent the tool still registers and the extension refuses in
|
|
452
449
|
* words, which is what `refuseTodo` in core.ts is for.
|
|
450
|
+
*
|
|
451
|
+
* `sessionFactory` is the TEST SEAM for the session path: the pool builds sessions through it instead
|
|
452
|
+
* of `createPiSession`, so a test can drive `answer()` against a fake `PiSessionLike` and assert on
|
|
453
|
+
* what the turn did with it - the same role the factory parameter plays in `PiSessionPool` itself.
|
|
453
454
|
*/
|
|
454
455
|
private readonly options;
|
|
455
456
|
private memory?;
|
|
@@ -457,6 +458,43 @@ export declare class PiRpcRuntime implements AgentRuntime {
|
|
|
457
458
|
private knowledge?;
|
|
458
459
|
setKnowledge(knowledge: KnowledgeBase): void;
|
|
459
460
|
private readonly runs;
|
|
461
|
+
/**
|
|
462
|
+
* THE LIVE SESSIONS, one per conversation, alive across turns.
|
|
463
|
+
*
|
|
464
|
+
* `runs` is the OLD registry - a child that exists only while a turn is running - and it still
|
|
465
|
+
* describes the one-shot paths (memory review, knowledge rerank, catalog probe), which keep their
|
|
466
|
+
* own throwaway children. The main conversation path does NOT use it: a conversation's session lives
|
|
467
|
+
* in the pool, and a turn is a run ON that session rather than a process OF its own.
|
|
468
|
+
*/
|
|
469
|
+
private readonly sessionPool;
|
|
470
|
+
/**
|
|
471
|
+
* The turn currently running on each conversation's session.
|
|
472
|
+
*
|
|
473
|
+
* This is the piece the old design did not need, because a turn WAS a process and the process
|
|
474
|
+
* registry said everything. With a session that outlives its turns, the host has to know which turn
|
|
475
|
+
* is on the session RIGHT NOW - that is what an abort targets, and what a steer is delivered into.
|
|
476
|
+
*/
|
|
477
|
+
private readonly activeTurns;
|
|
478
|
+
/**
|
|
479
|
+
* THE CURRENT TURN'S EXTENSION-UI HOST, per conversation.
|
|
480
|
+
*
|
|
481
|
+
* Pi's blocking UI questions (`select`/`confirm`/`input`/`editor`) are answered by a host, and the
|
|
482
|
+
* host is a per-TURN thing - `run-service` builds one per `answer()` call - while the UI context the
|
|
483
|
+
* session's extensions talk to is bound ONCE per session. This map is the bridge between the two
|
|
484
|
+
* lifetimes: a turn registers its host here for exactly its own duration, and the session's bound
|
|
485
|
+
* context reads through it at call time. A question asked while no turn is registered gets the honest
|
|
486
|
+
* answer - `cancelled` - never a default, which is the same rule the RPC path's no-host branch follows.
|
|
487
|
+
*/
|
|
488
|
+
private readonly sessionHosts;
|
|
489
|
+
/** The sessions whose extension runner already has the UI bridge bound. */
|
|
490
|
+
private readonly uiBoundSessions;
|
|
491
|
+
/**
|
|
492
|
+
* The session-file path last reported for a conversation, so `onSessionFile` fires when it CHANGES
|
|
493
|
+
* rather than once per turn. The path is stable for the life of a session; what changes it is a
|
|
494
|
+
* rebuild (a revert, an eviction, a fingerprint change), which is exactly when the cached locator
|
|
495
|
+
* needs refreshing.
|
|
496
|
+
*/
|
|
497
|
+
private readonly reportedSessionFiles;
|
|
460
498
|
/**
|
|
461
499
|
* `steer` commands written to a child and not yet answered by Pi, keyed by the command id.
|
|
462
500
|
*
|
|
@@ -475,6 +513,8 @@ export declare class PiRpcRuntime implements AgentRuntime {
|
|
|
475
513
|
private readonly pendingCatalogs;
|
|
476
514
|
/** Latches the "no extension on disk" warning, so it is said once rather than once per turn. */
|
|
477
515
|
private warnedMissingExtension;
|
|
516
|
+
/** Latches the "Pi's SDK is not installed" warning, for the same reason. */
|
|
517
|
+
private warnedMissingSdk;
|
|
478
518
|
/**
|
|
479
519
|
* Agent definitions are read from disk, never cached. A file is cheap to parse, and re-reading it
|
|
480
520
|
* per turn is the whole point: editing an agent's prompt or tool allowlist changes the NEXT message
|
|
@@ -517,6 +557,10 @@ export declare class PiRpcRuntime implements AgentRuntime {
|
|
|
517
557
|
* older checkout has no todo endpoint at all), and a derived path could not tell "not configured"
|
|
518
558
|
* from "configured wrong". When it is absent the tool still registers and the extension refuses in
|
|
519
559
|
* words, which is what `refuseTodo` in core.ts is for.
|
|
560
|
+
*
|
|
561
|
+
* `sessionFactory` is the TEST SEAM for the session path: the pool builds sessions through it instead
|
|
562
|
+
* of `createPiSession`, so a test can drive `answer()` against a fake `PiSessionLike` and assert on
|
|
563
|
+
* what the turn did with it - the same role the factory parameter plays in `PiSessionPool` itself.
|
|
520
564
|
*/
|
|
521
565
|
options?: {
|
|
522
566
|
endpoint?: string;
|
|
@@ -526,7 +570,57 @@ export declare class PiRpcRuntime implements AgentRuntime {
|
|
|
526
570
|
questionEndpoint?: string;
|
|
527
571
|
toolsEndpoint?: string;
|
|
528
572
|
uploadsDir?: string;
|
|
573
|
+
sessionFactory?: PiSessionFactory;
|
|
529
574
|
});
|
|
575
|
+
/**
|
|
576
|
+
* The configuration of the session that was built for a conversation, for tests to inspect.
|
|
577
|
+
*
|
|
578
|
+
* WHY THIS EXISTS. The permission boundary - an agent's tool allowlist, its prompt file, the
|
|
579
|
+
* extensions it mounts - used to be proved by reading the ARGV of a spawned child. A session is not
|
|
580
|
+
* a child and has no argv, so the evidence moved: this is what the session was actually BUILT with,
|
|
581
|
+
* which is the same fact stated in the new architecture's terms.
|
|
582
|
+
*
|
|
583
|
+
* It is a RECORD, not a control channel: nothing in the runtime reads it back, and a test that
|
|
584
|
+
* asserts on it is asserting on the real decision the real code made.
|
|
585
|
+
*/
|
|
586
|
+
private readonly builtSessions;
|
|
587
|
+
/** The configuration a conversation's session was built with, if one was built. */
|
|
588
|
+
builtSessionConfig(conversationId: string): {
|
|
589
|
+
tools?: string[];
|
|
590
|
+
excludeTools?: string[];
|
|
591
|
+
systemPromptFile?: string;
|
|
592
|
+
systemPromptMode?: "replace" | "append";
|
|
593
|
+
extensionPaths: string[];
|
|
594
|
+
extensionEnv: Record<string, string>;
|
|
595
|
+
model?: {
|
|
596
|
+
provider: string;
|
|
597
|
+
modelId: string;
|
|
598
|
+
};
|
|
599
|
+
thinkingLevel?: string;
|
|
600
|
+
} | undefined;
|
|
601
|
+
/**
|
|
602
|
+
* Whether the SDK-backed session layer can be used at all.
|
|
603
|
+
*
|
|
604
|
+
* The SDK is a LIBRARY and it is optional: Super Pi also supports running against a Pi installed as a
|
|
605
|
+
* COMMAND, and a server that cannot load the library still has a working one-shot path for the
|
|
606
|
+
* conversations it can serve. The answer is reported rather than thrown, so the caller can say what is
|
|
607
|
+
* missing instead of failing a turn with a stack trace.
|
|
608
|
+
*/
|
|
609
|
+
sessionSupport(): Promise<{
|
|
610
|
+
available: true;
|
|
611
|
+
} | {
|
|
612
|
+
available: false;
|
|
613
|
+
reason: string;
|
|
614
|
+
}>;
|
|
615
|
+
/**
|
|
616
|
+
* Throws away every live session.
|
|
617
|
+
*
|
|
618
|
+
* Called at shutdown, and after a `revert` - the transcript on disk changed, and a session holding
|
|
619
|
+
* the old context in memory would keep answering from history the user has withdrawn.
|
|
620
|
+
*/
|
|
621
|
+
disposeSessions(conversationId?: string): Promise<void>;
|
|
622
|
+
/** Throws away the sessions that have been idle for a while. */
|
|
623
|
+
evictIdleSessions(): Promise<void>;
|
|
530
624
|
/**
|
|
531
625
|
* The agent a conversation runs as, or undefined for Pi's own defaults.
|
|
532
626
|
*
|
|
@@ -565,8 +659,120 @@ export declare class PiRpcRuntime implements AgentRuntime {
|
|
|
565
659
|
* The TODO ENDPOINT is the one value here that is NOT per-turn derived from disk: it is this server's
|
|
566
660
|
* own address, so it is the same string on every turn and is read once from the constructor's options.
|
|
567
661
|
*/
|
|
662
|
+
/**
|
|
663
|
+
* The environment Super Pi's extensions read when they are loaded.
|
|
664
|
+
*
|
|
665
|
+
* IT DOES NOT INCLUDE THE CREDENTIAL. The credential is minted by the session pool, when the session
|
|
666
|
+
* is created, and added there - because the credential belongs to the SESSION (the extension reads
|
|
667
|
+
* its environment once, when it is loaded) and a token minted per turn would be replaced before the
|
|
668
|
+
* second turn of the conversation, leaving the extension holding a value the server had forgotten.
|
|
669
|
+
*/
|
|
568
670
|
private taskExtensionEnv;
|
|
569
|
-
|
|
671
|
+
/**
|
|
672
|
+
* The environment for a ONE-SHOT child, which is the same as the session's except that it carries
|
|
673
|
+
* the credential.
|
|
674
|
+
*
|
|
675
|
+
* THE DIFFERENCE IS THE LIFETIME. A one-shot child lives for one turn, so its credential is minted
|
|
676
|
+
* for that turn and dies with the child - which is the original rule, and it is still exactly right
|
|
677
|
+
* here. A session lives for many turns, so its credential is minted when the session is created (see
|
|
678
|
+
* `PiSessionPool.acquire`) and the server separately requires a running turn for it to be usable.
|
|
679
|
+
*/
|
|
680
|
+
private oneShotExtensionEnv;
|
|
681
|
+
/**
|
|
682
|
+
* RUNS A TURN ON A PERSISTENT SESSION.
|
|
683
|
+
*
|
|
684
|
+
* This is the path every conversation takes now, and it is where the latency win comes from. There is
|
|
685
|
+
* no process to spawn and none to kill: the conversation's session already exists, its context is
|
|
686
|
+
* already in memory, and a turn is a `prompt()` on it. An interrupt is `abort()` on the same session -
|
|
687
|
+
* which is why an interrupt no longer costs a process teardown and a process startup.
|
|
688
|
+
*
|
|
689
|
+
* THE EVENT ROUTING. Pi pushes events through a subscription; this method pulls them, one at a time,
|
|
690
|
+
* through a queue. The generator yields text deltas to its caller, and every non-text event is
|
|
691
|
+
* delivered to the turn's callbacks as it arrives, so the transcript shows tool calls and reasoning
|
|
692
|
+
* WHILE the model is producing them rather than after the turn ends.
|
|
693
|
+
*
|
|
694
|
+
* THE ABORT. The caller's `signal` is the turn's abort. When it fires, the session is aborted - Pi
|
|
695
|
+
* stops the current operation and waits for the session to go idle - and the turn ends. The session
|
|
696
|
+
* ITSELF survives: it is the next turn's session, and it is the reason the next turn costs no startup.
|
|
697
|
+
*/
|
|
698
|
+
/**
|
|
699
|
+
* The configuration a conversation's session is built with.
|
|
700
|
+
*
|
|
701
|
+
* SEPARATED FROM THE BUILD ON PURPOSE, because this is a decision and the build is its consequence.
|
|
702
|
+
* Everything that decides what Pi is allowed to do for a conversation - the agent's allowlist, its
|
|
703
|
+
* prompt file, the extensions it mounts, the model it runs on - is decided HERE, in one place, and
|
|
704
|
+
* the session is then built from it.
|
|
705
|
+
*
|
|
706
|
+
* Two things want that separation. The FIRST is the fingerprint: the decision is what the session is
|
|
707
|
+
* compared against, so a changed decision means the session is stale. The SECOND is the tests: the
|
|
708
|
+
* permission boundary is the most important thing this runtime does, and asserting on the decision
|
|
709
|
+
* is a DIRECT way to check it - a test that has to start a real Pi to learn what Pi was told is a
|
|
710
|
+
* test that only runs on a machine with a Pi.
|
|
711
|
+
*/
|
|
712
|
+
sessionConfiguration(conversation: Conversation, cwd: string): Promise<{
|
|
713
|
+
tools?: string[];
|
|
714
|
+
excludeTools?: string[];
|
|
715
|
+
systemPromptFile?: string;
|
|
716
|
+
systemPromptMode?: "replace" | "append";
|
|
717
|
+
extensionPaths: string[];
|
|
718
|
+
extensionEnv: Record<string, string>;
|
|
719
|
+
model?: {
|
|
720
|
+
provider: string;
|
|
721
|
+
modelId: string;
|
|
722
|
+
};
|
|
723
|
+
thinkingLevel?: string;
|
|
724
|
+
}>;
|
|
725
|
+
private answerOnSession;
|
|
726
|
+
/**
|
|
727
|
+
* The Extension UI context a session's extensions talk to, bridged to the host of the turn that is
|
|
728
|
+
* running right now.
|
|
729
|
+
*
|
|
730
|
+
* THE SHAPES MIRROR PI'S OWN RPC UI CONTEXT (rpc-mode.ts), method for method, because the extensions
|
|
731
|
+
* were written against that contract: `select`/`input`/`editor` resolve to the chosen text or
|
|
732
|
+
* `undefined` on cancel, `confirm` resolves to a boolean with cancel reading as `false` (Pi's own
|
|
733
|
+
* mapping, not an invention), and the one-way methods are fire-and-forget. The TUI-only methods
|
|
734
|
+
* (`setWorkingMessage`, footers, headers, `custom`, ...) are the same no-ops they are in RPC mode.
|
|
735
|
+
*
|
|
736
|
+
* A BLOCKING CALL WITH NO HOST IS CANCELLED, NEVER DEFAULTED. The no-op context Pi starts with answers
|
|
737
|
+
* `confirm` with `false`, which is indistinguishable from the user clicking "no" - the exact failure
|
|
738
|
+
* the "cancelled, never a default" rule exists to prevent. So an idle session (no turn, no host) and a
|
|
739
|
+
* failed host both answer `cancelled`, and the reason is logged.
|
|
740
|
+
*/
|
|
741
|
+
private extensionUiBridge;
|
|
742
|
+
/**
|
|
743
|
+
* Records the id of the user message this turn wrote, read from the live session's own transcript.
|
|
744
|
+
*
|
|
745
|
+
* THE SAME CONTRACT AS `recordTurnEntryId` ON THE ONE-SHOT PATH, over memory instead of RPC: the
|
|
746
|
+
* captured pre-turn leaf is what makes the read exact, because the entries written after it belong to
|
|
747
|
+
* THIS turn, and the first USER message among them is the one Super Pi just pushed. The three outcomes
|
|
748
|
+
* are the same three:
|
|
749
|
+
*
|
|
750
|
+
* - leaf `null` - the session was empty, so the whole branch is this turn's.
|
|
751
|
+
* - leaf found in the branch - everything after it is this turn's.
|
|
752
|
+
* - leaf NOT found - the transcript moved underneath us (a compaction rewrite, a branch switch), so
|
|
753
|
+
* nothing can be attributed to this turn with certainty and nothing is recorded. Guessing here would
|
|
754
|
+
* hand back an EARLIER turn's id, and a revert to it would silently cut more than the user asked for.
|
|
755
|
+
*
|
|
756
|
+
* Every failure is a warning, never a throw: the answer has already streamed by this point, and "this
|
|
757
|
+
* message cannot be a revert boundary" is not "your message failed".
|
|
758
|
+
*/
|
|
759
|
+
private recordSessionTurnEntryId;
|
|
760
|
+
/**
|
|
761
|
+
* The tool names Super Pi's own extensions contribute, for the agent's allowlist.
|
|
762
|
+
*
|
|
763
|
+
* The same list the spawn line used to carry, so an agent that declares `tools: {"*": false, "read":
|
|
764
|
+
* true}` gets exactly the same tools it used to get - and an agent that says `task: false` still wins,
|
|
765
|
+
* because `toolFlags` treats an explicit false as outranking a host extra.
|
|
766
|
+
*/
|
|
767
|
+
private extensionToolNames;
|
|
768
|
+
answer(input: AnswerInput): AsyncIterable<string>;
|
|
769
|
+
/**
|
|
770
|
+
* The one-shot path: a fresh child process for a single turn.
|
|
771
|
+
*
|
|
772
|
+
* Still used for the memory review, the knowledge reranker, and as the fallback when Pi's SDK is not
|
|
773
|
+
* installed. The main conversation path does NOT come here - see `answer`.
|
|
774
|
+
*/
|
|
775
|
+
private answerOneShot;
|
|
570
776
|
/**
|
|
571
777
|
* Reads back the id of the user message this turn just created, and hands it to the caller.
|
|
572
778
|
*
|
|
@@ -703,6 +909,14 @@ export declare class PiRpcRuntime implements AgentRuntime {
|
|
|
703
909
|
cwd: string;
|
|
704
910
|
signal?: AbortSignal;
|
|
705
911
|
}): Promise<string>;
|
|
912
|
+
/**
|
|
913
|
+
* The live session for a conversation, if there is one.
|
|
914
|
+
*
|
|
915
|
+
* Throws when there is none, because every caller wants a session it can act on - a steer that has
|
|
916
|
+
* nowhere to go is a refusal the caller turns into "this message could not be delivered", not a
|
|
917
|
+
* silent success.
|
|
918
|
+
*/
|
|
919
|
+
private sessionFor;
|
|
706
920
|
steer(conversationId: string, message: string, images?: MessageImage[]): Promise<SteerDisposition>;
|
|
707
921
|
/**
|
|
708
922
|
* The model/thinking catalog, from Pi, cached.
|