@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.
Files changed (90) hide show
  1. package/README.md +2 -2
  2. package/dist/channels/feishu/context-buffer.d.ts +46 -0
  3. package/dist/channels/feishu/context-buffer.js +133 -0
  4. package/dist/channels/feishu/crypto.d.ts +4 -2
  5. package/dist/channels/feishu/crypto.js +4 -2
  6. package/dist/channels/feishu/feishu-api.d.ts +4 -4
  7. package/dist/channels/feishu/feishu-api.js +2 -2
  8. package/dist/channels/feishu/feishu.d.ts +11 -1
  9. package/dist/channels/feishu/feishu.js +143 -30
  10. package/dist/channels/feishu/invoke-turn.d.ts +19 -13
  11. package/dist/channels/feishu/invoke-turn.js +70 -19
  12. package/dist/channels/feishu/model.d.ts +97 -0
  13. package/dist/channels/feishu/model.js +9 -0
  14. package/dist/channels/feishu/normalize.d.ts +22 -0
  15. package/dist/channels/feishu/normalize.js +132 -0
  16. package/dist/channels/feishu/owned-threads.d.ts +11 -0
  17. package/dist/channels/feishu/owned-threads.js +47 -0
  18. package/dist/channels/feishu/parse.d.ts +20 -102
  19. package/dist/channels/feishu/parse.js +35 -145
  20. package/dist/channels/feishu/preview.js +2 -2
  21. package/dist/channels/feishu/scaffold/channel.ts +10 -3
  22. package/dist/channels/feishu/seen.js +12 -12
  23. package/dist/channels/lark/scaffold/channel.ts +10 -3
  24. package/dist/cli/commands/add.d.ts +9 -0
  25. package/dist/cli/commands/add.js +142 -0
  26. package/dist/cli/commands/chat.d.ts +3 -0
  27. package/dist/cli/commands/chat.js +16 -0
  28. package/dist/cli/commands/deploy.d.ts +13 -0
  29. package/dist/cli/commands/deploy.js +338 -0
  30. package/dist/cli/commands/dev.d.ts +11 -0
  31. package/dist/cli/commands/dev.js +76 -0
  32. package/dist/cli/commands/fire.d.ts +7 -0
  33. package/dist/cli/commands/fire.js +45 -0
  34. package/dist/cli/commands/info.d.ts +7 -0
  35. package/dist/cli/commands/info.js +108 -0
  36. package/dist/cli/commands/init.d.ts +8 -0
  37. package/dist/cli/commands/init.js +81 -0
  38. package/dist/cli/commands/invoke.d.ts +7 -0
  39. package/dist/cli/commands/invoke.js +29 -0
  40. package/dist/cli/commands/login.d.ts +6 -0
  41. package/dist/cli/commands/login.js +63 -0
  42. package/dist/cli/commands/models.d.ts +1 -0
  43. package/dist/cli/commands/models.js +15 -0
  44. package/dist/cli/commands/schedule.d.ts +12 -0
  45. package/dist/cli/commands/schedule.js +89 -0
  46. package/dist/cli/commands/start.d.ts +10 -0
  47. package/dist/cli/commands/start.js +90 -0
  48. package/dist/cli/commands/tool.d.ts +1 -0
  49. package/dist/cli/commands/tool.js +37 -0
  50. package/dist/cli/fail.d.ts +19 -0
  51. package/dist/cli/fail.js +32 -0
  52. package/dist/cli/kernel.d.ts +89 -0
  53. package/dist/cli/kernel.js +190 -0
  54. package/dist/cli/program.d.ts +11 -0
  55. package/dist/cli/program.js +421 -0
  56. package/dist/cli/serve.d.ts +28 -0
  57. package/dist/cli/serve.js +90 -0
  58. package/dist/cli/shared.d.ts +24 -0
  59. package/dist/cli/shared.js +116 -0
  60. package/dist/cli.js +8 -1329
  61. package/dist/deploy/docker/plan.d.ts +45 -0
  62. package/dist/deploy/docker/plan.js +139 -0
  63. package/dist/deploy/docker/run.d.ts +40 -0
  64. package/dist/deploy/docker/run.js +126 -0
  65. package/dist/deploy/preflight.js +4 -3
  66. package/dist/deploy/runner.d.ts +4 -1
  67. package/dist/deploy/runner.js +1 -0
  68. package/dist/engines/pi/auth.js +160 -46
  69. package/dist/engines/pi/chat.js +77 -4
  70. package/dist/engines/pi/config.d.ts +12 -3
  71. package/dist/engines/pi/config.js +16 -1
  72. package/dist/engines/pi/create.d.ts +14 -5
  73. package/dist/engines/pi/create.js +44 -9
  74. package/dist/engines/pi/harness.d.ts +16 -1
  75. package/dist/engines/pi/harness.js +77 -1
  76. package/dist/engines/pi/invoke.d.ts +1 -1
  77. package/dist/engines/pi/invoke.js +37 -2
  78. package/dist/engines/pi/login.js +1 -1
  79. package/dist/engines/pi/search-tools.d.ts +10 -0
  80. package/dist/engines/pi/search-tools.js +138 -0
  81. package/dist/engines/pi/tool-context.d.ts +28 -0
  82. package/dist/engines/pi/tool-context.js +8 -0
  83. package/dist/engines/pi/tool.d.ts +32 -1
  84. package/dist/engines/pi/tool.js +42 -1
  85. package/dist/engines/pi/workspace.d.ts +4 -1
  86. package/dist/engines/pi/workspace.js +3 -1
  87. package/dist/pi.d.ts +2 -1
  88. package/dist/scaffold/add-channel.js +3 -1
  89. package/dist/scaffold/templates/fastagent.config.mjs +1 -0
  90. package/package.json +7 -4
