@evandu/super-pi 0.1.131 → 0.1.133

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 (70) hide show
  1. package/README.md +18 -0
  2. package/agents/super-pi-feishu/index.ts +88 -0
  3. package/agents/super-pi-knowledge/index.ts +1 -1
  4. package/agents/super-pi-task/core.ts +177 -0
  5. package/agents/super-pi-task/index.ts +59 -0
  6. package/dist/.super-pi-check-inputs.json +2 -2
  7. package/dist/client/.super-pi-client-sources.json +12 -9
  8. package/dist/client/assets/CodeMirrorEditor-CZZfGD0U.js +2 -0
  9. package/dist/client/assets/{dist-B448EWTM.js → dist-0FH-oFco.js} +1 -1
  10. package/dist/client/assets/{dist-Fhkmvjdx.js → dist-4Pts9xPO.js} +1 -1
  11. package/dist/client/assets/{dist-BcE6tJvc.js → dist-B5ruVtUG.js} +1 -1
  12. package/dist/client/assets/{dist-CpuexUlh.js → dist-BJinarhP.js} +1 -1
  13. package/dist/client/assets/{dist-Cybsoapc.js → dist-BffE00vI.js} +1 -1
  14. package/dist/client/assets/{dist-DxoWS5MH.js → dist-BtTFCNVQ.js} +1 -1
  15. package/dist/client/assets/{dist-CtdlYgGc.js → dist-C2nuBCP4.js} +1 -1
  16. package/dist/client/assets/{dist-hfpd-aWa.js → dist-CApAttfj.js} +1 -1
  17. package/dist/client/assets/{dist-D6PoSxR5.js → dist-CE5BnwJa.js} +1 -1
  18. package/dist/client/assets/{dist-COp25nhe.js → dist-CFPXj4ws.js} +1 -1
  19. package/dist/client/assets/{dist-D7bj81KQ.js → dist-CUKaDTyO.js} +1 -1
  20. package/dist/client/assets/{dist-BFlq-kiF.js → dist-CYD2SqmG.js} +1 -1
  21. package/dist/client/assets/{dist-lQWWWxh9.js → dist-CxsunljU.js} +1 -1
  22. package/dist/client/assets/{dist-BXVFgriA.js → dist-D4x1XTQb.js} +1 -1
  23. package/dist/client/assets/{dist-DXNDtyHP.js → dist-DHqqpxo4.js} +1 -1
  24. package/dist/client/assets/{dist-r5Af94tH.js → dist-DOYWHMIq.js} +1 -1
  25. package/dist/client/assets/{dist-B68Q8uXX.js → dist-DQf5nNxa.js} +1 -1
  26. package/dist/client/assets/{dist-C6qSkkeN.js → dist-DbbBgSwL.js} +1 -1
  27. package/dist/client/assets/{dist-B1_1Ui9E.js → dist-LFfokYJs.js} +1 -1
  28. package/dist/client/assets/{dist-ByIqsb5-.js → dist-aL2NGji_.js} +1 -1
  29. package/dist/client/assets/{dist-C9AkgNbt.js → dist-azDN8a65.js} +1 -1
  30. package/dist/client/assets/{dist-DP2ygAFF.js → dist-fYp8Xwrx.js} +1 -1
  31. package/dist/client/assets/{index-CXB6U5mz.css → index-D3MEG8uH.css} +1 -1
  32. package/dist/client/assets/index-YEN4ysHF.js +100 -0
  33. package/dist/client/index.html +2 -2
  34. package/dist/server/agents.js +21 -1
  35. package/dist/server/application/feishu-service.d.ts +179 -0
  36. package/dist/server/application/feishu-service.js +358 -0
  37. package/dist/server/application/run-service.js +15 -1
  38. package/dist/server/application/scheduled-task-service.d.ts +164 -0
  39. package/dist/server/application/scheduled-task-service.js +350 -0
  40. package/dist/server/command-handler.d.ts +25 -1
  41. package/dist/server/command-handler.js +60 -1
  42. package/dist/server/feishu-cli.d.ts +223 -0
  43. package/dist/server/feishu-cli.js +453 -0
  44. package/dist/server/feishu-config.d.ts +68 -0
  45. package/dist/server/feishu-config.js +84 -0
  46. package/dist/server/feishu.d.ts +187 -0
  47. package/dist/server/feishu.js +695 -0
  48. package/dist/server/files.js +3 -3
  49. package/dist/server/http-server.js +232 -1
  50. package/dist/server/index.js +29 -2
  51. package/dist/server/knowledge.js +10 -2
  52. package/dist/server/persistence/app-state-repository.d.ts +13 -1
  53. package/dist/server/persistence/sqlite-state-repository.js +21 -0
  54. package/dist/server/pi-session-events.js +10 -2
  55. package/dist/server/pi-session-pool.d.ts +33 -1
  56. package/dist/server/pi-session-pool.js +20 -2
  57. package/dist/server/pi-session.js +3 -0
  58. package/dist/server/runtime.d.ts +47 -1
  59. package/dist/server/runtime.js +110 -15
  60. package/dist/server/scheduled-tasks.d.ts +101 -0
  61. package/dist/server/scheduled-tasks.js +138 -0
  62. package/dist/server/server-context.d.ts +16 -0
  63. package/dist/server/store.d.ts +30 -0
  64. package/dist/server/store.js +81 -2
  65. package/dist/server/task-tool.d.ts +18 -0
  66. package/dist/server/task-tool.js +18 -0
  67. package/dist/server/types.d.ts +56 -0
  68. package/package.json +26 -4
  69. package/dist/client/assets/CodeMirrorEditor-BlQhH3vs.js +0 -2
  70. package/dist/client/assets/index-BApJ3Zbq.js +0 -98
