@evandu/super-pi 0.1.101 → 0.1.103
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/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +7 -7
- package/dist/client/assets/{index-8olCbCbk.css → index-DDkbPYLz.css} +1 -1
- package/dist/client/assets/index-mAu39Bra.js +74 -0
- package/dist/client/index.html +2 -2
- package/dist/server/application/catalog-service.d.ts +21 -0
- package/dist/server/application/catalog-service.js +37 -0
- package/dist/server/application/conversation-service.js +20 -1
- package/dist/server/application/run-service.d.ts +51 -0
- package/dist/server/application/run-service.js +157 -7
- package/dist/server/command-handler.js +12 -0
- package/dist/server/pi-session.d.ts +34 -0
- package/dist/server/runtime.d.ts +51 -0
- package/dist/server/runtime.js +89 -2
- package/dist/server/store.d.ts +19 -1
- package/dist/server/store.js +37 -2
- package/dist/server/types.d.ts +43 -0
- package/package.json +4 -1
- package/scripts/diagnose.mjs +122 -8
- package/dist/client/assets/index-gjLqMWYT.js +0 -74
package/dist/client/index.html
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
<link rel="alternate icon" href="/favicon.ico" />
|
|
9
9
|
<link rel="apple-touch-icon" href="/favicon.svg" />
|
|
10
10
|
<title>Super Pi</title>
|
|
11
|
-
<script type="module" crossorigin src="/assets/index-
|
|
12
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
11
|
+
<script type="module" crossorigin src="/assets/index-mAu39Bra.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DDkbPYLz.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
15
15
|
<div id="root"></div>
|
|
@@ -58,6 +58,27 @@ export declare class CatalogService {
|
|
|
58
58
|
* Broadcasts subagents to connected clients.
|
|
59
59
|
*/
|
|
60
60
|
broadcastSubagents(): Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* Sets the app-wide default model, or clears it back to "Pi's own default".
|
|
63
|
+
*
|
|
64
|
+
* THE MODEL IS CHECKED AGAINST PI'S OWN CATALOG, for the same reason the workspace default is:
|
|
65
|
+
* a default Pi does not offer would be stamped onto every new conversation, and the first turn
|
|
66
|
+
* would fail on a model the user never chose. The check happens at SAVE time rather than at
|
|
67
|
+
* birth time so the user hears about it while they are still looking at the list.
|
|
68
|
+
*
|
|
69
|
+
* The label is taken from the catalog row rather than from the request, so the stored value
|
|
70
|
+
* always carries the display name Pi itself reported.
|
|
71
|
+
*/
|
|
72
|
+
setDefaultModel(model: {
|
|
73
|
+
provider: string;
|
|
74
|
+
modelId: string;
|
|
75
|
+
} | null): Promise<void>;
|
|
76
|
+
/**
|
|
77
|
+
* The UNFILTERED catalog, for the settings window's model panel. Deliberately not the scoped one:
|
|
78
|
+
* the panel is where the app-wide default is chosen, and a workspace's model rules must not hide
|
|
79
|
+
* models from the place that decides what the app-wide default may be.
|
|
80
|
+
*/
|
|
81
|
+
getAllCatalog(): Promise<ModelCatalog>;
|
|
61
82
|
/**
|
|
62
83
|
* Helper formatting an error when a requested model/thinking level is not offered by Pi.
|
|
63
84
|
*/
|
|
@@ -165,6 +165,43 @@ export class CatalogService {
|
|
|
165
165
|
async broadcastSubagents() {
|
|
166
166
|
this.broadcaster.broadcast({ type: "subagent.catalog", catalog: await this.subagentCatalog() });
|
|
167
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* Sets the app-wide default model, or clears it back to "Pi's own default".
|
|
170
|
+
*
|
|
171
|
+
* THE MODEL IS CHECKED AGAINST PI'S OWN CATALOG, for the same reason the workspace default is:
|
|
172
|
+
* a default Pi does not offer would be stamped onto every new conversation, and the first turn
|
|
173
|
+
* would fail on a model the user never chose. The check happens at SAVE time rather than at
|
|
174
|
+
* birth time so the user hears about it while they are still looking at the list.
|
|
175
|
+
*
|
|
176
|
+
* The label is taken from the catalog row rather than from the request, so the stored value
|
|
177
|
+
* always carries the display name Pi itself reported.
|
|
178
|
+
*/
|
|
179
|
+
async setDefaultModel(model) {
|
|
180
|
+
if (!this.store)
|
|
181
|
+
throw new Error("Store unavailable");
|
|
182
|
+
let selection;
|
|
183
|
+
if (model) {
|
|
184
|
+
const catalog = await this.runtime.catalog();
|
|
185
|
+
const option = catalog.source === "pi"
|
|
186
|
+
? catalog.models.find(item => item.provider === model.provider && item.modelId === model.modelId)
|
|
187
|
+
: undefined;
|
|
188
|
+
if (catalog.source === "pi" && !option)
|
|
189
|
+
throw this.notOffered(`模型 ${model.provider}/${model.modelId}`, catalog);
|
|
190
|
+
selection = option
|
|
191
|
+
? { provider: option.provider, modelId: option.modelId, label: option.label }
|
|
192
|
+
: { provider: model.provider, modelId: model.modelId, label: model.modelId };
|
|
193
|
+
}
|
|
194
|
+
await this.store.setDefaultModel(selection);
|
|
195
|
+
this.broadcaster.broadcastState();
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* The UNFILTERED catalog, for the settings window's model panel. Deliberately not the scoped one:
|
|
199
|
+
* the panel is where the app-wide default is chosen, and a workspace's model rules must not hide
|
|
200
|
+
* models from the place that decides what the app-wide default may be.
|
|
201
|
+
*/
|
|
202
|
+
async getAllCatalog() {
|
|
203
|
+
return this.runtime.catalog();
|
|
204
|
+
}
|
|
168
205
|
/**
|
|
169
206
|
* Helper formatting an error when a requested model/thinking level is not offered by Pi.
|
|
170
207
|
*/
|
|
@@ -42,7 +42,13 @@ export class ConversationService {
|
|
|
42
42
|
// stamp a dead selection onto every new conversation: the scoped catalog probe would
|
|
43
43
|
// fail and leave the model/thinking controls unavailable. A missing or invalid default
|
|
44
44
|
// means "follow Pi's current default" for this newly-created conversation.
|
|
45
|
-
|
|
45
|
+
// THE PRECEDENCE AT BIRTH IS WORKSPACE > APP > Pi. The app-wide default is a FALLBACK, not an
|
|
46
|
+
// override: a workspace that has a default of its own keeps it, and only a workspace with none
|
|
47
|
+
// inherits the app-wide one. Both are copied onto the conversation and never consulted again,
|
|
48
|
+
// so changing either default never rewrites an existing conversation.
|
|
49
|
+
const appDefault = this.store.snapshot().defaultModel;
|
|
50
|
+
const inheritsAppDefault = !workspace.defaultModel && Boolean(appDefault);
|
|
51
|
+
let model = workspace.defaultModel ?? appDefault;
|
|
46
52
|
let thinkingLevel = workspace.defaultThinkingLevel;
|
|
47
53
|
const catalog = model || thinkingLevel ? await this.runtime.catalog() : undefined;
|
|
48
54
|
if (catalog?.source === "pi" && model) {
|
|
@@ -60,6 +66,19 @@ export class ConversationService {
|
|
|
60
66
|
}
|
|
61
67
|
}
|
|
62
68
|
}
|
|
69
|
+
if (inheritsAppDefault && model) {
|
|
70
|
+
// An app-wide default belongs to no workspace, so the workspace's model rules are checked HERE,
|
|
71
|
+
// at birth, where the workspace is finally known. A default the rules forbid is not stamped
|
|
72
|
+
// on: the conversation starts on Pi's own default instead, which is the same outcome as a
|
|
73
|
+
// workspace default that pointed at a model Pi no longer offers.
|
|
74
|
+
try {
|
|
75
|
+
assertModelAllowed(this.store.snapshot().modelFilterRules, workspace, model);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
model = undefined;
|
|
79
|
+
thinkingLevel = undefined;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
63
82
|
const resolved = workspace.defaultAgentId
|
|
64
83
|
? await this.resolveAgentConfiguration({ id: workspaceId, model, thinkingLevel }, workspace.defaultAgentId, workspaceId)
|
|
65
84
|
: { model, thinkingLevel };
|
|
@@ -219,6 +219,57 @@ export declare class RunService {
|
|
|
219
219
|
* the moment Pi holds it.
|
|
220
220
|
*/
|
|
221
221
|
onPiQueue(conversationId: string, snapshot: PiQueueSnapshot): void;
|
|
222
|
+
/**
|
|
223
|
+
* The message a delivery is about, resolved by the text Pi announced.
|
|
224
|
+
*
|
|
225
|
+
* WHY THE TEXT AND NOT THE POSITION. A message that was STEERED is `sending` here - claimed, so the
|
|
226
|
+
* user cannot withdraw it - and the oldest `sending` message is the one Pi picked up, because Pi's
|
|
227
|
+
* steering queue is FIFO. That is the old rule and it still holds. A message that was handed to Pi's
|
|
228
|
+
* FOLLOW-UP queue is NOT `sending`: Super Pi keeps it in its own list so the user can still withdraw
|
|
229
|
+
* it, and a conversation can hold a `waiting` message and a `sending` one at the same time. For that
|
|
230
|
+
* one, position is the wrong question and the text is the right one.
|
|
231
|
+
*
|
|
232
|
+
* THE TEXT IS THE SAME TEXT ON BOTH SIDES. Pi matches its own queue by `indexOf` on the message text
|
|
233
|
+
* (`agent-session.ts:1123`), so two messages with identical words are ONE entry to Pi and the first of
|
|
234
|
+
* them to Super Pi as well. Resolving by text is therefore not a guess that could pick a different
|
|
235
|
+
* message than Pi did - it is the same comparison Pi itself made.
|
|
236
|
+
*
|
|
237
|
+
* A `sending` message wins over a `waiting` one with the same text, because Pi drains its steering
|
|
238
|
+
* queue before its follow-up queue, so the steering message is the one that was delivered first.
|
|
239
|
+
*/
|
|
240
|
+
private matchDeliveredMessage;
|
|
241
|
+
/**
|
|
242
|
+
* Hands a waiting message to Pi's follow-up queue, so the run that is already going answers it.
|
|
243
|
+
*
|
|
244
|
+
* WHY THIS EXISTS. A message typed while the agent was busy used to wait for the current turn to END
|
|
245
|
+
* and then be answered by a turn of its own - a fresh `prompt()`, which runs Pi's `_prepareRun`, which
|
|
246
|
+
* emits `before_agent_start`. Super Pi's memory extension answers that hook with an HTTP search and
|
|
247
|
+
* patches the system prompt with what came back, so every queued message paid for a round trip to the
|
|
248
|
+
* memory endpoint and for a prompt larger than the one before it. A follow-up is delivered by Pi's
|
|
249
|
+
* agent loop inside the running turn, so it pays for neither.
|
|
250
|
+
*
|
|
251
|
+
* IT IS AN OPTIMISATION, NOT A COMMITMENT. The message stays in Super Pi's own waiting list, so it is
|
|
252
|
+
* still the user's to withdraw and still the user's to interrupt, and if Pi does not deliver it - the
|
|
253
|
+
* run was aborted, the queues were cleared - the message falls back to being answered by a turn of its
|
|
254
|
+
* own. Nothing is lost either way, which is what makes it safe to try.
|
|
255
|
+
*/
|
|
256
|
+
private submitToPiQueue;
|
|
257
|
+
/**
|
|
258
|
+
* Empties Pi's steering and follow-up queues for a conversation.
|
|
259
|
+
*
|
|
260
|
+
* WHY A HOST HAS TO DO THIS. Pi delivers whatever is left in those queues when the run ends, and it
|
|
261
|
+
* cannot take back a single message - `clear_queue` is the only queue command it has. So whenever
|
|
262
|
+
* Super Pi takes a message back, the queues have to be emptied, or Pi answers words the user believes
|
|
263
|
+
* they took back.
|
|
264
|
+
*
|
|
265
|
+
* NOTHING IS LOST. Every message Pi was holding is still in Super Pi's own waiting list, and the run's
|
|
266
|
+
* loop answers those as turns of their own.
|
|
267
|
+
*
|
|
268
|
+
* THE STEERS COME BACK. Emptying the queues also empties the steering queue, and a steer is a message
|
|
269
|
+
* the user asked to be answered NOW - so the ones Pi had not delivered go back in, and clearing the
|
|
270
|
+
* queues for one message does not silently un-steer another.
|
|
271
|
+
*/
|
|
272
|
+
private clearPiQueues;
|
|
222
273
|
/**
|
|
223
274
|
* Opens a turn on a message that is ALREADY IN THE TRANSCRIPT.
|
|
224
275
|
*
|
|
@@ -285,6 +285,11 @@ export class RunService {
|
|
|
285
285
|
console.warn(`Super Pi: auto-trigger for background exec result failed: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
286
286
|
});
|
|
287
287
|
}
|
|
288
|
+
else {
|
|
289
|
+
// THE CONVERSATION IS BUSY, so the run that is going answers this result itself - through
|
|
290
|
+
// Pi's own follow-up queue, so the result does not have to wait for a turn of its own.
|
|
291
|
+
await this.submitToPiQueue(task.conversationId, queued);
|
|
292
|
+
}
|
|
288
293
|
}
|
|
289
294
|
catch (error) {
|
|
290
295
|
console.warn(`Super Pi: exec result could not be appended as a tool message: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
@@ -541,6 +546,118 @@ export class RunService {
|
|
|
541
546
|
onPiQueue(conversationId, snapshot) {
|
|
542
547
|
this.broadcaster.broadcast({ type: "pi.queue", conversationId, ...snapshot });
|
|
543
548
|
}
|
|
549
|
+
/**
|
|
550
|
+
* The message a delivery is about, resolved by the text Pi announced.
|
|
551
|
+
*
|
|
552
|
+
* WHY THE TEXT AND NOT THE POSITION. A message that was STEERED is `sending` here - claimed, so the
|
|
553
|
+
* user cannot withdraw it - and the oldest `sending` message is the one Pi picked up, because Pi's
|
|
554
|
+
* steering queue is FIFO. That is the old rule and it still holds. A message that was handed to Pi's
|
|
555
|
+
* FOLLOW-UP queue is NOT `sending`: Super Pi keeps it in its own list so the user can still withdraw
|
|
556
|
+
* it, and a conversation can hold a `waiting` message and a `sending` one at the same time. For that
|
|
557
|
+
* one, position is the wrong question and the text is the right one.
|
|
558
|
+
*
|
|
559
|
+
* THE TEXT IS THE SAME TEXT ON BOTH SIDES. Pi matches its own queue by `indexOf` on the message text
|
|
560
|
+
* (`agent-session.ts:1123`), so two messages with identical words are ONE entry to Pi and the first of
|
|
561
|
+
* them to Super Pi as well. Resolving by text is therefore not a guess that could pick a different
|
|
562
|
+
* message than Pi did - it is the same comparison Pi itself made.
|
|
563
|
+
*
|
|
564
|
+
* A `sending` message wins over a `waiting` one with the same text, because Pi drains its steering
|
|
565
|
+
* queue before its follow-up queue, so the steering message is the one that was delivered first.
|
|
566
|
+
*/
|
|
567
|
+
matchDeliveredMessage(conversationId, text) {
|
|
568
|
+
if (!text)
|
|
569
|
+
return undefined;
|
|
570
|
+
const waiting = this.store.listWaitingMessages(conversationId);
|
|
571
|
+
const match = waiting.find((item) => item.delivery === "sending" && composePrompt(item) === text)
|
|
572
|
+
?? waiting.find((item) => item.delivery === "waiting" && composePrompt(item) === text);
|
|
573
|
+
return match?.id;
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Hands a waiting message to Pi's follow-up queue, so the run that is already going answers it.
|
|
577
|
+
*
|
|
578
|
+
* WHY THIS EXISTS. A message typed while the agent was busy used to wait for the current turn to END
|
|
579
|
+
* and then be answered by a turn of its own - a fresh `prompt()`, which runs Pi's `_prepareRun`, which
|
|
580
|
+
* emits `before_agent_start`. Super Pi's memory extension answers that hook with an HTTP search and
|
|
581
|
+
* patches the system prompt with what came back, so every queued message paid for a round trip to the
|
|
582
|
+
* memory endpoint and for a prompt larger than the one before it. A follow-up is delivered by Pi's
|
|
583
|
+
* agent loop inside the running turn, so it pays for neither.
|
|
584
|
+
*
|
|
585
|
+
* IT IS AN OPTIMISATION, NOT A COMMITMENT. The message stays in Super Pi's own waiting list, so it is
|
|
586
|
+
* still the user's to withdraw and still the user's to interrupt, and if Pi does not deliver it - the
|
|
587
|
+
* run was aborted, the queues were cleared - the message falls back to being answered by a turn of its
|
|
588
|
+
* own. Nothing is lost either way, which is what makes it safe to try.
|
|
589
|
+
*/
|
|
590
|
+
async submitToPiQueue(conversationId, message) {
|
|
591
|
+
if (!this.runtime.followUp)
|
|
592
|
+
return;
|
|
593
|
+
// NOTHING TO FOLLOW UP ON. With no turn running, Pi would hold the message until the NEXT one,
|
|
594
|
+
// which is a later answer than the one the message is waiting for.
|
|
595
|
+
if (!this.activeRuns.has(conversationId))
|
|
596
|
+
return;
|
|
597
|
+
// AN INTERRUPT IS ALREADY STARTING. It aborts the run and empties Pi's queues, so a message handed
|
|
598
|
+
// to Pi now would be taken back a moment later. It waits in the list for the next run, exactly as it
|
|
599
|
+
// did before.
|
|
600
|
+
if (this.interruptTargets.has(conversationId))
|
|
601
|
+
return;
|
|
602
|
+
// A MESSAGE THAT IS A COMMAND IS NOT QUEUED. Pi expands a skill command or a prompt template before
|
|
603
|
+
// it queues the text (`agent-session.ts:2221-2222`), and it REFUSES an extension command outright.
|
|
604
|
+
// An expanded text is not the text Super Pi has, so the delivery could not be resolved back to its
|
|
605
|
+
// message - the answer would land under the wrong question. Such a message waits for a turn of its
|
|
606
|
+
// own, where it is the prompt and the question and the answer are the same thing.
|
|
607
|
+
if (composePrompt(message).startsWith("/"))
|
|
608
|
+
return;
|
|
609
|
+
try {
|
|
610
|
+
await this.runtime.followUp(conversationId, composePrompt(message), message.images);
|
|
611
|
+
}
|
|
612
|
+
catch (error) {
|
|
613
|
+
// THE FALLBACK IS THE OLD BEHAVIOUR, so a refusal here is not a failure: the message stays in the
|
|
614
|
+
// waiting list and the run's loop answers it with a turn of its own.
|
|
615
|
+
console.warn(`Super Pi: handing a queued message to Pi's follow-up queue failed: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Empties Pi's steering and follow-up queues for a conversation.
|
|
620
|
+
*
|
|
621
|
+
* WHY A HOST HAS TO DO THIS. Pi delivers whatever is left in those queues when the run ends, and it
|
|
622
|
+
* cannot take back a single message - `clear_queue` is the only queue command it has. So whenever
|
|
623
|
+
* Super Pi takes a message back, the queues have to be emptied, or Pi answers words the user believes
|
|
624
|
+
* they took back.
|
|
625
|
+
*
|
|
626
|
+
* NOTHING IS LOST. Every message Pi was holding is still in Super Pi's own waiting list, and the run's
|
|
627
|
+
* loop answers those as turns of their own.
|
|
628
|
+
*
|
|
629
|
+
* THE STEERS COME BACK. Emptying the queues also empties the steering queue, and a steer is a message
|
|
630
|
+
* the user asked to be answered NOW - so the ones Pi had not delivered go back in, and clearing the
|
|
631
|
+
* queues for one message does not silently un-steer another.
|
|
632
|
+
*/
|
|
633
|
+
async clearPiQueues(conversationId) {
|
|
634
|
+
if (!this.runtime.clearQueues)
|
|
635
|
+
return;
|
|
636
|
+
let cleared;
|
|
637
|
+
try {
|
|
638
|
+
cleared = await this.runtime.clearQueues(conversationId);
|
|
639
|
+
}
|
|
640
|
+
catch (error) {
|
|
641
|
+
console.warn(`Super Pi: clearing Pi's queues for ${conversationId} failed: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
for (const text of cleared.steering ?? []) {
|
|
645
|
+
// A STEER NEEDS A TURN TO GO INTO. With no turn running there is nothing to steer, and the message
|
|
646
|
+
// is released back to `waiting` when the run ends anyway - so it is answered as a turn of its own.
|
|
647
|
+
if (this.runtime.canSteer && !this.runtime.canSteer(conversationId))
|
|
648
|
+
continue;
|
|
649
|
+
const message = this.store.listWaitingMessages(conversationId)
|
|
650
|
+
.find((item) => item.delivery === "sending" && composePrompt(item) === text);
|
|
651
|
+
if (!message)
|
|
652
|
+
continue;
|
|
653
|
+
try {
|
|
654
|
+
await this.runtime.steer?.(conversationId, text, message.images);
|
|
655
|
+
}
|
|
656
|
+
catch (error) {
|
|
657
|
+
console.warn(`Super Pi: putting a steered message back failed: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
544
661
|
/**
|
|
545
662
|
* Opens a turn on a message that is ALREADY IN THE TRANSCRIPT.
|
|
546
663
|
*
|
|
@@ -825,18 +942,28 @@ export class RunService {
|
|
|
825
942
|
* cleared rather than reassigned for the same reason. `thinkingBlock` continues numbering on purpose:
|
|
826
943
|
* it is a global fold sequence, and restarting it would make the browser render two folds numbered 0.
|
|
827
944
|
*/
|
|
828
|
-
const onSteerDelivered = () => {
|
|
829
|
-
enqueueTools("a
|
|
945
|
+
const onSteerDelivered = (text) => {
|
|
946
|
+
enqueueTools("a queued message being delivered", async () => {
|
|
830
947
|
const previous = assistant;
|
|
831
|
-
// Checkpoint first: the message being left behind is complete - Pi drains
|
|
948
|
+
// Checkpoint first: the message being left behind is complete - Pi drains its queues at a turn
|
|
832
949
|
// boundary - and this is the last chance to write it durably with the lane it belongs to.
|
|
833
950
|
await this.store.checkpointMessage(conversation.id, previous.id);
|
|
834
|
-
|
|
951
|
+
// WHICH MESSAGE THIS DELIVERY IS ABOUT. Pi announces the text it picked up, and Pi itself matches
|
|
952
|
+
// its own queue by that text (`agent-session.ts:1123`), so the same text resolves to the same
|
|
953
|
+
// message on both sides - including when two messages carry identical words, where both sides
|
|
954
|
+
// pick the first. A message handed to Pi's FOLLOW-UP queue was never claimed here: it is still
|
|
955
|
+
// `waiting`, so the user can still withdraw it, and a conversation can hold a `waiting` and a
|
|
956
|
+
// `sending` message at the same time. Position cannot answer that one; the text can.
|
|
957
|
+
//
|
|
958
|
+
// WHEN THE TEXT MATCHES NOTHING the old position-based answer is used, which is the steer path's
|
|
959
|
+
// proven behaviour: the message Pi is holding is the one Pi picked up. A text that matches
|
|
960
|
+
// nothing AND has no `sending` message is a delivery of a message Super Pi no longer has - one
|
|
961
|
+
// the user withdrew - and there is then nothing to open an answer to.
|
|
962
|
+
const target = this.matchDeliveredMessage(conversation.id, text);
|
|
963
|
+
const delivered = await this.store.deliverWaitingMessage(conversation.id, agentIdentity, target);
|
|
835
964
|
// NOTHING TO DELIVER MEANS NOTHING TO SPLIT. Pi may announce a user `message_start` that this
|
|
836
965
|
// server never handed it - the prompt that opened the turn is the obvious case - and inventing an
|
|
837
|
-
// assistant message for one of those would open an empty bubble before every single turn.
|
|
838
|
-
// question "was anything `sending`?" is the server's own state, not something the event decides,
|
|
839
|
-
// which is the same reason the delivery is matched by position rather than by text.
|
|
966
|
+
// assistant message for one of those would open an empty bubble before every single turn.
|
|
840
967
|
if (!delivered)
|
|
841
968
|
return [];
|
|
842
969
|
const next = delivered.assistant;
|
|
@@ -978,6 +1105,13 @@ export class RunService {
|
|
|
978
1105
|
for (;;) {
|
|
979
1106
|
if (controller.signal.aborted)
|
|
980
1107
|
break;
|
|
1108
|
+
// PI'S QUEUES ARE EMPTIED BEFORE THE NEXT MESSAGE IS TAKEN. A message handed to Pi's follow-up
|
|
1109
|
+
// queue is delivered by the agent loop inside the run that has just ended - Pi drains that queue
|
|
1110
|
+
// before it settles - so whatever is STILL in the queue when the turn ended was not delivered.
|
|
1111
|
+
// Emptying it gives those messages back: they are still in the waiting list, and the loop below
|
|
1112
|
+
// answers them with a turn of their own, which is the behaviour there was before Pi's queue was
|
|
1113
|
+
// used at all. Nothing is lost, and nothing is delivered twice.
|
|
1114
|
+
await this.clearPiQueues(conversation.id);
|
|
981
1115
|
// Taken AND opened, in that order, by two separate writes. The take is what bounds this loop -
|
|
982
1116
|
// every pass strictly shrinks the waiting set - and it is what makes a crash honest, because a
|
|
983
1117
|
// server that dies between the two has a message that is neither waiting nor answered, and the
|
|
@@ -1045,6 +1179,10 @@ export class RunService {
|
|
|
1045
1179
|
await this.store.markMessageWaiting(conversationId, message.id);
|
|
1046
1180
|
this.broadcaster.broadcastState();
|
|
1047
1181
|
}
|
|
1182
|
+
// The turn that is running answers this message itself, through Pi's own follow-up queue, rather
|
|
1183
|
+
// than by a turn of its own once the running one is over. The message stays in the waiting list
|
|
1184
|
+
// either way, so it is still the user's to withdraw - Pi is holding a copy, not the only copy.
|
|
1185
|
+
await this.submitToPiQueue(conversationId, message);
|
|
1048
1186
|
return;
|
|
1049
1187
|
}
|
|
1050
1188
|
const controller = new AbortController();
|
|
@@ -1199,6 +1337,12 @@ export class RunService {
|
|
|
1199
1337
|
try {
|
|
1200
1338
|
if (this.activeRuns.has(conversationId))
|
|
1201
1339
|
await this.stopRun(conversationId);
|
|
1340
|
+
// PI'S QUEUES ARE EMPTIED BEFORE THE SELECTED MESSAGE IS ANSWERED ON ITS OWN. The message may be
|
|
1341
|
+
// in Pi's follow-up queue - it was handed over when it was written - and Pi would deliver it when
|
|
1342
|
+
// the aborted run ended. The message is about to be answered as a turn of its own, so the queue
|
|
1343
|
+
// copy has to go: a message answered twice is worse than one answered once, and the transcript
|
|
1344
|
+
// would show the same words under two answers.
|
|
1345
|
+
await this.clearPiQueues(conversationId);
|
|
1202
1346
|
await this.awaitRunSlotReleased(conversationId);
|
|
1203
1347
|
await this.processWaitingMessage(conversationId, messageId);
|
|
1204
1348
|
}
|
|
@@ -1300,6 +1444,12 @@ export class RunService {
|
|
|
1300
1444
|
async cancelQueuedUnsafe(conversationId, messageId) {
|
|
1301
1445
|
if (this.interruptTargets.get(conversationId) === messageId)
|
|
1302
1446
|
throw new Error("这条消息正在打断并回答,无法撤回");
|
|
1447
|
+
// PI'S QUEUES ARE EMPTIED BEFORE THE MESSAGE IS TAKEN BACK. A message that was handed to Pi's
|
|
1448
|
+
// follow-up queue is in Pi's hands as well as in Super Pi's, and Pi would deliver it when the run
|
|
1449
|
+
// ends - the model would answer words the user believes they withdrew. Pi cannot take back one
|
|
1450
|
+
// message, so its queues are emptied; the other messages Pi was holding are still in Super Pi's
|
|
1451
|
+
// waiting list and are answered as turns of their own.
|
|
1452
|
+
await this.clearPiQueues(conversationId);
|
|
1303
1453
|
await this.store.cancelWaitingMessage(conversationId, messageId);
|
|
1304
1454
|
this.broadcaster.broadcastState();
|
|
1305
1455
|
}
|
|
@@ -65,6 +65,10 @@ export class CommandHandler {
|
|
|
65
65
|
await this.catalogService.setModelRules(command.rules);
|
|
66
66
|
return;
|
|
67
67
|
}
|
|
68
|
+
if (command.type === "settings.defaultModel") {
|
|
69
|
+
await this.catalogService.setDefaultModel(command.model);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
68
72
|
if (command.type === "workspace.create") {
|
|
69
73
|
return await this.workspaceService.createWorkspace(command.name, command.path);
|
|
70
74
|
}
|
|
@@ -239,6 +243,14 @@ export class CommandHandler {
|
|
|
239
243
|
await this.catalogService.broadcastCatalog(undefined, command.conversationId);
|
|
240
244
|
return;
|
|
241
245
|
}
|
|
246
|
+
// The unfiltered catalog, for the settings window's model panel. Sent to the ASKING socket only:
|
|
247
|
+
// it is a panel's private list, and broadcasting it would overwrite every open conversation's
|
|
248
|
+
// filtered catalog with one that no workspace's rules apply to.
|
|
249
|
+
if (command.type === "catalog.fetchAll") {
|
|
250
|
+
if (sendToSocket)
|
|
251
|
+
sendToSocket({ type: "model.catalog.all", catalog: await this.catalogService.getAllCatalog() });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
242
254
|
if (command.type === "agent.catalog.fetch") {
|
|
243
255
|
await this.catalogService.broadcastAgents();
|
|
244
256
|
return;
|
|
@@ -174,6 +174,31 @@ export interface PiSessionLike {
|
|
|
174
174
|
images?: unknown[];
|
|
175
175
|
streamingBehavior?: "steer" | "followUp";
|
|
176
176
|
}): Promise<void>;
|
|
177
|
+
/**
|
|
178
|
+
* Queues a message to be answered AFTER the current run finishes its pending work.
|
|
179
|
+
*
|
|
180
|
+
* PI'S OWN FOLLOW-UP QUEUE, and the reason a queued message does not need a turn of its own. A
|
|
181
|
+
* `prompt()` runs `_prepareRun`, which emits `before_agent_start` - and Super Pi's memory extension
|
|
182
|
+
* answers that hook with an HTTP search whose result is patched into the system prompt. So a fresh
|
|
183
|
+
* prompt pays a round trip to the memory endpoint and a bigger prompt; a follow-up is delivered by
|
|
184
|
+
* the agent loop inside the run and pays neither.
|
|
185
|
+
*
|
|
186
|
+
* Optional because a test double has no queues; its absence means "this session cannot hold a
|
|
187
|
+
* message for later", and the caller falls back to answering it as a turn of its own.
|
|
188
|
+
*/
|
|
189
|
+
followUp?(message: string, images?: unknown[]): Promise<"queued" | "handled">;
|
|
190
|
+
/**
|
|
191
|
+
* Removes every message Pi is holding in its steering and follow-up queues, and returns what was
|
|
192
|
+
* removed.
|
|
193
|
+
*
|
|
194
|
+
* PI CANNOT TAKE BACK ONE MESSAGE - `clear_queue` is the only queue command it has - so this is
|
|
195
|
+
* how a host says "none of these are wanted any more". The returned texts are the queue as it
|
|
196
|
+
* was, which is what lets a host put back the ones that should still be answered.
|
|
197
|
+
*/
|
|
198
|
+
clearQueue?(): {
|
|
199
|
+
steering: string[];
|
|
200
|
+
followUp: string[];
|
|
201
|
+
};
|
|
177
202
|
/** Aborts the current operation and waits for the session to become idle. */
|
|
178
203
|
abort(): Promise<void>;
|
|
179
204
|
/** Subscribes to session events. Returns the unsubscribe function. */
|
|
@@ -184,6 +209,15 @@ export interface PiSessionLike {
|
|
|
184
209
|
readonly sessionFile?: string | undefined;
|
|
185
210
|
/** The id of the session. */
|
|
186
211
|
readonly sessionId?: string | undefined;
|
|
212
|
+
/**
|
|
213
|
+
* The system prompt Pi will send with every turn.
|
|
214
|
+
*
|
|
215
|
+
* READ FOR ITS SIZE, not for its content. The time to first token is a function of how many bytes
|
|
216
|
+
* the provider has to receive before it can answer, so this number is what turns "the model was
|
|
217
|
+
* slow" into a fact. Pi builds the prompt from the agent directory, the skills, the extensions and
|
|
218
|
+
* the context files - none of which Super Pi controls.
|
|
219
|
+
*/
|
|
220
|
+
readonly systemPrompt?: string | undefined;
|
|
187
221
|
/**
|
|
188
222
|
* The model the session is running on.
|
|
189
223
|
*
|
package/dist/server/runtime.d.ts
CHANGED
|
@@ -236,6 +236,32 @@ export interface AgentRuntime {
|
|
|
236
236
|
*/
|
|
237
237
|
steer?(conversationId: string, message: string, images?: MessageImage[]): Promise<SteerDisposition>;
|
|
238
238
|
canSteer?(conversationId: string): boolean;
|
|
239
|
+
/**
|
|
240
|
+
* Hands one message to the Pi that is running RIGHT NOW, to be answered after the current run
|
|
241
|
+
* finishes its pending work.
|
|
242
|
+
*
|
|
243
|
+
* PI'S OWN FOLLOW-UP QUEUE, and the difference from a steer is the difference in WHEN. A steer is
|
|
244
|
+
* injected at the next turn boundary of the running turn; a follow-up waits until the agent would
|
|
245
|
+
* otherwise stop. Both are delivered by the agent loop inside the same run, which is what makes
|
|
246
|
+
* them cheap: neither goes through `_prepareRun`, so neither re-emits `before_agent_start` and
|
|
247
|
+
* neither pays for the memory search and the system-prompt patch that hook does.
|
|
248
|
+
*
|
|
249
|
+
* A message handed over this way is still Super Pi's to cancel, because Super Pi keeps it in its
|
|
250
|
+
* own waiting list - Pi is holding a copy, not the only copy.
|
|
251
|
+
*/
|
|
252
|
+
followUp?(conversationId: string, message: string, images?: MessageImage[]): Promise<SteerDisposition>;
|
|
253
|
+
/**
|
|
254
|
+
* Empties Pi's steering and follow-up queues, and returns what was in them.
|
|
255
|
+
*
|
|
256
|
+
* WHY A HOST NEEDS THIS. Pi cannot take back a single queued message, and it will deliver whatever
|
|
257
|
+
* is left in those queues when the run ends. So whenever Super Pi takes a message back - the user
|
|
258
|
+
* withdrew it, or the user asked for it to be answered on its own - the queues have to be emptied,
|
|
259
|
+
* or Pi answers words the user believes they took back.
|
|
260
|
+
*/
|
|
261
|
+
clearQueues?(conversationId: string): Promise<{
|
|
262
|
+
steering: string[];
|
|
263
|
+
followUp: string[];
|
|
264
|
+
}>;
|
|
239
265
|
/**
|
|
240
266
|
* ONE-SHOT TITLE GENERATION. Spawns its own short-lived Pi child rather than borrowing a running one.
|
|
241
267
|
*
|
|
@@ -966,6 +992,31 @@ export declare class PiRpcRuntime implements AgentRuntime {
|
|
|
966
992
|
* silent success.
|
|
967
993
|
*/
|
|
968
994
|
private sessionFor;
|
|
995
|
+
/**
|
|
996
|
+
* Hands a message to Pi's FOLLOW-UP queue, to be answered when the current run finishes its work.
|
|
997
|
+
*
|
|
998
|
+
* WHY THIS EXISTS ALONGSIDE A STEER. Both put a message into a queue Pi drains inside the running
|
|
999
|
+
* turn, and neither of them re-runs `_prepareRun` - so neither re-emits `before_agent_start`, and
|
|
1000
|
+
* neither pays for the memory search and the system-prompt patch that hook does. A queued message
|
|
1001
|
+
* that answered itself with a fresh `prompt()` paid for both, once per message, and the prompt it
|
|
1002
|
+
* sent was larger than the one before it.
|
|
1003
|
+
*
|
|
1004
|
+
* The difference between the two is WHEN Pi delivers the message: a steer lands at the next turn
|
|
1005
|
+
* boundary (in the middle of the answer, which is what 「插队」 means), and a follow-up lands when
|
|
1006
|
+
* the agent would otherwise stop (after the answer, which is what a message typed while the agent
|
|
1007
|
+
* was busy means).
|
|
1008
|
+
*/
|
|
1009
|
+
followUp(conversationId: string, message: string, images?: MessageImage[]): Promise<SteerDisposition>;
|
|
1010
|
+
/**
|
|
1011
|
+
* Empties Pi's steering and follow-up queues and returns what was in them.
|
|
1012
|
+
*
|
|
1013
|
+
* The session path asks Pi directly (`session.clearQueue`). The one-shot path writes Pi's
|
|
1014
|
+
* `clear_queue` command, which answers with the text it removed.
|
|
1015
|
+
*/
|
|
1016
|
+
clearQueues(conversationId: string): Promise<{
|
|
1017
|
+
steering: string[];
|
|
1018
|
+
followUp: string[];
|
|
1019
|
+
}>;
|
|
969
1020
|
steer(conversationId: string, message: string, images?: MessageImage[]): Promise<SteerDisposition>;
|
|
970
1021
|
/**
|
|
971
1022
|
* The model/thinking catalog, from Pi, cached.
|
package/dist/server/runtime.js
CHANGED
|
@@ -755,12 +755,29 @@ export class PiRpcRuntime {
|
|
|
755
755
|
abortTurn();
|
|
756
756
|
else
|
|
757
757
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
758
|
+
// HOW BIG IS THE PROMPT WE ARE ABOUT TO SEND. The time to first token is a function of
|
|
759
|
+
// this, not of anything Super Pi does, and it is the number that explains a slow first turn: the
|
|
760
|
+
// model's provider has to RECEIVE the whole thing before it can answer, so a big prompt on a slow
|
|
761
|
+
// link is seconds before the first word.
|
|
762
|
+
//
|
|
763
|
+
// It is traced rather than computed, because the system prompt is what Pi builds from the agent
|
|
764
|
+
// directory, the skills, the extensions and the context files - none of which Super Pi controls,
|
|
765
|
+
// and all of which the person who installed them controls.
|
|
766
|
+
const promptSize = () => {
|
|
767
|
+
try {
|
|
768
|
+
const system = typeof session.systemPrompt === "string" ? session.systemPrompt : "";
|
|
769
|
+
return `systemPrompt=${Buffer.byteLength(system, "utf8")}B`;
|
|
770
|
+
}
|
|
771
|
+
catch {
|
|
772
|
+
return "systemPrompt=?";
|
|
773
|
+
}
|
|
774
|
+
};
|
|
758
775
|
let firstTextSeen = false;
|
|
759
776
|
const finished = (async () => {
|
|
760
777
|
try {
|
|
761
778
|
const promptOptions = images?.length
|
|
762
|
-
? { images: images, preflightResult: () => traceLatency(conversation.id, "session.prompt.accepted", latencyStartedAt) }
|
|
763
|
-
: { preflightResult: () => traceLatency(conversation.id, "session.prompt.accepted", latencyStartedAt) };
|
|
779
|
+
? { images: images, preflightResult: () => traceLatency(conversation.id, "session.prompt.accepted", latencyStartedAt, promptSize()) }
|
|
780
|
+
: { preflightResult: () => traceLatency(conversation.id, "session.prompt.accepted", latencyStartedAt, promptSize()) };
|
|
764
781
|
await session.prompt(prompt, promptOptions);
|
|
765
782
|
// `prompt()` RETURNING IS NOT THE TURN ENDING. Pi's `prompt` resolves when the message has
|
|
766
783
|
// been ACCEPTED and the run has been started; the agent is still streaming, still executing
|
|
@@ -2080,6 +2097,76 @@ export class PiRpcRuntime {
|
|
|
2080
2097
|
throw new SteerUnavailableError(`会话 ${conversationId} 还没有可用的 Pi 会话`);
|
|
2081
2098
|
return session;
|
|
2082
2099
|
}
|
|
2100
|
+
/**
|
|
2101
|
+
* Hands a message to Pi's FOLLOW-UP queue, to be answered when the current run finishes its work.
|
|
2102
|
+
*
|
|
2103
|
+
* WHY THIS EXISTS ALONGSIDE A STEER. Both put a message into a queue Pi drains inside the running
|
|
2104
|
+
* turn, and neither of them re-runs `_prepareRun` - so neither re-emits `before_agent_start`, and
|
|
2105
|
+
* neither pays for the memory search and the system-prompt patch that hook does. A queued message
|
|
2106
|
+
* that answered itself with a fresh `prompt()` paid for both, once per message, and the prompt it
|
|
2107
|
+
* sent was larger than the one before it.
|
|
2108
|
+
*
|
|
2109
|
+
* The difference between the two is WHEN Pi delivers the message: a steer lands at the next turn
|
|
2110
|
+
* boundary (in the middle of the answer, which is what 「插队」 means), and a follow-up lands when
|
|
2111
|
+
* the agent would otherwise stop (after the answer, which is what a message typed while the agent
|
|
2112
|
+
* was busy means).
|
|
2113
|
+
*/
|
|
2114
|
+
async followUp(conversationId, message, images) {
|
|
2115
|
+
// THE SESSION PATH. `session.followUp` is Pi's own follow-up queue, so the message joins the run
|
|
2116
|
+
// that is already going and is delivered by the agent loop when that run has nothing left to do.
|
|
2117
|
+
if (this.activeTurns.has(conversationId)) {
|
|
2118
|
+
const session = await this.sessionFor(conversationId);
|
|
2119
|
+
const imagePayloads = this.options.uploadsDir && images?.length
|
|
2120
|
+
? await loadConversationImages(this.options.uploadsDir, conversationId, images)
|
|
2121
|
+
: undefined;
|
|
2122
|
+
if (!session.followUp)
|
|
2123
|
+
throw new SteerUnavailableError(`会话 ${conversationId} 的 Pi 不支持排队消息`);
|
|
2124
|
+
const disposition = await session.followUp(message, imagePayloads);
|
|
2125
|
+
return disposition === "handled" ? "handled" : "queued";
|
|
2126
|
+
}
|
|
2127
|
+
// NO TURN TO FOLLOW. A follow-up is only meaningful while something is running - with nothing
|
|
2128
|
+
// running, Pi would hold the message until the NEXT run, which is the same answer arriving much
|
|
2129
|
+
// later than a prompt would have given it.
|
|
2130
|
+
//
|
|
2131
|
+
// THE ONE-SHOT PATH IS THIS CASE TOO, and it is not an accident: that path runs each turn in a
|
|
2132
|
+
// short-lived child that is killed when the turn ends, so a follow-up queue would die with it. The
|
|
2133
|
+
// message falls back to being answered by a turn of its own, which is the only thing that path can
|
|
2134
|
+
// do anyway.
|
|
2135
|
+
throw new SteerUnavailableError(`会话 ${conversationId} 当前没有可以接手的 Pi 会话,无法排队`);
|
|
2136
|
+
}
|
|
2137
|
+
/**
|
|
2138
|
+
* Empties Pi's steering and follow-up queues and returns what was in them.
|
|
2139
|
+
*
|
|
2140
|
+
* The session path asks Pi directly (`session.clearQueue`). The one-shot path writes Pi's
|
|
2141
|
+
* `clear_queue` command, which answers with the text it removed.
|
|
2142
|
+
*/
|
|
2143
|
+
async clearQueues(conversationId) {
|
|
2144
|
+
const empty = { steering: [], followUp: [] };
|
|
2145
|
+
if (this.activeTurns.has(conversationId)) {
|
|
2146
|
+
const session = this.sessionPool.get(conversationId);
|
|
2147
|
+
if (!session?.clearQueue)
|
|
2148
|
+
return empty;
|
|
2149
|
+
try {
|
|
2150
|
+
const cleared = session.clearQueue();
|
|
2151
|
+
return { steering: [...(cleared?.steering ?? [])], followUp: [...(cleared?.followUp ?? [])] };
|
|
2152
|
+
}
|
|
2153
|
+
catch (error) {
|
|
2154
|
+
console.warn(`PiRpcRuntime: clearing the queues of ${conversationId} threw: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
2155
|
+
return empty;
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
const run = this.runs.get(conversationId);
|
|
2159
|
+
if (!run || !this.canSteer(conversationId))
|
|
2160
|
+
return empty;
|
|
2161
|
+
// THE ONE-SHOT PATH NEEDS NO CLEAR, and the reason is structural rather than convenient: that path
|
|
2162
|
+
// runs each turn in a short-lived child that is KILLED when the turn ends (`answerOneShot`'s
|
|
2163
|
+
// finally), and the child's queues die with it. A message Pi was holding in that child is gone the
|
|
2164
|
+
// moment the turn is over, so there is nothing to clear and nothing to wait for. Clearing it
|
|
2165
|
+
// anyway would cost a round trip to a child that is about to be killed, on the path between a turn
|
|
2166
|
+
// ending and the next one starting.
|
|
2167
|
+
void run;
|
|
2168
|
+
return empty;
|
|
2169
|
+
}
|
|
2083
2170
|
async steer(conversationId, message, images) {
|
|
2084
2171
|
// THE SESSION PATH. A steer on a persistent session is a `prompt` with `streamingBehavior:
|
|
2085
2172
|
// "steer"` - the same queueing semantics Pi's RPC `steer` command has, expressed the way the SDK
|