@evandu/super-pi 0.1.129 → 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.
Files changed (70) hide show
  1. package/agents/super-pi-documents/index.ts +92 -0
  2. package/agents/super-pi-knowledge/index.ts +32 -1
  3. package/agents/super-pi-task/core.ts +40 -0
  4. package/agents/super-pi-task/index.ts +15 -1
  5. package/agents/super-pi-web/index.ts +32 -0
  6. package/assets/fonts/LICENSE +92 -0
  7. package/assets/fonts/NotoSansSC-Bold.otf +0 -0
  8. package/assets/fonts/NotoSansSC-Regular.otf +0 -0
  9. package/dist/.super-pi-check-inputs.json +2 -2
  10. package/dist/client/.super-pi-client-sources.json +5 -5
  11. package/dist/client/assets/{CodeMirrorEditor-BNVBZZSS.js → CodeMirrorEditor-BlQhH3vs.js} +2 -2
  12. package/dist/client/assets/{dist-DmAOqr0M.js → dist-B1_1Ui9E.js} +1 -1
  13. package/dist/client/assets/{dist-GkIV1LTX.js → dist-B448EWTM.js} +1 -1
  14. package/dist/client/assets/{dist-CiSGSVBZ.js → dist-B68Q8uXX.js} +1 -1
  15. package/dist/client/assets/{dist-DB5M5fZG.js → dist-BFlq-kiF.js} +1 -1
  16. package/dist/client/assets/{dist-BykVFExd.js → dist-BXVFgriA.js} +1 -1
  17. package/dist/client/assets/{dist-CZ9I2JCd.js → dist-BcE6tJvc.js} +1 -1
  18. package/dist/client/assets/{dist-BhJGK8-b.js → dist-ByIqsb5-.js} +1 -1
  19. package/dist/client/assets/{dist-DZnUERoW.js → dist-C6qSkkeN.js} +1 -1
  20. package/dist/client/assets/{dist-BKp4AWEJ.js → dist-C9AkgNbt.js} +1 -1
  21. package/dist/client/assets/{dist-CRzRB9k3.js → dist-COp25nhe.js} +1 -1
  22. package/dist/client/assets/{dist-CMnvChDX.js → dist-CpuexUlh.js} +1 -1
  23. package/dist/client/assets/{dist-ChszWGDv.js → dist-CtdlYgGc.js} +1 -1
  24. package/dist/client/assets/{dist-Dq4-QFAJ.js → dist-Cybsoapc.js} +1 -1
  25. package/dist/client/assets/{dist-Ur37340K.js → dist-D6PoSxR5.js} +1 -1
  26. package/dist/client/assets/{dist-C90MPTnk.js → dist-D7bj81KQ.js} +1 -1
  27. package/dist/client/assets/{dist-BBW2SE_R.js → dist-DP2ygAFF.js} +1 -1
  28. package/dist/client/assets/{dist-B61RSa-K.js → dist-DXNDtyHP.js} +1 -1
  29. package/dist/client/assets/{dist-CWYRjnj-.js → dist-DxoWS5MH.js} +1 -1
  30. package/dist/client/assets/{dist-Cxerc7Bb.js → dist-Fhkmvjdx.js} +1 -1
  31. package/dist/client/assets/{dist-BDygQgZD.js → dist-hfpd-aWa.js} +1 -1
  32. package/dist/client/assets/{dist-B2nPVdKh.js → dist-lQWWWxh9.js} +1 -1
  33. package/dist/client/assets/{dist-HdeIBl2J.js → dist-r5Af94tH.js} +1 -1
  34. package/dist/client/assets/{index-D_c-CYAr.js → index-BApJ3Zbq.js} +3 -3
  35. package/dist/client/index.html +1 -1
  36. package/dist/server/active-memory.d.ts +12 -0
  37. package/dist/server/active-memory.js +25 -1
  38. package/dist/server/application/conversation-service.js +5 -0
  39. package/dist/server/application/document-service.d.ts +68 -0
  40. package/dist/server/application/document-service.js +120 -0
  41. package/dist/server/application/run-service.js +49 -7
  42. package/dist/server/documents.d.ts +152 -0
  43. package/dist/server/documents.js +998 -0
  44. package/dist/server/file-changes.js +6 -4
  45. package/dist/server/files.d.ts +32 -0
  46. package/dist/server/files.js +28 -0
  47. package/dist/server/http-server.js +113 -2
  48. package/dist/server/index.js +13 -1
  49. package/dist/server/inline-thinking.d.ts +90 -0
  50. package/dist/server/inline-thinking.js +181 -0
  51. package/dist/server/knowledge.d.ts +24 -14
  52. package/dist/server/knowledge.js +47 -32
  53. package/dist/server/pi-session-pool.js +2 -1
  54. package/dist/server/pi-session.d.ts +20 -6
  55. package/dist/server/pi-session.js +43 -11
  56. package/dist/server/presentations/schema.d.ts +120 -0
  57. package/dist/server/presentations/schema.js +128 -0
  58. package/dist/server/runtime.d.ts +46 -0
  59. package/dist/server/runtime.js +150 -16
  60. package/dist/server/server-context.d.ts +3 -0
  61. package/dist/server/subagents.js +33 -6
  62. package/dist/server/web.d.ts +24 -0
  63. package/dist/server/web.js +180 -0
  64. package/package.json +33 -5
  65. package/skills/presentation/SKILL.md +30 -0
  66. package/skills/presentation/references/data.md +10 -0
  67. package/skills/presentation/references/quality.md +12 -0
  68. package/skills/presentation/references/story.md +12 -0
  69. package/skills/presentation/references/templates-and-editing.md +11 -0
  70. 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(size, mtimeNs, ino) {
110
- return createHash("sha1").update(`${size}:${mtimeNs}:${ino}`).digest("hex").slice(0, 20);
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(info.size, info.mtimeNs, info.ino);
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
- const current = etagOf(info.size, info.mtimeNs, info.ino);
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".
@@ -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;
@@ -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
- if (requestPath !== "/api/memory/tool" && requestPath !== "/api/knowledge/tool" && requestPath !== "/api/todos" && requestPath !== "/api/question" && (requestPath !== "/api/tools" || request.method !== "POST") && !requestPath.startsWith("/tools/") && !isAuthorized(request, accessToken)) {
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))
@@ -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
+ }