@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
@@ -137,7 +137,10 @@ const paths = {
137
137
  const runtime = process.env.SUPER_PI_RUNTIME === "welcome"
138
138
  ? new WelcomeRuntime()
139
139
  : new PiRpcRuntime(join(dataDir, "pi-sessions"), undefined, { dir: initialAgentDirs, promptDir: agentPromptDir }, { endpoint: taskEndpoint, execEndpoint, todoEndpoint, workflowEndpoint, questionEndpoint, toolsEndpoint, documentsEndpoint, scheduledTaskEndpoint, uploadsDir });
140
- const broadcaster = new WsBroadcaster(() => store.snapshot());
140
+ // THE CLIENT GETS THE VISIBLE STATE, NOT THE WHOLE STORE. `clientState()` is `snapshot()` minus the
141
+ // workspaces that were taken out of the sidebar - they are still in the store (that is what lets the
142
+ // same folder be added back with its history), but they are not part of the app any more.
143
+ const broadcaster = new WsBroadcaster(() => store.clientState());
141
144
  const memory = new ActiveMemory(join(dataDir, "memories"), store, runtime);
142
145
  process.env.SUPER_PI_MEMORY_ENDPOINT = `http://${internalHost}:${port}/api/memory/tool`;
143
146
  if (runtime instanceof PiRpcRuntime)
@@ -243,7 +246,7 @@ const nameIndexes = new Map();
243
246
  runService.setNameIndexes(nameIndexes);
244
247
  const conversationService = new ConversationService(store, runtime, subagents, broadcaster, catalogService, runService, extensionUiService, initialAgentDirs);
245
248
  conversationService.setMemory(memory);
246
- const workspaceService = new WorkspaceService(store, runtime, broadcaster, catalogService, runService, extensionUiService, uploadsDir, initialAgentDirs);
249
+ const workspaceService = new WorkspaceService(store, runtime, broadcaster, catalogService, runService, extensionUiService, initialAgentDirs);
247
250
  /**
248
251
  * The terminal, constructed before `serverContext` because that object carries the two predicates
249
252
  * `/api/terminal` answers with.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,27 @@
1
+ import { parentPort } from "node:worker_threads";
2
+ let extractor;
3
+ let loading;
4
+ async function load(request) {
5
+ if (extractor)
6
+ return extractor;
7
+ loading ??= (async () => {
8
+ const specifier = "@huggingface/transformers";
9
+ const transformers = await import(specifier);
10
+ transformers.env.localModelPath = request.modelRoot;
11
+ transformers.env.allowRemoteModels = false;
12
+ extractor = await transformers.pipeline("feature-extraction", request.model, { dtype: "q8" });
13
+ return extractor;
14
+ })();
15
+ return loading;
16
+ }
17
+ parentPort?.on("message", async (request) => {
18
+ try {
19
+ const run = await load(request);
20
+ const output = await run(request.texts, { pooling: "cls", normalize: true });
21
+ const buffer = output.data.buffer;
22
+ parentPort?.postMessage({ id: request.id, data: output.data, dims: output.dims }, [buffer]);
23
+ }
24
+ catch (error) {
25
+ parentPort?.postMessage({ id: request.id, error: error instanceof Error ? error.message : String(error) });
26
+ }
27
+ });
@@ -59,8 +59,10 @@ export type VectorDocument = {
59
59
  */
60
60
  export declare class VectorIndex {
61
61
  private readonly directory;
62
- private extractor?;
63
- private loading?;
62
+ private worker?;
63
+ private workerSequence;
64
+ private workerReady;
65
+ private workerPending;
64
66
  private failure?;
65
67
  private cache;
66
68
  private pending;
@@ -91,7 +93,7 @@ export declare class VectorIndex {
91
93
  * weights turns a failure mode into a file, and makes the feature work on a machine that has never
92
94
  * been online since it was installed.
93
95
  */
94
- private load;
96
+ private ensureWorker;
95
97
  private embed;
96
98
  /** Vectors already stored for one workspace, keyed by document then chunk index. */
97
99
  private read;
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { existsSync } from "node:fs";
3
3
  import { mkdir, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises";
4
4
  import { join, resolve } from "node:path";
5
+ import { Worker } from "node:worker_threads";
5
6
  /**
6
7
  * The model, and the only one this feature can use.
7
8
  *
@@ -78,8 +79,10 @@ export function cosine(a, b) {
78
79
  */
79
80
  export class VectorIndex {
80
81
  directory;
81
- extractor;
82
- loading;
82
+ worker;
83
+ workerSequence = 0;
84
+ workerReady = false;
85
+ workerPending = new Map();
83
86
  failure;
84
87
  cache = new Map();
85
88
  pending = new Set();
@@ -100,7 +103,7 @@ export class VectorIndex {
100
103
  enabled() { return process.env.SUPER_PI_KNOWLEDGE_EMBEDDINGS !== "0"; }
101
104
  status(indexed) {
102
105
  return {
103
- enabled: this.enabled(), ready: Boolean(this.extractor), indexed, error: this.failure,
106
+ enabled: this.enabled(), ready: this.workerReady, indexed, error: this.failure,
104
107
  // Whether the shipped weights are on disk, reported before the model is ever loaded: "the
105
108
  // package was built without its assets" and "the model failed to load" are different problems.
106
109
  bundled: existsSync(this.bundledModel()),
@@ -119,57 +122,56 @@ export class VectorIndex {
119
122
  * weights turns a failure mode into a file, and makes the feature work on a machine that has never
120
123
  * been online since it was installed.
121
124
  */
122
- load() {
123
- if (this.extractor)
124
- return Promise.resolve(this.extractor);
125
+ ensureWorker() {
126
+ if (this.worker)
127
+ return this.worker;
125
128
  if (this.failure)
126
- return Promise.resolve(undefined);
127
- this.loading ??= (async () => {
128
- try {
129
- if (!existsSync(this.bundledModel()))
130
- throw new Error(`随包分发的模型缺失:${this.bundledModel()}`);
131
- // THE SPECIFIER GOES THROUGH A VARIABLE, and that is deliberate. Written as a literal,
132
- // `tsc` resolves it while compiling - so a machine whose optional install skipped this package
133
- // could not build the server at all, which makes an "optional" dependency mandatory in the
134
- // only sense that matters to a person who installed it. Node resolves the variable at RUN time
135
- // instead, where its absence is already handled: the throw below is the same branch that
136
- // handles a missing model.
137
- const specifier = "@huggingface/transformers";
138
- const { pipeline, env } = await import(specifier);
139
- // The library resolves `<localModelPath>/<model id>/onnx/...`, which is why the vendored tree
140
- // keeps the `Xenova/bge-small-zh-v1.5` directory rather than being flattened.
141
- env.localModelPath = this.bundledModel();
142
- env.allowRemoteModels = false;
143
- this.extractor = await pipeline("feature-extraction", KNOWLEDGE_MODEL, { dtype: "q8" });
144
- return this.extractor;
145
- }
146
- catch (error) {
147
- this.failure = error instanceof Error ? error.message : String(error);
148
- console.warn(`Super Pi: 语义检索不可用,将只用关键词检索:${this.failure}`);
149
- return undefined;
150
- }
151
- finally {
152
- this.loading = undefined;
129
+ return undefined;
130
+ if (!existsSync(this.bundledModel())) {
131
+ this.failure = `随包分发的模型缺失:${this.bundledModel()}`;
132
+ return undefined;
133
+ }
134
+ const worker = new Worker(new URL("./knowledge-embedding-worker.js", import.meta.url));
135
+ worker.unref();
136
+ worker.on("message", (message) => {
137
+ const pending = this.workerPending.get(message.id);
138
+ if (!pending)
139
+ return;
140
+ this.workerPending.delete(message.id);
141
+ if (message.error || !message.data || !message.dims) {
142
+ this.failure = message.error ?? "向量 worker 返回了无效结果";
143
+ pending.resolve(null);
144
+ return;
153
145
  }
154
- })();
155
- return this.loading;
146
+ this.workerReady = true;
147
+ const dimension = message.dims.at(-1) ?? 0;
148
+ pending.resolve(Array.from({ length: message.dims[0] ?? 0 }, (_, row) => Array.from(message.data.slice(row * dimension, (row + 1) * dimension))));
149
+ });
150
+ const fail = (error) => {
151
+ this.failure = error.message;
152
+ for (const pending of this.workerPending.values())
153
+ pending.resolve(null);
154
+ this.workerPending.clear();
155
+ this.worker = undefined;
156
+ this.workerReady = false;
157
+ };
158
+ worker.on("error", fail);
159
+ worker.on("exit", (code) => { if (code !== 0)
160
+ fail(new Error(`向量 worker 异常退出(${code})`)); });
161
+ this.worker = worker;
162
+ return worker;
156
163
  }
157
164
  async embed(texts) {
158
165
  if (!texts.length || !this.enabled())
159
166
  return null;
160
- const extractor = await this.load();
161
- if (!extractor)
167
+ const worker = this.ensureWorker();
168
+ if (!worker)
162
169
  return null;
163
- try {
164
- const output = await extractor(texts, { pooling: "cls", normalize: true });
165
- const dimension = output.dims[output.dims.length - 1];
166
- return Array.from({ length: output.dims[0] }, (_, row) => Array.from(output.data.slice(row * dimension, (row + 1) * dimension)));
167
- }
168
- catch (error) {
169
- this.failure = error instanceof Error ? error.message : String(error);
170
- console.warn(`Super Pi: 生成向量失败,将只用关键词检索:${this.failure}`);
171
- return null;
172
- }
170
+ const id = ++this.workerSequence;
171
+ return new Promise((resolvePromise) => {
172
+ this.workerPending.set(id, { resolve: resolvePromise });
173
+ worker.postMessage({ id, texts, modelRoot: this.bundledModel(), model: KNOWLEDGE_MODEL });
174
+ });
173
175
  }
174
176
  /** Vectors already stored for one workspace, keyed by document then chunk index. */
175
177
  async read(workspaceId, documentId) {
@@ -125,6 +125,7 @@ export declare class KnowledgeBase {
125
125
  private indexes;
126
126
  private serial;
127
127
  constructor(directory: string, store: Store, runtime?: KnowledgeRuntime | undefined, vectors?: VectorIndex | undefined);
128
+ private hasWorkspace;
128
129
  /** `SUPER_PI_KNOWLEDGE_ENABLED=0` turns the whole feature off, the same way memory turns off. */
129
130
  enabled(): boolean;
130
131
  /** The opaque workspace identity on disk, encoded. Paths never come from the model or the request. */
@@ -253,13 +253,17 @@ export class KnowledgeBase {
253
253
  this.runtime = runtime;
254
254
  this.vectors = vectors;
255
255
  }
256
+ hasWorkspace(workspaceId) {
257
+ const store = this.store;
258
+ return store.hasWorkspace ? store.hasWorkspace(workspaceId) : store.snapshot().workspaces.some(workspace => workspace.id === workspaceId);
259
+ }
256
260
  /** `SUPER_PI_KNOWLEDGE_ENABLED=0` turns the whole feature off, the same way memory turns off. */
257
261
  enabled() { return process.env.SUPER_PI_KNOWLEDGE_ENABLED !== "0"; }
258
262
  /** The opaque workspace identity on disk, encoded. Paths never come from the model or the request. */
259
263
  folder(workspaceId) { return join(this.directory, Buffer.from(workspaceId).toString("hex")); }
260
264
  file(workspaceId, documentId) { return join(this.folder(workspaceId), `${documentId}.json`); }
261
265
  requireWorkspace(workspaceId) {
262
- if (!this.store.snapshot().workspaces.some(workspace => workspace.id === workspaceId))
266
+ if (!this.hasWorkspace(workspaceId))
263
267
  throw new Error("工作区不存在");
264
268
  }
265
269
  async locked(work) {
@@ -710,7 +714,7 @@ export class KnowledgeBase {
710
714
  const identity = this.capabilities.get(token);
711
715
  // The workspace is re-checked against the store, so a token minted before a workspace was deleted
712
716
  // cannot keep serving its documents afterwards.
713
- if (!identity || !this.store.snapshot().workspaces.some(workspace => workspace.id === identity.workspaceId))
717
+ if (!identity || !this.hasWorkspace(identity.workspaceId))
714
718
  throw new Error("知识库授权已失效");
715
719
  return identity;
716
720
  }
@@ -1,6 +1,5 @@
1
1
  import type { AppState } from "../types.js";
2
2
  import type { AppStateRepository, StateTransaction } from "./app-state-repository.js";
3
- /** SQLite snapshot adapter with row-level writes for streamed messages and runs. */
4
3
  export declare class SqliteStateRepository implements AppStateRepository {
5
4
  private readonly db;
6
5
  constructor(path: string);
@@ -1,5 +1,24 @@
1
1
  import { DatabaseSync } from "node:sqlite";
2
2
  /** SQLite snapshot adapter with row-level writes for streamed messages and runs. */
3
+ /**
4
+ * THE ROW THAT SAYS "THIS CONVERSATION'S ARRAY IS EMPTY".
5
+ *
6
+ * A missing row in `message_order` used to mean two different things: "this conversation was never
7
+ * written incrementally" AND "this conversation's array was emptied". The loader could only read the
8
+ * first one, so a conversation whose LAST message was withdrawn fell back to the base snapshot - and
9
+ * the withdrawn message came back, carrying `delivery: "waiting"`, to be handed to the model by the
10
+ * next turn. A withdrawal is the last act of "take back what I have not sent yet", and it did not
11
+ * survive a restart (ledger TEST-041).
12
+ *
13
+ * A sentinel row says the difference. It is marked by a NEGATIVE POSITION - a real message's
14
+ * position is never negative - so it is never mistaken for one, and the loader reads it as "empty"
15
+ * rather than as "not written". (The marker is the position and NOT the id on purpose: a NUL in the
16
+ * id would be truncated by SQLite's TEXT handling, and the row would stop being recognisable.)
17
+ */
18
+ const EMPTY_ARRAY_POSITION = -1;
19
+ /** The id that goes with the sentinel row. It never becomes a message: the loader drops every row
20
+ * whose position is negative, and this row's position IS negative. */
21
+ const EMPTY_ARRAY_SENTINEL_ID = "empty-array";
3
22
  export class SqliteStateRepository {
4
23
  db;
5
24
  constructor(path) {
@@ -58,7 +77,12 @@ export class SqliteStateRepository {
58
77
  // was last rewritten - the placeholder an assistant message was born with, not what it streamed.
59
78
  const ordered = this.db.prepare("SELECT id,position,json FROM message_order WHERE conversation=? ORDER BY position").all(conversation.id);
60
79
  if (ordered.length) {
61
- conversation.messages = ordered.map((row) => overrides.get(String(row.id)) ?? JSON.parse(String(row.json)));
80
+ // Rows EXIST, so this conversation was written incrementally and these rows are authoritative -
81
+ // including the sentinel, which says the array is empty. The sentinel is not a message and
82
+ // never becomes one.
83
+ conversation.messages = ordered
84
+ .filter((row) => Number(row.position) >= 0)
85
+ .map((row) => overrides.get(String(row.id)) ?? JSON.parse(String(row.json)));
62
86
  }
63
87
  else {
64
88
  if (!Array.isArray(conversation.messages))
@@ -163,6 +187,12 @@ export class SqliteStateRepository {
163
187
  upsertMessage.run(conversationId, message.id, json);
164
188
  insertOrdered.run(conversationId, message.id, position, json);
165
189
  });
190
+ // An EMPTY ARRAY MUST BE WRITTEN DOWN, or "no rows" will read as "never written" and the
191
+ // loader will fall back to the base snapshot - which still holds the message the person
192
+ // just withdrew (ledger TEST-041).
193
+ if (transaction.messages.length === 0) {
194
+ insertOrdered.run(conversationId, EMPTY_ARRAY_SENTINEL_ID, EMPTY_ARRAY_POSITION, "[]");
195
+ }
166
196
  }
167
197
  else {
168
198
  const nextIds = new Set(transaction.messages.map((message) => message.id));
@@ -185,6 +215,12 @@ export class SqliteStateRepository {
185
215
  if (positions.get(message.id) !== position)
186
216
  upsertOrder.run(conversationId, message.id, position, json);
187
217
  });
218
+ // The LAST MESSAGE GONE leaves the order table with no rows, and no rows read as "never
219
+ // written" - so the loader would fall back to the base snapshot and hand the withdrawn
220
+ // message back to the model. The sentinel says the array is empty (ledger TEST-041).
221
+ if (transaction.messages.length === 0) {
222
+ upsertOrder.run(conversationId, EMPTY_ARRAY_SENTINEL_ID, EMPTY_ARRAY_POSITION, "[]");
223
+ }
188
224
  }
189
225
  // The stamp is WRITTEN, not cleared. Opening a turn moves `updatedAt`, and this is the only write
190
226
  // that can record it; clearing the overlay instead would leave the reopened conversation reporting
@@ -284,3 +284,16 @@ export declare function fetchRemoteModels(input: {
284
284
  apiKey?: string;
285
285
  api?: string;
286
286
  }): Promise<DiscoveredModel[]>;
287
+ /**
288
+ * THE ANSWER, READ.
289
+ *
290
+ * Split out of `fetchRemoteModels` so that the part worth asserting - what a provider's answer MEANS -
291
+ * can be tested without a network in between. A remote provider answers in three shapes (a bare array,
292
+ * `{data: [...]}` for OpenAI, `{models: [...]}` for Ollama), and each entry names itself with `id`,
293
+ * `name` or `model`; what the panel shows next to it - whether the model reasons, whether it takes
294
+ * images - is inferred from the name, because that is all most providers tell us.
295
+ *
296
+ * `fetchRemoteModels` is the transport; this is the reading. Keeping them apart means the reading can
297
+ * be pinned exactly, and a slow machine can never turn a parsing question into a timeout.
298
+ */
299
+ export declare function parseRemoteModels(payload: unknown): DiscoveredModel[];
@@ -741,6 +741,7 @@ export async function fetchRemoteModels(input) {
741
741
  candidates.push(`${cleanBase}/v1/models`, `${cleanBase}/models`);
742
742
  }
743
743
  let lastError = "";
744
+ let lastCause;
744
745
  let payload = null;
745
746
  for (const candidate of candidates) {
746
747
  try {
@@ -758,11 +759,35 @@ export async function fetchRemoteModels(input) {
758
759
  }
759
760
  catch (error) {
760
761
  lastError = error instanceof Error ? error.message : String(error);
762
+ lastCause = error;
761
763
  }
762
764
  }
763
765
  if (!payload) {
764
- throw new Error(`无法从远程服务获取模型列表 (${lastError || "请求超时或无法连接"})`);
765
- }
766
+ // THE CAUSE, NOT JUST "fetch failed". `fetch` reports every transport-level problem - a refused
767
+ // connection, a reset, an aborted timeout - as the same one-word "fetch failed", so a user staring
768
+ // at 「无法从远程服务获取模型列表 (fetch failed)」 learns nothing and neither does the log. The
769
+ // cause is where the truth is: "The operation was aborted due to timeout" and "connect
770
+ // ECONNREFUSED" are two different problems with two different fixes.
771
+ const cause = lastCause instanceof Error
772
+ ? (lastCause.cause instanceof Error ? lastCause.cause.message : lastCause.cause instanceof Object && lastCause.cause !== null ? String(lastCause.cause.code ?? "") : undefined)
773
+ : undefined;
774
+ throw new Error(`无法从远程服务获取模型列表 (${lastError || "请求超时或无法连接"}${cause ? `:${cause}` : ""})`);
775
+ }
776
+ return parseRemoteModels(payload);
777
+ }
778
+ /**
779
+ * THE ANSWER, READ.
780
+ *
781
+ * Split out of `fetchRemoteModels` so that the part worth asserting - what a provider's answer MEANS -
782
+ * can be tested without a network in between. A remote provider answers in three shapes (a bare array,
783
+ * `{data: [...]}` for OpenAI, `{models: [...]}` for Ollama), and each entry names itself with `id`,
784
+ * `name` or `model`; what the panel shows next to it - whether the model reasons, whether it takes
785
+ * images - is inferred from the name, because that is all most providers tell us.
786
+ *
787
+ * `fetchRemoteModels` is the transport; this is the reading. Keeping them apart means the reading can
788
+ * be pinned exactly, and a slow machine can never turn a parsing question into a timeout.
789
+ */
790
+ export function parseRemoteModels(payload) {
766
791
  const rawList = Array.isArray(payload)
767
792
  ? payload
768
793
  : Array.isArray(payload.data)
@@ -941,7 +941,46 @@ export class PiRpcRuntime {
941
941
  const promptOptions = images?.length
942
942
  ? { images: images, preflightResult: () => traceLatency(conversation.id, "session.prompt.accepted", latencyStartedAt, promptSize()) }
943
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
+ }
944
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
+ }
945
984
  // `prompt()` RETURNING IS NOT THE TURN ENDING. Pi's `prompt` resolves when the message has
946
985
  // been ACCEPTED and the run has been started; the agent is still streaming, still executing
947
986
  // tools, still settling. A turn that ended here would release the reader while the model was
@@ -1437,6 +1476,13 @@ export class PiRpcRuntime {
1437
1476
  const imagePayloads = this.options.uploadsDir && images?.length
1438
1477
  ? await loadConversationImages(this.options.uploadsDir, conversation.id, images)
1439
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;
1440
1486
  channel.write({ id: "prompt", type: "prompt", message: prompt, ...(imagePayloads?.length ? { images: imagePayloads } : {}) });
1441
1487
  traceLatency(conversation.id, "prompt.sent", latencyStartedAt);
1442
1488
  let settled = false;
@@ -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;