@evandu/super-pi 0.1.133 → 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.
Files changed (55) hide show
  1. package/dist/.super-pi-check-inputs.json +2 -2
  2. package/dist/client/.super-pi-client-sources.json +6 -6
  3. package/dist/client/assets/{CodeMirrorEditor-CZZfGD0U.js → CodeMirrorEditor-D6awRUZO.js} +2 -2
  4. package/dist/client/assets/{dist-azDN8a65.js → dist-1jSyapdW.js} +1 -1
  5. package/dist/client/assets/{dist-CE5BnwJa.js → dist-BGgidsaF.js} +1 -1
  6. package/dist/client/assets/{dist-DOYWHMIq.js → dist-BYMYC_2I.js} +1 -1
  7. package/dist/client/assets/{dist-CYD2SqmG.js → dist-BkVaPA_q.js} +1 -1
  8. package/dist/client/assets/{dist-CxsunljU.js → dist-BoeM5xW7.js} +1 -1
  9. package/dist/client/assets/{dist-D4x1XTQb.js → dist-C46474ys.js} +1 -1
  10. package/dist/client/assets/{dist-BJinarhP.js → dist-CCkdCH6a.js} +1 -1
  11. package/dist/client/assets/{dist-DQf5nNxa.js → dist-CNuKauOq.js} +1 -1
  12. package/dist/client/assets/{dist-CApAttfj.js → dist-CubYGjAk.js} +1 -1
  13. package/dist/client/assets/{dist-0FH-oFco.js → dist-Cwb-Lb5-.js} +1 -1
  14. package/dist/client/assets/{dist-CFPXj4ws.js → dist-D0Iw5XQA.js} +1 -1
  15. package/dist/client/assets/{dist-CUKaDTyO.js → dist-DCiuVvGU.js} +1 -1
  16. package/dist/client/assets/{dist-B5ruVtUG.js → dist-DHBWUQhM.js} +1 -1
  17. package/dist/client/assets/{dist-BtTFCNVQ.js → dist-DOwrSiuY.js} +1 -1
  18. package/dist/client/assets/{dist-4Pts9xPO.js → dist-DeZsNbFx.js} +1 -1
  19. package/dist/client/assets/{dist-aL2NGji_.js → dist-Dk3e6RfY.js} +1 -1
  20. package/dist/client/assets/{dist-LFfokYJs.js → dist-DoDmoWTn.js} +1 -1
  21. package/dist/client/assets/{dist-DbbBgSwL.js → dist-WkVUFX28.js} +1 -1
  22. package/dist/client/assets/{dist-C2nuBCP4.js → dist-lZUWPQbT.js} +1 -1
  23. package/dist/client/assets/{dist-DHqqpxo4.js → dist-mPR8uuzk.js} +1 -1
  24. package/dist/client/assets/{dist-fYp8Xwrx.js → dist-spB2Q2CG.js} +1 -1
  25. package/dist/client/assets/{dist-BffE00vI.js → dist-yTTCht0J.js} +1 -1
  26. package/dist/client/assets/{index-YEN4ysHF.js → index-Dyta-HkQ.js} +37 -37
  27. package/dist/client/assets/{index-D3MEG8uH.css → index-V93Kzott.css} +1 -1
  28. package/dist/client/index.html +2 -2
  29. package/dist/server/active-memory.d.ts +1 -0
  30. package/dist/server/active-memory.js +6 -2
  31. package/dist/server/application/run-service.d.ts +4 -0
  32. package/dist/server/application/run-service.js +114 -93
  33. package/dist/server/application/workspace-service.d.ts +8 -4
  34. package/dist/server/application/workspace-service.js +11 -10
  35. package/dist/server/failure-text.d.ts +18 -0
  36. package/dist/server/failure-text.js +44 -0
  37. package/dist/server/http-server.js +16 -5
  38. package/dist/server/index.js +5 -2
  39. package/dist/server/knowledge-embedding-worker.d.ts +1 -0
  40. package/dist/server/knowledge-embedding-worker.js +27 -0
  41. package/dist/server/knowledge-vectors.d.ts +5 -3
  42. package/dist/server/knowledge-vectors.js +49 -47
  43. package/dist/server/knowledge.d.ts +1 -0
  44. package/dist/server/knowledge.js +6 -2
  45. package/dist/server/persistence/sqlite-state-repository.d.ts +0 -1
  46. package/dist/server/persistence/sqlite-state-repository.js +37 -1
  47. package/dist/server/providers.d.ts +13 -0
  48. package/dist/server/providers.js +27 -2
  49. package/dist/server/runtime.js +46 -0
  50. package/dist/server/store.d.ts +61 -0
  51. package/dist/server/store.js +112 -14
  52. package/dist/server/types.d.ts +10 -0
  53. package/dist/server/ws-server.d.ts +3 -0
  54. package/dist/server/ws-server.js +35 -6
  55. package/package.json +16 -2
