@evandu/super-pi 0.1.130 → 0.1.132
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/agents/super-pi-documents/index.ts +92 -0
- package/agents/super-pi-feishu/index.ts +88 -0
- package/agents/super-pi-knowledge/index.ts +32 -1
- package/agents/super-pi-task/core.ts +217 -0
- package/agents/super-pi-task/index.ts +74 -1
- package/agents/super-pi-web/index.ts +32 -0
- package/assets/fonts/LICENSE +92 -0
- package/assets/fonts/NotoSansSC-Bold.otf +0 -0
- package/assets/fonts/NotoSansSC-Regular.otf +0 -0
- package/dist/client/.super-pi-client-sources.json +13 -10
- package/dist/client/assets/CodeMirrorEditor-ktyL2PcX.js +2 -0
- package/dist/client/assets/{dist-BKp4AWEJ.js → dist--igDAzRS.js} +1 -1
- package/dist/client/assets/{dist-BBW2SE_R.js → dist-60usx__o.js} +1 -1
- package/dist/client/assets/{dist-GkIV1LTX.js → dist-8pdzh6h1.js} +1 -1
- package/dist/client/assets/{dist-B2nPVdKh.js → dist-B8OZ-n_C.js} +1 -1
- package/dist/client/assets/{dist-BDygQgZD.js → dist-BAJCrbnX.js} +1 -1
- package/dist/client/assets/{dist-CiSGSVBZ.js → dist-BJi5HmH4.js} +1 -1
- package/dist/client/assets/{dist-CRzRB9k3.js → dist-BRjGckmQ.js} +1 -1
- package/dist/client/assets/{dist-Cxerc7Bb.js → dist-Bg9zLfex.js} +1 -1
- package/dist/client/assets/{dist-C90MPTnk.js → dist-BjZUYxLd.js} +1 -1
- package/dist/client/assets/{dist-DmAOqr0M.js → dist-Bv6P2Rcp.js} +1 -1
- package/dist/client/assets/{dist-BhJGK8-b.js → dist-C6iAHGvb.js} +1 -1
- package/dist/client/assets/{dist-Dq4-QFAJ.js → dist-CMscy2Nn.js} +1 -1
- package/dist/client/assets/{dist-Ur37340K.js → dist-CPLCQDqy.js} +1 -1
- package/dist/client/assets/{dist-DZnUERoW.js → dist-CgmISAMr.js} +1 -1
- package/dist/client/assets/{dist-CWYRjnj-.js → dist-CoszOk5k.js} +1 -1
- package/dist/client/assets/{dist-CMnvChDX.js → dist-Cu75NyQ0.js} +1 -1
- package/dist/client/assets/{dist-HdeIBl2J.js → dist-DNZMXYNq.js} +1 -1
- package/dist/client/assets/{dist-BykVFExd.js → dist-D_Zdnhm8.js} +1 -1
- package/dist/client/assets/{dist-ChszWGDv.js → dist-DvHghuGd.js} +1 -1
- package/dist/client/assets/{dist-CZ9I2JCd.js → dist-Hj_dWgPr.js} +1 -1
- package/dist/client/assets/{dist-B61RSa-K.js → dist-Zhdkfvng.js} +1 -1
- package/dist/client/assets/{dist-DB5M5fZG.js → dist-vsMBLoxV.js} +1 -1
- package/dist/client/assets/{index-D_c-CYAr.js → index-ChyemF2I.js} +23 -21
- package/dist/client/assets/{index-CXB6U5mz.css → index-D3MEG8uH.css} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/server/application/document-service.d.ts +68 -0
- package/dist/server/application/document-service.js +120 -0
- package/dist/server/application/feishu-service.d.ts +132 -0
- package/dist/server/application/feishu-service.js +264 -0
- package/dist/server/application/run-service.js +49 -7
- package/dist/server/application/scheduled-task-service.d.ts +164 -0
- package/dist/server/application/scheduled-task-service.js +350 -0
- package/dist/server/command-handler.d.ts +25 -1
- package/dist/server/command-handler.js +60 -1
- package/dist/server/documents.d.ts +152 -0
- package/dist/server/documents.js +998 -0
- package/dist/server/feishu-cli.d.ts +207 -0
- package/dist/server/feishu-cli.js +419 -0
- package/dist/server/file-changes.js +6 -4
- package/dist/server/files.d.ts +32 -0
- package/dist/server/files.js +31 -3
- package/dist/server/http-server.js +324 -2
- package/dist/server/index.js +41 -2
- package/dist/server/inline-thinking.d.ts +90 -0
- package/dist/server/inline-thinking.js +181 -0
- package/dist/server/knowledge.d.ts +24 -14
- package/dist/server/knowledge.js +57 -34
- package/dist/server/persistence/app-state-repository.d.ts +13 -1
- package/dist/server/persistence/sqlite-state-repository.js +3 -0
- package/dist/server/pi-session-pool.js +2 -1
- package/dist/server/pi-session.d.ts +4 -0
- package/dist/server/pi-session.js +14 -0
- package/dist/server/presentations/schema.d.ts +120 -0
- package/dist/server/presentations/schema.js +128 -0
- package/dist/server/runtime.d.ts +67 -0
- package/dist/server/runtime.js +204 -18
- package/dist/server/scheduled-tasks.d.ts +101 -0
- package/dist/server/scheduled-tasks.js +138 -0
- package/dist/server/server-context.d.ts +19 -0
- package/dist/server/store.d.ts +30 -0
- package/dist/server/store.js +81 -2
- package/dist/server/subagents.js +33 -6
- package/dist/server/task-tool.d.ts +18 -0
- package/dist/server/task-tool.js +18 -0
- package/dist/server/types.d.ts +56 -0
- package/dist/server/web.d.ts +24 -0
- package/dist/server/web.js +180 -0
- package/package.json +45 -6
- package/skills/presentation/SKILL.md +30 -0
- package/skills/presentation/references/data.md +10 -0
- package/skills/presentation/references/quality.md +12 -0
- package/skills/presentation/references/story.md +12 -0
- package/skills/presentation/references/templates-and-editing.md +11 -0
- package/skills/presentation/references/visual-system.md +10 -0
- package/dist/.super-pi-check-inputs.json +0 -4
- package/dist/client/assets/CodeMirrorEditor-BNVBZZSS.js +0 -2
package/dist/server/files.d.ts
CHANGED
|
@@ -97,9 +97,30 @@ export declare function looksBinary(sample: Uint8Array): boolean;
|
|
|
97
97
|
* the extension path with a fixture whose CONTENT is text.
|
|
98
98
|
*/
|
|
99
99
|
export declare function hasBinaryExtension(name: string): boolean;
|
|
100
|
+
/**
|
|
101
|
+
* The extensions of the documents the parsing layer can read.
|
|
102
|
+
*
|
|
103
|
+
* WHY THE LIST LIVES HERE AND NOT IN `documents.ts`. This module has no relative imports, and that is
|
|
104
|
+
* load-bearing: `scripts/files-api.test.mjs` copies this file ALONE into a temp directory and
|
|
105
|
+
* compiles it there, to prove that a broken containment check really does leak. An import of a
|
|
106
|
+
* sibling module would not resolve in that scratch directory, so the mutation would fail to compile
|
|
107
|
+
* - which the test would read as "the guard still refuses the escape", the exact opposite of what it
|
|
108
|
+
* is checking.
|
|
109
|
+
*
|
|
110
|
+
* The parsing layer holds the same list, and the two are checked against each other by
|
|
111
|
+
* `scripts/documents.test.mjs`, so the tree cannot offer a file the parser cannot read.
|
|
112
|
+
*/
|
|
113
|
+
export declare const DOCUMENT_EXTENSIONS: Set<string>;
|
|
100
114
|
/** Whether a file is worth putting in an editor tab at all, decided from its NAME alone, for the tree's
|
|
101
115
|
* own labelling. The authoritative answer comes from `readWorkspaceFile`, which reads bytes. */
|
|
102
116
|
export declare function isTextName(name: string): boolean;
|
|
117
|
+
/**
|
|
118
|
+
* Whether a file is a DOCUMENT the parsing layer can read, decided from its name.
|
|
119
|
+
*
|
|
120
|
+
* The same list the knowledge base and the document tools use, so a picker that offers a file and a
|
|
121
|
+
* server that accepts it cannot disagree about which files those are.
|
|
122
|
+
*/
|
|
123
|
+
export declare function isDocumentName(name: string): boolean;
|
|
103
124
|
/** What a caller is told about one entry in a directory. */
|
|
104
125
|
export type DirEntry = {
|
|
105
126
|
/** The path RELATIVE to the workspace root, always `/`-separated, never with a leading slash. This is
|
|
@@ -110,6 +131,17 @@ export type DirEntry = {
|
|
|
110
131
|
kind: "directory" | "file" | "symlink";
|
|
111
132
|
/** Whether a file is worth opening, decided from its name. `null` for a directory. */
|
|
112
133
|
text: boolean | null;
|
|
134
|
+
/**
|
|
135
|
+
* Whether this file is a DOCUMENT the parsing layer can read - a PDF, a Word document, a
|
|
136
|
+
* spreadsheet. `null` for a directory.
|
|
137
|
+
*
|
|
138
|
+
* WHY IT IS A SEPARATE FLAG AND NOT PART OF `text`. A document is not text - it cannot be opened in
|
|
139
|
+
* the editor, and opening it as text would show a zip file's bytes - but it IS worth offering to
|
|
140
|
+
* the person, because the knowledge base can import it and the agent can read it. One flag for
|
|
141
|
+
* "worth showing" would force the two cases into one, and a picker that offers a file the server
|
|
142
|
+
* then refuses is worse than one that offers nothing.
|
|
143
|
+
*/
|
|
144
|
+
document?: boolean;
|
|
113
145
|
/** Set when this entry is a link, and `kind` becomes `symlink` when it points OUT of the workspace. The
|
|
114
146
|
* tree can then say "cannot be opened" before the request comes back 403 rather than after. */
|
|
115
147
|
symlink?: boolean;
|
package/dist/server/files.js
CHANGED
|
@@ -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
|
|
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
|
|
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, "
|
|
163
|
+
throw new FilesError(403, "拒绝绝对路径:请给出相对于工作区根目录的路径", "outside_workspace");
|
|
164
164
|
return requested.replace(/\\/g, "/");
|
|
165
165
|
}
|
|
166
166
|
/**
|
|
@@ -352,6 +352,20 @@ export function hasBinaryExtension(name) {
|
|
|
352
352
|
}
|
|
353
353
|
return false;
|
|
354
354
|
}
|
|
355
|
+
/**
|
|
356
|
+
* The extensions of the documents the parsing layer can read.
|
|
357
|
+
*
|
|
358
|
+
* WHY THE LIST LIVES HERE AND NOT IN `documents.ts`. This module has no relative imports, and that is
|
|
359
|
+
* load-bearing: `scripts/files-api.test.mjs` copies this file ALONE into a temp directory and
|
|
360
|
+
* compiles it there, to prove that a broken containment check really does leak. An import of a
|
|
361
|
+
* sibling module would not resolve in that scratch directory, so the mutation would fail to compile
|
|
362
|
+
* - which the test would read as "the guard still refuses the escape", the exact opposite of what it
|
|
363
|
+
* is checking.
|
|
364
|
+
*
|
|
365
|
+
* The parsing layer holds the same list, and the two are checked against each other by
|
|
366
|
+
* `scripts/documents.test.mjs`, so the tree cannot offer a file the parser cannot read.
|
|
367
|
+
*/
|
|
368
|
+
export const DOCUMENT_EXTENSIONS = new Set([".pdf", ".docx", ".xlsx", ".pptx", ".ppt"]);
|
|
355
369
|
/** Whether a file is worth putting in an editor tab at all, decided from its NAME alone, for the tree's
|
|
356
370
|
* own labelling. The authoritative answer comes from `readWorkspaceFile`, which reads bytes. */
|
|
357
371
|
export function isTextName(name) {
|
|
@@ -365,6 +379,19 @@ export function isTextName(name) {
|
|
|
365
379
|
return true; // extensionless files are usually source or config; the sniff decides
|
|
366
380
|
return TEXT_EXTENSIONS.has(lower.slice(dot));
|
|
367
381
|
}
|
|
382
|
+
/**
|
|
383
|
+
* Whether a file is a DOCUMENT the parsing layer can read, decided from its name.
|
|
384
|
+
*
|
|
385
|
+
* The same list the knowledge base and the document tools use, so a picker that offers a file and a
|
|
386
|
+
* server that accepts it cannot disagree about which files those are.
|
|
387
|
+
*/
|
|
388
|
+
export function isDocumentName(name) {
|
|
389
|
+
const lower = basename(name).toLowerCase();
|
|
390
|
+
const dot = lower.lastIndexOf(".");
|
|
391
|
+
if (dot <= 0)
|
|
392
|
+
return false;
|
|
393
|
+
return DOCUMENT_EXTENSIONS.has(lower.slice(dot));
|
|
394
|
+
}
|
|
368
395
|
/** The `/`-separated relative form used on the wire and in every comparison. */
|
|
369
396
|
export function toRelative(root, absolute) {
|
|
370
397
|
return relative(root, absolute).split(sep).join("/");
|
|
@@ -409,6 +436,7 @@ export async function listDirectory(root, requested) {
|
|
|
409
436
|
name: dirent.name,
|
|
410
437
|
kind: outside ? "symlink" : isDir ? "directory" : "file",
|
|
411
438
|
text: isDir ? null : !outside && isTextName(dirent.name),
|
|
439
|
+
...(isDir ? {} : { document: !outside && isDocumentName(dirent.name) }),
|
|
412
440
|
...(isLink ? { symlink: true } : {}),
|
|
413
441
|
...(outside ? { outside: true } : {}),
|
|
414
442
|
});
|
|
@@ -9,7 +9,13 @@ import { findUploadFile, MAX_UPLOAD_BODY_BYTES, saveUpload } from "./upload.js";
|
|
|
9
9
|
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
|
+
import { verifyCapability } from "./capability.js";
|
|
13
|
+
import { conversationWorkspace } from "./model-filter.js";
|
|
14
|
+
import { describeSchedule } from "./scheduled-tasks.js";
|
|
12
15
|
import { MAX_KNOWLEDGE_REQUEST_BYTES } from "./knowledge.js";
|
|
16
|
+
import { MAX_DOCUMENT_REQUEST_BYTES } from "./application/document-service.js";
|
|
17
|
+
import { MAX_FEISHU_REQUEST_BYTES } from "./application/feishu-service.js";
|
|
18
|
+
import { MAX_WEB_REQUEST_BYTES } from "./web.js";
|
|
13
19
|
import { MAX_TOOL_BODY_BYTES } from "./tool-registry.js";
|
|
14
20
|
import { packageUpdateArgs } from "./update.js";
|
|
15
21
|
import { FileIndex } from "./file-index.js";
|
|
@@ -240,7 +246,18 @@ export function createHttpServer(options) {
|
|
|
240
246
|
// the same reason: it is reached only by the Pi process of one turn, carrying that turn's
|
|
241
247
|
// capability in a header. The MANAGEMENT half (`/api/knowledge`) is deliberately NOT exempted - it
|
|
242
248
|
// imports and deletes documents, so it stays behind the ordinary token.
|
|
243
|
-
|
|
249
|
+
//
|
|
250
|
+
// `/api/documents/tool` is the same shape again, and the exemption is safe for the same reason:
|
|
251
|
+
// the endpoint verifies the SESSION's capability on its own, and refuses a request whose turn is
|
|
252
|
+
// not running. The global token is a server-wide secret; the capability is a per-conversation
|
|
253
|
+
// one that only the Pi process of that conversation holds, so the exemption does not widen who
|
|
254
|
+
// can reach the server - it only spares the one caller that already proves who it is.
|
|
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)) {
|
|
244
261
|
response.writeHead(401, { "www-authenticate": "Bearer" }).end("Authentication required");
|
|
245
262
|
return;
|
|
246
263
|
}
|
|
@@ -705,6 +722,197 @@ export function createHttpServer(options) {
|
|
|
705
722
|
}
|
|
706
723
|
return;
|
|
707
724
|
}
|
|
725
|
+
if (url.pathname === "/api/documents/tool") {
|
|
726
|
+
if (!context.documentService) {
|
|
727
|
+
response.writeHead(503).end();
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
if (request.method !== "POST") {
|
|
731
|
+
response.writeHead(405).end();
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
const origin = request.headers.origin;
|
|
735
|
+
if (origin && origin !== url.origin) {
|
|
736
|
+
response.writeHead(403).end("Origin not allowed");
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
try {
|
|
740
|
+
let raw = "";
|
|
741
|
+
let totalBytes = 0;
|
|
742
|
+
// A DOCUMENT'S TEXT RIDES IN THE REQUEST BODY, so the cap is the document cap: a request
|
|
743
|
+
// past it is refused outright rather than truncated into a document that silently lost its
|
|
744
|
+
// tail.
|
|
745
|
+
for await (const chunk of request) {
|
|
746
|
+
totalBytes += chunk.length;
|
|
747
|
+
if (totalBytes > MAX_DOCUMENT_REQUEST_BYTES) {
|
|
748
|
+
response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "文档请求过大" }));
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
raw += chunk;
|
|
752
|
+
}
|
|
753
|
+
const body = raw ? JSON.parse(raw) : {};
|
|
754
|
+
// The capability says WHICH CONVERSATION is asking, and the workspace is derived from it
|
|
755
|
+
// rather than taken from the request: a request that named its own workspace would be a
|
|
756
|
+
// request that could read another workspace.
|
|
757
|
+
const conversationId = String(request.headers["x-documents-conversation"] ?? "");
|
|
758
|
+
const verdict = verifyCapability(conversationId, request.headers["x-documents-capability"], { running: context.runService.isRunning(conversationId) });
|
|
759
|
+
if (!verdict.ok) {
|
|
760
|
+
response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: verdict.message }));
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
const result = body.action === "write"
|
|
764
|
+
? await context.documentService.write(conversationId, String(body.path ?? ""), String(body.content ?? ""), typeof body.title === "string" ? body.title : undefined, typeof body.baseEtag === "string" ? body.baseEtag : undefined)
|
|
765
|
+
: await context.documentService.read(conversationId, String(body.path ?? ""));
|
|
766
|
+
response.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(result));
|
|
767
|
+
}
|
|
768
|
+
catch (error) {
|
|
769
|
+
response.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
|
770
|
+
}
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
if (url.pathname === "/api/web/tool") {
|
|
774
|
+
if (!context.web) {
|
|
775
|
+
response.writeHead(503).end();
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
if (request.method !== "POST") {
|
|
779
|
+
response.writeHead(405).end();
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
const origin = request.headers.origin;
|
|
783
|
+
if (origin && origin !== url.origin) {
|
|
784
|
+
response.writeHead(403).end("Origin not allowed");
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
try {
|
|
788
|
+
let raw = "", totalBytes = 0;
|
|
789
|
+
for await (const chunk of request) {
|
|
790
|
+
totalBytes += chunk.length;
|
|
791
|
+
if (totalBytes > MAX_WEB_REQUEST_BYTES) {
|
|
792
|
+
response.writeHead(413).end();
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
raw += chunk;
|
|
796
|
+
}
|
|
797
|
+
const conversationId = String(request.headers["x-web-conversation"] ?? "");
|
|
798
|
+
const verdict = verifyCapability(conversationId, request.headers["x-web-capability"], { running: context.runService.isRunning(conversationId) });
|
|
799
|
+
if (!verdict.ok) {
|
|
800
|
+
response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: verdict.message }));
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
const body = raw ? JSON.parse(raw) : {};
|
|
804
|
+
const result = body.action === "search"
|
|
805
|
+
? await context.web.search(String(body.query ?? ""), Number(body.limit ?? 5), abortedAs(request))
|
|
806
|
+
: body.action === "fetch"
|
|
807
|
+
? await context.web.fetch(String(body.url ?? ""), abortedAs(request))
|
|
808
|
+
: await Promise.reject(new Error("未知网页工具操作"));
|
|
809
|
+
json(200, result);
|
|
810
|
+
}
|
|
811
|
+
catch (error) {
|
|
812
|
+
json(400, { error: error instanceof Error ? error.message : String(error) });
|
|
813
|
+
}
|
|
814
|
+
return;
|
|
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
|
+
}
|
|
708
916
|
if (url.pathname === "/api/shutdown") {
|
|
709
917
|
if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
|
|
710
918
|
response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Shutdown requires a loopback or private network POST");
|
|
@@ -1054,14 +1262,128 @@ export function createHttpServer(options) {
|
|
|
1054
1262
|
}
|
|
1055
1263
|
return;
|
|
1056
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
|
+
}
|
|
1057
1368
|
if (url.pathname === "/api/question") {
|
|
1058
1369
|
if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
|
|
1059
1370
|
response.writeHead(403).end();
|
|
1060
1371
|
return;
|
|
1061
1372
|
}
|
|
1373
|
+
// READ WITH A CEILING. This endpoint is exempt from the global token (the caller proves itself
|
|
1374
|
+
// with a per-conversation capability instead), which means anyone on the LAN can REACH it - and
|
|
1375
|
+
// this loop used to read the whole body into one string before checking anything, so an oversized
|
|
1376
|
+
// body cost its full size in memory before the 403 said no. A question is a few hundred bytes;
|
|
1377
|
+
// 64KB is orders of magnitude above any honest one and far below what a hostile body could use,
|
|
1378
|
+
// and the shape is the same ceiling the `/api/tools` endpoints already enforce.
|
|
1062
1379
|
let raw = "";
|
|
1063
|
-
for await (const chunk of request)
|
|
1380
|
+
for await (const chunk of request) {
|
|
1064
1381
|
raw += chunk;
|
|
1382
|
+
if (raw.length > 64_000) {
|
|
1383
|
+
response.writeHead(413, { "content-type": "application/json" }).end(JSON.stringify({ error: "question request too large" }));
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1065
1387
|
try {
|
|
1066
1388
|
const body = JSON.parse(raw);
|
|
1067
1389
|
if (!body.conversationId || !body.question || !Array.isArray(body.options))
|
package/dist/server/index.js
CHANGED
|
@@ -15,12 +15,15 @@ import { SubagentSupervisor } from "./subagents.js";
|
|
|
15
15
|
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
|
+
import { DocumentService } from "./application/document-service.js";
|
|
19
|
+
import { FeishuService } from "./application/feishu-service.js";
|
|
18
20
|
import { CatalogService } from "./application/catalog-service.js";
|
|
19
21
|
import { ProviderService } from "./application/provider-service.js";
|
|
20
22
|
import { ProviderRegistry } from "./providers.js";
|
|
21
23
|
import { RunService } from "./application/run-service.js";
|
|
22
24
|
import { FileChangeTracker } from "./file-changes.js";
|
|
23
25
|
import { ConversationService } from "./application/conversation-service.js";
|
|
26
|
+
import { ScheduledTaskService } from "./application/scheduled-task-service.js";
|
|
24
27
|
import { WorkspaceService } from "./application/workspace-service.js";
|
|
25
28
|
import { CommandHandler } from "./command-handler.js";
|
|
26
29
|
import { TerminalService, terminalDisabledReason } from "./terminal.js";
|
|
@@ -32,6 +35,7 @@ import { installServerLogging } from "./logging.js";
|
|
|
32
35
|
import { ToolRegistry } from "./tool-registry.js";
|
|
33
36
|
import { ToolRunner } from "./tool-runner.js";
|
|
34
37
|
import { ToolLlmService } from "./tool-llm.js";
|
|
38
|
+
import { WebService } from "./web.js";
|
|
35
39
|
// ------------------------------------------------------------- Environment & paths
|
|
36
40
|
const rootDir = resolve(import.meta.dirname, "../..");
|
|
37
41
|
initRunningVersion(rootDir);
|
|
@@ -105,9 +109,11 @@ await mkdir(uploadsDir, { recursive: true }).catch(() => { });
|
|
|
105
109
|
const taskEndpoint = `http://${internalHost}:${port}/api/subagent/dispatch`;
|
|
106
110
|
const execEndpoint = `http://${internalHost}:${port}/api/exec`;
|
|
107
111
|
const todoEndpoint = `http://${internalHost}:${port}/api/todos`;
|
|
112
|
+
const scheduledTaskEndpoint = `http://${internalHost}:${port}/api/scheduled-tasks`;
|
|
108
113
|
const workflowEndpoint = `http://${internalHost}:${port}/api/workflow/run`;
|
|
109
114
|
const questionEndpoint = `http://${internalHost}:${port}/api/question`;
|
|
110
115
|
const toolsEndpoint = `http://${internalHost}:${port}/api/tools`;
|
|
116
|
+
const documentsEndpoint = `http://${internalHost}:${port}/api/documents/tool`;
|
|
111
117
|
// ------------------------------------------------------------- Core singletons
|
|
112
118
|
const store = await openStateStore(dataDir, process.env.SUPER_PI_PERSISTENCE ?? "sqlite");
|
|
113
119
|
const toolRegistry = new ToolRegistry(dataDir);
|
|
@@ -130,7 +136,7 @@ const paths = {
|
|
|
130
136
|
};
|
|
131
137
|
const runtime = process.env.SUPER_PI_RUNTIME === "welcome"
|
|
132
138
|
? new WelcomeRuntime()
|
|
133
|
-
: new PiRpcRuntime(join(dataDir, "pi-sessions"), undefined, { dir: initialAgentDirs, promptDir: agentPromptDir }, { endpoint: taskEndpoint, execEndpoint, todoEndpoint, workflowEndpoint, questionEndpoint, toolsEndpoint, uploadsDir });
|
|
139
|
+
: new PiRpcRuntime(join(dataDir, "pi-sessions"), undefined, { dir: initialAgentDirs, promptDir: agentPromptDir }, { endpoint: taskEndpoint, execEndpoint, todoEndpoint, workflowEndpoint, questionEndpoint, toolsEndpoint, documentsEndpoint, scheduledTaskEndpoint, uploadsDir });
|
|
134
140
|
const broadcaster = new WsBroadcaster(() => store.snapshot());
|
|
135
141
|
const memory = new ActiveMemory(join(dataDir, "memories"), store, runtime);
|
|
136
142
|
process.env.SUPER_PI_MEMORY_ENDPOINT = `http://${internalHost}:${port}/api/memory/tool`;
|
|
@@ -143,6 +149,19 @@ const knowledge = new KnowledgeBase(join(dataDir, "knowledge"), store, runtime,
|
|
|
143
149
|
process.env.SUPER_PI_KNOWLEDGE_ENDPOINT = `http://${internalHost}:${port}/api/knowledge/tool`;
|
|
144
150
|
if (runtime instanceof PiRpcRuntime)
|
|
145
151
|
runtime.setKnowledge(knowledge);
|
|
152
|
+
// The document tools. The endpoint is published here, the same way the knowledge one is: the Pi
|
|
153
|
+
// process of a turn reads it out of its environment, and the endpoint verifies the session's
|
|
154
|
+
// capability on its own.
|
|
155
|
+
const documentService = new DocumentService(store);
|
|
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`;
|
|
163
|
+
const web = new WebService();
|
|
164
|
+
process.env.SUPER_PI_WEB_ENDPOINT = `http://${internalHost}:${port}/api/web/tool`;
|
|
146
165
|
const extensionUiService = new ExtensionUiService(broadcaster);
|
|
147
166
|
const supervisor = new SubagentSupervisor(join(dataDir, "pi-sessions"), undefined, {
|
|
148
167
|
dir: initialAgentDirs,
|
|
@@ -246,9 +265,20 @@ const terminalService = new TerminalService({
|
|
|
246
265
|
*/
|
|
247
266
|
const execService = new ExecService((event) => broadcaster.broadcast(event), (sessionId, exitCode, stdout, stderr) => runService.enqueueExecResult(sessionId, exitCode, stdout, stderr));
|
|
248
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);
|
|
249
276
|
const serverContext = {
|
|
250
277
|
memory,
|
|
251
278
|
knowledge,
|
|
279
|
+
web,
|
|
280
|
+
documentService,
|
|
281
|
+
feishu,
|
|
252
282
|
paths,
|
|
253
283
|
store,
|
|
254
284
|
runtime,
|
|
@@ -261,6 +291,7 @@ const serverContext = {
|
|
|
261
291
|
extensionUiService,
|
|
262
292
|
catalogService,
|
|
263
293
|
providerService,
|
|
294
|
+
scheduledTaskService,
|
|
264
295
|
execService,
|
|
265
296
|
toolRegistry,
|
|
266
297
|
toolRunner,
|
|
@@ -271,7 +302,7 @@ const serverContext = {
|
|
|
271
302
|
terminalEnabled: () => terminalService.enabled,
|
|
272
303
|
terminalDisabledReason: () => terminalDisabledReason(),
|
|
273
304
|
};
|
|
274
|
-
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);
|
|
275
306
|
// ------------------------------------------------------------- Lifecycle & shutdown
|
|
276
307
|
let shutdownPromise;
|
|
277
308
|
async function closeHttpServer() {
|
|
@@ -290,6 +321,10 @@ async function shutdown(reason) {
|
|
|
290
321
|
execService.closeAll();
|
|
291
322
|
runService.abortAll();
|
|
292
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();
|
|
293
328
|
for (const c of store.snapshot().conversations) {
|
|
294
329
|
extensionUiService.cancelPendingUi(c.id, "aborted");
|
|
295
330
|
}
|
|
@@ -338,6 +373,10 @@ httpServer.listen(port, host, () => {
|
|
|
338
373
|
const displayHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
|
|
339
374
|
console.log(`Super Pi is running at http://${displayHost}:${port}`);
|
|
340
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();
|
|
341
380
|
if (host === "0.0.0.0" || host === "::") {
|
|
342
381
|
const lanIps = getLanIps();
|
|
343
382
|
for (const ip of lanIps) {
|