@evandu/super-pi 0.1.130 → 0.1.131
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/agents/super-pi-documents/index.ts +92 -0
- package/agents/super-pi-knowledge/index.ts +32 -1
- package/agents/super-pi-task/core.ts +40 -0
- package/agents/super-pi-task/index.ts +15 -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/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +5 -5
- package/dist/client/assets/{CodeMirrorEditor-BNVBZZSS.js → CodeMirrorEditor-BlQhH3vs.js} +2 -2
- package/dist/client/assets/{dist-DmAOqr0M.js → dist-B1_1Ui9E.js} +1 -1
- package/dist/client/assets/{dist-GkIV1LTX.js → dist-B448EWTM.js} +1 -1
- package/dist/client/assets/{dist-CiSGSVBZ.js → dist-B68Q8uXX.js} +1 -1
- package/dist/client/assets/{dist-DB5M5fZG.js → dist-BFlq-kiF.js} +1 -1
- package/dist/client/assets/{dist-BykVFExd.js → dist-BXVFgriA.js} +1 -1
- package/dist/client/assets/{dist-CZ9I2JCd.js → dist-BcE6tJvc.js} +1 -1
- package/dist/client/assets/{dist-BhJGK8-b.js → dist-ByIqsb5-.js} +1 -1
- package/dist/client/assets/{dist-DZnUERoW.js → dist-C6qSkkeN.js} +1 -1
- package/dist/client/assets/{dist-BKp4AWEJ.js → dist-C9AkgNbt.js} +1 -1
- package/dist/client/assets/{dist-CRzRB9k3.js → dist-COp25nhe.js} +1 -1
- package/dist/client/assets/{dist-CMnvChDX.js → dist-CpuexUlh.js} +1 -1
- package/dist/client/assets/{dist-ChszWGDv.js → dist-CtdlYgGc.js} +1 -1
- package/dist/client/assets/{dist-Dq4-QFAJ.js → dist-Cybsoapc.js} +1 -1
- package/dist/client/assets/{dist-Ur37340K.js → dist-D6PoSxR5.js} +1 -1
- package/dist/client/assets/{dist-C90MPTnk.js → dist-D7bj81KQ.js} +1 -1
- package/dist/client/assets/{dist-BBW2SE_R.js → dist-DP2ygAFF.js} +1 -1
- package/dist/client/assets/{dist-B61RSa-K.js → dist-DXNDtyHP.js} +1 -1
- package/dist/client/assets/{dist-CWYRjnj-.js → dist-DxoWS5MH.js} +1 -1
- package/dist/client/assets/{dist-Cxerc7Bb.js → dist-Fhkmvjdx.js} +1 -1
- package/dist/client/assets/{dist-BDygQgZD.js → dist-hfpd-aWa.js} +1 -1
- package/dist/client/assets/{dist-B2nPVdKh.js → dist-lQWWWxh9.js} +1 -1
- package/dist/client/assets/{dist-HdeIBl2J.js → dist-r5Af94tH.js} +1 -1
- package/dist/client/assets/{index-D_c-CYAr.js → index-BApJ3Zbq.js} +3 -3
- package/dist/client/index.html +1 -1
- package/dist/server/application/document-service.d.ts +68 -0
- package/dist/server/application/document-service.js +120 -0
- package/dist/server/application/run-service.js +49 -7
- package/dist/server/documents.d.ts +152 -0
- package/dist/server/documents.js +998 -0
- package/dist/server/file-changes.js +6 -4
- package/dist/server/files.d.ts +32 -0
- package/dist/server/files.js +28 -0
- package/dist/server/http-server.js +113 -2
- package/dist/server/index.js +13 -1
- 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 +47 -32
- package/dist/server/pi-session-pool.js +2 -1
- package/dist/server/pi-session.d.ts +4 -0
- package/dist/server/pi-session.js +11 -0
- package/dist/server/presentations/schema.d.ts +120 -0
- package/dist/server/presentations/schema.js +128 -0
- package/dist/server/runtime.d.ts +46 -0
- package/dist/server/runtime.js +150 -16
- package/dist/server/server-context.d.ts +3 -0
- package/dist/server/subagents.js +33 -6
- package/dist/server/web.d.ts +24 -0
- package/dist/server/web.js +180 -0
- package/package.json +31 -5
- 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
|
@@ -106,8 +106,8 @@ const MAX_BASELINE_BYTES = 1024 * 1024;
|
|
|
106
106
|
* `stat` reports by default rounds two edits inside the same millisecond into one value, and a check
|
|
107
107
|
* that misses a change is worse than no check.
|
|
108
108
|
*/
|
|
109
|
-
function etagOf(
|
|
110
|
-
return createHash("sha1").update(
|
|
109
|
+
function etagOf(content) {
|
|
110
|
+
return createHash("sha1").update(content, "utf8").digest("hex").slice(0, 20);
|
|
111
111
|
}
|
|
112
112
|
export class FileChangeTracker {
|
|
113
113
|
/** Workspace id -> path -> the change recorded for it. A path is recorded once: the LAST change to a
|
|
@@ -165,7 +165,7 @@ export class FileChangeTracker {
|
|
|
165
165
|
}
|
|
166
166
|
else {
|
|
167
167
|
before = readFileSync(absolute, "utf8");
|
|
168
|
-
etag = etagOf(
|
|
168
|
+
etag = etagOf(before);
|
|
169
169
|
}
|
|
170
170
|
}
|
|
171
171
|
catch (error) {
|
|
@@ -223,7 +223,9 @@ export class FileChangeTracker {
|
|
|
223
223
|
return;
|
|
224
224
|
try {
|
|
225
225
|
const info = statSync(resolve(root, change.path), { bigint: true });
|
|
226
|
-
|
|
226
|
+
if (info.size > MAX_BASELINE_BYTES)
|
|
227
|
+
return;
|
|
228
|
+
const current = etagOf(readFileSync(resolve(root, change.path), "utf8"));
|
|
227
229
|
if (current === change.etag) {
|
|
228
230
|
// The file is byte-for-byte what the baseline says it was, so the baseline was taken AFTER the
|
|
229
231
|
// write - or nothing changed at all. Either way it cannot be shown as "before".
|
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
|
@@ -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,10 @@ 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";
|
|
12
13
|
import { MAX_KNOWLEDGE_REQUEST_BYTES } from "./knowledge.js";
|
|
14
|
+
import { MAX_DOCUMENT_REQUEST_BYTES } from "./application/document-service.js";
|
|
15
|
+
import { MAX_WEB_REQUEST_BYTES } from "./web.js";
|
|
13
16
|
import { MAX_TOOL_BODY_BYTES } from "./tool-registry.js";
|
|
14
17
|
import { packageUpdateArgs } from "./update.js";
|
|
15
18
|
import { FileIndex } from "./file-index.js";
|
|
@@ -240,7 +243,13 @@ export function createHttpServer(options) {
|
|
|
240
243
|
// the same reason: it is reached only by the Pi process of one turn, carrying that turn's
|
|
241
244
|
// capability in a header. The MANAGEMENT half (`/api/knowledge`) is deliberately NOT exempted - it
|
|
242
245
|
// imports and deletes documents, so it stays behind the ordinary token.
|
|
243
|
-
|
|
246
|
+
//
|
|
247
|
+
// `/api/documents/tool` is the same shape again, and the exemption is safe for the same reason:
|
|
248
|
+
// the endpoint verifies the SESSION's capability on its own, and refuses a request whose turn is
|
|
249
|
+
// not running. The global token is a server-wide secret; the capability is a per-conversation
|
|
250
|
+
// one that only the Pi process of that conversation holds, so the exemption does not widen who
|
|
251
|
+
// 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)) {
|
|
244
253
|
response.writeHead(401, { "www-authenticate": "Bearer" }).end("Authentication required");
|
|
245
254
|
return;
|
|
246
255
|
}
|
|
@@ -705,6 +714,97 @@ export function createHttpServer(options) {
|
|
|
705
714
|
}
|
|
706
715
|
return;
|
|
707
716
|
}
|
|
717
|
+
if (url.pathname === "/api/documents/tool") {
|
|
718
|
+
if (!context.documentService) {
|
|
719
|
+
response.writeHead(503).end();
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
if (request.method !== "POST") {
|
|
723
|
+
response.writeHead(405).end();
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
const origin = request.headers.origin;
|
|
727
|
+
if (origin && origin !== url.origin) {
|
|
728
|
+
response.writeHead(403).end("Origin not allowed");
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
try {
|
|
732
|
+
let raw = "";
|
|
733
|
+
let totalBytes = 0;
|
|
734
|
+
// A DOCUMENT'S TEXT RIDES IN THE REQUEST BODY, so the cap is the document cap: a request
|
|
735
|
+
// past it is refused outright rather than truncated into a document that silently lost its
|
|
736
|
+
// tail.
|
|
737
|
+
for await (const chunk of request) {
|
|
738
|
+
totalBytes += chunk.length;
|
|
739
|
+
if (totalBytes > MAX_DOCUMENT_REQUEST_BYTES) {
|
|
740
|
+
response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "文档请求过大" }));
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
raw += chunk;
|
|
744
|
+
}
|
|
745
|
+
const body = raw ? JSON.parse(raw) : {};
|
|
746
|
+
// The capability says WHICH CONVERSATION is asking, and the workspace is derived from it
|
|
747
|
+
// rather than taken from the request: a request that named its own workspace would be a
|
|
748
|
+
// request that could read another workspace.
|
|
749
|
+
const conversationId = String(request.headers["x-documents-conversation"] ?? "");
|
|
750
|
+
const verdict = verifyCapability(conversationId, request.headers["x-documents-capability"], { running: context.runService.isRunning(conversationId) });
|
|
751
|
+
if (!verdict.ok) {
|
|
752
|
+
response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: verdict.message }));
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
const result = body.action === "write"
|
|
756
|
+
? 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)
|
|
757
|
+
: await context.documentService.read(conversationId, String(body.path ?? ""));
|
|
758
|
+
response.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(result));
|
|
759
|
+
}
|
|
760
|
+
catch (error) {
|
|
761
|
+
response.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
|
762
|
+
}
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
if (url.pathname === "/api/web/tool") {
|
|
766
|
+
if (!context.web) {
|
|
767
|
+
response.writeHead(503).end();
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
if (request.method !== "POST") {
|
|
771
|
+
response.writeHead(405).end();
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
const origin = request.headers.origin;
|
|
775
|
+
if (origin && origin !== url.origin) {
|
|
776
|
+
response.writeHead(403).end("Origin not allowed");
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
try {
|
|
780
|
+
let raw = "", totalBytes = 0;
|
|
781
|
+
for await (const chunk of request) {
|
|
782
|
+
totalBytes += chunk.length;
|
|
783
|
+
if (totalBytes > MAX_WEB_REQUEST_BYTES) {
|
|
784
|
+
response.writeHead(413).end();
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
raw += chunk;
|
|
788
|
+
}
|
|
789
|
+
const conversationId = String(request.headers["x-web-conversation"] ?? "");
|
|
790
|
+
const verdict = verifyCapability(conversationId, request.headers["x-web-capability"], { running: context.runService.isRunning(conversationId) });
|
|
791
|
+
if (!verdict.ok) {
|
|
792
|
+
response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: verdict.message }));
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
const body = raw ? JSON.parse(raw) : {};
|
|
796
|
+
const result = body.action === "search"
|
|
797
|
+
? await context.web.search(String(body.query ?? ""), Number(body.limit ?? 5), abortedAs(request))
|
|
798
|
+
: body.action === "fetch"
|
|
799
|
+
? await context.web.fetch(String(body.url ?? ""), abortedAs(request))
|
|
800
|
+
: await Promise.reject(new Error("未知网页工具操作"));
|
|
801
|
+
json(200, result);
|
|
802
|
+
}
|
|
803
|
+
catch (error) {
|
|
804
|
+
json(400, { error: error instanceof Error ? error.message : String(error) });
|
|
805
|
+
}
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
708
808
|
if (url.pathname === "/api/shutdown") {
|
|
709
809
|
if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
|
|
710
810
|
response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Shutdown requires a loopback or private network POST");
|
|
@@ -1059,9 +1159,20 @@ export function createHttpServer(options) {
|
|
|
1059
1159
|
response.writeHead(403).end();
|
|
1060
1160
|
return;
|
|
1061
1161
|
}
|
|
1162
|
+
// READ WITH A CEILING. This endpoint is exempt from the global token (the caller proves itself
|
|
1163
|
+
// with a per-conversation capability instead), which means anyone on the LAN can REACH it - and
|
|
1164
|
+
// this loop used to read the whole body into one string before checking anything, so an oversized
|
|
1165
|
+
// body cost its full size in memory before the 403 said no. A question is a few hundred bytes;
|
|
1166
|
+
// 64KB is orders of magnitude above any honest one and far below what a hostile body could use,
|
|
1167
|
+
// and the shape is the same ceiling the `/api/tools` endpoints already enforce.
|
|
1062
1168
|
let raw = "";
|
|
1063
|
-
for await (const chunk of request)
|
|
1169
|
+
for await (const chunk of request) {
|
|
1064
1170
|
raw += chunk;
|
|
1171
|
+
if (raw.length > 64_000) {
|
|
1172
|
+
response.writeHead(413, { "content-type": "application/json" }).end(JSON.stringify({ error: "question request too large" }));
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1065
1176
|
try {
|
|
1066
1177
|
const body = JSON.parse(raw);
|
|
1067
1178
|
if (!body.conversationId || !body.question || !Array.isArray(body.options))
|
package/dist/server/index.js
CHANGED
|
@@ -15,6 +15,7 @@ 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";
|
|
18
19
|
import { CatalogService } from "./application/catalog-service.js";
|
|
19
20
|
import { ProviderService } from "./application/provider-service.js";
|
|
20
21
|
import { ProviderRegistry } from "./providers.js";
|
|
@@ -32,6 +33,7 @@ import { installServerLogging } from "./logging.js";
|
|
|
32
33
|
import { ToolRegistry } from "./tool-registry.js";
|
|
33
34
|
import { ToolRunner } from "./tool-runner.js";
|
|
34
35
|
import { ToolLlmService } from "./tool-llm.js";
|
|
36
|
+
import { WebService } from "./web.js";
|
|
35
37
|
// ------------------------------------------------------------- Environment & paths
|
|
36
38
|
const rootDir = resolve(import.meta.dirname, "../..");
|
|
37
39
|
initRunningVersion(rootDir);
|
|
@@ -108,6 +110,7 @@ const todoEndpoint = `http://${internalHost}:${port}/api/todos`;
|
|
|
108
110
|
const workflowEndpoint = `http://${internalHost}:${port}/api/workflow/run`;
|
|
109
111
|
const questionEndpoint = `http://${internalHost}:${port}/api/question`;
|
|
110
112
|
const toolsEndpoint = `http://${internalHost}:${port}/api/tools`;
|
|
113
|
+
const documentsEndpoint = `http://${internalHost}:${port}/api/documents/tool`;
|
|
111
114
|
// ------------------------------------------------------------- Core singletons
|
|
112
115
|
const store = await openStateStore(dataDir, process.env.SUPER_PI_PERSISTENCE ?? "sqlite");
|
|
113
116
|
const toolRegistry = new ToolRegistry(dataDir);
|
|
@@ -130,7 +133,7 @@ const paths = {
|
|
|
130
133
|
};
|
|
131
134
|
const runtime = process.env.SUPER_PI_RUNTIME === "welcome"
|
|
132
135
|
? new WelcomeRuntime()
|
|
133
|
-
: new PiRpcRuntime(join(dataDir, "pi-sessions"), undefined, { dir: initialAgentDirs, promptDir: agentPromptDir }, { endpoint: taskEndpoint, execEndpoint, todoEndpoint, workflowEndpoint, questionEndpoint, toolsEndpoint, uploadsDir });
|
|
136
|
+
: new PiRpcRuntime(join(dataDir, "pi-sessions"), undefined, { dir: initialAgentDirs, promptDir: agentPromptDir }, { endpoint: taskEndpoint, execEndpoint, todoEndpoint, workflowEndpoint, questionEndpoint, toolsEndpoint, documentsEndpoint, uploadsDir });
|
|
134
137
|
const broadcaster = new WsBroadcaster(() => store.snapshot());
|
|
135
138
|
const memory = new ActiveMemory(join(dataDir, "memories"), store, runtime);
|
|
136
139
|
process.env.SUPER_PI_MEMORY_ENDPOINT = `http://${internalHost}:${port}/api/memory/tool`;
|
|
@@ -143,6 +146,13 @@ const knowledge = new KnowledgeBase(join(dataDir, "knowledge"), store, runtime,
|
|
|
143
146
|
process.env.SUPER_PI_KNOWLEDGE_ENDPOINT = `http://${internalHost}:${port}/api/knowledge/tool`;
|
|
144
147
|
if (runtime instanceof PiRpcRuntime)
|
|
145
148
|
runtime.setKnowledge(knowledge);
|
|
149
|
+
// The document tools. The endpoint is published here, the same way the knowledge one is: the Pi
|
|
150
|
+
// process of a turn reads it out of its environment, and the endpoint verifies the session's
|
|
151
|
+
// capability on its own.
|
|
152
|
+
const documentService = new DocumentService(store);
|
|
153
|
+
process.env.SUPER_PI_DOCUMENTS_ENDPOINT = documentsEndpoint;
|
|
154
|
+
const web = new WebService();
|
|
155
|
+
process.env.SUPER_PI_WEB_ENDPOINT = `http://${internalHost}:${port}/api/web/tool`;
|
|
146
156
|
const extensionUiService = new ExtensionUiService(broadcaster);
|
|
147
157
|
const supervisor = new SubagentSupervisor(join(dataDir, "pi-sessions"), undefined, {
|
|
148
158
|
dir: initialAgentDirs,
|
|
@@ -249,6 +259,8 @@ runService.setExecService(execService);
|
|
|
249
259
|
const serverContext = {
|
|
250
260
|
memory,
|
|
251
261
|
knowledge,
|
|
262
|
+
web,
|
|
263
|
+
documentService,
|
|
252
264
|
paths,
|
|
253
265
|
store,
|
|
254
266
|
runtime,
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE REASONING A MODEL PUTS INSIDE ITS OWN ANSWER.
|
|
3
|
+
*
|
|
4
|
+
* Some models do not have a reasoning channel. They answer in one stream of text and put their
|
|
5
|
+
* thinking in it, marked up with `<think>…</think>` tags - MiniMax's M2 series is the one this
|
|
6
|
+
* file exists for, and every gateway that proxies it behaves the same way. The tags arrive as
|
|
7
|
+
* ordinary text, so nothing downstream can tell them from an answer.
|
|
8
|
+
*
|
|
9
|
+
* WHY THIS IS SUPER PI'S PROBLEM AND NOT THE USER'S. Pi's OpenAI-compatible adapter only treats
|
|
10
|
+
* `reasoning_content`, `reasoning`, `reasoning_text` and `reasoning_details` as reasoning; it
|
|
11
|
+
* never parses tags out of `content`. A provider that inlines its thinking therefore hands Pi one
|
|
12
|
+
* long text block, Pi hands Super Pi one long text block, and Super Pi would draw the reasoning as
|
|
13
|
+
* part of the answer - in the reader's face, unlabelled, with the tags still in it. The obvious
|
|
14
|
+
* "fix" is to tell the user to go and set a provider-specific switch in Pi's `models.json`, which
|
|
15
|
+
* is a private API of a program Super Pi ships, for a quirk of one vendor. A user who never
|
|
16
|
+
* hears of it sees a broken transcript and has no way to know why.
|
|
17
|
+
*
|
|
18
|
+
* SO THE SPLIT HAPPENS HERE, ON SUPER PI'S OWN SIDE, FOR EVERY PROVIDER. Whatever the endpoint
|
|
19
|
+
* does with its reasoning, the transcript reads the same way: the reasoning goes into a thinking
|
|
20
|
+
* block, the answer goes into the answer, and the tags are never shown to anyone.
|
|
21
|
+
*
|
|
22
|
+
* THE ORDER IS THE WHOLE TRICK. A tag can straddle two deltas - the model writes `</thi` and then
|
|
23
|
+
* `nk>` in the next one - so the splitter holds back the tail that might be the start of a tag
|
|
24
|
+
* until the next delta settles it. And a delta can hold an answer, a thought and another answer,
|
|
25
|
+
* so the splitter returns SEGMENTS IN ORDER rather than "the text" and "the thinking": the caller
|
|
26
|
+
* has to be able to put the answer that came before a thought after it, and has to be able to say
|
|
27
|
+
* where that thought began.
|
|
28
|
+
*
|
|
29
|
+
* THE RULES, because each of them has a case that would otherwise be a bug:
|
|
30
|
+
*
|
|
31
|
+
* - A PROPERLY CLOSED region is reasoning. It leaves the answer and enters the thinking block.
|
|
32
|
+
* - AN UNCLOSED region is reasoning too. The model said it was thinking and then the turn
|
|
33
|
+
* ended - an abort, a truncation, a provider that stopped mid-thought. The reasoning goes
|
|
34
|
+
* into the block, and the answer keeps whatever came before the tag. Nothing is thrown away
|
|
35
|
+
* either way: the fold is expandable, so the words are still there to be read.
|
|
36
|
+
* - A CLOSING TAG WITH NOTHING OPEN is dropped. The model wrote it by mistake, and showing it
|
|
37
|
+
* would be showing a fragment of markup as if it were content.
|
|
38
|
+
* - TEXT IS NEVER LOST. A `<` that turns out not to begin a tag is held for one delta and then
|
|
39
|
+
* released as text, so a comparison like `a < b` survives intact.
|
|
40
|
+
*/
|
|
41
|
+
/**
|
|
42
|
+
* One piece of a turn's text, in the order the model wrote it.
|
|
43
|
+
*
|
|
44
|
+
* `thinking-end` is a segment of its own because the end of a reasoning region is a FACT the
|
|
45
|
+
* browser needs and not text: it is what closes the "正在思考" indicator and what tells the
|
|
46
|
+
* transcript that the next fold is a new one.
|
|
47
|
+
*/
|
|
48
|
+
export type InlineThinkingSegment = {
|
|
49
|
+
kind: "text";
|
|
50
|
+
text: string;
|
|
51
|
+
} | {
|
|
52
|
+
kind: "thinking";
|
|
53
|
+
text: string;
|
|
54
|
+
} | {
|
|
55
|
+
kind: "thinking-end";
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Splits a model's text into the answer and the reasoning the model wrote inside it.
|
|
59
|
+
*
|
|
60
|
+
* ONE SPLITTER PER TURN. The state it keeps - the held-back tail and whether the stream is
|
|
61
|
+
* inside a reasoning region - belongs to a turn and to nothing else, so a splitter is created
|
|
62
|
+
* when a turn starts and dropped when it ends.
|
|
63
|
+
*/
|
|
64
|
+
export declare class InlineThinkingSplitter {
|
|
65
|
+
/** The tail that might be the start of a tag, held until the next delta settles it. */
|
|
66
|
+
private held;
|
|
67
|
+
/** True while the stream is inside a reasoning region. */
|
|
68
|
+
private inside;
|
|
69
|
+
/**
|
|
70
|
+
* Feeds one delta in and gets back the segments it produced, in order.
|
|
71
|
+
*
|
|
72
|
+
* A delta that carries nothing but text produces one text segment. A delta that opens a
|
|
73
|
+
* reasoning region produces the text before it, then the reasoning, then the end of the
|
|
74
|
+
* reasoning when the closing tag arrives - and the text after it, if there is any.
|
|
75
|
+
*/
|
|
76
|
+
push(delta: string): InlineThinkingSegment[];
|
|
77
|
+
/**
|
|
78
|
+
* Closes the turn: whatever is still held is released, and a reasoning region that never
|
|
79
|
+
* closed is finished as one.
|
|
80
|
+
*/
|
|
81
|
+
flush(): InlineThinkingSegment[];
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The answer inside a model's text, with the reasoning taken out of it.
|
|
85
|
+
*
|
|
86
|
+
* For the paths that want a STRING rather than a stream of segments: the title generator, the
|
|
87
|
+
* tool LLM, a sub-agent's result. A title with a `<think>` prefix in it is not a title, and a
|
|
88
|
+
* sub-agent's card whose summary starts with markup reads as broken.
|
|
89
|
+
*/
|
|
90
|
+
export declare function stripInlineThinking(text: string): string;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE REASONING A MODEL PUTS INSIDE ITS OWN ANSWER.
|
|
3
|
+
*
|
|
4
|
+
* Some models do not have a reasoning channel. They answer in one stream of text and put their
|
|
5
|
+
* thinking in it, marked up with `<think>…</think>` tags - MiniMax's M2 series is the one this
|
|
6
|
+
* file exists for, and every gateway that proxies it behaves the same way. The tags arrive as
|
|
7
|
+
* ordinary text, so nothing downstream can tell them from an answer.
|
|
8
|
+
*
|
|
9
|
+
* WHY THIS IS SUPER PI'S PROBLEM AND NOT THE USER'S. Pi's OpenAI-compatible adapter only treats
|
|
10
|
+
* `reasoning_content`, `reasoning`, `reasoning_text` and `reasoning_details` as reasoning; it
|
|
11
|
+
* never parses tags out of `content`. A provider that inlines its thinking therefore hands Pi one
|
|
12
|
+
* long text block, Pi hands Super Pi one long text block, and Super Pi would draw the reasoning as
|
|
13
|
+
* part of the answer - in the reader's face, unlabelled, with the tags still in it. The obvious
|
|
14
|
+
* "fix" is to tell the user to go and set a provider-specific switch in Pi's `models.json`, which
|
|
15
|
+
* is a private API of a program Super Pi ships, for a quirk of one vendor. A user who never
|
|
16
|
+
* hears of it sees a broken transcript and has no way to know why.
|
|
17
|
+
*
|
|
18
|
+
* SO THE SPLIT HAPPENS HERE, ON SUPER PI'S OWN SIDE, FOR EVERY PROVIDER. Whatever the endpoint
|
|
19
|
+
* does with its reasoning, the transcript reads the same way: the reasoning goes into a thinking
|
|
20
|
+
* block, the answer goes into the answer, and the tags are never shown to anyone.
|
|
21
|
+
*
|
|
22
|
+
* THE ORDER IS THE WHOLE TRICK. A tag can straddle two deltas - the model writes `</thi` and then
|
|
23
|
+
* `nk>` in the next one - so the splitter holds back the tail that might be the start of a tag
|
|
24
|
+
* until the next delta settles it. And a delta can hold an answer, a thought and another answer,
|
|
25
|
+
* so the splitter returns SEGMENTS IN ORDER rather than "the text" and "the thinking": the caller
|
|
26
|
+
* has to be able to put the answer that came before a thought after it, and has to be able to say
|
|
27
|
+
* where that thought began.
|
|
28
|
+
*
|
|
29
|
+
* THE RULES, because each of them has a case that would otherwise be a bug:
|
|
30
|
+
*
|
|
31
|
+
* - A PROPERLY CLOSED region is reasoning. It leaves the answer and enters the thinking block.
|
|
32
|
+
* - AN UNCLOSED region is reasoning too. The model said it was thinking and then the turn
|
|
33
|
+
* ended - an abort, a truncation, a provider that stopped mid-thought. The reasoning goes
|
|
34
|
+
* into the block, and the answer keeps whatever came before the tag. Nothing is thrown away
|
|
35
|
+
* either way: the fold is expandable, so the words are still there to be read.
|
|
36
|
+
* - A CLOSING TAG WITH NOTHING OPEN is dropped. The model wrote it by mistake, and showing it
|
|
37
|
+
* would be showing a fragment of markup as if it were content.
|
|
38
|
+
* - TEXT IS NEVER LOST. A `<` that turns out not to begin a tag is held for one delta and then
|
|
39
|
+
* released as text, so a comparison like `a < b` survives intact.
|
|
40
|
+
*/
|
|
41
|
+
/** The tag that opens a reasoning region. */
|
|
42
|
+
const OPEN_TAG = "<think>";
|
|
43
|
+
/** The tag that closes one. */
|
|
44
|
+
const CLOSE_TAG = "</think>";
|
|
45
|
+
/** Appends a piece of text, skipping the empty ones: an empty segment is not an event. */
|
|
46
|
+
function append(segments, kind, text) {
|
|
47
|
+
if (text)
|
|
48
|
+
segments.push({ kind, text });
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* How much of the tail of `text` could be the beginning of `tag`.
|
|
52
|
+
*
|
|
53
|
+
* The whole tag is not a candidate - a complete tag would have been found by `indexOf` - so this
|
|
54
|
+
* returns the length of the longest PROPER prefix of the tag that the tail ends with. It is what
|
|
55
|
+
* makes `<thi` wait for the next delta instead of being published as text and then having to be
|
|
56
|
+
* taken back.
|
|
57
|
+
*/
|
|
58
|
+
function partialTagLength(text, tag) {
|
|
59
|
+
const max = Math.min(tag.length - 1, text.length);
|
|
60
|
+
for (let length = max; length > 0; length -= 1) {
|
|
61
|
+
if (text.endsWith(tag.slice(0, length)))
|
|
62
|
+
return length;
|
|
63
|
+
}
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Splits a model's text into the answer and the reasoning the model wrote inside it.
|
|
68
|
+
*
|
|
69
|
+
* ONE SPLITTER PER TURN. The state it keeps - the held-back tail and whether the stream is
|
|
70
|
+
* inside a reasoning region - belongs to a turn and to nothing else, so a splitter is created
|
|
71
|
+
* when a turn starts and dropped when it ends.
|
|
72
|
+
*/
|
|
73
|
+
export class InlineThinkingSplitter {
|
|
74
|
+
/** The tail that might be the start of a tag, held until the next delta settles it. */
|
|
75
|
+
held = "";
|
|
76
|
+
/** True while the stream is inside a reasoning region. */
|
|
77
|
+
inside = false;
|
|
78
|
+
/**
|
|
79
|
+
* Feeds one delta in and gets back the segments it produced, in order.
|
|
80
|
+
*
|
|
81
|
+
* A delta that carries nothing but text produces one text segment. A delta that opens a
|
|
82
|
+
* reasoning region produces the text before it, then the reasoning, then the end of the
|
|
83
|
+
* reasoning when the closing tag arrives - and the text after it, if there is any.
|
|
84
|
+
*/
|
|
85
|
+
push(delta) {
|
|
86
|
+
const segments = [];
|
|
87
|
+
if (!delta)
|
|
88
|
+
return segments;
|
|
89
|
+
const buffer = this.held + delta;
|
|
90
|
+
this.held = "";
|
|
91
|
+
let index = 0;
|
|
92
|
+
while (index < buffer.length) {
|
|
93
|
+
if (this.inside) {
|
|
94
|
+
const close = buffer.indexOf(CLOSE_TAG, index);
|
|
95
|
+
if (close === -1) {
|
|
96
|
+
// No closing tag yet. Everything that cannot be the start of one is reasoning, and the
|
|
97
|
+
// rest waits for the next delta.
|
|
98
|
+
const keep = partialTagLength(buffer.slice(index), CLOSE_TAG);
|
|
99
|
+
append(segments, "thinking", buffer.slice(index, buffer.length - keep));
|
|
100
|
+
this.held = buffer.slice(buffer.length - keep);
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
append(segments, "thinking", buffer.slice(index, close));
|
|
104
|
+
index = close + CLOSE_TAG.length;
|
|
105
|
+
this.inside = false;
|
|
106
|
+
segments.push({ kind: "thinking-end" });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const open = buffer.indexOf(OPEN_TAG, index);
|
|
110
|
+
const close = buffer.indexOf(CLOSE_TAG, index);
|
|
111
|
+
if (open === -1 && close === -1) {
|
|
112
|
+
// BOTH TAGS CAN START HERE, SO BOTH TAILS ARE HELD. The tail is held so that a tag the
|
|
113
|
+
// tokenizer cut in half is still recognized as a tag; which tag it might be is exactly the
|
|
114
|
+
// question, and outside a reasoning region it can be EITHER one: a stray `</think>` is markup
|
|
115
|
+
// the model wrote by mistake and must be dropped, exactly like a `<think>` that opens a
|
|
116
|
+
// region. Holding only the OPEN prefix published the CLOSE prefix as text at once, so the
|
|
117
|
+
// same stray tag was dropped when it arrived whole and survived in the answer when the
|
|
118
|
+
// tokenizer cut it - `["答案</thi", "nk>还是答案"]` read as `答案</think>还是答案`
|
|
119
|
+
// (measured by scripts/inline-thinking-close-tag.test.mjs, ledger TEST-021). The longer of
|
|
120
|
+
// the two prefixes is held; a tail that turns out to be neither is released as text on the
|
|
121
|
+
// next delta, which is the "text is never lost" rule.
|
|
122
|
+
const rest = buffer.slice(index);
|
|
123
|
+
const keep = Math.max(partialTagLength(rest, OPEN_TAG), partialTagLength(rest, CLOSE_TAG));
|
|
124
|
+
append(segments, "text", rest.slice(0, rest.length - keep));
|
|
125
|
+
this.held = rest.slice(rest.length - keep);
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
if (open !== -1 && (close === -1 || open < close)) {
|
|
129
|
+
append(segments, "text", buffer.slice(index, open));
|
|
130
|
+
index = open + OPEN_TAG.length;
|
|
131
|
+
this.inside = true;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
// A CLOSING TAG WITH NOTHING OPEN. Dropped: it is markup the model wrote by mistake, and a
|
|
135
|
+
// reader would take it for content. The text BEFORE it is ordinary text and is kept - the tag
|
|
136
|
+
// is dropped, not everything up to it.
|
|
137
|
+
append(segments, "text", buffer.slice(index, close));
|
|
138
|
+
index = close + CLOSE_TAG.length;
|
|
139
|
+
}
|
|
140
|
+
return segments;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Closes the turn: whatever is still held is released, and a reasoning region that never
|
|
144
|
+
* closed is finished as one.
|
|
145
|
+
*/
|
|
146
|
+
flush() {
|
|
147
|
+
const segments = [];
|
|
148
|
+
if (this.inside) {
|
|
149
|
+
if (this.held)
|
|
150
|
+
segments.push({ kind: "thinking", text: this.held });
|
|
151
|
+
this.held = "";
|
|
152
|
+
this.inside = false;
|
|
153
|
+
segments.push({ kind: "thinking-end" });
|
|
154
|
+
return segments;
|
|
155
|
+
}
|
|
156
|
+
if (this.held) {
|
|
157
|
+
segments.push({ kind: "text", text: this.held });
|
|
158
|
+
this.held = "";
|
|
159
|
+
}
|
|
160
|
+
return segments;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The answer inside a model's text, with the reasoning taken out of it.
|
|
165
|
+
*
|
|
166
|
+
* For the paths that want a STRING rather than a stream of segments: the title generator, the
|
|
167
|
+
* tool LLM, a sub-agent's result. A title with a `<think>` prefix in it is not a title, and a
|
|
168
|
+
* sub-agent's card whose summary starts with markup reads as broken.
|
|
169
|
+
*/
|
|
170
|
+
export function stripInlineThinking(text) {
|
|
171
|
+
if (!text)
|
|
172
|
+
return "";
|
|
173
|
+
const splitter = new InlineThinkingSplitter();
|
|
174
|
+
const segments = [...splitter.push(text), ...splitter.flush()];
|
|
175
|
+
let answer = "";
|
|
176
|
+
for (const segment of segments) {
|
|
177
|
+
if (segment.kind === "text")
|
|
178
|
+
answer += segment.text;
|
|
179
|
+
}
|
|
180
|
+
return answer;
|
|
181
|
+
}
|