@evandu/super-pi 0.1.130 → 0.1.132

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +18 -0
  2. package/agents/super-pi-documents/index.ts +92 -0
  3. package/agents/super-pi-feishu/index.ts +88 -0
  4. package/agents/super-pi-knowledge/index.ts +32 -1
  5. package/agents/super-pi-task/core.ts +217 -0
  6. package/agents/super-pi-task/index.ts +74 -1
  7. package/agents/super-pi-web/index.ts +32 -0
  8. package/assets/fonts/LICENSE +92 -0
  9. package/assets/fonts/NotoSansSC-Bold.otf +0 -0
  10. package/assets/fonts/NotoSansSC-Regular.otf +0 -0
  11. package/dist/client/.super-pi-client-sources.json +13 -10
  12. package/dist/client/assets/CodeMirrorEditor-ktyL2PcX.js +2 -0
  13. package/dist/client/assets/{dist-BKp4AWEJ.js → dist--igDAzRS.js} +1 -1
  14. package/dist/client/assets/{dist-BBW2SE_R.js → dist-60usx__o.js} +1 -1
  15. package/dist/client/assets/{dist-GkIV1LTX.js → dist-8pdzh6h1.js} +1 -1
  16. package/dist/client/assets/{dist-B2nPVdKh.js → dist-B8OZ-n_C.js} +1 -1
  17. package/dist/client/assets/{dist-BDygQgZD.js → dist-BAJCrbnX.js} +1 -1
  18. package/dist/client/assets/{dist-CiSGSVBZ.js → dist-BJi5HmH4.js} +1 -1
  19. package/dist/client/assets/{dist-CRzRB9k3.js → dist-BRjGckmQ.js} +1 -1
  20. package/dist/client/assets/{dist-Cxerc7Bb.js → dist-Bg9zLfex.js} +1 -1
  21. package/dist/client/assets/{dist-C90MPTnk.js → dist-BjZUYxLd.js} +1 -1
  22. package/dist/client/assets/{dist-DmAOqr0M.js → dist-Bv6P2Rcp.js} +1 -1
  23. package/dist/client/assets/{dist-BhJGK8-b.js → dist-C6iAHGvb.js} +1 -1
  24. package/dist/client/assets/{dist-Dq4-QFAJ.js → dist-CMscy2Nn.js} +1 -1
  25. package/dist/client/assets/{dist-Ur37340K.js → dist-CPLCQDqy.js} +1 -1
  26. package/dist/client/assets/{dist-DZnUERoW.js → dist-CgmISAMr.js} +1 -1
  27. package/dist/client/assets/{dist-CWYRjnj-.js → dist-CoszOk5k.js} +1 -1
  28. package/dist/client/assets/{dist-CMnvChDX.js → dist-Cu75NyQ0.js} +1 -1
  29. package/dist/client/assets/{dist-HdeIBl2J.js → dist-DNZMXYNq.js} +1 -1
  30. package/dist/client/assets/{dist-BykVFExd.js → dist-D_Zdnhm8.js} +1 -1
  31. package/dist/client/assets/{dist-ChszWGDv.js → dist-DvHghuGd.js} +1 -1
  32. package/dist/client/assets/{dist-CZ9I2JCd.js → dist-Hj_dWgPr.js} +1 -1
  33. package/dist/client/assets/{dist-B61RSa-K.js → dist-Zhdkfvng.js} +1 -1
  34. package/dist/client/assets/{dist-DB5M5fZG.js → dist-vsMBLoxV.js} +1 -1
  35. package/dist/client/assets/{index-D_c-CYAr.js → index-ChyemF2I.js} +23 -21
  36. package/dist/client/assets/{index-CXB6U5mz.css → index-D3MEG8uH.css} +1 -1
  37. package/dist/client/index.html +2 -2
  38. package/dist/server/application/document-service.d.ts +68 -0
  39. package/dist/server/application/document-service.js +120 -0
  40. package/dist/server/application/feishu-service.d.ts +132 -0
  41. package/dist/server/application/feishu-service.js +264 -0
  42. package/dist/server/application/run-service.js +49 -7
  43. package/dist/server/application/scheduled-task-service.d.ts +164 -0
  44. package/dist/server/application/scheduled-task-service.js +350 -0
  45. package/dist/server/command-handler.d.ts +25 -1
  46. package/dist/server/command-handler.js +60 -1
  47. package/dist/server/documents.d.ts +152 -0
  48. package/dist/server/documents.js +998 -0
  49. package/dist/server/feishu-cli.d.ts +207 -0
  50. package/dist/server/feishu-cli.js +419 -0
  51. package/dist/server/file-changes.js +6 -4
  52. package/dist/server/files.d.ts +32 -0
  53. package/dist/server/files.js +31 -3
  54. package/dist/server/http-server.js +324 -2
  55. package/dist/server/index.js +41 -2
  56. package/dist/server/inline-thinking.d.ts +90 -0
  57. package/dist/server/inline-thinking.js +181 -0
  58. package/dist/server/knowledge.d.ts +24 -14
  59. package/dist/server/knowledge.js +57 -34
  60. package/dist/server/persistence/app-state-repository.d.ts +13 -1
  61. package/dist/server/persistence/sqlite-state-repository.js +3 -0
  62. package/dist/server/pi-session-pool.js +2 -1
  63. package/dist/server/pi-session.d.ts +4 -0
  64. package/dist/server/pi-session.js +14 -0
  65. package/dist/server/presentations/schema.d.ts +120 -0
  66. package/dist/server/presentations/schema.js +128 -0
  67. package/dist/server/runtime.d.ts +67 -0
  68. package/dist/server/runtime.js +204 -18
  69. package/dist/server/scheduled-tasks.d.ts +101 -0
  70. package/dist/server/scheduled-tasks.js +138 -0
  71. package/dist/server/server-context.d.ts +19 -0
  72. package/dist/server/store.d.ts +30 -0
  73. package/dist/server/store.js +81 -2
  74. package/dist/server/subagents.js +33 -6
  75. package/dist/server/task-tool.d.ts +18 -0
  76. package/dist/server/task-tool.js +18 -0
  77. package/dist/server/types.d.ts +56 -0
  78. package/dist/server/web.d.ts +24 -0
  79. package/dist/server/web.js +180 -0
  80. package/package.json +45 -6
  81. package/skills/presentation/SKILL.md +30 -0
  82. package/skills/presentation/references/data.md +10 -0
  83. package/skills/presentation/references/quality.md +12 -0
  84. package/skills/presentation/references/story.md +12 -0
  85. package/skills/presentation/references/templates-and-editing.md +11 -0
  86. package/skills/presentation/references/visual-system.md +10 -0
  87. package/dist/.super-pi-check-inputs.json +0 -4
  88. package/dist/client/assets/CodeMirrorEditor-BNVBZZSS.js +0 -2
