@evandu/super-pi 0.1.130 → 0.1.132

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 (88) hide show
  1. package/README.md +18 -0
  2. package/agents/super-pi-documents/index.ts +92 -0
  3. package/agents/super-pi-feishu/index.ts +88 -0
  4. package/agents/super-pi-knowledge/index.ts +32 -1
  5. package/agents/super-pi-task/core.ts +217 -0
  6. package/agents/super-pi-task/index.ts +74 -1
  7. package/agents/super-pi-web/index.ts +32 -0
  8. package/assets/fonts/LICENSE +92 -0
  9. package/assets/fonts/NotoSansSC-Bold.otf +0 -0
  10. package/assets/fonts/NotoSansSC-Regular.otf +0 -0
  11. package/dist/client/.super-pi-client-sources.json +13 -10
  12. package/dist/client/assets/CodeMirrorEditor-ktyL2PcX.js +2 -0
  13. package/dist/client/assets/{dist-BKp4AWEJ.js → dist--igDAzRS.js} +1 -1
  14. package/dist/client/assets/{dist-BBW2SE_R.js → dist-60usx__o.js} +1 -1
  15. package/dist/client/assets/{dist-GkIV1LTX.js → dist-8pdzh6h1.js} +1 -1
  16. package/dist/client/assets/{dist-B2nPVdKh.js → dist-B8OZ-n_C.js} +1 -1
  17. package/dist/client/assets/{dist-BDygQgZD.js → dist-BAJCrbnX.js} +1 -1
  18. package/dist/client/assets/{dist-CiSGSVBZ.js → dist-BJi5HmH4.js} +1 -1
  19. package/dist/client/assets/{dist-CRzRB9k3.js → dist-BRjGckmQ.js} +1 -1
  20. package/dist/client/assets/{dist-Cxerc7Bb.js → dist-Bg9zLfex.js} +1 -1
  21. package/dist/client/assets/{dist-C90MPTnk.js → dist-BjZUYxLd.js} +1 -1
  22. package/dist/client/assets/{dist-DmAOqr0M.js → dist-Bv6P2Rcp.js} +1 -1
  23. package/dist/client/assets/{dist-BhJGK8-b.js → dist-C6iAHGvb.js} +1 -1
  24. package/dist/client/assets/{dist-Dq4-QFAJ.js → dist-CMscy2Nn.js} +1 -1
  25. package/dist/client/assets/{dist-Ur37340K.js → dist-CPLCQDqy.js} +1 -1
  26. package/dist/client/assets/{dist-DZnUERoW.js → dist-CgmISAMr.js} +1 -1
  27. package/dist/client/assets/{dist-CWYRjnj-.js → dist-CoszOk5k.js} +1 -1
  28. package/dist/client/assets/{dist-CMnvChDX.js → dist-Cu75NyQ0.js} +1 -1
  29. package/dist/client/assets/{dist-HdeIBl2J.js → dist-DNZMXYNq.js} +1 -1
  30. package/dist/client/assets/{dist-BykVFExd.js → dist-D_Zdnhm8.js} +1 -1
  31. package/dist/client/assets/{dist-ChszWGDv.js → dist-DvHghuGd.js} +1 -1
  32. package/dist/client/assets/{dist-CZ9I2JCd.js → dist-Hj_dWgPr.js} +1 -1
  33. package/dist/client/assets/{dist-B61RSa-K.js → dist-Zhdkfvng.js} +1 -1
  34. package/dist/client/assets/{dist-DB5M5fZG.js → dist-vsMBLoxV.js} +1 -1
  35. package/dist/client/assets/{index-D_c-CYAr.js → index-ChyemF2I.js} +23 -21
  36. package/dist/client/assets/{index-CXB6U5mz.css → index-D3MEG8uH.css} +1 -1
  37. package/dist/client/index.html +2 -2
  38. package/dist/server/application/document-service.d.ts +68 -0
  39. package/dist/server/application/document-service.js +120 -0
  40. package/dist/server/application/feishu-service.d.ts +132 -0
  41. package/dist/server/application/feishu-service.js +264 -0
  42. package/dist/server/application/run-service.js +49 -7
  43. package/dist/server/application/scheduled-task-service.d.ts +164 -0
  44. package/dist/server/application/scheduled-task-service.js +350 -0
  45. package/dist/server/command-handler.d.ts +25 -1
  46. package/dist/server/command-handler.js +60 -1
  47. package/dist/server/documents.d.ts +152 -0
  48. package/dist/server/documents.js +998 -0
  49. package/dist/server/feishu-cli.d.ts +207 -0
  50. package/dist/server/feishu-cli.js +419 -0
  51. package/dist/server/file-changes.js +6 -4
  52. package/dist/server/files.d.ts +32 -0
  53. package/dist/server/files.js +31 -3
  54. package/dist/server/http-server.js +324 -2
  55. package/dist/server/index.js +41 -2
  56. package/dist/server/inline-thinking.d.ts +90 -0
  57. package/dist/server/inline-thinking.js +181 -0
  58. package/dist/server/knowledge.d.ts +24 -14
  59. package/dist/server/knowledge.js +57 -34
  60. package/dist/server/persistence/app-state-repository.d.ts +13 -1
  61. package/dist/server/persistence/sqlite-state-repository.js +3 -0
  62. package/dist/server/pi-session-pool.js +2 -1
  63. package/dist/server/pi-session.d.ts +4 -0
  64. package/dist/server/pi-session.js +14 -0
  65. package/dist/server/presentations/schema.d.ts +120 -0
  66. package/dist/server/presentations/schema.js +128 -0
  67. package/dist/server/runtime.d.ts +67 -0
  68. package/dist/server/runtime.js +204 -18
  69. package/dist/server/scheduled-tasks.d.ts +101 -0
  70. package/dist/server/scheduled-tasks.js +138 -0
  71. package/dist/server/server-context.d.ts +19 -0
  72. package/dist/server/store.d.ts +30 -0
  73. package/dist/server/store.js +81 -2
  74. package/dist/server/subagents.js +33 -6
  75. package/dist/server/task-tool.d.ts +18 -0
  76. package/dist/server/task-tool.js +18 -0
  77. package/dist/server/types.d.ts +56 -0
  78. package/dist/server/web.d.ts +24 -0
  79. package/dist/server/web.js +180 -0
  80. package/package.json +45 -6
  81. package/skills/presentation/SKILL.md +30 -0
  82. package/skills/presentation/references/data.md +10 -0
  83. package/skills/presentation/references/quality.md +12 -0
  84. package/skills/presentation/references/story.md +12 -0
  85. package/skills/presentation/references/templates-and-editing.md +11 -0
  86. package/skills/presentation/references/visual-system.md +10 -0
  87. package/dist/.super-pi-check-inputs.json +0 -4
  88. package/dist/client/assets/CodeMirrorEditor-BNVBZZSS.js +0 -2
