@evandu/super-pi 0.1.135 → 0.1.137

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 (44) hide show
  1. package/dist/.super-pi-check-inputs.json +2 -2
  2. package/dist/client/.super-pi-client-sources.json +5 -5
  3. package/dist/client/assets/{CodeMirrorEditor-D6awRUZO.js → CodeMirrorEditor-CkA_5D5r.js} +2 -2
  4. package/dist/client/assets/{dist-BGgidsaF.js → dist-9Fm8XKu4.js} +1 -1
  5. package/dist/client/assets/{dist-spB2Q2CG.js → dist-B02Erd0t.js} +1 -1
  6. package/dist/client/assets/{dist-DoDmoWTn.js → dist-B3Mfuv1D.js} +1 -1
  7. package/dist/client/assets/{dist-yTTCht0J.js → dist-BfEmujEC.js} +1 -1
  8. package/dist/client/assets/{dist-WkVUFX28.js → dist-BqGjijI6.js} +1 -1
  9. package/dist/client/assets/{dist-DOwrSiuY.js → dist-BsqzrZ5R.js} +1 -1
  10. package/dist/client/assets/{dist-BYMYC_2I.js → dist-BySjislr.js} +1 -1
  11. package/dist/client/assets/{dist-Cwb-Lb5-.js → dist-C2tSd-AY.js} +1 -1
  12. package/dist/client/assets/{dist-DCiuVvGU.js → dist-CrxmpY93.js} +1 -1
  13. package/dist/client/assets/{dist-1jSyapdW.js → dist-CsNytPug.js} +1 -1
  14. package/dist/client/assets/{dist-mPR8uuzk.js → dist-D-LtG92p.js} +1 -1
  15. package/dist/client/assets/{dist-DeZsNbFx.js → dist-DGhdkud4.js} +1 -1
  16. package/dist/client/assets/{dist-BkVaPA_q.js → dist-DL4Q05ts.js} +1 -1
  17. package/dist/client/assets/{dist-CNuKauOq.js → dist-D_umYm0o.js} +1 -1
  18. package/dist/client/assets/{dist-CubYGjAk.js → dist-DbovsYeH.js} +1 -1
  19. package/dist/client/assets/{dist-C46474ys.js → dist-DcdzfM-n.js} +1 -1
  20. package/dist/client/assets/{dist-BoeM5xW7.js → dist-DdVs8Y0X.js} +1 -1
  21. package/dist/client/assets/{dist-DHBWUQhM.js → dist-MC2XuZgq.js} +1 -1
  22. package/dist/client/assets/{dist-D0Iw5XQA.js → dist-b9CfAgzG.js} +1 -1
  23. package/dist/client/assets/{dist-lZUWPQbT.js → dist-j9X7j381.js} +1 -1
  24. package/dist/client/assets/{dist-CCkdCH6a.js → dist-k6bOz9RS.js} +1 -1
  25. package/dist/client/assets/{dist-Dk3e6RfY.js → dist-ufRHdQ8G.js} +1 -1
  26. package/dist/client/assets/{index-Dyta-HkQ.js → index-DVmE8H4Y.js} +12 -12
  27. package/dist/client/assets/index-FxTrUapG.css +1 -0
  28. package/dist/client/index.html +2 -2
  29. package/dist/server/active-memory.d.ts +17 -2
  30. package/dist/server/active-memory.js +76 -8
  31. package/dist/server/application/catalog-service.js +8 -6
  32. package/dist/server/application/conversation-service.js +4 -2
  33. package/dist/server/application/scheduled-task-service.d.ts +7 -0
  34. package/dist/server/application/scheduled-task-service.js +14 -5
  35. package/dist/server/files.js +11 -1
  36. package/dist/server/http-server.d.ts +0 -3
  37. package/dist/server/http-server.js +131 -87
  38. package/dist/server/model-filter.d.ts +13 -3
  39. package/dist/server/model-filter.js +7 -5
  40. package/dist/server/store.d.ts +27 -0
  41. package/dist/server/store.js +44 -0
  42. package/dist/server/subagents.js +10 -2
  43. package/package.json +9 -1
  44. package/dist/client/assets/index-V93Kzott.css +0 -1