@@ -8,8 +8,8 @@
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>
12
- <link rel="stylesheet" crossorigin href="/assets/index-CXB6U5mz.css">
11
+ <script type="module" crossorigin src="/assets/index-ChyemF2I.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-D3MEG8uH.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -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
+ }
@@ -0,0 +1,132 @@
1
+ import { FeishuCli } from "../feishu-cli.js";
2
+ /**
3
+ * The largest request the Feishu endpoint accepts.
4
+ *
5
+ * The Markdown of a document rides in the request body, so the cap is generous for a document and
6
+ * small enough that a runaway paste is refused rather than accepted.
7
+ */
8
+ export declare const MAX_FEISHU_REQUEST_BYTES: number;
9
+ /**
10
+ * WHAT THE PANEL NEEDS TO SHOW, in words a person can act on.
11
+ */
12
+ export type FeishuStatus = {
13
+ /** Whether the official CLI is on this machine at all. */
14
+ installed: boolean;
15
+ version?: string;
16
+ /** The application the CLI is configured with, when it has one. */
17
+ appId?: string;
18
+ /** Whether a person has authorized the CLI to act as them. */
19
+ userReady: boolean;
20
+ /** Whether the CLI can act as the application itself. */
21
+ botReady: boolean;
22
+ /** The identity the CLI will use by default. */
23
+ identity?: "user" | "bot";
24
+ /** Whether the agent can read and write documents right now. */
25
+ connected: boolean;
26
+ /** What to do next, in one sentence. */
27
+ message: string;
28
+ };
29
+ /** The outcome of starting the connect flow. */
30
+ export type FeishuConnectResult = {
31
+ ok: boolean;
32
+ /** Which step the person is on: creating an application, or authorizing their account. */
33
+ step?: "app" | "login";
34
+ /** The URL to open in a browser. */
35
+ verificationUri?: string;
36
+ /** The device code, kept server-side until the person says they are done. */
37
+ deviceCode?: string;
38
+ message: string;
39
+ /** Feishu's own "what to do about it", when there was one. */
40
+ hint?: string;
41
+ };
42
+ /**
43
+ * THE FEISHU SERVICE: what an agent can do with the cloud documents of a Feishu enterprise.
44
+ *
45
+ * IT OWNS NO CREDENTIAL. The official CLI owns them, in the OS keychain, and this service only
46
+ * runs commands. That is the whole point of going through the CLI: Super Pi never sees an App
47
+ * Secret, never sees a user token, and never writes a credential file to disk.
48
+ */
49
+ export declare class FeishuService {
50
+ private readonly cli;
51
+ /** The device code of a login that is waiting for the person to authorize it. */
52
+ private pendingDeviceCode?;
53
+ constructor(cli?: FeishuCli);
54
+ /**
55
+ * WHAT THE CLI CAN DO RIGHT NOW.
56
+ *
57
+ * The three states a person can be in, and what each one means for the agent:
58
+ *
59
+ * - The CLI is not installed: nothing works, and the fix is one command.
60
+ * - The CLI has no application: `config init --new` creates one through a browser confirmation,
61
+ * so the developer console is never visited.
62
+ * - The CLI has an application but no person logged in: the agent can still act as the
63
+ * application, but it cannot reach that person's own documents until they authorize it.
64
+ */
65
+ status(): Promise<FeishuStatus>;
66
+ /**
67
+ * STARTS THE CONNECT FLOW.
68
+ *
69
+ * Two steps, and which one runs is decided by what the CLI already has:
70
+ *
71
+ * - No application: `config init --new` registers one through a browser confirmation. It BLOCKS
72
+ * until the person confirms, so it runs in the background and the URL is taken from its output.
73
+ * - An application but no person: `auth login --no-wait --json` asks for a device code and hands
74
+ * back the URL to open. The code is kept here until the person says they are done.
75
+ */
76
+ connect(): Promise<FeishuConnectResult>;
77
+ /**
78
+ * FINISHES THE LOGIN, once the person says they authorized it.
79
+ *
80
+ * The device code is handed back to the CLI, which completes the exchange and stores the token
81
+ * in the keychain. Super Pi never sees the token itself.
82
+ */
83
+ completeConnect(): Promise<FeishuConnectResult>;
84
+ /**
85
+ * READS A DOCUMENT.
86
+ *
87
+ * The CLI resolves a knowledge-base link itself, so a `/wiki/` URL is passed through untouched -
88
+ * and it answers with Markdown, which is the shape a model wants.
89
+ */
90
+ read(input: {
91
+ url: string;
92
+ }): Promise<{
93
+ documentId?: string;
94
+ revisionId?: number;
95
+ title?: string;
96
+ url: string;
97
+ format: "markdown";
98
+ content: string;
99
+ }>;
100
+ /**
101
+ * WRITES A DOCUMENT.
102
+ *
103
+ * THREE MODES, and the difference between them is who decides what survives:
104
+ *
105
+ * - `create` makes a new document. Nothing can be lost, because nothing was there.
106
+ * - `append` adds to the end. Nothing can be lost either.
107
+ * - `replace` replaces the whole document. This one CAN lose a person's work, so it needs the
108
+ * revision the caller read - the same rule the file panel's write lives by, and for the same
109
+ * reason: an agent told to "把这份报告改成正式版" would otherwise destroy a hand edit made an
110
+ * hour ago, with no refusal and no warning.
111
+ *
112
+ * THE REVISION IS THE ETAG. The CLI passes it as the base revision of the edit, and Feishu
113
+ * refuses the edit when the document has moved on - so the check is enforced by the API rather
114
+ * than by a comparison Super Pi makes and then has to keep honest.
115
+ */
116
+ write(input: {
117
+ url?: string;
118
+ content: string;
119
+ mode?: "create" | "append" | "replace";
120
+ title?: string;
121
+ baseRevisionId?: string;
122
+ }): Promise<{
123
+ documentId?: string;
124
+ revisionId?: number;
125
+ url?: string;
126
+ mode: "create" | "append" | "replace";
127
+ }>;
128
+ /**
129
+ * THE TOOL CALL, as the extension sends it.
130
+ */
131
+ tool(body: unknown): Promise<unknown>;
132
+ }
@@ -0,0 +1,264 @@
1
+ import { FeishuCli, FeishuCliError, errorHint, errorMessage } from "../feishu-cli.js";
2
+ /**
3
+ * The largest request the Feishu endpoint accepts.
4
+ *
5
+ * The Markdown of a document rides in the request body, so the cap is generous for a document and
6
+ * small enough that a runaway paste is refused rather than accepted.
7
+ */
8
+ export const MAX_FEISHU_REQUEST_BYTES = 4 * 1024 * 1024;
9
+ /**
10
+ * THE FEISHU SERVICE: what an agent can do with the cloud documents of a Feishu enterprise.
11
+ *
12
+ * IT OWNS NO CREDENTIAL. The official CLI owns them, in the OS keychain, and this service only
13
+ * runs commands. That is the whole point of going through the CLI: Super Pi never sees an App
14
+ * Secret, never sees a user token, and never writes a credential file to disk.
15
+ */
16
+ export class FeishuService {
17
+ cli;
18
+ /** The device code of a login that is waiting for the person to authorize it. */
19
+ pendingDeviceCode;
20
+ constructor(cli = new FeishuCli()) {
21
+ this.cli = cli;
22
+ }
23
+ /**
24
+ * WHAT THE CLI CAN DO RIGHT NOW.
25
+ *
26
+ * The three states a person can be in, and what each one means for the agent:
27
+ *
28
+ * - The CLI is not installed: nothing works, and the fix is one command.
29
+ * - The CLI has no application: `config init --new` creates one through a browser confirmation,
30
+ * so the developer console is never visited.
31
+ * - The CLI has an application but no person logged in: the agent can still act as the
32
+ * application, but it cannot reach that person's own documents until they authorize it.
33
+ */
34
+ async status() {
35
+ const status = await this.cli.status();
36
+ if (!status.installed) {
37
+ return {
38
+ installed: false,
39
+ userReady: false,
40
+ botReady: false,
41
+ connected: false,
42
+ message: "没有检测到官方飞书 CLI(lark-cli)。请先在终端运行 npm i -g @larksuite/cli 装上它,再回到这里点「连接飞书账号」。如果已经装了但 Super Pi 找不到(两边的 PATH 不一样),用环境变量 SUPER_PI_FEISHU_CLI 指向它的完整路径再重启 Super Pi。",
43
+ };
44
+ }
45
+ if (!status.appId) {
46
+ return {
47
+ installed: true,
48
+ ...(status.version ? { version: status.version } : {}),
49
+ userReady: false,
50
+ botReady: false,
51
+ connected: false,
52
+ message: "飞书 CLI 已就绪,但还没有应用。点「连接飞书账号」,它会给你一个浏览器链接,确认之后应用就建好了——不用去开发者后台注册。",
53
+ };
54
+ }
55
+ if (!status.userReady) {
56
+ return {
57
+ installed: true,
58
+ ...(status.version ? { version: status.version } : {}),
59
+ appId: status.appId,
60
+ userReady: false,
61
+ botReady: status.botReady,
62
+ connected: false,
63
+ ...(status.identity ? { identity: status.identity } : {}),
64
+ message: "应用已配置,但还没有登录你的账号。点「连接飞书账号」用浏览器授权,授权之后 Agent 就能以你的身份读写你自己的文档,不用逐篇加协作者。",
65
+ };
66
+ }
67
+ return {
68
+ installed: true,
69
+ ...(status.version ? { version: status.version } : {}),
70
+ appId: status.appId,
71
+ userReady: true,
72
+ botReady: status.botReady,
73
+ connected: true,
74
+ identity: status.identity ?? "user",
75
+ message: "已连接:Agent 会以你的身份读写飞书云文档。",
76
+ };
77
+ }
78
+ /**
79
+ * STARTS THE CONNECT FLOW.
80
+ *
81
+ * Two steps, and which one runs is decided by what the CLI already has:
82
+ *
83
+ * - No application: `config init --new` registers one through a browser confirmation. It BLOCKS
84
+ * until the person confirms, so it runs in the background and the URL is taken from its output.
85
+ * - An application but no person: `auth login --no-wait --json` asks for a device code and hands
86
+ * back the URL to open. The code is kept here until the person says they are done.
87
+ */
88
+ async connect() {
89
+ const status = await this.status();
90
+ if (!status.installed) {
91
+ return { ok: false, message: status.message };
92
+ }
93
+ if (!status.appId) {
94
+ const verificationUri = await this.cli.startAppCreation();
95
+ return {
96
+ ok: true,
97
+ step: "app",
98
+ ...(verificationUri ? { verificationUri } : {}),
99
+ message: verificationUri
100
+ ? "在浏览器里打开这个链接确认一下,应用就建好了(CLI 会把应用和凭据存进系统钥匙串)。确认完点「我已完成」。"
101
+ : "CLI 没有打印出链接。请在终端里运行 lark-cli config init --new,按提示扫码确认,然后回到这里点「连接飞书账号」。",
102
+ };
103
+ }
104
+ try {
105
+ const login = await this.cli.beginLogin();
106
+ this.pendingDeviceCode = login.deviceCode;
107
+ return {
108
+ ok: true,
109
+ step: "login",
110
+ ...(login.verificationUri ? { verificationUri: login.verificationUri } : {}),
111
+ deviceCode: login.deviceCode,
112
+ message: login.verificationUri
113
+ ? "在浏览器里打开这个链接,用你的飞书账号确认授权,然后点「我已完成」。"
114
+ : "CLI 已经发起设备码授权。请在浏览器里确认,然后点「我已完成」。",
115
+ };
116
+ }
117
+ catch (error) {
118
+ return { ok: false, message: messageOf(error), ...(hintOf(error) ? { hint: hintOf(error) } : {}) };
119
+ }
120
+ }
121
+ /**
122
+ * FINISHES THE LOGIN, once the person says they authorized it.
123
+ *
124
+ * The device code is handed back to the CLI, which completes the exchange and stores the token
125
+ * in the keychain. Super Pi never sees the token itself.
126
+ */
127
+ async completeConnect() {
128
+ if (!this.pendingDeviceCode) {
129
+ return { ok: false, message: "没有待完成的登录。点「连接飞书账号」重新开始。" };
130
+ }
131
+ const deviceCode = this.pendingDeviceCode;
132
+ this.pendingDeviceCode = undefined;
133
+ try {
134
+ const envelope = await this.cli.completeLogin(deviceCode);
135
+ if (envelope.ok === false) {
136
+ return { ok: false, message: errorMessage(envelope), ...(errorHint(envelope) ? { hint: errorHint(envelope) } : {}) };
137
+ }
138
+ return { ok: true, message: "已连接。现在 Agent 能以你的身份读写飞书云文档了。" };
139
+ }
140
+ catch (error) {
141
+ return { ok: false, message: messageOf(error), ...(hintOf(error) ? { hint: hintOf(error) } : {}) };
142
+ }
143
+ }
144
+ /**
145
+ * READS A DOCUMENT.
146
+ *
147
+ * The CLI resolves a knowledge-base link itself, so a `/wiki/` URL is passed through untouched -
148
+ * and it answers with Markdown, which is the shape a model wants.
149
+ */
150
+ async read(input) {
151
+ const document = await this.cli.fetchDocument(input.url);
152
+ return {
153
+ ...(document.documentId ? { documentId: document.documentId } : {}),
154
+ ...(document.revisionId !== undefined ? { revisionId: document.revisionId } : {}),
155
+ ...(document.title ? { title: document.title } : {}),
156
+ url: input.url,
157
+ format: "markdown",
158
+ content: document.content,
159
+ };
160
+ }
161
+ /**
162
+ * WRITES A DOCUMENT.
163
+ *
164
+ * THREE MODES, and the difference between them is who decides what survives:
165
+ *
166
+ * - `create` makes a new document. Nothing can be lost, because nothing was there.
167
+ * - `append` adds to the end. Nothing can be lost either.
168
+ * - `replace` replaces the whole document. This one CAN lose a person's work, so it needs the
169
+ * revision the caller read - the same rule the file panel's write lives by, and for the same
170
+ * reason: an agent told to "把这份报告改成正式版" would otherwise destroy a hand edit made an
171
+ * hour ago, with no refusal and no warning.
172
+ *
173
+ * THE REVISION IS THE ETAG. The CLI passes it as the base revision of the edit, and Feishu
174
+ * refuses the edit when the document has moved on - so the check is enforced by the API rather
175
+ * than by a comparison Super Pi makes and then has to keep honest.
176
+ */
177
+ async write(input) {
178
+ const mode = input.mode ?? (input.url ? "append" : "create");
179
+ const content = String(input.content ?? "");
180
+ if (!content.trim())
181
+ throw new Error("要写入的内容是空的。");
182
+ if (mode === "create") {
183
+ const title = input.title?.trim() || titleFromMarkdown(content);
184
+ const created = await this.cli.createDocument(title, content);
185
+ return {
186
+ ...(created.documentId ? { documentId: created.documentId } : {}),
187
+ ...(created.revisionId !== undefined ? { revisionId: created.revisionId } : {}),
188
+ ...(created.url ? { url: created.url } : {}),
189
+ mode,
190
+ };
191
+ }
192
+ if (!input.url)
193
+ throw new Error("追加或覆盖需要给出文档链接或 token。");
194
+ if (mode === "replace") {
195
+ if (!input.baseRevisionId) {
196
+ throw new Error("覆盖前需要确认文档版本:先用 feishu_doc_read 读一次这篇文档,把它返回的 revisionId 作为 baseRevisionId 传入。覆盖会清空整篇文档,必须先看过。");
197
+ }
198
+ const revision = Number(input.baseRevisionId);
199
+ if (!Number.isFinite(revision)) {
200
+ throw new Error(`baseRevisionId 不是有效的版本号:${input.baseRevisionId}`);
201
+ }
202
+ const updated = await this.cli.updateDocument(input.url, "overwrite", content, revision);
203
+ return {
204
+ ...(updated.revisionId !== undefined ? { revisionId: updated.revisionId } : {}),
205
+ url: input.url,
206
+ mode,
207
+ };
208
+ }
209
+ const updated = await this.cli.updateDocument(input.url, "append", content);
210
+ return {
211
+ ...(updated.revisionId !== undefined ? { revisionId: updated.revisionId } : {}),
212
+ url: input.url,
213
+ mode,
214
+ };
215
+ }
216
+ /**
217
+ * THE TOOL CALL, as the extension sends it.
218
+ */
219
+ async tool(body) {
220
+ const request = (body ?? {});
221
+ const action = String(request.action ?? "read");
222
+ if (action === "read") {
223
+ return this.read({ url: String(request.url ?? "") });
224
+ }
225
+ if (action === "write") {
226
+ const mode = request.mode === "create" || request.mode === "append" || request.mode === "replace" ? request.mode : undefined;
227
+ return this.write({
228
+ ...(request.url ? { url: String(request.url) } : {}),
229
+ content: String(request.content ?? ""),
230
+ ...(mode ? { mode } : {}),
231
+ ...(request.title ? { title: String(request.title) } : {}),
232
+ ...(request.baseRevisionId ? { baseRevisionId: String(request.baseRevisionId) } : {}),
233
+ });
234
+ }
235
+ throw new Error(`未知的飞书工具操作:${action}`);
236
+ }
237
+ }
238
+ /** The message of a failure, whatever shape the failure has. */
239
+ function messageOf(error) {
240
+ if (error instanceof FeishuCliError)
241
+ return error.message;
242
+ if (error instanceof Error)
243
+ return error.message;
244
+ return String(error);
245
+ }
246
+ /** The hint of a failure, when it carries one. */
247
+ function hintOf(error) {
248
+ return error instanceof FeishuCliError ? error.hint : undefined;
249
+ }
250
+ /**
251
+ * A TITLE FROM THE CONTENT, for a document created without one.
252
+ *
253
+ * The first heading is what a person would have called it, and a generated title is better than
254
+ * "未命名文档" only when there is something to take.
255
+ */
256
+ function titleFromMarkdown(content) {
257
+ for (const line of content.split("\n")) {
258
+ const heading = /^#{1,6}\s+(.+?)\s*$/.exec(line.trim());
259
+ if (heading?.[1])
260
+ return heading[1].slice(0, 800);
261
+ }
262
+ const firstLine = content.split("\n").map((line) => line.trim()).find((line) => line.length > 0);
263
+ return (firstLine ? firstLine.slice(0, 800) : "未命名文档");
264
+ }