@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
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
|
|
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
|
|
4
|
-
*
|
|
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
|
|
25
|
-
*
|
|
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
|
|
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
|
|
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;
|
|
57
|
-
*
|
|
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
|
|
216
|
-
// must reply_in_thread on EVERY chunk; a plain chat send would leak continuations to
|
|
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 {
|
|
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 —
|
|
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,
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
199
|
-
//
|
|
200
|
-
// re-enqueues a crash
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
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
|
|
330
|
-
|
|
331
|
-
//
|
|
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
|
|
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() !== "" ||
|
|
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
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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.
|
|
49
|
-
* event (never a silent drop). `onCompleted` (if given) fires on 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
|
*
|