@@ -230,6 +230,47 @@ function abortedAs(request) {
230
230
  /**
231
231
  * Creates the HTTP server providing static client assets and all `/api/*` endpoints.
232
232
  */
233
+ /**
234
+ * A REQUEST BODY BEING READ AS TEXT, decoded across chunk boundaries.
235
+ *
236
+ * The counterpart of `readRequestText` for the sites that need to inspect the body WHILE it streams
237
+ * (the file uploads, which refuse an oversized body without reading the rest of it).
238
+ */
239
+ function textBodyReader() {
240
+ const decoder = new TextDecoder("utf-8");
241
+ let text = "";
242
+ return {
243
+ push: (chunk) => { text += decoder.decode(chunk, { stream: true }); return text; },
244
+ finish: () => text + decoder.decode(),
245
+ };
246
+ }
247
+ /**
248
+ * THE REQUEST BODY AS TEXT, DECODED ACROSS CHUNK BOUNDARIES.
249
+ *
250
+ * `raw += chunk` decodes EVERY CHUNK ON ITS OWN: the chunk is a Buffer, and concatenating it into a
251
+ * string calls `toString("utf8")` on it. A UTF-8 character that straddles two chunks is then two
252
+ * half-characters, and each half decodes to U+FFFD - the replacement character. So a Chinese
253
+ * sentence that happens to be split in the wrong place is silently corrupted, and the corrupted text
254
+ * is what the endpoint PERSISTS (a knowledge note, a memory, a scheduled task). The corruption is
255
+ * invisible - the JSON still parses - and permanent.
256
+ *
257
+ * A `TextDecoder` in streaming mode keeps the incomplete tail of a chunk and completes it with the
258
+ * next one, which is what "the body is one UTF-8 document" means. The size guards count BYTES, which
259
+ * is what a body limit is about anyway.
260
+ */
261
+ async function readRequestText(request, limitBytes) {
262
+ const decoder = new TextDecoder("utf-8");
263
+ let text = "";
264
+ let bytes = 0;
265
+ for await (const chunk of request) {
266
+ const piece = chunk;
267
+ bytes += piece.length;
268
+ if (bytes > limitBytes)
269
+ return { text: "", tooLarge: true };
270
+ text += decoder.decode(piece, { stream: true });
271
+ }
272
+ return { text: text + decoder.decode(), tooLarge: false };
273
+ }
233
274
  export function createHttpServer(options) {
234
275
  const { context, onShutdown } = options;
235
276
  const { store, paths, accessToken, broadcaster, runService, catalogService, execService, toolRegistry, toolRunner } = context;
@@ -320,16 +361,12 @@ export function createHttpServer(options) {
320
361
  // BYTES, NOT CHARACTERS, AND FROM THE REGISTRY'S OWN CONSTANT. The old cap counted UTF-16 code
321
362
  // units at 256,000, below the registry's own 512,000-byte asset limit, so JSON escaping decided
322
363
  // where the real ceiling was and an exported bundle could be refused on the way back in.
323
- let raw = "", bytes = 0;
324
- for await (const chunk of request) {
325
- const piece = chunk;
326
- bytes += piece.length;
327
- if (bytes > MAX_TOOL_BODY_BYTES) {
328
- json(413, { error: "导入的内容太大,超过了大小上限。" });
329
- return;
330
- }
331
- raw += piece.toString("utf8");
364
+ const imported = await readRequestText(request, MAX_TOOL_BODY_BYTES);
365
+ if (imported.tooLarge) {
366
+ json(413, { error: "导入的内容太大,超过了大小上限。" });
367
+ return;
332
368
  }
369
+ const raw = imported.text;
333
370
  const body = JSON.parse(raw);
334
371
  if (!isAuthorized(request, accessToken)) {
335
372
  if (!body.conversationId) {
@@ -404,14 +441,12 @@ export function createHttpServer(options) {
404
441
  toolApi(405, { error: "method" });
405
442
  return;
406
443
  }
407
- let raw = "";
408
- for await (const chunk of request) {
409
- raw += chunk;
410
- if (raw.length > 64_000) {
411
- toolApi(413, { error: "request too large" });
412
- return;
413
- }
444
+ const executed = await readRequestText(request, 64_000);
445
+ if (executed.tooLarge) {
446
+ toolApi(413, { error: "request too large" });
447
+ return;
414
448
  }
449
+ const raw = executed.text;
415
450
  try {
416
451
  const body = JSON.parse(raw);
417
452
  const resolved = await toolRegistry.resolve(id);
@@ -501,14 +536,12 @@ export function createHttpServer(options) {
501
536
  toolApi(405, { error: "method" });
502
537
  return;
503
538
  }
504
- let raw = "";
505
- for await (const chunk of request) {
506
- raw += chunk;
507
- if (raw.length > 64_000) {
508
- toolApi(413, { error: "request too large" });
509
- return;
510
- }
539
+ const asked = await readRequestText(request, 64_000);
540
+ if (asked.tooLarge) {
541
+ toolApi(413, { error: "request too large" });
542
+ return;
511
543
  }
544
+ const raw = asked.text;
512
545
  try {
513
546
  const body = JSON.parse(raw);
514
547
  toolApi(202, context.toolLlm.start({ toolId: id, prompt: body.prompt ?? "", system: body.system, timeoutMs: body.timeoutMs }));
@@ -598,14 +631,10 @@ export function createHttpServer(options) {
598
631
  return;
599
632
  }
600
633
  try {
601
- let raw = "";
602
- let totalBytes = 0;
603
- for await (const chunk of request) {
604
- totalBytes += chunk.length;
605
- if (totalBytes > 512000)
606
- throw new Error("记忆请求过大");
607
- raw += chunk;
608
- }
634
+ const remembered = await readRequestText(request, 512000);
635
+ if (remembered.tooLarge)
636
+ throw new Error("记忆请求过大");
637
+ const raw = remembered.text;
609
638
  const body = raw ? JSON.parse(raw) : {};
610
639
  const result = url.pathname.endsWith("/tool")
611
640
  ? await context.memory.tool(String(request.headers["x-memory-capability"] ?? ""), body)
@@ -634,9 +663,10 @@ export function createHttpServer(options) {
634
663
  let name = url.searchParams.get("name") ?? url.searchParams.get("skillId") ?? "";
635
664
  if (request.method === "POST") {
636
665
  try {
637
- let raw = "";
638
- for await (const chunk of request)
639
- raw += chunk;
666
+ const asked = await readRequestText(request, 512000);
667
+ if (asked.tooLarge)
668
+ throw new Error("记忆请求过大");
669
+ const raw = asked.text;
640
670
  if (raw) {
641
671
  const body = JSON.parse(raw);
642
672
  if (body.workspaceId)
@@ -673,9 +703,12 @@ export function createHttpServer(options) {
673
703
  return;
674
704
  }
675
705
  try {
676
- let raw = "";
677
- for await (const chunk of request)
678
- raw += chunk;
706
+ const asked = await readRequestText(request, 512000);
707
+ if (asked.tooLarge) {
708
+ json(413, { error: "请求过大" });
709
+ return;
710
+ }
711
+ const raw = asked.text;
679
712
  const body = raw ? JSON.parse(raw) : {};
680
713
  const models = await context.providerService.fetchModels(body);
681
714
  json(200, { ok: true, models });
@@ -704,14 +737,12 @@ export function createHttpServer(options) {
704
737
  let totalBytes = 0;
705
738
  // A pasted document rides in the body, so the cap is the document cap - past it the request is
706
739
  // refused outright rather than truncated into a document that silently lost its tail.
707
- for await (const chunk of request) {
708
- totalBytes += chunk.length;
709
- if (totalBytes > MAX_KNOWLEDGE_REQUEST_BYTES) {
710
- response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "知识库请求过大" }));
711
- return;
712
- }
713
- raw += chunk;
740
+ const known = await readRequestText(request, MAX_KNOWLEDGE_REQUEST_BYTES);
741
+ if (known.tooLarge) {
742
+ response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "知识库请求过大" }));
743
+ return;
714
744
  }
745
+ raw = known.text;
715
746
  const body = raw ? JSON.parse(raw) : {};
716
747
  const result = url.pathname.endsWith("/tool")
717
748
  ? await context.knowledge.tool(String(request.headers["x-knowledge-capability"] ?? ""), body, abortedAs(request))
@@ -743,14 +774,12 @@ export function createHttpServer(options) {
743
774
  // A DOCUMENT'S TEXT RIDES IN THE REQUEST BODY, so the cap is the document cap: a request
744
775
  // past it is refused outright rather than truncated into a document that silently lost its
745
776
  // tail.
746
- for await (const chunk of request) {
747
- totalBytes += chunk.length;
748
- if (totalBytes > MAX_DOCUMENT_REQUEST_BYTES) {
749
- response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "文档请求过大" }));
750
- return;
751
- }
752
- raw += chunk;
777
+ const documented = await readRequestText(request, MAX_DOCUMENT_REQUEST_BYTES);
778
+ if (documented.tooLarge) {
779
+ response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "文档请求过大" }));
780
+ return;
753
781
  }
782
+ raw = documented.text;
754
783
  const body = raw ? JSON.parse(raw) : {};
755
784
  // The capability says WHICH CONVERSATION is asking, and the workspace is derived from it
756
785
  // rather than taken from the request: a request that named its own workspace would be a
@@ -787,14 +816,12 @@ export function createHttpServer(options) {
787
816
  }
788
817
  try {
789
818
  let raw = "", totalBytes = 0;
790
- for await (const chunk of request) {
791
- totalBytes += chunk.length;
792
- if (totalBytes > MAX_WEB_REQUEST_BYTES) {
793
- response.writeHead(413).end();
794
- return;
795
- }
796
- raw += chunk;
819
+ const asked = await readRequestText(request, MAX_WEB_REQUEST_BYTES);
820
+ if (asked.tooLarge) {
821
+ response.writeHead(413).end();
822
+ return;
797
823
  }
824
+ raw = asked.text;
798
825
  const conversationId = String(request.headers["x-web-conversation"] ?? "");
799
826
  const verdict = verifyCapability(conversationId, request.headers["x-web-capability"], { running: context.runService.isRunning(conversationId) });
800
827
  if (!verdict.ok) {
@@ -831,14 +858,12 @@ export function createHttpServer(options) {
831
858
  try {
832
859
  let raw = "", totalBytes = 0;
833
860
  // The Markdown of a document rides in the request body, so the cap is the document cap.
834
- for await (const chunk of request) {
835
- totalBytes += chunk.length;
836
- if (totalBytes > MAX_FEISHU_REQUEST_BYTES) {
837
- response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "飞书请求过大" }));
838
- return;
839
- }
840
- raw += chunk;
861
+ const asked = await readRequestText(request, MAX_FEISHU_REQUEST_BYTES);
862
+ if (asked.tooLarge) {
863
+ response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "飞书请求过大" }));
864
+ return;
841
865
  }
866
+ raw = asked.text;
842
867
  const conversationId = String(request.headers["x-feishu-conversation"] ?? "");
843
868
  // THE SAME PROOF THE DOCUMENT TOOLS USE: the request must carry the session's own
844
869
  // capability, and the session must actually be running a turn. The Feishu credential is a
@@ -1050,9 +1075,12 @@ export function createHttpServer(options) {
1050
1075
  json(403, { error: "Git 写操作只允许来自本机或局域网的请求。" });
1051
1076
  return;
1052
1077
  }
1053
- let raw = "";
1054
- for await (const chunk of request)
1055
- raw += chunk;
1078
+ const asked = await readRequestText(request, 512000);
1079
+ if (asked.tooLarge) {
1080
+ json(413, { error: "请求过大" });
1081
+ return;
1082
+ }
1083
+ const raw = asked.text;
1056
1084
  const body = raw ? JSON.parse(raw) : {};
1057
1085
  json(200, await gitMutate(workspace.path, op, body.path, body.message));
1058
1086
  return;
@@ -1113,15 +1141,18 @@ export function createHttpServer(options) {
1113
1141
  }
1114
1142
  let raw = "";
1115
1143
  let totalBytes = 0;
1144
+ const reader = textBodyReader();
1116
1145
  for await (const chunk of request) {
1117
- totalBytes += chunk.length;
1146
+ const piece = chunk;
1147
+ totalBytes += piece.length;
1118
1148
  if (totalBytes > MAX_FILE_BYTES * 2) {
1119
1149
  response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "请求体过大", code: "too_large" }));
1120
1150
  request.destroy();
1121
1151
  return;
1122
1152
  }
1123
- raw += chunk;
1153
+ raw = reader.push(piece);
1124
1154
  }
1155
+ raw = reader.finish();
1125
1156
  const json = (status, body) => {
1126
1157
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
1127
1158
  };
@@ -1151,15 +1182,18 @@ export function createHttpServer(options) {
1151
1182
  }
1152
1183
  let raw = "";
1153
1184
  let totalBytes = 0;
1185
+ const reader = textBodyReader();
1154
1186
  for await (const chunk of request) {
1155
- totalBytes += chunk.length;
1187
+ const piece = chunk;
1188
+ totalBytes += piece.length;
1156
1189
  if (totalBytes > MAX_FILE_BYTES * 2) {
1157
1190
  response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "请求体过大", code: "too_large" }));
1158
1191
  request.destroy();
1159
1192
  return;
1160
1193
  }
1161
- raw += chunk;
1194
+ raw = reader.push(piece);
1162
1195
  }
1196
+ raw = reader.finish();
1163
1197
  const json = (status, body) => {
1164
1198
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
1165
1199
  };
@@ -1234,15 +1268,18 @@ export function createHttpServer(options) {
1234
1268
  }
1235
1269
  let raw = "";
1236
1270
  let totalBytes = 0;
1271
+ const reader = textBodyReader();
1237
1272
  for await (const chunk of request) {
1238
- totalBytes += chunk.length;
1273
+ const piece = chunk;
1274
+ totalBytes += piece.length;
1239
1275
  if (totalBytes > MAX_FILE_BYTES * 2) {
1240
1276
  response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "请求体过大", code: "too_large" }));
1241
1277
  request.destroy();
1242
1278
  return;
1243
1279
  }
