@fastagent-sh/fastagent 0.18.0 → 0.20.0
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/README.md +25 -7
- package/dist/agent.d.ts +1 -1
- package/dist/agent.js +1 -1
- package/dist/atomic-write.d.ts +23 -0
- package/dist/atomic-write.js +53 -0
- package/dist/{host/node.d.ts → channel.d.ts} +24 -28
- package/dist/channel.js +1 -0
- package/dist/channels/agentcore-limits.d.ts +9 -0
- package/dist/channels/agentcore-limits.js +9 -0
- package/dist/channels/agentcore-service.d.ts +55 -0
- package/dist/channels/agentcore-service.js +107 -0
- package/dist/channels/agentcore.d.ts +22 -20
- package/dist/channels/agentcore.js +20 -11
- package/dist/channels/busy.d.ts +1 -1
- package/dist/channels/busy.js +1 -1
- package/dist/channels/control.d.ts +55 -7
- package/dist/channels/control.js +312 -67
- package/dist/channels/discover.d.ts +59 -0
- package/dist/{engines/pi/channel.js → channels/discover.js} +19 -34
- package/dist/channels/feishu/card.js +1 -1
- package/dist/channels/feishu/context-buffer.d.ts +1 -1
- package/dist/channels/feishu/context-buffer.js +4 -4
- package/dist/channels/feishu/feishu-api.js +6 -9
- package/dist/channels/feishu/feishu.d.ts +1 -1
- package/dist/channels/feishu/feishu.js +41 -24
- package/dist/channels/feishu/invoke-turn.d.ts +1 -1
- package/dist/channels/feishu/invoke-turn.js +3 -4
- package/dist/channels/feishu/preview.d.ts +1 -1
- package/dist/channels/feishu/preview.js +7 -3
- package/dist/channels/feishu/ws-ingress.d.ts +1 -1
- package/dist/channels/github/github.d.ts +1 -1
- package/dist/channels/http.d.ts +11 -7
- package/dist/channels/http.js +1 -109
- package/dist/channels/kit/attachment-path.d.ts +12 -0
- package/dist/channels/kit/attachment-path.js +43 -0
- package/dist/channels/{context-buffer.d.ts → kit/context-buffer.d.ts} +9 -0
- package/dist/channels/{context-buffer.js → kit/context-buffer.js} +13 -2
- package/dist/channels/{invoke-turn-kit.d.ts → kit/invoke-turn-kit.d.ts} +14 -3
- package/dist/channels/{invoke-turn-kit.js → kit/invoke-turn-kit.js} +17 -6
- package/dist/channels/{preview-kit.d.ts → kit/preview-kit.d.ts} +3 -1
- package/dist/channels/{seen.js → kit/seen.js} +1 -1
- package/dist/channels/kit/signature.d.ts +12 -0
- package/dist/channels/kit/signature.js +17 -0
- package/dist/channels/{state.js → kit/state.js} +4 -7
- package/dist/channels/{stop-command.d.ts → kit/stop-command.d.ts} +1 -1
- package/dist/channels/{stop-command.js → kit/stop-command.js} +5 -5
- package/dist/channels/{tasks.d.ts → kit/tasks.d.ts} +1 -1
- package/dist/channels/{tasks.js → kit/tasks.js} +11 -5
- package/dist/channels/{thread-participants.js → kit/thread-participants.js} +1 -1
- package/dist/channels/{turn-queue.js → kit/turn-queue.js} +2 -2
- package/dist/channels/{turn-store.d.ts → kit/turn-store.d.ts} +19 -3
- package/dist/channels/{turn-store.js → kit/turn-store.js} +30 -3
- package/dist/channels/lark/lark.d.ts +1 -1
- package/dist/channels/serve.d.ts +73 -0
- package/dist/channels/serve.js +243 -0
- package/dist/channels/slack/bot-auth.js +3 -14
- package/dist/channels/slack/context-buffer.d.ts +2 -2
- package/dist/channels/slack/context-buffer.js +2 -2
- package/dist/channels/slack/invoke-turn.d.ts +2 -2
- package/dist/channels/slack/invoke-turn.js +13 -6
- package/dist/channels/slack/onboard.js +5 -5
- package/dist/channels/slack/onboarding-state.d.ts +7 -3
- package/dist/channels/slack/onboarding-state.js +13 -20
- package/dist/channels/slack/parse.js +2 -2
- package/dist/channels/slack/preview.d.ts +1 -1
- package/dist/channels/slack/preview.js +11 -3
- package/dist/channels/slack/register-webhook.js +1 -1
- package/dist/channels/slack/scaffold/slack-send.ts +2 -17
- package/dist/channels/slack/slack-api.js +23 -55
- package/dist/channels/slack/slack.d.ts +1 -1
- package/dist/channels/slack/slack.js +20 -20
- package/dist/channels/slack/welcomed.js +1 -1
- package/dist/channels/telegram/context-buffer.d.ts +2 -2
- package/dist/channels/telegram/context-buffer.js +2 -2
- package/dist/channels/telegram/invoke-turn.d.ts +1 -1
- package/dist/channels/telegram/invoke-turn.js +1 -1
- package/dist/channels/telegram/parse.d.ts +21 -1
- package/dist/channels/telegram/parse.js +67 -13
- package/dist/channels/telegram/preview.d.ts +1 -1
- package/dist/channels/telegram/preview.js +6 -2
- package/dist/channels/telegram/scaffold/channel.ts +7 -3
- package/dist/channels/telegram/telegram-api.js +5 -6
- package/dist/channels/telegram/telegram.d.ts +3 -3
- package/dist/channels/telegram/telegram.js +60 -72
- package/dist/channels/telegram/turn-store.d.ts +2 -2
- package/dist/channels/telegram/turn-store.js +2 -2
- package/dist/cli/add-slack.js +6 -5
- package/dist/cli/commands/add.js +1 -5
- package/dist/cli/commands/attach.d.ts +2 -4
- package/dist/cli/commands/attach.js +17 -15
- package/dist/cli/commands/deploy.d.ts +50 -5
- package/dist/cli/commands/deploy.js +183 -134
- package/dist/cli/commands/dev.js +25 -40
- package/dist/cli/commands/fire.js +1 -1
- package/dist/cli/commands/info.js +7 -5
- package/dist/cli/commands/init.js +1 -2
- package/dist/cli/commands/schedule.js +1 -1
- package/dist/cli/commands/start.js +80 -121
- package/dist/cli/commands/tool.js +5 -7
- package/dist/cli/program.js +1 -1
- package/dist/cli/serve.d.ts +22 -70
- package/dist/cli/serve.js +66 -257
- package/dist/cli/shared.d.ts +40 -10
- package/dist/cli/shared.js +53 -6
- package/dist/core.d.ts +3 -5
- package/dist/core.js +7 -5
- package/dist/deploy/agentcore/logs.d.ts +2 -2
- package/dist/deploy/agentcore/logs.js +2 -2
- package/dist/deploy/agentcore/plan.d.ts +12 -10
- package/dist/deploy/agentcore/plan.js +39 -31
- package/dist/deploy/agentcore/run.d.ts +5 -4
- package/dist/deploy/agentcore/run.js +13 -36
- package/dist/deploy/channel-ingress.d.ts +73 -0
- package/dist/deploy/channel-ingress.js +101 -0
- package/dist/deploy/docker/plan.d.ts +3 -7
- package/dist/deploy/docker/plan.js +4 -15
- package/dist/deploy/docker/run.d.ts +18 -1
- package/dist/deploy/docker/run.js +10 -0
- package/dist/deploy/fly/plan.d.ts +4 -5
- package/dist/deploy/fly/plan.js +7 -22
- package/dist/deploy/fly/run.d.ts +9 -23
- package/dist/deploy/fly/run.js +32 -68
- package/dist/deploy/preflight.d.ts +6 -7
- package/dist/deploy/preflight.js +27 -23
- package/dist/deploy/railway/plan.d.ts +4 -5
- package/dist/deploy/railway/plan.js +11 -26
- package/dist/deploy/railway/run.d.ts +9 -9
- package/dist/deploy/railway/run.js +40 -40
- package/dist/deploy/runner.js +22 -2
- package/dist/deploy/secrets.d.ts +18 -9
- package/dist/deploy/secrets.js +64 -12
- package/dist/dev-supervisor.js +11 -6
- package/dist/engines/pi/agent-session-factory.d.ts +104 -0
- package/dist/engines/pi/agent-session-factory.js +295 -0
- package/dist/engines/pi/auth.js +43 -43
- package/dist/engines/pi/config.d.ts +5 -5
- package/dist/engines/pi/config.js +1 -1
- package/dist/engines/pi/create.d.ts +67 -42
- package/dist/engines/pi/create.js +142 -72
- package/dist/engines/pi/definition.d.ts +42 -8
- package/dist/engines/pi/definition.js +114 -11
- package/dist/engines/pi/invoke-session.d.ts +22 -19
- package/dist/engines/pi/invoke-session.js +286 -100
- package/dist/engines/pi/login.js +39 -20
- package/dist/engines/pi/models.d.ts +14 -0
- package/dist/engines/pi/models.js +9 -1
- package/dist/engines/pi/open.d.ts +10 -8
- package/dist/engines/pi/open.js +21 -11
- package/dist/engines/pi/report.d.ts +0 -4
- package/dist/engines/pi/report.js +0 -7
- package/dist/engines/pi/search-tools.js +1 -1
- package/dist/engines/pi/service.d.ts +23 -0
- package/dist/engines/pi/service.js +27 -0
- package/dist/engines/pi/session-builder.js +87 -118
- package/dist/engines/pi/session-control.d.ts +28 -14
- package/dist/engines/pi/session-control.js +570 -438
- package/dist/engines/pi/session-inheritance.d.ts +30 -0
- package/dist/engines/pi/session-inheritance.js +261 -0
- package/dist/engines/pi/session-markers.d.ts +48 -0
- package/dist/engines/pi/session-markers.js +59 -0
- package/dist/engines/pi/session-settings.d.ts +15 -3
- package/dist/engines/pi/session-settings.js +22 -1
- package/dist/engines/pi/session-store.d.ts +140 -0
- package/dist/engines/pi/session-store.js +666 -0
- package/dist/engines/pi/tool-context.d.ts +36 -13
- package/dist/engines/pi/tool-context.js +51 -4
- package/dist/engines/pi/tool.d.ts +5 -5
- package/dist/engines/pi/tool.js +3 -3
- package/dist/engines/pi/turn-kit.d.ts +32 -6
- package/dist/engines/pi/turn-kit.js +28 -1
- package/dist/engines/pi/wake-tool.js +1 -1
- package/dist/env.js +1 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/loader.d.ts +49 -7
- package/dist/loader.js +79 -18
- package/dist/log.d.ts +3 -0
- package/dist/log.js +5 -1
- package/dist/node.d.ts +16 -0
- package/dist/node.js +20 -0
- package/dist/paths.d.ts +26 -0
- package/dist/paths.js +37 -2
- package/dist/pi.d.ts +14 -8
- package/dist/pi.js +6 -5
- package/dist/scaffold/add-channel.js +2 -2
- package/dist/scaffold/init.js +7 -2
- package/dist/scaffold/templates/persona.md +2 -2
- package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
- package/dist/schedule/discover.js +3 -15
- package/dist/schedule/state.js +4 -9
- package/dist/schedule/wake-alarm.d.ts +13 -7
- package/dist/schedule/wake-alarm.js +88 -46
- package/dist/schedule/wakeups.d.ts +2 -2
- package/dist/schedule/wakeups.js +11 -8
- package/dist/service.d.ts +157 -0
- package/dist/service.js +376 -0
- package/dist/session-remote.d.ts +8 -3
- package/dist/session-remote.js +162 -92
- package/dist/session.d.ts +229 -93
- package/dist/session.js +61 -23
- package/dist/telegram.d.ts +1 -1
- package/dist/telegram.js +1 -1
- package/dist/tunnel.d.ts +18 -3
- package/dist/tunnel.js +32 -40
- package/package.json +14 -5
- package/dist/engines/pi/channel.d.ts +0 -37
- package/dist/engines/pi/harness.d.ts +0 -115
- package/dist/engines/pi/harness.js +0 -154
- package/dist/engines/pi/invoke.d.ts +0 -60
- package/dist/engines/pi/invoke.js +0 -459
- package/dist/engines/pi/read-image.d.ts +0 -4
- package/dist/engines/pi/read-image.js +0 -62
- package/dist/engines/pi/sessions.d.ts +0 -60
- package/dist/engines/pi/sessions.js +0 -464
- package/dist/host/node.js +0 -52
- /package/dist/channels/{preview-kit.js → kit/preview-kit.js} +0 -0
- /package/dist/channels/{seen.d.ts → kit/seen.d.ts} +0 -0
- /package/dist/channels/{state.d.ts → kit/state.d.ts} +0 -0
- /package/dist/channels/{text.d.ts → kit/text.d.ts} +0 -0
- /package/dist/channels/{text.js → kit/text.js} +0 -0
- /package/dist/channels/{thread-participants.d.ts → kit/thread-participants.d.ts} +0 -0
- /package/dist/channels/{turn-queue.d.ts → kit/turn-queue.d.ts} +0 -0
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Channel discovery (the N axis, filesystem form). A channel file default-exports either the existing
|
|
3
3
|
* route factory `(ctx) => Routes`, or an explicit long-connection module `{ name, connect(ctx, signal) }`.
|
|
4
|
+
*
|
|
5
|
+
* Engine-neutral, and living here rather than under `engines/` because of it: reading `channels/*.ts`
|
|
6
|
+
* is the Channel contract plus a directory, with no engine in sight.
|
|
4
7
|
*/
|
|
5
|
-
import { readdir } from "node:fs/promises";
|
|
6
8
|
import { isAbsolute, join } from "node:path";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { assertInsideAgentDir } from "
|
|
9
|
+
import { assertRouteKey, routeKeysConflict } from "./serve.js";
|
|
10
|
+
import { loadModuleDir, moduleInventory } from "../loader.js";
|
|
11
|
+
import { assertInsideAgentDir } from "../paths.js";
|
|
10
12
|
function longConnectionModule(value) {
|
|
11
13
|
return value !== null && typeof value === "object" && typeof value.connect === "function";
|
|
12
14
|
}
|
|
@@ -15,28 +17,29 @@ function validateLongConnectionModule(value, label) {
|
|
|
15
17
|
throw new Error(`${label}: long-connection channel name must be a non-empty string`);
|
|
16
18
|
}
|
|
17
19
|
}
|
|
20
|
+
/** Declared channels from basenames that share one ingress: the serving surface's mounted route list
|
|
21
|
+
* (a long-connection channel mounts no HTTP route, so every route IS a webhook channel), and fixtures. */
|
|
22
|
+
export function declaredChannels(names, ingress = "webhook") {
|
|
23
|
+
return names.map((name) => ({ name, ingress }));
|
|
24
|
+
}
|
|
18
25
|
/**
|
|
19
26
|
* Import channel files without mounting route factories or opening connections. Deployment needs only
|
|
20
|
-
* the authored structural fact: function exports are
|
|
27
|
+
* the authored structural fact: function exports are webhook channels; `{ connect() }` exports are
|
|
21
28
|
* long-connection channels. There is no second ingress/lifecycle declaration to keep in sync.
|
|
22
29
|
*/
|
|
23
30
|
export async function inspectChannels(dir) {
|
|
24
31
|
await assertInsideAgentDir(dir, "channels");
|
|
25
32
|
const { modules, failures } = await loadModuleDir(join(dir, "channels"));
|
|
26
33
|
const channels = [];
|
|
27
|
-
const routeChannels = [];
|
|
28
|
-
const longConnectionChannels = [];
|
|
29
34
|
for (const { name, label, file, mod } of modules) {
|
|
30
35
|
try {
|
|
31
36
|
if (typeof mod.default === "function") {
|
|
32
|
-
channels.push(name);
|
|
33
|
-
routeChannels.push(name);
|
|
37
|
+
channels.push({ name, ingress: "webhook" });
|
|
34
38
|
continue;
|
|
35
39
|
}
|
|
36
40
|
if (longConnectionModule(mod.default)) {
|
|
37
41
|
validateLongConnectionModule(mod.default, label);
|
|
38
|
-
channels.push(name);
|
|
39
|
-
longConnectionChannels.push(name);
|
|
42
|
+
channels.push({ name, ingress: "long-connection" });
|
|
40
43
|
continue;
|
|
41
44
|
}
|
|
42
45
|
throw new Error(`${label} must default-export (ctx) => Routes or { name, connect(ctx, signal) }`);
|
|
@@ -45,7 +48,7 @@ export async function inspectChannels(dir) {
|
|
|
45
48
|
failures.push({ label, file, message: error.message });
|
|
46
49
|
}
|
|
47
50
|
}
|
|
48
|
-
return { channels,
|
|
51
|
+
return { channels, failures };
|
|
49
52
|
}
|
|
50
53
|
/**
|
|
51
54
|
* Channel file basenames under `<dir>/channels/` — the authoring view (`fastagent info`), which lists
|
|
@@ -53,19 +56,8 @@ export async function inspectChannels(dir) {
|
|
|
53
56
|
*/
|
|
54
57
|
export async function discoverChannelFiles(dir) {
|
|
55
58
|
await assertInsideAgentDir(dir, "channels");
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
names = await readdir(join(dir, "channels"));
|
|
59
|
-
}
|
|
60
|
-
catch (error) {
|
|
61
|
-
if (error.code === "ENOENT")
|
|
62
|
-
return [];
|
|
63
|
-
throw error;
|
|
64
|
-
}
|
|
65
|
-
return names
|
|
66
|
-
.filter(isModuleFile)
|
|
67
|
-
.map((name) => name.replace(/\.(ts|js|mjs)$/, ""))
|
|
68
|
-
.sort();
|
|
59
|
+
const entries = await moduleInventory(join(dir, "channels"));
|
|
60
|
+
return entries.map((entry) => entry.name);
|
|
69
61
|
}
|
|
70
62
|
function validateRoutes(value, label) {
|
|
71
63
|
if (value === null || typeof value !== "object" || value instanceof Map) {
|
|
@@ -79,9 +71,7 @@ function validateRoutes(value, label) {
|
|
|
79
71
|
if (typeof handler !== "function") {
|
|
80
72
|
throw new Error(`${label}: route "${route}" must map to a handler function, got ${typeof handler}`);
|
|
81
73
|
}
|
|
82
|
-
|
|
83
|
-
throw new Error(`${label}: route "${route}" is not a valid route key (expected "METHOD /path" or "/path")`);
|
|
84
|
-
}
|
|
74
|
+
assertRouteKey(route, (problem) => `${label}: route "${route}" is not a valid route key — ${problem}`);
|
|
85
75
|
}
|
|
86
76
|
return routes;
|
|
87
77
|
}
|
|
@@ -121,12 +111,7 @@ export async function loadChannels(dir, ctx) {
|
|
|
121
111
|
}
|
|
122
112
|
const declaredRoutes = validateRoutes(declared, label);
|
|
123
113
|
for (const [route, handler] of declaredRoutes) {
|
|
124
|
-
const
|
|
125
|
-
const clash = Object.keys(routes).some((key) => {
|
|
126
|
-
const existing = parseRouteKey(key);
|
|
127
|
-
return (existing.path === parsed.path &&
|
|
128
|
-
(existing.method === undefined || parsed.method === undefined || existing.method === parsed.method));
|
|
129
|
-
});
|
|
114
|
+
const clash = Object.keys(routes).some((key) => routeKeysConflict(key, route));
|
|
130
115
|
if (clash) {
|
|
131
116
|
collisions.push({ route, source: label });
|
|
132
117
|
continue;
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* Budget: a card entity is capped at 30 KB, so the final answer's card chunk (and the live view) stay
|
|
18
18
|
* well under it; longer answers overflow into follow-up messages (preview.ts owns that policy).
|
|
19
19
|
*/
|
|
20
|
-
import { truncateCodePointPrefix } from "../text.js";
|
|
20
|
+
import { truncateCodePointPrefix } from "../kit/text.js";
|
|
21
21
|
/** The append-only answer element's id — shared by create (card.ts) and update (preview.ts). */
|
|
22
22
|
export const ANSWER_ELEMENT_ID = "answer";
|
|
23
23
|
/** The volatile process element's id (thinking tail + tool lines + retry notice; live-only). */
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Feishu/Lark's half of the shared context buffer (mechanics + consume protocol:
|
|
3
|
-
* ../context-buffer.ts): the entry shape, its fold-line rendering, place-key derivation, and
|
|
3
|
+
* ../kit/context-buffer.ts): the entry shape, its fold-line rendering, place-key derivation, and
|
|
4
4
|
* buffered-resource selection. Entries are bucketed by conversation place (main chat, or one
|
|
5
5
|
* concrete thread root) and folded into the next answered turn in that place.
|
|
6
6
|
*/
|
|
7
7
|
import { log } from "../../log.js";
|
|
8
|
-
import { BUFFER_ATTACH_MAX, BUFFER_LINE_MAX_CHARS, createContextBuffer as createGenericContextBuffer, } from "../context-buffer.js";
|
|
9
|
-
import { loadStateFile, saveStateFile } from "../state.js";
|
|
10
|
-
import { truncateCodePointPrefix } from "../text.js";
|
|
8
|
+
import { BUFFER_ATTACH_MAX, BUFFER_LINE_MAX_CHARS, createContextBuffer as createGenericContextBuffer, } from "../kit/context-buffer.js";
|
|
9
|
+
import { loadStateFile, saveStateFile } from "../kit/state.js";
|
|
10
|
+
import { truncateCodePointPrefix } from "../kit/text.js";
|
|
11
11
|
function bufferLine(entry) {
|
|
12
12
|
const meta = [`msg ${entry.messageId}`, entry.replyTo ? `reply to msg ${entry.replyTo}` : undefined]
|
|
13
13
|
.filter(Boolean)
|
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
* here are shape-compatible with the SDK's `client.im.*` style, so the policy layer survives that swap.
|
|
21
21
|
*/
|
|
22
22
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
23
|
-
import {
|
|
24
|
-
import { utf8Prefix } from "../text.js";
|
|
23
|
+
import { attachmentPath } from "../kit/attachment-path.js";
|
|
24
|
+
import { utf8Prefix } from "../kit/text.js";
|
|
25
25
|
/** Per-attempt timeout for a JSON API call — small JSON round-trips, so 30s is generous. */
|
|
26
26
|
const API_TIMEOUT_MS = 30_000;
|
|
27
27
|
/** Timeout for downloading resource bytes — sized for a slow link, not a JSON call. */
|
|
@@ -283,13 +283,10 @@ export function createFeishuApi(opts) {
|
|
|
283
283
|
},
|
|
284
284
|
async fetchFile(messageId, fileKey, name, chatId, filesDir) {
|
|
285
285
|
const { bytes } = await api.downloadResource(messageId, fileKey, "file");
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
const dest = join(dir, safe);
|
|
291
|
-
await writeFile(dest, bytes);
|
|
292
|
-
return { path: dest, name: safe, size: bytes.byteLength };
|
|
286
|
+
const dest = attachmentPath(filesDir, chatId, name);
|
|
287
|
+
await mkdir(dest.dir, { recursive: true });
|
|
288
|
+
await writeFile(dest.path, bytes);
|
|
289
|
+
return { path: dest.path, name: dest.name, size: bytes.byteLength };
|
|
293
290
|
},
|
|
294
291
|
async getAppConfig(appId) {
|
|
295
292
|
// v6 app detail — the one read surface that returns the event-security material (under data.app).
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ChannelModule, LongConnectionChannelModule } from "../../
|
|
1
|
+
import type { ChannelModule, LongConnectionChannelModule } from "../../channel.ts";
|
|
2
2
|
import { type FeishuCloudProfile } from "./cloud.ts";
|
|
3
3
|
import { type FeishuMessage, type FeishuMessageEvent, type FeishuRoute, defaultFeishuRoute, feishuEnvelope } from "./parse.ts";
|
|
4
4
|
import { type FeishuFailure } from "./preview.ts";
|
|
@@ -12,31 +12,38 @@ import { isAbsolute, join } from "node:path";
|
|
|
12
12
|
import { log } from "../../log.js";
|
|
13
13
|
import { readBodyCapped } from "../body.js";
|
|
14
14
|
import { text } from "../respond.js";
|
|
15
|
-
import { createSeenRing } from "../seen.js";
|
|
16
|
-
import { createTaskTracker } from "../tasks.js";
|
|
17
|
-
import { ensureStateHome, loadStateFile, saveStateFile } from "../state.js";
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
15
|
+
import { createSeenRing } from "../kit/seen.js";
|
|
16
|
+
import { createTaskTracker } from "../kit/tasks.js";
|
|
17
|
+
import { ensureStateHome, loadStateFile, saveStateFile } from "../kit/state.js";
|
|
18
|
+
import { signatureIsFresh } from "../kit/signature.js";
|
|
19
|
+
import { dispatchStop, isStopText } from "../kit/stop-command.js";
|
|
20
|
+
import { createTurnQueue } from "../kit/turn-queue.js";
|
|
21
|
+
import { commitAnsweredTurn, createTurnStore } from "../kit/turn-store.js";
|
|
22
|
+
import { discussionBlock } from "../kit/context-buffer.js";
|
|
21
23
|
import { FEISHU_CLOUD } from "./cloud.js";
|
|
22
24
|
import { collectFeishuBufferedAttachments, createFeishuContextBuffer, feishuBufferPlaceKey, feishuBufferText, } from "./context-buffer.js";
|
|
23
25
|
import { decryptEvent, timingSafeEqualStr, verifySignature } from "./crypto.js";
|
|
24
26
|
import { invokeFeishuTurn } from "./invoke-turn.js";
|
|
25
27
|
import { createFeishuApi } from "./feishu-api.js";
|
|
26
28
|
import { normalizeFeishuMessage } from "./normalize.js";
|
|
27
|
-
import { createThreadParticipants } from "../thread-participants.js";
|
|
29
|
+
import { createThreadParticipants } from "../kit/thread-participants.js";
|
|
28
30
|
import { FEISHU_GROUP_CONTEXT_SCOPE, FEISHU_MESSAGE_READ_REQUEST, FEISHU_MESSAGE_READ_SCOPE, scopeSatisfied, } from "./setup-mode.js";
|
|
29
31
|
import { cloudEnvelope, defaultFeishuRoute, feishuEnvelope, placeKey, senderId, senderLabel, } from "./parse.js";
|
|
30
32
|
import { defaultErrorMessage, mountFeishuPreview, settleFeishuPreview, streamFeishuReply, } from "./preview.js";
|
|
31
33
|
import { connectFeishuWs } from "./ws-ingress.js";
|
|
32
34
|
// Canonical public surface; the Lark subpath aliases these types/functions at its compatibility boundary.
|
|
33
35
|
export { defaultFeishuRoute, feishuEnvelope };
|
|
34
|
-
/** Execution ceiling: a turn that has STARTED running this many times without finishing is dropped
|
|
35
|
-
* rather than run again (a poison turn must not loop forever under a restart policy). Counted per turn
|
|
36
|
-
* at dequeue, so a never-run turn queued behind a poison one keeps its full budget. */
|
|
37
|
-
const MAX_TURN_ATTEMPTS = 3;
|
|
38
36
|
/** Event body cap — events are small JSON; 1 MiB is generous and guards a public endpoint. */
|
|
39
37
|
const MAX_EVENT_BYTES = 1 << 20;
|
|
38
|
+
/**
|
|
39
|
+
* Replay window for a SIGNED event, sized by the OPEN PLATFORM'S REDELIVERY SCHEDULE, not by Slack's
|
|
40
|
+
* 5 minutes: a failed push is retried at 15s / 5min / 1h / 6h. It is not established whether a retry
|
|
41
|
+
* is re-signed with a fresh timestamp or replays the original one, and only the second case is safe
|
|
42
|
+
* to guess wrong about in one direction — a window under 6h would 401 three of the four retries, i.e.
|
|
43
|
+
* turn one transient fault into a permanently lost user message (and a callback-health alarm). Wide
|
|
44
|
+
* enough to cover the chain, still bounded, and the `seen` ring dedups an event_id inside it.
|
|
45
|
+
*/
|
|
46
|
+
const MAX_SIGNATURE_AGE_S = 7 * 60 * 60;
|
|
40
47
|
/** Queue feedback is immediate by default: it is the user's acknowledgement that this exact ask was
|
|
41
48
|
* accepted behind another turn. The same reply-quoted card becomes the preview/final answer, so there
|
|
42
49
|
* is no extra message or recall tombstone to avoid. Authors may still configure a delay explicitly. */
|
|
@@ -220,7 +227,7 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
|
|
|
220
227
|
});
|
|
221
228
|
const seen = createSeenRing(join(stateHome, "seen.json"), label);
|
|
222
229
|
// Side tasks (stop feedback) run off the ingress path but drain in turnsIdle.
|
|
223
|
-
const sideTasks = createTaskTracker();
|
|
230
|
+
const sideTasks = createTaskTracker(label);
|
|
224
231
|
const toStored = (r) => {
|
|
225
232
|
const { preview: _live, ...intent } = r; // drop the live-only field; TS enforces the rest is complete
|
|
226
233
|
return { ...intent, attempts: 0 };
|
|
@@ -285,7 +292,7 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
|
|
|
285
292
|
await notice?.done;
|
|
286
293
|
notices.delete(rec.id);
|
|
287
294
|
// Count this execution against the durable record (poison-turn ceiling) before running it again.
|
|
288
|
-
const decision = store.startAttempt(rec.id
|
|
295
|
+
const decision = store.startAttempt(rec.id);
|
|
289
296
|
if (decision === "exceeded") {
|
|
290
297
|
notifyDropped(rec);
|
|
291
298
|
return;
|
|
@@ -312,8 +319,7 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
|
|
|
312
319
|
const roomBlock = room?.text
|
|
313
320
|
? `[recent discussion in the room this thread branched from — not yet answered there:\n${room.text}\n]\n\n`
|
|
314
321
|
: "";
|
|
315
|
-
const
|
|
316
|
-
const prompt = `${roomBlock}${threadBlock}${rec.baseText}`;
|
|
322
|
+
const prompt = `${roomBlock}${discussionBlock(recent)}${rec.baseText}`;
|
|
317
323
|
// Room entries FIRST: the collector keeps the TAIL under its cap, so the thread's own
|
|
318
324
|
// attachments win the slots.
|
|
319
325
|
const buffered = collectFeishuBufferedAttachments([...(room?.consumed ?? []), ...consumed], {
|
|
@@ -331,12 +337,7 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
|
|
|
331
337
|
label,
|
|
332
338
|
appId,
|
|
333
339
|
...(parentSession !== undefined ? { parentSession } : {}),
|
|
334
|
-
}, { primary: { images: rec.images, files: rec.files, parentId: rec.parentId }, buffered }, () => {
|
|
335
|
-
// Drop intent first: a crash between these writes may re-fold answered context later,
|
|
336
|
-
// but can never replay this turn after its context was removed.
|
|
337
|
-
store.remove(rec.id);
|
|
338
|
-
buffer.commit(rec.bufferKey, consumed);
|
|
339
|
-
}), api, targetOf(rec), formatError, rec.preview, label);
|
|
340
|
+
}, { primary: { images: rec.images, files: rec.files, parentId: rec.parentId }, buffered }, () => commitAnsweredTurn(store, buffer, { id: rec.id, bufferKey: rec.bufferKey, consumed })), api, targetOf(rec), formatError, rec.preview, label);
|
|
340
341
|
log.info(`${label} turn done: turn=${rec.id} session=${rec.session} (${Date.now() - startedAt}ms)`);
|
|
341
342
|
}
|
|
342
343
|
catch (error) {
|
|
@@ -583,6 +584,12 @@ function createFeishuWebhookRoutes(profile, opts, runtime) {
|
|
|
583
584
|
const { verificationToken, encryptKey } = opts;
|
|
584
585
|
const { kind, envPrefix } = profile;
|
|
585
586
|
const label = `[${kind}]`;
|
|
587
|
+
if (!encryptKey) {
|
|
588
|
+
// Said once at wiring, not per request: without an encrypt key events arrive in plaintext and
|
|
589
|
+
// carry no signature, so the freshness window below never runs and a captured body replays for
|
|
590
|
+
// as long as the verification token lives.
|
|
591
|
+
log.warn(`${label} no ${envPrefix}_ENCRYPT_KEY: events are accepted unsigned, with no replay window — set one in the console to enable it`);
|
|
592
|
+
}
|
|
586
593
|
const handler = async (req) => {
|
|
587
594
|
if (req.method !== "POST")
|
|
588
595
|
return text("POST only\n", 405);
|
|
@@ -609,9 +616,19 @@ function createFeishuWebhookRoutes(profile, opts, runtime) {
|
|
|
609
616
|
nonce: req.headers.get("x-lark-request-nonce") ?? "",
|
|
610
617
|
signature: req.headers.get("x-lark-signature") ?? "",
|
|
611
618
|
};
|
|
612
|
-
if (sig.signature
|
|
613
|
-
|
|
614
|
-
|
|
619
|
+
if (sig.signature) {
|
|
620
|
+
// Freshness BEFORE the signature: the signature covers the timestamp but proves nothing about
|
|
621
|
+
// it, so without a window a captured body + its three x-lark-* headers replays forever. The
|
|
622
|
+
// `seen` ring is not that defence — it is bounded, and past its rollover a replay re-runs the
|
|
623
|
+
// turn (a re-sent message, a re-fired tool).
|
|
624
|
+
if (!signatureIsFresh(sig.timestamp, MAX_SIGNATURE_AGE_S)) {
|
|
625
|
+
log.warn(`${label} rejected an event: X-Lark-Request-Timestamp outside the ±${MAX_SIGNATURE_AGE_S / 3600} h replay window`);
|
|
626
|
+
return text("stale signature\n", 401);
|
|
627
|
+
}
|
|
628
|
+
if (!verifySignature(encryptKey, sig, body.text)) {
|
|
629
|
+
log.warn(`${label} rejected an event: invalid X-Lark-Signature (encrypt key mismatch, or a forgery)`);
|
|
630
|
+
return text("invalid signature\n", 401);
|
|
631
|
+
}
|
|
615
632
|
}
|
|
616
633
|
try {
|
|
617
634
|
envelope = JSON.parse(decryptEvent(encryptKey, outer.encrypt));
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* or hide its still-readable siblings.
|
|
14
14
|
*/
|
|
15
15
|
import type { Agent, AgentEvent } from "../../agent.ts";
|
|
16
|
-
import { type BusyRetry } from "../invoke-turn-kit.ts";
|
|
16
|
+
import { type BusyRetry } from "../kit/invoke-turn-kit.ts";
|
|
17
17
|
import type { FeishuBufferedRef } from "./context-buffer.ts";
|
|
18
18
|
import type { FeishuApi } from "./feishu-api.ts";
|
|
19
19
|
/** Everything the transport needs to fetch a turn's attachments. */
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { log } from "../../log.js";
|
|
2
|
-
import { DEFAULT_BUSY_RETRY, attachedFilesManifest, attributedFileName, backgroundImagesManifest, missingAttachmentsNote, streamTurnWithBusyRetry, } from "../invoke-turn-kit.js";
|
|
3
|
-
import { BUFFER_ATTACH_MAX } from "../context-buffer.js";
|
|
2
|
+
import { DEFAULT_BUSY_RETRY, attachedFilesManifest, attributedFileName, backgroundImagesManifest, missingAttachmentsNote, streamTurnWithBusyRetry, } from "../kit/invoke-turn-kit.js";
|
|
3
|
+
import { BUFFER_ATTACH_MAX } from "../kit/context-buffer.js";
|
|
4
4
|
import { parseContent } from "./parse.js";
|
|
5
|
-
import { REFERENT_MAX_CODE_POINTS, truncateCodePointPrefix } from "../text.js";
|
|
5
|
+
import { REFERENT_MAX_CODE_POINTS, truncateCodePointPrefix } from "../kit/text.js";
|
|
6
6
|
/** The per-turn REPLY CONTRACT, appended to the prompt (not the system prompt). Two halves, one
|
|
7
7
|
* concept — what happens to the reply: its FORMAT (rendered in a card whose markdown element is the
|
|
8
8
|
* natural fit for LLM output — steer away from HTML/plain) and its DELIVERY OWNERSHIP (the channel
|
|
@@ -169,7 +169,6 @@ async function resolveTurnInputs(t, attachments) {
|
|
|
169
169
|
referentBlock += chain.block;
|
|
170
170
|
}
|
|
171
171
|
}
|
|
172
|
-
// Primary first and fail-fast: these are resources the current user explicitly pointed at.
|
|
173
172
|
const imageRefs = [];
|
|
174
173
|
for (const ref of images)
|
|
175
174
|
imageRefs.push(await t.api.fetchImage(ref.msg, ref.key));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AgentEvent } from "../../agent.ts";
|
|
2
2
|
import { type FeishuApi, type FeishuTarget } from "./feishu-api.ts";
|
|
3
|
-
import { type ChannelFailure, defaultErrorMessage } from "../preview-kit.ts";
|
|
3
|
+
import { type ChannelFailure, defaultErrorMessage } from "../kit/preview-kit.ts";
|
|
4
4
|
/** A terminal failure, as the channel hands it to `onError` — the shared channel shape. */
|
|
5
5
|
export type FeishuFailure = ChannelFailure;
|
|
6
6
|
export { defaultErrorMessage };
|
|
@@ -25,8 +25,8 @@ import { setTimeout as sleep } from "node:timers/promises";
|
|
|
25
25
|
import { log } from "../../log.js";
|
|
26
26
|
import { ANSWER_ELEMENT_ID, CARD_MARKDOWN_MAX_BYTES, PROCESS_ELEMENT_ID, cardEntityContent, finalCardJson, streamingCardJson, } from "./card.js";
|
|
27
27
|
import { chunkFeishuText, isCardStreamingClosed } from "./feishu-api.js";
|
|
28
|
-
import { RETRY_NOTICE, THINKING_PLACEHOLDER, applyTurnEvent, composeTurnBody, createPreviewPump, createTurnView, defaultErrorMessage, revealedAnswer, thinkingLine, toolLines, } from "../preview-kit.js";
|
|
29
|
-
import { truncateCodePointPrefix, truncateUtf8 } from "../text.js";
|
|
28
|
+
import { RETRY_NOTICE, THINKING_PLACEHOLDER, applyTurnEvent, composeTurnBody, createPreviewPump, createTurnView, defaultErrorMessage, revealedAnswer, thinkingLine, toolLines, } from "../kit/preview-kit.js";
|
|
29
|
+
import { truncateCodePointPrefix, truncateUtf8 } from "../kit/text.js";
|
|
30
30
|
export { defaultErrorMessage };
|
|
31
31
|
/** How often (ms) to push a live-preview snapshot; tool events still flush on the next loop. Cardkit
|
|
32
32
|
* allows 10 QPS per card entity (50 per app), but one snapshot a second reads smoothly (the client
|
|
@@ -286,7 +286,11 @@ export async function streamFeishuReply(events, api, target, formatError, initia
|
|
|
286
286
|
// we throw below regardless.
|
|
287
287
|
finalized = true;
|
|
288
288
|
{
|
|
289
|
-
const msg = formatError({
|
|
289
|
+
const msg = formatError({
|
|
290
|
+
details: e.details,
|
|
291
|
+
retryable: e.retryable,
|
|
292
|
+
...(e.code !== undefined ? { code: e.code } : {}),
|
|
293
|
+
}) ?? "";
|
|
290
294
|
try {
|
|
291
295
|
await settle(msg);
|
|
292
296
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Schema } from "@octokit/webhooks-types";
|
|
2
|
-
import type { ChannelModule } from "../../
|
|
2
|
+
import type { ChannelModule } from "../../channel.ts";
|
|
3
3
|
/** A verified GitHub webhook event. Header fields plus the official typed payload. */
|
|
4
4
|
export interface GithubEvent {
|
|
5
5
|
/** `X-GitHub-Event` (e.g. "pull_request", "issue_comment"). */
|
package/dist/channels/http.d.ts
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* HTTP/SSE channel: fan one invoke stream out to Server-Sent Events.
|
|
3
|
+
*
|
|
4
|
+
* The handler is Fetch-shaped (`(Request) => Promise<Response>`) — the cross-runtime form every
|
|
5
|
+
* embedding host speaks, so it mounts inside an existing app's own route. It is path-agnostic. The
|
|
6
|
+
* web stream primitives give cancellation (consumer disconnect → cancel() → iterator.return() →
|
|
7
|
+
* invoke cancellation, SPEC MUST 3), backpressure (pull-based), and the body cap natively.
|
|
8
|
+
*
|
|
9
|
+
* Serving it is somebody else's job: the node:http bridge and the route table belong to the HOST
|
|
10
|
+
* (`./serve.ts`). This file knows only the Agent contract and the wire shape of one stream.
|
|
11
|
+
*/
|
|
2
12
|
import type { Agent } from "../agent.ts";
|
|
3
13
|
/** Request body cap (1 MiB) — shared by every Prompt-bearing wire surface (the control plane's
|
|
4
14
|
* dispatch imports it), so the two caps cannot drift apart. */
|
|
@@ -19,9 +29,3 @@ export declare const INVOKE_EXAMPLE_BODY = "{\"session\":\"dev\",\"text\":\"hell
|
|
|
19
29
|
* Returns SSE (`text/event-stream`) with one `data:` line per AgentEvent.
|
|
20
30
|
*/
|
|
21
31
|
export declare function createInvokeHandler(agent: Agent): (req: Request) => Promise<Response>;
|
|
22
|
-
/**
|
|
23
|
-
* node:http adapter for a Fetch handler. Bridges IncomingMessage → Request and pumps the
|
|
24
|
-
* Response body back to ServerResponse with backpressure; a client disconnect (`res` close)
|
|
25
|
-
* cancels both the request signal and the response stream (→ invoke cancellation).
|
|
26
|
-
*/
|
|
27
|
-
export declare function nodeListener(handler: (req: Request) => Promise<Response>): (req: IncomingMessage, res: ServerResponse) => void;
|
package/dist/channels/http.js
CHANGED
|
@@ -1,17 +1,5 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HTTP/SSE channel: fan one invoke stream out to Server-Sent Events.
|
|
3
|
-
*
|
|
4
|
-
* The handler is Fetch-shaped (`(Request) => Promise<Response>`) — the cross-runtime form every
|
|
5
|
-
* embedding host speaks, so it mounts inside an existing app's own route. It is path-agnostic. The
|
|
6
|
-
* web stream primitives give cancellation (consumer disconnect → cancel() → iterator.return() →
|
|
7
|
-
* invoke cancellation, SPEC MUST 3), backpressure (pull-based), and the body cap natively.
|
|
8
|
-
*
|
|
9
|
-
* `nodeListener` is the thin node:http adapter for the embedded `fastagent dev/start` server.
|
|
10
|
-
*/
|
|
11
|
-
import { Readable } from "node:stream";
|
|
12
|
-
import { log } from "../log.js";
|
|
13
1
|
import { readBodyCapped } from "./body.js";
|
|
14
|
-
import { text
|
|
2
|
+
import { text } from "./respond.js";
|
|
15
3
|
/** Request body cap (1 MiB) — shared by every Prompt-bearing wire surface (the control plane's
|
|
16
4
|
* dispatch imports it), so the two caps cannot drift apart. */
|
|
17
5
|
export const MAX_BODY_BYTES = 1 << 20;
|
|
@@ -109,99 +97,3 @@ export function createInvokeHandler(agent) {
|
|
|
109
97
|
});
|
|
110
98
|
};
|
|
111
99
|
}
|
|
112
|
-
/**
|
|
113
|
-
* node:http adapter for a Fetch handler. Bridges IncomingMessage → Request and pumps the
|
|
114
|
-
* Response body back to ServerResponse with backpressure; a client disconnect (`res` close)
|
|
115
|
-
* cancels both the request signal and the response stream (→ invoke cancellation).
|
|
116
|
-
*/
|
|
117
|
-
export function nodeListener(handler) {
|
|
118
|
-
return (req, res) => {
|
|
119
|
-
void pump(handler, req, res); // safe: pump is TOTAL (never rejects) — see its contract below
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
/**
|
|
123
|
-
* Consume ONE request and drive its response to a terminal state. pump is TOTAL: a SINGLE try/catch wraps
|
|
124
|
-
* the whole request→response→stream path, so EVERY failure — a handler throw, a non-Response return
|
|
125
|
-
* (`response.headers` undefined), a header Node rejects, `getReader`, or a body stream that errors
|
|
126
|
-
* mid-flight — ends the response and the returned promise NEVER rejects, which is what lets the
|
|
127
|
-
* `void pump(...)` above be safe. Before any byte goes out (headers not sent) it is a clean 500; once the
|
|
128
|
-
* response is streaming, the only honest signal left is to destroy the socket (truncated stream, not a
|
|
129
|
-
* hang). The process installs no `unhandledRejection` handler by design: robustness against a background
|
|
130
|
-
* throw is each fire-and-forget's OWN contract (fail into a terminal HTTP response here), not a global net
|
|
131
|
-
* that would blanket-swallow.
|
|
132
|
-
*/
|
|
133
|
-
async function pump(handler, req, res) {
|
|
134
|
-
const controller = new AbortController();
|
|
135
|
-
res.on("close", () => controller.abort());
|
|
136
|
-
try {
|
|
137
|
-
const method = req.method ?? "GET";
|
|
138
|
-
const hasBody = method !== "GET" && method !== "HEAD";
|
|
139
|
-
const headers = new Headers();
|
|
140
|
-
for (const [k, v] of Object.entries(req.headers)) {
|
|
141
|
-
if (Array.isArray(v))
|
|
142
|
-
for (const vv of v)
|
|
143
|
-
headers.append(k, vv);
|
|
144
|
-
else if (v != null)
|
|
145
|
-
headers.set(k, v);
|
|
146
|
-
}
|
|
147
|
-
const request = new Request(`http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`, {
|
|
148
|
-
method,
|
|
149
|
-
headers,
|
|
150
|
-
body: hasBody ? Readable.toWeb(req) : undefined,
|
|
151
|
-
duplex: "half",
|
|
152
|
-
signal: controller.signal,
|
|
153
|
-
});
|
|
154
|
-
const response = await handler(request);
|
|
155
|
-
const outHeaders = {};
|
|
156
|
-
response.headers.forEach((value, key) => {
|
|
157
|
-
outHeaders[key] = value;
|
|
158
|
-
});
|
|
159
|
-
res.writeHead(response.status, outHeaders);
|
|
160
|
-
if (!response.body) {
|
|
161
|
-
res.end();
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
const reader = response.body.getReader();
|
|
165
|
-
res.on("close", () => void reader.cancel());
|
|
166
|
-
for (;;) {
|
|
167
|
-
const { done, value } = await reader.read();
|
|
168
|
-
if (done || res.destroyed)
|
|
169
|
-
break;
|
|
170
|
-
// Backpressure: wait for drain, but ALSO resolve on close. A client disconnect after write()
|
|
171
|
-
// returned false never emits 'drain' on the closed socket, so waiting on 'drain' alone would
|
|
172
|
-
// suspend pump() forever (leaking the request/stream).
|
|
173
|
-
if (!res.write(value)) {
|
|
174
|
-
await new Promise((resolve) => {
|
|
175
|
-
const settle = () => {
|
|
176
|
-
res.off("drain", settle);
|
|
177
|
-
res.off("close", settle);
|
|
178
|
-
resolve();
|
|
179
|
-
};
|
|
180
|
-
res.once("drain", settle);
|
|
181
|
-
res.once("close", settle);
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
if (!res.destroyed)
|
|
186
|
-
res.end(); // normal completion
|
|
187
|
-
}
|
|
188
|
-
catch (error) {
|
|
189
|
-
// The ONE totality boundary: every failure above lands here, so pump never rejects (see the header
|
|
190
|
-
// doc) — which REQUIRES the catch itself not to throw. Don't leak the internal message to the client.
|
|
191
|
-
log.error(`[host] request failed: ${String(error)}`);
|
|
192
|
-
// Never touch an already-terminal res: a client that disconnects during the handler await destroys res
|
|
193
|
-
// (headers not yet sent), and writeHead/end on a dead socket can throw ERR_STREAM_DESTROYED here — which
|
|
194
|
-
// WOULD be the unhandled rejection this boundary exists to kill. One named gate states the invariant;
|
|
195
|
-
// with it the catch is provably non-throwing (writeHead only when !headersSent && !destroyed, destroy is
|
|
196
|
-
// idempotent).
|
|
197
|
-
if (res.destroyed)
|
|
198
|
-
return;
|
|
199
|
-
if (res.headersSent) {
|
|
200
|
-
res.destroy(error instanceof Error ? error : undefined); // streaming → truncate (not a hang)
|
|
201
|
-
}
|
|
202
|
-
else {
|
|
203
|
-
res.writeHead(500, textHeaders); // pre-header → a clean 500
|
|
204
|
-
res.end("internal error\n");
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attachment destination for one conversation. Never rejects an id — but the path it returns is not
|
|
3
|
+
* promised to be one the filesystem accepts: percent-encoding spends up to 9 bytes per character, so
|
|
4
|
+
* an id of ~29 CJK characters exceeds a 255-byte `NAME_MAX` and the caller's `mkdir` fails with
|
|
5
|
+
* `ENAMETOOLONG`. Left to fail there rather than truncated here: a digest suffix would buy a length
|
|
6
|
+
* no platform id approaches, at the cost of the readability this encoding exists to keep.
|
|
7
|
+
*/
|
|
8
|
+
export declare function attachmentPath(filesDir: string, conversationId: string | number, fileName: string): {
|
|
9
|
+
dir: string;
|
|
10
|
+
name: string;
|
|
11
|
+
path: string;
|
|
12
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where an inbound attachment lands: `<filesDir>/<conversation>/<file>`.
|
|
3
|
+
*
|
|
4
|
+
* Both halves come from outside the channel, and they are NOT the same question:
|
|
5
|
+
*
|
|
6
|
+
* - The conversation is an id, and turning an arbitrary caller id into a storage name is a job this
|
|
7
|
+
* repo already answers by ENCODING it, never by rejecting it (`piSessionId` in
|
|
8
|
+
* engines/pi/session-store.ts, same shape: a character whitelist under a fixed prefix). An id is
|
|
9
|
+
* the caller's — a route returning `../..` gets an odd directory, not a failed turn, because the
|
|
10
|
+
* place to notice that id is broken is the reply it cannot deliver, not the disk.
|
|
11
|
+
* - The file name is not an id: it is read by a human and by the model, in a path this puts into the
|
|
12
|
+
* prompt. Encoding it would cost that, so it is reduced instead — losing the odd character rather
|
|
13
|
+
* than the readability. A leading dot survives: `.gitignore` is a name, not an escape, and nothing
|
|
14
|
+
* lists this directory, so hiding is not a property anyone here depends on.
|
|
15
|
+
*
|
|
16
|
+
* The encoding makes containment structural rather than checked: `encodeURIComponent` emits no path
|
|
17
|
+
* separator (`/` → `%2F`, `\` → `%5C`, `:` → `%3A`), and the prefix leaves no way to spell `.`, `..`
|
|
18
|
+
* or the empty string.
|
|
19
|
+
*
|
|
20
|
+
* It is lossless for a well-formed id, so `a/b` and `a_b` are different places. Two exceptions, both
|
|
21
|
+
* outside what a platform hands over: ids differing only in malformed UTF-16 (see the substitution
|
|
22
|
+
* below), and ids differing only in case on a case-insensitive filesystem.
|
|
23
|
+
*/
|
|
24
|
+
import { resolve, sep } from "node:path";
|
|
25
|
+
/**
|
|
26
|
+
* Attachment destination for one conversation. Never rejects an id — but the path it returns is not
|
|
27
|
+
* promised to be one the filesystem accepts: percent-encoding spends up to 9 bytes per character, so
|
|
28
|
+
* an id of ~29 CJK characters exceeds a 255-byte `NAME_MAX` and the caller's `mkdir` fails with
|
|
29
|
+
* `ENAMETOOLONG`. Left to fail there rather than truncated here: a digest suffix would buy a length
|
|
30
|
+
* no platform id approaches, at the cost of the readability this encoding exists to keep.
|
|
31
|
+
*/
|
|
32
|
+
export function attachmentPath(filesDir, conversationId, fileName) {
|
|
33
|
+
// A lone surrogate makes `encodeURIComponent` throw `URIError`, and this function rejecting an id
|
|
34
|
+
// is the one thing it must not do — a permanent bad id would surface as a retryable turn failure.
|
|
35
|
+
// The `u` flag matches by code point, so a valid pair (an emoji in a route's id) is left alone.
|
|
36
|
+
const id = String(conversationId).replace(/\p{Surrogate}/gu, "\uFFFD");
|
|
37
|
+
const dir = resolve(filesDir, `c-${encodeURIComponent(id)}`);
|
|
38
|
+
// The name keeps its own check because it is NOT encoded: separators go, and `resolve` settles
|
|
39
|
+
// whether what is left (`..`, a Windows `D:foo`) still lands in `dir`.
|
|
40
|
+
const cleaned = fileName.replace(/[/\\]/g, "_");
|
|
41
|
+
const name = resolve(dir, cleaned).startsWith(dir + sep) ? cleaned : "file";
|
|
42
|
+
return { dir, name, path: resolve(dir, name) };
|
|
43
|
+
}
|
|
@@ -9,6 +9,15 @@ export declare const BUFFER_LINE_MAX_CHARS = 280;
|
|
|
9
9
|
* note, so the model never sees an attachment reference it silently cannot open. Shared policy:
|
|
10
10
|
* each channel's attachment collector caps against this. */
|
|
11
11
|
export declare const BUFFER_ATTACH_MAX = 3;
|
|
12
|
+
/**
|
|
13
|
+
* The folded discussion as it reaches the model — the prompt block, or nothing when the buffer is
|
|
14
|
+
* empty. One renderer for every channel: what the agent is told about un-summoned discussion should
|
|
15
|
+
* not depend on which chat platform delivered it, and three copies of the literal is how that drifts.
|
|
16
|
+
*
|
|
17
|
+
* A channel that folds a SECOND source (feishu's originating room) labels that one itself — it is a
|
|
18
|
+
* different claim about a different place, not this block with another name.
|
|
19
|
+
*/
|
|
20
|
+
export declare function discussionBlock(text: string): string;
|
|
12
21
|
export interface ContextBuffer<E> {
|
|
13
22
|
/** Record an un-summoned message. Persists BEFORE returning (pre-ACK; see the module header). */
|
|
14
23
|
push(placeKey: string, entry: E): void;
|