@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
@@ -8,7 +8,7 @@
8
8
  <link rel="alternate icon" href="/favicon.ico" />
9
9
  <link rel="apple-touch-icon" href="/favicon.svg" />
10
10
  <title>Super Pi</title>
11
- <script type="module" crossorigin src="/assets/index-D_c-CYAr.js"></script>
11
+ <script type="module" crossorigin src="/assets/index-BApJ3Zbq.js"></script>
12
12
  <link rel="stylesheet" crossorigin href="/assets/index-CXB6U5mz.css">
13
13
  </head>
14
14
  <body>
@@ -70,6 +70,18 @@ export declare class ActiveMemory {
70
70
  content: string;
71
71
  }[];
72
72
  private ensureIndex;
73
+ /**
74
+ * Drops a workspace's search index so the next search rebuilds it from the store.
75
+ *
76
+ * WHY THIS EXISTS. `ensureIndex` builds once per workspace and `addToIndex` only ever adds, so the
77
+ * index outlives the messages it indexed: a revert removes messages from the store while the index
78
+ * keeps their text, and the next recall then hands the model a turn the user has just withdrawn -
79
+ * measured by scripts/revert-real-pi.test.mjs, where the withdrawn codeword came back as "recalled
80
+ * history" after a revert every file-level assertion proved had really truncated. The store is the
81
+ * source of truth; the index is a cache of it, and a cache that survives an event that invalidated
82
+ * its source is wrong by definition.
83
+ */
84
+ invalidateSearchIndex(workspaceId: string): void;
73
85
  private addToIndex;
