@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
@@ -150,17 +150,17 @@ function rejectUnsafeShape(requested) {
150
150
  // UNC: `\\server\share\...` and its POSIX-spelled twin `//server/share`. Named explicitly because
151
151
  // `isAbsolute` is true for the first on Windows and false for the second on Linux.
152
152
  if (/^[\\/]{2}/.test(requested))
153
- throw new FilesError(403, "拒绝 UNC 路径:路径必须在 workspace 内", "outside_workspace");
153
+ throw new FilesError(403, "拒绝 UNC 路径:请给出相对于工作区根目录的路径", "outside_workspace");
154
154
  // A drive letter in EITHER position: `C:\x` is absolute, `C:x` is drive-relative and resolves against
155
155
  // that drive's current directory, which is not a location the caller gets to choose. The colon is also
156
156
  // how NTFS alternate data streams are addressed (`notes.md:hidden`), which is a second file hiding
157
157
  // behind one path - handled by the segment check below.
158
158
  if (/^[A-Za-z]:/.test(requested))
159
- throw new FilesError(403, "拒绝 Windows 盘符路径:路径必须在 workspace 内", "outside_workspace");
159
+ throw new FilesError(403, "拒绝 Windows 盘符路径:请给出相对于工作区根目录的路径", "outside_workspace");
160
160
  // Rooted, POSIX or Windows: `/etc/passwd` and `\Windows`. Both are absolute, and the leading-slash
161
161
  // forms are spelled out so the refusal does not depend on the host's own path parser agreeing.
162
162
  if (requested.startsWith("/") || requested.startsWith("\\"))
163
- throw new FilesError(403, "拒绝绝对路径:路径必须在 workspace 内", "outside_workspace");
163
+ throw new FilesError(403, "拒绝绝对路径:请给出相对于工作区根目录的路径", "outside_workspace");
164
164
  return requested.replace(/\\/g, "/");
165
165
  }