@@ -6,11 +6,10 @@
6
6
  * half touches the Open API + disk; split from feishu.ts so the factory keeps only wiring and the
7
7
  * per-turn lifecycle.
8
8
  *
9
- * Everything here is PRIMARY input the summoning message's own attachments and the message the user
10
- * explicitly replied to — so any load failure THROWS and the caller sees a `failed` event: the agent
11
- * never runs on inputs the user pointed at but we failed to load. (The telegram channel's second,
12
- * degrade-per-attachment tier is its context BUFFER background material; Feishu grows that tier only
13
- * with the buffer itself, which needs the sensitive all-group-messages scope.)
9
+ * Inputs have two tiers. PRIMARY is the summoning message plus the message it explicitly replied to;
10
+ * any load failure there aborts visibly so the Agent never runs without an input the user pointed at.
11
+ * BUFFERED resources come from earlier un-summoned thread/group discussion and degrade per attachment:
12
+ * one expired background file must not block the current ask or hide its still-readable siblings.
14
13
  */
15
14
  import { SESSION_BUSY_CODE } from "../../agent.js";
16
15
  import { log } from "../../log.js";
@@ -21,16 +20,17 @@ import { codePointPrefix } from "./text.js";
21
20
  const MARKDOWN_INSTRUCTION = "\n\n(Format your reply in standard Markdown — it is rendered in a Feishu/Lark card.)";
22
21
  /**
23
22
  * Resolve a turn's inputs (module header): fetch the reply referent's content, then load every image
24
- * (vision) and file (disk). Throws on any failure primary inputs, no silent drops.
23
+ * (vision) and file (disk). Primary failures throw; buffered resources degrade independently.
25
24
  */
