@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.
@@ -0,0 +1,262 @@
1
+ /**
2
+ * THE SESSION POOL: one live Pi session per conversation, kept across turns.
3
+ *
4
+ * WHAT IT OWNS. A conversation's Pi session, its fingerprint, and the rule that decides when the live
5
+ * session has gone stale and must be thrown away. Everything that can make a session stale is a
6
+ * FINGERPRINT (see `pi-session.ts`): the agent's prompt, the tool allowlist, the mounted extensions,
7
+ * the workspace, the model. Pi decides all of those when a session is created and cannot change any of
8
+ * them afterwards, so a session whose fingerprint no longer matches the conversation's current
9
+ * requirements is not "slightly wrong" - it is the wrong session, and the only honest response is to
10
+ * replace it.
11
+ *
12
+ * WHY THE POOL IS EXPLICIT ABOUT EVICTING. A session holds a model client, a session file handle and a
13
+ * conversation context. Left alone, a server that has seen fifty conversations would hold fifty of
14
+ * them, and the ones nobody is using would be the ones costing the most memory. So an idle session is
15
+ * evicted after a while, and the conversation's context is not lost with it: Pi writes the transcript
16
+ * to disk, and the next session for that conversation is rebuilt from that file.
17
+ *
18
+ * THE REVERT CASE. `revert` truncates the transcript on disk. A live session's in-memory context does
19
+ * NOT change, and nothing in Pi would notice: the model would keep answering from history the user
20
+ * has withdrawn. So a revert must EVICT the session, and the next turn rebuilds from the truncated
21
+ * file. This is the one case where eviction is not an optimization but a correctness requirement.
22
+ */
23
+ import { existsSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import { createPiSession, sameFingerprint, superPiExtensionPaths, } from "./pi-session.js";
26
+ /** How long an idle session survives before it is evicted. */
27
+ const IDLE_EVICTION_MS = 10 * 60_000;
28
+ /**
29
+ * The environment variable the session's credential travels in.
30
+ *
31
+ * THE SAME NAME THE EXTENSION READS, written out rather than imported: `agents/` is loaded by Pi's
32
+ * module loader and `src/` by tsc, so an import across the two would resolve differently under each.
33
+ * `scripts/task-tool.test.mjs` asserts that the two copies agree.
34
+ */
35
+ const CAPABILITY_ENV = "SUPER_PI_TASK_CAPABILITY";
36
+ const TOOLS_CAPABILITY_ENV = "SUPER_PI_TOOLS_CAPABILITY";
37
+ /**
38
+ * The pool.
39
+ *
40
+ * One instance per server. It is deliberately not a general-purpose pool: the sessions it holds differ
41
+ * from each other in their agent, their tools and their workspace, so there is nothing to share between
42
+ * them and no reason to try.
43
+ */
44
+ export class PiSessionPool {
45
+ sessionDir;
46
+ repositoryRoot;
47
+ agentDir;
48
+ onSessionDestroyed;
49
+ factory;
50
+ mintCapability;
51
+ sessions = new Map();
52
+ constructor(
53
+ /** Where Pi keeps its session files. */
54
+ sessionDir,
55
+ /** The repository root, for locating Super Pi's own extensions. */
56
+ repositoryRoot,
57
+ /** Pi's global configuration directory, when it is not Pi's own default. */
58
+ agentDir,
59
+ /**
60
+ * Called when a session is destroyed, so the caller can release whatever the session was holding.
61
+ *
62
+ * A session holds a capability: the token its extension read out of the environment when it was
63
+ * loaded. When the session goes, that token must go with it - a credential whose holder no longer
64
+ * exists is exactly the shape the capability model exists to prevent. The pool does not know what a
65
+ * capability is, so it says WHEN and the caller says WHAT.
66
+ */
67
+ onSessionDestroyed,
68
+ /**
69
+ * How a session is built. Injectable so a test can drive a session without a real Pi behind it.
70
+ */
71
+ factory = createPiSession,
72
+ /**
73
+ * Mints the credential the session's extension will hold. Injected rather than imported so the pool
74
+ * does not have to know what a capability is - it needs to know WHEN one is needed, and the caller
75
+ * knows what one looks like.
76
+ */
77
+ mintCapability = () => "") {
78
+ this.sessionDir = sessionDir;
79
+ this.repositoryRoot = repositoryRoot;
80
+ this.agentDir = agentDir;
81
+ this.onSessionDestroyed = onSessionDestroyed;
82
+ this.factory = factory;
83
+ this.mintCapability = mintCapability;
84
+ }
85
+ /**
86
+ * The session for a conversation, built if there is none and REPLACED if the live one is stale.
87
+ *
88
+ * The fingerprint comparison is what makes a stale session impossible to use: it happens BEFORE the
89
+ * session is handed out, not after, so a caller can never accidentally run a turn on a session whose
90
+ * tool allowlist has changed.
91
+ */
92
+ async acquire(request) {
93
+ const fingerprint = this.fingerprintOf(request);
94
+ const existing = this.sessions.get(request.conversationId);
95
+ if (existing) {
96
+ if (sameFingerprint(existing.fingerprint, fingerprint)) {
97
+ existing.lastUsedAt = Date.now();
98
+ // A session that is still being built is joined, not rebuilt: two sends arriving together must
99
+ // not produce two sessions, and the second one would be a duplicate model client talking to the
100
+ // same conversation.
101
+ if (existing.building)
102
+ return existing.building;
103
+ return existing.session;
104
+ }
105
+ // STALE. The conversation's requirements changed underneath the live session, so it is thrown away
106
+ // BEFORE the new one is built, and not after - a session that is being disposed of while the new
107
+ // one starts would have two contexts for one conversation.
108
+ await this.evict(request.conversationId);
109
+ }
110
+ // THE CREDENTIAL IS MINTED HERE, WHERE THE SESSION IS BORN. The capability travels in the
111
+ // extension's environment, and the extension reads its environment when it is LOADED - which happens
112
+ // once per session. A token minted per TURN would be replaced before the second turn of the
113
+ // conversation, and the extension would still be holding the first turn's value: the server would
114
+ // (correctly) refuse it, and delegation would break after the first message.
115
+ //
116
+ // So the token belongs to the SESSION, and it is minted exactly when the session is created. The
117
+ // server separately requires that a turn is RUNNING for the token to be usable, which is what keeps a
118
+ // longer-lived token from being a weaker credential than a per-turn one.
119
+ const capability = this.mintCapability(request.conversationId);
120
+ const extensionEnv = { ...request.extensionEnv, [CAPABILITY_ENV]: capability, [TOOLS_CAPABILITY_ENV]: capability };
121
+ const building = this.build({ ...request, extensionEnv }, fingerprint);
122
+ this.sessions.set(request.conversationId, { session: undefined, fingerprint, lastUsedAt: Date.now(), building });
123
+ try {
124
+ const session = await building;
125
+ const entry = this.sessions.get(request.conversationId);
126
+ if (entry) {
127
+ entry.session = session;
128
+ entry.building = undefined;
129
+ }
130
+ return session;
131
+ }
132
+ catch (error) {
133
+ // A failed build must not leave a broken entry behind: the next turn would find it and treat the
134
+ // failure as "the session exists", and every later turn would fail the same way with no reason.
135
+ this.sessions.delete(request.conversationId);
136
+ // And a failed build must not leave a CREDENTIAL behind either: the session it belonged to was
137
+ // never born, so the token is a credential with no owner.
138
+ this.onSessionDestroyed?.(request.conversationId);
139
+ throw error;
140
+ }
141
+ }
142
+ async build(request, fingerprint) {
143
+ const creation = {
144
+ cwd: request.cwd,
145
+ sessionId: request.conversationId,
146
+ sessionDir: this.sessionDir,
147
+ extensionPaths: superPiExtensionPaths(this.repositoryRoot),
148
+ extensionEnv: request.extensionEnv,
149
+ ...(request.systemPromptFile ? { systemPromptFile: request.systemPromptFile } : {}),
150
+ ...(request.systemPromptMode ? { systemPromptMode: request.systemPromptMode } : {}),
151
+ ...(request.tools ? { tools: request.tools } : {}),
152
+ ...(request.excludeTools ? { excludeTools: request.excludeTools } : {}),
153
+ ...(request.model ? { model: request.model } : {}),
154
+ ...(request.thinkingLevel ? { thinkingLevel: request.thinkingLevel } : {}),
155
+ ...(this.agentDir ? { agentDir: this.agentDir } : {}),
156
+ };
157
+ return this.factory(creation);
158
+ }
159
+ fingerprintOf(request) {
160
+ return {
161
+ cwd: request.cwd,
162
+ agentId: request.agentId,
163
+ tools: request.tools,
164
+ excludeTools: request.excludeTools,
165
+ extensions: superPiExtensionPaths(this.repositoryRoot),
166
+ model: request.model,
167
+ thinkingLevel: request.thinkingLevel,
168
+ };
169
+ }
170
+ /**
171
+ * The live session for a conversation, without building one.
172
+ *
173
+ * Absent when there is none - including when the session is still being built, because a session
174
+ * that is not ready cannot be steered into.
175
+ */
176
+ get(conversationId) {
177
+ const entry = this.sessions.get(conversationId);
178
+ return entry && !entry.building ? entry.session : undefined;
179
+ }
180
+ /**
181
+ * Whether a conversation has a live session, without building one.
182
+ *
183
+ * Used by the paths that need to know whether a session EXISTS - `revert` has to know whether there is
184
+ * an in-memory context to invalidate - and by the tests.
185
+ */
186
+ has(conversationId) {
187
+ return this.sessions.has(conversationId);
188
+ }
189
+ /**
190
+ * Throws away a conversation's session.
191
+ *
192
+ * The reason this is a PUBLIC operation and not an internal detail is that the callers are the ones
193
+ * that know WHY the session is stale. `revert` calls it because the disk changed under the session;
194
+ * a configuration change calls it because the requirements changed. The pool does not second-guess
195
+ * either - it disposes what it is told to dispose.
196
+ */
197
+ async evict(conversationId) {
198
+ const entry = this.sessions.get(conversationId);
199
+ if (!entry)
200
+ return;
201
+ this.sessions.delete(conversationId);
202
+ // The credential the session was holding dies with it, in every path - including the one where the
203
+ // build is still running, because a session that finishes building after its eviction would
204
+ // otherwise come back holding a token the server has already forgotten.
205
+ this.onSessionDestroyed?.(conversationId);
206
+ if (entry.building) {
207
+ // The build is still running. Awaiting it before disposing is what guarantees the session is
208
+ // actually released rather than leaked - a session that finishes building after the eviction has
209
+ // no owner left to dispose it.
210
+ const session = await entry.building.catch(() => undefined);
211
+ session?.dispose();
212
+ return;
213
+ }
214
+ try {
215
+ entry.session?.dispose();
216
+ }
217
+ catch (error) {
218
+ console.warn(`Super Pi: disposing the Pi session of ${conversationId} threw: ${error instanceof Error ? error.message : "unknown error"}`);
219
+ }
220
+ }
221
+ /**
222
+ * Throws away every session that has not been used for a while.
223
+ *
224
+ * The conversation is not lost: Pi wrote the transcript to disk, and the next turn rebuilds the
225
+ * session from that file. What is lost is the in-memory context, which is exactly what an idle
226
+ * conversation does not need.
227
+ */
228
+ async evictIdle(now = Date.now()) {
229
+ for (const [conversationId, entry] of [...this.sessions]) {
230
+ if (now - entry.lastUsedAt >= IDLE_EVICTION_MS)
231
+ await this.evict(conversationId);
232
+ }
233
+ }
234
+ /** Throws away every session. Used at shutdown. */
235
+ async disposeAll() {
236
+ for (const conversationId of [...this.sessions.keys()])
237
+ await this.evict(conversationId);
238
+ }
239
+ /** The conversations with a live session. */
240
+ get conversationIds() {
241
+ return [...this.sessions.keys()];
242
+ }
243
+ }
244
+ /**
245
+ * Where Super Pi's repository root is, found from the compiled server's own location.
246
+ *
247
+ * The extensions are mounted by PATH, and Pi resolves a relative path against the CONVERSATION's
248
+ * workspace rather than the server's directory - so the path has to be absolute, and it has to be
249
+ * derived from something that is the same in every way the server runs (built to `dist/server/`, or run
250
+ * from `src/server/` under tsx). `import.meta.dirname` is that thing.
251
+ */
252
+ export function repositoryRootFrom(from = import.meta.dirname) {
253
+ return join(from, "..", "..");
254
+ }
255
+ /** Whether Super Pi's own extensions are present on disk. */
256
+ export function extensionsAvailable(repositoryRoot) {
257
+ return superPiExtensionPaths(repositoryRoot).length > 0;
258
+ }
259
+ /** Whether the extension directory exists at all, for a clearer failure message. */
260
+ export function extensionDirectoryExists(repositoryRoot) {
261
+ return existsSync(join(repositoryRoot, "agents", "super-pi-task", "index.ts"));
262
+ }
@@ -0,0 +1,204 @@
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
+ /** Whether the SDK could be loaded. Used by the server to report the reason instead of failing a turn. */
27
+ export declare function piSdkAvailable(): Promise<{
28
+ available: true;
29
+ } | {
30
+ available: false;
31
+ reason: string;
32
+ }>;
33
+ /**
34
+ * Everything that was DECIDED when a session was created and cannot be changed afterwards.
35
+ *
36
+ * A change in any of these means the live session is no longer the session the user asked for, and the
37
+ * only honest response is to throw it away and build the right one. The fingerprint exists so that
38
+ * decision is made in one place, by comparison, rather than by a human remembering to restart.
39
+ */
40
+ export interface SessionFingerprint {
41
+ /** The workspace directory. Pi resolves its session file by cwd, so a different cwd is a different file. */
42
+ cwd: string;
43
+ /** The agent's id, or null for Pi's own defaults. The prompt body is read from disk at creation. */
44
+ agentId: string | null;
45
+ /** The tool allowlist, exactly as it was passed to Pi. */
46
+ tools: string[] | undefined;
47
+ /** The denylist, exactly as it was passed to Pi. */
48
+ excludeTools: string[] | undefined;
49
+ /** Which of Super Pi's own extensions were mounted. */
50
+ 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
+ }
58
+ /**
59
+ * Fingerprints are compared by VALUE, and this is where that comparison is spelled out.
60
+ *
61
+ * The array fields are compared as SETS, not sequences: the tool allowlist is a set of names, and a
62
+ * caller that happens to list them in a different order is asking for the same session, not a new one.
63
+ * Getting this wrong in the strict direction would throw away a perfectly good session on every turn
64
+ * (a latency bug that looks like nothing at all), and getting it wrong in the loose direction would keep
65
+ * a session whose allowlist had genuinely changed (a permission bug). So: order-insensitive, and every
66
+ * element compared exactly.
67
+ */
68
+ export declare function sameFingerprint(a: SessionFingerprint, b: SessionFingerprint): boolean;
69
+ /**
70
+ * The values Super Pi's own extensions read out of their environment.
71
+ *
72
+ * Pi's extension model has no per-session environment: an extension reads `process.env` when it is
73
+ * LOADED. That is fine for a one-process-per-turn design (each turn was its own process) and it is a
74
+ * problem for a shared process, where two conversations would otherwise read each other's values.
75
+ *
76
+ * The resolution is the order of operations, and it is verified rather than assumed: the environment is
77
+ * set, the loader is RELOADED (which is when Pi calls the extension factory, and the factory is what
78
+ * captures the value), and only then is the environment cleared. Two sessions built in an interleaved
79
+ * order each captured their own value - see `scripts/pi-session-isolation.test.mjs`.
80
+ */
81
+ export interface ExtensionEnvironment {
82
+ /** The variable name to value pairs, in the order they should be applied. */
83
+ values: Record<string, string>;
84
+ }
85
+ /**
86
+ * Runs `work` with the given environment applied, and restores the previous values afterwards.
87
+ *
88
+ * The values are applied SYNCHRONOUSLY and the whole of `work` is awaited inside the window, because
89
+ * the extension factory runs during the loader's reload and reads the environment at that moment. A
90
+ * concurrent session's environment would otherwise land in the middle of this one.
91
+ */
92
+ export declare function withEnvironment<T>(values: Record<string, string>, work: () => Promise<T>): Promise<T>;
93
+ /**
94
+ * The live session for one conversation.
95
+ *
96
+ * The Pi types are structural rather than imported, so this file does not force the SDK to be installed
97
+ * for the type checker to work. The shapes here are the ones this code actually uses, and they are
98
+ * checked against the SDK at runtime by the session tests.
99
+ */
100
+ export interface PiSessionLike {
101
+ /** Runs one turn. Resolves when the agent settles. */
102
+ prompt(message: string, options?: {
103
+ images?: unknown[];
104
+ streamingBehavior?: "steer" | "followUp";
105
+ }): Promise<void>;
106
+ /** Aborts the current operation and waits for the session to become idle. */
107
+ abort(): Promise<void>;
108
+ /** Subscribes to session events. Returns the unsubscribe function. */
109
+ subscribe(listener: (event: unknown) => void): () => void;
110
+ /** Releases the session's resources. */
111
+ dispose(): void;
112
+ /** The file Pi writes this session to, when it writes one. */
113
+ readonly sessionFile?: string | undefined;
114
+ /** The id of the session. */
115
+ readonly sessionId?: string | undefined;
116
+ /**
117
+ * The session's transcript, for naming a position in it.
118
+ *
119
+ * This is what makes a message a REVERT BOUNDARY on the session path: the one-shot path asked Pi over
120
+ * RPC (`get_entries`, then `get_entries(since: leaf)`); a live session already holds the same entries
121
+ * in memory, so the same question is two synchronous reads - `getLeafId` before the prompt, `getBranch`
122
+ * after it settles. Optional because a test double has no transcript; its absence means "this turn's
123
+ * message cannot be a revert boundary", reported in words, exactly as a failed RPC read was.
124
+ */
125
+ readonly sessionManager?: {
126
+ /** The id of the current leaf entry, or null when the session is still empty. */
127
+ getLeafId(): string | null;
128
+ /** The entries from the root to `fromId` (or the current leaf), in path order. */
129
+ getBranch(fromId?: string): unknown[];
130
+ };
131
+ /**
132
+ * The session's extension runner, for binding a UI context AFTER creation.
133
+ *
134
+ * Pi's extensions reach `ctx.ui` through this runner, and the runner starts life with a no-op context
135
+ * that silently answers `undefined`/`false` to everything - which for `confirm` is a fabricated "no"
136
+ * the user never gave. The host binds a real bridging context the first time it runs a turn on the
137
+ * session. Optional for the same reason as `sessionManager`: a test double has no extensions.
138
+ */
139
+ readonly extensionRunner?: {
140
+ setUIContext(uiContext: unknown, mode?: string): void;
141
+ };
142
+ }
143
+ /** How a session should be built. Everything here is decided once, at creation. */
144
+ export interface SessionCreation {
145
+ /** The workspace. Pi resolves tools and the session file against it. */
146
+ cwd: string;
147
+ /** The session id, which is the Super Pi conversation id. */
148
+ sessionId: string;
149
+ /** The directory Pi keeps its session files in. */
150
+ sessionDir: string;
151
+ /** Super Pi's extensions to mount, as absolute paths. */
152
+ extensionPaths: string[];
153
+ /** The environment those extensions read when they are loaded. */
154
+ extensionEnv: Record<string, string>;
155
+ /** The agent's system prompt file, or undefined for Pi's own defaults. */
156
+ systemPromptFile?: string;
157
+ /** Whether the agent prompt REPLACES Pi's own or is appended to it. */
158
+ systemPromptMode?: "replace" | "append";
159
+ /** The tool allowlist. */
160
+ tools?: string[];
161
+ /** The tool denylist. */
162
+ excludeTools?: string[];
163
+ /** The model to pin. */
164
+ model?: {
165
+ provider: string;
166
+ modelId: string;
167
+ };
168
+ /** The thinking level to pin. */
169
+ thinkingLevel?: string;
170
+ /** Pi's global configuration directory. Defaults to Pi's own resolution. */
171
+ agentDir?: string;
172
+ /** Whether the session writes a file at all. A memory review must not. */
173
+ persist?: boolean;
174
+ }
175
+ /**
176
+ * Builds one Pi session.
177
+ *
178
+ * The order of operations here IS the isolation mechanism, and each step is load-bearing:
179
+ *
180
+ * 1. The environment is applied, so the extension factories that run during the reload read this
181
+ * session's values rather than another session's.
182
+ * 2. The loader is created and reloaded, which is when Pi calls the extension factories. The factories
183
+ * CAPTURE the values; they do not read them later.
184
+ * 3. The session is created with that loader.
185
+ *
186
+ * Step 2 happening outside step 1 would give every session the same configuration, and step 1 happening
187
+ * after step 2 would give every session no configuration at all. The window is the whole of the reload.
188
+ *
189
+ * THE FACTORY IS INJECTABLE. A test needs to drive a session without a real Pi behind it - to check what
190
+ * a session was BUILT with (its working directory, its allowlist) rather than what it eventually
191
+ * produced. The factory is therefore a parameter rather than a hard-coded call, and production passes
192
+ * the real one.
193
+ */
194
+ export type PiSessionFactory = (creation: SessionCreation) => Promise<PiSessionLike>;
195
+ export declare function createPiSession(creation: SessionCreation): Promise<PiSessionLike>;
196
+ /**
197
+ * The Super Pi extension files, as absolute paths.
198
+ *
199
+ * Resolved from the repository root, exactly as `runtime.ts` resolves the `--extension` argument it
200
+ * passes to a child process: Pi resolves a relative `--extension` against the CONVERSATION's workspace
201
+ * rather than against the server's directory, so a relative path would find a different file for every
202
+ * workspace. The SDK takes an absolute path, and this keeps the two paths of the same file.
203
+ */
204
+ export declare function superPiExtensionPaths(repositoryRoot: string): string[];