166
166
  /**
@@ -10,8 +10,11 @@ import { checkDiskVersion } from "./version.js";
10
10
  import { gitBlame, gitDiff, gitLog, gitMutate, gitStatus } from "./git.js";
11
11
  import { computeFileDiff } from "./file-diff.js";
12
12
  import { verifyCapability } from "./capability.js";
13
+ import { conversationWorkspace } from "./model-filter.js";
14
+ import { describeSchedule } from "./scheduled-tasks.js";
13
15
  import { MAX_KNOWLEDGE_REQUEST_BYTES } from "./knowledge.js";
14
16
  import { MAX_DOCUMENT_REQUEST_BYTES } from "./application/document-service.js";
17
+ import { MAX_FEISHU_REQUEST_BYTES } from "./application/feishu-service.js";
15
18
  import { MAX_WEB_REQUEST_BYTES } from "./web.js";
16
19
  import { MAX_TOOL_BODY_BYTES } from "./tool-registry.js";
17
20
  import { packageUpdateArgs } from "./update.js";
@@ -249,7 +252,12 @@ export function createHttpServer(options) {
249
252
  // not running. The global token is a server-wide secret; the capability is a per-conversation
250
253
  // one that only the Pi process of that conversation holds, so the exemption does not widen who
251
254
  // can reach the server - it only spares the one caller that already proves who it is.
252
- if (requestPath !== "/api/memory/tool" && requestPath !== "/api/knowledge/tool" && requestPath !== "/api/documents/tool" && requestPath !== "/api/web/tool" && requestPath !== "/api/todos" && requestPath !== "/api/question" && (requestPath !== "/api/tools" || request.method !== "POST") && !requestPath.startsWith("/tools/") && !isAuthorized(request, accessToken)) {
255
+ //
256
+ // `/api/feishu/tool` is that shape once more. The Feishu credential is a company secret, so
257
+ // the endpoint that can spend it is exactly the one an agent's own turn can reach, and the
258
+ // management half (`/api/feishu/config`) stays behind the ordinary token AND the local-address
259
+ // predicate.
260
+ if (requestPath !== "/api/memory/tool" && requestPath !== "/api/knowledge/tool" && requestPath !== "/api/documents/tool" && requestPath !== "/api/web/tool" && requestPath !== "/api/feishu/tool" && requestPath !== "/api/todos" && requestPath !== "/api/scheduled-tasks" && requestPath !== "/api/question" && (requestPath !== "/api/tools" || request.method !== "POST") && !requestPath.startsWith("/tools/") && !isAuthorized(request, accessToken)) {
253
261
  response.writeHead(401, { "www-authenticate": "Bearer" }).end("Authentication required");
254
262
  return;
255
263
  }
@@ -805,6 +813,126 @@ export function createHttpServer(options) {
805
813
  }
806
814
  return;
807
815
  }
816
+ if (url.pathname === "/api/feishu/tool") {
817
+ if (!context.feishu) {
818
+ response.writeHead(503).end();
819
+ return;
820
+ }
821
+ if (request.method !== "POST") {
822
+ response.writeHead(405).end();
823
+ return;
824
+ }
825
+ const origin = request.headers.origin;
826
+ if (origin && origin !== url.origin) {
827
+ response.writeHead(403).end("Origin not allowed");
828
+ return;
829
+ }
830
+ try {
831
+ let raw = "", totalBytes = 0;
832
+ // The Markdown of a document rides in the request body, so the cap is the document cap.
833
+ for await (const chunk of request) {
834
+ totalBytes += chunk.length;
835
+ if (totalBytes > MAX_FEISHU_REQUEST_BYTES) {
836
+ response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "飞书请求过大" }));
837
+ return;
838
+ }
839
+ raw += chunk;
840
+ }
841
+ const conversationId = String(request.headers["x-feishu-conversation"] ?? "");
842
+ // THE SAME PROOF THE DOCUMENT TOOLS USE: the request must carry the session's own
843
+ // capability, and the session must actually be running a turn. The Feishu credential is a
844
+ // company secret, so this endpoint is the only way to spend it.
845
+ const verdict = verifyCapability(conversationId, request.headers["x-feishu-capability"], { running: context.runService.isRunning(conversationId) });
846
+ if (!verdict.ok) {
847
+ response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: verdict.message }));
848
+ return;
849
+ }
850
+ const body = raw ? JSON.parse(raw) : {};
851
+ json(200, await context.feishu.tool(body));
852
+ }
853
+ catch (error) {
854
+ json(400, { error: error instanceof Error ? error.message : String(error) });
855
+ }
856
+ return;
857
+ }
858
+ /**
859
+ * THE FEISHU CONNECTION, as the settings panel sees it.
860
+ *
861
+ * A GET answers with what the official CLI can do right now: whether it is installed, whether
862
+ * it has an application, and whether a person has authorized it. It never answers with a
863
+ * credential - the CLI keeps those in the OS keychain, and this server never sees them.
864
+ *
865
+ * The address predicate is on the POSTs and not on the GET: connecting is a change to the
866
+ * machine, and the project's rule is that changes like this come from the local network.
867
+ * Reading whether the CLI is connected leaks nothing beyond what the panel already shows.
868
+ */
869
+ if (url.pathname === "/api/feishu/status") {
870
+ if (!context.feishu) {
871
+ response.writeHead(503).end();
872
+ return;
873
+ }
874
+ if (request.method !== "GET") {
875
+ json(405, { error: "这个接口只接受 GET 请求。" });
876
+ return;
877
+ }
878
+ json(200, await context.feishu.status());
879
+ return;
880
+ }
881
+ /**
882
+ * STARTS THE CONNECT FLOW, and finishes it.
883
+ *
884
+ * The first POST asks the CLI for a device code (or for it to register an application), and
885
+ * answers with the URL the person has to open. The second one hands the device code back to
886
+ * the CLI once they have authorized it, which is where the token is written - into the keychain,
887
+ * not into this server.
888
+ *
889
+ * The CLI's own documentation describes exactly this two-step shape for agents, and the two
890
+ * calls are separate endpoints because the person has to go and do something in between.
891
+ */
892
+ if (url.pathname === "/api/feishu/connect" || url.pathname === "/api/feishu/connect/complete") {
893
+ if (!context.feishu) {
894
+ response.writeHead(503).end();
895
+ return;
896
+ }
897
+ if (request.method !== "POST") {
898
+ json(405, { error: "这个接口只接受 POST 请求。" });
899
+ return;
900
+ }
901
+ if (!isLocalOrPrivateAddress(remote)) {
902
+ json(403, { error: "连接飞书账号只允许来自本机或局域网的请求。" });
903
+ return;
904
+ }
905
+ try {
906
+ const result = url.pathname.endsWith("/complete")
907
+ ? await context.feishu.completeConnect()
908
+ : await context.feishu.connect();
909
+ json(result.ok ? 200 : 400, result);
910
+ }
911
+ catch (error) {
912
+ json(400, { error: error instanceof Error ? error.message : String(error) });
913
+ }
914
+ return;
915
+ }
916
+ /**
917
+ * CHECKS THE CONNECTION, and says why it does not work when it does not.
918
+ *
919
+ * The status already verifies the credential against Feishu, so this is the same answer shaped
920
+ * for a button - the difference is that a failure comes back as a MESSAGE AND A HINT rather
921
+ * than as a state the panel has to interpret.
922
+ */
923
+ if (url.pathname === "/api/feishu/verify" && request.method === "POST") {
924
+ if (!context.feishu) {
925
+ response.writeHead(503).end();
926
+ return;
927
+ }
928
+ if (!isLocalOrPrivateAddress(remote)) {
929
+ json(403, { error: "测试飞书连接只允许来自本机或局域网的请求。" });
930
+ return;
931
+ }
932
+ const result = await context.feishu.verify();
933
+ json(result.ok ? 200 : 400, result);
934
+ return;
935
+ }
808
936
  if (url.pathname === "/api/shutdown") {
809
937
  if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
810
938
  response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Shutdown requires a loopback or private network POST");
@@ -1154,6 +1282,109 @@ export function createHttpServer(options) {
1154
1282
  }
1155
1283
  return;
1156
1284
  }