74
86
  tool(token: string, body: {
75
87
  kind?: string;
@@ -71,7 +71,16 @@ export class ActiveMemory {
71
71
  // The live set is whatever SURVIVED, read back rather than derived from the dropped ids: deriving it
72
72
  // as "everything not dropped" would be wrong the moment an entry was removed by someone else.
73
73
  const live = new Set((await this.notes.apply(conversation.workspaceId, "", { target: "skills", action: "list" })).map((entry) => entry.id));
74
- for (const name of await readdir(folder)) {
74
+ // The skills folder exists only once a skill has been written for this workspace, so its absence is
75
+ // the ordinary note-only case and not a failure. Throwing on it here used to discard the forget
76
+ // result above with it: the entries were already dropped, but the caller read the catch's zero as
77
+ // "nothing was withdrawn", and the log lied about the very rewind this method exists to do.
78
+ let skillFiles = [];
79
+ try {
80
+ skillFiles = await readdir(folder);
81
+ }
82
+ catch { /* no skill has ever been written for this workspace */ }
83
+ for (const name of skillFiles) {
75
84
  if (name.endsWith(".md") && !live.has(name.slice(0, -3)))
76
85
  await unlink(join(folder, name)).catch(() => { });
77
86
  }
@@ -135,6 +144,21 @@ export class ActiveMemory {
135
144
  this.inverted.set(workspaceId, index);
136
145
  this.indexedWorkspaces.add(workspaceId);
137
146
  }
147
+ /**
148
+ * Drops a workspace's search index so the next search rebuilds it from the store.
149
+ *
150
+ * WHY THIS EXISTS. `ensureIndex` builds once per workspace and `addToIndex` only ever adds, so the
151
+ * index outlives the messages it indexed: a revert removes messages from the store while the index
152
+ * keeps their text, and the next recall then hands the model a turn the user has just withdrawn -
153
+ * measured by scripts/revert-real-pi.test.mjs, where the withdrawn codeword came back as "recalled
154
+ * history" after a revert every file-level assertion proved had really truncated. The store is the
155
+ * source of truth; the index is a cache of it, and a cache that survives an event that invalidated
156
+ * its source is wrong by definition.
157
+ */
158
+ invalidateSearchIndex(workspaceId) {
159
+ this.indexedWorkspaces.delete(workspaceId);
160
+ this.inverted.delete(workspaceId);
161
+ }
138
162
  addToIndex(conversation, message) {
139
163
  if (!this.indexedWorkspaces.has(conversation.workspaceId) || (message.role !== "user" && message.role !== "assistant"))
140
164
  return;
@@ -356,6 +356,11 @@ export class ConversationService {
356
356
  // so this runs on data that is settled and cannot be racing the store's own writes.
357
357
  const firstRemoved = conversation.messages[applied.boundaryIndex + 1];
358
358
  const forgotten = firstRemoved ? await this.memory?.forgetAfter(conversationId, firstRemoved.createdAt) ?? 0 : 0;
359
+ // The recall index is a cache of the store's messages, and the store just lost several: left alone it
360
+ // keeps answering the memory extension's searches with the withdrawn turns, so the model is told about
361
+ // history the person reverted away even though the transcript, Pi's file and the memory store all
362
+ // agree it is gone. Invalidate it and the next search rebuilds from what actually survived.
363
+ this.memory?.invalidateSearchIndex(workspaceId);
359
364
  console.log(`Super Pi: reverted conversation ${conversationId} to ${applied.boundaryIndex + 1} message(s): ` +
360
365
  `${plan.removedMessageIds.length} message(s) and ${applied.doomedRunIds.length} sub-agent run(s) dropped from state.json; ` +
361
366
  `Pi's file ${outcome.sessionFile} kept ${outcome.linesKept} line(s) and lost ${outcome.entriesDropped}; ` +
@@ -0,0 +1,68 @@
1
+ import type { Store } from "../store.js";
2
+ import type { DocumentFormat } from "../documents.js";
3
+ /**
4
+ * The largest request the document endpoint accepts.
5
+ *
6
+ * A document's extracted text rides in the request, so the cap is the document cap - a request past
7
+ * it is refused outright rather than truncated into a document that silently lost its tail.
8
+ */
9
+ export declare const MAX_DOCUMENT_REQUEST_BYTES: number;
10
+ /**
11
+ * THE DOCUMENT SERVICE: what an agent can do with the files in a workspace.
12
+ *
13
+ * It is the agent-facing half of the parsing layer. The knowledge base uses the same parsing code
14
+ * (see `knowledge.ts`), so a PDF reads the same way whether it was imported into the knowledge base
15
+ * or read straight out of the workspace - which is the point of having one parsing layer.
16
+ *
17
+ * WHY THE SERVICE OWNS THE PATH GUARD AND NOT THE EXTENSION. The extension runs in Pi's process and
18
+ * could ask for any path it liked. The service resolves every path against the workspace root with
19
+ * the same `resolveInsideRoot` guard the file panel uses, so a `..` or a symlink pointing outside
20
+ * the workspace is refused by construction rather than by a check somebody has to remember.
21
+ */
22
+ export declare class DocumentService {
23
+ private readonly store;
24
+ constructor(store: Store);
25
+ /**
26
+ * The workspace a conversation works in, taken from the store.
27
+ *
28
+ * The conversation is the caller's identity, and the workspace is derived from it. A request that
29
+ * named its own workspace would be a request that could read another workspace.
30
+ */
31
+ private workspacePath;
32
+ /**
33
+ * Reads a file's contents.
34
+ *
35
+ * A text file is returned as it is; a document is parsed. The distinction matters because the two
36
+ * fail differently: a text file that does not exist is a missing file, while a document that
37
+ * cannot be parsed is a document with a reason.
38
+ */
39
+ read(conversationId: string, path: string): Promise<{
40
+ text: string;
41
+ format: string;
42
+ warnings: string[];
43
+ etag: string;
44
+ }>;
45
+ /**
46
+ * Writes a document into the workspace.
47
+ *
48
+ * The content is Markdown for a Word or PDF document and CSV for a spreadsheet, and the format is
49
+ * taken from the file's extension - so the caller says "write this report to REPORT.docx" and the
50
+ * right writer is chosen. An extension that names no document format is refused, because writing
51
+ * a `.txt` file is what Pi's own `write` tool is for, and a second way to do it is a second way to
52
+ * be wrong about it.
53
+ *
54
+ * OVERWRITING AN EXISTING FILE NEEDS THE VERSION THE CALLER READ, the same rule the file panel's
55
+ * write enforces (docs/DOCUMENTS.md: 覆盖已有文件前要确认,etag 语义和文件面板一致). A document is a
56
+ * file a person edits; an agent told to "把这份报告改成正式版" would otherwise destroy a hand edit
57
+ * made an hour ago with no refusal, no diff, no warning. A missing etag over an existing file and a
58
+ * stale one are both refused, and the refusal carries the CURRENT etag - which is what makes an
59
+ * informed overwrite possible, the same way the panel's 409 does: re-sending the current etag is
60
+ * the deliberate "仍然覆盖", not a skipped check. A file that does not exist yet is the ordinary
61
+ * create case and needs no confirmation.
62
+ */
63
+ write(conversationId: string, path: string, content: string, title?: string, baseEtag?: string): Promise<{
64
+ path: string;
65
+ bytes: number;
66
+ format: DocumentFormat;
67
+ }>;
68
+ }
@@ -0,0 +1,120 @@
1
+ import { mkdir, stat, writeFile } from "node:fs/promises";
2
+ import { dirname, extname } from "node:path";
3
+ import { buildDocument, documentFormatOf, DocumentError, extractText } from "../documents.js";
4
+ import { fileEtag, resolveInsideRoot } from "../files.js";
5
+ /**
6
+ * The largest request the document endpoint accepts.
7
+ *
8
+ * A document's extracted text rides in the request, so the cap is the document cap - a request past
9
+ * it is refused outright rather than truncated into a document that silently lost its tail.
10
+ */
11
+ export const MAX_DOCUMENT_REQUEST_BYTES = 8 * 1024 * 1024;
12
+ /**
13
+ * THE DOCUMENT SERVICE: what an agent can do with the files in a workspace.
14
+ *
15
+ * It is the agent-facing half of the parsing layer. The knowledge base uses the same parsing code
16
+ * (see `knowledge.ts`), so a PDF reads the same way whether it was imported into the knowledge base
17
+ * or read straight out of the workspace - which is the point of having one parsing layer.
18
+ *
19
+ * WHY THE SERVICE OWNS THE PATH GUARD AND NOT THE EXTENSION. The extension runs in Pi's process and
20
+ * could ask for any path it liked. The service resolves every path against the workspace root with
21
+ * the same `resolveInsideRoot` guard the file panel uses, so a `..` or a symlink pointing outside
22
+ * the workspace is refused by construction rather than by a check somebody has to remember.
23
+ */
24
+ export class DocumentService {
25
+ store;
26
+ constructor(store) {
27
+ this.store = store;
28
+ }
29
+ /**
30
+ * The workspace a conversation works in, taken from the store.
31
+ *
32
+ * The conversation is the caller's identity, and the workspace is derived from it. A request that
33
+ * named its own workspace would be a request that could read another workspace.
34
+ */
35
+ workspacePath(conversationId) {
36
+ const conversation = this.store.snapshot().conversations.find(item => item.id === conversationId);
37
+ if (!conversation)
38
+ throw new Error("会话不存在");
39
+ const workspace = this.store.snapshot().workspaces.find(item => item.id === conversation.workspaceId);
40
+ if (!workspace)
41
+ throw new Error("工作区不存在");
42
+ return workspace.path;
43
+ }
44
+ /**
45
+ * Reads a file's contents.
46
+ *
47
+ * A text file is returned as it is; a document is parsed. The distinction matters because the two
48
+ * fail differently: a text file that does not exist is a missing file, while a document that
49
+ * cannot be parsed is a document with a reason.
50
+ */
51
+ async read(conversationId, path) {
52
+ const root = this.workspacePath(conversationId);
53
+ const absolute = await resolveInsideRoot(root, path);
54
+ const info = await stat(absolute).catch(() => null);
55
+ if (!info || !info.isFile())
56
+ throw new Error(`找不到文件:${path}`);
57
+ const extension = extname(absolute).toLocaleLowerCase();
58
+ const format = documentFormatOf(extension);
59
+ if (format) {
60
+ try {
61
+ const extracted = await extractText(absolute, format);
62
+ return { text: extracted.text, format, warnings: extracted.warnings, etag: await fileEtag(absolute) };
63
+ }
64
+ catch (error) {
65
+ // The parser's reasons are already written for a person - a scan with no text layer, an
66
+ // encrypted file, a missing package - and they are passed through unchanged. A model that
67
+ // is told "读取失败" cannot help the person; a model that is told "这份 PDF 是扫描件" can.
68
+ if (error instanceof DocumentError)
69
+ throw new Error(error.message);
70
+ throw error;
71
+ }
72
+ }
73
+ const { readFile } = await import("node:fs/promises");
74
+ const buffer = await readFile(absolute);
75
+ // The etag rides along so a follow-up `document_write` over this file can present the version it
76
+ // read - the same confirm-before-overwrite the file panel's editor lives by (see `write`).
77
+ return { text: buffer.toString("utf8"), format: extension || "text", warnings: [], etag: await fileEtag(absolute) };
78
+ }
79
+ /**
80
+ * Writes a document into the workspace.
81
+ *
82
+ * The content is Markdown for a Word or PDF document and CSV for a spreadsheet, and the format is
83
+ * taken from the file's extension - so the caller says "write this report to REPORT.docx" and the
84
+ * right writer is chosen. An extension that names no document format is refused, because writing
85
+ * a `.txt` file is what Pi's own `write` tool is for, and a second way to do it is a second way to
86
+ * be wrong about it.
87
+ *
88
+ * OVERWRITING AN EXISTING FILE NEEDS THE VERSION THE CALLER READ, the same rule the file panel's
89
+ * write enforces (docs/DOCUMENTS.md: 覆盖已有文件前要确认,etag 语义和文件面板一致). A document is a
90
+ * file a person edits; an agent told to "把这份报告改成正式版" would otherwise destroy a hand edit
91
+ * made an hour ago with no refusal, no diff, no warning. A missing etag over an existing file and a
92
+ * stale one are both refused, and the refusal carries the CURRENT etag - which is what makes an
93
+ * informed overwrite possible, the same way the panel's 409 does: re-sending the current etag is
94
+ * the deliberate "仍然覆盖", not a skipped check. A file that does not exist yet is the ordinary
95
+ * create case and needs no confirmation.
96
+ */
97
+ async write(conversationId, path, content, title, baseEtag) {
98
+ const root = this.workspacePath(conversationId);
99
+ const absolute = await resolveInsideRoot(root, path);
100
+ const extension = extname(absolute).toLocaleLowerCase();
101
+ const format = documentFormatOf(extension);
102
+ if (!format) {
103
+ throw new Error(`只能写 PDF、Word(.docx)和 Excel(.xlsx)文件;${extension || "这个文件"} 请用 write 工具直接写文本。`);
104
+ }
105
+ const existing = await stat(absolute).catch(() => null);
106
+ if (existing?.isFile()) {
107
+ const current = await fileEtag(absolute);
108
+ if (!baseEtag) {
109
+ throw new Error(`文件已存在:${path}。覆盖它必须先确认版本——先用 document_read 读取该文件,把它返回的 etag 作为 baseEtag 传入;当前 etag:${current}。`);
110
+ }
111
+ if (baseEtag !== current) {
112
+ throw new Error(`文件在磁盘上已被其他程序修改,未写入:${path}(当前 etag:${current},你提供的是 ${baseEtag})。如确认覆盖,请带着当前 etag 重试。`);
113
+ }
114
+ }
115
+ const { bytes } = await buildDocument({ format, content, title });
116
+ await mkdir(dirname(absolute), { recursive: true });
117
+ await writeFile(absolute, bytes);
118
+ return { path, bytes: bytes.length, format };
119
+ }
120
+ }
@@ -60,7 +60,14 @@ function composePrompt(message) {
60
60
  const summary = summaryMatch ? summaryMatch[1].trim() : "后台任务";
61
61
  const stateMatch = /<task id="[^"]+" state="([^"]+)"/.exec(content);
62
62
  const state = stateMatch ? stateMatch[1] : "completed";
63
- const bodyMatch = /<task_(?:result|error)>\n?([\s\S]*?)\n?<\/(?:task_result|task_error)>/.exec(content);
63
+ // GREEDY ON PURPOSE. The envelope is machine-built with the command's RAW output interpolated before
64
+ // its closing tags, so the body may itself contain `</task_result>` - printed XML, a log excerpt, the
65
+ // model's own echoed envelope format. A non-greedy match stops at the FIRST such tag and hands the
66
+ // model a truncated half-result while the reader sees the full one (measured by
67
+ // scripts/background-result-body.test.mjs with `hello </task_result> world`). Matching greedily lands
68
+ // on the envelope's own closing tag - the LAST one in the content - which is where the body ends
69
+ // regardless of what the command printed.
70
+ const bodyMatch = /<task_(?:result|error)>\n?([\s\S]*)\n?<\/(?:task_result|task_error)>/.exec(content);
64
71
  const body = bodyMatch ? bodyMatch[1] : content;
65
72
  const stateLabel = state === "completed" ? "已完成" : state === "error" ? "失败" : state;
66
73
  return `[后台任务「${summary}」${stateLabel}]\n\n${body}`;
@@ -239,14 +246,33 @@ export class RunService {
239
246
  // processes the result" contract, and the auto-triggered turn goes through the same
240
247
  // `composePrompt` path the success case does (so a helper failure surfaces as `state="error"`
241
248
  // prose the agent can react to).
249
+ // EXACTLY ONE DELIVERY PER RESULT. The custom channel hands the result straight to Pi's own
250
+ // follow-up queue, but it knows nothing about the `waiting` row queued above, and
251
+ // `takeNextWaitingMessage` knows nothing about Pi's queue - so delivering through the channel
252
+ // while leaving the row waiting gave the model the SAME result twice (once as the custom
253
+ // message, once as the row's own prose turn). Measured by
254
+ // scripts/background-result-delivery.test.mjs. So a successful custom delivery CONSUMES the row,
255
+ // and a failed one - `SteerUnavailableError`, which is what oneshot mode and a not-yet-created
256
+ // session throw - falls back to the queue path, the same semantics the sub-agent result path
257
+ // (processWaitingMessage only) has always had.
258
+ let deliveredViaCustom = false;
242
259
  const custom = this.runtime.sendCustomMessage;
243
260
  if (custom) {
244
- await custom.call(this.runtime, task.conversationId, {
245
- customType: "super-pi-task-result",
246
- content: body,
247
- display: "后台任务结果",
248
- details: { taskId: sessionId, source: "exec", state: exitCode === 0 ? "completed" : "error", summary: task.title },
249
- });
261
+ try {
262
+ await custom.call(this.runtime, task.conversationId, {
263
+ customType: "super-pi-task-result",
264
+ content: body,
265
+ display: "后台任务结果",
266
+ details: { taskId: sessionId, source: "exec", state: exitCode === 0 ? "completed" : "error", summary: task.title },
267
+ });
268
+ deliveredViaCustom = true;
269
+ }
270
+ catch (error) {
271
+ console.warn(`Super Pi: custom delivery of the exec result failed, the queue path takes over: ${error instanceof Error ? error.message : "unknown error"}`);
272
+ }
273
+ }
274
+ if (deliveredViaCustom) {
275
+ await this.store.takeWaitingMessage(task.conversationId, queued.id);
250
276
  }
251
277
  else if (!this.activeRuns.has(task.conversationId) && !this.interruptTargets.has(task.conversationId)) {
252
278
  void this.processWaitingMessage(task.conversationId, queued.id).catch((error) => {
@@ -551,6 +577,22 @@ export class RunService {
551
577
  async stopRunUnsafe(conversationId) {
552
578
  this.memory?.cancel(conversationId);
553
579
  this.extensionUiService.cancelPendingUi(conversationId, "aborted");
580
+ // THE `question` TOOL IS A PENDING ASK THE STOP MUST SETTLE, not a client-only state. Its row only
581
+ // ever leaves `pending` through the user's `question.answer` command, so a stop used to leave the
582
+ // ask waiting forever: the extension's HTTP handler never returned, the ask's 250ms full-snapshot
583
+ // poll never cleared, and the card stayed interactive on a dead turn (measured by
584
+ // scripts/question-stop.test.mjs). The same settlement the Extension UI channel gets from
585
+ // `cancelPendingUi` above - answered `cancelled`, never a default - applied to the question rows.
586
+ const stopped = this.store.snapshot().conversations.find((item) => item.id === conversationId);
587
+ for (const message of stopped?.messages ?? []) {
588
+ for (let index = 0; index < (message.toolCalls?.length ?? 0); index += 1) {
589
+ const question = message.toolCalls?.[index]?.question;
590
+ if (question?.status !== "pending")
591
+ continue;
592
+ const calls = await this.store.updateToolCall(conversationId, message.id, index, { question: { items: question.items, status: "cancelled" } });
593
+ this.broadcaster.broadcast({ type: "message.tools", conversationId, messageId: message.id, toolCalls: calls });
594
+ }
595
+ }
554
596
  await this.store.setStatus(conversationId, "stopped");
555
597
  if (this.subagents.stopConversation(conversationId)) {
556
598
  console.log(`Super Pi: stopped the sub-agent runs of conversation ${conversationId}.`);
@@ -0,0 +1,152 @@
1
+ /**
2
+ * THE DOCUMENT PARSING LAYER.
3
+ *
4
+ * It turns the files a person actually has - PDFs, Word documents, spreadsheets - into text, and it
5
+ * turns text back into those files. Everything else in Super Pi - the knowledge base, the agent's
6
+ * document tool - goes through here, so the same file reads the same way in both places.
7
+ *
8
+ * WHY IT LIVES ON THE SERVER AND NOT IN A PI EXTENSION. A Pi extension runs inside Pi's process, and
9
+ * Pi's process resolves `node_modules` against PI's install, not against Super Pi's. The parsing
10
+ * libraries are Super Pi's dependencies, so an extension could not load them: the code would work in
11
+ * the dev checkout and fail in every installed layout, which is the exact failure mode
12
+ * `scripts/installed-layout.test.mjs` exists to catch. The server is also the only layer that holds
13
+ * both the workspace's absolute paths and the capability tokens, so the path guard and the
14
+ * credential check live in the same place as the parsing.
15
+ *
16
+ * WHY EVERY LIBRARY IS OPTIONAL. The knowledge base's vector layer is optional for the same reason:
17
+ * a missing package must degrade the FEATURE, not break the PANEL. A user who did not install
18
+ * pdfjs-dist should still be able to run Super Pi and read their Markdown; they should get a clear
19
+ * sentence about which package to install when they try to read a PDF.
20
+ */
21
+ /**
22
+ * The document formats this layer understands, by file extension.
23
+ *
24
+ * `ppt` is READ-ONLY: the legacy binary format has no pure-JS writer, and generating a real
25
+ * PowerPoint 97 file means reimplementing a 1997 binary format. Reading it is a record walk; writing
26
+ * it is refused with a pointer to `.pptx`.
27
+ */
28
+ export type DocumentFormat = "pdf" | "docx" | "xlsx" | "pptx" | "ppt";
29
+ /**
30
+ * The tools the agent sees.
31
+ *
32
+ * Duplicated as literals in `agents/super-pi-documents/index.ts`, the way `KNOWLEDGE_TOOL_NAME` is:
33
+ * the extension runs in Pi's process and cannot import from the server, and a name that exists in
34
+ * only one of the two places is a name one of them will get wrong.
35
+ */
36
+ export declare const DOCUMENT_READ_TOOL_NAME = "document_read";
37
+ export declare const DOCUMENT_WRITE_TOOL_NAME = "document_write";
38
+ /** What a caller needs to know about a document after it has been read. */
39
+ export type ExtractResult = {
40
+ /** The extracted text. Empty only when the source genuinely had no text layer. */
41
+ text: string;
42
+ /** The format the text came from. */
43
+ format: DocumentFormat;
44
+ /** How many pages the document had, when the format has pages. */
45
+ pages?: number;
46
+ /**
47
+ * What was lost in the conversion, stated rather than hidden.
48
+ *
49
+ * WHY THIS IS A FIELD AND NOT A LOG LINE. A Word document's tables become pipe-separated lines and
50
+ * its images become nothing at all. A caller that knows the extraction was lossy can say so to the
51
+ * person; a caller that does not will present a flattened table as if it were the document.
52
+ */
53
+ warnings: string[];
54
+ };
55
+ /** The failure of a document that could not be read, in a form a person can act on. */
56
+ export declare class DocumentError extends Error {
57
+ readonly code: DocumentErrorCode;
58
+ constructor(code: DocumentErrorCode, message: string);
59
+ }
60
+ /**
61
+ * WHY THE CODES EXIST. "Read failed" is not actionable, and the ways a document fails are
62
+ * genuinely different from one another: a dependency is missing (install something), a PDF has no
63
+ * text layer (it is a scan - use a different route), a PDF is encrypted (it needs a password), and
64
+ * the file is simply broken (nothing to do about it). Each of those needs a different sentence, and
65
+ * a person who is told "read failed" learns nothing.
66
+ */
67
+ export type DocumentErrorCode = "missing_dependency" | "missing_asset" | "no_text_layer" | "encrypted" | "corrupt" | "unsupported" | "empty";
68
+ /**
69
+ * Whether an extension names a document this layer can read.
70
+ *
71
+ * The name is checked BEFORE the bytes are sniffed, because the caller needs to know whether to
72
+ * attempt a parse at all - and because a `.pdf` that is really a text file is a different situation
73
+ * from a file with no extension that happens to be a PDF.
74
+ */
75
+ export declare function isDocumentFormat(extension: string): boolean;
76
+ /** The format an extension names, or null when it names none. */
77
+ export declare function documentFormatOf(extension: string): DocumentFormat | null;
78
+ /** The extension a format is written with. */
79
+ export declare function extensionForFormat(format: DocumentFormat): string;
80
+ /**
81
+ * Reads a document's text.
82
+ *
83
+ * The file is read here rather than handed in as a buffer because the parsers differ in what they
84
+ * want: pdfjs wants a path or a typed array, mammoth wants a buffer, exceljs wants a stream or a
85
+ * buffer. Reading once here keeps that difference from leaking into the callers.
86
+ */
87
+ export declare function extractText(path: string, format: DocumentFormat): Promise<ExtractResult>;
88
+ /**
89
+ * Reads a document's text from bytes already in hand.
90
+ *
91
+ * Split out from `extractText` so the parsers can be exercised without touching the filesystem, and
92
+ * so a caller that already has the bytes (an upload, an API request) does not write them to a
93
+ * temporary file to read them back.
94
+ */
95
+ export declare function extractTextFromBytes(bytes: Buffer, format: DocumentFormat): Promise<ExtractResult>;
96
+ /**
97
+ * WHAT A DOCUMENT IS TO BE WRITTEN FROM.
98
+ *
99
+ * The content is Markdown for prose documents and CSV for spreadsheets, chosen by the format rather
100
+ * than by the caller: a spreadsheet built from Markdown is a table with the wrong shape, and the
101
+ * caller should not have to know that.
102
+ */
103
+ export type DocumentSpec = {
104
+ format: DocumentFormat;
105
+ /** The content. Markdown for `docx`, `pdf` and `pptx`, CSV for `xlsx`. */
106
+ content: string;
107
+ /** The document's title, used for the Word heading and the PDF title line. */
108
+ title?: string;
109
+ };
110
+ /**
111
+ * Builds a document's bytes.
112
+ *
113
+ * The output is a REAL document - it opens in Word, Excel and a PDF reader - not a renamed text
114
+ * file. The structure is simple on purpose: headings, paragraphs, lists and tables for Word;
115
+ * sheets of cells for Excel; lines of text for a PDF.
116
+ */
117
+ export declare function buildDocument(spec: DocumentSpec): Promise<{
118
+ bytes: Buffer;
119
+ format: DocumentFormat;
120
+ }>;
121
+ /**
122
+ * The blocks a Markdown document is made of.
123
+ *
124
+ * A full Markdown parser is not needed here, and pulling one in would mean deciding what to do with
125
+ * the constructs it supports that a Word document has no place for. The subset below is the one a
126
+ * person actually uses when they ask for "a Word version of this".
127
+ */
128
+ type Block = {
129
+ kind: "heading";
130
+ level: number;
131
+ text: string;
132
+ } | {
133
+ kind: "paragraph";
134
+ text: string;
135
+ } | {
136
+ kind: "list";
137
+ items: string[];
138
+ } | {
139
+ kind: "table";
140
+ header: string[];
141
+ rows: string[][];
142
+ } | {
143
+ kind: "code";
144
+ text: string;
145
+ };
146
+ /** Splits Markdown into the blocks a document is built from. Exported for the tests to exercise directly. */
147
+ export declare function parseMarkdownBlocks(markdown: string): Block[];
148
+ /**
149
+ * The format a file's extension names, for the callers that hold a path rather than a format.
150
+ */
151
+ export declare function formatOfPath(path: string): DocumentFormat | null;
152
+ export {};