@evandu/super-pi 0.1.131 → 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 (60) 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/client/.super-pi-client-sources.json +11 -8
  7. package/dist/client/assets/CodeMirrorEditor-ktyL2PcX.js +2 -0
  8. package/dist/client/assets/{dist-C9AkgNbt.js → dist--igDAzRS.js} +1 -1
  9. package/dist/client/assets/{dist-DP2ygAFF.js → dist-60usx__o.js} +1 -1
  10. package/dist/client/assets/{dist-B448EWTM.js → dist-8pdzh6h1.js} +1 -1
  11. package/dist/client/assets/{dist-lQWWWxh9.js → dist-B8OZ-n_C.js} +1 -1
  12. package/dist/client/assets/{dist-hfpd-aWa.js → dist-BAJCrbnX.js} +1 -1
  13. package/dist/client/assets/{dist-B68Q8uXX.js → dist-BJi5HmH4.js} +1 -1
  14. package/dist/client/assets/{dist-COp25nhe.js → dist-BRjGckmQ.js} +1 -1
  15. package/dist/client/assets/{dist-Fhkmvjdx.js → dist-Bg9zLfex.js} +1 -1
  16. package/dist/client/assets/{dist-D7bj81KQ.js → dist-BjZUYxLd.js} +1 -1
  17. package/dist/client/assets/{dist-B1_1Ui9E.js → dist-Bv6P2Rcp.js} +1 -1
  18. package/dist/client/assets/{dist-ByIqsb5-.js → dist-C6iAHGvb.js} +1 -1
  19. package/dist/client/assets/{dist-Cybsoapc.js → dist-CMscy2Nn.js} +1 -1
  20. package/dist/client/assets/{dist-D6PoSxR5.js → dist-CPLCQDqy.js} +1 -1
  21. package/dist/client/assets/{dist-C6qSkkeN.js → dist-CgmISAMr.js} +1 -1
  22. package/dist/client/assets/{dist-DxoWS5MH.js → dist-CoszOk5k.js} +1 -1
  23. package/dist/client/assets/{dist-CpuexUlh.js → dist-Cu75NyQ0.js} +1 -1
  24. package/dist/client/assets/{dist-r5Af94tH.js → dist-DNZMXYNq.js} +1 -1
  25. package/dist/client/assets/{dist-BXVFgriA.js → dist-D_Zdnhm8.js} +1 -1
  26. package/dist/client/assets/{dist-CtdlYgGc.js → dist-DvHghuGd.js} +1 -1
  27. package/dist/client/assets/{dist-BcE6tJvc.js → dist-Hj_dWgPr.js} +1 -1
  28. package/dist/client/assets/{dist-DXNDtyHP.js → dist-Zhdkfvng.js} +1 -1
  29. package/dist/client/assets/{dist-BFlq-kiF.js → dist-vsMBLoxV.js} +1 -1
  30. package/dist/client/assets/{index-BApJ3Zbq.js → index-ChyemF2I.js} +23 -21
  31. package/dist/client/assets/{index-CXB6U5mz.css → index-D3MEG8uH.css} +1 -1
  32. package/dist/client/index.html +2 -2
  33. package/dist/server/application/feishu-service.d.ts +132 -0
  34. package/dist/server/application/feishu-service.js +264 -0
  35. package/dist/server/application/scheduled-task-service.d.ts +164 -0
  36. package/dist/server/application/scheduled-task-service.js +350 -0
  37. package/dist/server/command-handler.d.ts +25 -1
  38. package/dist/server/command-handler.js +60 -1
  39. package/dist/server/feishu-cli.d.ts +207 -0
  40. package/dist/server/feishu-cli.js +419 -0
  41. package/dist/server/files.js +3 -3
  42. package/dist/server/http-server.js +212 -1
  43. package/dist/server/index.js +29 -2
  44. package/dist/server/knowledge.js +10 -2
  45. package/dist/server/persistence/app-state-repository.d.ts +13 -1
  46. package/dist/server/persistence/sqlite-state-repository.js +3 -0
  47. package/dist/server/pi-session.js +3 -0
  48. package/dist/server/runtime.d.ts +21 -0
  49. package/dist/server/runtime.js +58 -6
  50. package/dist/server/scheduled-tasks.d.ts +101 -0
  51. package/dist/server/scheduled-tasks.js +138 -0
  52. package/dist/server/server-context.d.ts +16 -0
  53. package/dist/server/store.d.ts +30 -0
  54. package/dist/server/store.js +81 -2
  55. package/dist/server/task-tool.d.ts +18 -0
  56. package/dist/server/task-tool.js +18 -0
  57. package/dist/server/types.d.ts +56 -0
  58. package/package.json +17 -4
  59. package/dist/.super-pi-check-inputs.json +0 -4
  60. package/dist/client/assets/CodeMirrorEditor-BlQhH3vs.js +0 -2
