@evandu/super-pi 0.1.138 → 0.1.139
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-knowledge/index.ts +11 -13
- package/agents/super-pi-memory/index.ts +11 -5
- package/bin/super-pi.js +7 -11
- package/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +6 -6
- package/dist/client/assets/{CodeMirrorEditor-CVImD0nh.js → CodeMirrorEditor-H7SHGhdS.js} +2 -2
- package/dist/client/assets/{dist-At94VK1P.js → dist-2XXLO-IZ.js} +1 -1
- package/dist/client/assets/{dist-DhEa5hHR.js → dist-BEM6tttY.js} +1 -1
- package/dist/client/assets/{dist-CKI1xVeE.js → dist-BfUeRJKr.js} +1 -1
- package/dist/client/assets/{dist-1H_gIENV.js → dist-BokzcFLE.js} +1 -1
- package/dist/client/assets/{dist-DSCjkO-y.js → dist-BoucVzKA.js} +1 -1
- package/dist/client/assets/{dist-CUACXe1Y.js → dist-BxTGjU84.js} +1 -1
- package/dist/client/assets/{dist-yONALz0V.js → dist-C-MUXK_t.js} +1 -1
- package/dist/client/assets/{dist-YB4GJD3u.js → dist-C8NllalC.js} +1 -1
- package/dist/client/assets/{dist-quQ6u6Rw.js → dist-CSdzvQnf.js} +1 -1
- package/dist/client/assets/{dist-Cocw5XJ_.js → dist-CUNctZ5v.js} +1 -1
- package/dist/client/assets/{dist-4uPxkRdw.js → dist-CZ9Krsno.js} +1 -1
- package/dist/client/assets/{dist-B75OT6A9.js → dist-C_ftUQZN.js} +1 -1
- package/dist/client/assets/{dist-pnGHl3nq.js → dist-CuMnZOGE.js} +1 -1
- package/dist/client/assets/{dist-CiEW7LQW.js → dist-CukPKrEn.js} +1 -1
- package/dist/client/assets/{dist-CNP-Q5Gs.js → dist-D3Ta0y4e.js} +1 -1
- package/dist/client/assets/{dist-BqblSnWL.js → dist-DJ8SwQl_.js} +1 -1
- package/dist/client/assets/{dist-DozjvPAZ.js → dist-DLKVFZhO.js} +1 -1
- package/dist/client/assets/{dist-amySbADh.js → dist-Jw-GQ-re.js} +1 -1
- package/dist/client/assets/{dist-BwQM8_Mk.js → dist-NlopeJ5R.js} +1 -1
- package/dist/client/assets/{dist-BIgRwYsI.js → dist-POoQ4j29.js} +1 -1
- package/dist/client/assets/{dist-faxwvvOU.js → dist-cP2vnTP_.js} +1 -1
- package/dist/client/assets/{dist-Clf26yld.js → dist-zUJqdbfo.js} +1 -1
- package/dist/client/assets/{index-CNoiRdJC.js → index-B32VZVYx.js} +13 -13
- package/dist/client/assets/index-Bdym8BPo.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/application/conversation-service.d.ts +13 -4
- package/dist/server/application/conversation-service.js +112 -61
- package/dist/server/application/run-service.d.ts +13 -0
- package/dist/server/application/run-service.js +255 -69
- package/dist/server/application/scheduled-task-service.js +8 -1
- package/dist/server/application/workspace-service.js +16 -1
- package/dist/server/feishu-cli.js +15 -4
- package/dist/server/file-index.d.ts +30 -0
- package/dist/server/file-index.js +49 -4
- package/dist/server/files.d.ts +21 -0
- package/dist/server/files.js +38 -0
- package/dist/server/http-server.js +1455 -1412
- package/dist/server/index.js +3 -4
- package/dist/server/logging.js +56 -3
- package/dist/server/pi-session-pool.d.ts +2 -0
- package/dist/server/pi-session-pool.js +1 -0
- package/dist/server/pi-session.d.ts +118 -0
- package/dist/server/pi-session.js +132 -10
- package/dist/server/runtime.d.ts +47 -0
- package/dist/server/runtime.js +183 -84
- package/dist/server/store.d.ts +44 -1
- package/dist/server/store.js +114 -12
- package/dist/server/subagent-runs.js +18 -7
- package/dist/server/subagents.js +7 -0
- package/dist/server/tool-runner.js +13 -4
- package/dist/server/types.d.ts +27 -0
- package/dist/server/ws-server.d.ts +34 -5
- package/dist/server/ws-server.js +170 -46
- package/package.json +14 -1
- package/scripts/diagnose.mjs +161 -58
- package/dist/client/assets/index-FxTrUapG.css +0 -1
package/dist/server/runtime.js
CHANGED
|
@@ -411,6 +411,20 @@ export class PiRpcRuntime {
|
|
|
411
411
|
* it was started.
|
|
412
412
|
*/
|
|
413
413
|
pi;
|
|
414
|
+
/**
|
|
415
|
+
* HOW MANY LIVE SESSIONS THIS CONVERSATION HAS, and whether it has one at all.
|
|
416
|
+
*
|
|
417
|
+
* A reader of this number learns whether a conversation's context is still in Pi's memory. That is
|
|
418
|
+
* the fact a revert has to change: it rewrites the transcript on disk, and a live session keeps the
|
|
419
|
+
* OLD context in memory, so a session that outlived the revert is a model that answers from
|
|
420
|
+
* history the person withdrew - with nothing saying so.
|
|
421
|
+
*
|
|
422
|
+
* It is here because that failure is invisible from the outside: the file is shorter, the state is
|
|
423
|
+
* clean, and only the model knows something the reader cannot see.
|
|
424
|
+
*/
|
|
425
|
+
liveSessionCount(conversationId) {
|
|
426
|
+
return this.sessionPool.has(conversationId) ? 1 : 0;
|
|
427
|
+
}
|
|
414
428
|
canSteer(conversationId) {
|
|
415
429
|
// A steer is only meaningful while a turn is RUNNING. A live session with nothing running is not a
|
|
416
430
|
// steer target: there is no current answer for the message to be inserted into, and the transcript's
|
|
@@ -801,6 +815,7 @@ export class PiRpcRuntime {
|
|
|
801
815
|
const session = await this.sessionPool.acquire({
|
|
802
816
|
conversationId: conversation.id,
|
|
803
817
|
cwd: resolve(cwd),
|
|
818
|
+
...(conversation.piSessionFile ? { sessionFile: conversation.piSessionFile } : {}),
|
|
804
819
|
agentId: conversation.agentId ?? null,
|
|
805
820
|
extensionEnv: configuration.extensionEnv,
|
|
806
821
|
...(configuration.tools ? { tools: configuration.tools } : {}),
|
|
@@ -898,20 +913,18 @@ export class PiRpcRuntime {
|
|
|
898
913
|
// The turn has its OWN controller, and the caller's signal aborts it. A run can span several turns
|
|
899
914
|
// on one session, so "the caller wants this run to stop" and "Pi has to be told about the turn in
|
|
900
915
|
// flight" are two different facts, and only the second one is Pi's business.
|
|
901
|
-
//
|
|
902
|
-
// `abortDone` is the abort's OWN promise, kept so `stop()` can await Pi actually going idle rather
|
|
903
|
-
// than merely having been ASKED to. `AgentSession.abort()` resolves when the session is idle, which
|
|
904
|
-
// is the fact an interrupt needs before the next prompt: prompting earlier is refused by Pi with
|
|
905
|
-
// "Agent is already processing".
|
|
906
916
|
const turnController = new AbortController();
|
|
907
917
|
let abortDone = Promise.resolve();
|
|
918
|
+
const abortSession = () => {
|
|
919
|
+
abortDone = session.abort().catch((error) => {
|
|
920
|
+
console.warn(`PiRpcRuntime: aborting the session of ${conversation.id} threw: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
921
|
+
});
|
|
922
|
+
};
|
|
908
923
|
const abortTurn = () => {
|
|
909
924
|
if (turnController.signal.aborted)
|
|
910
925
|
return;
|
|
911
926
|
turnController.abort();
|
|
912
|
-
|
|
913
|
-
console.warn(`PiRpcRuntime: aborting the session of ${conversation.id} threw: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
914
|
-
});
|
|
927
|
+
abortSession();
|
|
915
928
|
};
|
|
916
929
|
const onAbort = () => { abortTurn(); };
|
|
917
930
|
if (signal.aborted)
|
|
@@ -936,11 +949,29 @@ export class PiRpcRuntime {
|
|
|
936
949
|
}
|
|
937
950
|
};
|
|
938
951
|
let firstTextSeen = false;
|
|
952
|
+
let markPromptFinished;
|
|
953
|
+
const promptFinished = new Promise((resolvePromise) => { markPromptFinished = resolvePromise; });
|
|
939
954
|
const finished = (async () => {
|
|
940
955
|
try {
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
956
|
+
let promptAccepted = false;
|
|
957
|
+
const promptOptions = {
|
|
958
|
+
...(images?.length ? { images: images } : {}),
|
|
959
|
+
preflightResult: (success) => {
|
|
960
|
+
if (!success)
|
|
961
|
+
return;
|
|
962
|
+
promptAccepted = true;
|
|
963
|
+
traceLatency(conversation.id, "session.prompt.accepted", latencyStartedAt, promptSize());
|
|
964
|
+
// Pi calls this hook immediately BEFORE _runAgentPrompt synchronously marks the run
|
|
965
|
+
// active. An abort during before_agent_start can have returned while Pi was idle. Repeat
|
|
966
|
+
// it in the next microtask, after the active flag and the agent's controller exist. Waiting
|
|
967
|
+
// for prompt() would wait for the entire answer and make the cancellation ineffective.
|
|
968
|
+
if (turnController.signal.aborted)
|
|
969
|
+
queueMicrotask(() => {
|
|
970
|
+
if (!queue.isSettled)
|
|
971
|
+
abortSession();
|
|
972
|
+
});
|
|
973
|
+
},
|
|
974
|
+
};
|
|
944
975
|
// A STOP THAT ARRIVED WHILE THIS SESSION WAS BEING BUILT MUST NOT SEND THE PROMPT.
|
|
945
976
|
//
|
|
946
977
|
// The window between "the person pressed stop" and "the prompt goes out" is the whole session
|
|
@@ -962,38 +993,15 @@ export class PiRpcRuntime {
|
|
|
962
993
|
return;
|
|
963
994
|
}
|
|
964
995
|
await session.prompt(prompt, promptOptions);
|
|
965
|
-
|
|
966
|
-
//
|
|
967
|
-
//
|
|
968
|
-
//
|
|
969
|
-
//
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
//
|
|
973
|
-
//
|
|
974
|
-
// second one is the one that lands.
|
|
975
|
-
if (signal.aborted) {
|
|
976
|
-
// The same 5s backstop `runtime.stop` uses: a wedged session must not hang the turn forever.
|
|
977
|
-
await Promise.race([
|
|
978
|
-
session.abort().catch((error) => {
|
|
979
|
-
console.warn(`PiRpcRuntime: aborting the session of ${conversation.id} after a late cancellation threw: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
980
|
-
}),
|
|
981
|
-
new Promise((resolvePromise) => setTimeout(resolvePromise, 5_000)),
|
|
982
|
-
]);
|
|
983
|
-
}
|
|
984
|
-
// `prompt()` RETURNING IS NOT THE TURN ENDING. Pi's `prompt` resolves when the message has
|
|
985
|
-
// been ACCEPTED and the run has been started; the agent is still streaming, still executing
|
|
986
|
-
// tools, still settling. A turn that ended here would release the reader while the model was
|
|
987
|
-
// still producing, and the NEXT turn would arrive at a session Pi considers busy - which Pi
|
|
988
|
-
// refuses with "Agent is already processing".
|
|
989
|
-
//
|
|
990
|
-
// The turn's end is the `agent_settled` EVENT, and the queue is what carries it: the bridge
|
|
991
|
-
// pushes the terminal item when Pi settles, and the consumer below reads it. This promise
|
|
992
|
-
// waits for that signal, and the race with the prompt's own failure is what keeps a turn that
|
|
993
|
-
// never settles from hanging forever - a hung turn is a conversation that never answers.
|
|
994
|
-
// `prompt()` only means Pi accepted the request. Do not end this generator on a wall-clock
|
|
995
|
-
// timeout: the session may still be streaming or executing a tool, and ending here drops
|
|
996
|
-
// subsequent events while falsely allowing the next prompt onto a busy session.
|
|
996
|
+
markPromptFinished();
|
|
997
|
+
// Pi 0.86's prompt() awaits the whole agent run. Input hooks and extension commands can also
|
|
998
|
+
// handle it without starting a run, in which case no agent_settled event is emitted. Pi's idle
|
|
999
|
+
// flag distinguishes that legitimate completion from an asynchronous session adapter whose
|
|
1000
|
+
// prompt promise returns before its stream finishes.
|
|
1001
|
+
if (promptAccepted && session.isIdle === true && !queue.isSettled)
|
|
1002
|
+
queue.push({ kind: "end" });
|
|
1003
|
+
// Drain every queued callback and delta before releasing this turn, even if Pi already went
|
|
1004
|
+
// idle. An adapter that returns early must still provide the authoritative settled event.
|
|
997
1005
|
await queue.settledSignal;
|
|
998
1006
|
traceLatency(conversation.id, "session.settled", latencyStartedAt);
|
|
999
1007
|
// THE REVERT BOUNDARY, recorded at the same moment the one-shot path records it: the turn has
|
|
@@ -1008,9 +1016,22 @@ export class PiRpcRuntime {
|
|
|
1008
1016
|
catch (error) {
|
|
1009
1017
|
queue.fail(error instanceof Error ? error : new Error(String(error)));
|
|
1010
1018
|
}
|
|
1019
|
+
finally {
|
|
1020
|
+
// Also release preparation failures and a turn cancelled before prompt() was called.
|
|
1021
|
+
markPromptFinished();
|
|
1022
|
+
}
|
|
1011
1023
|
})();
|
|
1012
|
-
this.activeTurns.set(conversation.id, { queue, unsubscribe, finished, abort: abortTurn, get abortDone() { return abortDone; } });
|
|
1024
|
+
this.activeTurns.set(conversation.id, { queue, unsubscribe, finished, promptFinished, abort: abortTurn, get abortDone() { return abortDone; } });
|
|
1013
1025
|
try {
|
|
1026
|
+
// THE SPLITTER LIVES HERE, INSIDE THE LOOP THAT DRAINS THE QUEUE, and its thinking events are
|
|
1027
|
+
// applied IN LINE rather than deferred. Two producers write into this one queue - Pi's session
|
|
1028
|
+
// events (text deltas, tool calls) and the splitter's reading of the text - and the deferred way
|
|
1029
|
+
// was to announce the second producer's findings at the END of the queue, which put "the thinking
|
|
1030
|
+
// ended" BEHIND the tool call that Pi had announced after the very text that ended it. The tool
|
|
1031
|
+
// callback then ran while the turn still thought it was thinking, and the call was filed inside
|
|
1032
|
+
// the thinking block (UX-008). In line, the thinking lifecycle happens where its text sits, so
|
|
1033
|
+
// the order Pi announced is the order the turn sees.
|
|
1034
|
+
const splitter = new InlineThinkingSplitter();
|
|
1014
1035
|
for (;;) {
|
|
1015
1036
|
const item = await queue.next();
|
|
1016
1037
|
if (item.kind === "text") {
|
|
@@ -1018,7 +1039,17 @@ export class PiRpcRuntime {
|
|
|
1018
1039
|
firstTextSeen = true;
|
|
1019
1040
|
traceLatency(conversation.id, "session.first-text", latencyStartedAt);
|
|
1020
1041
|
}
|
|
1021
|
-
|
|
1042
|
+
for (const segment of splitter.push(item.delta)) {
|
|
1043
|
+
if (segment.kind === "text") {
|
|
1044
|
+
yield segment.text;
|
|
1045
|
+
}
|
|
1046
|
+
else if (segment.kind === "thinking") {
|
|
1047
|
+
await input.onThinking?.(segment.text);
|
|
1048
|
+
}
|
|
1049
|
+
else {
|
|
1050
|
+
input.onThinkingEnd?.();
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1022
1053
|
continue;
|
|
1023
1054
|
}
|
|
1024
1055
|
if (item.kind === "callback") {
|
|
@@ -1027,6 +1058,19 @@ export class PiRpcRuntime {
|
|
|
1027
1058
|
}
|
|
1028
1059
|
if (item.kind === "error")
|
|
1029
1060
|
throw item.error;
|
|
1061
|
+
// THE END OF THE TURN. A model that was still thinking when the turn ended leaves an unclosed
|
|
1062
|
+
// region behind, and it belongs in the block rather than in the answer.
|
|
1063
|
+
for (const segment of splitter.flush()) {
|
|
1064
|
+
if (segment.kind === "text") {
|
|
1065
|
+
yield segment.text;
|
|
1066
|
+
}
|
|
1067
|
+
else if (segment.kind === "thinking") {
|
|
1068
|
+
await input.onThinking?.(segment.text);
|
|
1069
|
+
}
|
|
1070
|
+
else {
|
|
1071
|
+
input.onThinkingEnd?.();
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1030
1074
|
break;
|
|
1031
1075
|
}
|
|
1032
1076
|
}
|
|
@@ -1220,27 +1264,20 @@ export class PiRpcRuntime {
|
|
|
1220
1264
|
* transcript all work without knowing where the reasoning came from.
|
|
1221
1265
|
*/
|
|
1222
1266
|
async *answer(input) {
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
//
|
|
1235
|
-
//
|
|
1236
|
-
|
|
1237
|
-
if (segment.kind === "text")
|
|
1238
|
-
yield segment.text;
|
|
1239
|
-
else if (segment.kind === "thinking")
|
|
1240
|
-
input.onThinking?.(segment.text);
|
|
1241
|
-
else
|
|
1242
|
-
input.onThinkingEnd?.();
|
|
1243
|
-
}
|
|
1267
|
+
// THE SPLITTING HAPPENS INSIDE `answerTurn`, NOT HERE - and the reason is ORDER.
|
|
1268
|
+
//
|
|
1269
|
+
// The reasoning of an inline-thinking model is a `<think>…</think>` region INSIDE the answer text,
|
|
1270
|
+
// so "the thinking ended" is a fact that only exists once the text carrying the closing tag has
|
|
1271
|
+
// been read. When that fact was produced out here - one layer ABOVE the queue that carries Pi's
|
|
1272
|
+
// events - it was announced by DEFERRED, and a deferred announcement lands at the END of the
|
|
1273
|
+
// queue: BEHIND the tool call that Pi had already announced. So the tool callback ran while the
|
|
1274
|
+
// turn still believed it was thinking, and the call was filed INSIDE the thinking block - a
|
|
1275
|
+
// question that came after the answer was drawn inside the fold above it (UX-008).
|
|
1276
|
+
//
|
|
1277
|
+
// Splitting inside the queue's consumer keeps the two producers in ONE order: the thinking
|
|
1278
|
+
// lifecycle derived from a piece of text happens where that text sits, and a tool call Pi
|
|
1279
|
+
// announced after that text is announced after it too.
|
|
1280
|
+
yield* this.answerTurn(input);
|
|
1244
1281
|
}
|
|
1245
1282
|
/**
|
|
1246
1283
|
* THE TURN ITSELF, on whichever path it runs. See `answer`, which is the seam the reasoning is
|
|
@@ -1252,7 +1289,7 @@ export class PiRpcRuntime {
|
|
|
1252
1289
|
// lifetimes, and folding them into a conversation's session would give them that conversation's
|
|
1253
1290
|
// context. They are identified by the flags that describe exactly that.
|
|
1254
1291
|
if (input.memoryReview || input.knowledgeRerank) {
|
|
1255
|
-
yield* this.
|
|
1292
|
+
yield* this.answerOneShotWithInlineThinking(input);
|
|
1256
1293
|
return;
|
|
1257
1294
|
}
|
|
1258
1295
|
// WHICH PATH A CONVERSATION RUNS ON, and why it is NOT decided by which Pi was named.
|
|
@@ -1275,7 +1312,7 @@ export class PiRpcRuntime {
|
|
|
1275
1312
|
// anyone who started Super Pi the way most people do. The version number said 0.1.95; the speed said
|
|
1276
1313
|
// 0.1.84.
|
|
1277
1314
|
if (this.sessionMode === "oneshot") {
|
|
1278
|
-
yield* this.
|
|
1315
|
+
yield* this.answerOneShotWithInlineThinking(input);
|
|
1279
1316
|
return;
|
|
1280
1317
|
}
|
|
1281
1318
|
// The main path: a persistent session, reused across turns. When the SDK cannot be loaded the turn
|
|
@@ -1287,11 +1324,53 @@ export class PiRpcRuntime {
|
|
|
1287
1324
|
this.warnedMissingSdk = true;
|
|
1288
1325
|
console.warn(`PiRpcRuntime: ${support.reason} Falling back to one-shot Pi processes for conversations.`);
|
|
1289
1326
|
}
|
|
1290
|
-
yield* this.
|
|
1327
|
+
yield* this.answerOneShotWithInlineThinking(input);
|
|
1291
1328
|
return;
|
|
1292
1329
|
}
|
|
1293
1330
|
yield* this.answerOnSession(input);
|
|
1294
1331
|
}
|
|
1332
|
+
/**
|
|
1333
|
+
* THE ONE-SHOT PATH, WITH THE INLINE THINKING SPLIT OUT OF IT.
|
|
1334
|
+
*
|
|
1335
|
+
* WHY THIS IS A SEPARATE FUNCTION AND NOT A LINE IN `answerTurn`. The reasoning of an inline-thinking
|
|
1336
|
+
* model is a `<think>…</think>` region INSIDE the answer text, so splitting it is not a formatting
|
|
1337
|
+
* step - it is a reading of the text, and the facts it produces ("the thinking started", "the thinking
|
|
1338
|
+
* ended") belong where the text is read.
|
|
1339
|
+
*
|
|
1340
|
+
* WHEN IT WAS FORGOTTEN, IT COST THE PRODUCT A BUG. The splitter used to live one layer up, in
|
|
1341
|
+
* `answer()`, where it covered EVERY path. It was moved down into the SDK session path to fix the
|
|
1342
|
+
* ordering of "the thinking ended" against a tool call (UX-008), and in that move the ONE-SHOT
|
|
1343
|
+
* CONVERSATION path - the path a server without the Pi SDK falls back to, and the path the whole test
|
|
1344
|
+
* suite drives - lost the splitting altogether: the `<think>` tags reached the answer verbatim.
|
|
1345
|
+
* `scripts/inline-thinking.test.mjs` caught it; the SDK path was fine, which is why it took a test
|
|
1346
|
+
* suite running the other path to notice.
|
|
1347
|
+
*
|
|
1348
|
+
* SO EVERY ONE-SHOT PATH GOES THROUGH HERE, and the session path keeps its own splitter inside the
|
|
1349
|
+
* queue's consumer, where the ORDER of "thinking ended" and a tool call is decided.
|
|
1350
|
+
*/
|
|
1351
|
+
async *answerOneShotWithInlineThinking(input) {
|
|
1352
|
+
const splitter = new InlineThinkingSplitter();
|
|
1353
|
+
for await (const chunk of this.answerOneShot(input)) {
|
|
1354
|
+
for (const segment of splitter.push(chunk)) {
|
|
1355
|
+
if (segment.kind === "text")
|
|
1356
|
+
yield segment.text;
|
|
1357
|
+
else if (segment.kind === "thinking")
|
|
1358
|
+
await input.onThinking?.(segment.text);
|
|
1359
|
+
else
|
|
1360
|
+
await input.onThinkingEnd?.();
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
// THE END OF THE TURN. A model that was still thinking when the turn ended leaves an unclosed region
|
|
1364
|
+
// behind, and it belongs in the block rather than in the answer.
|
|
1365
|
+
for (const segment of splitter.flush()) {
|
|
1366
|
+
if (segment.kind === "text")
|
|
1367
|
+
yield segment.text;
|
|
1368
|
+
else if (segment.kind === "thinking")
|
|
1369
|
+
await input.onThinking?.(segment.text);
|
|
1370
|
+
else
|
|
1371
|
+
await input.onThinkingEnd?.();
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1295
1374
|
/**
|
|
1296
1375
|
* The one-shot path: a fresh child process for a single turn.
|
|
1297
1376
|
*
|
|
@@ -1967,6 +2046,23 @@ export class PiRpcRuntime {
|
|
|
1967
2046
|
* revert is a rare, deliberate act, and a second Pi answers in well under a second. It buys the
|
|
1968
2047
|
* elimination of an entire class of "we truncated the wrong file and nothing said so" failures.
|
|
1969
2048
|
*/
|
|
2049
|
+
/**
|
|
2050
|
+
* THE LIVE SESSION MUST GO WHEN A REVERT REWRITES THE TRANSCRIPT.
|
|
2051
|
+
*
|
|
2052
|
+
* A revert rewrites Pi's transcript on disk. A live Pi session holds the OLD context in memory, and
|
|
2053
|
+
* nothing in Pi would notice the difference: the model would keep answering from history the user
|
|
2054
|
+
* has just withdrawn, while `state.json` and the file on disk both said it was gone. Three views of
|
|
2055
|
+
* the conversation, two of them wrong, and no error anywhere. So the session is destroyed and the
|
|
2056
|
+
* next turn rebuilds it from the truncated file.
|
|
2057
|
+
*
|
|
2058
|
+
* THIS IS A CORRECTNESS REQUIREMENT, NOT AN OPTIMIZATION, and it is why it lives in one place
|
|
2059
|
+
* called by EVERY branch of `revert`. It used to be inline in two of the three, and the third -
|
|
2060
|
+
* the one taken when the transcript path was not cached - missed it, so a session reverted before
|
|
2061
|
+
* its path was ever learned kept talking about the withdrawn turn with nothing saying so.
|
|
2062
|
+
*/
|
|
2063
|
+
async evictAfterRevert(conversationId) {
|
|
2064
|
+
await this.sessionPool.evict(conversationId);
|
|
2065
|
+
}
|
|
1970
2066
|
async revert({ conversation, cwd, boundaryEntryId, beforeBoundary }) {
|
|
1971
2067
|
if (this.runs.has(conversation.id) || this.activeTurns.has(conversation.id))
|
|
1972
2068
|
throw new Error("这个会话还有一轮任务在跑,先停止它再回退");
|
|
@@ -1981,14 +2077,7 @@ export class PiRpcRuntime {
|
|
|
1981
2077
|
const exists = await stat(cached).catch(() => undefined);
|
|
1982
2078
|
if (exists?.isFile()) {
|
|
1983
2079
|
const result = await truncateSessionFile(cached, boundaryEntryId, conversation.id, beforeBoundary);
|
|
1984
|
-
|
|
1985
|
-
// holds the OLD context in memory. Nothing in Pi would notice the difference: the model would
|
|
1986
|
-
// keep answering from history the user has just withdrawn, while `state.json` and the file on
|
|
1987
|
-
// disk both said it was gone. Three views of the conversation, two of them wrong, and no error
|
|
1988
|
-
// anywhere - so the session is destroyed here and the next turn rebuilds it from the truncated
|
|
1989
|
-
// file. This is the one place where eviction is a correctness requirement and not an
|
|
1990
|
-
// optimization.
|
|
1991
|
-
await this.sessionPool.evict(conversation.id);
|
|
2080
|
+
await this.evictAfterRevert(conversation.id);
|
|
1992
2081
|
return { sessionFile: cached, linesKept: result.linesKept, entriesDropped: result.entriesDropped };
|
|
1993
2082
|
}
|
|
1994
2083
|
}
|
|
@@ -2014,7 +2103,7 @@ export class PiRpcRuntime {
|
|
|
2014
2103
|
if (!found?.isFile())
|
|
2015
2104
|
throw new Error(`Pi 还没有为这个会话建立记录文件(${resolved}),没有可以回退的历史。`);
|
|
2016
2105
|
const result = await truncateSessionFile(resolved, boundaryEntryId, conversation.id, beforeBoundary);
|
|
2017
|
-
await this.
|
|
2106
|
+
await this.evictAfterRevert(conversation.id);
|
|
2018
2107
|
return { sessionFile: resolved, linesKept: result.linesKept, entriesDropped: result.entriesDropped };
|
|
2019
2108
|
}
|
|
2020
2109
|
// NO agent flags and NO extension on this spawn. It sends no prompt and must leave nothing behind:
|
|
@@ -2065,6 +2154,7 @@ export class PiRpcRuntime {
|
|
|
2065
2154
|
if (!exists?.isFile())
|
|
2066
2155
|
throw new Error(`Pi 还没有为这个会话建立记录文件(${sessionFile}),没有可以回退的历史。`);
|
|
2067
2156
|
const result = await truncateSessionFile(sessionFile, boundaryEntryId, conversation.id, beforeBoundary);
|
|
2157
|
+
await this.evictAfterRevert(conversation.id);
|
|
2068
2158
|
return { sessionFile, linesKept: result.linesKept, entriesDropped: result.entriesDropped };
|
|
2069
2159
|
}
|
|
2070
2160
|
/**
|
|
@@ -2236,12 +2326,21 @@ export class PiRpcRuntime {
|
|
|
2236
2326
|
const turn = this.activeTurns.get(conversationId);
|
|
2237
2327
|
if (turn) {
|
|
2238
2328
|
turn.abort();
|
|
2239
|
-
//
|
|
2240
|
-
//
|
|
2241
|
-
//
|
|
2242
|
-
//
|
|
2243
|
-
//
|
|
2244
|
-
|
|
2329
|
+
// abort() can resolve during prompt preflight, before any agent exists. Await the prompt call
|
|
2330
|
+
// as well, which includes that preflight and the actual run in Pi 0.86. For adapters returning
|
|
2331
|
+
// earlier, abortDone still waits for idle. Do not wait for the caller to drain the event queue:
|
|
2332
|
+
// a caller may stop while paused at a yielded delta. A deadline bounds only this request;
|
|
2333
|
+
// RunService retains the real run slot until the reader ends.
|
|
2334
|
+
let stopDeadline;
|
|
2335
|
+
try {
|
|
2336
|
+
await Promise.race([Promise.all([turn.promptFinished, turn.abortDone]), new Promise((resolvePromise) => {
|
|
2337
|
+
stopDeadline = setTimeout(resolvePromise, 5_000);
|
|
2338
|
+
})]);
|
|
2339
|
+
}
|
|
2340
|
+
finally {
|
|
2341
|
+
if (stopDeadline !== undefined)
|
|
2342
|
+
clearTimeout(stopDeadline);
|
|
2343
|
+
}
|
|
2245
2344
|
return;
|
|
2246
2345
|
}
|
|
2247
2346
|
// The one-shot path: a child process that has to be told to abort over its stdin.
|
package/dist/server/store.d.ts
CHANGED
|
@@ -119,6 +119,29 @@ export declare class Store {
|
|
|
119
119
|
*/
|
|
120
120
|
workspaceForConversation(conversationId: string): Workspace | undefined;
|
|
121
121
|
workspace(workspaceId: string): Workspace | undefined;
|
|
122
|
+
/** Read the app-wide default without cloning conversations or transcripts. */
|
|
123
|
+
defaultModel(): ModelSelection | undefined;
|
|
124
|
+
/** Read one detached conversation for lifecycle operations. */
|
|
125
|
+
conversation(conversationId: string): Conversation | undefined;
|
|
126
|
+
/** Return every pending question in one conversation without cloning unrelated history. */
|
|
127
|
+
pendingQuestions(conversationId: string): Array<{
|
|
128
|
+
messageId: string;
|
|
129
|
+
index: number;
|
|
130
|
+
question: ToolCallQuestion;
|
|
131
|
+
}>;
|
|
132
|
+
/**
|
|
133
|
+
* THE SCHEDULED TASKS, WITHOUT CLONING THE STATE TO FIND THEM.
|
|
134
|
+
*
|
|
135
|
+
* The same argument as the readers above, with a sharper edge: the scheduled-task clock sweeps
|
|
136
|
+
* every fifteen seconds and used to read its three-to-zero tasks through `snapshot()` - a deep
|
|
137
|
+
* clone of the whole application, every fifteen seconds, whether the user has any scheduled task or
|
|
138
|
+
* not. On a server holding a long transcript that is a full copy of the app four times a minute,
|
|
139
|
+
* produced for a list that is usually empty.
|
|
140
|
+
*
|
|
141
|
+
* The tasks are returned as a shallow copy of the array: the sweep reads `enabled` and
|
|
142
|
+
* `nextRunAt` and then hands the whole task back to the store, which owns the real one.
|
|
143
|
+
*/
|
|
144
|
+
scheduledTasks(): ScheduledTask[];
|
|
122
145
|
/** Small detached rows used when refreshing each conversation's scoped model catalog. */
|
|
123
146
|
catalogConversationTargets(): Array<Pick<Conversation, "id" | "archivedAt" | "model">>;
|
|
124
147
|
/** Clone only one workspace's transcripts, never unrelated workspaces or app state. */
|
|
@@ -239,11 +262,14 @@ export declare class Store {
|
|
|
239
262
|
* Filtering HERE, in one place, rather than in every reader, is what keeps the client dumb: the
|
|
240
263
|
* sidebar renders `state.workspaces` and never has to know that a closed workspace exists.
|
|
241
264
|
*/
|
|
242
|
-
clientState(
|
|
265
|
+
clientState(scope?: {
|
|
266
|
+
conversationId?: string;
|
|
267
|
+
}): AppState;
|
|
243
268
|
createConversation(workspaceId: string, configuration?: {
|
|
244
269
|
agentId?: string;
|
|
245
270
|
model?: ModelSelection | null;
|
|
246
271
|
thinkingLevel?: string | null;
|
|
272
|
+
modelNotice?: string;
|
|
247
273
|
}): Promise<Conversation>;
|
|
248
274
|
/**
|
|
249
275
|
* Copies a conversation into a NEW one, under an id the caller has already minted.
|
|
@@ -480,6 +506,20 @@ export declare class Store {
|
|
|
480
506
|
* Every field is `undefined`-tested for the same reason `settleToolCall` does it: `""` and `[]` are
|
|
481
507
|
* values a tool can genuinely produce, and treating them as "not reported" would erase a real answer.
|
|
482
508
|
*/
|
|
509
|
+
/**
|
|
510
|
+
* THE TOOL-CALL WRITES, PERSISTED AS ONE MESSAGE INSTEAD OF THE WHOLE APPLICATION.
|
|
511
|
+
*
|
|
512
|
+
* A tool call's lifecycle touches exactly ONE thing: the `toolCalls` array of ONE message. The
|
|
513
|
+
* write used to be `this.persist()`, which hands the ENTIRE state to the repository - every
|
|
514
|
+
* workspace, every conversation, every message of every conversation - to be serialized and
|
|
515
|
+
* written. A turn that calls five tools therefore wrote the whole application ten to fifteen
|
|
516
|
+
* times, and every one of those writes was proportional to the whole history rather than to the
|
|
517
|
+
* one row that changed (LAT-020).
|
|
518
|
+
*
|
|
519
|
+
* `message.replace` is the transaction that means 「this one message now reads like this」, and it
|
|
520
|
+
* is exactly the write this is: the row's content did not change, only its tool calls did.
|
|
521
|
+
*/
|
|
522
|
+
private persistToolCallMessage;
|
|
483
523
|
updateToolCall(conversationId: string, messageId: string, index: number, patch: {
|
|
484
524
|
label?: string;
|
|
485
525
|
detail?: string;
|
|
@@ -487,6 +527,9 @@ export declare class Store {
|
|
|
487
527
|
live?: string;
|
|
488
528
|
preparing?: boolean;
|
|
489
529
|
question?: ToolCallQuestion;
|
|
530
|
+
at?: number;
|
|
531
|
+
thinkingAt?: number;
|
|
532
|
+
thinkingBlock?: number | null;
|
|
490
533
|
backgroundTask?: ToolCallBlock["backgroundTask"];
|
|
491
534
|
}): Promise<ToolCallBlock[]>;
|
|
492
535
|
/**
|