@@ -2,7 +2,10 @@ import { spawn } from "node:child_process";
2
2
  import { RpcChannel } from "./rpc-channel.js";
3
3
  export { RpcChannel } from "./rpc-channel.js";
4
4
  import { randomUUID } from "node:crypto";
5
- import { KNOWLEDGE_TOOL_NAME } from "./knowledge.js";
5
+ import { KNOWLEDGE_IMPORT_TOOL_NAME, KNOWLEDGE_TOOL_NAME } from "./knowledge.js";
6
+ import { DOCUMENT_READ_TOOL_NAME, DOCUMENT_WRITE_TOOL_NAME } from "./documents.js";
7
+ import { FEISHU_READ_TOOL_NAME, FEISHU_WRITE_TOOL_NAME } from "./feishu-cli.js";
8
+ import { WEB_FETCH_TOOL_NAME, WEB_SEARCH_TOOL_NAME } from "./web.js";
6
9
  import { existsSync } from "node:fs";
7
10
  import { mkdir, stat } from "node:fs/promises";
8
11
  import { forkSessionFile, truncateSessionFile } from "./session-repository.js";
@@ -18,10 +21,11 @@ import { describeDeliveredUserMessage, describePiQueue, handleSessionEvent, Turn
18
21
  // paths recognise a delivery identically. Re-exported because they were part of this module's surface.
19
22
  export { describeDeliveredUserMessage, describePiQueue } from "./pi-session-events.js";
20
23
  import { createPiSession, createSdkCatalog, piSdkAvailable, resolveTranscriptFile, superPiExtensionPaths } from "./pi-session.js";
21
- import { TASK_CONVERSATION_ENV, TASK_DEPTH_ENV, TASK_ENDPOINT_ENV, TASK_EXEC_ENDPOINT_ENV, TASK_ROSTER_ENV, TASK_TOOL_NAME, TODO_ENDPOINT_ENV, TODO_TOOL_NAME, WORKFLOW_ENDPOINT_ENV, WORKFLOW_TOOL_NAME, RUN_ASYNC_TOOL_NAME, rosterPayload, } from "./task-tool.js";
24
+ import { TASK_CONVERSATION_ENV, TASK_DEPTH_ENV, TASK_ENDPOINT_ENV, TASK_EXEC_ENDPOINT_ENV, TASK_ROSTER_ENV, TASK_TOOL_NAME, TODO_ENDPOINT_ENV, TODO_TOOL_NAME, SCHEDULED_TASK_ENDPOINT_ENV, SCHEDULED_TASK_TOOL_NAME, WORKFLOW_ENDPOINT_ENV, WORKFLOW_TOOL_NAME, RUN_ASYNC_TOOL_NAME, rosterPayload, } from "./task-tool.js";
22
25
  import { BLOCKING_UI_METHODS, EXTENSION_UI_METHODS, } from "./types.js";
23
26
  import { loadConversationImages } from "./upload.js";
24
27
  import { describeToolCall, describeToolCallArgs, describeToolCallInProgress, extractPartialResultText, extractToolResultText, readQuestionCall } from "./tool-calls.js";
28
+ import { InlineThinkingSplitter, stripInlineThinking } from "./inline-thinking.js";
25
29
  import { toolTargetPath } from "./file-changes.js";
26
30
  const traceLatency = (conversationId, phase, startedAt, extra = "") => {
27
31
  if (process.env.SUPER_PI_TRACE_LATENCY !== "1")
@@ -238,6 +242,23 @@ export const catalogKey = (selection) => (selection?.provider && selection.model
238
242
  export function taskExtensionPath(from = import.meta.dirname) {
239
243
  return resolve(from, "../../agents/super-pi-task/index.ts");
240
244
  }
245
+ /**
246
+ * The document extension's file, resolved the same way the task extension's is: two `..` segments
247
+ * from `import.meta.dirname` reach the repository root both after `tsc -b` and under `tsx watch`.
248
+ */
249
+ export function documentExtensionPath(from = import.meta.dirname) {
250
+ return resolve(from, "../../agents/super-pi-documents/index.ts");
251
+ }
252
+ /**
253
+ * The Feishu extension's file, resolved the same way the document extension's is.
254
+ *
255
+ * Named on both sides of the wire rather than imported by the extension, for the reason every one
256
+ * of these names is written out twice: `agents/` is loaded by Pi's module loader and `src/` by
257
+ * tsc, so a relative import across the two would resolve differently under each.
258
+ */
259
+ export function feishuExtensionPath(from = import.meta.dirname) {
260
+ return resolve(from, "../../agents/super-pi-feishu/index.ts");
261
+ }
241
262
  /** Bridges a Pi session to Super Pi through Pi's documented JSONL RPC protocol. */
242
263
  /**
243
264
  * The tool's name as far as the stream has revealed it so far, or an empty string while the model is
@@ -356,6 +377,10 @@ export class PiRpcRuntime {
356
377
  modelEpoch = 0;
357
378
  /** Latches the "no extension on disk" warning, so it is said once rather than once per turn. */
358
379
  warnedMissingExtension = false;
380
+ /** Latches the "the document extension is missing" warning, for the same reason. */
381
+ warnedMissingDocumentExtension = false;
382
+ /** Latches the "the Feishu extension is missing" warning, for the same reason. */
383
+ warnedMissingFeishuExtension = false;
359
384
  /** Latches the "Pi's SDK is not installed" warning, for the same reason. */
360
385
  warnedMissingSdk = false;
361
386
  /**
@@ -541,6 +566,52 @@ export class PiRpcRuntime {
541
566
  }
542
567
  return ["--extension", path];
543
568
  }
569
+ /**
570
+ * The document extension's spawn argument.
571
+ *
572
+ * IT IS MOUNTED UNCONDITIONALLY, unlike the knowledge extension. The knowledge extension is only
573
+ * worth mounting when the workspace HAS documents, because its tool returns nothing otherwise; the
574
+ * document tools are about the files in the workspace, and a workspace with no PDF in it is exactly
575
+ * the workspace where an agent might be asked to WRITE one.
576
+ *
577
+ * The endpoint is the gate: without it the extension returns without registering anything, so a
578
+ * runtime with no document endpoint mounts nothing rather than a tool whose every call fails.
579
+ */
580
+ documentExtensionArgs() {
581
+ if (!this.options.documentsEndpoint)
582
+ return [];
583
+ const path = documentExtensionPath();
584
+ if (!existsSync(path)) {
585
+ if (!this.warnedMissingDocumentExtension) {
586
+ this.warnedMissingDocumentExtension = true;
587
+ console.warn(`PiRpcRuntime: ${path} does not exist, so the primary agent cannot read or write PDF, Word and Excel files.`);
588
+ }
589
+ return [];
590
+ }
591
+ return ["--extension", path];
592
+ }
593
+ /**
594
+ * The Feishu extension's spawn argument.
595
+ *
596
+ * MOUNTED UNCONDITIONALLY, for the same reason the document tools are: the endpoint is the gate,
597
+ * and the extension registers nothing without it. The credential is NOT the gate - a user who
598
+ * has not configured an application yet still gets the tools, because the error those tools
599
+ * return is the one that tells the user what to do, and a tool that is simply absent cannot say
600
+ * anything at all.
601
+ */
602
+ feishuExtensionArgs() {
603
+ if (!process.env.SUPER_PI_FEISHU_ENDPOINT)
604
+ return [];
605
+ const path = feishuExtensionPath();
606
+ if (!existsSync(path)) {
607
+ if (!this.warnedMissingFeishuExtension) {
608
+ this.warnedMissingFeishuExtension = true;
609
+ console.warn(`PiRpcRuntime: ${path} does not exist, so the primary agent cannot read or write Feishu cloud documents.`);
610
+ }
611
+ return [];
612
+ }
613
+ return ["--extension", path];
614
+ }
544
615
  /**
545
616
  * The five values the extension reads out of its own environment. See `task-tool.ts` for why they are
546
617
  * written out on both sides rather than imported.
@@ -576,9 +647,23 @@ export class PiRpcRuntime {
576
647
  [TASK_CONVERSATION_ENV]: conversationId,
577
648
  [TASK_DEPTH_ENV]: "0",
578
649
  [TODO_ENDPOINT_ENV]: this.options.todoEndpoint ?? "",
650
+ [SCHEDULED_TASK_ENDPOINT_ENV]: this.options.scheduledTaskEndpoint ?? "",
579
651
  [WORKFLOW_ENDPOINT_ENV]: this.options.workflowEndpoint ?? "",
580
652
  SUPER_PI_QUESTION_ENDPOINT: this.options.questionEndpoint ?? "",
581
653
  SUPER_PI_TOOLS_ENDPOINT: this.options.toolsEndpoint ?? "",
654
+ // The document tools' endpoint and the conversation they act for. The CREDENTIAL is not here:
655
+ // it is the session's own capability, which the session pool adds to this environment when the
656
+ // session is born, and the document extension reads that same value. Minting a second
657
+ // credential for the same session would be a second thing to rotate and a second thing to
658
+ // forget to release.
659
+ SUPER_PI_DOCUMENTS_ENDPOINT: this.options.documentsEndpoint ?? "",
660
+ SUPER_PI_DOCUMENTS_CONVERSATION: conversationId,
661
+ SUPER_PI_WEB_ENDPOINT: process.env.SUPER_PI_WEB_ENDPOINT ?? "",
662
+ // The Feishu tools' endpoint and the conversation they act for. The CREDENTIAL is not here:
663
+ // it is the session's own capability, the same value the task dispatch uses, because a
664
+ // second credential for one session would be a second thing to rotate and to release.
665
+ SUPER_PI_FEISHU_ENDPOINT: process.env.SUPER_PI_FEISHU_ENDPOINT ?? "",
666
+ SUPER_PI_FEISHU_CONVERSATION: conversationId,
582
667
  };
583
668
  }
584
669
  /**
@@ -590,8 +675,7 @@ export class PiRpcRuntime {
590
675
  * here. A session lives for many turns, so its credential is minted when the session is created (see
591
676
  * `PiSessionPool.acquire`) and the server separately requires a running turn for it to be usable.
592
677
  */
593
- async oneShotExtensionEnv(conversationId) {
594
- const capability = mintCapability(conversationId);
678
+ async oneShotExtensionEnv(conversationId, capability) {
595
679
  return {
596
680
  ...(await this.taskExtensionEnv(conversationId)),
597
681
  [TASK_CAPABILITY_ENV]: capability,
@@ -1012,14 +1096,75 @@ export class PiRpcRuntime {
1012
1096
  * because `toolFlags` treats an explicit false as outranking a host extra.
1013
1097
  */
1014
1098
  extensionToolNames() {
1015
- const names = [TASK_TOOL_NAME, TODO_TOOL_NAME, WORKFLOW_TOOL_NAME, RUN_ASYNC_TOOL_NAME, "question", "tool_register"];
1099
+ const names = [TASK_TOOL_NAME, TODO_TOOL_NAME, WORKFLOW_TOOL_NAME, RUN_ASYNC_TOOL_NAME, SCHEDULED_TASK_TOOL_NAME, "question", "tool_register"];
1016
1100
  if (this.memory)
1017
1101
  names.push("memory", "skill_manage", "session_search");
1018
1102
  if (this.knowledge)
1019
- names.push(KNOWLEDGE_TOOL_NAME);
1103
+ names.push(KNOWLEDGE_TOOL_NAME, KNOWLEDGE_IMPORT_TOOL_NAME);
1104
+ if (process.env.SUPER_PI_WEB_ENDPOINT)
1105
+ names.push(WEB_SEARCH_TOOL_NAME, WEB_FETCH_TOOL_NAME);
1106
+ // The document tools are named whenever the endpoint is configured, because that is what decides
1107
+ // whether the extension registers them. An agent that declares `tools: {"*": false, read: true}`
1108
+ // would otherwise get a loaded-but-undeclared tool - broken with no error to explain it.
1109
+ if (this.options.documentsEndpoint)
1110
+ names.push(DOCUMENT_READ_TOOL_NAME, DOCUMENT_WRITE_TOOL_NAME);
1111
+ // The Feishu tools are named whenever the endpoint is configured, for the same reason the
1112
+ // document tools are: the endpoint is what decides whether the extension registers them, and
1113
+ // an agent that declares `tools: {"*": false, read: true}` would otherwise get a
1114
+ // loaded-but-undeclared tool - broken with no error to explain it.
1115
+ if (process.env.SUPER_PI_FEISHU_ENDPOINT)
1116
+ names.push(FEISHU_READ_TOOL_NAME, FEISHU_WRITE_TOOL_NAME);
1020
1117
  return names;
1021
1118
  }
1119
+ /**
1120
+ * A TURN, WITH THE REASONING TAKEN OUT OF THE ANSWER.
1121
+ *
1122
+ * This is the ONE place every turn passes through, whichever path it runs on - the persistent
1123
+ * session or a one-shot process - so it is where the model's own text is read once and read
1124
+ * properly.
1125
+ *
1126
+ * WHAT IT DOES. A model that has no reasoning channel answers in one stream of text and marks its
1127
+ * thinking up inside it with `<think>…</think>`. Pi's adapter passes that through as ordinary
1128
+ * text, because Pi only knows the four reasoning FIELDS a provider can use and a tag is not one
1129
+ * of them. Left alone, the reasoning would be drawn as part of the answer, unlabelled, with the
1130
+ * tags in it.
1131
+ *
1132
+ * WHY IT IS HERE AND NOT IN THE USER'S `models.json`. The alternative is to tell every reader to
1133
+ * go and set a vendor-specific switch in a file Super Pi owns but Pi reads. A user who never
1134
+ * hears of it sees a broken transcript and has no way to find out why. Super Pi reads the tags
1135
+ * itself, for every provider, so the transcript reads the same way whichever endpoint is behind
1136
+ * it - and the reasoning goes where the reasoning goes: into a thinking block, over the same
1137
+ * channel Pi's own reasoning events use, so the fold, the live indicator and the persisted
1138
+ * transcript all work without knowing where the reasoning came from.
1139
+ */
1022
1140
  async *answer(input) {
1141
+ const splitter = new InlineThinkingSplitter();
1142
+ for await (const chunk of this.answerTurn(input)) {
1143
+ for (const segment of splitter.push(chunk)) {
1144
+ if (segment.kind === "text")
1145
+ yield segment.text;
1146
+ else if (segment.kind === "thinking")
1147
+ input.onThinking?.(segment.text);
1148
+ else
1149
+ input.onThinkingEnd?.();
1150
+ }
1151
+ }
1152
+ // THE END OF THE TURN. A model that was still thinking when the turn ended leaves an unclosed
1153
+ // region behind, and it belongs in the block rather than in the answer.
1154
+ for (const segment of splitter.flush()) {
1155
+ if (segment.kind === "text")
1156
+ yield segment.text;
1157
+ else if (segment.kind === "thinking")
1158
+ input.onThinking?.(segment.text);
1159
+ else
1160
+ input.onThinkingEnd?.();
1161
+ }
1162
+ }
1163
+ /**
1164
+ * THE TURN ITSELF, on whichever path it runs. See `answer`, which is the seam the reasoning is
1165
+ * separated at.
1166
+ */
1167
+ async *answerTurn(input) {
1023
1168
  // The one-shot paths keep their own child process, and they are NOT the same thing as a conversation
1024
1169
  // turn: a memory review and a knowledge reranker have their own ids, their own tool sets and their own
1025
1170
  // lifetimes, and folding them into a conversation's session would give them that conversation's
@@ -1111,15 +1256,35 @@ export class PiRpcRuntime {
1111
1256
  const memoryToken = memoryEnabled ? this.memory.issue(validationId, { review: memoryReview }) : undefined;
1112
1257
  const memoryExtension = resolve(import.meta.dirname, "../../agents/super-pi-memory/index.ts");
1113
1258
  const memoryArgs = memoryEnabled ? ["--extension", memoryExtension] : [];
1114
- // Mounted only when the workspace actually HAS documents. An empty knowledge base would otherwise
1115
- // cost a catalogue round trip and one declared tool on every turn to tell the model there is
1116
- // nothing to find. Review turns never mount it: a background reflection has no business
1117
- // retrieving documents it was not asked about, and its `--tools` line is a closed list. A
1118
- // reranker never mounts it either - it must not be able to call the search it is ranking.
1119
- const knowledgeEnabled = !memoryReview && !knowledgeRerank && !!this.knowledge && await this.knowledge.hasDocuments(conversation.workspaceId).catch(() => false);
1259
+ // Mounted on EVERY primary turn, empty base or not. The old condition (hasDocuments) saved a
1260
+ // catalogue round trip and a tool slot when there was nothing to find, but it also made the one
1261
+ // workspace where a user most plausibly says 「把这些文件加入知识库」 the one workspace whose
1262
+ // agent had no import tool. knowledge_search on an empty base answers no hits and costs nothing;
1263
+ // knowledge_import has to exist before the first document does. Review turns never mount it: a
1264
+ // background reflection has no business reading or writing documents, and its `--tools` line is a
1265
+ // closed list. A reranker never mounts it either - it must not be able to call what it ranks.
1266
+ const knowledgeEnabled = !memoryReview && !knowledgeRerank && !!this.knowledge && this.knowledge.enabled();
1120
1267
  const knowledgeToken = knowledgeEnabled ? this.knowledge.issue(conversation.id) : undefined;
1121
1268
  const knowledgeExtension = resolve(import.meta.dirname, "../../agents/super-pi-knowledge/index.ts");
1122
1269
  const knowledgeArgs = knowledgeEnabled ? ["--extension", knowledgeExtension] : [];
1270
+ const webExtension = resolve(import.meta.dirname, "../../agents/super-pi-web/index.ts");
1271
+ const webEnabled = !memoryReview && !knowledgeRerank && existsSync(webExtension) && !!process.env.SUPER_PI_WEB_ENDPOINT;
1272
+ const webArgs = webEnabled ? ["--extension", webExtension] : [];
1273
+ // The Feishu tools are mounted for every ordinary turn, and NOT for a review or a rerank: a
1274
+ // background reflection has no business reading a company's cloud documents, and a reranker
1275
+ // must not be able to reach the tools it is ranking.
1276
+ const feishuEnabled = !memoryReview && !knowledgeRerank && this.feishuExtensionArgs().length > 0;
1277
+ const feishuArgs = feishuEnabled ? this.feishuExtensionArgs() : [];
1278
+ // The document tools are mounted for every ordinary turn, and NOT for a review or a rerank: a
1279
+ // background reflection has no business reading a PDF it was not asked about, and a reranker
1280
+ // must not be able to reach the tools it is ranking.
1281
+ const documentsEnabled = !memoryReview && !knowledgeRerank && this.documentExtensionArgs().length > 0;
1282
+ const documentArgs = documentsEnabled ? this.documentExtensionArgs() : [];
1283
+ // THE ONE-SHOT CHILD'S CREDENTIAL, MINTED ONCE. The server holds ONE capability per
1284
+ // conversation, so minting a second one for the same conversation would replace the first and
1285
+ // leave the task dispatch holding a token the server has already forgotten. Both the task
1286
+ // extension and the document extension therefore share this one value.
1287
+ const oneShotCapability = (extensionArgs.length || documentsEnabled || webEnabled || feishuEnabled) ? mintCapability(conversation.id) : undefined;
1123
1288
  // BOTH extension tools, or neither. Naming one while the other goes unmentioned would be a lie in a
1124
1289
  // different direction: Pi filters its registry through `--tools`, so an agent that declares
1125
1290
  // `tools: {"*": false, read: true}` gets whichever of the two is on this line and silently not the
@@ -1132,13 +1297,13 @@ export class PiRpcRuntime {
1132
1297
  // allowlist and naming a tool Pi has not registered would change the spawn line for every other
1133
1298
  // mode. The prompt asks for a bare JSON array and nothing else; if the model spends a turn on a
1134
1299
  // tool call anyway the reranker times out and falls back to the lexical order.
1135
- const agentArgs = memoryReview ? ["--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates", "--tools", "memory,skill_manage,session_search"] : knowledgeRerank ? ["--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates"] : await agentSpawnArgs(this.agents.promptDir, agent, { extraAllowedTools: [...(extensionArgs.length ? [TASK_TOOL_NAME, TODO_TOOL_NAME, WORKFLOW_TOOL_NAME, RUN_ASYNC_TOOL_NAME, "question", "tool_register"] : []), ...(memoryEnabled ? ["memory", "skill_manage", "session_search"] : []), ...(knowledgeEnabled ? [KNOWLEDGE_TOOL_NAME] : [])] });
1300
+ const agentArgs = memoryReview ? ["--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates", "--tools", "memory,skill_manage,session_search"] : knowledgeRerank ? ["--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates"] : await agentSpawnArgs(this.agents.promptDir, agent, { extraAllowedTools: [...(extensionArgs.length ? [TASK_TOOL_NAME, TODO_TOOL_NAME, WORKFLOW_TOOL_NAME, RUN_ASYNC_TOOL_NAME, SCHEDULED_TASK_TOOL_NAME, "question", "tool_register"] : []), ...(memoryEnabled ? ["memory", "skill_manage", "session_search"] : []), ...(knowledgeEnabled ? [KNOWLEDGE_TOOL_NAME, KNOWLEDGE_IMPORT_TOOL_NAME] : []), ...(documentsEnabled ? [DOCUMENT_READ_TOOL_NAME, DOCUMENT_WRITE_TOOL_NAME] : []), ...(webEnabled ? [WEB_SEARCH_TOOL_NAME, WEB_FETCH_TOOL_NAME] : []), ...(feishuEnabled ? [FEISHU_READ_TOOL_NAME, FEISHU_WRITE_TOOL_NAME] : [])] });
1136
1301
  // Refused here, with the reason, rather than handed to the spawn: an ENOENT from `spawn` reads as
1137
1302
  // "the app is broken" and hides the one fact that matters, which is where it looked for Pi.
1138
1303
  if (!this.pi.found)
1139
1304
  throw new Error(this.pi.reason);
1140
1305
  // npm exposes global CLIs as .cmd shims on Windows; Node requires a shell to launch those shims.
1141
- const child = spawn(shellQuote(this.pi.command), ["--mode", "rpc", "--session-dir", this.sessionDir, "--session-id", conversation.id, ...extensionArgs, ...memoryArgs, ...knowledgeArgs, ...agentArgs], {
1306
+ const child = spawn(shellQuote(this.pi.command), ["--mode", "rpc", "--session-dir", this.sessionDir, "--session-id", conversation.id, ...extensionArgs, ...memoryArgs, ...knowledgeArgs, ...documentArgs, ...webArgs, ...feishuArgs, ...agentArgs], {
1142
1307
  cwd: childCwd,
1143
1308
  stdio: ["pipe", "pipe", "pipe"],
1144
1309
  shell: process.platform === "win32",
@@ -1146,13 +1311,26 @@ export class PiRpcRuntime {
1146
1311
  // every turn would silently replace the child's environment with whatever THIS process has at
1147
1312
  // spawn time. Inheriting is what an omitted `env` already does, so the two paths differ in nothing
1148
1313
  // but the four documented additions.
1149
- ...((extensionArgs.length || memoryEnabled || knowledgeEnabled) ? { env: { ...process.env, ...(extensionArgs.length ? await this.oneShotExtensionEnv(conversation.id) : {}), ...(memoryEnabled ? {
1314
+ ...((extensionArgs.length || memoryEnabled || knowledgeEnabled || documentsEnabled || webEnabled || feishuEnabled) ? { env: { ...process.env, ...(oneShotCapability ? await this.oneShotExtensionEnv(conversation.id, oneShotCapability) : {}), ...(memoryEnabled ? {
1150
1315
  SUPER_PI_MEMORY_ENDPOINT: process.env.SUPER_PI_MEMORY_ENDPOINT,
1151
1316
  SUPER_PI_MEMORY_TOKEN: memoryToken,
1152
1317
  SUPER_PI_MEMORY_REVIEW: memoryReview ? "1" : "0",
1153
1318
  } : {}), ...(knowledgeEnabled ? {
1154
1319
  SUPER_PI_KNOWLEDGE_ENDPOINT: process.env.SUPER_PI_KNOWLEDGE_ENDPOINT,
1155
1320
  SUPER_PI_KNOWLEDGE_TOKEN: knowledgeToken,
1321
+ } : {}), ...(documentsEnabled ? {
1322
+ SUPER_PI_DOCUMENTS_ENDPOINT: this.options.documentsEndpoint ?? "",
1323
+ // The document tools carry the SESSION's capability, the same credential the task dispatch
1324
+ // uses: it proves which conversation is asking, and the server checks that the turn is
1325
+ // running. The conversation id travels with it, because the workspace is derived from the
1326
+ // conversation and never from the request.
1327
+ //
1328
+ // IT IS THE SAME VALUE THE TASK EXTENSION GETS. A one-shot child has no session to borrow a
1329
+ // credential from, so the token is minted for the turn - and minted ONCE, because the server
1330
+ // holds one capability per conversation and a second mint would replace the first in the
1331
+ // map, leaving the task dispatch holding a token the server had already forgotten.
1332
+ SUPER_PI_DOCUMENTS_TOKEN: oneShotCapability ?? "",
1333
+ SUPER_PI_DOCUMENTS_CONVERSATION: conversation.id,
1156
1334
  } : {}) } } : {}),
1157
1335
  });
1158
1336
  const channel = new RpcChannel(child);
@@ -2070,7 +2248,11 @@ export class PiRpcRuntime {
2070
2248
  // A model asked for "only the title" still wraps it now and then. Stripping the wrappers is not
2071
2249
  // cosmetic: without it a leading quote or a `Title:` prefix becomes the first characters of every
2072
2250
  // title in the sidebar.
2073
- const title = text
2251
+ //
2252
+ // AND THE MODEL'S OWN REASONING IS STRIPPED FIRST, because a model that thinks inside its
2253
+ // answer puts the thinking in front of the title: the title would then start with `<think>`,
2254
+ // and the sidebar would show the markup of a reasoning trace where a label belongs.
2255
+ const title = stripInlineThinking(text)
2074
2256
  .replace(/^\s*(?:title|标题)\s*[::]\s*/iu, "")
2075
2257
  .replace(/^[`"'“”‘’「『]+/u, "")
2076
2258
  .replace(/[`"'“”‘’」』]+$/u, "")
@@ -2125,9 +2307,13 @@ export class PiRpcRuntime {
2125
2307
  }
2126
2308
  if (!settled)
2127
2309
  throw new Error(`模型在 ${Math.round(input.timeoutMs / 1000)} 秒内没有返回`);
2128
- if (!text.trim())
2310
+ // THE MODEL'S OWN REASONING IS NOT THE TOOL'S OUTPUT. A model that thinks inside its answer
2311
+ // would otherwise hand the caller a `<think>`-prefixed string, and the caller - a tool, a
2312
+ // title, a card - would store the reasoning trace as its result.
2313
+ const answer = stripInlineThinking(text);
2314
+ if (!answer.trim())
2129
2315
  throw new Error("模型没有返回任何内容");
2130
- return text;
2316
+ return answer;
2131
2317
  }
2132
2318
  finally {
2133
2319
  if (child.exitCode === null && child.pid !== undefined)
@@ -0,0 +1,101 @@
1
+ import type { ModelSelection } from "./types.js";
2
+ /**
3
+ * WHEN A SCHEDULED TASK RUNS.
4
+ *
5
+ * FOUR SHAPES, AND NOT A CRON EXPRESSION. A cron string would bring a parser, a timezone story and a
6
+ * daylight-saving story to a panel that runs on the same machine as the server - and the two shapes
7
+ * people actually ask for are "every N minutes" and "every day at HH:MM". The set is closed on purpose:
8
+ * a schedule that cannot be expressed here is not a schedule the panel can edit, and the panel is the
9
+ * only place a user can see what they are agreeing to.
10
+ *
11
+ * - `interval`: every N minutes. The interval is anchored on the last run, not on the wall clock, so a
12
+ * task that takes four minutes to run does not slowly drift later every cycle.
13
+ * - `daily`: every day at a local `HH:MM`.
14
+ * - `weekly`: on the listed weekdays (0 = Sunday) at a local `HH:MM`.
15
+ * - `once`: at one instant. The task is finished after it fires.
16
+ *
17
+ * THE TIME IS LOCAL, not UTC and not a named zone, because the server and the panel are the same
18
+ * machine for every installation that exists. A "09:00" that means something different in the panel
19
+ * than in the scheduler would be the worst possible bug in this feature: it would fire at the wrong
20
+ * hour and look like it had fired at the right one.
21
+ */
22
+ export type ScheduleSpec = {
23
+ kind: "interval";
24
+ everyMinutes: number;
25
+ } | {
26
+ kind: "daily";
27
+ time: string;
28
+ } | {
29
+ kind: "weekly";
30
+ weekdays: number[];
31
+ time: string;
32
+ } | {
33
+ kind: "once";
34
+ at: number;
35
+ };
36
+ /**
37
+ * ONE SCHEDULED TASK: a prompt, a model to run it with, and when to run it.
38
+ *
39
+ * `conversationId` is the task's OWN conversation, created lazily on the first run. The task never runs
40
+ * in somebody else's conversation: a scheduled run that landed in the user's working session would
41
+ * interleave with what they were doing, and would change that session's model out from under it.
42
+ */
43
+ export type ScheduledTask = {
44
+ id: string;
45
+ name: string;
46
+ /** The instruction, sent VERBATIM as the user message of the task's conversation. */
47
+ prompt: string;
48
+ /** The workspace the task belongs to: it decides the working directory and the defaults. */
49
+ workspaceId: string;
50
+ /** The task's own conversation, created on the first run. Absent until then. */
51
+ conversationId?: string;
52
+ /** Overrides the workspace's default agent. */
53
+ agentId?: string;
54
+ /** Overrides the workspace's default model. A `provider`/`modelId` PAIR, never half of one. */
55
+ model?: ModelSelection;
56
+ schedule: ScheduleSpec;
57
+ enabled: boolean;
58
+ /** When the task fires next, in epoch milliseconds. Absent for a finished one-shot task. */
59
+ nextRunAt?: number;
60
+ lastRunAt?: number;
61
+ lastRunStatus?: "completed" | "failed" | "skipped";
62
+ lastRunMessage?: string;
63
+ /** Whether a person or the agent created it. Both are editable by both. */
64
+ createdBy: "user" | "agent";
65
+ createdAt: number;
66
+ updatedAt: number;
67
+ };
68
+ /** The shortest interval a task may ask for. A one-minute floor is what makes a task a task. */
69
+ export declare const MIN_INTERVAL_MINUTES = 1;
70
+ /** The longest interval: a week. Anything longer is a calendar arrangement, not an interval. */
71
+ export declare const MAX_INTERVAL_MINUTES: number;
72
+ /**
73
+ * Reads a schedule out of whatever a caller sent, or explains why it cannot.
74
+ *
75
+ * THE SERVER OWNS THIS, and the extension on the other side of the wire deliberately does NOT have a
76
+ * copy of it. The extension hands over what the model wrote; this function decides what it means. A
77
+ * second implementation on the extension side would be a second opinion about the same wire, and the two
78
+ * would disagree the first time a model wrote a schedule in a shape one of them accepted and the other
79
+ * did not - with the model's mistake showing up as a server error it cannot explain.
80
+ */
81
+ export declare function normalizeSchedule(raw: unknown): ScheduleSpec;
82
+ /**
83
+ * WHEN THE TASK FIRES NEXT, given a moment to look forward from.
84
+ *
85
+ * `undefined` means "never again": a one-shot task whose moment has passed.
86
+ *
87
+ * The daily and weekly cases walk forward day by day in LOCAL time rather than adding 24 hours, so a
88
+ * schedule at 09:00 stays at 09:00 across a daylight-saving change instead of drifting to 08:00 or
89
+ * 10:00 for half the year.
90
+ */
91
+ export declare function nextRunAt(spec: ScheduleSpec, from: number): number | undefined;
92
+ /**
93
+ * THE SCHEDULE IN WORDS, for the panel and for the text the agent reads back.
94
+ *
95
+ * One implementation for both readers, because two descriptions of one schedule would eventually say
96
+ * two different things - and the agent's description is what the user is told when the agent says
97
+ * "I set up a daily task". If the two disagreed, the user would find out from the clock.
98
+ */
99
+ export declare function describeSchedule(spec: ScheduleSpec): string;
100
+ /** A moment as `YYYY-MM-DD HH:MM`, in local time. */
101
+ export declare function formatMoment(ms: number): string;
@@ -0,0 +1,138 @@
1
+ /** The shortest interval a task may ask for. A one-minute floor is what makes a task a task. */
2
+ export const MIN_INTERVAL_MINUTES = 1;
3
+ /** The longest interval: a week. Anything longer is a calendar arrangement, not an interval. */
4
+ export const MAX_INTERVAL_MINUTES = 60 * 24 * 7;
5
+ /**
6
+ * Reads a schedule out of whatever a caller sent, or explains why it cannot.
7
+ *
8
+ * THE SERVER OWNS THIS, and the extension on the other side of the wire deliberately does NOT have a
9
+ * copy of it. The extension hands over what the model wrote; this function decides what it means. A
10
+ * second implementation on the extension side would be a second opinion about the same wire, and the two
11
+ * would disagree the first time a model wrote a schedule in a shape one of them accepted and the other
12
+ * did not - with the model's mistake showing up as a server error it cannot explain.
13
+ */
14
+ export function normalizeSchedule(raw) {
15
+ if (!raw || typeof raw !== "object")
16
+ throw new Error("周期必须是一个对象:{\"kind\":\"interval\",\"everyMinutes\":30} 这样写。");
17
+ const value = raw;
18
+ const kind = typeof value.kind === "string" ? value.kind.trim() : "";
19
+ if (kind === "interval") {
20
+ const everyMinutes = Number(value.everyMinutes);
21
+ if (!Number.isFinite(everyMinutes) || !Number.isInteger(everyMinutes))
22
+ throw new Error("每隔 N 分钟需要一个整数分钟数。");
23
+ if (everyMinutes < MIN_INTERVAL_MINUTES || everyMinutes > MAX_INTERVAL_MINUTES) {
24
+ throw new Error(`间隔分钟数要在 ${MIN_INTERVAL_MINUTES} 到 ${MAX_INTERVAL_MINUTES} 之间。`);
25
+ }
26
+ return { kind: "interval", everyMinutes };
27
+ }
28
+ if (kind === "daily")
29
+ return { kind: "daily", time: normalizeTime(value.time) };
30
+ if (kind === "weekly") {
31
+ const weekdays = normalizeWeekdays(value.weekdays);
32
+ return { kind: "weekly", weekdays, time: normalizeTime(value.time) };
33
+ }
34
+ if (kind === "once") {
35
+ const at = Number(value.at);
36
+ if (!Number.isFinite(at) || at <= 0)
37
+ throw new Error("一次性任务需要一个触发时刻(毫秒时间戳)。");
38
+ return { kind: "once", at };
39
+ }
40
+ throw new Error("周期只能是 interval(每隔 N 分钟)、daily(每天)、weekly(每周)或 once(仅一次)。");
41
+ }
42
+ /** `HH:MM`, twenty-four hour, leading zero or not. The only time shape this feature accepts. */
43
+ function normalizeTime(raw) {
44
+ if (typeof raw !== "string")
45
+ throw new Error("触发时刻要写成 \"HH:MM\",比如 \"09:00\"。");
46
+ const match = /^(\d{1,2}):(\d{2})$/.exec(raw.trim());
47
+ if (!match)
48
+ throw new Error("触发时刻要写成 \"HH:MM\",比如 \"09:00\"。");
49
+ const hours = Number(match[1]);
50
+ const minutes = Number(match[2]);
51
+ if (hours > 23 || minutes > 59)
52
+ throw new Error("触发时刻不是一个真实的时间。");
53
+ return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
54
+ }
55
+ /** The weekdays of a weekly schedule, sorted and deduplicated. An empty list means every day. */
56
+ function normalizeWeekdays(raw) {
57
+ if (!Array.isArray(raw))
58
+ throw new Error("每周任务需要给出星期几,比如 [1,3] 表示周一和周三。");
59
+ const days = Array.from(new Set(raw.map((item) => Number(item)).filter((item) => Number.isInteger(item) && item >= 0 && item <= 6)));
60
+ if (!days.length)
61
+ throw new Error("每周任务至少要有一天。");
62
+ return days.sort((a, b) => a - b);
63
+ }
64
+ /**
65
+ * WHEN THE TASK FIRES NEXT, given a moment to look forward from.
66
+ *
67
+ * `undefined` means "never again": a one-shot task whose moment has passed.
68
+ *
69
+ * The daily and weekly cases walk forward day by day in LOCAL time rather than adding 24 hours, so a
70
+ * schedule at 09:00 stays at 09:00 across a daylight-saving change instead of drifting to 08:00 or
71
+ * 10:00 for half the year.
72
+ */
73
+ export function nextRunAt(spec, from) {
74
+ if (spec.kind === "interval")
75
+ return from + spec.everyMinutes * 60_000;
76
+ if (spec.kind === "once")
77
+ return spec.at > from ? spec.at : undefined;
78
+ if (spec.kind === "daily")
79
+ return nextDailyAt(spec.time, from);
80
+ return nextWeeklyAt(spec.weekdays, spec.time, from);
81
+ }
82
+ /** The next moment at `HH:MM` strictly after `from`, in local time. */
83
+ function nextDailyAt(time, from) {
84
+ const [hours, minutes] = time.split(":").map(Number);
85
+ const candidate = new Date(from);
86
+ candidate.setHours(hours, minutes, 0, 0);
87
+ if (candidate.getTime() <= from)
88
+ candidate.setDate(candidate.getDate() + 1);
89
+ return candidate.getTime();
90
+ }
91
+ /**
92
+ * The next moment at `HH:MM` on one of `weekdays`, strictly after `from`, in local time.
93
+ *
94
+ * At most eight days are examined, which covers every weekday list including an empty one: an empty
95
+ * list means "every day", and the daily case is a special case of it.
96
+ */
97
+ function nextWeeklyAt(weekdays, time, from) {
98
+ const days = weekdays.length ? weekdays : [0, 1, 2, 3, 4, 5, 6];
99
+ const [hours, minutes] = time.split(":").map(Number);
100
+ for (let offset = 0; offset <= 7; offset += 1) {
101
+ const candidate = new Date(from);
102
+ candidate.setDate(candidate.getDate() + offset);
103
+ candidate.setHours(hours, minutes, 0, 0);
104
+ if (candidate.getTime() <= from)
105
+ continue;
106
+ if (days.includes(candidate.getDay()))
107
+ return candidate.getTime();
108
+ }
109
+ // Unreachable: within eight days every weekday in a full week comes round. The fallback keeps the
110
+ // function total rather than returning `undefined` for a shape that cannot happen.
111
+ return nextDailyAt(time, from);
112
+ }
113
+ /**
114
+ * THE SCHEDULE IN WORDS, for the panel and for the text the agent reads back.
115
+ *
116
+ * One implementation for both readers, because two descriptions of one schedule would eventually say
117
+ * two different things - and the agent's description is what the user is told when the agent says
118
+ * "I set up a daily task". If the two disagreed, the user would find out from the clock.
119
+ */
120
+ export function describeSchedule(spec) {
121
+ if (spec.kind === "interval")
122
+ return `每隔 ${spec.everyMinutes} 分钟`;
123
+ if (spec.kind === "daily")
124
+ return `每天 ${spec.time}`;
125
+ if (spec.kind === "weekly")
126
+ return `每周${spec.weekdays.map(weekdayName).join("、")} ${spec.time}`;
127
+ return `仅一次:${formatMoment(spec.at)}`;
128
+ }
129
+ /** 0 = Sunday, which is what `Date.getDay()` says. */
130
+ function weekdayName(day) {
131
+ return ["日", "一", "二", "三", "四", "五", "六"][day] ?? "?";
132
+ }
133
+ /** A moment as `YYYY-MM-DD HH:MM`, in local time. */
134
+ export function formatMoment(ms) {
135
+ const date = new Date(ms);
136
+ const pad = (value) => String(value).padStart(2, "0");
137
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
138
+ }
@@ -39,6 +39,17 @@ export interface EventBroadcaster {
39
39
  export interface ServerContext {
40
40
  memory?: import("./active-memory.js").ActiveMemory;
41
41
  knowledge?: import("./knowledge.js").KnowledgeBase;
42
+ web?: import("./web.js").WebService;
43
+ /** The agent-facing document tools. Optional so the server can start without them. */
44
+ documentService?: import("./application/document-service.js").DocumentService;
45
+ /**
46
+ * THE FEISHU TOOLS.
47
+ *
48
+ * Optional because the credential may be missing, not because the feature may be missing: the
49
+ * service is always constructed, and a call made before the credential is configured is refused
50
+ * with the message that says what to do - which a missing service could not say.
51
+ */
52
+ feishu?: import("./application/feishu-service.js").FeishuService;
42
53
  paths: ServerPaths;
43
54
  store: Store;
44
55
  runtime: AgentRuntime;
@@ -52,6 +63,14 @@ export interface ServerContext {
52
63
  catalogService: CatalogService;
53
64
  /** The providers a person added through the settings window. */
54
65
  providerService: ProviderService;
66
+ /**
67
+ * The scheduled tasks and the clock behind them.
68
+ *
69
+ * In the context because TWO layers need it: the command handler routes the panel's five commands
70
+ * into it, and the HTTP layer exposes the agent-facing endpoint that lets a running turn create a
71
+ * task of its own.
72
+ */
73
+ scheduledTaskService?: import("./application/scheduled-task-service.js").ScheduledTaskService;
55
74
  execService: ExecService;
56
75
  toolRegistry: ToolRegistry;
57
76
  toolRunner: ToolRunner;