@@ -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,106 @@ 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
+ }
808
916
  if (url.pathname === "/api/shutdown") {
809
917
  if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
810
918
  response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Shutdown requires a loopback or private network POST");
@@ -1154,6 +1262,109 @@ export function createHttpServer(options) {
1154
1262
  }
1155
1263
  return;
1156
1264
  }
1265
+ if (url.pathname === "/api/scheduled-tasks") {
1266
+ // THE AGENT'S DOOR TO THE SCHEDULED TASKS, and the same shape as `/api/todos`: exempt from the
1267
+ // global token because the caller proves itself with a per-conversation capability instead, and
1268
+ // restricted to loopback or a private network for the same reason `/api/todos` is.
1269
+ if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
1270
+ response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Scheduled task writes require a loopback or private network POST");
1271
+ return;
1272
+ }
1273
+ let raw = "";
1274
+ let totalBytes = 0;
1275
+ for await (const chunk of request) {
1276
+ totalBytes += chunk.length;
1277
+ if (totalBytes > MAX_FILE_BYTES * 2) {
1278
+ response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "请求体过大", code: "too_large" }));
1279
+ request.destroy();
1280
+ return;
1281
+ }
1282
+ raw += chunk;
1283
+ }
1284
+ const json = (status, body) => {
1285
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
1286
+ };
1287
+ try {
1288
+ const body = JSON.parse(raw);
1289
+ const conversationId = body.conversationId?.trim();
1290
+ if (!conversationId)
1291
+ throw new Error("scheduled_task requires conversationId");
1292
+ const verdict = runService.verifyPrimaryCapability(conversationId, body.capability);
1293
+ if (!verdict.ok) {
1294
+ json(403, { error: verdict.message });
1295
+ return;
1296
+ }
1297
+ const service = context.scheduledTaskService;
1298
+ if (!service) {
1299
+ json(503, { error: "定时任务功能在这个实例上没有启用。" });
1300
+ return;
1301
+ }
1302
+ // THE WORKSPACE IS THE CALLER'S, not the agent's choice. The conversation the turn belongs to
1303
+ // decides where the task runs and what its defaults are; a task could otherwise be filed under
1304
+ // a workspace its own author cannot see.
1305
+ const workspaceId = conversationWorkspace(store.snapshot(), conversationId).id;
1306
+ const action = (body.action ?? "create").trim();
1307
+ if (action === "list") {
1308
+ json(200, { tasks: service.listForWorkspace(workspaceId) });
1309
+ return;
1310
+ }
1311
+ if (action === "create") {
1312
+ const task = await service.create({
1313
+ name: body.name,
1314
+ prompt: body.prompt,
1315
+ schedule: body.schedule,
1316
+ workspaceId,
1317
+ model: body.model ? { provider: body.model.provider, modelId: body.model.modelId, label: body.model.label ?? body.model.modelId } : null,
1318
+ agentId: body.agentId,
1319
+ }, "agent");
1320
+ json(200, { task, describe: describeSchedule(task.schedule) });
1321
+ return;
1322
+ }
1323
+ if (action === "update") {
1324
+ const id = body.id?.trim();
1325
+ if (!id)
1326
+ throw new Error("scheduled_task update requires an id");
1327
+ // THE TASK MUST BE THE CALLER'S TO CHANGE. A task runs in a workspace: it opens its
1328
+ // conversation there, uses that workspace's defaults and its tools. The caller cannot even
1329
+ // LIST another workspace's tasks (`list` is scoped), so it must not be able to change or
1330
+ // delete them either - and the refusal must not say "it exists, over there".
1331
+ service.requireForWorkspace(id, workspaceId);
1332
+ const task = await service.update(id, {
1333
+ name: body.name,
1334
+ prompt: body.prompt,
1335
+ schedule: body.schedule,
1336
+ // "NOT MENTIONED" AND "CLEARED" ARE DIFFERENT REQUESTS. The extension only sends the
1337
+ // fields the model actually wrote, so a model that changes the prompt and says nothing
1338
+ // about the model leaves the key ABSENT - and the service reads an absent model as
1339
+ // "keep what is there". Mapping absence to null made "not mentioned" mean "delete", so a
1340
+ // model the user had picked for this task was silently dropped and the task fell back to
1341
+ // the default model (measured by scripts/scheduled-task-scope.test.mjs, ledger
1342
+ // TEST-025). Every other field here already has this asymmetry right: name, prompt,
1343
+ // schedule and agentId are all "absent means unchanged".
1344
+ model: body.model === undefined
1345
+ ? undefined
1346
+ : (body.model ? { provider: body.model.provider, modelId: body.model.modelId, label: body.model.label ?? body.model.modelId } : null),
1347
+ agentId: body.agentId,
1348
+ });
1349
+ json(200, { task, describe: describeSchedule(task.schedule) });
1350
+ return;
1351
+ }
1352
+ if (action === "delete") {
1353
+ const id = body.id?.trim();
1354
+ if (!id)
1355
+ throw new Error("scheduled_task delete requires an id");
1356
+ service.requireForWorkspace(id, workspaceId);
1357
+ await service.remove(id);
1358
+ json(200, { deleted: id });
1359
+ return;
1360
+ }
1361
+ json(400, { error: "action 只能是 create、list、update 或 delete。" });
1362
+ }
1363
+ catch (error) {
1364
+ json(400, { error: error instanceof Error ? error.message : "scheduled task request failed" });
1365
+ }
1366
+ return;
1367
+ }
1157
1368
  if (url.pathname === "/api/question") {
1158
1369
  if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
1159
1370
  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,9 @@ 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
+ }
121
124
  else if (transaction.kind === "conversation.messages") {
122
125
  // A DIFF, NOT A REWRITE. This used to DELETE the conversation's rows and re-INSERT every
123
126
  // message - stringified twice, once per table - on every single send, under
@@ -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
  }
