@fastagent-sh/fastagent 0.13.0 → 0.14.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 +2 -2
- package/dist/channels/feishu/context-buffer.d.ts +46 -0
- package/dist/channels/feishu/context-buffer.js +133 -0
- package/dist/channels/feishu/crypto.d.ts +4 -2
- package/dist/channels/feishu/crypto.js +4 -2
- package/dist/channels/feishu/feishu-api.d.ts +4 -4
- package/dist/channels/feishu/feishu-api.js +2 -2
- package/dist/channels/feishu/feishu.d.ts +11 -1
- package/dist/channels/feishu/feishu.js +143 -30
- package/dist/channels/feishu/invoke-turn.d.ts +19 -13
- package/dist/channels/feishu/invoke-turn.js +70 -19
- package/dist/channels/feishu/model.d.ts +97 -0
- package/dist/channels/feishu/model.js +9 -0
- package/dist/channels/feishu/normalize.d.ts +22 -0
- package/dist/channels/feishu/normalize.js +132 -0
- package/dist/channels/feishu/owned-threads.d.ts +11 -0
- package/dist/channels/feishu/owned-threads.js +47 -0
- package/dist/channels/feishu/parse.d.ts +20 -102
- package/dist/channels/feishu/parse.js +35 -145
- package/dist/channels/feishu/preview.js +2 -2
- package/dist/channels/feishu/scaffold/channel.ts +10 -3
- package/dist/channels/feishu/seen.js +12 -12
- package/dist/channels/lark/scaffold/channel.ts +10 -3
- package/dist/cli/commands/add.d.ts +9 -0
- package/dist/cli/commands/add.js +142 -0
- package/dist/cli/commands/chat.d.ts +3 -0
- package/dist/cli/commands/chat.js +16 -0
- package/dist/cli/commands/deploy.d.ts +13 -0
- package/dist/cli/commands/deploy.js +338 -0
- package/dist/cli/commands/dev.d.ts +11 -0
- package/dist/cli/commands/dev.js +76 -0
- package/dist/cli/commands/fire.d.ts +7 -0
- package/dist/cli/commands/fire.js +45 -0
- package/dist/cli/commands/info.d.ts +7 -0
- package/dist/cli/commands/info.js +108 -0
- package/dist/cli/commands/init.d.ts +8 -0
- package/dist/cli/commands/init.js +81 -0
- package/dist/cli/commands/invoke.d.ts +7 -0
- package/dist/cli/commands/invoke.js +29 -0
- package/dist/cli/commands/login.d.ts +6 -0
- package/dist/cli/commands/login.js +63 -0
- package/dist/cli/commands/models.d.ts +1 -0
- package/dist/cli/commands/models.js +15 -0
- package/dist/cli/commands/schedule.d.ts +12 -0
- package/dist/cli/commands/schedule.js +89 -0
- package/dist/cli/commands/start.d.ts +10 -0
- package/dist/cli/commands/start.js +90 -0
- package/dist/cli/commands/tool.d.ts +1 -0
- package/dist/cli/commands/tool.js +37 -0
- package/dist/cli/fail.d.ts +19 -0
- package/dist/cli/fail.js +32 -0
- package/dist/cli/kernel.d.ts +89 -0
- package/dist/cli/kernel.js +190 -0
- package/dist/cli/program.d.ts +11 -0
- package/dist/cli/program.js +421 -0
- package/dist/cli/serve.d.ts +28 -0
- package/dist/cli/serve.js +90 -0
- package/dist/cli/shared.d.ts +24 -0
- package/dist/cli/shared.js +116 -0
- package/dist/cli.js +8 -1329
- package/dist/deploy/docker/plan.d.ts +45 -0
- package/dist/deploy/docker/plan.js +139 -0
- package/dist/deploy/docker/run.d.ts +40 -0
- package/dist/deploy/docker/run.js +126 -0
- package/dist/deploy/preflight.js +4 -3
- package/dist/deploy/runner.d.ts +4 -1
- package/dist/deploy/runner.js +1 -0
- package/dist/engines/pi/auth.js +160 -46
- package/dist/engines/pi/chat.js +77 -4
- package/dist/engines/pi/config.d.ts +12 -3
- package/dist/engines/pi/config.js +16 -1
- package/dist/engines/pi/create.d.ts +14 -5
- package/dist/engines/pi/create.js +44 -9
- package/dist/engines/pi/harness.d.ts +16 -1
- package/dist/engines/pi/harness.js +77 -1
- package/dist/engines/pi/invoke.d.ts +1 -1
- package/dist/engines/pi/invoke.js +37 -2
- package/dist/engines/pi/login.js +1 -1
- package/dist/engines/pi/search-tools.d.ts +10 -0
- package/dist/engines/pi/search-tools.js +138 -0
- package/dist/engines/pi/tool-context.d.ts +28 -0
- package/dist/engines/pi/tool-context.js +8 -0
- package/dist/engines/pi/tool.d.ts +32 -1
- package/dist/engines/pi/tool.js +42 -1
- package/dist/engines/pi/workspace.d.ts +4 -1
- package/dist/engines/pi/workspace.js +3 -1
- package/dist/pi.d.ts +2 -1
- package/dist/scaffold/add-channel.js +3 -1
- package/dist/scaffold/templates/fastagent.config.mjs +1 -0
- package/package.json +7 -4
|
@@ -1,175 +1,65 @@
|
|
|
1
|
-
|
|
2
|
-
function restoreMentions(text, mentions) {
|
|
3
|
-
let out = text;
|
|
4
|
-
for (const m of mentions ?? []) {
|
|
5
|
-
if (!m.key)
|
|
6
|
-
continue;
|
|
7
|
-
out = out.split(m.key).join(`@${m.name ?? "user"}`);
|
|
8
|
-
}
|
|
9
|
-
return out;
|
|
10
|
-
}
|
|
1
|
+
import { decodeFeishuContent } from "./normalize.js";
|
|
11
2
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* external input, and a message the agent cannot read should still say WHAT it couldn't read.
|
|
3
|
+
* Compatibility decoder for existing helpers/tests and parent-message resolution. The canonical
|
|
4
|
+
* decoder now emits typed resources; this wrapper projects them onto the historical parallel arrays.
|
|
15
5
|
*/
|
|
16
|
-
export function parseContent(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
const imageKeys = [];
|
|
27
|
-
const fileRefs = [];
|
|
28
|
-
const str = (v) => (typeof v === "string" && v !== "" ? v : undefined);
|
|
29
|
-
switch (m.message_type) {
|
|
30
|
-
case "text":
|
|
31
|
-
return { text: restoreMentions(str(c.text) ?? "", m.mentions), imageKeys, fileRefs };
|
|
32
|
-
case "post": {
|
|
33
|
-
// A post is paragraphs of typed nodes; renders as text lines with inline markers. Mentions in a
|
|
34
|
-
// post are `at` NODES (user_name inline), not placeholders — no restore pass needed.
|
|
35
|
-
const lines = [];
|
|
36
|
-
const title = str(c.title);
|
|
37
|
-
if (title)
|
|
38
|
-
lines.push(title);
|
|
39
|
-
const paragraphs = Array.isArray(c.content) ? c.content : [];
|
|
40
|
-
for (const para of paragraphs) {
|
|
41
|
-
if (!Array.isArray(para))
|
|
42
|
-
continue;
|
|
43
|
-
const parts = [];
|
|
44
|
-
for (const node of para) {
|
|
45
|
-
if (typeof node !== "object" || node === null)
|
|
46
|
-
continue;
|
|
47
|
-
if (node.tag === "at")
|
|
48
|
-
parts.push(`@${str(node.user_name) ?? str(node.user_id) ?? "user"}`);
|
|
49
|
-
else if (node.tag === "a")
|
|
50
|
-
parts.push(node.href ? `${str(node.text) ?? node.href} (${node.href})` : (str(node.text) ?? ""));
|
|
51
|
-
else if (node.tag === "img") {
|
|
52
|
-
if (str(node.image_key))
|
|
53
|
-
imageKeys.push(node.image_key);
|
|
54
|
-
parts.push("[image]");
|
|
55
|
-
}
|
|
56
|
-
else if (node.tag === "media") {
|
|
57
|
-
if (str(node.file_key))
|
|
58
|
-
fileRefs.push({ key: node.file_key, name: str(node.file_name) });
|
|
59
|
-
parts.push("[video]");
|
|
60
|
-
}
|
|
61
|
-
else if (node.tag === "code_block")
|
|
62
|
-
parts.push(`\n\`\`\`${str(node.language)?.toLowerCase() ?? ""}\n${str(node.text) ?? ""}\n\`\`\`\n`);
|
|
63
|
-
else if (str(node.text))
|
|
64
|
-
parts.push(node.text);
|
|
65
|
-
}
|
|
66
|
-
const line = parts.join("").trim();
|
|
67
|
-
if (line)
|
|
68
|
-
lines.push(line);
|
|
69
|
-
}
|
|
70
|
-
return { text: lines.join("\n"), imageKeys, fileRefs };
|
|
71
|
-
}
|
|
72
|
-
case "image": {
|
|
73
|
-
if (str(c.image_key))
|
|
74
|
-
imageKeys.push(c.image_key);
|
|
75
|
-
return { text: "[image]", imageKeys, fileRefs };
|
|
76
|
-
}
|
|
77
|
-
case "file": {
|
|
78
|
-
const name = str(c.file_name);
|
|
79
|
-
if (str(c.file_key))
|
|
80
|
-
fileRefs.push({ key: c.file_key, name });
|
|
81
|
-
return { text: `[file: ${name ?? "file"}]`, imageKeys, fileRefs };
|
|
82
|
-
}
|
|
83
|
-
case "audio": {
|
|
84
|
-
if (str(c.file_key))
|
|
85
|
-
fileRefs.push({ key: c.file_key, name: "voice-message" });
|
|
86
|
-
return { text: "[voice message]", imageKeys, fileRefs };
|
|
87
|
-
}
|
|
88
|
-
case "media": {
|
|
89
|
-
const name = str(c.file_name);
|
|
90
|
-
if (str(c.file_key))
|
|
91
|
-
fileRefs.push({ key: c.file_key, name });
|
|
92
|
-
return { text: `[video: ${name ?? "video"}]`, imageKeys, fileRefs };
|
|
93
|
-
}
|
|
94
|
-
case "location": {
|
|
95
|
-
const name = str(c.name);
|
|
96
|
-
return {
|
|
97
|
-
text: `[location: ${name ? `${name} — ` : ""}${str(c.latitude) ?? "?"},${str(c.longitude) ?? "?"}]`,
|
|
98
|
-
imageKeys,
|
|
99
|
-
fileRefs,
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
default:
|
|
103
|
-
// sticker / share_chat / share_user / system / … — name the type so the agent can say what it got.
|
|
104
|
-
return { text: `[${m.message_type} message]`, imageKeys, fileRefs };
|
|
105
|
-
}
|
|
6
|
+
export function parseContent(message) {
|
|
7
|
+
const decoded = decodeFeishuContent(message);
|
|
8
|
+
return {
|
|
9
|
+
text: decoded.text,
|
|
10
|
+
imageKeys: decoded.resources.filter((resource) => resource.kind === "image").map((resource) => resource.key),
|
|
11
|
+
fileRefs: decoded.resources
|
|
12
|
+
.filter((resource) => resource.kind === "file" || resource.kind === "audio" || resource.kind === "video")
|
|
13
|
+
.map((resource) => ({ key: resource.key, name: resource.name })),
|
|
14
|
+
};
|
|
106
15
|
}
|
|
107
|
-
/** A stable sender label for attribution.
|
|
108
|
-
* contacts-API scope), so the label is the open_id — stable across turns, which is what a shared
|
|
109
|
-
* multi-user session needs to tell participants apart. */
|
|
16
|
+
/** A stable sender label for attribution. Display names require an additional contacts permission. */
|
|
110
17
|
export function senderLabel(sender) {
|
|
111
18
|
const id = sender?.sender_id?.open_id ?? sender?.sender_id?.user_id ?? sender?.sender_id?.union_id;
|
|
112
19
|
return id ? `user ${id}` : undefined;
|
|
113
20
|
}
|
|
114
|
-
/** The place a message lives (chat, or chat:topic in a topic group) — the default session key. */
|
|
115
|
-
export function placeKey(
|
|
116
|
-
return
|
|
21
|
+
/** The place a message lives (chat, or chat:topic in a topic group) — the legacy default session key. */
|
|
22
|
+
export function placeKey(message) {
|
|
23
|
+
return message.thread_id ? `${message.chat_id}:${message.thread_id}` : message.chat_id;
|
|
117
24
|
}
|
|
118
|
-
/**
|
|
119
|
-
* The default base prompt: a context envelope (chat/thread/sender + a group note + a reply marker),
|
|
120
|
-
* then the message's decoded body. The sender is named on every message and a group chat is flagged —
|
|
121
|
-
* in a shared multi-user session that is how the model tells participants apart and knows it is not a
|
|
122
|
-
* 1:1. A reply carries only `[in reply to msg …]` here: the referent's CONTENT is not in the event, so
|
|
123
|
-
* the channel fetches and appends it in the IO half (invoke-turn.ts), keeping this layer pure. Exported
|
|
124
|
-
* so a custom Feishu `route` can reuse it, e.g. `text: `${feishuEnvelope(event)}\n\n[extra]``. The
|
|
125
|
-
* internal compatibility seam binds the same shape to `[lark: …]`; each kind's send tool reads the
|
|
126
|
-
* chat id from its own branded line.
|
|
127
|
-
*/
|
|
25
|
+
/** The canonical Feishu-branded prompt envelope. */
|
|
128
26
|
export function feishuEnvelope(event) {
|
|
129
27
|
return cloudEnvelope(event, "feishu");
|
|
130
28
|
}
|
|
131
29
|
/** Internal compatibility seam: bind the canonical envelope shape to one cloud's branded tag. */
|
|
132
30
|
export function cloudEnvelope(event, tag) {
|
|
133
|
-
const
|
|
134
|
-
if (!
|
|
31
|
+
const message = event.message;
|
|
32
|
+
if (!message)
|
|
135
33
|
return "";
|
|
34
|
+
const from = senderLabel(event.sender);
|
|
136
35
|
const meta = [
|
|
137
|
-
`chat ${
|
|
138
|
-
|
|
139
|
-
|
|
36
|
+
`chat ${message.chat_id} (${message.chat_type})`,
|
|
37
|
+
message.thread_id ? `topic ${message.thread_id}` : undefined,
|
|
38
|
+
from ? `from ${from}` : undefined,
|
|
140
39
|
]
|
|
141
40
|
.filter(Boolean)
|
|
142
41
|
.join(", ");
|
|
143
|
-
const scope =
|
|
144
|
-
const replyTo =
|
|
145
|
-
return `[${tag}: ${meta}]${scope}${replyTo}\n${parseContent(
|
|
42
|
+
const scope = message.chat_type === "group" ? "\n[group chat — multiple people; each message is prefixed with its sender]" : "";
|
|
43
|
+
const replyTo = message.parent_id ? `\n[in reply to msg ${message.parent_id}]` : "";
|
|
44
|
+
return `[${tag}: ${meta}]${scope}${replyTo}\n${parseContent(message).text}`;
|
|
146
45
|
}
|
|
147
|
-
/**
|
|
148
|
-
|
|
149
|
-
* (never a regex over the text: a pasted `@bot` in a code block is not a mention entry), matched on the
|
|
150
|
-
* bot's open_id (stable identity; names are mutable). No id → fail closed (false): answering "is this
|
|
151
|
-
* mention me?" with "I don't know who I am, so yes" would mis-summon in every multi-bot group.
|
|
152
|
-
*/
|
|
153
|
-
export function mentionsBot(m, botOpenId) {
|
|
46
|
+
/** Whether the parsed mention list contains this bot's app-scoped open_id. */
|
|
47
|
+
export function mentionsBot(message, botOpenId) {
|
|
154
48
|
if (!botOpenId)
|
|
155
49
|
return false;
|
|
156
|
-
return (
|
|
50
|
+
return (message.mentions ?? []).some((mention) => mention.id?.open_id === botOpenId);
|
|
157
51
|
}
|
|
158
52
|
/**
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
* feishuChannel resolves via bot/v3/info). NOTE the platform side of the same coin: with the default
|
|
163
|
-
* `im:message.group_at_msg` scope, un-mentioned group messages are never even delivered — receiving
|
|
164
|
-
* everything needs the sensitive `im:message.group_msg` scope. Returns `{}` (act; the channel fills
|
|
165
|
-
* session/target/prompt from the message) or `null` (ignore).
|
|
53
|
+
* Default EXPLICIT-summon policy: ignore non-user senders, always answer p2p, and answer groups only
|
|
54
|
+
* when THIS bot is structurally mentioned. No bot identity means group routing fails closed. The
|
|
55
|
+
* stateful channel wiring may additionally admit unmentioned continuations from its managed-root index.
|
|
166
56
|
*/
|
|
167
57
|
export function defaultFeishuRoute(event, options) {
|
|
168
|
-
const
|
|
169
|
-
if (!
|
|
58
|
+
const message = event.message;
|
|
59
|
+
if (!message)
|
|
170
60
|
return null;
|
|
171
61
|
if (event.sender?.sender_type !== "user")
|
|
172
62
|
return null;
|
|
173
|
-
const summoned =
|
|
63
|
+
const summoned = message.chat_type === "p2p" || mentionsBot(message, options?.botOpenId);
|
|
174
64
|
return summoned ? {} : null;
|
|
175
65
|
}
|
|
@@ -88,8 +88,8 @@ async function finalize(api, target, preview, text, seq) {
|
|
|
88
88
|
// Settle failed (card expired / rejected) — fall through to delete + fresh send below.
|
|
89
89
|
}
|
|
90
90
|
if (settled) {
|
|
91
|
-
//
|
|
92
|
-
// repeating the quote on every chunk. sendText owns the same distinction for its own chunking.
|
|
91
|
+
// Threaded continuations must keep reply_in_thread; continuous top-level group replies intentionally
|
|
92
|
+
// avoid repeating the quote on every chunk. sendText owns the same distinction for its own chunking.
|
|
93
93
|
// A continuation failure propagates: the card is already authoritative, so deleting it and sending
|
|
94
94
|
// the full answer again would deterministically duplicate every continuation that already landed.
|
|
95
95
|
const continuationTarget = target.replyInThread ? target : { chatId: target.chatId };
|
|
@@ -7,7 +7,9 @@ import { feishuChannel } from "@fastagent-sh/fastagent/feishu";
|
|
|
7
7
|
// 1. create a custom app → enable the BOT capability → copy App ID / App Secret into .env
|
|
8
8
|
// 2. Permissions: add `im:message.p2p_msg:readonly` (direct messages), `im:message.group_at_msg:readonly`
|
|
9
9
|
// (group @mentions), `im:message:send_as_bot` (reply), `im:resource` (attachments), and the
|
|
10
|
-
// card scope ("Create and update card" — the live preview streams through a card)
|
|
10
|
+
// card scope ("Create and update card" — the live preview streams through a card). To answer bare
|
|
11
|
+
// messages in Agent-managed threads and buffer other unsummoned group/thread context, also add the
|
|
12
|
+
// sensitive `im:message.group_msg` scope (tenant-admin approval) and publish a new version.
|
|
11
13
|
// 3. Events & Callbacks → subscribe to `im.message.receive_v1`; copy the Verification Token into
|
|
12
14
|
// .env; RECOMMENDED: set an Encrypt Key there and mirror it in FEISHU_ENCRYPT_KEY
|
|
13
15
|
// 4. the event Request URL (https://your.host/feishu) is registered AUTOMATICALLY by
|
|
@@ -21,14 +23,19 @@ export default feishuChannel({
|
|
|
21
23
|
appSecret: process.env.FEISHU_APP_SECRET ?? "",
|
|
22
24
|
verificationToken: process.env.FEISHU_VERIFICATION_TOKEN ?? "", // authenticates inbound events
|
|
23
25
|
encryptKey: process.env.FEISHU_ENCRYPT_KEY || undefined, // optional; when set, plaintext events are refused
|
|
26
|
+
// Direct and group chats default to one Agent session/thread per top-level ask. Opt out independently:
|
|
27
|
+
// directMessageSession: "continuous",
|
|
28
|
+
// groupMessageSession: "continuous",
|
|
24
29
|
// Dev/personal bot: surface raw errors to the chat so you (and your AI agent) can act on them. The
|
|
25
30
|
// chat is customer-facing by default — for a public bot, drop this or return a neutral string;
|
|
26
31
|
// full details always go to the server log regardless.
|
|
27
32
|
onError: (failed) => `⚠️ ${failed.details}`,
|
|
28
33
|
// The channel owns transport + format (markdown card) + attachments (image→vision, file→disk) +
|
|
29
34
|
// the live streaming preview. `route` (POLICY) is OPTIONAL — omitted, it uses defaultFeishuRoute:
|
|
30
|
-
// p2p chats always answer
|
|
31
|
-
//
|
|
35
|
+
// p2p chats always answer; groups answer on @this-bot, plus bare continuations in Agent-managed
|
|
36
|
+
// threads. Other human group/thread discussion buffers until that place's next answered turn;
|
|
37
|
+
// @other-only messages in managed threads buffer rather than triggering the Agent.
|
|
38
|
+
// Override to customise explicit routing, reusing the export:
|
|
32
39
|
// route: (e) => defaultFeishuRoute(e, { botOpenId: "ou_xxx" }) && { session: `user:${e.sender?.sender_id?.open_id}` },
|
|
33
40
|
// route: (e) => defaultFeishuRoute(e, { botOpenId: "ou_xxx" }) && { text: `${feishuEnvelope(e)}\n[extra]` },
|
|
34
41
|
});
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* cannot cover
|
|
6
|
-
*
|
|
7
|
-
* free, so recording them would just churn the ring.
|
|
2
|
+
* Bounded delivery dedup for message_ids whose webhook handling produced a durable side effect: either
|
|
3
|
+
* an accepted turn intent or a buffered group-context entry. Feishu/Lark document duplicate pushes even
|
|
4
|
+
* after a successful 200 and recommend idempotency on message_id (not event_id). The unfinished-turn
|
|
5
|
+
* store cannot cover a duplicate after completion, and a duplicated background event would otherwise be
|
|
6
|
+
* folded twice.
|
|
8
7
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* the
|
|
8
|
+
* Record only AFTER the pre-ACK side effect is durable: recording first could turn a later state-write
|
|
9
|
+
* failure into silent loss when the platform redelivers. The ring write is best-effort post-persist
|
|
10
|
+
* insurance. A crash between the two writes, a ring-write failure, or an id older than the bounded cap
|
|
11
|
+
* retains L1's at-least-once tail; this is dedup, not exactly-once execution.
|
|
12
12
|
*/
|
|
13
13
|
import { log } from "../../log.js";
|
|
14
14
|
import { loadStateFile, saveStateFile } from "../state.js";
|
|
@@ -17,7 +17,7 @@ export function createSeenRing(path, label = "[feishu]", cap = 2000) {
|
|
|
17
17
|
const raw = loadStateFile(path);
|
|
18
18
|
if (raw === undefined)
|
|
19
19
|
return [];
|
|
20
|
-
if (Array.isArray(raw) && raw.every((
|
|
20
|
+
if (Array.isArray(raw) && raw.every((id) => typeof id === "string"))
|
|
21
21
|
return raw.slice(-cap);
|
|
22
22
|
log.warn(`${label} unexpected shape in ${path} — starting with no seen ids`);
|
|
23
23
|
return [];
|
|
@@ -39,8 +39,8 @@ export function createSeenRing(path, label = "[feishu]", cap = 2000) {
|
|
|
39
39
|
try {
|
|
40
40
|
saveStateFile(path, order);
|
|
41
41
|
}
|
|
42
|
-
catch (
|
|
43
|
-
log.warn(`${label} seen-ring write failed (dedup
|
|
42
|
+
catch (error) {
|
|
43
|
+
log.warn(`${label} seen-ring write failed (delivery dedup is in-memory until restart): ${String(error)}`);
|
|
44
44
|
}
|
|
45
45
|
},
|
|
46
46
|
};
|
|
@@ -7,7 +7,9 @@ import { larkChannel } from "@fastagent-sh/fastagent/lark";
|
|
|
7
7
|
// 1. create a custom app → enable the BOT capability → copy App ID / App Secret into .env
|
|
8
8
|
// 2. Permissions: add `im:message.p2p_msg:readonly` (direct messages), `im:message.group_at_msg:readonly`
|
|
9
9
|
// (group @mentions), `im:message:send_as_bot` (reply), `im:resource` (attachments), and the
|
|
10
|
-
// card scope ("Create and update card" — the live preview streams through a card)
|
|
10
|
+
// card scope ("Create and update card" — the live preview streams through a card). To answer bare
|
|
11
|
+
// messages in Agent-managed threads and buffer other unsummoned group/thread context, also add the
|
|
12
|
+
// sensitive `im:message.group_msg` scope (tenant-admin approval) and publish a new version.
|
|
11
13
|
// 3. Events & Callbacks → subscribe to `im.message.receive_v1`; copy the Verification Token into
|
|
12
14
|
// .env; RECOMMENDED: set an Encrypt Key there and mirror it in LARK_ENCRYPT_KEY
|
|
13
15
|
// 4. run `fastagent dev --tunnel`: it attempts to switch Subscription mode to webhook + register
|
|
@@ -19,14 +21,19 @@ export default larkChannel({
|
|
|
19
21
|
appSecret: process.env.LARK_APP_SECRET ?? "",
|
|
20
22
|
verificationToken: process.env.LARK_VERIFICATION_TOKEN ?? "", // authenticates inbound events
|
|
21
23
|
encryptKey: process.env.LARK_ENCRYPT_KEY || undefined, // optional; when set, plaintext events are refused
|
|
24
|
+
// Direct and group chats default to one Agent session/thread per top-level ask. Opt out independently:
|
|
25
|
+
// directMessageSession: "continuous",
|
|
26
|
+
// groupMessageSession: "continuous",
|
|
22
27
|
// Dev/personal bot: surface raw errors to the chat so you (and your AI agent) can act on them. The
|
|
23
28
|
// chat is customer-facing by default — for a public bot, drop this or return a neutral string;
|
|
24
29
|
// full details always go to the server log regardless.
|
|
25
30
|
onError: (failed) => `⚠️ ${failed.details}`,
|
|
26
31
|
// The channel owns transport + format (markdown card) + attachments (image→vision, file→disk) +
|
|
27
32
|
// the live streaming preview. `route` (POLICY) is OPTIONAL — omitted, it uses defaultLarkRoute:
|
|
28
|
-
// p2p chats always answer
|
|
29
|
-
//
|
|
33
|
+
// p2p chats always answer; groups answer on @this-bot, plus bare continuations in Agent-managed
|
|
34
|
+
// threads. Other human group/thread discussion buffers until that place's next answered turn;
|
|
35
|
+
// @other-only messages in managed threads buffer rather than triggering the Agent.
|
|
36
|
+
// Override to customise explicit routing, reusing the export:
|
|
30
37
|
// route: (e) => defaultLarkRoute(e, { botOpenId: "ou_xxx" }) && { session: `user:${e.sender?.sender_id?.open_id}` },
|
|
31
38
|
// route: (e) => defaultLarkRoute(e, { botOpenId: "ou_xxx" }) && { text: `${larkEnvelope(e)}\n[extra]` },
|
|
32
39
|
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type ChannelKind } from "../../scaffold/add-channel.ts";
|
|
2
|
+
/** `fastagent add <kind> [dir]`: scaffold `channels/<kind>.ts` — the adapter import plus a starter `on()`. */
|
|
3
|
+
export declare function runAddChannel(channelKind: ChannelKind, dirArg: string, opts: {
|
|
4
|
+
createApp?: boolean;
|
|
5
|
+
}): Promise<void>;
|
|
6
|
+
/** `fastagent add skill <source> [dir]`: vendor an Agent Skills skill into <dir>/skills/<name>/. */
|
|
7
|
+
export declare function runAddSkill(source: string | undefined, dirArg: string, opts: {
|
|
8
|
+
update?: boolean;
|
|
9
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fastagent add <channel>|skill` — scaffold channel glue (`channels/<kind>.ts`) or vendor an Agent
|
|
3
|
+
* Skills skill. feishu/lark additionally CREATE OR RESUME the platform app (cli-add-feishu.ts).
|
|
4
|
+
*/
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { join, relative, resolve } from "node:path";
|
|
7
|
+
import { onboardFeishuCloudApp } from "../../cli-add-feishu.js";
|
|
8
|
+
import { loadConfig, resolveAgentDir } from "../../engines/pi/config.js";
|
|
9
|
+
import { detectRuntime, readPackageJson } from "../../runtime.js";
|
|
10
|
+
import { appendChannelDotEnv, appendChannelEnv, assertChannelReady, channelExists, channelSetup, scaffoldChannel, } from "../../scaffold/add-channel.js";
|
|
11
|
+
import { vendorSkill } from "../../scaffold/vendor-skill.js";
|
|
12
|
+
import { loadRootIgnore } from "../../workspace.js";
|
|
13
|
+
import { failStartup, failUsage } from "../fail.js";
|
|
14
|
+
/** `fastagent add <kind> [dir]`: scaffold `channels/<kind>.ts` — the adapter import plus a starter `on()`. */
|
|
15
|
+
export async function runAddChannel(channelKind, dirArg, opts) {
|
|
16
|
+
const target = resolve(dirArg);
|
|
17
|
+
// App creation is not a flag — it is what `add feishu` IS (the scan-to-create flow is the default
|
|
18
|
+
// and only path there). The retired --create-app spelling gets a pointer, not silence.
|
|
19
|
+
if (opts.createApp) {
|
|
20
|
+
if (channelKind === "feishu") {
|
|
21
|
+
console.error(`[fastagent] note: --create-app is retired — \`add feishu\` creates the app by default`);
|
|
22
|
+
}
|
|
23
|
+
else if (channelKind === "lark") {
|
|
24
|
+
failStartup(new Error("--create-app is retired — `add lark` now opens the developer console and guides credential setup by default"));
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
failStartup(new Error("--create-app is retired — app creation is the default behavior of `add feishu`"));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// The channel (glue + companion tool) is agent surface — it lands in agentDir (config.agentDir, or
|
|
31
|
+
// target when flat), the same place dev/start discover channels/. .env(.example) and the secret
|
|
32
|
+
// hygiene stay at the run root, where .env is actually read.
|
|
33
|
+
const { config: addConfig } = await loadConfig(target).catch(failStartup);
|
|
34
|
+
const channelHome = resolveAgentDir(target, addConfig);
|
|
35
|
+
// Preconditions before the write, so a refusal is side-effect-free. feishu/lark are exceptions:
|
|
36
|
+
// their add is scaffold + ONBOARD THE APP, so an existing scaffold skips the write and continues (a
|
|
37
|
+
// failed or cancelled scan/paste flow must be re-runnable without hand-deleting glue); never touch it.
|
|
38
|
+
const file = join(channelHome, "channels", `${channelKind}.ts`);
|
|
39
|
+
if (await channelExists(channelHome, channelKind).catch(failStartup)) {
|
|
40
|
+
if (channelKind !== "feishu" && channelKind !== "lark") {
|
|
41
|
+
failStartup(new Error(`${relative(target, file)} already exists — edit it, or remove it to re-scaffold`));
|
|
42
|
+
}
|
|
43
|
+
console.error(`[fastagent] ${relative(target, file)} already exists — keeping it`);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
await assertChannelReady(channelHome).catch(failStartup);
|
|
47
|
+
await scaffoldChannel(channelHome, channelKind).catch(failStartup);
|
|
48
|
+
console.error(`[fastagent] created ${relative(target, file)}`);
|
|
49
|
+
}
|
|
50
|
+
if (await appendChannelEnv(target, channelKind).catch(failStartup)) {
|
|
51
|
+
console.error(`[fastagent] added ${channelKind} env vars to .env.example`);
|
|
52
|
+
}
|
|
53
|
+
// Secret hygiene: a channel's GENERATED secret (a random string the user contributes nothing to) is
|
|
54
|
+
// written into `.env` — but only when `.env` is already gitignored: the CLI must never materialize a
|
|
55
|
+
// secret into a committable file. Warn, not refuse, when it is exposed — channel glue may read a real
|
|
56
|
+
// env var instead.
|
|
57
|
+
const envIgnored = (await loadRootIgnore(target).catch(failStartup))?.ignores(".env") ?? false;
|
|
58
|
+
if (!envIgnored) {
|
|
59
|
+
console.error(`[fastagent] warn: .env is not gitignored — a deploy that copies the directory would ship a secret placed there; add .env to .gitignore/.fastagentignore, or use a real env var`);
|
|
60
|
+
}
|
|
61
|
+
// `add feishu`/`add lark` = scaffold + CREATE OR RESUME the app (cli-add-feishu.ts): feishu persists
|
|
62
|
+
// its irreversible App ID/Secret boundary internally; lark returns guided credentials for the
|
|
63
|
+
// generic .env write below.
|
|
64
|
+
let created;
|
|
65
|
+
if (channelKind === "feishu" || channelKind === "lark") {
|
|
66
|
+
created = await onboardFeishuCloudApp(target, channelKind, envIgnored).catch(failStartup);
|
|
67
|
+
}
|
|
68
|
+
const { env, steps } = channelSetup(channelKind);
|
|
69
|
+
const generated = Object.fromEntries(env.filter((e) => e.generate).map((e) => [e.name, randomBytes(24).toString("hex")]));
|
|
70
|
+
// Kind-neutral: every channel's generated secrets get the same treatment (github's webhook secret is
|
|
71
|
+
// the same class of value as telegram's); guided Lark credentials ride the same write as overwrites.
|
|
72
|
+
// Feishu's irreversible credentials were already staged inside cli-add-feishu.ts before bootstrap.
|
|
73
|
+
const dotEnv = envIgnored
|
|
74
|
+
? await appendChannelDotEnv(target, channelKind, { ...generated, ...created }, Object.keys(created ?? {})).catch(failStartup)
|
|
75
|
+
: undefined;
|
|
76
|
+
if (dotEnv && dotEnv.written.length > 0) {
|
|
77
|
+
console.error(`[fastagent] wrote ${dotEnv.written.join(", ")} to .env`);
|
|
78
|
+
}
|
|
79
|
+
const install = detectRuntime(channelHome, await readPackageJson(channelHome)).runtime === "bun" ? "bun install" : "npm install";
|
|
80
|
+
// The kit's manifest lives in channelHome (agentDir when set) — point the install there, not the run root.
|
|
81
|
+
const installCmd = channelHome === target ? install : `(cd ${relative(target, channelHome)} && ${install})`;
|
|
82
|
+
console.error(` next steps:`);
|
|
83
|
+
console.error(` ${installCmd} # if @fastagent-sh/fastagent is not installed yet`);
|
|
84
|
+
for (const e of env) {
|
|
85
|
+
if (dotEnv?.alreadySet.includes(e.name))
|
|
86
|
+
continue; // the user already has it — nothing to do
|
|
87
|
+
if (dotEnv?.written.includes(e.name)) {
|
|
88
|
+
// Written, but its hint may still carry an action (github: paste the same value into the webhook
|
|
89
|
+
// UI) — keep the variable visible instead of silently absorbing it.
|
|
90
|
+
console.error(` ${e.name} — ${e.generate ? "generated and " : ""}written to .env # ${e.hint}`);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const value = e.generate ? `=${generated[e.name]}` : "";
|
|
94
|
+
const action = e.required ? "set" : "optionally set";
|
|
95
|
+
console.error(` ${action} ${e.name}${value} in .env${envIgnored ? " (gitignored)" : ""} # ${e.hint}`);
|
|
96
|
+
}
|
|
97
|
+
// Steps carry `{channel}`/`{tools}` path placeholders (their filenames are the scaffold's private
|
|
98
|
+
// knowledge) — resolve them to the real workspace-relative locations (agentDir-aware) here.
|
|
99
|
+
const kitPrefix = channelHome === target ? "" : `${relative(target, channelHome)}/`;
|
|
100
|
+
for (const s of steps) {
|
|
101
|
+
console.error(` ${s.replace("{channel}", relative(target, file)).replace("{tools}", `${kitPrefix}tools`)}`);
|
|
102
|
+
}
|
|
103
|
+
if (channelKind !== "lark") {
|
|
104
|
+
console.error(` fastagent dev --tunnel # serve locally + a public URL, auto-registering the webhook`);
|
|
105
|
+
}
|
|
106
|
+
// The app-creation flow leaves keep-alive sockets behind (platform API fetches, the throwaway tunnel's
|
|
107
|
+
// health probes) that would hold the event loop open for a while — the work is done, exit crisply.
|
|
108
|
+
process.exit(0);
|
|
109
|
+
}
|
|
110
|
+
/** `fastagent add skill <source> [dir]`: vendor an Agent Skills skill into <dir>/skills/<name>/. */
|
|
111
|
+
export async function runAddSkill(source, dirArg, opts) {
|
|
112
|
+
const target = resolve(dirArg);
|
|
113
|
+
if (!source) {
|
|
114
|
+
// A missing source is a usage error (exit 2), but the guide is worth more than a bare
|
|
115
|
+
// missing-argument line — the common path (writing your own skill) needs no command at all.
|
|
116
|
+
failUsage(`add a skill — two ways:\n` +
|
|
117
|
+
` 1. write your own (vibe): create skills/<name>/SKILL.md with name + description\n` +
|
|
118
|
+
` frontmatter; it's auto-discovered. No command needed — this is the common path.\n` +
|
|
119
|
+
` 2. vendor an existing Agent Skills skill (copied in, git-tracked):\n` +
|
|
120
|
+
` fastagent add skill <source> [dir]\n` +
|
|
121
|
+
` source: a git ref (owner/repo/path, github default), a local path (./x, /abs), or a\n` +
|
|
122
|
+
` bare name found in your global skill dirs (~/.agents/skills, ~/.pi/agent/skills)\n` +
|
|
123
|
+
` --update overwrites an existing skill (re-fetch from source); review with git diff`);
|
|
124
|
+
}
|
|
125
|
+
// Skills are agent surface — vendored into agentDir/skills (config.agentDir, or target when flat).
|
|
126
|
+
const { config: skillConfig } = await loadConfig(target).catch(failStartup);
|
|
127
|
+
const skillHome = resolveAgentDir(target, skillConfig);
|
|
128
|
+
const { name, description, dest, hasScripts, diagnostics, overwritten } = await vendorSkill(skillHome, source, {
|
|
129
|
+
update: opts.update ?? false,
|
|
130
|
+
}).catch(failStartup);
|
|
131
|
+
console.error(`[fastagent] ${overwritten ? "updated" : "vendored"} skill "${name}" → ${dest}/`);
|
|
132
|
+
if (overwritten)
|
|
133
|
+
console.error(` overwrote it — \`git diff ${dest}\` to review, \`git checkout ${dest}\` to revert`);
|
|
134
|
+
if (description)
|
|
135
|
+
console.error(` ${description.length > 100 ? `${description.slice(0, 100)}…` : description}`);
|
|
136
|
+
for (const d of diagnostics)
|
|
137
|
+
console.error(` warn: ${d.message}`);
|
|
138
|
+
if (hasScripts) {
|
|
139
|
+
console.error(` warn: this skill ships scripts/ (executable code that runs in your agent) — review it before deploying`);
|
|
140
|
+
}
|
|
141
|
+
console.error(` next: mention "${name}" in persona.md so the model knows when to use it; then \`fastagent dev\``);
|
|
142
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** `fastagent chat [dir]`: open the SAME assembled agent in pi's interactive TUI. */
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { loadDotEnv } from "../../env.js";
|
|
4
|
+
import { installProxyFetch } from "../../proxy.js";
|
|
5
|
+
import { failStartup } from "../fail.js";
|
|
6
|
+
export async function runChat(dirArg, opts) {
|
|
7
|
+
const dir = resolve(dirArg);
|
|
8
|
+
loadDotEnv(dir);
|
|
9
|
+
installProxyFetch(); // model calls (and the login dialog) must go through the proxy too
|
|
10
|
+
// Run the chat process IN the workspace: pi resolves a session's cwd as `header.cwd ?? process.cwd()`,
|
|
11
|
+
// so aligning process.cwd() with the workspace keeps a cwd-less session on the workspace. `dir` is absolute.
|
|
12
|
+
process.chdir(dir);
|
|
13
|
+
// Lazy-import: chat pulls pi's interactive TUI module graph; headless start/dev never need it.
|
|
14
|
+
const { runPiChat } = await import("../../engines/pi/chat.js");
|
|
15
|
+
await runPiChat(dir, { model: opts.model }).catch(failStartup);
|
|
16
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type DeployHost = "docker" | "fly" | "railway";
|
|
2
|
+
export interface DeployOptions {
|
|
3
|
+
run?: boolean;
|
|
4
|
+
tunnel?: boolean;
|
|
5
|
+
force?: boolean;
|
|
6
|
+
stop?: boolean;
|
|
7
|
+
/** false ⇔ `--no-scale-to-zero`. */
|
|
8
|
+
scaleToZero?: boolean;
|
|
9
|
+
intoLinked?: boolean;
|
|
10
|
+
model?: string;
|
|
11
|
+
authPath?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function runDeploy(host: DeployHost, dirArg: string, opts: DeployOptions): Promise<void>;
|