1244
- raw += chunk;
1280
+ raw = reader.push(piece);
1245
1281
  }
1282
+ raw = reader.finish();
1246
1283
  const json = (status, body) => {
1247
1284
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
1248
1285
  };
@@ -1262,15 +1299,18 @@ export function createHttpServer(options) {
1262
1299
  }
1263
1300
  let raw = "";
1264
1301
  let totalBytes = 0;
1302
+ const reader = textBodyReader();
1265
1303
  for await (const chunk of request) {
1266
- totalBytes += chunk.length;
1304
+ const piece = chunk;
1305
+ totalBytes += piece.length;
1267
1306
  if (totalBytes > MAX_FILE_BYTES * 2) {
1268
1307
  response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "请求体过大", code: "too_large" }));
1269
1308
  request.destroy();
1270
1309
  return;
1271
1310
  }
1272
- raw += chunk;
1311
+ raw = reader.push(piece);
1273
1312
  }
1313
+ raw = reader.finish();
1274
1314
  const json = (status, body) => {
1275
1315
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
1276
1316
  };
@@ -1303,15 +1343,18 @@ export function createHttpServer(options) {
1303
1343
  }
1304
1344
  let raw = "";
1305
1345
  let totalBytes = 0;
1346
+ const reader = textBodyReader();
1306
1347
  for await (const chunk of request) {
1307
- totalBytes += chunk.length;
1348
+ const piece = chunk;
1349
+ totalBytes += piece.length;
1308
1350
  if (totalBytes > MAX_FILE_BYTES * 2) {
1309
1351
  response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "请求体过大", code: "too_large" }));