@@ -517,6 +517,14 @@ export declare function taskExtensionPath(from?: string): string;
517
517
  * from `import.meta.dirname` reach the repository root both after `tsc -b` and under `tsx watch`.
518
518
  */
519
519
  export declare function documentExtensionPath(from?: string): string;
520
+ /**
521
+ * The Feishu extension's file, resolved the same way the document extension's is.
522
+ *
523
+ * Named on both sides of the wire rather than imported by the extension, for the reason every one
524
+ * of these names is written out twice: `agents/` is loaded by Pi's module loader and `src/` by
525
+ * tsc, so a relative import across the two would resolve differently under each.
526
+ */
527
+ export declare function feishuExtensionPath(from?: string): string;
520
528
  export declare class PiRpcRuntime implements AgentRuntime {
521
529
  private readonly sessionDir;
522
530
  /**
@@ -642,6 +650,8 @@ export declare class PiRpcRuntime implements AgentRuntime {
642
650
  private warnedMissingExtension;
643
651
  /** Latches the "the document extension is missing" warning, for the same reason. */
644
652
  private warnedMissingDocumentExtension;
653
+ /** Latches the "the Feishu extension is missing" warning, for the same reason. */
654
+ private warnedMissingFeishuExtension;
645
655
  /** Latches the "Pi's SDK is not installed" warning, for the same reason. */
646
656
  private warnedMissingSdk;
647
657
  /**
@@ -709,6 +719,7 @@ export declare class PiRpcRuntime implements AgentRuntime {
709
719
  questionEndpoint?: string;
710
720
  toolsEndpoint?: string;
711
721
  documentsEndpoint?: string;
722
+ scheduledTaskEndpoint?: string;
712
723
  uploadsDir?: string;
713
724
  sessionFactory?: PiSessionFactory;
714
725
  sessionMode?: "sdk" | "oneshot";
@@ -804,6 +815,16 @@ export declare class PiRpcRuntime implements AgentRuntime {
804
815
  * runtime with no document endpoint mounts nothing rather than a tool whose every call fails.
805
816
  */
806
817
  private documentExtensionArgs;
818
+ /**
819
+ * The Feishu extension's spawn argument.
820
+ *
821
+ * MOUNTED UNCONDITIONALLY, for the same reason the document tools are: the endpoint is the gate,
822
+ * and the extension registers nothing without it. The credential is NOT the gate - a user who
823
+ * has not configured an application yet still gets the tools, because the error those tools
824
+ * return is the one that tells the user what to do, and a tool that is simply absent cannot say
825
+ * anything at all.
826
+ */
827
+ private feishuExtensionArgs;
807
828
  /**
808
829
  * The five values the extension reads out of its own environment. See `task-tool.ts` for why they are
809
830
  * written out on both sides rather than imported.
@@ -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
@@ -368,6 +379,8 @@ export class PiRpcRuntime {
368
379
  warnedMissingExtension = false;
369
380
  /** Latches the "the document extension is missing" warning, for the same reason. */
370
381
  warnedMissingDocumentExtension = false;
382
+ /** Latches the "the Feishu extension is missing" warning, for the same reason. */
383
+ warnedMissingFeishuExtension = false;
371
384
  /** Latches the "Pi's SDK is not installed" warning, for the same reason. */
372
385
  warnedMissingSdk = false;
373
386
  /**
@@ -577,6 +590,28 @@ export class PiRpcRuntime {
577
590
  }
578
591
  return ["--extension", path];
579
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
+ }
580
615
  /**
581
616
  * The five values the extension reads out of its own environment. See `task-tool.ts` for why they are
582
617
  * written out on both sides rather than imported.
@@ -612,6 +647,7 @@ export class PiRpcRuntime {
612
647
  [TASK_CONVERSATION_ENV]: conversationId,
613
648
  [TASK_DEPTH_ENV]: "0",
614
649
  [TODO_ENDPOINT_ENV]: this.options.todoEndpoint ?? "",
650
+ [SCHEDULED_TASK_ENDPOINT_ENV]: this.options.scheduledTaskEndpoint ?? "",
615
651
  [WORKFLOW_ENDPOINT_ENV]: this.options.workflowEndpoint ?? "",
616
652
  SUPER_PI_QUESTION_ENDPOINT: this.options.questionEndpoint ?? "",
617
653
  SUPER_PI_TOOLS_ENDPOINT: this.options.toolsEndpoint ?? "",
@@ -623,6 +659,11 @@ export class PiRpcRuntime {
623
659
  SUPER_PI_DOCUMENTS_ENDPOINT: this.options.documentsEndpoint ?? "",
624
660
  SUPER_PI_DOCUMENTS_CONVERSATION: conversationId,
625
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,
626
667
  };
627
668
  }
628
669
  /**
@@ -1055,7 +1096,7 @@ export class PiRpcRuntime {
1055
1096
  * because `toolFlags` treats an explicit false as outranking a host extra.
1056
1097
  */
1057
1098
  extensionToolNames() {
1058
- 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"];
1059
1100
  if (this.memory)
1060
1101
  names.push("memory", "skill_manage", "session_search");
1061
1102
  if (this.knowledge)
@@ -1067,6 +1108,12 @@ export class PiRpcRuntime {
1067
1108
  // would otherwise get a loaded-but-undeclared tool - broken with no error to explain it.
1068
1109
  if (this.options.documentsEndpoint)
1069
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);
1070
1117
  return names;
1071
1118
  }
1072
1119
  /**
@@ -1223,6 +1270,11 @@ export class PiRpcRuntime {
1223
1270
  const webExtension = resolve(import.meta.dirname, "../../agents/super-pi-web/index.ts");
1224
1271
  const webEnabled = !memoryReview && !knowledgeRerank && existsSync(webExtension) && !!process.env.SUPER_PI_WEB_ENDPOINT;
1225
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() : [];
1226
1278
  // The document tools are mounted for every ordinary turn, and NOT for a review or a rerank: a
1227
1279
  // background reflection has no business reading a PDF it was not asked about, and a reranker
1228
1280
  // must not be able to reach the tools it is ranking.
@@ -1232,7 +1284,7 @@ export class PiRpcRuntime {
1232
1284
  // conversation, so minting a second one for the same conversation would replace the first and
1233
1285
  // leave the task dispatch holding a token the server has already forgotten. Both the task
1234
1286
  // extension and the document extension therefore share this one value.
1235
- const oneShotCapability = (extensionArgs.length || documentsEnabled || webEnabled) ? mintCapability(conversation.id) : undefined;
1287
+ const oneShotCapability = (extensionArgs.length || documentsEnabled || webEnabled || feishuEnabled) ? mintCapability(conversation.id) : undefined;
1236
1288
  // BOTH extension tools, or neither. Naming one while the other goes unmentioned would be a lie in a
1237
1289
  // different direction: Pi filters its registry through `--tools`, so an agent that declares
1238
1290
  // `tools: {"*": false, read: true}` gets whichever of the two is on this line and silently not the
@@ -1245,13 +1297,13 @@ export class PiRpcRuntime {
1245
1297
  // allowlist and naming a tool Pi has not registered would change the spawn line for every other
1246
1298
  // mode. The prompt asks for a bare JSON array and nothing else; if the model spends a turn on a
1247
1299
  // 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] : [])] });
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] : [])] });
1249
1301
  // Refused here, with the reason, rather than handed to the spawn: an ENOENT from `spawn` reads as
1250
1302
  // "the app is broken" and hides the one fact that matters, which is where it looked for Pi.
1251
1303
  if (!this.pi.found)
1252
1304
  throw new Error(this.pi.reason);
1253
1305
  // 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], {
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], {
1255
1307
  cwd: childCwd,
1256
1308
  stdio: ["pipe", "pipe", "pipe"],
1257
1309
  shell: process.platform === "win32",
@@ -1259,7 +1311,7 @@ export class PiRpcRuntime {
1259
1311
  // every turn would silently replace the child's environment with whatever THIS process has at
1260
1312
  // spawn time. Inheriting is what an omitted `env` already does, so the two paths differ in nothing
1261
1313
  // but the four documented additions.
1262
- ...((extensionArgs.length || memoryEnabled || knowledgeEnabled || documentsEnabled || webEnabled) ? { env: { ...process.env, ...(oneShotCapability ? await this.oneShotExtensionEnv(conversation.id, oneShotCapability) : {}), ...(memoryEnabled ? {
1314
+ ...((extensionArgs.length || memoryEnabled || knowledgeEnabled || documentsEnabled || webEnabled || feishuEnabled) ? { env: { ...process.env, ...(oneShotCapability ? await this.oneShotExtensionEnv(conversation.id, oneShotCapability) : {}), ...(memoryEnabled ? {
1263
1315
  SUPER_PI_MEMORY_ENDPOINT: process.env.SUPER_PI_MEMORY_ENDPOINT,
1264
1316
  SUPER_PI_MEMORY_TOKEN: memoryToken,
1265
1317
  SUPER_PI_MEMORY_REVIEW: memoryReview ? "1" : "0",