26
25
  async function resolveTurnInputs(t, attachments) {
27
- const images = [...attachments.images];
28
- const files = [...attachments.files];
26
+ const images = [...attachments.primary.images];
27
+ const files = [...attachments.primary.files];
29
28
  let referentBlock = "";
30
- if (attachments.parentId !== undefined) {
31
- const parent = await t.api.getMessage(attachments.parentId);
29
+ if (attachments.primary.parentId !== undefined) {
30
+ const parentId = attachments.primary.parentId;
31
+ const parent = await t.api.getMessage(parentId);
32
32
  if (!parent)
33
- throw new Error(`replied-to message ${attachments.parentId} is not readable`);
33
+ throw new Error(`replied-to message ${parentId} is not readable`);
34
34
  const parsed = parseContent({
35
35
  message_type: parent.msg_type ?? "unknown",
36
36
  content: parent.body?.content ?? "",
@@ -38,33 +38,84 @@ async function resolveTurnInputs(t, attachments) {
38
38
  });
39
39
  // The referent's own resources join the turn as primary inputs, carried by the PARENT message id.
40
40
  for (const key of parsed.imageKeys)
41
- images.push({ msg: attachments.parentId, key });
41
+ images.push({ msg: parentId, key });
42
42
  for (const ref of parsed.fileRefs)
43
- files.push({ msg: attachments.parentId, key: ref.key, name: ref.name });
43
+ files.push({ msg: parentId, key: ref.key, name: ref.name });
44
44
  // getMessage's sender is `{ id, id_type, sender_type }` — a DIFFERENT shape from the event's
45
45
  // sender (`{ sender_id: { open_id } }`), so the label is built here, not via parse.senderLabel.
46
46
  const senderId = parent.sender?.id;
47
47
  const from = senderId ? `user ${senderId}` : undefined;
48
- referentBlock = `\n\n[replied-to message (msg ${attachments.parentId}${from ? `, from ${from}` : ""}): ${codePointPrefix(parsed.text, 560) || "(empty)"}]`;
48
+ referentBlock = `\n\n[replied-to message (msg ${parentId}${from ? `, from ${from}` : ""}): ${codePointPrefix(parsed.text, 560) || "(empty)"}]`;
49
49
  }
50
+ // Primary first and fail-fast: these are resources the current user explicitly pointed at.
50
51
  const imageRefs = [];
51
52
  for (const ref of images)
52
53
  imageRefs.push(await t.api.fetchImage(ref.msg, ref.key));
53
54
  const downloaded = [];
54
55
  for (const ref of files)
55
56
  downloaded.push(await t.api.fetchFile(ref.msg, ref.key, ref.name ?? ref.key, t.chatId, t.filesDir));
56
- const manifest = downloaded.length
57
- ? `\n\n[attached files — read them with your tools:\n${downloaded.map((f) => `- ${f.name} (${f.size} bytes) ${f.path}`).join("\n")}\n]`
57
+ // A replied-to buffered message is now primary. Filter by message-scoped identity so it is not
58
+ // downloaded twice or rendered twice in the manifest.
59
+ const primaryImages = new Set(images.map((ref) => `${ref.msg}\u0000${ref.key}`));
60
+ const primaryFiles = new Set(files.map((ref) => `${ref.msg}\u0000${ref.key}`));
61
+ const bufferedImages = attachments.buffered.images.filter((ref) => !primaryImages.has(`${ref.messageId}\u0000${ref.key}`));
62
+ const bufferedFiles = attachments.buffered.files.filter((ref) => !primaryFiles.has(`${ref.messageId}\u0000${ref.key}`));
63
+ const backgroundImages = [];
64
+ const backgroundFiles = [];
65
+ let lost = 0;
66
+ const imageResults = await Promise.allSettled(bufferedImages.map(async (ref) => ({ ref, image: await t.api.fetchImage(ref.messageId, ref.key) })));
67
+ for (const result of imageResults) {
68
+ if (result.status === "fulfilled")
69
+ backgroundImages.push(result.value);
70
+ else {
71
+ lost++;
72
+ log.warn(`${t.label} could not load an earlier (buffered) image: ${String(result.reason)}`);
73
+ }
74
+ }
75
+ const fileResults = await Promise.allSettled(bufferedFiles.map(async (ref) => ({
76
+ ref,
77
+ file: await t.api.fetchFile(ref.messageId, ref.key, ref.name ?? ref.key, t.chatId, t.filesDir),
78
+ })));
79
+ for (const result of fileResults) {
80
+ if (result.status === "fulfilled")
81
+ backgroundFiles.push(result.value);
82
+ else {
83
+ lost++;
84
+ log.warn(`${t.label} could not load an earlier (buffered) attachment: ${String(result.reason)}`);
85
+ }
86
+ }
87
+ const missing = lost + attachments.buffered.skipped;
88
+ const bufferedNote = missing > 0
89
+ ? `\n[note: ${missing} attachment(s) from the earlier discussion are not loaded (expired, or older than the most recent few)]`
90
+ : "";
91
+ const backgroundImageManifest = backgroundImages.length
92
+ ? `\n\n[background vision images from earlier discussion — appended after ${imageRefs.length} primary image(s):\n${backgroundImages
93
+ .map(({ ref }, index) => `- vision image ${imageRefs.length + index + 1}: from ${ref.from}, msg ${ref.messageId}`)
94
+ .join("\n")}\n]`
95
+ : "";
96
+ const allFiles = [
97
+ ...downloaded,
98
+ ...backgroundFiles.map(({ file, ref }) => ({
99
+ ...file,
100
+ name: `${file.name} (from ${ref.from}, msg ${ref.messageId}, earlier discussion)`,
101
+ })),
102
+ ];
103
+ const manifest = allFiles.length
104
+ ? `\n\n[attached files — read them with your tools:\n${allFiles.map((file) => `- ${file.name} (${file.size} bytes) → ${file.path}`).join("\n")}\n]`
58
105
  : "";
59
- return { images: imageRefs.length ? imageRefs : undefined, promptSuffix: `${referentBlock}${manifest}` };
106
+ const allImages = [...imageRefs, ...backgroundImages.map(({ image }) => image)];
107
+ return {
108
+ images: allImages.length ? allImages : undefined,
109
+ promptSuffix: `${referentBlock}${bufferedNote}${backgroundImageManifest}${manifest}`,
110
+ };
60
111
  }
61
112
  // Each retry is a lease-check-level reject (tryAcquire runs before harness assembly) — waiting is nearly
62
113
  // free, and the loop exits within one delay of the holder finishing. The cap is sized to outlast a real
63
114
  // tool-using wake turn (minutes); a holder that runs longer still surfaces the busy error to the user.
64
115
  const DEFAULT_BUSY_RETRY = { delayMs: 5_000, maxWaitMs: 600_000 };
65
116
  /**
66
- * Run one turn: resolve its inputs, then stream agent.invoke. An input failure surfaces as a `failed`
67
- * event (never a silent drop). `onCompleted` (if given) fires on the turn's `completed` event — the
117
+ * Run one turn: resolve its inputs, then stream agent.invoke. A primary-input failure surfaces as a
118
+ * `failed` event (never a silent drop). `onCompleted` (if given) fires on the `completed` event — the
68
119
  * durable-commit point; the caller uses it to remove the turn intent (turn-store L1) at the earliest
69
120
  * moment the turn provably lives in the session.
70
121
  *
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Canonical Feishu/Lark protocol and normalized-message models.
3
+ *
4
+ * Raw wire types stay at the webhook/policy boundary: authors' existing `route(event)` callbacks keep
5
+ * receiving the platform event unchanged. The narrow normalized shape below owns only what currently
6
+ * benefits from normalization: conversation-place identity, decoded content, mention presence, and
7
+ * message-scoped resource locators. Do not pre-model unused transport metadata for a future ingress.
8
+ */
9
+ /** The v2 event envelope header. Feishu and Lark use the same wire shape. */
10
+ export interface FeishuEventHeader {
11
+ event_id?: string;
12
+ event_type?: string;
13
+ create_time?: string;
14
+ token?: string;
15
+ app_id?: string;
16
+ tenant_key?: string;
17
+ [k: string]: unknown;
18
+ }
19
+ /** One entry of a message's `mentions` array. `key` is the placeholder carried in text content. */
20
+ export interface FeishuMention {
21
+ key: string;
22
+ id?: {
23
+ open_id?: string;
24
+ user_id?: string;
25
+ union_id?: string;
26
+ };
27
+ name?: string;
28
+ mentioned_type?: string;
29
+ tenant_key?: string;
30
+ [k: string]: unknown;
31
+ }
32
+ /** A received `im.message.receive_v1` message. `content` is JSON encoded according to `message_type`. */
33
+ export interface FeishuMessage {
34
+ message_id: string;
35
+ root_id?: string;
36
+ parent_id?: string;
37
+ create_time?: string;
38
+ update_time?: string;
39
+ chat_id: string;
40
+ thread_id?: string;
41
+ chat_type: string;
42
+ message_type: string;
43
+ content: string;
44
+ mentions?: FeishuMention[];
45
+ user_agent?: string;
46
+ lark_agent_context?: {
47
+ active_chat_id?: string;
48
+ [k: string]: unknown;
49
+ };
50
+ [k: string]: unknown;
51
+ }
52
+ export interface FeishuSender {
53
+ sender_id?: {
54
+ open_id?: string;
55
+ user_id?: string;
56
+ union_id?: string;
57
+ };
58
+ sender_type?: string;
59
+ tenant_key?: string;
60
+ [k: string]: unknown;
61
+ }
62
+ /** The `event` member of a v2 `im.message.receive_v1` envelope. */
63
+ export interface FeishuMessageEvent {
64
+ sender?: FeishuSender;
65
+ message?: FeishuMessage;
66
+ [k: string]: unknown;
67
+ }
68
+ /** Existing public route result: act with these overrides, or return null to ignore the message. */
69
+ export interface FeishuRoute {
70
+ session?: string;
71
+ chatId?: string;
72
+ text?: string;
73
+ }
74
+ export type FeishuResourceKind = "image" | "file" | "audio" | "video";
75
+ /**
76
+ * A resource locator. User-sent resources are scoped by BOTH their carrying message id and resource
77
+ * key; a bare file_key/image_key is insufficient for the message-resource download API.
78
+ */
79
+ export interface FeishuResourceRef {
80
+ kind: FeishuResourceKind;
81
+ messageId: string;
82
+ key: string;
83
+ name?: string;
84
+ }
85
+ /** Narrow internal representation shared by the Feishu reference and Lark compatibility clouds. */
86
+ export interface NormalizedFeishuMessage {
87
+ conversation: {
88
+ chatId: string;
89
+ threadId?: string;
90
+ rootId?: string;
91
+ };
92
+ content: {
93
+ text: string;
94
+ hasMentions: boolean;
95
+ resources: FeishuResourceRef[];
96
+ };
97
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Canonical Feishu/Lark protocol and normalized-message models.
3
+ *
4
+ * Raw wire types stay at the webhook/policy boundary: authors' existing `route(event)` callbacks keep
5
+ * receiving the platform event unchanged. The narrow normalized shape below owns only what currently
6
+ * benefits from normalization: conversation-place identity, decoded content, mention presence, and
7
+ * message-scoped resource locators. Do not pre-model unused transport metadata for a future ingress.
8
+ */
9
+ export {};
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Pure Feishu/Lark message normalization. The platform sends a stable event shell but encodes the
3
+ * actual message body as a JSON string selected by `message_type`; this module is the one decoder and
4
+ * converts resource keys into message-scoped locators before the turn engine sees them.
5
+ */
6
+ import type { FeishuMessage, FeishuMessageEvent, FeishuResourceKind, NormalizedFeishuMessage } from "./model.ts";
7
+ export interface DecodedFeishuResource {
8
+ kind: FeishuResourceKind;
9
+ key: string;
10
+ name?: string;
11
+ }
12
+ export interface DecodedFeishuContent {
13
+ text: string;
14
+ resources: DecodedFeishuResource[];
15
+ }
16
+ /**
17
+ * Decode one JSON-string message body. Unknown or malformed external input degrades to a visible
18
+ * marker rather than throwing, preserving the channel's existing fail-visible prompt behavior.
19
+ */
20
+ export declare function decodeFeishuContent(message: Pick<FeishuMessage, "message_type" | "content" | "mentions">): DecodedFeishuContent;
21
+ /** Normalize one verified message event. Returns null only when its required identity is absent. */
22
+ export declare function normalizeFeishuMessage(event: FeishuMessageEvent): NormalizedFeishuMessage | null;
@@ -0,0 +1,132 @@
1
+ function nonEmptyString(value) {
2
+ return typeof value === "string" && value !== "" ? value : undefined;
3
+ }
4
+ /** Restore text-message mention placeholders to readable names. */
5
+ function restoreMentions(text, mentions) {
6
+ let out = text;
7
+ for (const mention of mentions ?? []) {
8
+ if (!mention.key)
9
+ continue;
10
+ out = out.split(mention.key).join(`@${mention.name ?? "user"}`);
11
+ }
12
+ return out;
13
+ }
14
+ /**
15
+ * Decode one JSON-string message body. Unknown or malformed external input degrades to a visible
16
+ * marker rather than throwing, preserving the channel's existing fail-visible prompt behavior.
17
+ */
18
+ export function decodeFeishuContent(message) {
19
+ const rawType = typeof message.message_type === "string" ? message.message_type : "unknown";
20
+ let content;
21
+ try {
22
+ content = JSON.parse(message.content);
23
+ if (typeof content !== "object" || content === null)
24
+ throw new Error("not an object");
25
+ }
26
+ catch {
27
+ return { text: `[unreadable ${rawType} message]`, resources: [] };
28
+ }
29
+ const resources = [];
30
+ switch (rawType) {
31
+ case "text":
32
+ return { text: restoreMentions(nonEmptyString(content.text) ?? "", message.mentions), resources };
33
+ case "post": {
34
+ const lines = [];
35
+ const title = nonEmptyString(content.title);
36
+ if (title)
37
+ lines.push(title);
38
+ const paragraphs = Array.isArray(content.content) ? content.content : [];
39
+ for (const paragraph of paragraphs) {
40
+ if (!Array.isArray(paragraph))
41
+ continue;
42
+ const parts = [];
43
+ for (const node of paragraph) {
44
+ if (typeof node !== "object" || node === null)
45
+ continue;
46
+ if (node.tag === "at") {
47
+ parts.push(`@${nonEmptyString(node.user_name) ?? nonEmptyString(node.user_id) ?? "user"}`);
48
+ }
49
+ else if (node.tag === "a") {
50
+ parts.push(node.href ? `${nonEmptyString(node.text) ?? node.href} (${node.href})` : (node.text ?? ""));
51
+ }
52
+ else if (node.tag === "img") {
53
+ const key = nonEmptyString(node.image_key);
54
+ if (key)
55
+ resources.push({ kind: "image", key });
56
+ parts.push("[image]");
57
+ }
58
+ else if (node.tag === "media") {
59
+ const key = nonEmptyString(node.file_key);
60
+ if (key)
61
+ resources.push({ kind: "video", key, name: nonEmptyString(node.file_name) });
62
+ parts.push("[video]");
63
+ }
64
+ else if (node.tag === "code_block") {
65
+ parts.push(`\n\`\`\`${nonEmptyString(node.language)?.toLowerCase() ?? ""}\n${nonEmptyString(node.text) ?? ""}\n\`\`\`\n`);
66
+ }
67
+ else if (nonEmptyString(node.text)) {
68
+ parts.push(node.text);
69
+ }
70
+ }
71
+ const line = parts.join("").trim();
72
+ if (line)
73
+ lines.push(line);
74
+ }
75
+ return { text: lines.join("\n"), resources };
76
+ }
77
+ case "image": {
78
+ const key = nonEmptyString(content.image_key);
79
+ if (key)
80
+ resources.push({ kind: "image", key });
81
+ return { text: "[image]", resources };
82
+ }
83
+ case "file": {
84
+ const key = nonEmptyString(content.file_key);
85
+ const name = nonEmptyString(content.file_name);
86
+ if (key)
87
+ resources.push({ kind: "file", key, name });
88
+ return { text: `[file: ${name ?? "file"}]`, resources };
89
+ }
90
+ case "audio": {
91
+ const key = nonEmptyString(content.file_key);
92
+ if (key)
93
+ resources.push({ kind: "audio", key, name: "voice-message" });
94
+ return { text: "[voice message]", resources };
95
+ }
96
+ case "media": {
97
+ const key = nonEmptyString(content.file_key);
98
+ const name = nonEmptyString(content.file_name);
99
+ if (key)
100
+ resources.push({ kind: "video", key, name });
101
+ return { text: `[video: ${name ?? "video"}]`, resources };
102
+ }
103
+ case "location": {
104
+ const name = nonEmptyString(content.name);
105
+ return {
106
+ text: `[location: ${name ? `${name} — ` : ""}${nonEmptyString(content.latitude) ?? "?"},${nonEmptyString(content.longitude) ?? "?"}]`,
107
+ resources,
108
+ };
109
+ }
110
+ default:
111
+ return { text: `[${rawType} message]`, resources };
112
+ }
113
+ }
114
+ /** Normalize one verified message event. Returns null only when its required identity is absent. */
115
+ export function normalizeFeishuMessage(event) {
116
+ const message = event.message;
117
+ if (!message || typeof message.message_id !== "string" || typeof message.chat_id !== "string")
118
+ return null;
119
+ const decoded = decodeFeishuContent(message);
120
+ return {
121
+ conversation: {
122
+ chatId: message.chat_id,
123
+ threadId: message.thread_id,
124
+ rootId: message.root_id,
125
+ },
126
+ content: {
127
+ text: decoded.text,
128
+ hasMentions: (message.mentions?.length ?? 0) > 0,
129
+ resources: decoded.resources.map((resource) => ({ ...resource, messageId: message.message_id })),
130
+ },
131
+ };
132
+ }
@@ -0,0 +1,11 @@
1
+ export interface OwnedFeishuThread {
2
+ rootId: string;
3
+ chatId: string;
4
+ createdAt: number;
5
+ }
6
+ export interface OwnedFeishuThreads {
7
+ has(chatId: string, rootId: string): boolean;
8
+ /** Idempotent, synchronous, pre-ACK persistence. A failed write throws so the platform redelivers. */
9
+ add(chatId: string, rootId: string): void;
10
+ }
11
+ export declare function createOwnedFeishuThreads(path: string, label: string, now?: () => number): OwnedFeishuThreads;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Durable ownership index for group threads the channel created from a top-level summon. Feishu/Lark
3
+ * deliver an ordinary thread continuation as `thread_id + root_id`; the root is the original user
4
+ * message the channel reply-threaded under. Keeping that root across restarts lets the default route
5
+ * admit unmentioned continuations only inside Agent-managed threads, never across the whole group.
6
+ */
7
+ import { log } from "../../log.js";
8
+ import { loadStateFile, saveStateFile } from "../state.js";
9
+ function isRecord(value) {
10
+ const record = value;
11
+ return (typeof record?.rootId === "string" &&
12
+ typeof record.chatId === "string" &&
13
+ typeof record.createdAt === "number" &&
14
+ Number.isFinite(record.createdAt));
15
+ }
16
+ export function createOwnedFeishuThreads(path, label, now = Date.now) {
17
+ const raw = loadStateFile(path);
18
+ let records = new Map();
19
+ if (raw !== undefined) {
20
+ if (typeof raw === "object" &&
21
+ raw !== null &&
22
+ !Array.isArray(raw) &&
23
+ Object.entries(raw).every(([rootId, record]) => isRecord(record) && record.rootId === rootId)) {
24
+ records = new Map(Object.entries(raw));
25
+ }
26
+ else {
27
+ log.warn(`${label} unexpected shape in ${path} — starting with no managed group threads`);
28
+ }
29
+ }
30
+ return {
31
+ has(chatId, rootId) {
32
+ const record = records.get(rootId);
33
+ return record?.chatId === chatId;
34
+ },
35
+ add(chatId, rootId) {
36
+ const existing = records.get(rootId);
37
+ if (existing?.chatId === chatId)
38
+ return;
39
+ const next = new Map(records);
40
+ next.set(rootId, { rootId, chatId, createdAt: now() });
41
+ // Persist the staged copy first: an IO failure must not leave memory claiming durability the file
42
+ // does not have. The webhook remains un-ACKed and the platform can redeliver after recovery.
43
+ saveStateFile(path, Object.fromEntries(next));
44
+ records = next;
45
+ },
46
+ };
47
+ }
@@ -1,124 +1,42 @@
1
1
  /**
2
- * Canonical Feishu protocol parsing — PURE: event field extraction, message-content decoding, prompt
3
- * envelope, and summon/route policy. Lark compatibility events reuse these wire shapes. Feishu
4
- * messages carry their payload as a JSON-ENCODED STRING in `content`, shaped by `message_type`
5
- * (text / post / image / file / audio / media / …). {@link parseContent} is the single decoder: it
6
- * yields the readable text (mention placeholders restored), the vision image keys, and the downloadable
7
- * file refs — the envelope, the turn record, and the parent-message resolution all read through it.
2
+ * Canonical Feishu protocol policy and prompt-envelope helpers — PURE. Raw event types remain public
3
+ * for the existing `route(event)` authoring surface; JSON-string content decoding is delegated to the
4
+ * normalized-message boundary in normalize.ts so the turn engine and compatibility wrappers share one
5
+ * decoder.
8
6
  */
9
7
  import type { FeishuCloudKind } from "./cloud.ts";
10
- /** One entry of a message's `mentions` array: the platform's parsed @-mention (the `key` is the
11
- * placeholder in the text content, e.g. `@_user_1`). */
12
- export interface FeishuMention {
13
- key: string;
14
- id?: {
15
- open_id?: string;
16
- user_id?: string;
17
- union_id?: string;
18
- };
19
- name?: string;
20
- [k: string]: unknown;
21
- }
22
- /** A received message (the `im.message.receive_v1` event's `message`, common subset; `[k]` keeps the
23
- * rest reachable without a types dependency). */
24
- export interface FeishuMessage {
25
- message_id: string;
26
- /** The replied-to message, when this message is a reply. Content is NOT included — the channel
27
- * fetches it via the API (the IO half, invoke-turn.ts). */
28
- parent_id?: string;
29
- root_id?: string;
30
- chat_id: string;
31
- /** Present in topic groups — reply into the same topic to stay threaded. */
32
- thread_id?: string;
33
- chat_type: string;
34
- message_type: string;
35
- /** JSON-encoded string, shaped by message_type — decode with {@link parseContent}. */
36
- content: string;
37
- mentions?: FeishuMention[];
38
- [k: string]: unknown;
39
- }
40
- export interface FeishuSender {
41
- sender_id?: {
42
- open_id?: string;
43
- user_id?: string;
44
- union_id?: string;
45
- };
46
- /** "user" for humans; apps/bots have other values — the default route ignores those (anti-loop). */
47
- sender_type?: string;
48
- [k: string]: unknown;
49
- }
50
- /** The `im.message.receive_v1` event body (`event` of the v2.0 envelope). */
51
- export interface FeishuMessageEvent {
52
- sender?: FeishuSender;
53
- message?: FeishuMessage;
54
- [k: string]: unknown;
55
- }
56
- /** What `route` returns: act with these (every field optional — omitted ones default from the message),
57
- * or null to ignore. */
58
- export interface FeishuRoute {
59
- /** Conversation identity (default: `chat` or `chat:thread`). */
60
- session?: string;
61
- /** Reply target chat (default: the message's chat). */
62
- chatId?: string;
63
- /** Base prompt (default: {@link feishuEnvelope}); the channel still appends attachments. */
64
- text?: string;
65
- }
66
- /** A downloadable attachment reference: the resource key inside its carrying message (the resource API
67
- * addresses a file by message_id + key). */
8
+ import type { FeishuMention, FeishuMessage, FeishuMessageEvent, FeishuRoute, FeishuSender } from "./model.ts";
9
+ export type { FeishuMention, FeishuMessage, FeishuMessageEvent, FeishuRoute, FeishuSender };
10
+ /** Legacy compatibility shape returned by {@link parseContent}. New internal code consumes normalized
11
+ * resource refs, which retain resource kind + carrying message id. */
68
12
  export interface FeishuAttachmentRef {
69
13
  key: string;
70
14
  name?: string;
71
15
  }
72
- /** A message's decoded payload — see the module header. */
73
16
  export interface ParsedFeishuContent {
74
- /** Readable text: the text/post body with mention placeholders restored to `@Name`; marker lines
75
- * (`[image]`, `[file: …]`) for non-text payloads, so a media-only message is never blank. */
76
17
  text: string;
77
- /** Vision images: `image_key`s from an image message or a post's inline images. */
78
18
  imageKeys: string[];
79
- /** Files to download to disk (file / audio / media). */
80
19
  fileRefs: FeishuAttachmentRef[];
81
20
  }
82
21
  /**
83
- * Decode a message's `content` by its `message_type` — the single decoder (module header). Unknown or
84
- * malformed content degrades to a visible marker (`[sticker message]`), never a throw: the payload is
85
- * external input, and a message the agent cannot read should still say WHAT it couldn't read.
22
+ * Compatibility decoder for existing helpers/tests and parent-message resolution. The canonical
23
+ * decoder now emits typed resources; this wrapper projects them onto the historical parallel arrays.
86
24
  */
87
- export declare function parseContent(m: Pick<FeishuMessage, "message_type" | "content" | "mentions">): ParsedFeishuContent;
88
- /** A stable sender label for attribution. The receive event carries only ids (a display name needs a
89
- * contacts-API scope), so the label is the open_id — stable across turns, which is what a shared
90
- * multi-user session needs to tell participants apart. */
25
+ export declare function parseContent(message: Pick<FeishuMessage, "message_type" | "content" | "mentions">): ParsedFeishuContent;
26
+ /** A stable sender label for attribution. Display names require an additional contacts permission. */
91
27
  export declare function senderLabel(sender: FeishuSender | undefined): string | undefined;
92
- /** The place a message lives (chat, or chat:topic in a topic group) — the default session key. */
93
- export declare function placeKey(m: Pick<FeishuMessage, "chat_id" | "thread_id">): string;
94
- /**
95
- * The default base prompt: a context envelope (chat/thread/sender + a group note + a reply marker),
96
- * then the message's decoded body. The sender is named on every message and a group chat is flagged —
97
- * in a shared multi-user session that is how the model tells participants apart and knows it is not a
98
- * 1:1. A reply carries only `[in reply to msg …]` here: the referent's CONTENT is not in the event, so
99
- * the channel fetches and appends it in the IO half (invoke-turn.ts), keeping this layer pure. Exported
100
- * so a custom Feishu `route` can reuse it, e.g. `text: `${feishuEnvelope(event)}\n\n[extra]``. The
101
- * internal compatibility seam binds the same shape to `[lark: …]`; each kind's send tool reads the
102
- * chat id from its own branded line.
103
- */
28
+ /** The place a message lives (chat, or chat:topic in a topic group) — the legacy default session key. */
29
+ export declare function placeKey(message: Pick<FeishuMessage, "chat_id" | "thread_id">): string;
30
+ /** The canonical Feishu-branded prompt envelope. */
104
31
  export declare function feishuEnvelope(event: FeishuMessageEvent): string;
105
32
  /** Internal compatibility seam: bind the canonical envelope shape to one cloud's branded tag. */
106
33
  export declare function cloudEnvelope(event: FeishuMessageEvent, tag: FeishuCloudKind): string;
34
+ /** Whether the parsed mention list contains this bot's app-scoped open_id. */
35
+ export declare function mentionsBot(message: Pick<FeishuMessage, "mentions">, botOpenId: string | undefined): boolean;
107
36
  /**
108
- * Whether the message @mentions the bot read from the `mentions` array the platform already parsed
109
- * (never a regex over the text: a pasted `@bot` in a code block is not a mention entry), matched on the
110
- * bot's open_id (stable identity; names are mutable). No id fail closed (false): answering "is this
111
- * mention me?" with "I don't know who I am, so yes" would mis-summon in every multi-bot group.
112
- */
113
- export declare function mentionsBot(m: Pick<FeishuMessage, "mentions">, botOpenId: string | undefined): boolean;
114
- /**
115
- * The default routing policy (used when `route` is omitted; exported so a custom route can reuse it):
116
- * answer humans only (a non-`user` sender is another bot/app — two bots answering each other loop
117
- * forever), p2p chats always, a group only on an @mention of THIS bot (matched by open_id, which
118
- * feishuChannel resolves via bot/v3/info). NOTE the platform side of the same coin: with the default
119
- * `im:message.group_at_msg` scope, un-mentioned group messages are never even delivered — receiving
120
- * everything needs the sensitive `im:message.group_msg` scope. Returns `{}` (act; the channel fills
121
- * session/target/prompt from the message) or `null` (ignore).
37
+ * Default EXPLICIT-summon policy: ignore non-user senders, always answer p2p, and answer groups only
38
+ * when THIS bot is structurally mentioned. No bot identity means group routing fails closed. The
39
+ * stateful channel wiring may additionally admit unmentioned continuations from its managed-root index.
122
40
  */
123
41
  export declare function defaultFeishuRoute(event: FeishuMessageEvent, options?: {
124
42
  botOpenId?: string;