1310
1352
  request.destroy();
1311
1353
  return;
1312
1354
  }
1313
- raw += chunk;
1355
+ raw = reader.push(piece);
1314
1356
  }
1357
+ raw = reader.finish();
1315
1358
  const json = (status, body) => {
1316
1359
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
1317
1360
  };
@@ -1407,14 +1450,12 @@ export function createHttpServer(options) {
1407
1450
  // body cost its full size in memory before the 403 said no. A question is a few hundred bytes;
1408
1451
  // 64KB is orders of magnitude above any honest one and far below what a hostile body could use,
1409
1452
  // and the shape is the same ceiling the `/api/tools` endpoints already enforce.
1410
- let raw = "";
1411
- for await (const chunk of request) {
1412
- raw += chunk;
1413
- if (raw.length > 64_000) {
1414
- response.writeHead(413, { "content-type": "application/json" }).end(JSON.stringify({ error: "question request too large" }));
1415
- return;
1416
- }
1453
+ const asked = await readRequestText(request, 64_000);
1454
+ if (asked.tooLarge) {
1455
+ response.writeHead(413, { "content-type": "application/json" }).end(JSON.stringify({ error: "question request too large" }));
1456
+ return;
1417
1457
  }
1458
+ const raw = asked.text;
1418
1459
  try {
1419
1460
  const body = JSON.parse(raw);
1420
1461
  if (!body.conversationId || !body.question || !Array.isArray(body.options))
@@ -1633,15 +1674,18 @@ export function createHttpServer(options) {
1633
1674
  }
1634
1675
  let raw = "";
1635
1676
  let totalBytes = 0;
1677
+ const reader = textBodyReader();
1636
1678
  for await (const chunk of request) {
1637
- totalBytes += chunk.length;
1679
+ const piece = chunk;
1680
+ totalBytes += piece.length;
1638
1681
  if (totalBytes > MAX_FILE_BYTES * 2) {
1639
1682
  json(413, { error: "请求体过大", code: "too_large" });
1640
1683
  request.destroy();
1641
1684
  return;
1642
1685
  }
1643
- raw += chunk;
1686
+ raw = reader.push(piece);
1644
1687
  }
1688
+ raw = reader.finish();
1645
1689
  const declared = Number(request.headers["content-length"] ?? "0");
1646
1690
  if (Number.isFinite(declared) && declared > MAX_FILE_BYTES * 2) {
1647
1691
  json(413, { error: "请求体过大", code: "too_large" });
@@ -6,8 +6,18 @@ export declare function applicableRules(rules: ModelFilterRule[] | undefined, wo
6
6
  export declare function assertModelAllowed(rules: ModelFilterRule[] | undefined, workspace: Workspace, model: ModelSelection | undefined): void;
7
7
  export declare function conversationWorkspace(state: AppState, conversationId: string): Workspace;
8
8
  /** Resolve Pi's actual global default when no model was selected; never guess a permitted replacement. */
9
- export declare function validateConversationModel(store: {
10
- snapshot(): AppState;
11
- }, runtime: {
9
+ /**
10
+ * THE STORE READS THIS NEEDS, AND NOTHING ELSE.
11
+ *
12
+ * The check wants two things: the rules, and the workspace the conversation is in. Both now have a
13
+ * narrow reader on the store (`modelFilterRules()` / `workspaceForConversation()`), so this path
14
+ * clones NOTHING - it used to deep-clone the whole application three times over, on the way to
15
+ * sending a message.
16
+ */
17
+ export interface ModelFilterStore {
18
+ modelFilterRules(): import("./types.js").ModelFilterRule[];
19
+ workspaceForConversation(conversationId: string): Workspace | undefined;
20
+ }
21
+ export declare function validateConversationModel(store: ModelFilterStore, runtime: {
12
22
  catalog(selection?: ModelSelection): Promise<import("./types.js").ModelCatalog>;
13
23
  }, conversationId: string, selection?: ModelSelection): Promise<void>;
@@ -29,18 +29,20 @@ export function conversationWorkspace(state, conversationId) {
29
29
  throw new Error("会话所属工作区不存在");
30
30
  return workspace;
31
31
  }
32
- /** Resolve Pi's actual global default when no model was selected; never guess a permitted replacement. */
33
32
  export async function validateConversationModel(store, runtime, conversationId, selection) {
34
- const state = store.snapshot();
35
- const workspace = conversationWorkspace(state, conversationId);
36
- if (!applicableRules(state.modelFilterRules, workspace).length)
33
+ const workspace = store.workspaceForConversation(conversationId);
34
+ if (!workspace)
35
+ throw new Error("会话所属工作区不存在");
36
+ if (!applicableRules(store.modelFilterRules(), workspace).length)
37
37
  return;
38
38
  const startedAt = performance.now();
39
39
  if (process.env.SUPER_PI_TRACE_LATENCY === "1")
40
40
  console.warn(`[super-pi latency] conversation=${conversationId} phase=model.validate.begin selection=${selection ? `${selection.provider}/${selection.modelId}` : "default"}`);
41
41
  const catalog = await runtime.catalog();
42
42
  const model = selection ? catalog.models.find(item => item.provider === selection.provider && item.modelId === selection.modelId) ?? selection : catalog.defaultModel;
43
- assertModelAllowed(store.snapshot().modelFilterRules, conversationWorkspace(store.snapshot(), conversationId), model);
43
+ // READ AGAIN, NOT CACHED: the catalog fetch above is an await, and a rule edited during it must
44
+ // still be enforced for this message. The narrow readers make that re-read cheap.
45
+ assertModelAllowed(store.modelFilterRules(), store.workspaceForConversation(conversationId), model);
44
46
  if (process.env.SUPER_PI_TRACE_LATENCY === "1")
45
47
  console.warn(`[super-pi latency] conversation=${conversationId} phase=model.validate.end elapsedMs=${Math.round(performance.now() - startedAt)}`);
46
48
  }
@@ -96,6 +96,33 @@ export declare class Store {
96
96
  static open(filePath: string): Promise<Store>;
97
97
  /** Opens an injected repository, retaining domain reconciliation here. */
98
98
  static openRepository(repository: AppStateRepository): Promise<Store>;
99
+ /**
100
+ * THE MODEL FILTER RULES, WITHOUT CLONING THE STATE TO READ THEM.
101
+ *
102
+ * `snapshot()` deep-clones the WHOLE application - every workspace, every conversation, every
103
+ * message of every conversation - and the model filter check needs ONE small array. Reading that
104
+ * array through a full clone is the difference between a few hundred bytes and the entire
105
+ * transcript of the app, on the path that runs every time a message is sent.
106
+ *
107
+ * So the rules get their own narrow reader. It returns a COPY of the array (the caller may keep
108
+ * it, and the store's own state must not change under it), and the individual rules are treated
109
+ * as read-only - which is what the filter does with them.
110
+ */
111
+ modelFilterRules(): import("./types.js").ModelFilterRule[];
112
+ /**
113
+ * THE WORKSPACE A CONVERSATION BELONGS TO, WITHOUT CLONING THE STATE TO FIND ONE ROW.
114
+ *
115
+ * The same argument as the rules above: the filter needs to know WHICH workspace the conversation
116
+ * is in (the rules are per workspace), and the only way to know that today is to clone everything
117
+ * and look. This returns a shallow copy of the one workspace - cheap, and detached enough that a
118
+ * caller cannot reach into the store's state through it.
119
+ */
120
+ workspaceForConversation(conversationId: string): Workspace | undefined;
121
+ workspace(workspaceId: string): Workspace | undefined;
122
+ /** Small detached rows used when refreshing each conversation's scoped model catalog. */
123
+ catalogConversationTargets(): Array<Pick<Conversation, "id" | "archivedAt" | "model">>;
124
+ /** Clone only one workspace's transcripts, never unrelated workspaces or app state. */
125
+ conversationsForWorkspace(workspaceId: string): Conversation[];
99
126
  setModelFilterRules(rules: import("./types.js").ModelFilterRule[]): Promise<void>;
100
127
  /**
101
128
  * Sets (or clears) the app-wide default model.
@@ -689,6 +689,50 @@ export class Store {
689
689
  return new Store(repository, { workspaces: [], conversations: [], subagentRuns: [], backgroundTasks: [] });
690
690
  }
691
691
  }
692
+ /**
693
+ * THE MODEL FILTER RULES, WITHOUT CLONING THE STATE TO READ THEM.
694
+ *
695
+ * `snapshot()` deep-clones the WHOLE application - every workspace, every conversation, every
696
+ * message of every conversation - and the model filter check needs ONE small array. Reading that
697
+ * array through a full clone is the difference between a few hundred bytes and the entire
698
+ * transcript of the app, on the path that runs every time a message is sent.
699
+ *
700
+ * So the rules get their own narrow reader. It returns a COPY of the array (the caller may keep
701
+ * it, and the store's own state must not change under it), and the individual rules are treated
702
+ * as read-only - which is what the filter does with them.
703
+ */
704
+ modelFilterRules() {
705
+ return this.state.modelFilterRules ? [...this.state.modelFilterRules] : [];
706
+ }
707
+ /**
708
+ * THE WORKSPACE A CONVERSATION BELONGS TO, WITHOUT CLONING THE STATE TO FIND ONE ROW.
709
+ *
710
+ * The same argument as the rules above: the filter needs to know WHICH workspace the conversation
711
+ * is in (the rules are per workspace), and the only way to know that today is to clone everything
712
+ * and look. This returns a shallow copy of the one workspace - cheap, and detached enough that a
713
+ * caller cannot reach into the store's state through it.
714
+ */
715
+ workspaceForConversation(conversationId) {
716
+ const conversation = this.state.conversations.find((item) => item.id === conversationId);
717
+ const workspace = this.state.workspaces.find((item) => item.id === conversation?.workspaceId);
718
+ return workspace ? { ...workspace } : undefined;
719
+ }
720
+ workspace(workspaceId) {
721
+ const workspace = this.state.workspaces.find((item) => item.id === workspaceId);
722
+ return workspace ? { ...workspace } : undefined;
723
+ }
724
+ /** Small detached rows used when refreshing each conversation's scoped model catalog. */
725
+ catalogConversationTargets() {
726
+ return this.state.conversations.map(({ id, archivedAt, model }) => ({
727
+ id,
728
+ archivedAt,
729
+ model: model ? { ...model } : undefined,
730
+ }));
731
+ }
732
+ /** Clone only one workspace's transcripts, never unrelated workspaces or app state. */
733
+ conversationsForWorkspace(workspaceId) {
734
+ return structuredClone(this.state.conversations.filter((item) => item.workspaceId === workspaceId));
735
+ }
692
736
  async setModelFilterRules(rules) {
693
737
  if (!Array.isArray(rules) || rules.some(rule => !rule || typeof rule.id !== "string" || typeof rule.workspacePattern !== "string" || typeof rule.modelPattern !== "string" || (rule.label !== undefined && typeof rule.label !== "string")))
694
738
  throw new Error("模型过滤规则格式不合法");
@@ -421,8 +421,16 @@ export class SubagentSupervisor {
421
421
  }
422
422
  if (!record)
423
423
  break;
424
- if (record.type === "response" && record.id === "prompt" && record.success === false)
425
- throw new Error(`Pi rejected the sub-agent task: ${record.error ?? "no reason given"}`);
424
+ // THE RECEIPT FOR OUR OWN PROMPT IS A KNOWN RECORD, NOT AN UNKNOWN ONE. Pi answers every
425
+ // command with `type:"response"`, so the successful receipt for the prompt we just wrote is
426
+ // part of the protocol - and treating it as unrecognised made every ordinary sub-agent run
427
+ // log a false alarm ("unhandled child record response - consumed without action"). A FAILED
428
+ // receipt is the run's failure and is thrown; a successful one is simply consumed.
429
+ if (record.type === "response" && record.id === "prompt") {
430
+ if (record.success === false)
431
+ throw new Error(`Pi rejected the sub-agent task: ${record.error ?? "no reason given"}`);
432
+ continue;
433
+ }
426
434
  if (record.type === "message_update") {
427
435
  const event = record.assistantMessageEvent;
428
436
  // `text_delta` is the only inner event that is output. `start` and `done` are the boundaries
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.135",
3
+ "version": "0.1.137",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -76,6 +76,7 @@
76
76
  "scripts/workspace-cwd.test.mjs",
77
77
  "scripts/subagents.test.mjs",
78
78
  "scripts/subagent-noise.test.mjs",
79
+ "scripts/subagent-response-receipt.test.mjs",
79
80
  "scripts/subagent-runs.test.mjs",
80
81
  "scripts/conversation-reconcile.test.mjs",
81
82
  "scripts/subagent-dispatch.test.mjs",
@@ -110,6 +111,8 @@
110
111
  "scripts/todo-tool.test.mjs",
111
112
  "scripts/tool-calls.test.mjs",
112
113
  "scripts/files-api.test.mjs",
114
+ "scripts/http-body-utf8-chunking.test.mjs",
115
+ "scripts/files-eol-roundtrip.test.mjs",
113
116
  "scripts/files-missing-path.test.mjs",
114
117
  "scripts/file-changes.test.mjs",
115
118
  "scripts/file-index.test.mjs",
@@ -138,6 +141,7 @@
138
141
  "scripts/provider-pi-reads.test.mjs",
139
142
  "scripts/session-extension-credentials.test.mjs",
140
143
  "scripts/active-memory.test.mjs",
144
+ "scripts/active-memory-index-content.test.mjs",
141
145
  "scripts/knowledge-base.test.mjs",
142
146
  "scripts/knowledge-import-path.test.mjs",
143
147
  "scripts/knowledge-import-identity.test.mjs",
@@ -164,6 +168,7 @@
164
168
  "scripts/document-write-overwrite.test.mjs",
165
169
  "scripts/feishu.test.mjs",
166
170
  "scripts/scheduled-tasks.test.mjs",
171
+ "scripts/scheduled-task-model-clear.test.mjs",
167
172
  "scripts/scheduled-task-scope.test.mjs",
168
173
  "scripts/scheduled-task-tool.test.mjs",
169
174
  "scripts/run-state-sync.test.mjs",
@@ -182,6 +187,9 @@
182
187
  "scripts/conversation-reconcile-browser.test.mjs",
183
188
  "scripts/visual-defects-browser.test.mjs",
184
189
  "scripts/session-state-animation-browser.test.mjs",
190
+ "scripts/session-state-delivery-browser.test.mjs",
191
+ "scripts/question-awaiting-signal-browser.test.mjs",
192
+ "scripts/withdraw-then-send-browser.test.mjs",
185
193
  "scripts/agent-vis-card-browser.test.mjs",
186
194
  "scripts/queue-dock.test.mjs",
187
195
  "scripts/scheduled-tasks-browser.test.mjs",