@evandu/super-pi 0.1.152 → 0.1.153
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/dist/client/.super-pi-client-sources.json +3 -3
- package/dist/client/assets/{CodeMirrorEditor-Cb6Drhk5.js → CodeMirrorEditor-DmRMgD_e.js} +2 -2
- package/dist/client/assets/{dist-BHWjjFy_.js → dist-1ehoscoU.js} +1 -1
- package/dist/client/assets/{dist-ByaeX0Xa.js → dist-BAy39a02.js} +1 -1
- package/dist/client/assets/{dist-BorepJPL.js → dist-BIMTBXpD.js} +1 -1
- package/dist/client/assets/{dist-Df1eD154.js → dist-BPU0RZX2.js} +1 -1
- package/dist/client/assets/{dist-BvW835kJ.js → dist-BQvKL2T4.js} +1 -1
- package/dist/client/assets/{dist-DnIfPAlr.js → dist-BTfxEdeF.js} +1 -1
- package/dist/client/assets/{dist-DgaBkcOR.js → dist-BWxApOy6.js} +1 -1
- package/dist/client/assets/{dist-CFBpvn_F.js → dist-BaagZpIV.js} +1 -1
- package/dist/client/assets/{dist-B8RyLyAM.js → dist-BjZSq94T.js} +1 -1
- package/dist/client/assets/{dist-Kd8Ts9Qe.js → dist-BzOPeLJM.js} +1 -1
- package/dist/client/assets/{dist-BqN0geUv.js → dist-C5OCcu2D.js} +1 -1
- package/dist/client/assets/{dist-dPJqK8GC.js → dist-CMNmeOX_.js} +1 -1
- package/dist/client/assets/{dist-CjbILrLW.js → dist-Cl_nos6D.js} +1 -1
- package/dist/client/assets/{dist-i6E_Fwl0.js → dist-DE8_FyRQ.js} +1 -1
- package/dist/client/assets/{dist-Cjk1RRIz.js → dist-DM15RCDF.js} +1 -1
- package/dist/client/assets/{dist-1P3rbXrP.js → dist-DpK_uu3H.js} +1 -1
- package/dist/client/assets/{dist-DkEKqzzI.js → dist-QCoWWp1z.js} +1 -1
- package/dist/client/assets/{dist-BgQmfor3.js → dist-SHt_aVda.js} +1 -1
- package/dist/client/assets/{dist-BzySVYC0.js → dist-YiNVn-GQ.js} +1 -1
- package/dist/client/assets/{dist-ijCQUPtv.js → dist-h0HvU8ex.js} +1 -1
- package/dist/client/assets/{dist-D7zfHJFz.js → dist-mY7dpaip.js} +1 -1
- package/dist/client/assets/{dist-CyFyjl9w.js → dist-tpn03yX2.js} +1 -1
- package/dist/client/assets/{index-CgRiEaG2.js → index-Ce896Hh9.js} +12 -12
- package/dist/client/index.html +1 -1
- package/dist/server/terminal.d.ts +29 -4
- package/dist/server/terminal.js +38 -5
- package/package.json +5 -3
- package/dist/.super-pi-check-inputs.json +0 -4
- package/dist/server/feishu-config.d.ts +0 -68
- package/dist/server/feishu-config.js +0 -84
- package/dist/server/feishu.d.ts +0 -187
- package/dist/server/feishu.js +0 -695
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-Ce896Hh9.js"></script>
|
|
12
12
|
<link rel="stylesheet" crossorigin href="/assets/index-DCLaWQin.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
* nothing else; this file resolves the id against the store and spawns in that workspace's path.
|
|
30
30
|
* A browser that asked for `C:\` or `/etc` gets its own workspace's directory instead, because
|
|
31
31
|
* there is no code path on which a client-supplied path reaches `spawn`.
|
|
32
|
-
* 4. BOUNDED SESSIONS. `MAX_SESSIONS` shells,
|
|
33
|
-
*
|
|
32
|
+
* 4. BOUNDED SESSIONS. `MAX_SESSIONS` live shells, capped so a loop of reconnects cannot accumulate
|
|
33
|
+
* processes.
|
|
34
34
|
*
|
|
35
35
|
* What is NOT here is a per-command allowlist. A terminal that refuses `rm` or `curl` is not a terminal,
|
|
36
36
|
* and pretending otherwise would be worse than the honest version: with (1) and (2) the operator has
|
|
@@ -89,8 +89,33 @@ export declare function terminalShellCandidates(env?: NodeJS.ProcessEnv): {
|
|
|
89
89
|
args: string[];
|
|
90
90
|
label: string;
|
|
91
91
|
}[];
|
|
92
|
-
/**
|
|
93
|
-
|
|
92
|
+
/**
|
|
93
|
+
* THE CEILING ON LIVE SHELLS.
|
|
94
|
+
*
|
|
95
|
+
* WHAT THIS COUNTS. Only the browser's terminal panel: `terminal.open` is the ONE place a shell is
|
|
96
|
+
* spawned, and it is a command the browser sends over its own WebSocket. An agent session, a
|
|
97
|
+
* sub-agent run, and the `exec` tool do NOT consume this budget - the exec tool spawns its own
|
|
98
|
+
* short-lived `shell -c` process through a different service.
|
|
99
|
+
*
|
|
100
|
+
* WHY THE COUNT ACCUMULATES. The client opens up to five terminals per session (`MAX_SLOTS` in
|
|
101
|
+
* TerminalPanel), and a shell is deliberately KEPT ALIVE across a session switch: the panel unmounts
|
|
102
|
+
* without closing it, so the work in that shell survives the switch. Every session in the tree is a
|
|
103
|
+
* conversation with its own panel - including a CHILD session the user can click into - and each of
|
|
104
|
+
* them can hold five shells, and all of them land in this one map. A tree multiplies: eight parents
|
|
105
|
+
* with four children each are forty sessions, and forty sessions at four terminals is a hundred and
|
|
106
|
+
* sixty shells.
|
|
107
|
+
*
|
|
108
|
+
* WHY 256. Every shell in this map was opened by the user on purpose, and what runs inside it is the
|
|
109
|
+
* user's own business - so this constant is NOT a memory governor and does not pretend to be one. Its
|
|
110
|
+
* whole job is to be generous enough never to stand in the way of real work, and loud enough that a
|
|
111
|
+
* client looping on `terminal.open` is stopped with a message instead of quietly filling the machine.
|
|
112
|
+
* (For the record, the platform's own overhead per shell is the shell: an idle Windows PowerShell is
|
|
113
|
+
* ~90MB and an idle bash ~8MB, so the cost of "many terminals" is the cost of the shells themselves.)
|
|
114
|
+
* Reconnect storms do not need this constant to be low: the socket-close cleanup already kills a
|
|
115
|
+
* connection's shells, and the 30-second heartbeat reaps the half-open connections that would
|
|
116
|
+
* otherwise leave them behind.
|
|
117
|
+
*/
|
|
118
|
+
export declare const MAX_SESSIONS = 256;
|
|
94
119
|
/**
|
|
95
120
|
* The terminal's wire protocol, as members of the EXISTING `ClientCommand` / `ServerEvent` unions rather
|
|
96
121
|
* than a second transport.
|
package/dist/server/terminal.js
CHANGED
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
* nothing else; this file resolves the id against the store and spawns in that workspace's path.
|
|
30
30
|
* A browser that asked for `C:\` or `/etc` gets its own workspace's directory instead, because
|
|
31
31
|
* there is no code path on which a client-supplied path reaches `spawn`.
|
|
32
|
-
* 4. BOUNDED SESSIONS. `MAX_SESSIONS` shells,
|
|
33
|
-
*
|
|
32
|
+
* 4. BOUNDED SESSIONS. `MAX_SESSIONS` live shells, capped so a loop of reconnects cannot accumulate
|
|
33
|
+
* processes.
|
|
34
34
|
*
|
|
35
35
|
* What is NOT here is a per-command allowlist. A terminal that refuses `rm` or `curl` is not a terminal,
|
|
36
36
|
* and pretending otherwise would be worse than the honest version: with (1) and (2) the operator has
|
|
@@ -190,8 +190,33 @@ export function terminalShellCandidates(env = process.env) {
|
|
|
190
190
|
// ---------------------------------------------------------------------------------------------
|
|
191
191
|
// Bounds
|
|
192
192
|
// ---------------------------------------------------------------------------------------------
|
|
193
|
-
/**
|
|
194
|
-
|
|
193
|
+
/**
|
|
194
|
+
* THE CEILING ON LIVE SHELLS.
|
|
195
|
+
*
|
|
196
|
+
* WHAT THIS COUNTS. Only the browser's terminal panel: `terminal.open` is the ONE place a shell is
|
|
197
|
+
* spawned, and it is a command the browser sends over its own WebSocket. An agent session, a
|
|
198
|
+
* sub-agent run, and the `exec` tool do NOT consume this budget - the exec tool spawns its own
|
|
199
|
+
* short-lived `shell -c` process through a different service.
|
|
200
|
+
*
|
|
201
|
+
* WHY THE COUNT ACCUMULATES. The client opens up to five terminals per session (`MAX_SLOTS` in
|
|
202
|
+
* TerminalPanel), and a shell is deliberately KEPT ALIVE across a session switch: the panel unmounts
|
|
203
|
+
* without closing it, so the work in that shell survives the switch. Every session in the tree is a
|
|
204
|
+
* conversation with its own panel - including a CHILD session the user can click into - and each of
|
|
205
|
+
* them can hold five shells, and all of them land in this one map. A tree multiplies: eight parents
|
|
206
|
+
* with four children each are forty sessions, and forty sessions at four terminals is a hundred and
|
|
207
|
+
* sixty shells.
|
|
208
|
+
*
|
|
209
|
+
* WHY 256. Every shell in this map was opened by the user on purpose, and what runs inside it is the
|
|
210
|
+
* user's own business - so this constant is NOT a memory governor and does not pretend to be one. Its
|
|
211
|
+
* whole job is to be generous enough never to stand in the way of real work, and loud enough that a
|
|
212
|
+
* client looping on `terminal.open` is stopped with a message instead of quietly filling the machine.
|
|
213
|
+
* (For the record, the platform's own overhead per shell is the shell: an idle Windows PowerShell is
|
|
214
|
+
* ~90MB and an idle bash ~8MB, so the cost of "many terminals" is the cost of the shells themselves.)
|
|
215
|
+
* Reconnect storms do not need this constant to be low: the socket-close cleanup already kills a
|
|
216
|
+
* connection's shells, and the 30-second heartbeat reaps the half-open connections that would
|
|
217
|
+
* otherwise leave them behind.
|
|
218
|
+
*/
|
|
219
|
+
export const MAX_SESSIONS = 256;
|
|
195
220
|
/** Below the client panel's own minimum, and small enough that a `ls` cannot stall the socket. */
|
|
196
221
|
const MIN_COLS = 20;
|
|
197
222
|
const MIN_ROWS = 5;
|
|
@@ -296,7 +321,15 @@ export class TerminalService {
|
|
|
296
321
|
if (this.sessions.has(command.sessionId))
|
|
297
322
|
return;
|
|
298
323
|
if (this.sessions.size >= MAX_SESSIONS) {
|
|
299
|
-
|
|
324
|
+
// The count is in the message because the shells eating the budget are usually the BACKGROUND
|
|
325
|
+
// ones of other sessions - a parent session's children included, each of which has its own
|
|
326
|
+
// panel with its own terminals. The user sees three terminals in this panel and a refusal,
|
|
327
|
+
// and has no way to guess that the sessions they left open still hold shells.
|
|
328
|
+
send({
|
|
329
|
+
type: "terminal.error",
|
|
330
|
+
sessionId: command.sessionId,
|
|
331
|
+
message: `终端会话已达上限(服务器最多 ${MAX_SESSIONS} 个,当前已有 ${this.sessions.size} 个在运行)。切换会话后仍在后台运行的终端也计入其中(包括 session 树里的子会话),请到对应的会话里关闭一个再试。`,
|
|
332
|
+
});
|
|
300
333
|
return;
|
|
301
334
|
}
|
|
302
335
|
// The workspace id is the ONLY thing the browser supplies, and it is resolved here. A client that
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evandu/super-pi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.153",
|
|
4
4
|
"description": "Web control plane and companion for Pi",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
"node": ">=22.19.0"
|
|
30
30
|
},
|
|
31
31
|
"scripts": {
|
|
32
|
-
"prepack": "
|
|
33
|
-
"prepublishOnly": "
|
|
32
|
+
"prepack": "node scripts/clean-build.mjs",
|
|
33
|
+
"prepublishOnly": "node scripts/clean-build.mjs && cross-env SUPER_PI_REQUIRE_REAL_PI=1 node scripts/extension-load.test.mjs",
|
|
34
34
|
"dev": "concurrently --kill-others-on-fail \"npm:dev:server\" \"npm:dev:client\"",
|
|
35
35
|
"dev:client": "vite --host 0.0.0.0",
|
|
36
36
|
"dev:server": "cross-env SUPER_PI_PORT=3081 tsx watch src/server/index.ts",
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
"scripts/pi-session.test.mjs",
|
|
54
54
|
"scripts/length-continuation.test.mjs",
|
|
55
55
|
"scripts/pi-session-isolation.test.mjs",
|
|
56
|
+
"scripts/build-state.test.mjs",
|
|
56
57
|
"scripts/installed-layout.test.mjs",
|
|
57
58
|
"scripts/pi-model-switch.test.mjs",
|
|
58
59
|
"scripts/pi-thinking-levels.test.mjs",
|
|
@@ -277,6 +278,7 @@
|
|
|
277
278
|
"scripts/narrow-layout.test.mjs",
|
|
278
279
|
"scripts/scrollbar-style.test.mjs",
|
|
279
280
|
"scripts/stream-tail-browser.test.mjs",
|
|
281
|
+
"scripts/scroll-restore-browser.test.mjs",
|
|
280
282
|
"scripts/theme-contrast.test.mjs",
|
|
281
283
|
"scripts/theme-switch-state.test.mjs",
|
|
282
284
|
"scripts/settings-window-structure.test.mjs",
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import type { FeishuDomain } from "./feishu.js";
|
|
2
|
-
/**
|
|
3
|
-
* THE FEISHU CREDENTIALS, and where they live.
|
|
4
|
-
*
|
|
5
|
-
* WHY A FILE IN THE DATA DIRECTORY AND NOT A FIELD IN THE APP STATE. The App State is broadcast to
|
|
6
|
-
* every browser that connects, and it is written to disk in a form that is meant to be readable.
|
|
7
|
-
* A secret does not belong there - the same rule the provider registry follows, where the API key
|
|
8
|
-
* lives in `<dataDir>/providers.json` with restrictive permissions and the browser is told only
|
|
9
|
-
* that a key exists.
|
|
10
|
-
*
|
|
11
|
-
* WHY THE ENVIRONMENT OVERRIDES THE FILE. A deployment that provisions the credential through the
|
|
12
|
-
* environment should not have to write a file as well, and the environment is the harder thing to
|
|
13
|
-
* override accidentally. The file is the fallback, not the other way round.
|
|
14
|
-
*
|
|
15
|
-
* WHY THERE IS ONE SET OF CREDENTIALS FOR THE WHOLE SERVER. Feishu credentials belong to an
|
|
16
|
-
* application inside one enterprise, and Super Pi runs on one person's machine. A per-workspace
|
|
17
|
-
* credential would be a second enterprise's application, and the user of that workspace would have
|
|
18
|
-
* to be handed a secret they never asked for.
|
|
19
|
-
*/
|
|
20
|
-
export type FeishuConfig = {
|
|
21
|
-
/** The application's App ID. */
|
|
22
|
-
appId?: string;
|
|
23
|
-
/** The application's App Secret. Never leaves the server. */
|
|
24
|
-
appSecret?: string;
|
|
25
|
-
/** Which Feishu the calls go to. */
|
|
26
|
-
domain?: FeishuDomain;
|
|
27
|
-
};
|
|
28
|
-
/** Where the credentials come from, for the panel to show. */
|
|
29
|
-
export type FeishuConfigSource = "environment" | "file" | "none";
|
|
30
|
-
export declare class FeishuConfigStore {
|
|
31
|
-
private readonly file;
|
|
32
|
-
constructor(file: string);
|
|
33
|
-
/**
|
|
34
|
-
* The credentials in force, and where they came from.
|
|
35
|
-
*
|
|
36
|
-
* The environment wins over the file, and the file is read only when the environment has not
|
|
37
|
-
* said anything - so a machine that provisions the credential through the environment never
|
|
38
|
-
* touches the file at all.
|
|
39
|
-
*/
|
|
40
|
-
read(): Promise<{
|
|
41
|
-
config: FeishuConfig;
|
|
42
|
-
source: FeishuConfigSource;
|
|
43
|
-
}>;
|
|
44
|
-
/**
|
|
45
|
-
* The file's own contents, and only its own contents.
|
|
46
|
-
*
|
|
47
|
-
* A file that cannot be read is a file that is not there, and a file that cannot be parsed is a
|
|
48
|
-
* file this server did not write. Both are reported rather than thrown: a broken credential
|
|
49
|
-
* file must not stop the server from starting.
|
|
50
|
-
*/
|
|
51
|
-
private readFile;
|
|
52
|
-
/**
|
|
53
|
-
* Writes the credentials.
|
|
54
|
-
*
|
|
55
|
-
* The write is atomic (a temporary file and a rename) so a crash cannot leave half a JSON file
|
|
56
|
-
* behind, and the file's mode is 0600 so that a secret is not readable by every account on the
|
|
57
|
-
* machine. On Windows the mode is not enforced the same way, and there is nothing this code can
|
|
58
|
-
* do about that.
|
|
59
|
-
*/
|
|
60
|
-
write(config: FeishuConfig): Promise<void>;
|
|
61
|
-
}
|
|
62
|
-
/**
|
|
63
|
-
* The domain a name means, defaulting to Feishu.
|
|
64
|
-
*
|
|
65
|
-
* A name this does not recognise is Feishu rather than an error: the domain is a convenience for
|
|
66
|
-
* Lark users, and refusing to start over a typo in it would be worse than trying Feishu.
|
|
67
|
-
*/
|
|
68
|
-
export declare function domainOf(value: string | undefined): FeishuDomain;
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { dirname } from "node:path";
|
|
4
|
-
export class FeishuConfigStore {
|
|
5
|
-
file;
|
|
6
|
-
constructor(file) {
|
|
7
|
-
this.file = file;
|
|
8
|
-
}
|
|
9
|
-
/**
|
|
10
|
-
* The credentials in force, and where they came from.
|
|
11
|
-
*
|
|
12
|
-
* The environment wins over the file, and the file is read only when the environment has not
|
|
13
|
-
* said anything - so a machine that provisions the credential through the environment never
|
|
14
|
-
* touches the file at all.
|
|
15
|
-
*/
|
|
16
|
-
async read() {
|
|
17
|
-
const fromEnv = {
|
|
18
|
-
...(process.env.SUPER_PI_FEISHU_APP_ID ? { appId: process.env.SUPER_PI_FEISHU_APP_ID.trim() } : {}),
|
|
19
|
-
...(process.env.SUPER_PI_FEISHU_APP_SECRET ? { appSecret: process.env.SUPER_PI_FEISHU_APP_SECRET.trim() } : {}),
|
|
20
|
-
...(process.env.SUPER_PI_FEISHU_DOMAIN ? { domain: domainOf(process.env.SUPER_PI_FEISHU_DOMAIN) } : {}),
|
|
21
|
-
};
|
|
22
|
-
if (fromEnv.appId && fromEnv.appSecret)
|
|
23
|
-
return { config: fromEnv, source: "environment" };
|
|
24
|
-
const stored = await this.readFile();
|
|
25
|
-
if (fromEnv.appId || fromEnv.appSecret)
|
|
26
|
-
return { config: { ...stored, ...fromEnv }, source: "environment" };
|
|
27
|
-
return { config: stored, source: stored.appId && stored.appSecret ? "file" : "none" };
|
|
28
|
-
}
|
|
29
|
-
/**
|
|
30
|
-
* The file's own contents, and only its own contents.
|
|
31
|
-
*
|
|
32
|
-
* A file that cannot be read is a file that is not there, and a file that cannot be parsed is a
|
|
33
|
-
* file this server did not write. Both are reported rather than thrown: a broken credential
|
|
34
|
-
* file must not stop the server from starting.
|
|
35
|
-
*/
|
|
36
|
-
async readFile() {
|
|
37
|
-
try {
|
|
38
|
-
const raw = await readFile(this.file, "utf8");
|
|
39
|
-
const parsed = JSON.parse(raw);
|
|
40
|
-
return {
|
|
41
|
-
...(typeof parsed.appId === "string" ? { appId: parsed.appId } : {}),
|
|
42
|
-
...(typeof parsed.appSecret === "string" ? { appSecret: parsed.appSecret } : {}),
|
|
43
|
-
...(parsed.domain ? { domain: domainOf(parsed.domain) } : {}),
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
catch {
|
|
47
|
-
return {};
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
/**
|
|
51
|
-
* Writes the credentials.
|
|
52
|
-
*
|
|
53
|
-
* The write is atomic (a temporary file and a rename) so a crash cannot leave half a JSON file
|
|
54
|
-
* behind, and the file's mode is 0600 so that a secret is not readable by every account on the
|
|
55
|
-
* machine. On Windows the mode is not enforced the same way, and there is nothing this code can
|
|
56
|
-
* do about that.
|
|
57
|
-
*/
|
|
58
|
-
async write(config) {
|
|
59
|
-
await mkdir(dirname(this.file), { recursive: true });
|
|
60
|
-
const temporary = `${this.file}.${randomUUID()}.tmp`;
|
|
61
|
-
const value = {
|
|
62
|
-
...(config.appId ? { appId: config.appId } : {}),
|
|
63
|
-
...(config.appSecret ? { appSecret: config.appSecret } : {}),
|
|
64
|
-
...(config.domain ? { domain: config.domain } : {}),
|
|
65
|
-
};
|
|
66
|
-
try {
|
|
67
|
-
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
68
|
-
await rename(temporary, this.file);
|
|
69
|
-
}
|
|
70
|
-
catch (error) {
|
|
71
|
-
await writeFile(temporary, "{}", { mode: 0o600 }).catch(() => undefined);
|
|
72
|
-
throw error;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
/**
|
|
77
|
-
* The domain a name means, defaulting to Feishu.
|
|
78
|
-
*
|
|
79
|
-
* A name this does not recognise is Feishu rather than an error: the domain is a convenience for
|
|
80
|
-
* Lark users, and refusing to start over a typo in it would be worse than trying Feishu.
|
|
81
|
-
*/
|
|
82
|
-
export function domainOf(value) {
|
|
83
|
-
return String(value ?? "").trim().toLowerCase() === "lark" ? "lark" : "feishu";
|
|
84
|
-
}
|
package/dist/server/feishu.d.ts
DELETED
|
@@ -1,187 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* THE FEISHU CLIENT: everything Super Pi knows how to do with a cloud document on Feishu.
|
|
3
|
-
*
|
|
4
|
-
* WHY THIS LAYER EXISTS AT ALL. Feishu's document API is not "read a file" and "write a file". A
|
|
5
|
-
* document is a TREE OF BLOCKS, the API refuses to create a document with content in it, and the
|
|
6
|
-
* official way to put Markdown into a document is a three-step dance (convert to blocks, then
|
|
7
|
-
* insert them as nested blocks under the page block). An agent that had to know that would be
|
|
8
|
-
* writing protocol details into every prompt. So the protocol lives here, once, and what is left
|
|
9
|
-
* above it is "read this document" and "write this document".
|
|
10
|
-
*
|
|
11
|
-
* WHY AN HTTP CLIENT AND NOT THE OFFICIAL SDK. The official SDK (`@larksuiteoapi/node-sdk`) is a
|
|
12
|
-
* good library, but it is a dependency of the PACKAGE, and this package's whole point is that it
|
|
13
|
-
* installs and runs. Every call it makes is a plain HTTP call with a bearer token, so the four
|
|
14
|
-
* endpoints this feature needs are four functions - and the credential handling (a token that
|
|
15
|
-
* expires, a token that has to be refreshed, a rate limit that has to be waited out) is the part
|
|
16
|
-
* that is worth writing carefully and testing on its own.
|
|
17
|
-
*
|
|
18
|
-
* WHAT THE IDENTITY IS. Everything here runs as the APPLICATION (`tenant_access_token`), never as
|
|
19
|
-
* a person. That is the identity that needs no OAuth dance and no browser, and it is also the
|
|
20
|
-
* identity that can only touch the documents the application has been made a collaborator on.
|
|
21
|
-
* See `docs/FEISHU.md` for the setup that implies, because it is the one thing a user must do
|
|
22
|
-
* before any of this works.
|
|
23
|
-
*/
|
|
24
|
-
/** The tool that reads a Feishu document. */
|
|
25
|
-
export declare const FEISHU_READ_TOOL_NAME = "feishu_doc_read";
|
|
26
|
-
/** The tool that writes a Feishu document. */
|
|
27
|
-
export declare const FEISHU_WRITE_TOOL_NAME = "feishu_doc_write";
|
|
28
|
-
/** Which flavour of Feishu we talk to. The API paths differ, the shapes do not. */
|
|
29
|
-
export type FeishuDomain = "feishu" | "lark";
|
|
30
|
-
/** A block in a Feishu document. Only the fields this code reads are named. */
|
|
31
|
-
export type FeishuBlock = {
|
|
32
|
-
block_id: string;
|
|
33
|
-
parent_id?: string;
|
|
34
|
-
children?: string[];
|
|
35
|
-
block_type: number;
|
|
36
|
-
[key: string]: unknown;
|
|
37
|
-
};
|
|
38
|
-
/**
|
|
39
|
-
* A REFUSAL FROM FEISHU, with the code kept alongside the message.
|
|
40
|
-
*
|
|
41
|
-
* The code is kept because the message Feishu sends is written for a developer reading a log,
|
|
42
|
-
* and the part a person needs - "add this application as a collaborator", "open this permission
|
|
43
|
-
* point" - is not in it.
|
|
44
|
-
*/
|
|
45
|
-
export declare class FeishuError extends Error {
|
|
46
|
-
readonly code: number;
|
|
47
|
-
readonly hint?: string | undefined;
|
|
48
|
-
constructor(code: number, message: string, hint?: string | undefined);
|
|
49
|
-
}
|
|
50
|
-
/**
|
|
51
|
-
* The message a refusal is shown with: what Feishu said, and what to do about it.
|
|
52
|
-
*/
|
|
53
|
-
export declare function describeFeishuError(code: number, message: string): string;
|
|
54
|
-
/**
|
|
55
|
-
* WHAT A URL OR A TOKEN NAMES.
|
|
56
|
-
*
|
|
57
|
-
* `kind` is "docx" for a document and "wiki" for a knowledge-base node. The distinction is not
|
|
58
|
-
* cosmetic: a wiki URL's token is NOT the document id, and passing it as one produces a document
|
|
59
|
-
* that does not exist. A wiki link has to be resolved through the wiki API first.
|
|
60
|
-
*/
|
|
61
|
-
export type FeishuTarget = {
|
|
62
|
-
kind: "docx" | "wiki";
|
|
63
|
-
token: string;
|
|
64
|
-
/** The tenant host the link came from, when it came from a link. Used to build a URL for a new document. */
|
|
65
|
-
host?: string;
|
|
66
|
-
};
|
|
67
|
-
/**
|
|
68
|
-
* Reads a document reference: a full URL, or a bare token.
|
|
69
|
-
*
|
|
70
|
-
* WHY BARE TOKENS ARE ACCEPTED. A person reading a document's URL sees the token in it, and an
|
|
71
|
-
* agent that has been handed `doxcn...` should not have to be told to wrap it in a URL first.
|
|
72
|
-
*/
|
|
73
|
-
export declare function parseFeishuTarget(input: string): FeishuTarget;
|
|
74
|
-
/**
|
|
75
|
-
* Renders a document's blocks as Markdown.
|
|
76
|
-
*
|
|
77
|
-
* WHY MARKDOWN AND NOT THE RAW TEXT. `raw_content` gives the words without the shape: a heading
|
|
78
|
-
* arrives as a heading's text, a list arrives as separate lines, and a reader cannot tell them
|
|
79
|
-
* apart. The block tree is the document, and Markdown is the closest plain-text shape this side of
|
|
80
|
-
* the product already understands.
|
|
81
|
-
*/
|
|
82
|
-
export declare function blocksToMarkdown(blocks: FeishuBlock[]): string;
|
|
83
|
-
/** The client the service drives. Every call goes through here. */
|
|
84
|
-
export type FeishuClientOptions = {
|
|
85
|
-
appId: string;
|
|
86
|
-
appSecret: string;
|
|
87
|
-
domain?: FeishuDomain;
|
|
88
|
-
/** Injected in tests, so the suite never touches the network. */
|
|
89
|
-
fetchImpl?: typeof fetch;
|
|
90
|
-
};
|
|
91
|
-
export declare class FeishuClient {
|
|
92
|
-
private readonly options;
|
|
93
|
-
private token?;
|
|
94
|
-
/** Whether the newer OAuth token endpoint works for this application. */
|
|
95
|
-
private useLegacyAuth;
|
|
96
|
-
/** The write queue. One write at a time, spaced by the interval the API allows. */
|
|
97
|
-
private writeChain;
|
|
98
|
-
private lastWriteAt;
|
|
99
|
-
constructor(options: FeishuClientOptions);
|
|
100
|
-
private get apiBase();
|
|
101
|
-
private get accountsBase();
|
|
102
|
-
private get fetchImpl();
|
|
103
|
-
/**
|
|
104
|
-
* THE ACCESS TOKEN, fetched once and reused.
|
|
105
|
-
*
|
|
106
|
-
* The lifetime is two hours and the API says to believe the number it returns rather than a
|
|
107
|
-
* hardcoded one, so the expiry comes from the response and the token is renewed a couple of
|
|
108
|
-
* minutes before it runs out.
|
|
109
|
-
*/
|
|
110
|
-
accessToken(force?: boolean): Promise<string>;
|
|
111
|
-
/**
|
|
112
|
-
* The current OAuth endpoint: `POST https://accounts.feishu.cn/oauth/v3/token`.
|
|
113
|
-
*
|
|
114
|
-
* It is the one the documentation recommends. If it refuses - an older application, a tenant
|
|
115
|
-
* that has not been migrated - the legacy endpoint is tried instead, and the client remembers
|
|
116
|
-
* so the failing call is not made again on every token refresh.
|
|
117
|
-
*/
|
|
118
|
-
private fetchOAuthToken;
|
|
119
|
-
/**
|
|
120
|
-
* The historical endpoint: `POST /open-apis/auth/v3/tenant_access_token/internal`.
|
|
121
|
-
*
|
|
122
|
-
* It is documented as superseded, but it is the endpoint every 自建应用 has worked with for
|
|
123
|
-
* years, and a fallback that costs one request is cheaper than a feature that does not run.
|
|
124
|
-
*/
|
|
125
|
-
private fetchLegacyToken;
|
|
126
|
-
/**
|
|
127
|
-
* ONE CALL, with the bearer token attached.
|
|
128
|
-
*
|
|
129
|
-
* A 429 is waited out rather than reported: the API tells us how many seconds until the window
|
|
130
|
-
* resets, and a write that gives up because it was 200ms early is a write that did not happen.
|
|
131
|
-
*/
|
|
132
|
-
private request;
|
|
133
|
-
/**
|
|
134
|
-
* Runs one write, spaced from the last one.
|
|
135
|
-
*/
|
|
136
|
-
private withWriteSlot;
|
|
137
|
-
/** The basic information about a document: its title and its version. */
|
|
138
|
-
documentMeta(documentId: string): Promise<{
|
|
139
|
-
documentId: string;
|
|
140
|
-
title?: string;
|
|
141
|
-
revisionId?: number;
|
|
142
|
-
}>;
|
|
143
|
-
/** Every block of a document, across every page. */
|
|
144
|
-
documentBlocks(documentId: string): Promise<FeishuBlock[]>;
|
|
145
|
-
/** The plain text of a document, without any structure. */
|
|
146
|
-
rawContent(documentId: string): Promise<string>;
|
|
147
|
-
/** The blocks directly under the page block - the document's top level. */
|
|
148
|
-
rootChildren(documentId: string): Promise<string[]>;
|
|
149
|
-
/** Creates an empty document. The content goes in afterwards, because the API has no other way. */
|
|
150
|
-
createDocument(title: string, folderToken?: string): Promise<{
|
|
151
|
-
documentId: string;
|
|
152
|
-
revisionId?: number;
|
|
153
|
-
}>;
|
|
154
|
-
/** Resolves a knowledge-base node to the document behind it. */
|
|
155
|
-
wikiNode(token: string): Promise<{
|
|
156
|
-
objToken: string;
|
|
157
|
-
objType: string;
|
|
158
|
-
title?: string;
|
|
159
|
-
}>;
|
|
160
|
-
/**
|
|
161
|
-
* Turns Markdown into the blocks that represent it.
|
|
162
|
-
*
|
|
163
|
-
* The API will not create a document with content in it, and building the blocks by hand would
|
|
164
|
-
* mean reimplementing the conversion Feishu already does - tables, code fences, todos and all.
|
|
165
|
-
*/
|
|
166
|
-
convertMarkdown(markdown: string): Promise<{
|
|
167
|
-
blocks: FeishuBlock[];
|
|
168
|
-
firstLevelBlockIds: string[];
|
|
169
|
-
}>;
|
|
170
|
-
/**
|
|
171
|
-
* Inserts blocks under the page block.
|
|
172
|
-
*
|
|
173
|
-
* THE CHUNKING IS NOT AN OPTIMISATION. The API accepts at most 1000 blocks per call, and the
|
|
174
|
-
* blocks of one subtree cannot be split across calls - so the top-level blocks are packed into
|
|
175
|
-
* batches that each fit, and a document of any length goes in.
|
|
176
|
-
*/
|
|
177
|
-
insertBlocks(documentId: string, blocks: FeishuBlock[], firstLevelBlockIds: string[], index?: number): Promise<number | undefined>;
|
|
178
|
-
/** Deletes a range of the page block's children. The range is left-closed and right-open. */
|
|
179
|
-
deleteChildren(documentId: string, startIndex: number, endIndex: number): Promise<number | undefined>;
|
|
180
|
-
}
|
|
181
|
-
/**
|
|
182
|
-
* Removes the read-only merge information from a table block.
|
|
183
|
-
*
|
|
184
|
-
* The API rejects a table block that carries `merge_info`, and the conversion endpoint hands it
|
|
185
|
-
* back with that field on it. The documentation says to strip it, so it is stripped.
|
|
186
|
-
*/
|
|
187
|
-
export declare function stripTableMergeInfo(block: FeishuBlock): FeishuBlock;
|