@@ -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
- if (!this.state.workspaces.some((item) => item.id === workspaceId))
919
+ const workspace = this.state.workspaces.find((item) => item.id === workspaceId);
920
+ if (!workspace)
835
921
  throw new Error("找不到这个工作区。");
836
- // Deleting the LAST workspace is allowed: a fresh store starts with zero (see `openRepository`), so
837
- // "at least one" was an artifact of the old first-run seed, and the client's zero-workspace empty
838
- // state is the onboarding path back in.
839
- // Cascade: workspaces and conversations live in one state.json, so this single atomic persist removes
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 [...doomed];
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);
@@ -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;
@@ -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
- this.broadcast({ type: "state", state: this.getState() });
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
- socket.send(JSON.stringify({ type: "error", message: "command failed" }));
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: error instanceof Error ? error.message : String(error) }));
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: error instanceof Error ? error.message : "command failed",
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.warn(`Super Pi: command dispatch failed: ${error instanceof Error ? error.message : "unknown error"}`);
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.warn(`Super Pi: command dispatch failed: ${error instanceof Error ? error.message : "unknown error"}`);
320
+ console.error("Super Pi command dispatch failed:", describeThrownWithStack(error));
292
321
  });
293
322
  });
294
323
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.133",
3
+ "version": "0.1.135",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -85,10 +85,17 @@
85
85
  "scripts/workflow-ui.test.mjs",
86
86
  "scripts/agent-vis-records.test.mjs",
87
87
  "scripts/queue-store.test.mjs",
88
+ "scripts/git-api.test.mjs",
89
+ "scripts/turn-failure-marks-failed.test.mjs",
90
+ "scripts/update-ui-contract.test.mjs",
91
+ "scripts/temp-sweep.test.mjs",
92
+ "scripts/workspace-identity.test.mjs",
88
93
  "scripts/followup.test.mjs",
89
94
  "scripts/pi-followup-queue.test.mjs",
90
95
  "scripts/background-result-delivery.test.mjs",
96
+ "scripts/background-result-one-per-turn.test.mjs",
91
97
  "scripts/thinking-status-promotion.test.mjs",
98
+ "scripts/thinking-delivery-once.test.mjs",
92
99
  "scripts/question-call-session-path.test.mjs",
93
100
  "scripts/withdrawn-message-persistence.test.mjs",
94
101
  "scripts/git-write-endpoint-guard.test.mjs",
@@ -117,6 +124,7 @@
117
124
  "scripts/scoped-sidebar-tabs.test.mjs",
118
125
  "scripts/send-retry.test.mjs",
119
126
  "scripts/error-messages.test.mjs",
127
+ "scripts/stop-before-prompt.test.mjs",
120
128
  "scripts/git-diff-view.test.mjs",
121
129
  "scripts/git-blame.test.mjs",
122
130
  "scripts/fork-server.test.mjs",
@@ -157,7 +165,9 @@
157
165
  "scripts/feishu.test.mjs",
158
166
  "scripts/scheduled-tasks.test.mjs",
159
167
  "scripts/scheduled-task-scope.test.mjs",
160
- "scripts/scheduled-task-tool.test.mjs"
168
+ "scripts/scheduled-task-tool.test.mjs",
169
+ "scripts/run-state-sync.test.mjs",
170
+ "scripts/performance-architecture.test.mjs"
161
171
  ],
162
172
  "check:browser": [
163
173
  "scripts/interrupt-browser.test.mjs",
@@ -178,10 +188,14 @@
178
188
  "scripts/md-render-browser.test.mjs",
179
189
  "scripts/right-sidebar-browser.test.mjs",
180
190
  "scripts/rail-workspace-follows-session-browser.test.mjs",
191
+ "scripts/git-tab-browser.test.mjs",
192
+ "scripts/workspace-reopen-browser.test.mjs",
181
193
  "scripts/sidebar-tools-browser.test.mjs",
182
194
  "scripts/tool-runtime-browser.test.mjs",
183
195
  "scripts/session-context-menu-browser.test.mjs",
184
196
  "scripts/tool-call-blocks-browser.test.mjs",
197
+ "scripts/assistant-working-indicator-browser.test.mjs",
198
+ "scripts/thinking-first-delta-once-browser.test.mjs",
185
199
  "scripts/files-tab-browser.test.mjs",
186
200
  "scripts/editor-blame-browser.test.mjs",
187
201
  "scripts/file-panel-external-poll-browser.test.mjs",