1285
+ if (url.pathname === "/api/scheduled-tasks") {
1286
+ // THE AGENT'S DOOR TO THE SCHEDULED TASKS, and the same shape as `/api/todos`: exempt from the
1287
+ // global token because the caller proves itself with a per-conversation capability instead, and
1288
+ // restricted to loopback or a private network for the same reason `/api/todos` is.
1289
+ if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
1290
+ response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Scheduled task writes require a loopback or private network POST");
1291
+ return;
1292
+ }
1293
+ let raw = "";
1294
+ let totalBytes = 0;
1295
+ for await (const chunk of request) {
1296
+ totalBytes += chunk.length;
1297
+ if (totalBytes > MAX_FILE_BYTES * 2) {
1298
+ response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "请求体过大", code: "too_large" }));
1299
+ request.destroy();
1300
+ return;
1301
+ }
1302
+ raw += chunk;
1303
+ }
1304
+ const json = (status, body) => {
1305
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
1306
+ };
1307
+ try {
1308
+ const body = JSON.parse(raw);
1309
+ const conversationId = body.conversationId?.trim();
1310
+ if (!conversationId)
1311
+ throw new Error("scheduled_task requires conversationId");
1312
+ const verdict = runService.verifyPrimaryCapability(conversationId, body.capability);
1313
+ if (!verdict.ok) {
1314
+ json(403, { error: verdict.message });
1315
+ return;
1316
+ }
1317
+ const service = context.scheduledTaskService;
1318
+ if (!service) {
1319
+ json(503, { error: "定时任务功能在这个实例上没有启用。" });
1320
+ return;
1321
+ }
1322
+ // THE WORKSPACE IS THE CALLER'S, not the agent's choice. The conversation the turn belongs to
1323
+ // decides where the task runs and what its defaults are; a task could otherwise be filed under
1324
+ // a workspace its own author cannot see.
1325
+ const workspaceId = conversationWorkspace(store.snapshot(), conversationId).id;
1326
+ const action = (body.action ?? "create").trim();
1327
+ if (action === "list") {
1328
+ json(200, { tasks: service.listForWorkspace(workspaceId) });
1329
+ return;
1330
+ }
1331
+ if (action === "create") {
1332
+ const task = await service.create({
1333
+ name: body.name,
1334
+ prompt: body.prompt,
1335
+ schedule: body.schedule,
1336
+ workspaceId,
1337
+ model: body.model ? { provider: body.model.provider, modelId: body.model.modelId, label: body.model.label ?? body.model.modelId } : null,
1338
+ agentId: body.agentId,
1339
+ }, "agent");
1340
+ json(200, { task, describe: describeSchedule(task.schedule) });
1341
+ return;
1342
+ }
1343
+ if (action === "update") {
1344
+ const id = body.id?.trim();
1345
+ if (!id)
1346
+ throw new Error("scheduled_task update requires an id");
1347
+ // THE TASK MUST BE THE CALLER'S TO CHANGE. A task runs in a workspace: it opens its
1348
+ // conversation there, uses that workspace's defaults and its tools. The caller cannot even
1349
+ // LIST another workspace's tasks (`list` is scoped), so it must not be able to change or
1350
+ // delete them either - and the refusal must not say "it exists, over there".
1351
+ service.requireForWorkspace(id, workspaceId);
1352
+ const task = await service.update(id, {
1353
+ name: body.name,
1354
+ prompt: body.prompt,
1355
+ schedule: body.schedule,
1356
+ // "NOT MENTIONED" AND "CLEARED" ARE DIFFERENT REQUESTS. The extension only sends the
1357
+ // fields the model actually wrote, so a model that changes the prompt and says nothing
1358
+ // about the model leaves the key ABSENT - and the service reads an absent model as
1359
+ // "keep what is there". Mapping absence to null made "not mentioned" mean "delete", so a
1360
+ // model the user had picked for this task was silently dropped and the task fell back to
1361
+ // the default model (measured by scripts/scheduled-task-scope.test.mjs, ledger
1362
+ // TEST-025). Every other field here already has this asymmetry right: name, prompt,
1363
+ // schedule and agentId are all "absent means unchanged".
1364
+ model: body.model === undefined
1365
+ ? undefined
1366
+ : (body.model ? { provider: body.model.provider, modelId: body.model.modelId, label: body.model.label ?? body.model.modelId } : null),
1367
+ agentId: body.agentId,
1368
+ });
1369
+ json(200, { task, describe: describeSchedule(task.schedule) });
1370
+ return;
1371
+ }
1372
+ if (action === "delete") {
1373
+ const id = body.id?.trim();
1374
+ if (!id)
1375
+ throw new Error("scheduled_task delete requires an id");
1376
+ service.requireForWorkspace(id, workspaceId);
1377
+ await service.remove(id);
1378
+ json(200, { deleted: id });
1379
+ return;
1380
+ }
1381
+ json(400, { error: "action 只能是 create、list、update 或 delete。" });
1382
+ }
1383
+ catch (error) {
1384
+ json(400, { error: error instanceof Error ? error.message : "scheduled task request failed" });
1385
+ }
1386
+ return;
1387
+ }
1157
1388
  if (url.pathname === "/api/question") {
1158
1389
  if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
1159
1390
  response.writeHead(403).end();
@@ -16,12 +16,14 @@ import { SubagentRunRecorder } from "./subagent-runs.js";
16
16
  import { WorkflowEngine } from "./workflow-engine.js";
17
17
  import { ExtensionUiService } from "./application/extension-ui-service.js";
18
18
  import { DocumentService } from "./application/document-service.js";
19
+ import { FeishuService } from "./application/feishu-service.js";
19
20
  import { CatalogService } from "./application/catalog-service.js";
20
21
  import { ProviderService } from "./application/provider-service.js";
21
22
  import { ProviderRegistry } from "./providers.js";
22
23
  import { RunService } from "./application/run-service.js";
23
24
  import { FileChangeTracker } from "./file-changes.js";
24
25
  import { ConversationService } from "./application/conversation-service.js";
26
+ import { ScheduledTaskService } from "./application/scheduled-task-service.js";
25
27
  import { WorkspaceService } from "./application/workspace-service.js";
26
28
  import { CommandHandler } from "./command-handler.js";
27
29
  import { TerminalService, terminalDisabledReason } from "./terminal.js";
@@ -107,6 +109,7 @@ await mkdir(uploadsDir, { recursive: true }).catch(() => { });
107
109
  const taskEndpoint = `http://${internalHost}:${port}/api/subagent/dispatch`;
108
110
  const execEndpoint = `http://${internalHost}:${port}/api/exec`;
109
111
  const todoEndpoint = `http://${internalHost}:${port}/api/todos`;
112
+ const scheduledTaskEndpoint = `http://${internalHost}:${port}/api/scheduled-tasks`;
110
113
  const workflowEndpoint = `http://${internalHost}:${port}/api/workflow/run`;
111
114
  const questionEndpoint = `http://${internalHost}:${port}/api/question`;
112
115
  const toolsEndpoint = `http://${internalHost}:${port}/api/tools`;
@@ -133,7 +136,7 @@ const paths = {
133
136
  };
134
137
  const runtime = process.env.SUPER_PI_RUNTIME === "welcome"
135
138
  ? new WelcomeRuntime()
136
- : new PiRpcRuntime(join(dataDir, "pi-sessions"), undefined, { dir: initialAgentDirs, promptDir: agentPromptDir }, { endpoint: taskEndpoint, execEndpoint, todoEndpoint, workflowEndpoint, questionEndpoint, toolsEndpoint, documentsEndpoint, uploadsDir });
139
+ : new PiRpcRuntime(join(dataDir, "pi-sessions"), undefined, { dir: initialAgentDirs, promptDir: agentPromptDir }, { endpoint: taskEndpoint, execEndpoint, todoEndpoint, workflowEndpoint, questionEndpoint, toolsEndpoint, documentsEndpoint, scheduledTaskEndpoint, uploadsDir });
137
140
  const broadcaster = new WsBroadcaster(() => store.snapshot());
138
141
  const memory = new ActiveMemory(join(dataDir, "memories"), store, runtime);
139
142
  process.env.SUPER_PI_MEMORY_ENDPOINT = `http://${internalHost}:${port}/api/memory/tool`;
@@ -151,6 +154,12 @@ if (runtime instanceof PiRpcRuntime)
151
154
  // capability on its own.
152
155
  const documentService = new DocumentService(store);
153
156
  process.env.SUPER_PI_DOCUMENTS_ENDPOINT = documentsEndpoint;
157
+ // The Feishu tools. The credential belongs to the OFFICIAL CLI, which keeps it in the OS keychain,
158
+ // so this service holds nothing but the CLI's address. The endpoint is published here the same way
159
+ // the document one is: the Pi process of a turn reads it out of its environment, and the endpoint
160
+ // verifies the session's capability on its own.
161
+ const feishu = new FeishuService();
162
+ process.env.SUPER_PI_FEISHU_ENDPOINT = `http://${internalHost}:${port}/api/feishu/tool`;
154
163
  const web = new WebService();
155
164
  process.env.SUPER_PI_WEB_ENDPOINT = `http://${internalHost}:${port}/api/web/tool`;
156
165
  const extensionUiService = new ExtensionUiService(broadcaster);
@@ -256,11 +265,20 @@ const terminalService = new TerminalService({
256
265
  */
257
266
  const execService = new ExecService((event) => broadcaster.broadcast(event), (sessionId, exitCode, stdout, stderr) => runService.enqueueExecResult(sessionId, exitCode, stdout, stderr));
258
267
  runService.setExecService(execService);
268
+ /**
269
+ * THE SCHEDULED TASKS, and the clock that fires them.
270
+ *
271
+ * Constructed after the run service and the conversation service, because it drives both: a task that
272
+ * fires opens a conversation and sends a message into it, through exactly the path a person's own
273
+ * message takes.
274
+ */
275
+ const scheduledTaskService = new ScheduledTaskService(store, runService, conversationService, broadcaster);
259
276
  const serverContext = {
260
277
  memory,
261
278
  knowledge,
262
279
  web,
263
280
  documentService,
281
+ feishu,
264
282
  paths,
265
283
  store,
266
284
  runtime,
@@ -273,6 +291,7 @@ const serverContext = {
273
291
  extensionUiService,
274
292
  catalogService,
275
293
  providerService,
294
+ scheduledTaskService,
276
295
  execService,
277
296
  toolRegistry,
278
297
  toolRunner,
@@ -283,7 +302,7 @@ const serverContext = {
283
302
  terminalEnabled: () => terminalService.enabled,
284
303
  terminalDisabledReason: () => terminalDisabledReason(),
285
304
  };
286
- const commandHandler = new CommandHandler(workspaceService, conversationService, runService, extensionUiService, catalogService, terminalService, rootDir, providerService);
305
+ const commandHandler = new CommandHandler(workspaceService, conversationService, runService, extensionUiService, catalogService, terminalService, rootDir, providerService, scheduledTaskService);
287
306
  // ------------------------------------------------------------- Lifecycle & shutdown
288
307
  let shutdownPromise;
289
308
  async function closeHttpServer() {
@@ -302,6 +321,10 @@ async function shutdown(reason) {
302
321
  execService.closeAll();
303
322
  runService.abortAll();
304
323
  workflows.abortAll();
324
+ // The scheduler stops BEFORE the runs are waited for, so no new task starts while the server is
325
+ // closing. The runs already in flight are not aborted: a half-answered scheduled task is a real
326
+ // answer in a real conversation, and throwing it away to shut down faster would be the wrong trade.
327
+ await scheduledTaskService.stop();
305
328
  for (const c of store.snapshot().conversations) {
306
329
  extensionUiService.cancelPendingUi(c.id, "aborted");
307
330
  }
@@ -350,6 +373,10 @@ httpServer.listen(port, host, () => {
350
373
  const displayHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
351
374
  console.log(`Super Pi is running at http://${displayHost}:${port}`);
352
375
  logging.write("info", `listening on ${host}:${port} (pid ${process.pid})`);
376
+ // THE CLOCK STARTS WITH THE SERVER. A task whose moment passed while the server was down is fired
377
+ // once by the first sweep - the daily report the user asked for is still wanted when they come back,
378
+ // but seven of them are not.
379
+ scheduledTaskService.start();
353
380
  if (host === "0.0.0.0" || host === "::") {
354
381
  const lanIps = getLanIps();
355
382
  for (const ip of lanIps) {
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
3
- import { basename, extname, join } from "node:path";
3
+ import { basename, extname, join, relative, sep } from "node:path";
4
4
  import { looksBinary, resolveInsideRoot } from "./files.js";
5
5
  import { documentFormatOf, DocumentError, extractText } from "./documents.js";
6
6
  /** 1 MB. Big enough for a design doc or a spec, small enough that chunking stays instant. */
@@ -406,7 +406,15 @@ export class KnowledgeBase {
406
406
  }
407
407
  if (info.size > MAX_DOCUMENT_BYTES)
408
408
  throw new Error(`文件过大:${info.size} 字节超过 ${MAX_DOCUMENT_BYTES} 的上限`);
409
- source = body.path.trim();
409
+ // THE DOCUMENT'S IDENTITY IS THE FILE, NOT THE SPELLING OF ITS PATH. `resolveInsideRoot` has
410
+ // already resolved the caller's path to a canonical one, so the identity is derived from THAT -
411
+ // the workspace-relative path - instead of from whatever string the caller typed. Taking the raw
412
+ // string made `./notes.md` and `notes.md` two different documents: re-importing the same file
413
+ // under another spelling added a second document, and the REPLACED version stayed in the index
414
+ // where the agent could still cite it (measured by
415
+ // scripts/knowledge-import-identity.test.mjs, ledger TEST-027). The contract says a file whose
416
+ // content changed is an update, keeping its id, and only one document per file may exist.
417
+ source = relative(workspace.path, absolute).split(sep).join("/");
410
418
  if (!title)
411
419
  title = basename(absolute);
412
420
  if (documentFormat) {
@@ -10,7 +10,19 @@ export type StateTransaction = {
10
10
  run: SubagentRun;
11
11
  } | {
12
12
  kind: "collections.replace";
13
- values: Partial<Pick<AppState, "workflows" | "backgroundTasks">>;
13
+ values: Partial<Pick<AppState, "workflows" | "backgroundTasks" | "scheduledTasks">>;
14
+ }
15
+ /**
16
+ * Removes one collection row entirely.
17
+ *
18
+ * It exists because "this collection is empty" is stored as the ABSENCE of the key, not as an empty
19
+ * array: a fresh install must not claim the user considered a schedule and found none. A repository
20
+ * whose `collections.replace` can only write rows therefore cannot express "there are none" - it can
21
+ * only write `[]`, which is a different claim.
22
+ */
23
+ | {
24
+ kind: "collections.remove";
25
+ name: "workflows" | "backgroundTasks" | "scheduledTasks";
14
26
  } | {
15
27
  kind: "conversation.metadata";
16
28
  conversationId: string;
@@ -118,6 +118,27 @@ export class SqliteStateRepository {
118
118
  for (const [name, value] of Object.entries(transaction.values))
119
119
  write.run(name, JSON.stringify(value));
120
120
  }
121
+ else if (transaction.kind === "collections.remove") {
122
+ this.db.prepare("DELETE FROM collections WHERE name=?").run(transaction.name);
123
+ // AND THE BASE SNAPSHOT MUST STOP CARRYING IT TOO. Deleting only the overlay row was not
124
+ // enough: this loader treats an overlay as a PURE INCREMENT - "no row for this name" means
125
+ // "read it from the base snapshot" - so the base's stale array came back on the next load,
126
+ // and a scheduled task the user had deleted was resurrected and ran again on its old
127
+ // schedule (ledger TEST-038). "This collection is empty" is stored as the ABSENCE of the key
128
+ // (app-state-repository.ts), so the base has to lose the key as well - the row's deletion
129
+ // alone cannot express it, because the base is where the value would come from.
130
+ //
131
+ // The json backend has no incremental path and falls back to a whole-state save, which is
132
+ // why it was always right; that asymmetry is what makes this a defect and not a choice.
133
+ const base = this.db.prepare("SELECT json FROM state WHERE id=1").get();
134
+ if (base) {
135
+ const parsed = JSON.parse(String(base.json));
136
+ if (transaction.name in parsed) {
137
+ delete parsed[transaction.name];
138
+ this.db.prepare("UPDATE state SET json=? WHERE id=1").run(JSON.stringify(parsed));
139
+ }
140
+ }
141
+ }
121
142
  else if (transaction.kind === "conversation.messages") {
122
143
  // A DIFF, NOT A REWRITE. This used to DELETE the conversation's rows and re-INSERT every
123
144
  // message - stringified twice, once per table - on every single send, under
@@ -16,7 +16,7 @@
16
16
  * be announced as unhandled is still announced as unhandled - this bridge changes WHERE the events come
17
17
  * from, not what they mean.
18
18
  */
19
- import { describeToolCall, describeToolCallArgs, extractPartialResultText } from "./tool-calls.js";
19
+ import { describeToolCall, describeToolCallArgs, extractPartialResultText, readQuestionCall } from "./tool-calls.js";
20
20
  import { toolTargetPath } from "./file-changes.js";
21
21
  /** Keeps only real strings, so a malformed queue snapshot cannot put objects into a broadcast. */
22
22
  const textList = (value) => Array.isArray(value) ? value.filter((item) => typeof item === "string") : undefined;
@@ -254,7 +254,15 @@ export function handleSessionEvent(event, queue, handlers, report, failure) {
254
254
  return true;
255
255
  const name = record.toolName ?? "unknown";
256
256
  try {
257
- handlers.onToolCall?.({ id, name, detail: describeToolCall(name, record.args), args: describeToolCallArgs(record.args) });
257
+ // A `question` CALL IS READ HERE, AT `tool_execution_start` - the same place the one-shot path
258
+ // reads it, and for the same reason: the card on screen must be the tool call the model is
259
+ // actually blocked in, with the same identity and the same lifecycle. This path left the
260
+ // question out, so the host never saw a pending question and the model was told the session
261
+ // had no question waiting for an answer - the `question` tool was simply dead on the SDK
262
+ // session path, which is the PRODUCTION default (ledger TEST-037). Two paths must give the
263
+ // same answer to "what is a question call".
264
+ const question = readQuestionCall(name, record.args);
265
+ handlers.onToolCall?.({ id, name, detail: describeToolCall(name, record.args), args: describeToolCallArgs(record.args), ...(question ? { question } : {}) });
258
266
  }
259
267
  catch (error) {
260
268
  console.warn(`PiRpcRuntime: the tool call handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
@@ -87,6 +87,22 @@ export declare class PiSessionPool {
87
87
  * where that gets fixed.
88
88
  */
89
89
  private readonly modelEpoch;
90
+ /**
91
+ * Mints the OTHER per-session credentials the extensions need, at the moment the session is born.
92
+ *
93
+ * WHY A SECOND CALLBACK AND NOT PART OF THE CAPABILITY. The memory and knowledge extensions each
94
+ * need their own credential - the memory one even carries per-caller flags (read-only for a
95
+ * sub-agent) that a single session capability cannot express. The one-shot path has always minted
96
+ * them per turn; the SESSION path minted none, so those two extensions returned before registering
97
+ * a single tool or injecting a single line of prompt, and both features were absent in production -
98
+ * the SDK session path is the default backend, and only the test scripts use the one-shot path
99
+ * (ledger TEST-039).
100
+ *
101
+ * They are minted HERE, where the session is born, for the same reason the capability is: the
102
+ * extension reads its environment ONCE, when it is loaded, so a token minted per turn would be
103
+ * replaced before the second turn and the extension would still be holding the first turn's value.
104
+ */
105
+ private readonly mintExtensionTokens;
90
106
  private readonly sessions;
91
107
  constructor(
92
108
  /** Where Pi keeps its session files. */
@@ -122,7 +138,23 @@ export declare class PiSessionPool {
122
138
  * settings window makes every live session unable to reach the new model, and the next turn is
123
139
  * where that gets fixed.
124
140
  */
125
- modelEpoch?: () => number);
141
+ modelEpoch?: () => number,
142
+ /**
143
+ * Mints the OTHER per-session credentials the extensions need, at the moment the session is born.
144
+ *
145
+ * WHY A SECOND CALLBACK AND NOT PART OF THE CAPABILITY. The memory and knowledge extensions each
146
+ * need their own credential - the memory one even carries per-caller flags (read-only for a
147
+ * sub-agent) that a single session capability cannot express. The one-shot path has always minted
148
+ * them per turn; the SESSION path minted none, so those two extensions returned before registering
149
+ * a single tool or injecting a single line of prompt, and both features were absent in production -
150
+ * the SDK session path is the default backend, and only the test scripts use the one-shot path
151
+ * (ledger TEST-039).
152
+ *
153
+ * They are minted HERE, where the session is born, for the same reason the capability is: the
154
+ * extension reads its environment ONCE, when it is loaded, so a token minted per turn would be
155
+ * replaced before the second turn and the extension would still be holding the first turn's value.
156
+ */
157
+ mintExtensionTokens?: (conversationId: string) => Promise<Record<string, string>> | Record<string, string>);
126
158
  /**
127
159
  * The session for a conversation, built if there is none and REPLACED if the live one is stale.
128
160
  *
@@ -49,6 +49,7 @@ export class PiSessionPool {
49
49
  factory;
50
50
  mintCapability;
51
51
  modelEpoch;
52
+ mintExtensionTokens;
52
53
  sessions = new Map();
53
54
  constructor(
54
55
  /** Where Pi keeps its session files. */
@@ -84,7 +85,23 @@ export class PiSessionPool {
84
85
  * settings window makes every live session unable to reach the new model, and the next turn is
85
86
  * where that gets fixed.
86
87
  */
87
- modelEpoch = () => 0) {
88
+ modelEpoch = () => 0,
89
+ /**
90
+ * Mints the OTHER per-session credentials the extensions need, at the moment the session is born.
91
+ *
92
+ * WHY A SECOND CALLBACK AND NOT PART OF THE CAPABILITY. The memory and knowledge extensions each
93
+ * need their own credential - the memory one even carries per-caller flags (read-only for a
94
+ * sub-agent) that a single session capability cannot express. The one-shot path has always minted
95
+ * them per turn; the SESSION path minted none, so those two extensions returned before registering
96
+ * a single tool or injecting a single line of prompt, and both features were absent in production -
97
+ * the SDK session path is the default backend, and only the test scripts use the one-shot path
98
+ * (ledger TEST-039).
99
+ *
100
+ * They are minted HERE, where the session is born, for the same reason the capability is: the
101
+ * extension reads its environment ONCE, when it is loaded, so a token minted per turn would be
102
+ * replaced before the second turn and the extension would still be holding the first turn's value.
103
+ */
104
+ mintExtensionTokens = () => ({})) {
88
105
  this.sessionDir = sessionDir;
89
106
  this.repositoryRoot = repositoryRoot;
90
107
  this.agentDir = agentDir;
@@ -92,6 +109,7 @@ export class PiSessionPool {
92
109
  this.factory = factory;
93
110
  this.mintCapability = mintCapability;
94
111
  this.modelEpoch = modelEpoch;
112
+ this.mintExtensionTokens = mintExtensionTokens;
95
113
  }
96
114
  /**
97
115
  * The session for a conversation, built if there is none and REPLACED if the live one is stale.
@@ -138,7 +156,7 @@ export class PiSessionPool {
138
156
  // server separately requires that a turn is RUNNING for the token to be usable, which is what keeps a
139
157
  // longer-lived token from being a weaker credential than a per-turn one.
140
158
  const capability = this.mintCapability(request.conversationId);
141
- const extensionEnv = { ...request.extensionEnv, [CAPABILITY_ENV]: capability, [TOOLS_CAPABILITY_ENV]: capability };
159
+ const extensionEnv = { ...request.extensionEnv, [CAPABILITY_ENV]: capability, [TOOLS_CAPABILITY_ENV]: capability, ...await this.mintExtensionTokens(request.conversationId) };
142
160
  const building = this.build({ ...request, extensionEnv }, fingerprint);
143
161
  if (process.env.SUPER_PI_TRACE_LATENCY === "1")
144
162
  console.warn(`[super-pi latency] conversation=${request.conversationId} phase=session.build.begin`);
@@ -419,6 +419,9 @@ export function superPiExtensionPaths(repositoryRoot) {
419
419
  // whether to register anything: with no endpoint in its environment it returns without
420
420
  // registering a tool, so a runtime without a document endpoint mounts nothing harmful.
421
421
  join(repositoryRoot, "agents", "super-pi-documents", "index.ts"),
422
+ // The Feishu tools, mounted for every session on the same terms: the extension registers
423
+ // nothing without an endpoint in its environment.
424
+ join(repositoryRoot, "agents", "super-pi-feishu", "index.ts"),
422
425
  ];
423
426
  return paths.filter((path) => existsSync(path));
424
427
  }