@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
package/README.md CHANGED
@@ -39,7 +39,7 @@ FastAgent is the missing bridge from local agent directory to live service.
39
39
  - **Channels.** Serve the same agent as a GitHub PR reviewer, a Telegram bot, an HTTP/SSE endpoint, or your own adapter — verified webhooks, streaming replies, group-aware.
40
40
  - **Models, tools & skills.** Any model provider (OpenAI, Anthropic, Google, …) via OAuth or API key; typed tools discovered from `tools/` (the filename is the name, Zod-validated); Agent Skills loaded on demand. Built on the open-source [pi](https://github.com/earendil-works/pi) harness.
41
41
  - **App embedding — your stack, we plug in.** Mount the agent in your Next / Astro / Hono / Bun / Node route with one handler, or call `invoke` like any function from your own code — your auth, your database, your infra. FastAgent composes with your app, never owns it.
42
- - **Deploy anywhere.** No build step — the directory is the deployable unit. `fastagent deploy fly|railway` generates the deploy config + a runbook (`--run` drives it to completion); idle agents scale to zero and resume in hundreds of milliseconds on the next webhook. The generated container runs on any Docker host.
42
+ - **Deploy anywhere.** No application build step — the directory is the deployable unit. `fastagent deploy docker|fly|railway` generates the container + target config and a runbook (`--run` drives it to completion). Local Docker gets user-owned Compose + durable state; optional `--tunnel` adds an ephemeral Quick Tunnel service for webhook channels. Durable ingress remains yours.
43
43
 
44
44
  ## Design philosophy
45
45
 
@@ -198,7 +198,7 @@ The neutral contract leaves room for capabilities that are not complete product
198
198
  - **Sandboxed execution** — `ExecutionEnv` is an assembly seam, but the pi coding tools and project-context loader are still local; a complete sandbox adapter is future work.
199
199
  - **Observability export** — leveled logs and per-turn traces exist today; an OpenTelemetry exporter does not.
200
200
  - **More harness bindings and channels** — pi is the built-in harness; another harness can implement the Agent contract, and community channels can use the channel kit.
201
- - **More deploy targets** — Fly and Railway ship today; the generated container is the portable path for other hosts.
201
+ - **More deploy targets** — local Docker, Fly, and Railway ship today; the generated container is the portable path for other hosts.
202
202
 
203
203
  See [Contributing](https://github.com/fastagent-sh/fastagent/blob/main/CONTRIBUTING.md) if one of these is the problem you want to work on.
204
204
 
@@ -0,0 +1,46 @@
1
+ import type { NormalizedFeishuMessage } from "./model.ts";
2
+ export interface FeishuBufferedResource {
3
+ messageId: string;
4
+ key: string;
5
+ name?: string;
6
+ }
7
+ export interface FeishuBufferEntry {
8
+ sender: string;
9
+ body: string;
10
+ messageId: string;
11
+ replyTo?: string;
12
+ files?: FeishuBufferedResource[];
13
+ images?: FeishuBufferedResource[];
14
+ }
15
+ /** A background resource carried into a later turn, with attribution for its prompt manifest. */
16
+ export interface FeishuBufferedRef extends FeishuBufferedResource {
17
+ from: string;
18
+ }
19
+ /** Main-chat discussion stays in the chat bucket; a topic uses its stable root id (thread id fallback). */
20
+ export declare function feishuBufferPlaceKey(conversation: Pick<NormalizedFeishuMessage["conversation"], "chatId" | "rootId" | "threadId">): string;
21
+ /** One-line, bounded background text. Resource-only messages already carry a visible decoder marker. */
22
+ export declare function feishuBufferText(text: string): string;
23
+ /**
24
+ * Select the most recent background resources, excluding resources already primary on this turn. A
25
+ * resource is message-scoped in Feishu/Lark, so identity is `message_id + key`, never the bare key.
26
+ */
27
+ export declare function collectFeishuBufferedAttachments(consumed: FeishuBufferEntry[], primary: {
28
+ files: FeishuBufferedResource[];
29
+ images: FeishuBufferedResource[];
30
+ }): {
31
+ files: FeishuBufferedRef[];
32
+ images: FeishuBufferedRef[];
33
+ skipped: number;
34
+ };
35
+ export interface FeishuContextBuffer {
36
+ /** Persist before webhook ACK. A failed write throws and rolls memory back for safe redelivery. */
37
+ push(placeKey: string, entry: FeishuBufferEntry): void;
38
+ /** Render and snapshot without clearing. */
39
+ peek(placeKey: string): {
40
+ text: string;
41
+ consumed: FeishuBufferEntry[];
42
+ };
43
+ /** Remove only the consumed snapshot after `completed`; a post-ACK write failure is logged. */
44
+ commit(placeKey: string, consumed: FeishuBufferEntry[]): void;
45
+ }
46
+ export declare function createFeishuContextBuffer(path: string, label: string): FeishuContextBuffer;
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Durable context buffer for human group messages that do NOT currently summon the Feishu/Lark Agent.
3
+ * Entries are bucketed by conversation place (main chat, or one concrete thread root) and folded into
4
+ * the next answered turn in that place. The consume protocol mirrors Telegram: peek without clearing,
5
+ * then commit exactly that snapshot only after the Agent emits `completed`.
6
+ */
7
+ import { log } from "../../log.js";
8
+ import { loadStateFile, saveStateFile } from "../state.js";
9
+ const BUFFER_MAX_CHARS = 4000;
10
+ const BUFFER_ATTACH_MAX = 3;
11
+ function bufferLine(entry) {
12
+ const meta = [`msg ${entry.messageId}`, entry.replyTo ? `reply to msg ${entry.replyTo}` : undefined]
13
+ .filter(Boolean)
14
+ .join(", ");
15
+ return `${entry.sender} (${meta}): ${entry.body}`;
16
+ }
17
+ /** Main-chat discussion stays in the chat bucket; a topic uses its stable root id (thread id fallback). */
18
+ export function feishuBufferPlaceKey(conversation) {
19
+ const topic = conversation.rootId ?? conversation.threadId;
20
+ return topic ? `${conversation.chatId}:root:${topic}` : conversation.chatId;
21
+ }
22
+ /** One-line, bounded background text. Resource-only messages already carry a visible decoder marker. */
23
+ export function feishuBufferText(text) {
24
+ return text.replace(/\s+/g, " ").trim().slice(0, 280);
25
+ }
26
+ function resourceIdentity(resource) {
27
+ return `${resource.messageId}\u0000${resource.key}`;
28
+ }
29
+ /**
30
+ * Select the most recent background resources, excluding resources already primary on this turn. A
31
+ * resource is message-scoped in Feishu/Lark, so identity is `message_id + key`, never the bare key.
32
+ */
33
+ export function collectFeishuBufferedAttachments(consumed, primary) {
34
+ const refs = (pick, primaryRefs) => {
35
+ const excluded = new Set(primaryRefs.map(resourceIdentity));
36
+ const seen = new Set();
37
+ const out = [];
38
+ for (const entry of consumed) {
39
+ for (const resource of pick(entry) ?? []) {
40
+ const identity = resourceIdentity(resource);
41
+ if (excluded.has(identity) || seen.has(identity))
42
+ continue;
43
+ seen.add(identity);
44
+ out.push({ ...resource, from: entry.sender });
45
+ }
46
+ }
47
+ return out;
48
+ };
49
+ const files = refs((entry) => entry.files, primary.files);
50
+ const images = refs((entry) => entry.images, primary.images);
51
+ return {
52
+ files: files.slice(-BUFFER_ATTACH_MAX),
53
+ images: images.slice(-BUFFER_ATTACH_MAX),
54
+ skipped: Math.max(0, files.length - BUFFER_ATTACH_MAX) + Math.max(0, images.length - BUFFER_ATTACH_MAX),
55
+ };
56
+ }
57
+ function isResource(value) {
58
+ const resource = value;
59
+ return (typeof resource?.messageId === "string" &&
60
+ typeof resource.key === "string" &&
61
+ (resource.name === undefined || typeof resource.name === "string"));
62
+ }
63
+ function isEntry(value) {
64
+ const entry = value;
65
+ const resources = (candidate) => candidate === undefined || (Array.isArray(candidate) && candidate.every(isResource));
66
+ return (typeof entry?.sender === "string" &&
67
+ typeof entry.body === "string" &&
68
+ typeof entry.messageId === "string" &&
69
+ (entry.replyTo === undefined || typeof entry.replyTo === "string") &&
70
+ resources(entry.files) &&
71
+ resources(entry.images));
72
+ }
73
+ export function createFeishuContextBuffer(path, label) {
74
+ const load = () => {
75
+ const raw = loadStateFile(path);
76
+ if (raw === undefined)
77
+ return new Map();
78
+ if (typeof raw === "object" &&
79
+ raw !== null &&
80
+ !Array.isArray(raw) &&
81
+ Object.values(raw).every((entries) => Array.isArray(entries) && entries.every(isEntry))) {
82
+ return new Map(Object.entries(raw));
83
+ }
84
+ log.warn(`${label} unexpected shape in ${path} — starting with an empty context buffer`);
85
+ return new Map();
86
+ };
87
+ const buffers = load();
88
+ const persist = () => saveStateFile(path, Object.fromEntries(buffers));
89
+ return {
90
+ push(placeKey, entry) {
91
+ const previous = buffers.get(placeKey);
92
+ const entries = previous ? [...previous] : [];
93
+ entries.push(entry);
94
+ let total = entries.reduce((sum, candidate) => sum + bufferLine(candidate).length + 1, 0);
95
+ while (entries.length > 1 && total > BUFFER_MAX_CHARS) {
96
+ const dropped = entries.shift();
97
+ if (dropped)
98
+ total -= bufferLine(dropped).length + 1;
99
+ }
100
+ buffers.set(placeKey, entries);
101
+ try {
102
+ persist();
103
+ }
104
+ catch (error) {
105
+ if (previous)
106
+ buffers.set(placeKey, previous);
107
+ else
108
+ buffers.delete(placeKey);
109
+ throw error;
110
+ }
111
+ },
112
+ peek(placeKey) {
113
+ const entries = buffers.get(placeKey) ?? [];
114
+ return { text: entries.map(bufferLine).join("\n"), consumed: [...entries] };
115
+ },
116
+ commit(placeKey, consumed) {
117
+ const entries = buffers.get(placeKey);
118
+ if (!entries)
119
+ return;
120
+ const remaining = entries.filter((entry) => !consumed.includes(entry));
121
+ if (remaining.length === 0)
122
+ buffers.delete(placeKey);
123
+ else
124
+ buffers.set(placeKey, remaining);
125
+ try {
126
+ persist();
127
+ }
128
+ catch (error) {
129
+ log.error(`${label} context-buffer write failed post-ACK (a restart may re-fold answered discussion): ${String(error)}`);
130
+ }
131
+ },
132
+ };
133
+ }
@@ -1,7 +1,9 @@
1
1
  /** Constant-time string equality (padded to equal length first — timingSafeEqual demands it). */
2
2
  export declare function timingSafeEqualStr(a: string, b: string): boolean;
3
- /** Decrypt an `{"encrypt": …}` event payload to its plaintext JSON string. Throws on malformed input
4
- * or a wrong key (bad padding) the caller turns that into a 4xx, never a silent drop. */
3
+ /** Decrypt an `{"encrypt": …}` event payload to its plaintext JSON string. Throws on malformed
4
+ * input or invalid padding. AES-CBC is not authenticated, so a wrong key is not mathematically
5
+ * guaranteed to fail padding; the caller verifies signed events before decrypting and JSON-parses every
6
+ * plaintext envelope, turning wrong-key garbage into a 4xx rather than a silent drop. */
5
7
  export declare function decryptEvent(encryptKey: string, encryptB64: string): string;
6
8
  /** The expected `X-Lark-Signature` for a request: sha256(timestamp + nonce + encryptKey + rawBody) hex. */
7
9
  export declare function eventSignature(encryptKey: string, timestamp: string, nonce: string, rawBody: string): string;
@@ -21,8 +21,10 @@ export function timingSafeEqualStr(a, b) {
21
21
  const bb = Buffer.from(b);
22
22
  return ab.length === bb.length && timingSafeEqual(ab, bb);
23
23
  }
24
- /** Decrypt an `{"encrypt": …}` event payload to its plaintext JSON string. Throws on malformed input
25
- * or a wrong key (bad padding) the caller turns that into a 4xx, never a silent drop. */
24
+ /** Decrypt an `{"encrypt": …}` event payload to its plaintext JSON string. Throws on malformed
25
+ * input or invalid padding. AES-CBC is not authenticated, so a wrong key is not mathematically
26
+ * guaranteed to fail padding; the caller verifies signed events before decrypting and JSON-parses every
27
+ * plaintext envelope, turning wrong-key garbage into a 4xx rather than a silent drop. */
26
28
  export function decryptEvent(encryptKey, encryptB64) {
27
29
  const key = createHash("sha256").update(encryptKey, "utf8").digest();
28
30
  const buf = Buffer.from(encryptB64, "base64");
@@ -1,6 +1,6 @@
1
1
  import type { ImageRef } from "../../agent.ts";
2
2
  import type { FeishuCloudKind } from "./cloud.ts";
3
- /** Where a reply goes: a chat, optionally quote-replying a message (in-thread in topic groups). */
3
+ /** Where a reply goes: a chat, optionally quote-replying a message inside a platform thread. */
4
4
  export interface FeishuTarget {
5
5
  chatId: string;
6
6
  /** Message to reply to (the summoning message). Set in groups so the answer threads under the asker. */
@@ -49,12 +49,12 @@ export interface FeishuApi {
49
49
  }>;
50
50
  /** Send a message to a chat; returns the new message_id (undefined if the body carried none). */
51
51
  sendMessage(chatId: string, msgType: string, content: string): Promise<string | undefined>;
52
- /** Reply to a message (quote; `replyInThread` stays inside a topic group's thread). */
52
+ /** Reply to a message; `replyInThread` creates or continues its platform thread. */
53
53
  replyMessage(messageId: string, msgType: string, content: string, opts?: {
54
54
  replyInThread?: boolean;
55
55
  }): Promise<string | undefined>;
56
- /** Send `text`, split at the platform's size cap: ordinary groups quote only the first chunk; topic
57
- * groups reply_in_thread on every chunk. Returns the FIRST message_id. */
56
+ /** Send `text`, split at the platform's size cap: ordinary groups quote only the first chunk;
57
+ * threaded targets use reply_in_thread on every chunk. Returns the FIRST message_id. */
58
58
  sendText(target: FeishuTarget, text: string): Promise<string | undefined>;
59
59
  /** Edit a sent text message in place (PUT; the platform caps edits at 20 per message). */
60
60
  editTextMessage(messageId: string, text: string): Promise<void>;
@@ -212,8 +212,8 @@ export function createFeishuApi(opts) {
212
212
  let first = true;
213
213
  for (const chunk of chunks) {
214
214
  const content = JSON.stringify({ text: chunk });
215
- // A normal group quote-replies only the first chunk — N reply-quotes would be noise. A topic
216
- // must reply_in_thread on EVERY chunk; a plain chat send would leak continuations to the main group.
215
+ // A normal group quote-replies only the first chunk — N reply-quotes would be noise. A thread
216
+ // must reply_in_thread on EVERY chunk; a plain chat send would leak continuations to main chat.
217
217
  const reply = target.replyTo !== undefined && (first || target.replyInThread === true);
218
218
  const id = reply
219
219
  ? await api.replyMessage(target.replyTo, "text", content, {
@@ -17,6 +17,16 @@ export interface FeishuChannelOptions {
17
17
  * handshake from event signature verification; that narrow path is authenticated after decryption
18
18
  * by the Verification Token. Must match the console exactly. */
19
19
  encryptKey?: string;
20
+ /** Direct-message context + delivery policy. `threaded` (default) gives every top-level p2p message
21
+ * its own session, creates a platform thread for the answer, and routes later thread messages back
22
+ * by root message id. `continuous` keeps one session per p2p chat and sends ordinary unquoted replies. */
23
+ directMessageSession?: "continuous" | "threaded";
24
+ /** Group-message context + delivery policy. `threaded` (default) gives every top-level summoned
25
+ * message its own session and platform thread; later bare user messages in that managed thread answer
26
+ * in the same root session, while @other-only discussion buffers. `continuous` preserves the legacy
27
+ * chat/topic sessions (`chat_id` / `chat_id:thread_id`). Buffering and bare continuations require
28
+ * `im:message.group_msg`. */
29
+ groupMessageSession?: "continuous" | "threaded";
20
30
  /** Policy: whether/where to answer an event (return null to ignore). Defaults to {@link defaultFeishuRoute}. */
21
31
  route?: (event: FeishuMessageEvent) => FeishuRoute | null;
22
32
  /** Customer-facing failure text for the chat (the dev-facing full `details` always go to the operator
@@ -33,4 +43,4 @@ export interface FeishuChannelOptions {
33
43
  /** Build the canonical Feishu channel. Lark calls the internal profile-bound builder below. */
34
44
  export declare function feishuChannel(opts: FeishuChannelOptions): ChannelModule;
35
45
  /** Internal compatibility seam: protocol behavior comes from Feishu; the profile binds cloud edges. */
36
- export declare function buildFeishuChannel(profile: FeishuCloudProfile, { appId, appSecret, verificationToken, encryptKey, route, onError, baseUrl, queueNoticeDelayMs, }: FeishuChannelOptions, factoryName: string): ChannelModule;
46
+ export declare function buildFeishuChannel(profile: FeishuCloudProfile, { appId, appSecret, verificationToken, encryptKey, directMessageSession, groupMessageSession, route, onError, baseUrl, queueNoticeDelayMs, }: FeishuChannelOptions, factoryName: string): ChannelModule;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Canonical Feishu bot-channel engine: verify webhook → answer url_verification → dedup → route → run
2
+ * Canonical Feishu bot-channel engine: verify webhook → answer url_verification → dedup → route → persist → run
3
3
  * the turn → stream a live card → ACK 200. Feishu (open.feishu.cn) is the reference cloud. Lark
4
4
  * international binds this engine through an explicit compatibility profile because its control plane
5
5
  * trails Feishu; protocol reuse does not make Lark the design center.
@@ -16,10 +16,13 @@ import { ensureStateHome } from "../state.js";
16
16
  import { createTurnQueue } from "../turn-queue.js";
17
17
  import { createTurnStore } from "../turn-store.js";
18
18
  import { FEISHU_CLOUD } from "./cloud.js";
19
+ import { collectFeishuBufferedAttachments, createFeishuContextBuffer, feishuBufferPlaceKey, feishuBufferText, } from "./context-buffer.js";
19
20
  import { decryptEvent, timingSafeEqualStr, verifySignature } from "./crypto.js";
20
21
  import { invokeFeishuTurn } from "./invoke-turn.js";
21
22
  import { createFeishuApi } from "./feishu-api.js";
22
- import { cloudEnvelope, defaultFeishuRoute, feishuEnvelope, parseContent, placeKey, } from "./parse.js";
23
+ import { normalizeFeishuMessage } from "./normalize.js";
24
+ import { createOwnedFeishuThreads } from "./owned-threads.js";
25
+ import { cloudEnvelope, defaultFeishuRoute, feishuEnvelope, placeKey, senderLabel, } from "./parse.js";
23
26
  import { defaultErrorMessage, mountFeishuPreview, settleFeishuPreview, streamFeishuReply, } from "./preview.js";
24
27
  import { createSeenRing } from "./seen.js";
25
28
  // Canonical public surface; the Lark subpath aliases these types/functions at its compatibility boundary.
@@ -45,6 +48,7 @@ function isStoredFeishuTurn(t) {
45
48
  typeof r.seq === "number" &&
46
49
  typeof r.session === "string" &&
47
50
  typeof r.baseText === "string" &&
51
+ (r.bufferKey === undefined || typeof r.bufferKey === "string") &&
48
52
  typeof r.chatId === "string" &&
49
53
  (r.replyTo === undefined || typeof r.replyTo === "string") &&
50
54
  (r.queueReplyTo === undefined || typeof r.queueReplyTo === "string") &&
@@ -59,7 +63,7 @@ export function feishuChannel(opts) {
59
63
  return buildFeishuChannel(FEISHU_CLOUD, opts, feishuChannel.name);
60
64
  }
61
65
  /** Internal compatibility seam: protocol behavior comes from Feishu; the profile binds cloud edges. */
62
- export function buildFeishuChannel(profile, { appId, appSecret, verificationToken, encryptKey, route, onError, baseUrl = profile.apiBase, queueNoticeDelayMs = QUEUE_NOTICE_DELAY_MS, }, factoryName) {
66
+ export function buildFeishuChannel(profile, { appId, appSecret, verificationToken, encryptKey, directMessageSession = "threaded", groupMessageSession = "threaded", route, onError, baseUrl = profile.apiBase, queueNoticeDelayMs = QUEUE_NOTICE_DELAY_MS, }, factoryName) {
63
67
  const { kind, envPrefix } = profile;
64
68
  const label = `[${kind}]`;
65
69
  // All three are mandatory: without the app credentials no reply can be sent; without the verification
@@ -71,6 +75,12 @@ export function buildFeishuChannel(profile, { appId, appSecret, verificationToke
71
75
  if (!verificationToken) {
72
76
  throw new Error(`${factoryName} requires a non-empty verificationToken (console → Events & Callbacks; an unset one accepts forged events)`);
73
77
  }
78
+ if (directMessageSession !== "continuous" && directMessageSession !== "threaded") {
79
+ throw new Error(`${factoryName} directMessageSession must be "continuous" or "threaded"`);
80
+ }
81
+ if (groupMessageSession !== "continuous" && groupMessageSession !== "threaded") {
82
+ throw new Error(`${factoryName} groupMessageSession must be "continuous" or "threaded"`);
83
+ }
74
84
  return ({ agent, stateRoot }) => {
75
85
  const formatError = onError ?? defaultErrorMessage;
76
86
  const api = createFeishuApi({ kind, baseUrl, appId, appSecret });
@@ -90,7 +100,9 @@ export function buildFeishuChannel(profile, { appId, appSecret, verificationToke
90
100
  throw new Error(`${factoryName} requires an absolute ctx.stateRoot, got "${stateRoot}"`);
91
101
  }
92
102
  const stateHome = join(stateRoot, "channels", kind);
93
- ensureStateHome(stateHome); // create + self-ignore — downloaded files may carry chat content
103
+ ensureStateHome(stateHome); // create + self-ignore — buffers/files may carry chat content
104
+ const ownedThreads = createOwnedFeishuThreads(join(stateHome, "owned-threads.json"), label);
105
+ const buffer = createFeishuContextBuffer(join(stateHome, "buffers.json"), label);
94
106
  const store = createTurnStore(join(stateHome, "turns.json"), {
95
107
  label,
96
108
  isRecord: isStoredFeishuTurn,
@@ -177,10 +189,24 @@ export function buildFeishuChannel(profile, { appId, appSecret, verificationToke
177
189
  }
178
190
  const startedAt = Date.now();
179
191
  log.info(`${label} turn start: turn=${rec.id} session=${rec.session} chat=${rec.chatId}`);
192
+ // Snapshot background discussion at dequeue. Commit only this snapshot on `completed`, so a
193
+ // message arriving while the turn runs remains buffered for the next answered turn.
194
+ // ponytail: independent threaded roots in one main chat dequeue concurrently and may both fold
195
+ // this snapshot before either commits it. That fan-out loses nothing; claiming by buffer key
196
+ // would instead couple otherwise-independent root sessions and require failure rollback.
197
+ const { text: recent, consumed } = buffer.peek(rec.bufferKey);
198
+ const prompt = recent ? `[recent group discussion:\n${recent}\n]\n\n${rec.baseText}` : rec.baseText;
199
+ const buffered = collectFeishuBufferedAttachments(consumed, {
200
+ images: rec.images.map((ref) => ({ messageId: ref.msg, key: ref.key })),
201
+ files: rec.files.map((ref) => ({ messageId: ref.msg, key: ref.key, name: ref.name })),
202
+ });
180
203
  try {
181
- await streamFeishuReply(invokeFeishuTurn(agent, rec.session, rec.baseText, { api, chatId: rec.chatId, filesDir: join(stateHome, "files"), label }, { images: rec.images, files: rec.files, parentId: rec.parentId },
182
- // On completed, drop the intent the turn provably lives in the session from here on.
183
- () => store.remove(rec.id)), api, targetOf(rec), formatError, rec.preview, label);
204
+ await streamFeishuReply(invokeFeishuTurn(agent, rec.session, prompt, { api, chatId: rec.chatId, filesDir: join(stateHome, "files"), label }, { primary: { images: rec.images, files: rec.files, parentId: rec.parentId }, buffered }, () => {
205
+ // Drop intent first: a crash between these writes may re-fold answered context later,
206
+ // but can never replay this turn after its context was removed.
207
+ store.remove(rec.id);
208
+ buffer.commit(rec.bufferKey, consumed);
209
+ }), api, targetOf(rec), formatError, rec.preview, label);
184
210
  log.info(`${label} turn done: turn=${rec.id} session=${rec.session} (${Date.now() - startedAt}ms)`);
185
211
  }
186
212
  catch (error) {
@@ -195,13 +221,13 @@ export function buildFeishuChannel(profile, { appId, appSecret, verificationToke
195
221
  }
196
222
  },
197
223
  });
198
- // Accept a turn: persist its intent (pre-ACK; a failed write throws webhook 500 → redeliver),
199
- // record its id in the dedup ring (post-decision insurance, best-effort), enqueue it. Recovery
200
- // re-enqueues a crash-surviving turn WITHOUT re-persisting.
224
+ // Accept a turn: persist its intent before the ACK, then record the platform delivery id and enqueue
225
+ // it. The ordering is deliberate: recording first could turn a failed intent write into silent loss
226
+ // when the platform redelivers. Recovery re-enqueues a crash survivor without re-persisting it.
201
227
  const submit = (rec, persist) => {
202
228
  if (persist) {
203
- store.add(toStored(rec));
204
- seen.add(rec.id);
229
+ store.add(toStored(rec)); // failed write → webhook 500 → platform redelivery
230
+ seen.add(rec.id); // post-persist, best-effort protection from documented duplicate pushes
205
231
  }
206
232
  queue.accept(rec);
207
233
  };
@@ -218,8 +244,12 @@ export function buildFeishuChannel(profile, { appId, appSecret, verificationToke
218
244
  if (recovered.length > 0)
219
245
  log.info(`${label} recovering ${recovered.length} unfinished turn(s) from a prior run`);
220
246
  let seqCounter = recovered.reduce((max, r) => Math.max(max, r.seq), 0);
221
- for (const { attempts: _a, ...intent } of recovered)
222
- submit({ ...intent, preview: undefined }, false);
247
+ for (const { attempts: _a, ...intent } of recovered) {
248
+ // A pre-buffer-version record has no trustworthy place identity. Give it an empty private bucket
249
+ // rather than risk consuming new main-chat context that arrived after this restart.
250
+ const bufferKey = intent.bufferKey ?? `${intent.chatId}:legacy-turn:${intent.id}`;
251
+ submit({ ...intent, bufferKey, preview: undefined }, false);
252
+ }
223
253
  const handler = async (req) => {
224
254
  if (req.method !== "POST")
225
255
  return text("POST only\n", 405);
@@ -311,40 +341,123 @@ export function buildFeishuChannel(profile, { appId, appSecret, verificationToke
311
341
  if (!m?.message_id || !m.chat_id)
312
342
  return new Response(null, { status: 200 });
313
343
  if (seen.has(m.message_id)) {
314
- log.debug(`${label} duplicate push for message ${m.message_id} — already accepted, skipping`);
344
+ log.debug(`${label} duplicate push for message ${m.message_id} — already persisted, skipping`);
315
345
  return new Response(null, { status: 200 });
316
346
  }
317
- const r = decide(event);
347
+ let r = decide(event);
348
+ const normalized = normalizeFeishuMessage(event);
349
+ if (!normalized)
350
+ return new Response(null, { status: 200 });
351
+ const bufferKey = feishuBufferPlaceKey(normalized.conversation);
352
+ const isHumanGroup = event.sender?.sender_type === "user" && m.chat_type === "group";
353
+ const managedThread = groupMessageSession === "threaded" &&
354
+ isHumanGroup &&
355
+ m.thread_id !== undefined &&
356
+ m.root_id !== undefined &&
357
+ ownedThreads.has(m.chat_id, m.root_id);
358
+ // In an Agent-created thread, a bare user continuation still summons. Any explicit mention changes
359
+ // that intent: only defaultFeishuRoute's structural @THIS-bot match summons; @other-only discussion
360
+ // is buffered like unsummoned group context. A custom route remains fully authoritative.
361
+ if (!r && route === undefined && managedThread && !normalized.content.hasMentions)
362
+ r = {};
318
363
  if (!r) {
319
- log.debug(`${label} not summoned ignoring message ${m.message_id} (chat ${m.chat_id}, ${m.chat_type})`);
364
+ if (route === undefined && isHumanGroup) {
365
+ const bodyText = feishuBufferText(normalized.content.text);
366
+ if (bodyText) {
367
+ const resources = normalized.content.resources;
368
+ const images = resources
369
+ .filter((resource) => resource.kind === "image")
370
+ .map((resource) => ({ messageId: resource.messageId, key: resource.key }));
371
+ const files = resources
372
+ .filter((resource) => resource.kind === "file" || resource.kind === "audio" || resource.kind === "video")
373
+ .map((resource) => ({
374
+ messageId: resource.messageId,
375
+ key: resource.key,
376
+ name: resource.name,
377
+ }));
378
+ // Pre-ACK persistence: a write failure rejects the webhook so the platform can redeliver.
379
+ buffer.push(bufferKey, {
380
+ sender: senderLabel(event.sender) ?? "someone",
381
+ body: bodyText,
382
+ messageId: m.message_id,
383
+ replyTo: m.parent_id,
384
+ files: files.length ? files : undefined,
385
+ images: images.length ? images : undefined,
386
+ });
387
+ seen.add(m.message_id); // post-persist: a redelivery cannot duplicate buffered context
388
+ log.debug(`${label} buffered unsummoned group message ${m.message_id} (place ${bufferKey})`);
389
+ }
390
+ else {
391
+ log.debug(`${label} not summoned — ignoring empty message ${m.message_id} (chat ${m.chat_id})`);
392
+ }
393
+ }
394
+ else {
395
+ log.debug(`${label} not summoned — ignoring message ${m.message_id} (chat ${m.chat_id}, ${m.chat_type})`);
396
+ }
320
397
  return new Response(null, { status: 200 });
321
398
  }
322
399
  {
323
- const session = r.session ?? placeKey(m);
400
+ const threadedP2p = directMessageSession === "threaded" && m.chat_type === "p2p";
401
+ const threadedGroup = groupMessageSession === "threaded" && m.chat_type === "group";
402
+ const threadedConversation = threadedP2p || threadedGroup;
403
+ // A top-level threaded message has no thread_id yet. Its tenant-unique message_id is therefore
404
+ // the only identity available both before and after the first reply creates the thread.
405
+ // Continuations carry that same value as root_id (field-verified on Feishu p2p; shared protocol
406
+ // shape for groups/Lark). Prefix with the channel kind to isolate Feishu/Lark while keeping pi's
407
+ // provider-facing session/cache key under 64 characters.
408
+ if (threadedConversation && m.thread_id !== undefined && m.root_id === undefined) {
409
+ log.warn(`${label} threaded ${m.chat_type} message ${m.message_id} has thread_id ${m.thread_id} but no root_id — session continuity cannot be guaranteed`);
410
+ }
411
+ const defaultSession = threadedConversation
412
+ ? `${kind}:${m.thread_id === undefined ? m.message_id : (m.root_id ?? `missing-root:${m.thread_id}`)}`
413
+ : placeKey(m);
414
+ const session = r.session ?? defaultSession;
324
415
  const chatId = r.chatId ?? m.chat_id;
325
- // Reply to the summoning message in groups (threads the answer under the asker; stays inside a
326
- // topic); a 1:1 p2p chat needs no reply-quote. Only when the RESOLVED target is the message's own
327
- // chat: a route that redirects elsewhere must not quote a same-id message in the wrong place.
416
+ // Groups always quote the summon. Threaded groups and p2p add reply_in_thread: on a top-level
417
+ // message that creates the thread, and on a continuation it keeps the answer inside it. Only
418
+ // quote when the resolved target is the source chat a custom redirect cannot reuse a message
419
+ // id there. A continuous group still keeps replies inside an already-existing platform topic.
328
420
  const sameTarget = chatId === m.chat_id;
329
- const replyTo = m.chat_type !== "p2p" && sameTarget ? m.message_id : undefined;
330
- // Queue feedback always identifies the exact ask, including p2p. Ordinary p2p answers remain
331
- // unquoted unless the turn actually waited long enough for its queue preview to mount.
421
+ const replyTo = sameTarget && (m.chat_type === "group" || threadedP2p) ? m.message_id : undefined;
422
+ const replyInThread = replyTo !== undefined && (threadedConversation || m.thread_id !== undefined) ? true : undefined;
423
+ // Queue feedback always identifies the exact ask, including continuous modes. In threaded mode
424
+ // it inherits replyInThread, so an ask queued inside a root cannot leak a status card to main chat.
332
425
  const queueReplyTo = sameTarget ? m.message_id : undefined;
333
- const content = parseContent(m);
426
+ const resources = normalized.content.resources;
427
+ const images = resources
428
+ .filter((resource) => resource.kind === "image")
429
+ .map((resource) => ({ msg: resource.messageId, key: resource.key }));
430
+ const files = resources
431
+ .filter((resource) => resource.kind === "file" || resource.kind === "audio" || resource.kind === "video")
432
+ .map((resource) => ({ msg: resource.messageId, key: resource.key, name: resource.name }));
334
433
  const baseText = r.text ?? cloudEnvelope(event, kind);
335
- if (baseText.trim() !== "" || content.imageKeys.length > 0 || content.fileRefs.length > 0) {
434
+ if (baseText.trim() !== "" || images.length > 0 || files.length > 0) {
435
+ // Persist ownership before ACK. The platform thread does not exist until the first reply lands,
436
+ // but a failed reply has no continuation to misroute; pre-ACK ownership closes the opposite,
437
+ // worse window (thread created, process dies, then its unmentioned continuation is forgotten).
438
+ if (route === undefined &&
439
+ threadedGroup &&
440
+ m.thread_id === undefined &&
441
+ sameTarget &&
442
+ replyInThread === true) {
443
+ ownedThreads.add(m.chat_id, m.message_id);
444
+ }
336
445
  submit({
337
446
  id: m.message_id,
338
447
  seq: ++seqCounter,
339
448
  session,
340
449
  baseText,
450
+ bufferKey,
341
451
  chatId,
342
452
  replyTo,
343
453
  queueReplyTo,
344
- replyInThread: replyTo !== undefined && m.thread_id !== undefined ? true : undefined,
345
- parentId: m.parent_id,
346
- images: content.imageKeys.map((key) => ({ msg: m.message_id, key })),
347
- files: content.fileRefs.map((f) => ({ msg: m.message_id, key: f.key, name: f.name })),
454
+ replyInThread,
455
+ // Inside a threaded session the root conversation history already contains the previous
456
+ // turns. Reloading parent_id would duplicate that input (and its attachments). A top-level
457
+ // quoted reply has no thread_id, starts a new root, and still hydrates its referent.
458
+ parentId: threadedConversation && m.thread_id !== undefined ? undefined : m.parent_id,
459
+ images,
460
+ files,
348
461
  }, true);
349
462
  }
350
463
  }
@@ -6,13 +6,13 @@
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 { type Agent, type AgentEvent } from "../../agent.ts";
15
+ import type { FeishuBufferedRef } from "./context-buffer.ts";
16
16
  import type { FeishuApi } from "./feishu-api.ts";
17
17
  /** Everything the transport needs to fetch a turn's attachments. */
18
18
  export interface FeishuTurnTransport {
@@ -28,13 +28,19 @@ export interface FeishuAttachmentInput {
28
28
  key: string;
29
29
  name?: string;
30
30
  }
31
- /** A turn's attachment inputs: the summoning message's own resources, plus the message it replied to
32
- * (resolved here — content and resources both). */
31
+ /** A turn's primary resources plus background resources folded from the context buffer. */
33
32
  export interface FeishuTurnAttachments {
34
- images: FeishuAttachmentInput[];
35
- files: FeishuAttachmentInput[];
36
- /** The replied-to message's id, when the summon is a reply. */
37
- parentId?: string;
33
+ primary: {
34
+ images: FeishuAttachmentInput[];
35
+ files: FeishuAttachmentInput[];
36
+ /** The replied-to message's id, when the summon is a reply. */
37
+ parentId?: string;
38
+ };
39
+ buffered: {
40
+ files: FeishuBufferedRef[];
41
+ images: FeishuBufferedRef[];
42
+ skipped: number;
43
+ };
38
44
  }
39
45
  /** How the busy-wait paces: retry the invoke every `delayMs` while the session's lease is held by an
40
46
  * EXTERNAL turn (a self-scheduled wake, a concurrent embedder invoke), up to `maxWaitMs` total. The
@@ -45,8 +51,8 @@ export interface BusyRetry {
45
51
  maxWaitMs: number;
46
52
  }
47
53
  /**
48
- * Run one turn: resolve its inputs, then stream agent.invoke. An input failure surfaces as a `failed`
49
- * event (never a silent drop). `onCompleted` (if given) fires on the turn's `completed` event — the
54
+ * Run one turn: resolve its inputs, then stream agent.invoke. A primary-input failure surfaces as a
55
+ * `failed` event (never a silent drop). `onCompleted` (if given) fires on the `completed` event — the
50
56
  * durable-commit point; the caller uses it to remove the turn intent (turn-store L1) at the earliest
51
57
  * moment the turn provably lives in the session.
52
58
  *