@@ -164,7 +164,11 @@ export interface AnswerInput {
164
164
  onToolPreparing?(call: ToolCallPreparing): void;
165
165
  /** Called while the primary model is emitting reasoning, so the UI can show live progress. A call means
166
166
  * the model is STILL THINKING - there is no separate flag, because Pi does not send one. */
167
- onThinking?(delta: string): void;
167
+ /** Called while the primary model is emitting reasoning, so the UI can show live progress. A call means
168
+ * the model is STILL THINKING - there is no separate flag, because Pi does not send one.
169
+ * The handler may be async: the reasoning signal is the same "the model is producing" signal as
170
+ * a text delta, and the caller waits for it so the state it writes is ordered with the delta. */
171
+ onThinking?(delta: string): void | Promise<void>;
168
172
  /** Called when the reasoning phase ends - the only signal that it has. */
169
173
  onThinkingEnd?(): void;
170
174
  }
@@ -517,6 +521,14 @@ export declare function taskExtensionPath(from?: string): string;
517
521
  * from `import.meta.dirname` reach the repository root both after `tsc -b` and under `tsx watch`.
518
522
  */
519
523
  export declare function documentExtensionPath(from?: string): string;
524
+ /**
525
+ * The Feishu extension's file, resolved the same way the document extension's is.
526
+ *
527
+ * Named on both sides of the wire rather than imported by the extension, for the reason every one
528
+ * of these names is written out twice: `agents/` is loaded by Pi's module loader and `src/` by
529
+ * tsc, so a relative import across the two would resolve differently under each.
530
+ */
531
+ export declare function feishuExtensionPath(from?: string): string;
520
532
  export declare class PiRpcRuntime implements AgentRuntime {
521
533
  private readonly sessionDir;
522
534
  /**
@@ -552,6 +564,12 @@ export declare class PiRpcRuntime implements AgentRuntime {
552
564
  private readonly options;
553
565
  private memory?;
554
566
  setMemory(memory: ActiveMemory): void;
567
+ /**
568
+ * The memory and knowledge credentials minted for each LIVE session, so they can be released when
569
+ * the session goes. A credential whose session is gone must not keep working - that is exactly the
570
+ * shape of a secret with no owner.
571
+ */
572
+ private readonly sessionExtensionTokens;
555
573
  private knowledge?;
556
574
  setKnowledge(knowledge: KnowledgeBase): void;
557
575
  private readonly runs;
@@ -642,6 +660,8 @@ export declare class PiRpcRuntime implements AgentRuntime {
642
660
  private warnedMissingExtension;
643
661
  /** Latches the "the document extension is missing" warning, for the same reason. */
644
662
  private warnedMissingDocumentExtension;
663
+ /** Latches the "the Feishu extension is missing" warning, for the same reason. */
664
+ private warnedMissingFeishuExtension;
645
665
  /** Latches the "Pi's SDK is not installed" warning, for the same reason. */
646
666
  private warnedMissingSdk;
647
667
  /**
@@ -709,6 +729,7 @@ export declare class PiRpcRuntime implements AgentRuntime {
709
729
  questionEndpoint?: string;
710
730
  toolsEndpoint?: string;
711
731
  documentsEndpoint?: string;
732
+ scheduledTaskEndpoint?: string;
712
733
  uploadsDir?: string;
713
734
  sessionFactory?: PiSessionFactory;
714
735
  sessionMode?: "sdk" | "oneshot";
@@ -804,6 +825,16 @@ export declare class PiRpcRuntime implements AgentRuntime {
804
825
  * runtime with no document endpoint mounts nothing rather than a tool whose every call fails.
805
826
  */
806
827
  private documentExtensionArgs;
828
+ /**
829
+ * The Feishu extension's spawn argument.
830
+ *
831
+ * MOUNTED UNCONDITIONALLY, for the same reason the document tools are: the endpoint is the gate,
832
+ * and the extension registers nothing without it. The credential is NOT the gate - a user who
833
+ * has not configured an application yet still gets the tools, because the error those tools
834
+ * return is the one that tells the user what to do, and a tool that is simply absent cannot say
835
+ * anything at all.
836
+ */
837
+ private feishuExtensionArgs;
807
838
  /**
808
839
  * The five values the extension reads out of its own environment. See `task-tool.ts` for why they are
809
840
  * written out on both sides rather than imported.
@@ -820,6 +851,21 @@ export declare class PiRpcRuntime implements AgentRuntime {
820
851
  * The TODO ENDPOINT is the one value here that is NOT per-turn derived from disk: it is this server's
821
852
  * own address, so it is the same string on every turn and is read once from the constructor's options.
822
853
  */
854
+ /**
855
+ * THE MEMORY AND KNOWLEDGE CREDENTIALS FOR A NEW SESSION.
856
+ *
857
+ * Both extensions read their environment ONCE, when they are loaded, so these are minted where the
858
+ * session is born - the same place, and for the same reason, as the session's capability. The
859
+ * one-shot path has always minted them (per turn); the session path minted none, so the memory and
860
+ * knowledge extensions returned before registering a tool or injecting a prompt, and both features
861
+ * were missing in production - the SDK session path is the DEFAULT backend, and only the test
862
+ * scripts take the one-shot path (ledger TEST-039).
863
+ *
864
+ * The endpoints are published as global environment variables (index.ts), and they are repeated
865
+ * here so the extension's contract is stated in one place: an endpoint and a credential travel
866
+ * together, and the credential is what proves the caller.
867
+ */
868
+ private mintSessionExtensionTokens;
823
869
  /**
824
870
  * The environment Super Pi's extensions read when they are loaded.
825
871
  *
@@ -4,6 +4,7 @@ export { RpcChannel } from "./rpc-channel.js";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { KNOWLEDGE_IMPORT_TOOL_NAME, KNOWLEDGE_TOOL_NAME } from "./knowledge.js";
6
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";
7
8
  import { WEB_FETCH_TOOL_NAME, WEB_SEARCH_TOOL_NAME } from "./web.js";
8
9
  import { existsSync } from "node:fs";
9
10
  import { mkdir, stat } from "node:fs/promises";
@@ -20,7 +21,7 @@ import { describeDeliveredUserMessage, describePiQueue, handleSessionEvent, Turn
20
21
  // paths recognise a delivery identically. Re-exported because they were part of this module's surface.
21
22
  export { describeDeliveredUserMessage, describePiQueue } from "./pi-session-events.js";
22
23
  import { createPiSession, createSdkCatalog, piSdkAvailable, resolveTranscriptFile, superPiExtensionPaths } from "./pi-session.js";
23
- 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";
24
25
  import { BLOCKING_UI_METHODS, EXTENSION_UI_METHODS, } from "./types.js";
25
26
  import { loadConversationImages } from "./upload.js";
26
27
  import { describeToolCall, describeToolCallArgs, describeToolCallInProgress, extractPartialResultText, extractToolResultText, readQuestionCall } from "./tool-calls.js";
@@ -248,6 +249,16 @@ export function taskExtensionPath(from = import.meta.dirname) {
248
249
  export function documentExtensionPath(from = import.meta.dirname) {
249
250
  return resolve(from, "../../agents/super-pi-documents/index.ts");
250
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
+ }
251
262
  /** Bridges a Pi session to Super Pi through Pi's documented JSONL RPC protocol. */
252
263
  /**
253
264
  * The tool's name as far as the stream has revealed it so far, or an empty string while the model is
@@ -278,6 +289,12 @@ export class PiRpcRuntime {
278
289
  options;
279
290
  memory;
280
291
  setMemory(memory) { this.memory = memory; }
292
+ /**
293
+ * The memory and knowledge credentials minted for each LIVE session, so they can be released when
294
+ * the session goes. A credential whose session is gone must not keep working - that is exactly the
295
+ * shape of a secret with no owner.
296
+ */
297
+ sessionExtensionTokens = new Map();
281
298
  knowledge;
282
299
  setKnowledge(knowledge) { this.knowledge = knowledge; }
283
300
  runs = new Map();
@@ -368,6 +385,8 @@ export class PiRpcRuntime {
368
385
  warnedMissingExtension = false;
369
386
  /** Latches the "the document extension is missing" warning, for the same reason. */
370
387
  warnedMissingDocumentExtension = false;
388
+ /** Latches the "the Feishu extension is missing" warning, for the same reason. */
389
+ warnedMissingFeishuExtension = false;
371
390
  /** Latches the "Pi's SDK is not installed" warning, for the same reason. */
372
391
  warnedMissingSdk = false;
373
392
  /**
@@ -453,7 +472,16 @@ export class PiRpcRuntime {
453
472
  // ABOUT that session and stop being true with it.
454
473
  this.sessionHosts.delete(conversationId);
455
474
  this.reportedSessionFiles.delete(conversationId);
456
- }, options.sessionFactory ?? createPiSession, (conversationId) => mintCapability(conversationId), () => this.modelEpoch);
475
+ // The session held these credentials too, so they die with it.
476
+ const tokens = this.sessionExtensionTokens.get(conversationId);
477
+ if (tokens) {
478
+ if (tokens.memoryToken)
479
+ this.memory?.release(tokens.memoryToken);
480
+ if (tokens.knowledgeToken)
481
+ this.knowledge?.release(tokens.knowledgeToken);
482
+ this.sessionExtensionTokens.delete(conversationId);
483
+ }
484
+ }, options.sessionFactory ?? createPiSession, (conversationId) => mintCapability(conversationId), () => this.modelEpoch, (conversationId) => this.mintSessionExtensionTokens(conversationId));
457
485
  }
458
486
  /**
459
487
  * The configuration of the session that was built for a conversation, for tests to inspect.
@@ -577,6 +605,28 @@ export class PiRpcRuntime {
577
605
  }
578
606
  return ["--extension", path];
579
607
  }
608
+ /**
609
+ * The Feishu extension's spawn argument.
610
+ *
611
+ * MOUNTED UNCONDITIONALLY, for the same reason the document tools are: the endpoint is the gate,
612
+ * and the extension registers nothing without it. The credential is NOT the gate - a user who
613
+ * has not configured an application yet still gets the tools, because the error those tools
614
+ * return is the one that tells the user what to do, and a tool that is simply absent cannot say
615
+ * anything at all.
616
+ */
617
+ feishuExtensionArgs() {
618
+ if (!process.env.SUPER_PI_FEISHU_ENDPOINT)
619
+ return [];
620
+ const path = feishuExtensionPath();
621
+ if (!existsSync(path)) {
622
+ if (!this.warnedMissingFeishuExtension) {
623
+ this.warnedMissingFeishuExtension = true;
624
+ console.warn(`PiRpcRuntime: ${path} does not exist, so the primary agent cannot read or write Feishu cloud documents.`);
625
+ }
626
+ return [];
627
+ }
628
+ return ["--extension", path];
629
+ }
580
630
  /**
581
631
  * The five values the extension reads out of its own environment. See `task-tool.ts` for why they are
582
632
  * written out on both sides rather than imported.
@@ -593,6 +643,37 @@ export class PiRpcRuntime {
593
643
  * The TODO ENDPOINT is the one value here that is NOT per-turn derived from disk: it is this server's
594
644
  * own address, so it is the same string on every turn and is read once from the constructor's options.
595
645
  */
646
+ /**
647
+ * THE MEMORY AND KNOWLEDGE CREDENTIALS FOR A NEW SESSION.
648
+ *
649
+ * Both extensions read their environment ONCE, when they are loaded, so these are minted where the
650
+ * session is born - the same place, and for the same reason, as the session's capability. The
651
+ * one-shot path has always minted them (per turn); the session path minted none, so the memory and
652
+ * knowledge extensions returned before registering a tool or injecting a prompt, and both features
653
+ * were missing in production - the SDK session path is the DEFAULT backend, and only the test
654
+ * scripts take the one-shot path (ledger TEST-039).
655
+ *
656
+ * The endpoints are published as global environment variables (index.ts), and they are repeated
657
+ * here so the extension's contract is stated in one place: an endpoint and a credential travel
658
+ * together, and the credential is what proves the caller.
659
+ */
660
+ async mintSessionExtensionTokens(conversationId) {
661
+ const minted = {};
662
+ const env = {};
663
+ if (this.memory && (await this.memory.configuration()).enabled) {
664
+ minted.memoryToken = this.memory.issue(conversationId);
665
+ env.SUPER_PI_MEMORY_TOKEN = minted.memoryToken;
666
+ env.SUPER_PI_MEMORY_ENDPOINT = process.env.SUPER_PI_MEMORY_ENDPOINT ?? "";
667
+ }
668
+ if (this.knowledge && this.knowledge.enabled()) {
669
+ minted.knowledgeToken = this.knowledge.issue(conversationId);
670
+ env.SUPER_PI_KNOWLEDGE_TOKEN = minted.knowledgeToken;
671
+ env.SUPER_PI_KNOWLEDGE_ENDPOINT = process.env.SUPER_PI_KNOWLEDGE_ENDPOINT ?? "";
672
+ }
673
+ if (minted.memoryToken || minted.knowledgeToken)
674
+ this.sessionExtensionTokens.set(conversationId, minted);
675
+ return env;
676
+ }
596
677
  /**
597
678
  * The environment Super Pi's extensions read when they are loaded.
598
679
  *
@@ -612,6 +693,7 @@ export class PiRpcRuntime {
612
693
  [TASK_CONVERSATION_ENV]: conversationId,
613
694
  [TASK_DEPTH_ENV]: "0",
614
695
  [TODO_ENDPOINT_ENV]: this.options.todoEndpoint ?? "",
696
+ [SCHEDULED_TASK_ENDPOINT_ENV]: this.options.scheduledTaskEndpoint ?? "",
615
697
  [WORKFLOW_ENDPOINT_ENV]: this.options.workflowEndpoint ?? "",
616
698
  SUPER_PI_QUESTION_ENDPOINT: this.options.questionEndpoint ?? "",
617
699
  SUPER_PI_TOOLS_ENDPOINT: this.options.toolsEndpoint ?? "",
@@ -623,6 +705,11 @@ export class PiRpcRuntime {
623
705
  SUPER_PI_DOCUMENTS_ENDPOINT: this.options.documentsEndpoint ?? "",
624
706
  SUPER_PI_DOCUMENTS_CONVERSATION: conversationId,
625
707
  SUPER_PI_WEB_ENDPOINT: process.env.SUPER_PI_WEB_ENDPOINT ?? "",
708
+ // The Feishu tools' endpoint and the conversation they act for. The CREDENTIAL is not here:
709
+ // it is the session's own capability, the same value the task dispatch uses, because a
710
+ // second credential for one session would be a second thing to rotate and to release.
711
+ SUPER_PI_FEISHU_ENDPOINT: process.env.SUPER_PI_FEISHU_ENDPOINT ?? "",
712
+ SUPER_PI_FEISHU_CONVERSATION: conversationId,
626
713
  };
627
714
  }
628
715
  /**
@@ -865,13 +952,10 @@ export class PiRpcRuntime {
865
952
  // pushes the terminal item when Pi settles, and the consumer below reads it. This promise
866
953
  // waits for that signal, and the race with the prompt's own failure is what keeps a turn that
867
954
  // never settles from hanging forever - a hung turn is a conversation that never answers.
868
- await Promise.race([
869
- queue.settledSignal,
870
- // The safety net. A session that was aborted, or a Pi that died mid-turn, may never settle,
871
- // and the turn still has to end - otherwise the conversation is stuck on a turn that will
872
- // never produce anything and the next message can never be sent.
873
- new Promise((resolvePromise) => setTimeout(resolvePromise, 5_000)),
874
- ]);
955
+ // `prompt()` only means Pi accepted the request. Do not end this generator on a wall-clock
956
+ // timeout: the session may still be streaming or executing a tool, and ending here drops
957
+ // subsequent events while falsely allowing the next prompt onto a busy session.
958
+ await queue.settledSignal;
875
959
  traceLatency(conversation.id, "session.settled", latencyStartedAt);
876
960
  // THE REVERT BOUNDARY, recorded at the same moment the one-shot path records it: the turn has
877
961
  // settled, so every entry this turn wrote - the user message included - is in the transcript.
@@ -1055,7 +1139,7 @@ export class PiRpcRuntime {
1055
1139
  * because `toolFlags` treats an explicit false as outranking a host extra.
1056
1140
  */
1057
1141
  extensionToolNames() {
1058
- const names = [TASK_TOOL_NAME, TODO_TOOL_NAME, WORKFLOW_TOOL_NAME, RUN_ASYNC_TOOL_NAME, "question", "tool_register"];
1142
+ const names = [TASK_TOOL_NAME, TODO_TOOL_NAME, WORKFLOW_TOOL_NAME, RUN_ASYNC_TOOL_NAME, SCHEDULED_TASK_TOOL_NAME, "question", "tool_register"];
1059
1143
  if (this.memory)
1060
1144
  names.push("memory", "skill_manage", "session_search");
1061
1145
  if (this.knowledge)
@@ -1067,6 +1151,12 @@ export class PiRpcRuntime {
1067
1151
  // would otherwise get a loaded-but-undeclared tool - broken with no error to explain it.
1068
1152
  if (this.options.documentsEndpoint)
1069
1153
  names.push(DOCUMENT_READ_TOOL_NAME, DOCUMENT_WRITE_TOOL_NAME);
1154
+ // The Feishu tools are named whenever the endpoint is configured, for the same reason the
1155
+ // document tools are: the endpoint is what decides whether the extension registers them, and
1156
+ // an agent that declares `tools: {"*": false, read: true}` would otherwise get a
1157
+ // loaded-but-undeclared tool - broken with no error to explain it.
1158
+ if (process.env.SUPER_PI_FEISHU_ENDPOINT)
1159
+ names.push(FEISHU_READ_TOOL_NAME, FEISHU_WRITE_TOOL_NAME);
1070
1160
  return names;
1071
1161
  }
1072
1162
  /**
@@ -1097,7 +1187,7 @@ export class PiRpcRuntime {
1097
1187
  if (segment.kind === "text")
1098
1188
  yield segment.text;
1099
1189
  else if (segment.kind === "thinking")
1100
- input.onThinking?.(segment.text);
1190
+ await input.onThinking?.(segment.text);
1101
1191
  else
1102
1192
  input.onThinkingEnd?.();
1103
1193
  }
@@ -1223,6 +1313,11 @@ export class PiRpcRuntime {
1223
1313
  const webExtension = resolve(import.meta.dirname, "../../agents/super-pi-web/index.ts");
1224
1314
  const webEnabled = !memoryReview && !knowledgeRerank && existsSync(webExtension) && !!process.env.SUPER_PI_WEB_ENDPOINT;
1225
1315
  const webArgs = webEnabled ? ["--extension", webExtension] : [];
1316
+ // The Feishu tools are mounted for every ordinary turn, and NOT for a review or a rerank: a
1317
+ // background reflection has no business reading a company's cloud documents, and a reranker
1318
+ // must not be able to reach the tools it is ranking.
1319
+ const feishuEnabled = !memoryReview && !knowledgeRerank && this.feishuExtensionArgs().length > 0;
1320
+ const feishuArgs = feishuEnabled ? this.feishuExtensionArgs() : [];
1226
1321
  // The document tools are mounted for every ordinary turn, and NOT for a review or a rerank: a
1227
1322
  // background reflection has no business reading a PDF it was not asked about, and a reranker
1228
1323
  // must not be able to reach the tools it is ranking.
@@ -1232,7 +1327,7 @@ export class PiRpcRuntime {
1232
1327
  // conversation, so minting a second one for the same conversation would replace the first and
1233
1328
  // leave the task dispatch holding a token the server has already forgotten. Both the task
1234
1329
  // extension and the document extension therefore share this one value.
1235
- const oneShotCapability = (extensionArgs.length || documentsEnabled || webEnabled) ? mintCapability(conversation.id) : undefined;
1330
+ const oneShotCapability = (extensionArgs.length || documentsEnabled || webEnabled || feishuEnabled) ? mintCapability(conversation.id) : undefined;
1236
1331
  // BOTH extension tools, or neither. Naming one while the other goes unmentioned would be a lie in a
1237
1332
  // different direction: Pi filters its registry through `--tools`, so an agent that declares
1238
1333
  // `tools: {"*": false, read: true}` gets whichever of the two is on this line and silently not the
@@ -1245,13 +1340,13 @@ export class PiRpcRuntime {
1245
1340
  // allowlist and naming a tool Pi has not registered would change the spawn line for every other
1246
1341
  // mode. The prompt asks for a bare JSON array and nothing else; if the model spends a turn on a
1247
1342
  // tool call anyway the reranker times out and falls back to the lexical order.
1248
- 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, KNOWLEDGE_IMPORT_TOOL_NAME] : []), ...(documentsEnabled ? [DOCUMENT_READ_TOOL_NAME, DOCUMENT_WRITE_TOOL_NAME] : []), ...(webEnabled ? [WEB_SEARCH_TOOL_NAME, WEB_FETCH_TOOL_NAME] : [])] });
1343
+ 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] : [])] });
1249
1344
  // Refused here, with the reason, rather than handed to the spawn: an ENOENT from `spawn` reads as
1250
1345
  // "the app is broken" and hides the one fact that matters, which is where it looked for Pi.
1251
1346
  if (!this.pi.found)
1252
1347
  throw new Error(this.pi.reason);
1253
1348
  // npm exposes global CLIs as .cmd shims on Windows; Node requires a shell to launch those shims.
1254
- const child = spawn(shellQuote(this.pi.command), ["--mode", "rpc", "--session-dir", this.sessionDir, "--session-id", conversation.id, ...extensionArgs, ...memoryArgs, ...knowledgeArgs, ...documentArgs, ...webArgs, ...agentArgs], {
1349
+ const child = spawn(shellQuote(this.pi.command), ["--mode", "rpc", "--session-dir", this.sessionDir, "--session-id", conversation.id, ...extensionArgs, ...memoryArgs, ...knowledgeArgs, ...documentArgs, ...webArgs, ...feishuArgs, ...agentArgs], {
1255
1350
  cwd: childCwd,
1256
1351
  stdio: ["pipe", "pipe", "pipe"],
1257
1352
  shell: process.platform === "win32",
@@ -1259,7 +1354,7 @@ export class PiRpcRuntime {
1259
1354
  // every turn would silently replace the child's environment with whatever THIS process has at
1260
1355
  // spawn time. Inheriting is what an omitted `env` already does, so the two paths differ in nothing
1261
1356
  // but the four documented additions.
1262
- ...((extensionArgs.length || memoryEnabled || knowledgeEnabled || documentsEnabled || webEnabled) ? { env: { ...process.env, ...(oneShotCapability ? await this.oneShotExtensionEnv(conversation.id, oneShotCapability) : {}), ...(memoryEnabled ? {
1357
+ ...((extensionArgs.length || memoryEnabled || knowledgeEnabled || documentsEnabled || webEnabled || feishuEnabled) ? { env: { ...process.env, ...(oneShotCapability ? await this.oneShotExtensionEnv(conversation.id, oneShotCapability) : {}), ...(memoryEnabled ? {
1263
1358
  SUPER_PI_MEMORY_ENDPOINT: process.env.SUPER_PI_MEMORY_ENDPOINT,
1264
1359
  SUPER_PI_MEMORY_TOKEN: memoryToken,
1265
1360
  SUPER_PI_MEMORY_REVIEW: memoryReview ? "1" : "0",
@@ -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
+ }
@@ -42,6 +42,14 @@ export interface ServerContext {
42
42
  web?: import("./web.js").WebService;
43
43
  /** The agent-facing document tools. Optional so the server can start without them. */
44
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;
45
53
  paths: ServerPaths;
46
54
  store: Store;
47
55
  runtime: AgentRuntime;
@@ -55,6 +63,14 @@ export interface ServerContext {
55
63
  catalogService: CatalogService;
56
64
  /** The providers a person added through the settings window. */
57
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;
58
74
  execService: ExecService;
59
75
  toolRegistry: ToolRegistry;
60
76
  toolRunner: ToolRunner;