@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.
- 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/active-memory.d.ts +12 -0
- package/dist/server/active-memory.js +25 -1
- package/dist/server/application/conversation-service.js +5 -0
- 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 +20 -6
- package/dist/server/pi-session.js +43 -11
- 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 +33 -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
package/dist/client/index.html
CHANGED
|
@@ -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-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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 {};
|