@evandu/super-pi 0.1.132 → 0.1.135
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 +4 -0
- package/dist/client/.super-pi-client-sources.json +8 -8
- package/dist/client/assets/{CodeMirrorEditor-ktyL2PcX.js → CodeMirrorEditor-D6awRUZO.js} +2 -2
- package/dist/client/assets/{dist--igDAzRS.js → dist-1jSyapdW.js} +1 -1
- package/dist/client/assets/{dist-CPLCQDqy.js → dist-BGgidsaF.js} +1 -1
- package/dist/client/assets/{dist-DNZMXYNq.js → dist-BYMYC_2I.js} +1 -1
- package/dist/client/assets/{dist-vsMBLoxV.js → dist-BkVaPA_q.js} +1 -1
- package/dist/client/assets/{dist-B8OZ-n_C.js → dist-BoeM5xW7.js} +1 -1
- package/dist/client/assets/{dist-D_Zdnhm8.js → dist-C46474ys.js} +1 -1
- package/dist/client/assets/{dist-Cu75NyQ0.js → dist-CCkdCH6a.js} +1 -1
- package/dist/client/assets/{dist-BJi5HmH4.js → dist-CNuKauOq.js} +1 -1
- package/dist/client/assets/{dist-BAJCrbnX.js → dist-CubYGjAk.js} +1 -1
- package/dist/client/assets/{dist-8pdzh6h1.js → dist-Cwb-Lb5-.js} +1 -1
- package/dist/client/assets/{dist-BRjGckmQ.js → dist-D0Iw5XQA.js} +1 -1
- package/dist/client/assets/{dist-BjZUYxLd.js → dist-DCiuVvGU.js} +1 -1
- package/dist/client/assets/{dist-Hj_dWgPr.js → dist-DHBWUQhM.js} +1 -1
- package/dist/client/assets/{dist-CoszOk5k.js → dist-DOwrSiuY.js} +1 -1
- package/dist/client/assets/{dist-Bg9zLfex.js → dist-DeZsNbFx.js} +1 -1
- package/dist/client/assets/{dist-C6iAHGvb.js → dist-Dk3e6RfY.js} +1 -1
- package/dist/client/assets/{dist-Bv6P2Rcp.js → dist-DoDmoWTn.js} +1 -1
- package/dist/client/assets/{dist-CgmISAMr.js → dist-WkVUFX28.js} +1 -1
- package/dist/client/assets/{dist-DvHghuGd.js → dist-lZUWPQbT.js} +1 -1
- package/dist/client/assets/{dist-Zhdkfvng.js → dist-mPR8uuzk.js} +1 -1
- package/dist/client/assets/{dist-60usx__o.js → dist-spB2Q2CG.js} +1 -1
- package/dist/client/assets/{dist-CMscy2Nn.js → dist-yTTCht0J.js} +1 -1
- package/dist/client/assets/index-Dyta-HkQ.js +100 -0
- package/dist/client/assets/{index-D3MEG8uH.css → index-V93Kzott.css} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/server/active-memory.d.ts +1 -0
- package/dist/server/active-memory.js +6 -2
- package/dist/server/agents.js +21 -1
- package/dist/server/application/feishu-service.d.ts +57 -10
- package/dist/server/application/feishu-service.js +118 -24
- package/dist/server/application/run-service.d.ts +4 -0
- package/dist/server/application/run-service.js +123 -88
- package/dist/server/application/workspace-service.d.ts +8 -4
- package/dist/server/application/workspace-service.js +11 -10
- package/dist/server/failure-text.d.ts +18 -0
- package/dist/server/failure-text.js +44 -0
- package/dist/server/feishu-cli.d.ts +18 -2
- package/dist/server/feishu-cli.js +46 -12
- package/dist/server/feishu-config.d.ts +68 -0
- package/dist/server/feishu-config.js +84 -0
- package/dist/server/feishu.d.ts +187 -0
- package/dist/server/feishu.js +695 -0
- package/dist/server/http-server.js +36 -5
- package/dist/server/index.js +5 -2
- package/dist/server/knowledge-embedding-worker.d.ts +1 -0
- package/dist/server/knowledge-embedding-worker.js +27 -0
- package/dist/server/knowledge-vectors.d.ts +5 -3
- package/dist/server/knowledge-vectors.js +49 -47
- package/dist/server/knowledge.d.ts +1 -0
- package/dist/server/knowledge.js +6 -2
- package/dist/server/persistence/sqlite-state-repository.d.ts +0 -1
- package/dist/server/persistence/sqlite-state-repository.js +55 -1
- package/dist/server/pi-session-events.js +10 -2
- package/dist/server/pi-session-pool.d.ts +33 -1
- package/dist/server/pi-session-pool.js +20 -2
- package/dist/server/providers.d.ts +13 -0
- package/dist/server/providers.js +27 -2
- package/dist/server/runtime.d.ts +26 -1
- package/dist/server/runtime.js +98 -9
- package/dist/server/store.d.ts +61 -0
- package/dist/server/store.js +112 -14
- package/dist/server/types.d.ts +10 -0
- package/dist/server/ws-server.d.ts +3 -0
- package/dist/server/ws-server.js +35 -6
- package/package.json +25 -2
- package/dist/client/assets/index-ChyemF2I.js +0 -100
package/dist/server/runtime.d.ts
CHANGED
|
@@ -164,7 +164,11 @@ export interface AnswerInput {
|
|
|
164
164
|
onToolPreparing?(call: ToolCallPreparing): void;
|
|
165
165
|
/** Called while the primary model is emitting reasoning, so the UI can show live progress. A call means
|
|
166
166
|
* the model is STILL THINKING - there is no separate flag, because Pi does not send one. */
|
|
167
|
-
|
|
167
|
+
/** Called while the primary model is emitting reasoning, so the UI can show live progress. A call means
|
|
168
|
+
* the model is STILL THINKING - there is no separate flag, because Pi does not send one.
|
|
169
|
+
* The handler may be async: the reasoning signal is the same "the model is producing" signal as
|
|
170
|
+
* a text delta, and the caller waits for it so the state it writes is ordered with the delta. */
|
|
171
|
+
onThinking?(delta: string): void | Promise<void>;
|
|
168
172
|
/** Called when the reasoning phase ends - the only signal that it has. */
|
|
169
173
|
onThinkingEnd?(): void;
|
|
170
174
|
}
|
|
@@ -560,6 +564,12 @@ export declare class PiRpcRuntime implements AgentRuntime {
|
|
|
560
564
|
private readonly options;
|
|
561
565
|
private memory?;
|
|
562
566
|
setMemory(memory: ActiveMemory): void;
|
|
567
|
+
/**
|
|
568
|
+
* The memory and knowledge credentials minted for each LIVE session, so they can be released when
|
|
569
|
+
* the session goes. A credential whose session is gone must not keep working - that is exactly the
|
|
570
|
+
* shape of a secret with no owner.
|
|
571
|
+
*/
|
|
572
|
+
private readonly sessionExtensionTokens;
|
|
563
573
|
private knowledge?;
|
|
564
574
|
setKnowledge(knowledge: KnowledgeBase): void;
|
|
565
575
|
private readonly runs;
|
|
@@ -841,6 +851,21 @@ export declare class PiRpcRuntime implements AgentRuntime {
|
|
|
841
851
|
* The TODO ENDPOINT is the one value here that is NOT per-turn derived from disk: it is this server's
|
|
842
852
|
* own address, so it is the same string on every turn and is read once from the constructor's options.
|
|
843
853
|
*/
|
|
854
|
+
/**
|
|
855
|
+
* THE MEMORY AND KNOWLEDGE CREDENTIALS FOR A NEW SESSION.
|
|
856
|
+
*
|
|
857
|
+
* Both extensions read their environment ONCE, when they are loaded, so these are minted where the
|
|
858
|
+
* session is born - the same place, and for the same reason, as the session's capability. The
|
|
859
|
+
* one-shot path has always minted them (per turn); the session path minted none, so the memory and
|
|
860
|
+
* knowledge extensions returned before registering a tool or injecting a prompt, and both features
|
|
861
|
+
* were missing in production - the SDK session path is the DEFAULT backend, and only the test
|
|
862
|
+
* scripts take the one-shot path (ledger TEST-039).
|
|
863
|
+
*
|
|
864
|
+
* The endpoints are published as global environment variables (index.ts), and they are repeated
|
|
865
|
+
* here so the extension's contract is stated in one place: an endpoint and a credential travel
|
|
866
|
+
* together, and the credential is what proves the caller.
|
|
867
|
+
*/
|
|
868
|
+
private mintSessionExtensionTokens;
|
|
844
869
|
/**
|
|
845
870
|
* The environment Super Pi's extensions read when they are loaded.
|
|
846
871
|
*
|
package/dist/server/runtime.js
CHANGED
|
@@ -289,6 +289,12 @@ export class PiRpcRuntime {
|
|
|
289
289
|
options;
|
|
290
290
|
memory;
|
|
291
291
|
setMemory(memory) { this.memory = memory; }
|
|
292
|
+
/**
|
|
293
|
+
* The memory and knowledge credentials minted for each LIVE session, so they can be released when
|
|
294
|
+
* the session goes. A credential whose session is gone must not keep working - that is exactly the
|
|
295
|
+
* shape of a secret with no owner.
|
|
296
|
+
*/
|
|
297
|
+
sessionExtensionTokens = new Map();
|
|
292
298
|
knowledge;
|
|
293
299
|
setKnowledge(knowledge) { this.knowledge = knowledge; }
|
|
294
300
|
runs = new Map();
|
|
@@ -466,7 +472,16 @@ export class PiRpcRuntime {
|
|
|
466
472
|
// ABOUT that session and stop being true with it.
|
|
467
473
|
this.sessionHosts.delete(conversationId);
|
|
468
474
|
this.reportedSessionFiles.delete(conversationId);
|
|
469
|
-
|
|
475
|
+
// The session held these credentials too, so they die with it.
|
|
476
|
+
const tokens = this.sessionExtensionTokens.get(conversationId);
|
|
477
|
+
if (tokens) {
|
|
478
|
+
if (tokens.memoryToken)
|
|
479
|
+
this.memory?.release(tokens.memoryToken);
|
|
480
|
+
if (tokens.knowledgeToken)
|
|
481
|
+
this.knowledge?.release(tokens.knowledgeToken);
|
|
482
|
+
this.sessionExtensionTokens.delete(conversationId);
|
|
483
|
+
}
|
|
484
|
+
}, options.sessionFactory ?? createPiSession, (conversationId) => mintCapability(conversationId), () => this.modelEpoch, (conversationId) => this.mintSessionExtensionTokens(conversationId));
|
|
470
485
|
}
|
|
471
486
|
/**
|
|
472
487
|
* The configuration of the session that was built for a conversation, for tests to inspect.
|
|
@@ -628,6 +643,37 @@ export class PiRpcRuntime {
|
|
|
628
643
|
* The TODO ENDPOINT is the one value here that is NOT per-turn derived from disk: it is this server's
|
|
629
644
|
* own address, so it is the same string on every turn and is read once from the constructor's options.
|
|
630
645
|
*/
|
|
646
|
+
/**
|
|
647
|
+
* THE MEMORY AND KNOWLEDGE CREDENTIALS FOR A NEW SESSION.
|
|
648
|
+
*
|
|
649
|
+
* Both extensions read their environment ONCE, when they are loaded, so these are minted where the
|
|
650
|
+
* session is born - the same place, and for the same reason, as the session's capability. The
|
|
651
|
+
* one-shot path has always minted them (per turn); the session path minted none, so the memory and
|
|
652
|
+
* knowledge extensions returned before registering a tool or injecting a prompt, and both features
|
|
653
|
+
* were missing in production - the SDK session path is the DEFAULT backend, and only the test
|
|
654
|
+
* scripts take the one-shot path (ledger TEST-039).
|
|
655
|
+
*
|
|
656
|
+
* The endpoints are published as global environment variables (index.ts), and they are repeated
|
|
657
|
+
* here so the extension's contract is stated in one place: an endpoint and a credential travel
|
|
658
|
+
* together, and the credential is what proves the caller.
|
|
659
|
+
*/
|
|
660
|
+
async mintSessionExtensionTokens(conversationId) {
|
|
661
|
+
const minted = {};
|
|
662
|
+
const env = {};
|
|
663
|
+
if (this.memory && (await this.memory.configuration()).enabled) {
|
|
664
|
+
minted.memoryToken = this.memory.issue(conversationId);
|
|
665
|
+
env.SUPER_PI_MEMORY_TOKEN = minted.memoryToken;
|
|
666
|
+
env.SUPER_PI_MEMORY_ENDPOINT = process.env.SUPER_PI_MEMORY_ENDPOINT ?? "";
|
|
667
|
+
}
|
|
668
|
+
if (this.knowledge && this.knowledge.enabled()) {
|
|
669
|
+
minted.knowledgeToken = this.knowledge.issue(conversationId);
|
|
670
|
+
env.SUPER_PI_KNOWLEDGE_TOKEN = minted.knowledgeToken;
|
|
671
|
+
env.SUPER_PI_KNOWLEDGE_ENDPOINT = process.env.SUPER_PI_KNOWLEDGE_ENDPOINT ?? "";
|
|
672
|
+
}
|
|
673
|
+
if (minted.memoryToken || minted.knowledgeToken)
|
|
674
|
+
this.sessionExtensionTokens.set(conversationId, minted);
|
|
675
|
+
return env;
|
|
676
|
+
}
|
|
631
677
|
/**
|
|
632
678
|
* The environment Super Pi's extensions read when they are loaded.
|
|
633
679
|
*
|
|
@@ -895,7 +941,46 @@ export class PiRpcRuntime {
|
|
|
895
941
|
const promptOptions = images?.length
|
|
896
942
|
? { images: images, preflightResult: () => traceLatency(conversation.id, "session.prompt.accepted", latencyStartedAt, promptSize()) }
|
|
897
943
|
: { preflightResult: () => traceLatency(conversation.id, "session.prompt.accepted", latencyStartedAt, promptSize()) };
|
|
944
|
+
// A STOP THAT ARRIVED WHILE THIS SESSION WAS BEING BUILT MUST NOT SEND THE PROMPT.
|
|
945
|
+
//
|
|
946
|
+
// The window between "the person pressed stop" and "the prompt goes out" is the whole session
|
|
947
|
+
// build: Pi's resource loader, the extensions, the transcript, the model lookup - seconds, on a
|
|
948
|
+
// cold session. The abort listener above already noticed the cancellation, but `session.abort()`
|
|
949
|
+
// on a session that has not started running is a no-op, so nothing stopped the prompt that was
|
|
950
|
+
// about to be sent: the model was asked a question the person had already cancelled, and it
|
|
951
|
+
// answered it - which is why pressing stop looked like it "took effect" only once the reply was
|
|
952
|
+
// over.
|
|
953
|
+
//
|
|
954
|
+
// So the cancellation is checked HERE, at the last moment before the prompt goes out, and a
|
|
955
|
+
// cancelled turn ends with no question asked at all.
|
|
956
|
+
//
|
|
957
|
+
// `turnController.signal.aborted` is the OTHER half of the same fact: the turn's own controller
|
|
958
|
+
// is what `abortTurn()` trips, and it is tripped by the very same cancellation. Both are checked
|
|
959
|
+
// because they are tripped by different readers - one by the person, one by Pi's abort.
|
|
960
|
+
if (signal.aborted || turnController.signal.aborted) {
|
|
961
|
+
queue.push({ kind: "end" });
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
898
964
|
await session.prompt(prompt, promptOptions);
|
|
965
|
+
// THE CANCELLATION MAY HAVE ARRIVED WHILE PI WAS PREPARING THE PROMPT.
|
|
966
|
+
//
|
|
967
|
+
// `session.prompt()` is not one step: it runs Pi's input handlers (the extensions' hooks -
|
|
968
|
+
// Super Pi's own memory search is one of them), the compaction check and the auth check before
|
|
969
|
+
// the agent's run starts. An abort that lands in that window finds a session that has NOT
|
|
970
|
+
// started running, so Pi's abort has nothing to abort - and the prompt goes on to the model,
|
|
971
|
+
// which answers a question the person cancelled.
|
|
972
|
+
//
|
|
973
|
+
// The run IS started by the time `prompt()` resolves, so the abort is said AGAIN here. The
|
|
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
|
+
}
|
|
899
984
|
// `prompt()` RETURNING IS NOT THE TURN ENDING. Pi's `prompt` resolves when the message has
|
|
900
985
|
// been ACCEPTED and the run has been started; the agent is still streaming, still executing
|
|
901
986
|
// tools, still settling. A turn that ended here would release the reader while the model was
|
|
@@ -906,13 +991,10 @@ export class PiRpcRuntime {
|
|
|
906
991
|
// pushes the terminal item when Pi settles, and the consumer below reads it. This promise
|
|
907
992
|
// waits for that signal, and the race with the prompt's own failure is what keeps a turn that
|
|
908
993
|
// never settles from hanging forever - a hung turn is a conversation that never answers.
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
// never produce anything and the next message can never be sent.
|
|
914
|
-
new Promise((resolvePromise) => setTimeout(resolvePromise, 5_000)),
|
|
915
|
-
]);
|
|
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.
|
|
997
|
+
await queue.settledSignal;
|
|
916
998
|
traceLatency(conversation.id, "session.settled", latencyStartedAt);
|
|
917
999
|
// THE REVERT BOUNDARY, recorded at the same moment the one-shot path records it: the turn has
|
|
918
1000
|
// settled, so every entry this turn wrote - the user message included - is in the transcript.
|
|
@@ -1144,7 +1226,7 @@ export class PiRpcRuntime {
|
|
|
1144
1226
|
if (segment.kind === "text")
|
|
1145
1227
|
yield segment.text;
|
|
1146
1228
|
else if (segment.kind === "thinking")
|
|
1147
|
-
input.onThinking?.(segment.text);
|
|
1229
|
+
await input.onThinking?.(segment.text);
|
|
1148
1230
|
else
|
|
1149
1231
|
input.onThinkingEnd?.();
|
|
1150
1232
|
}
|
|
@@ -1394,6 +1476,13 @@ export class PiRpcRuntime {
|
|
|
1394
1476
|
const imagePayloads = this.options.uploadsDir && images?.length
|
|
1395
1477
|
? await loadConversationImages(this.options.uploadsDir, conversation.id, images)
|
|
1396
1478
|
: undefined;
|
|
1479
|
+
// A STOP THAT ARRIVED WHILE THE CHILD WAS STARTING MUST NOT SEND THE PROMPT.
|
|
1480
|
+
//
|
|
1481
|
+
// The same gap the session path has, and the same reason: `abort` above writes an `abort`
|
|
1482
|
+
// command to a child that has not been given anything yet, so it does nothing - and the prompt
|
|
1483
|
+
// went out anyway, and the model answered a question the person had already cancelled.
|
|
1484
|
+
if (signal.aborted)
|
|
1485
|
+
return;
|
|
1397
1486
|
channel.write({ id: "prompt", type: "prompt", message: prompt, ...(imagePayloads?.length ? { images: imagePayloads } : {}) });
|
|
1398
1487
|
traceLatency(conversation.id, "prompt.sent", latencyStartedAt);
|
|
1399
1488
|
let settled = false;
|
package/dist/server/store.d.ts
CHANGED
|
@@ -11,6 +11,16 @@ import type { AppState, Conversation, Message, MessageDelivery, MessageImage, Mo
|
|
|
11
11
|
* disk, or a failed write would leave a conversation whose model context is silently empty. The runtime is
|
|
12
12
|
* therefore handed the id and the store is handed the same one back. See `ForkRequest.forkedConversationId`. */
|
|
13
13
|
export declare function newConversationId(): string;
|
|
14
|
+
/**
|
|
15
|
+
* ONE FOLDER, ONE KEY.
|
|
16
|
+
*
|
|
17
|
+
* The same directory reaches the app spelled several ways - `F:\code\app` and `F:/code/app/` and
|
|
18
|
+
* `f:\code\app` are one folder - and two workspaces for one folder are one project split in half.
|
|
19
|
+
* The key folds the separators and the trailing slash away, and folds case away only where the
|
|
20
|
+
* filesystem folds it too: Windows and macOS treat `F:\Code` and `f:\code` as one directory, Linux
|
|
21
|
+
* does not, and pretending otherwise would merge two real projects into one.
|
|
22
|
+
*/
|
|
23
|
+
export declare function workspacePathKey(path: string): string;
|
|
14
24
|
/**
|
|
15
25
|
* How many SETTLED sub-agent runs `state.json` keeps, so the file cannot grow without bound.
|
|
16
26
|
*
|
|
@@ -101,6 +111,15 @@ export declare class Store {
|
|
|
101
111
|
setDefaultModel(model?: ModelSelection): Promise<void>;
|
|
102
112
|
setTerminalShell(shell?: import("./types.js").TerminalShell): Promise<void>;
|
|
103
113
|
snapshot(): AppState;
|
|
114
|
+
/** Cheap scalar/entity reads for hot paths. Never expose the live mutable state object. */
|
|
115
|
+
hasWorkspace(workspaceId: string): boolean;
|
|
116
|
+
conversationStatus(conversationId: string): Conversation["status"] | undefined;
|
|
117
|
+
question(conversationId: string, messageId: string, index: number): ToolCallQuestion | undefined;
|
|
118
|
+
pendingQuestion(conversationId: string): {
|
|
119
|
+
messageId: string;
|
|
120
|
+
index: number;
|
|
121
|
+
question: ToolCallQuestion;
|
|
122
|
+
} | undefined;
|
|
104
123
|
/**
|
|
105
124
|
* Whether a conversation already holds a message with this id.
|
|
106
125
|
*
|
|
@@ -126,6 +145,17 @@ export declare class Store {
|
|
|
126
145
|
conversation: Conversation;
|
|
127
146
|
workspace: Workspace;
|
|
128
147
|
} | undefined;
|
|
148
|
+
/**
|
|
149
|
+
* THE PHYSICAL DIRECTORY IS THE WORKSPACE'S IDENTITY.
|
|
150
|
+
*
|
|
151
|
+
* A workspace is a folder, not a row in a table: two workspaces pointing at the same directory are
|
|
152
|
+
* the same project, and the app used to mint a fresh UUID for each one. That is what broke
|
|
153
|
+
* re-adding a workspace - the new id matched none of the conversations that belonged to that
|
|
154
|
+
* directory, so the history was unreachable (and, before the soft unmount, actually deleted).
|
|
155
|
+
*
|
|
156
|
+
* So the same folder always means the same workspace id, and the id is what the conversations
|
|
157
|
+
* point at.
|
|
158
|
+
*/
|
|
129
159
|
createWorkspace(name: string, path?: string): Promise<Workspace>;
|
|
130
160
|
renameWorkspace(workspaceId: string, name: string): Promise<Workspace>;
|
|
131
161
|
/**
|
|
@@ -151,7 +181,38 @@ export declare class Store {
|
|
|
151
181
|
thinkingLevel?: string;
|
|
152
182
|
agentId?: string | null;
|
|
153
183
|
}): Promise<Workspace>;
|
|
184
|
+
/**
|
|
185
|
+
* TAKES A WORKSPACE OUT OF THE SIDEBAR. IT DOES NOT DELETE ANYTHING.
|
|
186
|
+
*
|
|
187
|
+
* This used to cascade: the workspace row AND every conversation that pointed at it were erased
|
|
188
|
+
* from the state, in one persist. That made the most natural thing a user could do - remove a
|
|
189
|
+
* workspace to tidy the sidebar, then add the same folder back - silently destroy the history of
|
|
190
|
+
* that project, and there was no way to get it back.
|
|
191
|
+
*
|
|
192
|
+
* So the removal is a SOFT UNMOUNT, the same shape as a session's archive: the workspace row
|
|
193
|
+
* stays (with `closedAt` on it), the conversations stay, the uploads stay, and nothing is
|
|
194
|
+
* broadcast for either (see `clientState`). Adding the same folder again re-opens the very same
|
|
195
|
+
* workspace - same id, same conversations - which is the whole point of the physical path being
|
|
196
|
+
* the workspace's identity.
|
|
197
|
+
*
|
|
198
|
+
* The returned list is the conversations that were STOPPED, not deleted: a turn that was still
|
|
199
|
+
* running under a workspace that just left the sidebar cannot keep burning a model session
|
|
200
|
+
* behind the user's back, and a pending question would otherwise wait for an answer that the
|
|
201
|
+
* sidebar can no longer reach.
|
|
202
|
+
*/
|
|
154
203
|
deleteWorkspace(workspaceId: string): Promise<string[]>;
|
|
204
|
+
/**
|
|
205
|
+
* THE STATE THE CLIENT IS ALLOWED TO SEE.
|
|
206
|
+
*
|
|
207
|
+
* `snapshot()` is the truth of what the store HOLDS; this is the truth of what is ON SCREEN. The
|
|
208
|
+
* difference is the closed workspaces: one that was removed from the sidebar is still in the store
|
|
209
|
+
* - that is exactly why re-adding the folder brings its history back - but it is not part of the
|
|
210
|
+
* app any more, so neither it nor its conversations are broadcast.
|
|
211
|
+
*
|
|
212
|
+
* Filtering HERE, in one place, rather than in every reader, is what keeps the client dumb: the
|
|
213
|
+
* sidebar renders `state.workspaces` and never has to know that a closed workspace exists.
|
|
214
|
+
*/
|
|
215
|
+
clientState(): AppState;
|
|
155
216
|
createConversation(workspaceId: string, configuration?: {
|
|
156
217
|
agentId?: string;
|
|
157
218
|
model?: ModelSelection | null;
|
package/dist/server/store.js
CHANGED
|
@@ -11,6 +11,19 @@ function id(prefix) { return `${prefix}_${crypto.randomUUID()}`; }
|
|
|
11
11
|
* disk, or a failed write would leave a conversation whose model context is silently empty. The runtime is
|
|
12
12
|
* therefore handed the id and the store is handed the same one back. See `ForkRequest.forkedConversationId`. */
|
|
13
13
|
export function newConversationId() { return id("conversation"); }
|
|
14
|
+
/**
|
|
15
|
+
* ONE FOLDER, ONE KEY.
|
|
16
|
+
*
|
|
17
|
+
* The same directory reaches the app spelled several ways - `F:\code\app` and `F:/code/app/` and
|
|
18
|
+
* `f:\code\app` are one folder - and two workspaces for one folder are one project split in half.
|
|
19
|
+
* The key folds the separators and the trailing slash away, and folds case away only where the
|
|
20
|
+
* filesystem folds it too: Windows and macOS treat `F:\Code` and `f:\code` as one directory, Linux
|
|
21
|
+
* does not, and pretending otherwise would merge two real projects into one.
|
|
22
|
+
*/
|
|
23
|
+
export function workspacePathKey(path) {
|
|
24
|
+
const folded = path.trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
|
25
|
+
return process.platform === "linux" ? folded : folded.toLowerCase();
|
|
26
|
+
}
|
|
14
27
|
function now() { return new Date().toISOString(); }
|
|
15
28
|
const DEFAULT_CONVERSATION_TITLE = "\u65b0\u4f1a\u8bdd";
|
|
16
29
|
/** Whether a value survives a `new Date()` round-trip. `createdAt` is typed string, but state.json only
|
|
@@ -735,6 +748,35 @@ export class Store {
|
|
|
735
748
|
console.warn(`[super-pi latency] phase=state.snapshot bytes=${JSON.stringify(value).length} cloneMs=${Math.round(performance.now() - startedAt)}`);
|
|
736
749
|
return value;
|
|
737
750
|
}
|
|
751
|
+
/** Cheap scalar/entity reads for hot paths. Never expose the live mutable state object. */
|
|
752
|
+
hasWorkspace(workspaceId) {
|
|
753
|
+
return this.state.workspaces.some((workspace) => workspace.id === workspaceId);
|
|
754
|
+
}
|
|
755
|
+
conversationStatus(conversationId) {
|
|
756
|
+
return this.state.conversations.find((conversation) => conversation.id === conversationId)?.status;
|
|
757
|
+
}
|
|
758
|
+
question(conversationId, messageId, index) {
|
|
759
|
+
const question = this.state.conversations.find((conversation) => conversation.id === conversationId)
|
|
760
|
+
?.messages.find((message) => message.id === messageId)?.toolCalls?.[index]?.question;
|
|
761
|
+
return question ? structuredClone(question) : undefined;
|
|
762
|
+
}
|
|
763
|
+
pendingQuestion(conversationId) {
|
|
764
|
+
const conversation = this.state.conversations.find((item) => item.id === conversationId);
|
|
765
|
+
if (!conversation)
|
|
766
|
+
return undefined;
|
|
767
|
+
for (let messageIndex = conversation.messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
|
|
768
|
+
const message = conversation.messages[messageIndex];
|
|
769
|
+
if (message.role !== "assistant")
|
|
770
|
+
continue;
|
|
771
|
+
for (let index = (message.toolCalls?.length ?? 0) - 1; index >= 0; index -= 1) {
|
|
772
|
+
const question = message.toolCalls?.[index]?.question;
|
|
773
|
+
if (message.toolCalls?.[index]?.label === "question" && question?.status === "pending") {
|
|
774
|
+
return { messageId: message.id, index, question: structuredClone(question) };
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
return undefined;
|
|
779
|
+
}
|
|
738
780
|
/**
|
|
739
781
|
* Whether a conversation already holds a message with this id.
|
|
740
782
|
*
|
|
@@ -767,6 +809,17 @@ export class Store {
|
|
|
767
809
|
return undefined;
|
|
768
810
|
return { conversation: structuredClone(conversation), workspace: structuredClone(workspace) };
|
|
769
811
|
}
|
|
812
|
+
/**
|
|
813
|
+
* THE PHYSICAL DIRECTORY IS THE WORKSPACE'S IDENTITY.
|
|
814
|
+
*
|
|
815
|
+
* A workspace is a folder, not a row in a table: two workspaces pointing at the same directory are
|
|
816
|
+
* the same project, and the app used to mint a fresh UUID for each one. That is what broke
|
|
817
|
+
* re-adding a workspace - the new id matched none of the conversations that belonged to that
|
|
818
|
+
* directory, so the history was unreachable (and, before the soft unmount, actually deleted).
|
|
819
|
+
*
|
|
820
|
+
* So the same folder always means the same workspace id, and the id is what the conversations
|
|
821
|
+
* point at.
|
|
822
|
+
*/
|
|
770
823
|
async createWorkspace(name, path) {
|
|
771
824
|
const cleanName = name.trim();
|
|
772
825
|
if (!cleanName || cleanName.length > 80)
|
|
@@ -774,6 +827,19 @@ export class Store {
|
|
|
774
827
|
const cleanPath = path?.trim() || process.cwd();
|
|
775
828
|
if (cleanPath.length > 500)
|
|
776
829
|
throw new Error("工作区路径太长了。");
|
|
830
|
+
// THE SAME FOLDER IS THE SAME WORKSPACE. Adding a directory that is already here re-opens it
|
|
831
|
+
// instead of forking a second one: the id stays, the conversations stay attached to it, and the
|
|
832
|
+
// name is left alone (renaming is an explicit action elsewhere, and silently renaming a
|
|
833
|
+
// workspace because someone typed a name into the add dialog would be its own surprise).
|
|
834
|
+
const existing = this.state.workspaces.find((item) => workspacePathKey(item.path) === workspacePathKey(cleanPath));
|
|
835
|
+
if (existing) {
|
|
836
|
+
if (existing.closedAt) {
|
|
837
|
+
delete existing.closedAt;
|
|
838
|
+
existing.updatedAt = now();
|
|
839
|
+
await this.persist();
|
|
840
|
+
}
|
|
841
|
+
return structuredClone(existing);
|
|
842
|
+
}
|
|
777
843
|
const workspace = { id: id("workspace"), name: cleanName, path: cleanPath, updatedAt: now() };
|
|
778
844
|
this.state.workspaces.push(workspace);
|
|
779
845
|
await this.persist();
|
|
@@ -830,23 +896,55 @@ export class Store {
|
|
|
830
896
|
await this.persist();
|
|
831
897
|
return structuredClone(workspace);
|
|
832
898
|
}
|
|
899
|
+
/**
|
|
900
|
+
* TAKES A WORKSPACE OUT OF THE SIDEBAR. IT DOES NOT DELETE ANYTHING.
|
|
901
|
+
*
|
|
902
|
+
* This used to cascade: the workspace row AND every conversation that pointed at it were erased
|
|
903
|
+
* from the state, in one persist. That made the most natural thing a user could do - remove a
|
|
904
|
+
* workspace to tidy the sidebar, then add the same folder back - silently destroy the history of
|
|
905
|
+
* that project, and there was no way to get it back.
|
|
906
|
+
*
|
|
907
|
+
* So the removal is a SOFT UNMOUNT, the same shape as a session's archive: the workspace row
|
|
908
|
+
* stays (with `closedAt` on it), the conversations stay, the uploads stay, and nothing is
|
|
909
|
+
* broadcast for either (see `clientState`). Adding the same folder again re-opens the very same
|
|
910
|
+
* workspace - same id, same conversations - which is the whole point of the physical path being
|
|
911
|
+
* the workspace's identity.
|
|
912
|
+
*
|
|
913
|
+
* The returned list is the conversations that were STOPPED, not deleted: a turn that was still
|
|
914
|
+
* running under a workspace that just left the sidebar cannot keep burning a model session
|
|
915
|
+
* behind the user's back, and a pending question would otherwise wait for an answer that the
|
|
916
|
+
* sidebar can no longer reach.
|
|
917
|
+
*/
|
|
833
918
|
async deleteWorkspace(workspaceId) {
|
|
834
|
-
|
|
919
|
+
const workspace = this.state.workspaces.find((item) => item.id === workspaceId);
|
|
920
|
+
if (!workspace)
|
|
835
921
|
throw new Error("找不到这个工作区。");
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
// both together. A waiting message needs no sweep of its own any more - it is a message inside one of
|
|
841
|
-
// the conversations being removed, so it goes with its container, which is the whole reason it is a
|
|
842
|
-
// better shape than an entry keyed by conversation id in a separate array: that array needed this
|
|
843
|
-
// cascade to avoid naming a conversation that no longer exists, and could not have been trusted to
|
|
844
|
-
// get it right on its own.
|
|
845
|
-
const doomed = new Set(this.state.conversations.filter((item) => item.workspaceId === workspaceId).map((item) => item.id));
|
|
846
|
-
this.state.workspaces = this.state.workspaces.filter((item) => item.id !== workspaceId);
|
|
847
|
-
this.state.conversations = this.state.conversations.filter((item) => item.workspaceId !== workspaceId);
|
|
922
|
+
if (workspace.closedAt)
|
|
923
|
+
return [];
|
|
924
|
+
workspace.closedAt = now();
|
|
925
|
+
workspace.updatedAt = workspace.closedAt;
|
|
848
926
|
await this.persist();
|
|
849
|
-
return [
|
|
927
|
+
return [];
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* THE STATE THE CLIENT IS ALLOWED TO SEE.
|
|
931
|
+
*
|
|
932
|
+
* `snapshot()` is the truth of what the store HOLDS; this is the truth of what is ON SCREEN. The
|
|
933
|
+
* difference is the closed workspaces: one that was removed from the sidebar is still in the store
|
|
934
|
+
* - that is exactly why re-adding the folder brings its history back - but it is not part of the
|
|
935
|
+
* app any more, so neither it nor its conversations are broadcast.
|
|
936
|
+
*
|
|
937
|
+
* Filtering HERE, in one place, rather than in every reader, is what keeps the client dumb: the
|
|
938
|
+
* sidebar renders `state.workspaces` and never has to know that a closed workspace exists.
|
|
939
|
+
*/
|
|
940
|
+
clientState() {
|
|
941
|
+
const state = this.snapshot();
|
|
942
|
+
const closed = new Set(state.workspaces.filter((item) => item.closedAt).map((item) => item.id));
|
|
943
|
+
if (!closed.size)
|
|
944
|
+
return state;
|
|
945
|
+
state.workspaces = state.workspaces.filter((item) => !closed.has(item.id));
|
|
946
|
+
state.conversations = state.conversations.filter((item) => !closed.has(item.workspaceId));
|
|
947
|
+
return state;
|
|
850
948
|
}
|
|
851
949
|
async createConversation(workspaceId, configuration) {
|
|
852
950
|
const workspace = this.state.workspaces.find((item) => item.id === workspaceId);
|
package/dist/server/types.d.ts
CHANGED
|
@@ -586,6 +586,12 @@ export type AgentCatalog = {
|
|
|
586
586
|
* the conversation's own field, an existing conversation is therefore immune to later workspace
|
|
587
587
|
* edits, and deleting the workspace default cannot reach back into sessions already born.
|
|
588
588
|
*/
|
|
589
|
+
/**
|
|
590
|
+
* `closedAt` marks a workspace that was REMOVED FROM THE SIDEBAR but is not gone: its conversations
|
|
591
|
+
* are still on disk and still belong to it. The removal is a soft unmount, exactly like a session's
|
|
592
|
+
* `archivedAt` - a state marker, never a deletion - and the marker is what lets the same physical
|
|
593
|
+
* directory be added again later and come back with its history instead of with an empty list.
|
|
594
|
+
*/
|
|
589
595
|
export type Workspace = {
|
|
590
596
|
id: string;
|
|
591
597
|
name: string;
|
|
@@ -594,6 +600,7 @@ export type Workspace = {
|
|
|
594
600
|
defaultModel?: ModelSelection;
|
|
595
601
|
defaultThinkingLevel?: string;
|
|
596
602
|
defaultAgentId?: string;
|
|
603
|
+
closedAt?: string;
|
|
597
604
|
};
|
|
598
605
|
/**
|
|
599
606
|
* Usage accumulated across one sub-agent run. `turns` is the number of finished assistant messages,
|
|
@@ -1469,6 +1476,9 @@ export type ChangedFile = {
|
|
|
1469
1476
|
at: number;
|
|
1470
1477
|
};
|
|
1471
1478
|
export type ServerEvent = {
|
|
1479
|
+
type: "run.stopping";
|
|
1480
|
+
conversationId: string;
|
|
1481
|
+
} | {
|
|
1472
1482
|
type: "tools.updated";
|
|
1473
1483
|
tools: MountedToolSummary[];
|
|
1474
1484
|
}
|
|
@@ -13,6 +13,9 @@ export declare class WsBroadcaster implements EventBroadcaster {
|
|
|
13
13
|
private readonly sockets;
|
|
14
14
|
private readonly coalescedSockets;
|
|
15
15
|
private readonly pendingStreams;
|
|
16
|
+
private cachedState?;
|
|
17
|
+
private cachedStatePayload?;
|
|
18
|
+
private stateFlushScheduled;
|
|
16
19
|
constructor(getState: () => AppState);
|
|
17
20
|
register(socket: WebSocket): void;
|
|
18
21
|
unregister(socket: WebSocket): void;
|
package/dist/server/ws-server.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { WebSocket, WebSocketServer } from "ws";
|
|
2
2
|
import { isAuthorized } from "./http-server.js";
|
|
3
|
+
import { describeThrown, describeThrownWithStack } from "./failure-text.js";
|
|
3
4
|
/**
|
|
4
5
|
* Concrete implementation of EventBroadcaster delivering server events
|
|
5
6
|
* across active WebSocket connections.
|
|
@@ -9,6 +10,9 @@ export class WsBroadcaster {
|
|
|
9
10
|
sockets = new Set();
|
|
10
11
|
coalescedSockets = new Set();
|
|
11
12
|
pendingStreams = new Map();
|
|
13
|
+
cachedState;
|
|
14
|
+
cachedStatePayload;
|
|
15
|
+
stateFlushScheduled = false;
|
|
12
16
|
constructor(getState) {
|
|
13
17
|
this.getState = getState;
|
|
14
18
|
}
|
|
@@ -90,7 +94,23 @@ export class WsBroadcaster {
|
|
|
90
94
|
}
|
|
91
95
|
}
|
|
92
96
|
broadcastState() {
|
|
93
|
-
|
|
97
|
+
if (this.stateFlushScheduled)
|
|
98
|
+
return;
|
|
99
|
+
this.stateFlushScheduled = true;
|
|
100
|
+
queueMicrotask(() => {
|
|
101
|
+
this.stateFlushScheduled = false;
|
|
102
|
+
this.cachedState = this.getState();
|
|
103
|
+
this.cachedStatePayload = JSON.stringify({ type: "state", state: this.cachedState });
|
|
104
|
+
const payload = this.cachedStatePayload;
|
|
105
|
+
for (const socket of this.sockets) {
|
|
106
|
+
if (socket.readyState !== WebSocket.OPEN)
|
|
107
|
+
continue;
|
|
108
|
+
this.flushStreaming(socket);
|
|
109
|
+
socket.send(payload);
|
|
110
|
+
}
|
|
111
|
+
this.cachedState = undefined;
|
|
112
|
+
this.cachedStatePayload = undefined;
|
|
113
|
+
});
|
|
94
114
|
}
|
|
95
115
|
getStateSnapshot() {
|
|
96
116
|
return this.getState();
|
|
@@ -228,7 +248,8 @@ export class WsServer {
|
|
|
228
248
|
parsed = JSON.parse(raw.toString());
|
|
229
249
|
}
|
|
230
250
|
catch {
|
|
231
|
-
|
|
251
|
+
console.error("Super Pi command error: received a frame that is not valid JSON");
|
|
252
|
+
socket.send(JSON.stringify({ type: "error", message: "收到一条无法解析的命令,已忽略。" }));
|
|
232
253
|
return;
|
|
233
254
|
}
|
|
234
255
|
if (parsed.type === "stream.configure") {
|
|
@@ -264,13 +285,21 @@ export class WsServer {
|
|
|
264
285
|
}
|
|
265
286
|
}
|
|
266
287
|
catch (error) {
|
|
288
|
+
// A FAILED COMMAND WRITES ITSELF DOWN BEFORE IT ANSWERS.
|
|
289
|
+
//
|
|
290
|
+
// The server runs detached with its streams thrown away, so `console.error` here is not
|
|
291
|
+
// decoration: the logger tees it into `<dataDir>/logs/server.log`, which is the only place
|
|
292
|
+
// the stack survives. The browser gets the reason (never a stand-in phrase like
|
|
293
|
+
// `command failed`), because the reader is the one who has to act on it.
|
|
294
|
+
console.error("Super Pi command error:", describeThrownWithStack(error));
|
|
295
|
+
const reason = describeThrown(error);
|
|
267
296
|
if (command?.operationRequestId) {
|
|
268
|
-
socket.send(JSON.stringify({ type: "operation.result", requestId: command.operationRequestId, ok: false, message:
|
|
297
|
+
socket.send(JSON.stringify({ type: "operation.result", requestId: command.operationRequestId, ok: false, message: reason }));
|
|
269
298
|
return;
|
|
270
299
|
}
|
|
271
300
|
socket.send(JSON.stringify({
|
|
272
301
|
type: "error",
|
|
273
|
-
message:
|
|
302
|
+
message: reason || "服务端没有说明这次失败的原因。",
|
|
274
303
|
}));
|
|
275
304
|
}
|
|
276
305
|
finally {
|
|
@@ -284,11 +313,11 @@ export class WsServer {
|
|
|
284
313
|
};
|
|
285
314
|
if (runIndependently)
|
|
286
315
|
void execute().catch((error) => {
|
|
287
|
-
console.
|
|
316
|
+
console.error("Super Pi command dispatch failed:", describeThrownWithStack(error));
|
|
288
317
|
});
|
|
289
318
|
else
|
|
290
319
|
commandQueue = commandQueue.then(execute).catch((error) => {
|
|
291
|
-
console.
|
|
320
|
+
console.error("Super Pi command dispatch failed:", describeThrownWithStack(error));
|
|
292
321
|
});
|
|
293
322
|
});
|
|
294
323
|
});
|