@evandu/super-pi 0.1.94 → 0.1.96
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 +3 -3
- package/dist/client/assets/{index-zbxFjL-O.js → index-gjLqMWYT.js} +2 -2
- package/dist/client/index.html +1 -1
- package/dist/extension.js +13 -3
- 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 +82 -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 +262 -29
- package/dist/server/runtime.js +664 -60
- package/dist/server/store.js +4 -4
- package/dist/server/ws-server.js +5 -4
- package/package.json +7 -3
- package/scripts/patch-brace-expansion.mjs +179 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE EVENT BRIDGE: turns a Pi session's event stream into the turn-shaped stream Super Pi consumes.
|
|
3
|
+
*
|
|
4
|
+
* WHY A BRIDGE AND NOT AN INLINE LOOP. The old design read a child's stdout in a `while` loop and
|
|
5
|
+
* dispatched records inline. The new design has no child and no stdout: Pi emits EVENTS through a
|
|
6
|
+
* subscription. A subscription is a push, and Super Pi's turn is a pull - `answer()` is an async
|
|
7
|
+
* generator that yields text deltas to its caller.
|
|
8
|
+
*
|
|
9
|
+
* A push has to become a pull somewhere, and this is where. The bridge keeps a queue of pending items
|
|
10
|
+
* and hands them out one at a time; the generator awaits the next item when the queue is empty. The
|
|
11
|
+
* ordering that matters is preserved exactly: the subscription callback pushes, and the queue is FIFO,
|
|
12
|
+
* so the order the reader sees is the order Pi emitted.
|
|
13
|
+
*
|
|
14
|
+
* THE BOUNDARY IS THE SAME AS BEFORE. Only the events Super Pi already consumed are routed; everything
|
|
15
|
+
* else is counted and reported once, exactly as the old `noteUnhandled` did. A record type that used to
|
|
16
|
+
* be announced as unhandled is still announced as unhandled - this bridge changes WHERE the events come
|
|
17
|
+
* from, not what they mean.
|
|
18
|
+
*/
|
|
19
|
+
import { describeToolCall, describeToolCallArgs, extractPartialResultText } from "./tool-calls.js";
|
|
20
|
+
/** Keeps only real strings, so a malformed queue snapshot cannot put objects into a broadcast. */
|
|
21
|
+
const textList = (value) => Array.isArray(value) ? value.filter((item) => typeof item === "string") : undefined;
|
|
22
|
+
/**
|
|
23
|
+
* Reads a `queue_update` payload, or returns undefined when the record is not one.
|
|
24
|
+
*
|
|
25
|
+
* The same recogniser the RPC drain loop uses, and deliberately so: Pi's RPC mode forwards the session's
|
|
26
|
+
* own events verbatim, so a `queue_update` from a persistent SDK session and one from a child's stdout
|
|
27
|
+
* are the same fact in the same shape - Pi reporting what its in-memory steering/follow-up queues hold
|
|
28
|
+
* (`agent-session.ts:_emitQueueUpdate`). It is the ONLY proof that a steer was actually picked up: Pi
|
|
29
|
+
* removes the entry from its queue and emits this snapshot BEFORE it emits the delivered `message_start`,
|
|
30
|
+
* so a host that ignored it would have no way to tell "queued in Pi" from "delivered to the model".
|
|
31
|
+
*/
|
|
32
|
+
export function describePiQueue(record) {
|
|
33
|
+
const event = record;
|
|
34
|
+
if (event?.type !== "queue_update")
|
|
35
|
+
return undefined;
|
|
36
|
+
return { steering: textList(event.steering) ?? [], followUp: textList(event.followUp) ?? [] };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Reads the text of a steer Pi has just delivered, or undefined when this record is not one.
|
|
40
|
+
*
|
|
41
|
+
* A `message_start` carrying a `user` message is exactly that, and nothing else is: the prompt that
|
|
42
|
+
* opened the turn is never re-announced this way, and `assistant` messages are not deliveries.
|
|
43
|
+
*
|
|
44
|
+
* Content is joined with a newline rather than taken from the first block, so a multi-block steer comes
|
|
45
|
+
* back as the text the user actually wrote instead of its first paragraph. The text is a MATCHING HINT
|
|
46
|
+
* only - Pi identifies its own queued entry by `indexOf` on this string (`agent-session.ts:1123`) and so
|
|
47
|
+
* cannot tell two identical messages apart either, which is why the caller resolves a delivery by
|
|
48
|
+
* position and not by comparing this.
|
|
49
|
+
*/
|
|
50
|
+
export function describeDeliveredUserMessage(record) {
|
|
51
|
+
const event = record;
|
|
52
|
+
if (event?.type !== "message_start")
|
|
53
|
+
return undefined;
|
|
54
|
+
const message = event.message;
|
|
55
|
+
if (!message || typeof message !== "object" || message.role !== "user")
|
|
56
|
+
return undefined;
|
|
57
|
+
const content = message.content;
|
|
58
|
+
if (!Array.isArray(content))
|
|
59
|
+
return undefined;
|
|
60
|
+
const parts = content
|
|
61
|
+
.map((block) => (block && typeof block === "object" && typeof block.text === "string" ? block.text : undefined))
|
|
62
|
+
.filter((part) => part !== undefined);
|
|
63
|
+
return parts.length ? parts.join("\n") : undefined;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* A queue of turn items, with the waiters that are blocked on it.
|
|
67
|
+
*
|
|
68
|
+
* The `settled` flag matters: a turn that ends must release every waiter, including one that is
|
|
69
|
+
* waiting for text that will never arrive. A generator awaiting an item on a queue nobody will ever
|
|
70
|
+
* push to is a turn that hangs forever, and a hung turn is a conversation that never answers.
|
|
71
|
+
*/
|
|
72
|
+
export class TurnQueue {
|
|
73
|
+
items = [];
|
|
74
|
+
waiters = [];
|
|
75
|
+
settled = false;
|
|
76
|
+
/**
|
|
77
|
+
* Resolves when Pi has SETTLED the turn, which is the real end of a turn.
|
|
78
|
+
*
|
|
79
|
+
* The distinction matters: Pi's `prompt()` resolves when the message has been accepted, which is
|
|
80
|
+
* long before the agent has settled. A caller that treats the prompt's resolution as the end of the
|
|
81
|
+
* turn would release the reader while the model was still producing - and the next turn would then
|
|
82
|
+
* arrive at a session Pi considers busy, which Pi refuses outright.
|
|
83
|
+
*/
|
|
84
|
+
settledSignal;
|
|
85
|
+
markSettled;
|
|
86
|
+
constructor() {
|
|
87
|
+
this.settledSignal = new Promise((resolvePromise) => { this.markSettled = resolvePromise; });
|
|
88
|
+
}
|
|
89
|
+
push(item) {
|
|
90
|
+
if (this.settled)
|
|
91
|
+
return;
|
|
92
|
+
if (item.kind === "end" || item.kind === "error") {
|
|
93
|
+
this.settled = true;
|
|
94
|
+
// THE TERMINATING ITEM GOES AFTER THE TEXT THAT IS ALREADY QUEUED. The model produced those
|
|
95
|
+
// deltas, they are part of the answer, and dropping them would truncate the reply at exactly
|
|
96
|
+
// the moment the turn ended - which is the moment the answer is longest. So the terminal item is
|
|
97
|
+
// APPENDED, and a consumer reads the text first and the end second.
|
|
98
|
+
//
|
|
99
|
+
// A waiter that is already blocked is released immediately, because it is waiting for the NEXT
|
|
100
|
+
// item and the queue is finished - there will never be another one.
|
|
101
|
+
if (this.items.length === 0) {
|
|
102
|
+
const waiter = this.waiters.shift();
|
|
103
|
+
if (waiter) {
|
|
104
|
+
waiter(item);
|
|
105
|
+
this.markSettled();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
this.items.push(item);
|
|
110
|
+
// Any waiter still blocked gets the terminal item, so a consumer that is behind by one wake-up
|
|
111
|
+
// still terminates rather than hanging.
|
|
112
|
+
while (this.waiters.length)
|
|
113
|
+
this.waiters.shift()?.(item);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const waiter = this.waiters.shift();
|
|
117
|
+
if (waiter)
|
|
118
|
+
waiter(item);
|
|
119
|
+
else
|
|
120
|
+
this.items.push(item);
|
|
121
|
+
}
|
|
122
|
+
/** Settles the queue with an error, for a turn that failed before it produced anything. */
|
|
123
|
+
fail(error) {
|
|
124
|
+
this.push({ kind: "error", error });
|
|
125
|
+
}
|
|
126
|
+
async next() {
|
|
127
|
+
// The queue is drained BEFORE the settled flag is consulted: the terminal item was APPENDED after
|
|
128
|
+
// the text, so a settled queue can still have text in it, and that text is the model's answer.
|
|
129
|
+
const item = this.items.shift();
|
|
130
|
+
if (item) {
|
|
131
|
+
// The settled signal fires when the terminal item is TAKEN, not when it is queued. A caller
|
|
132
|
+
// waiting for "the turn is over" must not be released while the answer is still unread.
|
|
133
|
+
if (item.kind === "end" || item.kind === "error")
|
|
134
|
+
this.markSettled();
|
|
135
|
+
return item;
|
|
136
|
+
}
|
|
137
|
+
if (this.settled) {
|
|
138
|
+
this.markSettled();
|
|
139
|
+
return { kind: "end" };
|
|
140
|
+
}
|
|
141
|
+
return new Promise((resolve) => { this.waiters.push(resolve); });
|
|
142
|
+
}
|
|
143
|
+
get isSettled() { return this.settled; }
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Handles one Pi event, routing it to the queue and to the turn's callbacks.
|
|
147
|
+
*
|
|
148
|
+
* Returns whether the event was consumed. An unconsumed event is reported once per distinct type and
|
|
149
|
+
* then counted - the same policy the RPC loop applied, and for the same reason: an event type nobody
|
|
150
|
+
* understands is worth saying out loud exactly once.
|
|
151
|
+
*/
|
|
152
|
+
export function handleSessionEvent(event, queue, handlers, report) {
|
|
153
|
+
const record = event;
|
|
154
|
+
if (!record || typeof record.type !== "string")
|
|
155
|
+
return false;
|
|
156
|
+
switch (record.type) {
|
|
157
|
+
case "message_update": {
|
|
158
|
+
const assistant = record.assistantMessageEvent;
|
|
159
|
+
if (!assistant)
|
|
160
|
+
return false;
|
|
161
|
+
if (assistant.type === "text_delta") {
|
|
162
|
+
queue.push({ kind: "text", delta: assistant.delta ?? "" });
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
if (assistant.type === "thinking_delta") {
|
|
166
|
+
handlers.onThinking?.(assistant.delta ?? "");
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
if (assistant.type === "thinking_end") {
|
|
170
|
+
handlers.onThinkingEnd?.();
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
if (assistant.type === "toolcall_start" && typeof assistant.contentIndex === "number") {
|
|
174
|
+
handlers.onToolPreparing?.({ contentIndex: assistant.contentIndex, name: toolNameOf(assistant) });
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
if (assistant.type === "toolcall_end") {
|
|
178
|
+
// The name is announced with the START, and the end is what says the arguments are complete.
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
case "tool_execution_start": {
|
|
184
|
+
const id = record.toolCallId ?? "";
|
|
185
|
+
if (!id)
|
|
186
|
+
return true;
|
|
187
|
+
const name = record.toolName ?? "unknown";
|
|
188
|
+
try {
|
|
189
|
+
handlers.onToolCall?.({ id, name, detail: describeToolCall(name, record.args), args: describeToolCallArgs(record.args) });
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
console.warn(`PiRpcRuntime: the tool call handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
193
|
+
}
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
case "tool_execution_update": {
|
|
197
|
+
const id = record.toolCallId ?? "";
|
|
198
|
+
if (!id)
|
|
199
|
+
return true;
|
|
200
|
+
const name = record.toolName ?? "unknown";
|
|
201
|
+
const live = extractPartialResultText(record.partialResult);
|
|
202
|
+
try {
|
|
203
|
+
handlers.onToolUpdate?.({ id, name, detail: describeToolCall(name, record.args), args: describeToolCallArgs(record.args), ...(live ? { live } : {}) });
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
console.warn(`PiRpcRuntime: the tool update handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
207
|
+
}
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
case "tool_execution_end": {
|
|
211
|
+
const id = record.toolCallId ?? "";
|
|
212
|
+
if (!id)
|
|
213
|
+
return true;
|
|
214
|
+
const name = record.toolName ?? "unknown";
|
|
215
|
+
try {
|
|
216
|
+
handlers.onToolEnd?.({
|
|
217
|
+
id,
|
|
218
|
+
name,
|
|
219
|
+
status: record.isError === true ? "error" : "ok",
|
|
220
|
+
...(typeof record.durationMs === "number" && Number.isFinite(record.durationMs) ? { durationMs: Math.max(0, Math.round(record.durationMs)) } : {}),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
console.warn(`PiRpcRuntime: the tool end handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
225
|
+
}
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
case "queue_update": {
|
|
229
|
+
// Pi's own queue, pushed as a full snapshot whenever it changes. This is the only proof that a
|
|
230
|
+
// steer was really picked up - Pi removes the entry and emits this BEFORE the delivered
|
|
231
|
+
// `message_start` below - so it is routed, not dropped.
|
|
232
|
+
const snapshot = describePiQueue(record);
|
|
233
|
+
if (!snapshot)
|
|
234
|
+
return false;
|
|
235
|
+
try {
|
|
236
|
+
handlers.onPiQueue?.(snapshot);
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
console.warn(`PiRpcRuntime: the pi queue handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
240
|
+
}
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
case "message_start": {
|
|
244
|
+
// A STEER THIS TURN DELIVERED. Pi announces the moment a queued message stops waiting and starts
|
|
245
|
+
// being answered by emitting `message_start` with the user message it picked up, immediately after
|
|
246
|
+
// the `queue_update` above that shows the entry gone. The host uses it to split the turn so the
|
|
247
|
+
// answer lands under the message it answers.
|
|
248
|
+
//
|
|
249
|
+
// A NON-user `message_start` is not a delivery: it gets the same once-per-type report every other
|
|
250
|
+
// unrouted event gets, which is the same policy the RPC drain loop applies.
|
|
251
|
+
const delivered = describeDeliveredUserMessage(record);
|
|
252
|
+
if (delivered === undefined) {
|
|
253
|
+
report(`type ${JSON.stringify(record.type)}`);
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
try {
|
|
257
|
+
handlers.onSteerDelivered?.(delivered);
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
console.warn(`PiRpcRuntime: the steer delivery handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
261
|
+
}
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
case "agent_settled": {
|
|
265
|
+
queue.push({ kind: "end" });
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
default:
|
|
269
|
+
// AN EVENT NOBODY UNDERSTANDS IS REPORTED, HERE. The reporting lives inside this function rather
|
|
270
|
+
// than in its caller so that a caller cannot forget it: a new event type that nobody handles
|
|
271
|
+
// would then be silently dropped, and the "it looks wired up but nothing happens" failure is
|
|
272
|
+
// exactly the one that is hardest to diagnose from the outside.
|
|
273
|
+
report(`type ${JSON.stringify(record.type)}`);
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* The name of a tool call, read the way the RPC side reads it.
|
|
279
|
+
*
|
|
280
|
+
* `toolcall_start` may carry the name directly, or only inside a partial content block - the RPC side
|
|
281
|
+
* reads the direct field first and falls back to the block, and the same order applies here, because
|
|
282
|
+
* the same Pi produces both shapes depending on how much of the call has been written.
|
|
283
|
+
*/
|
|
284
|
+
function toolNameOf(event) {
|
|
285
|
+
const direct = event.toolName;
|
|
286
|
+
if (typeof direct === "string" && direct)
|
|
287
|
+
return direct;
|
|
288
|
+
const content = event.partial?.content;
|
|
289
|
+
const block = Array.isArray(content) ? content.find((item) => item?.type === "toolCall") : undefined;
|
|
290
|
+
return typeof block?.name === "string" ? block.name : "";
|
|
291
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
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 { type PiSessionFactory, type PiSessionLike } from "./pi-session.js";
|
|
24
|
+
/** What the pool needs to build a session for a conversation. */
|
|
25
|
+
export interface SessionRequest {
|
|
26
|
+
conversationId: string;
|
|
27
|
+
cwd: string;
|
|
28
|
+
/** The agent's id, or null. */
|
|
29
|
+
agentId: string | null;
|
|
30
|
+
/** The environment Super Pi's extensions read when they are loaded. */
|
|
31
|
+
extensionEnv: Record<string, string>;
|
|
32
|
+
/** The tool allowlist, or undefined for Pi's own defaults. */
|
|
33
|
+
tools?: string[];
|
|
34
|
+
/** The tool denylist. */
|
|
35
|
+
excludeTools?: string[];
|
|
36
|
+
/** The model, when the conversation pins one. */
|
|
37
|
+
model?: {
|
|
38
|
+
provider: string;
|
|
39
|
+
modelId: string;
|
|
40
|
+
};
|
|
41
|
+
/** The thinking level, when the conversation pins one. */
|
|
42
|
+
thinkingLevel?: string;
|
|
43
|
+
/** The agent's system prompt file, when the conversation runs as an agent. */
|
|
44
|
+
systemPromptFile?: string;
|
|
45
|
+
/** Whether the agent prompt replaces Pi's own. */
|
|
46
|
+
systemPromptMode?: "replace" | "append";
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The pool.
|
|
50
|
+
*
|
|
51
|
+
* One instance per server. It is deliberately not a general-purpose pool: the sessions it holds differ
|
|
52
|
+
* from each other in their agent, their tools and their workspace, so there is nothing to share between
|
|
53
|
+
* them and no reason to try.
|
|
54
|
+
*/
|
|
55
|
+
export declare class PiSessionPool {
|
|
56
|
+
/** Where Pi keeps its session files. */
|
|
57
|
+
private readonly sessionDir;
|
|
58
|
+
/** The repository root, for locating Super Pi's own extensions. */
|
|
59
|
+
private readonly repositoryRoot;
|
|
60
|
+
/** Pi's global configuration directory, when it is not Pi's own default. */
|
|
61
|
+
private readonly agentDir?;
|
|
62
|
+
/**
|
|
63
|
+
* Called when a session is destroyed, so the caller can release whatever the session was holding.
|
|
64
|
+
*
|
|
65
|
+
* A session holds a capability: the token its extension read out of the environment when it was
|
|
66
|
+
* loaded. When the session goes, that token must go with it - a credential whose holder no longer
|
|
67
|
+
* exists is exactly the shape the capability model exists to prevent. The pool does not know what a
|
|
68
|
+
* capability is, so it says WHEN and the caller says WHAT.
|
|
69
|
+
*/
|
|
70
|
+
private readonly onSessionDestroyed?;
|
|
71
|
+
/**
|
|
72
|
+
* How a session is built. Injectable so a test can drive a session without a real Pi behind it.
|
|
73
|
+
*/
|
|
74
|
+
private readonly factory;
|
|
75
|
+
/**
|
|
76
|
+
* Mints the credential the session's extension will hold. Injected rather than imported so the pool
|
|
77
|
+
* does not have to know what a capability is - it needs to know WHEN one is needed, and the caller
|
|
78
|
+
* knows what one looks like.
|
|
79
|
+
*/
|
|
80
|
+
private readonly mintCapability;
|
|
81
|
+
private readonly sessions;
|
|
82
|
+
constructor(
|
|
83
|
+
/** Where Pi keeps its session files. */
|
|
84
|
+
sessionDir: string,
|
|
85
|
+
/** The repository root, for locating Super Pi's own extensions. */
|
|
86
|
+
repositoryRoot: string,
|
|
87
|
+
/** Pi's global configuration directory, when it is not Pi's own default. */
|
|
88
|
+
agentDir?: string | undefined,
|
|
89
|
+
/**
|
|
90
|
+
* Called when a session is destroyed, so the caller can release whatever the session was holding.
|
|
91
|
+
*
|
|
92
|
+
* A session holds a capability: the token its extension read out of the environment when it was
|
|
93
|
+
* loaded. When the session goes, that token must go with it - a credential whose holder no longer
|
|
94
|
+
* exists is exactly the shape the capability model exists to prevent. The pool does not know what a
|
|
95
|
+
* capability is, so it says WHEN and the caller says WHAT.
|
|
96
|
+
*/
|
|
97
|
+
onSessionDestroyed?: ((conversationId: string) => void) | undefined,
|
|
98
|
+
/**
|
|
99
|
+
* How a session is built. Injectable so a test can drive a session without a real Pi behind it.
|
|
100
|
+
*/
|
|
101
|
+
factory?: PiSessionFactory,
|
|
102
|
+
/**
|
|
103
|
+
* Mints the credential the session's extension will hold. Injected rather than imported so the pool
|
|
104
|
+
* does not have to know what a capability is - it needs to know WHEN one is needed, and the caller
|
|
105
|
+
* knows what one looks like.
|
|
106
|
+
*/
|
|
107
|
+
mintCapability?: (conversationId: string) => string);
|
|
108
|
+
/**
|
|
109
|
+
* The session for a conversation, built if there is none and REPLACED if the live one is stale.
|
|
110
|
+
*
|
|
111
|
+
* The fingerprint comparison is what makes a stale session impossible to use: it happens BEFORE the
|
|
112
|
+
* session is handed out, not after, so a caller can never accidentally run a turn on a session whose
|
|
113
|
+
* tool allowlist has changed.
|
|
114
|
+
*/
|
|
115
|
+
acquire(request: SessionRequest): Promise<PiSessionLike>;
|
|
116
|
+
private build;
|
|
117
|
+
private fingerprintOf;
|
|
118
|
+
/**
|
|
119
|
+
* The live session for a conversation, without building one.
|
|
120
|
+
*
|
|
121
|
+
* Absent when there is none - including when the session is still being built, because a session
|
|
122
|
+
* that is not ready cannot be steered into.
|
|
123
|
+
*/
|
|
124
|
+
get(conversationId: string): PiSessionLike | undefined;
|
|
125
|
+
/**
|
|
126
|
+
* Whether a conversation has a live session, without building one.
|
|
127
|
+
*
|
|
128
|
+
* Used by the paths that need to know whether a session EXISTS - `revert` has to know whether there is
|
|
129
|
+
* an in-memory context to invalidate - and by the tests.
|
|
130
|
+
*/
|
|
131
|
+
has(conversationId: string): boolean;
|
|
132
|
+
/**
|
|
133
|
+
* Throws away a conversation's session.
|
|
134
|
+
*
|
|
135
|
+
* The reason this is a PUBLIC operation and not an internal detail is that the callers are the ones
|
|
136
|
+
* that know WHY the session is stale. `revert` calls it because the disk changed under the session;
|
|
137
|
+
* a configuration change calls it because the requirements changed. The pool does not second-guess
|
|
138
|
+
* either - it disposes what it is told to dispose.
|
|
139
|
+
*/
|
|
140
|
+
evict(conversationId: string): Promise<void>;
|
|
141
|
+
/**
|
|
142
|
+
* Throws away every session that has not been used for a while.
|
|
143
|
+
*
|
|
144
|
+
* The conversation is not lost: Pi wrote the transcript to disk, and the next turn rebuilds the
|
|
145
|
+
* session from that file. What is lost is the in-memory context, which is exactly what an idle
|
|
146
|
+
* conversation does not need.
|
|
147
|
+
*/
|
|
148
|
+
evictIdle(now?: number): Promise<void>;
|
|
149
|
+
/** Throws away every session. Used at shutdown. */
|
|
150
|
+
disposeAll(): Promise<void>;
|
|
151
|
+
/** The conversations with a live session. */
|
|
152
|
+
get conversationIds(): string[];
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Where Super Pi's repository root is, found from the compiled server's own location.
|
|
156
|
+
*
|
|
157
|
+
* The extensions are mounted by PATH, and Pi resolves a relative path against the CONVERSATION's
|
|
158
|
+
* workspace rather than the server's directory - so the path has to be absolute, and it has to be
|
|
159
|
+
* derived from something that is the same in every way the server runs (built to `dist/server/`, or run
|
|
160
|
+
* from `src/server/` under tsx). `import.meta.dirname` is that thing.
|
|
161
|
+
*/
|
|
162
|
+
export declare function repositoryRootFrom(from?: string): string;
|
|
163
|
+
/** Whether Super Pi's own extensions are present on disk. */
|
|
164
|
+
export declare function extensionsAvailable(repositoryRoot: string): boolean;
|
|
165
|
+
/** Whether the extension directory exists at all, for a clearer failure message. */
|
|
166
|
+
export declare function extensionDirectoryExists(repositoryRoot: string): boolean;
|