@fastagent-sh/fastagent 0.14.0 → 0.15.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 +3 -3
- package/dist/agent.d.ts +20 -0
- package/dist/agent.js +9 -0
- package/dist/channels/context-buffer.d.ts +30 -0
- package/dist/channels/context-buffer.js +98 -0
- package/dist/channels/control.d.ts +28 -0
- package/dist/channels/control.js +214 -0
- package/dist/channels/feishu/card.js +1 -1
- package/dist/channels/feishu/context-buffer.d.ts +8 -11
- package/dist/channels/feishu/context-buffer.js +6 -67
- package/dist/channels/feishu/feishu-api.d.ts +11 -3
- package/dist/channels/feishu/feishu-api.js +20 -2
- package/dist/channels/feishu/feishu.d.ts +28 -16
- package/dist/channels/feishu/feishu.js +210 -188
- package/dist/channels/feishu/invoke-turn.d.ts +7 -20
- package/dist/channels/feishu/invoke-turn.js +10 -62
- package/dist/channels/feishu/model.d.ts +2 -1
- package/dist/channels/feishu/normalize.d.ts +2 -1
- package/dist/channels/feishu/owned-threads.d.ts +2 -6
- package/dist/channels/feishu/parse.d.ts +1 -1
- package/dist/channels/feishu/preview.d.ts +4 -7
- package/dist/channels/feishu/preview.js +24 -140
- package/dist/channels/feishu/register-app.d.ts +2 -1
- package/dist/channels/feishu/register-webhook.d.ts +2 -1
- package/dist/channels/feishu/setup-mode.d.ts +8 -0
- package/dist/channels/feishu/setup-mode.js +2 -0
- package/dist/channels/feishu/ws-ingress.d.ts +28 -0
- package/dist/channels/feishu/ws-ingress.js +136 -0
- package/dist/channels/github/github.js +8 -6
- package/dist/channels/http.d.ts +14 -0
- package/dist/channels/http.js +35 -2
- package/dist/channels/invoke-turn-kit.d.ts +65 -0
- package/dist/channels/invoke-turn-kit.js +87 -0
- package/dist/channels/lark/lark.d.ts +4 -2
- package/dist/channels/lark/lark.js +4 -1
- package/dist/channels/lark/onboard.d.ts +8 -4
- package/dist/channels/lark/onboard.js +8 -0
- package/dist/channels/preview-kit.d.ts +109 -0
- package/dist/channels/preview-kit.js +183 -0
- package/dist/channels/seen.d.ts +5 -0
- package/dist/channels/seen.js +35 -0
- package/dist/channels/slack/bot-auth.d.ts +15 -0
- package/dist/channels/slack/bot-auth.js +146 -0
- package/dist/channels/slack/config-api.d.ts +60 -0
- package/dist/channels/slack/config-api.js +149 -0
- package/dist/channels/slack/context-buffer.d.ts +24 -0
- package/dist/channels/slack/context-buffer.js +37 -0
- package/dist/channels/slack/invoke-turn.d.ts +19 -0
- package/dist/channels/slack/invoke-turn.js +63 -0
- package/dist/channels/slack/manifest.d.ts +49 -0
- package/dist/channels/slack/manifest.js +69 -0
- package/dist/channels/slack/model.d.ts +67 -0
- package/dist/channels/slack/model.js +2 -0
- package/dist/channels/slack/onboard.d.ts +41 -0
- package/dist/channels/slack/onboard.js +120 -0
- package/dist/channels/slack/onboarding-state.d.ts +31 -0
- package/dist/channels/slack/onboarding-state.js +69 -0
- package/dist/channels/slack/owned-threads.d.ts +6 -0
- package/dist/channels/slack/owned-threads.js +43 -0
- package/dist/channels/slack/parse.d.ts +23 -0
- package/dist/channels/slack/parse.js +81 -0
- package/dist/channels/slack/preview.d.ts +24 -0
- package/dist/channels/slack/preview.js +359 -0
- package/dist/channels/slack/reaction.d.ts +24 -0
- package/dist/channels/slack/reaction.js +62 -0
- package/dist/channels/slack/register-webhook.d.ts +10 -0
- package/dist/channels/slack/register-webhook.js +49 -0
- package/dist/channels/slack/scaffold/channel.ts +33 -0
- package/dist/channels/slack/scaffold/slack-send.ts +171 -0
- package/dist/channels/slack/setup-server.d.ts +17 -0
- package/dist/channels/slack/setup-server.js +103 -0
- package/dist/channels/slack/slack-api.d.ts +77 -0
- package/dist/channels/slack/slack-api.js +415 -0
- package/dist/channels/slack/slack.d.ts +58 -0
- package/dist/channels/slack/slack.js +451 -0
- package/dist/channels/slack/welcomed.d.ts +5 -0
- package/dist/channels/slack/welcomed.js +32 -0
- package/dist/channels/state.js +3 -3
- package/dist/channels/stop-command.d.ts +6 -0
- package/dist/channels/stop-command.js +36 -0
- package/dist/channels/tasks.d.ts +13 -0
- package/dist/channels/tasks.js +10 -0
- package/dist/channels/telegram/context-buffer.d.ts +8 -17
- package/dist/channels/telegram/context-buffer.js +6 -85
- package/dist/channels/telegram/invoke-turn.d.ts +5 -22
- package/dist/channels/telegram/invoke-turn.js +11 -58
- package/dist/channels/telegram/preview.d.ts +4 -7
- package/dist/channels/telegram/preview.js +24 -142
- package/dist/channels/telegram/telegram.js +23 -9
- package/dist/channels/{feishu/text.d.ts → text.d.ts} +1 -1
- package/dist/channels/{feishu/text.js → text.js} +1 -1
- package/dist/channels/turn-queue.js +1 -1
- package/dist/channels/turn-store.d.ts +1 -1
- package/dist/channels/turn-store.js +2 -3
- package/dist/cli/add-feishu.d.ts +27 -0
- package/dist/{cli-add-feishu.js → cli/add-feishu.js} +167 -44
- package/dist/cli/add-slack.d.ts +10 -0
- package/dist/cli/add-slack.js +204 -0
- package/dist/{cli-auth.d.ts → cli/auth-view.d.ts} +1 -1
- package/dist/{cli-auth.js → cli/auth-view.js} +1 -1
- package/dist/cli/commands/add.d.ts +4 -0
- package/dist/cli/commands/add.js +152 -20
- package/dist/cli/commands/attach.d.ts +82 -0
- package/dist/cli/commands/attach.js +559 -0
- package/dist/cli/commands/chat.d.ts +1 -0
- package/dist/cli/commands/chat.js +6 -1
- package/dist/cli/commands/deploy.d.ts +2 -0
- package/dist/cli/commands/deploy.js +78 -22
- package/dist/cli/commands/dev.js +11 -5
- package/dist/cli/commands/fire.js +3 -3
- package/dist/cli/commands/info.js +2 -2
- package/dist/cli/commands/invoke.js +2 -3
- package/dist/cli/commands/login.js +14 -25
- package/dist/cli/commands/models.js +1 -1
- package/dist/cli/commands/start.js +15 -8
- package/dist/cli/commands/tool.js +2 -1
- package/dist/cli/fail.d.ts +0 -2
- package/dist/cli/fail.js +1 -1
- package/dist/{invoke-stream.d.ts → cli/invoke-stream.d.ts} +1 -1
- package/dist/{invoke-stream.js → cli/invoke-stream.js} +4 -0
- package/dist/cli/kernel.d.ts +3 -2
- package/dist/cli/models-view.d.ts +21 -0
- package/dist/cli/models-view.js +66 -0
- package/dist/cli/program.js +79 -21
- package/dist/cli/serve.d.ts +38 -18
- package/dist/cli/serve.js +197 -39
- package/dist/cli/shared.d.ts +26 -6
- package/dist/cli/shared.js +163 -33
- package/dist/collect.d.ts +14 -3
- package/dist/collect.js +24 -0
- package/dist/core.d.ts +3 -1
- package/dist/core.js +2 -0
- package/dist/deploy/docker/plan.d.ts +2 -2
- package/dist/deploy/docker/plan.js +8 -6
- package/dist/deploy/fly/plan.d.ts +3 -1
- package/dist/deploy/fly/plan.js +12 -7
- package/dist/deploy/fly/run.d.ts +5 -4
- package/dist/deploy/fly/run.js +16 -5
- package/dist/deploy/preflight.d.ts +10 -4
- package/dist/deploy/preflight.js +53 -9
- package/dist/deploy/railway/plan.d.ts +3 -1
- package/dist/deploy/railway/plan.js +12 -4
- package/dist/deploy/railway/run.d.ts +5 -4
- package/dist/deploy/railway/run.js +15 -5
- package/dist/deploy/runner.d.ts +2 -1
- package/dist/deploy/secrets.d.ts +2 -1
- package/dist/deploy/secrets.js +23 -3
- package/dist/dev-supervisor.d.ts +0 -2
- package/dist/dev-supervisor.js +7 -3
- package/dist/engines/pi/channel.d.ts +22 -16
- package/dist/engines/pi/channel.js +90 -60
- package/dist/engines/pi/chat.d.ts +4 -16
- package/dist/engines/pi/chat.js +8 -261
- package/dist/engines/pi/config.d.ts +14 -9
- package/dist/engines/pi/config.js +33 -18
- package/dist/engines/pi/create.d.ts +22 -2
- package/dist/engines/pi/create.js +20 -14
- package/dist/engines/pi/harness.d.ts +49 -0
- package/dist/engines/pi/harness.js +90 -2
- package/dist/engines/pi/invoke.d.ts +55 -2
- package/dist/engines/pi/invoke.js +304 -19
- package/dist/engines/pi/login.d.ts +11 -0
- package/dist/engines/pi/login.js +16 -4
- package/dist/engines/pi/models.d.ts +56 -10
- package/dist/engines/pi/models.js +61 -23
- package/dist/engines/pi/session-builder.d.ts +16 -0
- package/dist/engines/pi/session-builder.js +308 -0
- package/dist/engines/pi/session-control.d.ts +50 -0
- package/dist/engines/pi/session-control.js +604 -0
- package/dist/engines/pi/sessions.d.ts +17 -2
- package/dist/engines/pi/sessions.js +9 -0
- package/dist/engines/pi/tool-context.d.ts +18 -11
- package/dist/engines/pi/tool-context.js +3 -9
- package/dist/engines/pi/tool.d.ts +6 -8
- package/dist/engines/pi/tool.js +6 -1
- package/dist/engines/pi/wake-tool.d.ts +0 -3
- package/dist/engines/pi/wake-tool.js +9 -7
- package/dist/engines/pi/workspace.d.ts +52 -0
- package/dist/engines/pi/workspace.js +72 -15
- package/dist/feishu.d.ts +1 -1
- package/dist/feishu.js +1 -1
- package/dist/host/node.d.ts +23 -6
- package/dist/host/node.js +5 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lark.d.ts +1 -1
- package/dist/lark.js +1 -1
- package/dist/observe.js +3 -0
- package/dist/pi.d.ts +5 -4
- package/dist/pi.js +2 -1
- package/dist/scaffold/add-channel.d.ts +19 -6
- package/dist/scaffold/add-channel.js +126 -17
- package/dist/scaffold/templates/fastagent.config.mjs +4 -3
- package/dist/schedule/wakeups.d.ts +0 -3
- package/dist/schedule/wakeups.js +1 -1
- package/dist/session-remote.d.ts +53 -0
- package/dist/session-remote.js +336 -0
- package/dist/session.d.ts +265 -0
- package/dist/session.js +37 -0
- package/dist/slack.d.ts +2 -0
- package/dist/slack.js +2 -0
- package/dist/tunnel.d.ts +7 -4
- package/dist/tunnel.js +21 -10
- package/package.json +19 -7
- package/dist/channels/feishu/seen.d.ts +0 -5
- package/dist/channels/feishu/seen.js +0 -47
- package/dist/cli-add-feishu.d.ts +0 -8
- package/dist/cli-models.d.ts +0 -11
- package/dist/cli-models.js +0 -20
|
@@ -1,29 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* DURABLE: persisted synchronously before the webhook 200 (Telegram never redelivers an ACKed update,
|
|
8
|
-
* so ACK-then-persist would be a silent-loss window) and reloaded on start. The consume protocol is
|
|
9
|
-
* peek → (turn completes) → commit: peek renders WITHOUT clearing and snapshots exactly which entries
|
|
10
|
-
* it consumed; commit removes only those, by object identity — so a failure or crash before the turn's
|
|
11
|
-
* `completed` leaves them intact for the next summon, and a message that arrives while the turn runs
|
|
12
|
-
* survives for the next answered turn (a whole-bucket delete would lose it).
|
|
2
|
+
* Telegram's half of the shared context buffer (mechanics + consume protocol: ../context-buffer.ts):
|
|
3
|
+
* the entry shape, its fold-line rendering, and buffered-attachment selection. Bucketed by Telegram
|
|
4
|
+
* "place" (chat[:thread]), not session: an un-summoned message has no route session, and the flush
|
|
5
|
+
* feeds whatever turn answers that place.
|
|
13
6
|
*/
|
|
14
|
-
import {
|
|
15
|
-
import { loadStateFile, saveStateFile } from "../state.js";
|
|
16
|
-
/** Char budget for the per-place buffer — bounds the cost of folding it into a prompt; when exceeded
|
|
17
|
-
* the OLDEST un-summoned messages are dropped (not a time window: a quiet group keeps its
|
|
18
|
-
* sparse-but-relevant lines, a busy burst is capped). */
|
|
19
|
-
const BUFFER_MAX_CHARS = 4000;
|
|
20
|
-
/** How many buffered files and images (each, most recent first) a summon pulls in with the folded
|
|
21
|
-
* discussion — bounds the latency/token cost of "summarize the file from earlier" against a chatty
|
|
22
|
-
* group posting many attachments between summons. Skipped ones are counted into the prompt note, so
|
|
23
|
-
* the model never sees an attachment reference it silently cannot open. */
|
|
24
|
-
const BUFFER_ATTACH_MAX = 3;
|
|
25
|
-
/** One fold line. ALSO the eviction cost basis: the budget must price what the fold actually renders
|
|
26
|
-
* (sender + body + the msg/reply meta), or the fold would systematically overrun BUFFER_MAX_CHARS. */
|
|
7
|
+
import { BUFFER_ATTACH_MAX, createContextBuffer as createGenericContextBuffer, } from "../context-buffer.js";
|
|
27
8
|
function bufferLine(e) {
|
|
28
9
|
const meta = [
|
|
29
10
|
e.messageId !== undefined ? `msg ${e.messageId}` : undefined,
|
|
@@ -64,8 +45,6 @@ export function collectAttachments(consumed, primary) {
|
|
|
64
45
|
skipped: Math.max(0, files.length - BUFFER_ATTACH_MAX) + Math.max(0, images.length - BUFFER_ATTACH_MAX),
|
|
65
46
|
};
|
|
66
47
|
}
|
|
67
|
-
/** State files are an IO boundary: valid JSON of the WRONG SHAPE (hand-edited, version drift) must
|
|
68
|
-
* degrade exactly like a corrupt file — warn + empty — not flow in as trusted data. */
|
|
69
48
|
function isBufferEntry(e) {
|
|
70
49
|
const t = e;
|
|
71
50
|
const strings = (v) => v === undefined || (Array.isArray(v) && v.every((x) => typeof x === "string"));
|
|
@@ -77,63 +56,5 @@ function isBufferEntry(e) {
|
|
|
77
56
|
strings(t.imageIds));
|
|
78
57
|
}
|
|
79
58
|
export function createContextBuffer(path) {
|
|
80
|
-
|
|
81
|
-
const raw = loadStateFile(path);
|
|
82
|
-
if (raw === undefined)
|
|
83
|
-
return new Map();
|
|
84
|
-
if (typeof raw === "object" &&
|
|
85
|
-
raw !== null &&
|
|
86
|
-
!Array.isArray(raw) &&
|
|
87
|
-
Object.values(raw).every((v) => Array.isArray(v) && v.every(isBufferEntry))) {
|
|
88
|
-
return new Map(Object.entries(raw));
|
|
89
|
-
}
|
|
90
|
-
log.warn(`[telegram] unexpected shape in ${path} — starting with an empty buffer`);
|
|
91
|
-
return new Map();
|
|
92
|
-
};
|
|
93
|
-
const buffers = load();
|
|
94
|
-
const persist = () => saveStateFile(path, Object.fromEntries(buffers));
|
|
95
|
-
return {
|
|
96
|
-
push(placeKey, entry) {
|
|
97
|
-
const prev = buffers.get(placeKey);
|
|
98
|
-
const buf = prev ? [...prev] : [];
|
|
99
|
-
buf.push(entry);
|
|
100
|
-
let total = buf.reduce((n, e) => n + bufferLine(e).length + 1, 0);
|
|
101
|
-
while (buf.length > 1 && total > BUFFER_MAX_CHARS) {
|
|
102
|
-
const dropped = buf.shift();
|
|
103
|
-
if (dropped)
|
|
104
|
-
total -= bufferLine(dropped).length + 1;
|
|
105
|
-
}
|
|
106
|
-
buffers.set(placeKey, buf);
|
|
107
|
-
try {
|
|
108
|
-
persist();
|
|
109
|
-
}
|
|
110
|
-
catch (e) {
|
|
111
|
-
if (prev)
|
|
112
|
-
buffers.set(placeKey, prev);
|
|
113
|
-
else
|
|
114
|
-
buffers.delete(placeKey);
|
|
115
|
-
throw e;
|
|
116
|
-
}
|
|
117
|
-
},
|
|
118
|
-
peek(placeKey) {
|
|
119
|
-
const buf = buffers.get(placeKey) ?? [];
|
|
120
|
-
return { text: buf.map(bufferLine).join("\n"), consumed: [...buf] };
|
|
121
|
-
},
|
|
122
|
-
commit(placeKey, consumed) {
|
|
123
|
-
const buf = buffers.get(placeKey);
|
|
124
|
-
if (!buf)
|
|
125
|
-
return;
|
|
126
|
-
const remaining = buf.filter((e) => !consumed.includes(e));
|
|
127
|
-
if (remaining.length === 0)
|
|
128
|
-
buffers.delete(placeKey);
|
|
129
|
-
else
|
|
130
|
-
buffers.set(placeKey, remaining);
|
|
131
|
-
try {
|
|
132
|
-
persist();
|
|
133
|
-
}
|
|
134
|
-
catch (e) {
|
|
135
|
-
log.error(`[telegram] buffer write failed post-ACK (a restart may re-fold answered discussion): ${String(e)}`);
|
|
136
|
-
}
|
|
137
|
-
},
|
|
138
|
-
};
|
|
59
|
+
return createGenericContextBuffer({ path, label: "[telegram]", isEntry: isBufferEntry, line: bufferLine });
|
|
139
60
|
}
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* pure) because this half touches the Bot API + disk; split from telegram.ts so the factory keeps only
|
|
6
6
|
* wiring and the per-turn lifecycle.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import type { Agent, AgentEvent } from "../../agent.ts";
|
|
9
|
+
import { type BusyRetry } from "../invoke-turn-kit.ts";
|
|
9
10
|
import type { BufferedRef } from "./context-buffer.ts";
|
|
10
11
|
/** Everything the transport needs to fetch a turn's attachments. */
|
|
11
12
|
export interface TurnTransport {
|
|
@@ -27,27 +28,9 @@ export interface TurnAttachments {
|
|
|
27
28
|
skipped: number;
|
|
28
29
|
};
|
|
29
30
|
}
|
|
30
|
-
/** How the busy-wait paces: retry the invoke every `delayMs` while the session's lease is held by an
|
|
31
|
-
* EXTERNAL turn (a self-scheduled wake, a concurrent embedder invoke), up to `maxWaitMs` total. The
|
|
32
|
-
* channel's own turns never collide (the turn-queue serializes per session), so a busy reject here is
|
|
33
|
-
* always an outside holder — wait for it like a queued turn, instead of erroring at the user. */
|
|
34
|
-
export interface BusyRetry {
|
|
35
|
-
delayMs: number;
|
|
36
|
-
maxWaitMs: number;
|
|
37
|
-
}
|
|
38
31
|
/**
|
|
39
|
-
* Run one turn: resolve its attachments, then stream agent.invoke
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* so a failure or crash at ANY earlier point leaves the buffer intact for the next summon (a re-folded
|
|
43
|
-
* block beats lost context). The caller uses it to remove the turn intent AND commit the context buffer,
|
|
44
|
-
* in that order (see the call site) so a crash between the two clears cannot replay a context-stripped turn.
|
|
45
|
-
*
|
|
46
|
-
* BUSY-WAIT: a `failed{code: session_busy}` FIRST event means an external turn (e.g. a self-scheduled
|
|
47
|
-
* wake) holds this session's lease and OUR turn never started — replay-safe. Retry (bounded) instead of
|
|
48
|
-
* yielding it: the user sees the "Thinking…" placeholder while waiting (the mirror of the scheduler
|
|
49
|
-
* deferring a wake INTO a busy session), and only an exhausted wait surfaces the busy failure. Only a
|
|
50
|
-
* FIRST-event busy retries — attachments are already resolved, and a fail-fast reject is the only shape
|
|
51
|
-
* the engine emits it in, so nothing that started is ever re-run.
|
|
32
|
+
* Run one turn: resolve its attachments, then stream agent.invoke with the shared busy-wait
|
|
33
|
+
* (invoke-turn-kit — `onCompleted` is the durable-commit point; see streamTurnWithBusyRetry). A
|
|
34
|
+
* primary-attachment failure surfaces as a `failed` event (never a silent drop).
|
|
52
35
|
*/
|
|
53
36
|
export declare function invokeTurn(agent: Agent, session: string, text: string, transport: TurnTransport, attachments: TurnAttachments, onCompleted?: () => void, busyRetry?: BusyRetry): AsyncIterable<AgentEvent>;
|
|
@@ -1,12 +1,5 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Run one turn (the IO half of Telegram→Agent translation): assemble its inputs — resolve attachments
|
|
3
|
-
* (download files to disk, load vision images) — and stream `agent.invoke` with the assembled prompt.
|
|
4
|
-
* `invokeTurn` is the export; attachment resolution is an internal step. Split from parse.ts (which is
|
|
5
|
-
* pure) because this half touches the Bot API + disk; split from telegram.ts so the factory keeps only
|
|
6
|
-
* wiring and the per-turn lifecycle.
|
|
7
|
-
*/
|
|
8
|
-
import { SESSION_BUSY_CODE } from "../../agent.js";
|
|
9
1
|
import { log } from "../../log.js";
|
|
2
|
+
import { DEFAULT_BUSY_RETRY, attachedFilesManifest, attributedFileName, missingAttachmentsNote, streamTurnWithBusyRetry, } from "../invoke-turn-kit.js";
|
|
10
3
|
import { resolveFiles, resolveImages } from "./telegram-api.js";
|
|
11
4
|
/** Appended to the prompt (not the system prompt): the channel owns Telegram-HTML formatting. */
|
|
12
5
|
const HTML_INSTRUCTION = "\n\n(Format your reply in Telegram-supported HTML — <b> <i> <u> <s> <code> <pre> <a href> — not Markdown.)";
|
|
@@ -52,10 +45,7 @@ async function resolveTurnAttachments(t, attachments) {
|
|
|
52
45
|
log.warn(`[telegram] could not load an earlier (buffered) attachment: ${String(r.reason)}`);
|
|
53
46
|
}
|
|
54
47
|
}
|
|
55
|
-
const
|
|
56
|
-
const bufferedNote = missing > 0
|
|
57
|
-
? `\n[note: ${missing} attachment(s) from the earlier discussion are not loaded (expired, or older than the most recent few)]`
|
|
58
|
-
: "";
|
|
48
|
+
const missingNote = missingAttachmentsNote(lost + buffered.skipped);
|
|
59
49
|
// PRIMARY first, background after — consistent with "primary wins": what the user pointed at this
|
|
60
50
|
// turn leads. Buffered file entries are attributed like the fold's text lines ("the file Bob sent"
|
|
61
51
|
// resolves); buffered PHOTOS cannot be (ImageRef carries no label), so their attribution stops at
|
|
@@ -63,37 +53,18 @@ async function resolveTurnAttachments(t, attachments) {
|
|
|
63
53
|
// documented limit.
|
|
64
54
|
const allFiles = [
|
|
65
55
|
...(files ?? []),
|
|
66
|
-
...bufferedFiles.map(({ file, ref }) => ({
|
|
67
|
-
...file,
|
|
68
|
-
name: `${file.name} (from ${ref.from}${ref.msg !== undefined ? `, msg ${ref.msg}` : ""}, earlier discussion)`,
|
|
69
|
-
})),
|
|
56
|
+
...bufferedFiles.map(({ file, ref }) => ({ ...file, name: attributedFileName(file.name, ref.from, ref.msg) })),
|
|
70
57
|
];
|
|
71
|
-
const manifest = allFiles.length
|
|
72
|
-
? `\n\n[attached files — read them with your tools:\n${allFiles.map((f) => `- ${f.name} (${f.size} bytes) → ${f.path}`).join("\n")}\n]`
|
|
73
|
-
: "";
|
|
74
58
|
const allImages = [...(images ?? []), ...bufferedImages];
|
|
75
|
-
return {
|
|
59
|
+
return {
|
|
60
|
+
images: allImages.length ? allImages : undefined,
|
|
61
|
+
promptSuffix: `${missingNote}${attachedFilesManifest(allFiles)}`,
|
|
62
|
+
};
|
|
76
63
|
}
|
|
77
|
-
// Each retry is a lease-check-level reject (tryAcquire runs before harness assembly) — waiting is nearly
|
|
78
|
-
// free, and the loop exits within one delay of the holder finishing. So the cap is sized to outlast a
|
|
79
|
-
// real tool-using wake turn (minutes), not to be short: 10 min. CEILING: a holder that runs longer than
|
|
80
|
-
// this still surfaces the busy error to the user — the bound exists so a stuck lease can't hang a chat
|
|
81
|
-
// turn forever.
|
|
82
|
-
const DEFAULT_BUSY_RETRY = { delayMs: 5_000, maxWaitMs: 600_000 };
|
|
83
64
|
/**
|
|
84
|
-
* Run one turn: resolve its attachments, then stream agent.invoke
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* so a failure or crash at ANY earlier point leaves the buffer intact for the next summon (a re-folded
|
|
88
|
-
* block beats lost context). The caller uses it to remove the turn intent AND commit the context buffer,
|
|
89
|
-
* in that order (see the call site) so a crash between the two clears cannot replay a context-stripped turn.
|
|
90
|
-
*
|
|
91
|
-
* BUSY-WAIT: a `failed{code: session_busy}` FIRST event means an external turn (e.g. a self-scheduled
|
|
92
|
-
* wake) holds this session's lease and OUR turn never started — replay-safe. Retry (bounded) instead of
|
|
93
|
-
* yielding it: the user sees the "Thinking…" placeholder while waiting (the mirror of the scheduler
|
|
94
|
-
* deferring a wake INTO a busy session), and only an exhausted wait surfaces the busy failure. Only a
|
|
95
|
-
* FIRST-event busy retries — attachments are already resolved, and a fail-fast reject is the only shape
|
|
96
|
-
* the engine emits it in, so nothing that started is ever re-run.
|
|
65
|
+
* Run one turn: resolve its attachments, then stream agent.invoke with the shared busy-wait
|
|
66
|
+
* (invoke-turn-kit — `onCompleted` is the durable-commit point; see streamTurnWithBusyRetry). A
|
|
67
|
+
* primary-attachment failure surfaces as a `failed` event (never a silent drop).
|
|
97
68
|
*/
|
|
98
69
|
export async function* invokeTurn(agent, session, text, transport, attachments, onCompleted, busyRetry = DEFAULT_BUSY_RETRY) {
|
|
99
70
|
let resolved;
|
|
@@ -105,23 +76,5 @@ export async function* invokeTurn(agent, session, text, transport, attachments,
|
|
|
105
76
|
return;
|
|
106
77
|
}
|
|
107
78
|
const prompt = { text: `${text}${resolved.promptSuffix}${HTML_INSTRUCTION}`, images: resolved.images };
|
|
108
|
-
|
|
109
|
-
for (;;) {
|
|
110
|
-
let retryBusy = false;
|
|
111
|
-
let first = true;
|
|
112
|
-
for await (const e of agent.invoke({ session }, prompt)) {
|
|
113
|
-
if (first && e.type === "failed" && e.code === SESSION_BUSY_CODE && Date.now() + busyRetry.delayMs < deadline) {
|
|
114
|
-
retryBusy = true; // fail-fast reject — the stream ends after this event; wait and re-invoke
|
|
115
|
-
break;
|
|
116
|
-
}
|
|
117
|
-
first = false;
|
|
118
|
-
if (e.type === "completed")
|
|
119
|
-
onCompleted?.(); // the turn is durably in the session — commit point
|
|
120
|
-
yield e;
|
|
121
|
-
}
|
|
122
|
-
if (!retryBusy)
|
|
123
|
-
return;
|
|
124
|
-
log.info(`[telegram] session ${session} is busy (an external turn holds it) — retrying in ${busyRetry.delayMs}ms`);
|
|
125
|
-
await new Promise((r) => setTimeout(r, busyRetry.delayMs));
|
|
126
|
-
}
|
|
79
|
+
yield* streamTurnWithBusyRetry(agent, session, prompt, { label: "[telegram]", onCompleted, busyRetry });
|
|
127
80
|
}
|
|
@@ -6,14 +6,11 @@
|
|
|
6
6
|
* message, works in groups and private (unlike sendMessageDraft, which is private/forum-topic only).
|
|
7
7
|
*/
|
|
8
8
|
import type { AgentEvent } from "../../agent.ts";
|
|
9
|
+
import { type ChannelFailure, defaultErrorMessage } from "../preview-kit.ts";
|
|
9
10
|
import { type Target } from "./telegram-api.ts";
|
|
10
|
-
/** A terminal failure, as the channel hands it to `onError
|
|
11
|
-
export
|
|
12
|
-
|
|
13
|
-
retryable: boolean;
|
|
14
|
-
}
|
|
15
|
-
/** The customer-facing default: neutral, no leaked internals; differentiate only on whether to retry. */
|
|
16
|
-
export declare function defaultErrorMessage(failed: TelegramFailure): string;
|
|
11
|
+
/** A terminal failure, as the channel hands it to `onError` — the shared channel shape. */
|
|
12
|
+
export type TelegramFailure = ChannelFailure;
|
|
13
|
+
export { defaultErrorMessage };
|
|
17
14
|
/**
|
|
18
15
|
* Consume one turn's event stream into a Telegram chat, live (see the module header for the preview
|
|
19
16
|
* model). Preview edits are best-effort (logged once if they fail); the final write is authoritative
|
|
@@ -1,37 +1,14 @@
|
|
|
1
|
+
import { RETRY_NOTICE, THINKING_PLACEHOLDER, applyTurnEvent, composeTurnBody, createPreviewPump, createTurnView, defaultErrorMessage, revealedAnswer, thinkingLine, toolLines, } from "../preview-kit.js";
|
|
1
2
|
import { log } from "../../log.js";
|
|
2
3
|
import { TELEGRAM_MAX_TEXT, callApi, editMessageText, sendMessage } from "./telegram-api.js";
|
|
3
|
-
|
|
4
|
-
export function defaultErrorMessage(failed) {
|
|
5
|
-
return failed.retryable ? "⚠️ Temporary problem — please try again." : "⚠️ Sorry, something went wrong.";
|
|
6
|
-
}
|
|
4
|
+
export { defaultErrorMessage };
|
|
7
5
|
/** How often (ms) to edit the live-preview message; tool events still flush on the next loop. Edits to
|
|
8
6
|
* one message are rate-limited tighter than sends, so pace them ~1.5s (vs every token). Doubles as the
|
|
9
7
|
* answer-preview aging window (see answerView): partial answer text stays hidden until it has existed
|
|
10
8
|
* this long — one knob, same order of magnitude. */
|
|
11
9
|
const EDIT_THROTTLE_MS = 1500;
|
|
12
|
-
/** Max length of a tool's arg preview in the live view. */
|
|
13
|
-
const TOOL_ARG_MAX = 48;
|
|
14
10
|
/** How much of the (growing) reasoning to peek at in the live view — the most recent tail. */
|
|
15
11
|
const THINKING_PREVIEW = 280;
|
|
16
|
-
/** One-line, truncated: collapse whitespace so a multi-line command/arg stays on one line. */
|
|
17
|
-
function clip(s) {
|
|
18
|
-
const one = s.replace(/\s+/g, " ").trim();
|
|
19
|
-
return one.length > TOOL_ARG_MAX ? `${one.slice(0, TOOL_ARG_MAX - 1)}…` : one;
|
|
20
|
-
}
|
|
21
|
-
/**
|
|
22
|
-
* A compact, human-readable preview of a tool call's args so the live view reads `🔧 read AGENTS.md`
|
|
23
|
-
* rather than just `🔧 read`. Generic (the channel knows no tool schemas): show the salient value — the
|
|
24
|
-
* first primitive field, conventionally the subject (path / command / query / url) — else compact JSON.
|
|
25
|
-
*/
|
|
26
|
-
function summarizeArgs(args) {
|
|
27
|
-
if (args === null || typeof args !== "object" || Array.isArray(args))
|
|
28
|
-
return clip(String(args));
|
|
29
|
-
const values = Object.values(args);
|
|
30
|
-
const primary = values.find((v) => typeof v === "string" || typeof v === "number");
|
|
31
|
-
if (primary !== undefined)
|
|
32
|
-
return clip(String(primary));
|
|
33
|
-
return values.length > 0 ? clip(JSON.stringify(args)) : "";
|
|
34
|
-
}
|
|
35
12
|
/**
|
|
36
13
|
* The terminal-write POLICY: resolve the single preview message into `text`. streamReply owns the
|
|
37
14
|
* preview lifecycle, so this composition of transport primitives lives here, not in telegram-api. One
|
|
@@ -73,39 +50,18 @@ async function finalize(api, botToken, target, messageId, text, opts = {}) {
|
|
|
73
50
|
* and surfaces a real failure (bad token, etc.).
|
|
74
51
|
*/
|
|
75
52
|
export async function streamReply(events, api, botToken, target, formatError, previewId) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
let answer = "";
|
|
80
|
-
let answerPreviewSince;
|
|
81
|
-
const mark = { running: "…", ok: "✓", error: "✗" };
|
|
82
|
-
const toolView = () => tools.map((t) => `🔧 ${t.label} ${mark[t.status]}`).join("\n");
|
|
83
|
-
// Reasoning is process, not the answer: shown (capped to its tail) in the live preview only, never
|
|
84
|
-
// in the persisted final message (which is `answer` alone).
|
|
85
|
-
const thinkingView = () => {
|
|
86
|
-
const t = thinking.replace(/\s+/g, " ").trim();
|
|
87
|
-
if (t === "")
|
|
88
|
-
return "";
|
|
89
|
-
return `💭 ${t.length > THINKING_PREVIEW ? `…${t.slice(t.length - THINKING_PREVIEW + 1)}` : t}`;
|
|
90
|
-
};
|
|
91
|
-
// The answer is hidden until its first delta has aged one EDIT_THROTTLE_MS: the pump's leading-edge
|
|
92
|
-
// flush would otherwise turn the very first content delta (often a lone character or unbalanced markup)
|
|
93
|
-
// into its own Telegram edit — the short-reply flicker (placeholder → "O" → "OK."). Aging is anchored
|
|
94
|
-
// at delta ARRIVAL (set in the event loop, not here) so an in-flight edit can't skew the clock, and
|
|
95
|
-
// there is deliberately NO timer at the boundary: a young answer surfaces on the next content-driven
|
|
96
|
-
// preview pass, so a turn completing within the window sends the final answer edit only.
|
|
97
|
-
const answerView = () => {
|
|
98
|
-
if (answer.trim() === "" || answerPreviewSince === undefined)
|
|
99
|
-
return "";
|
|
100
|
-
return Date.now() - answerPreviewSince >= EDIT_THROTTLE_MS ? answer : "";
|
|
101
|
-
};
|
|
53
|
+
// Event → view-state reduction is the shared machine (preview-kit); this renderer owns the reveal
|
|
54
|
+
// policy, formatting, and delivery below.
|
|
55
|
+
const turn = createTurnView();
|
|
102
56
|
const view = () => {
|
|
103
|
-
const v = [
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
.
|
|
57
|
+
const v = composeTurnBody([
|
|
58
|
+
thinkingLine(turn, THINKING_PREVIEW),
|
|
59
|
+
toolLines(turn),
|
|
60
|
+
turn.retrying ? RETRY_NOTICE : "",
|
|
61
|
+
revealedAnswer(turn, EDIT_THROTTLE_MS),
|
|
62
|
+
]);
|
|
107
63
|
// Before any reasoning/tool/text arrives, show an explicit placeholder rather than an empty edit.
|
|
108
|
-
return v === "" ?
|
|
64
|
+
return v === "" ? THINKING_PLACEHOLDER : v;
|
|
109
65
|
};
|
|
110
66
|
// The live preview is ONE real message: sent once (capturing its id + threading under the asker),
|
|
111
67
|
// then edited in place. messageId/lastSent are shared with the final write on completion.
|
|
@@ -134,103 +90,27 @@ export async function streamReply(events, api, botToken, target, formatError, pr
|
|
|
134
90
|
if (messageId === undefined)
|
|
135
91
|
throw new Error("telegram sendMessage returned ok without a message_id — live preview disabled for this turn");
|
|
136
92
|
};
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
let pumping = false;
|
|
145
|
-
let stopped = false;
|
|
146
|
-
let previewErrLogged = false;
|
|
147
|
-
let pumpDone;
|
|
148
|
-
let wakeThrottle; // set while the pump is mid-throttle; finish() cuts it short
|
|
149
|
-
const runPump = async () => {
|
|
150
|
-
pumping = true;
|
|
151
|
-
try {
|
|
152
|
-
while (dirty && !stopped) {
|
|
153
|
-
dirty = false;
|
|
154
|
-
try {
|
|
155
|
-
await flushPreview();
|
|
156
|
-
}
|
|
157
|
-
catch (e) {
|
|
158
|
-
// Best-effort preview (the final write is authoritative), but a failing edit must be visible —
|
|
159
|
-
// log once per turn so a never-rendering preview is diagnosable, not silent.
|
|
160
|
-
if (!previewErrLogged) {
|
|
161
|
-
previewErrLogged = true;
|
|
162
|
-
log.warn(`[telegram] live preview failed (final reply still sends): ${String(e)}`);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
if (dirty && !stopped) {
|
|
166
|
-
// Pace + coalesce a burst into one edit. Interruptible: finish() cuts this short so the final
|
|
167
|
-
// write is not delayed by up to EDIT_THROTTLE_MS after the turn completes.
|
|
168
|
-
await new Promise((resolve) => {
|
|
169
|
-
const t = setTimeout(resolve, EDIT_THROTTLE_MS);
|
|
170
|
-
wakeThrottle = () => {
|
|
171
|
-
clearTimeout(t);
|
|
172
|
-
resolve();
|
|
173
|
-
};
|
|
174
|
-
});
|
|
175
|
-
wakeThrottle = undefined;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
finally {
|
|
180
|
-
pumping = false;
|
|
181
|
-
}
|
|
182
|
-
};
|
|
183
|
-
// Mark the preview dirty and ensure the single writer is running (an edit already in flight picks up
|
|
184
|
-
// the new state on its next loop). Synchronous — callers never await a network write.
|
|
185
|
-
const touch = () => {
|
|
186
|
-
dirty = true;
|
|
187
|
-
if (!pumping)
|
|
188
|
-
pumpDone = runPump();
|
|
189
|
-
};
|
|
93
|
+
// The shared single-writer pump (preview-kit) serializes edits to the one preview message. (No
|
|
94
|
+
// keepalive: a real message does not expire, unlike a Bot API `sendMessageDraft` (30s window).)
|
|
95
|
+
const { touch, finish } = createPreviewPump({
|
|
96
|
+
flush: flushPreview,
|
|
97
|
+
throttleMs: EDIT_THROTTLE_MS,
|
|
98
|
+
onError: (e) => log.warn(`[telegram] live preview failed (final reply still sends): ${String(e)}`),
|
|
99
|
+
});
|
|
190
100
|
touch(); // send the "💭 Thinking…" placeholder immediately
|
|
191
|
-
// Stop the pump and await any in-flight edit, so the final write below is strictly the LAST one to the
|
|
192
|
-
// preview message (no stale frame landing after the answer).
|
|
193
|
-
const finish = async () => {
|
|
194
|
-
stopped = true;
|
|
195
|
-
wakeThrottle?.(); // cut an in-flight throttle so the final write is not delayed up to EDIT_THROTTLE_MS
|
|
196
|
-
await pumpDone?.catch(() => { });
|
|
197
|
-
};
|
|
198
101
|
try {
|
|
199
102
|
for await (const e of events) {
|
|
200
|
-
if (e.type === "
|
|
201
|
-
answer += e.delta;
|
|
202
|
-
if (answerPreviewSince === undefined && answer.trim() !== "")
|
|
203
|
-
answerPreviewSince = Date.now();
|
|
204
|
-
touch();
|
|
205
|
-
}
|
|
206
|
-
else if (e.type === "thinking") {
|
|
207
|
-
thinking += e.delta;
|
|
208
|
-
touch();
|
|
209
|
-
}
|
|
210
|
-
else if (e.type === "tool_started") {
|
|
211
|
-
const arg = summarizeArgs(e.args);
|
|
212
|
-
toolIndexById.set(e.id, tools.length);
|
|
213
|
-
tools.push({ label: arg ? `${e.name} ${arg}` : e.name, status: "running" });
|
|
214
|
-
touch();
|
|
215
|
-
}
|
|
216
|
-
else if (e.type === "tool_ended") {
|
|
217
|
-
const i = toolIndexById.get(e.id);
|
|
218
|
-
const t = i === undefined ? undefined : tools[i];
|
|
219
|
-
if (t)
|
|
220
|
-
t.status = e.isError ? "error" : "ok";
|
|
221
|
-
touch();
|
|
222
|
-
}
|
|
223
|
-
else if (e.type === "completed") {
|
|
103
|
+
if (e.type === "completed") {
|
|
224
104
|
await finish();
|
|
225
105
|
// Edit the preview into the final answer (HTML, plain fallback); the persisted message is the
|
|
226
106
|
// answer alone — the process (thinking/tools) was preview-only. Mark finalized BEFORE delivering:
|
|
227
107
|
// the terminal was reached, so a delivery failure here is a plain failure, not an "abnormal exit"
|
|
228
108
|
// (which would wrongly fire the finally's neutral-notice fallback = double delivery + wrong text).
|
|
229
109
|
finalized = true;
|
|
230
|
-
await finalize(api, botToken, target, messageId, answer.trim() !== "" ? answer : "(no reply)");
|
|
110
|
+
await finalize(api, botToken, target, messageId, turn.answer.trim() !== "" ? turn.answer : "(no reply)");
|
|
231
111
|
return;
|
|
232
112
|
}
|
|
233
|
-
|
|
113
|
+
if (e.type === "failed") {
|
|
234
114
|
await finish();
|
|
235
115
|
// Two audiences: the chat (customer-facing — formatError, neutral by default) and the operator
|
|
236
116
|
// log (dev-facing — the full details, via the throw below + the handler's catch). Same terminal
|
|
@@ -245,6 +125,8 @@ export async function streamReply(events, api, botToken, target, formatError, pr
|
|
|
245
125
|
}
|
|
246
126
|
throw new Error(`agent failed: ${e.details} (retryable=${e.retryable})`);
|
|
247
127
|
}
|
|
128
|
+
if (applyTurnEvent(turn, e))
|
|
129
|
+
touch();
|
|
248
130
|
}
|
|
249
131
|
throw new Error("stream ended without a terminal event"); // violates SPEC MUST 1
|
|
250
132
|
}
|
|
@@ -32,6 +32,7 @@ import { collectAttachments, createContextBuffer } from "./context-buffer.js";
|
|
|
32
32
|
import { attachmentSummary, defaultTelegramRoute, extractFiles, extractImages, fromLabel, messageText, ownFiles, ownImages, pickMessage, telegramEnvelope, } from "./parse.js";
|
|
33
33
|
import { defaultErrorMessage, streamReply } from "./preview.js";
|
|
34
34
|
import { ensureStateHome } from "../state.js";
|
|
35
|
+
import { dispatchStop } from "../stop-command.js";
|
|
35
36
|
import { callApi, editMessageText, sendMessage } from "./telegram-api.js";
|
|
36
37
|
import { createTurnQueue } from "../turn-queue.js";
|
|
37
38
|
import { createTurnStore } from "./turn-store.js";
|
|
@@ -67,15 +68,14 @@ function tokenMatches(header, secret) {
|
|
|
67
68
|
* share the first's turn-store/context-buffer. One telegram instance per workspace (single-process).
|
|
68
69
|
*/
|
|
69
70
|
export function telegramChannel({ secretToken, botToken, route, onError, botUsername, apiBaseUrl = "https://api.telegram.org", }) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
return ({ agent, stateRoot }) => {
|
|
71
|
+
return ({ agent, stateRoot, control }) => {
|
|
72
|
+
// Validate at activation so deploy may inspect the module shape before secrets are provisioned.
|
|
73
|
+
if (!secretToken) {
|
|
74
|
+
throw new Error("telegramChannel requires a non-empty secretToken (the webhook secret_token; an unset one accepts forged updates)");
|
|
75
|
+
}
|
|
76
|
+
if (!botToken) {
|
|
77
|
+
throw new Error("telegramChannel requires a non-empty botToken (used to send the agent's reply)");
|
|
78
|
+
}
|
|
79
79
|
const formatError = onError ?? defaultErrorMessage;
|
|
80
80
|
// One getMe at startup: the bot's @username (for the default route's group @mention summon, only when
|
|
81
81
|
// not supplied) and the group-privacy flag — privacy mode off is required to receive the un-summoned
|
|
@@ -323,6 +323,20 @@ export function telegramChannel({ secretToken, botToken, route, onError, botUser
|
|
|
323
323
|
// that explicitly returns the same chat/thread still quotes.
|
|
324
324
|
const threadId = r.threadId ?? m.message_thread_id;
|
|
325
325
|
const sameTarget = String(chatId) === String(m.chat.id) && threadId === m.message_thread_id;
|
|
326
|
+
// Explicit user stop (`/stop`): a control action, never a turn — it must not queue behind the
|
|
327
|
+
// run it stops. `/stop@otherbot` is not ours; a bare `/stop` always is. Awaited before the ACK:
|
|
328
|
+
// dispatch + one sendMessage is fast, and a delivery failure logs instead of failing the webhook.
|
|
329
|
+
const stopMatch = /^\/stop(?:@([A-Za-z0-9_]+))?$/i.exec(messageText(m).trim());
|
|
330
|
+
if (stopMatch && (!stopMatch[1] || stopMatch[1].toLowerCase() === mentionName?.toLowerCase())) {
|
|
331
|
+
const feedback = await dispatchStop(control, session, "[telegram]");
|
|
332
|
+
const target = {
|
|
333
|
+
chatId,
|
|
334
|
+
threadId,
|
|
335
|
+
replyTo: m.chat.type !== "private" && sameTarget ? m.message_id : undefined,
|
|
336
|
+
};
|
|
337
|
+
await sendMessage(apiBaseUrl, botToken, target, feedback, { html: false }).catch((e) => log.warn(`[telegram] stop feedback failed: ${String(e)}`));
|
|
338
|
+
return new Response(null, { status: 200 });
|
|
339
|
+
}
|
|
326
340
|
const baseText = r.text ?? telegramEnvelope(m);
|
|
327
341
|
const imageFileIds = extractImages(m);
|
|
328
342
|
const fileIds = extractFiles(m);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Pure Unicode-safe text slicing helpers shared by Feishu/Lark
|
|
1
|
+
/** Pure Unicode-safe text slicing helpers shared by channel rendering paths (Feishu/Lark cards, the preview kit). JavaScript string
|
|
2
2
|
* indexes are UTF-16 code units, so direct `slice()` can tear a surrogate pair and send replacement
|
|
3
3
|
* characters after JSON/UTF-8 encoding. These helpers only cut at Unicode code-point boundaries. */
|
|
4
4
|
/** Take at most `maxPoints` Unicode code points from the start, without adding a marker. */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Pure Unicode-safe text slicing helpers shared by Feishu/Lark
|
|
1
|
+
/** Pure Unicode-safe text slicing helpers shared by channel rendering paths (Feishu/Lark cards, the preview kit). JavaScript string
|
|
2
2
|
* indexes are UTF-16 code units, so direct `slice()` can tear a surrogate pair and send replacement
|
|
3
3
|
* characters after JSON/UTF-8 encoding. These helpers only cut at Unicode code-point boundaries. */
|
|
4
4
|
/** Take at most `maxPoints` Unicode code points from the start, without adding a marker. */
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* instead of colliding on the lease and being dropped as "busy").
|
|
6
6
|
*
|
|
7
7
|
* Channel-neutral (records are opaque beyond a `session` key), shared by the stateful chat channels
|
|
8
|
-
* (
|
|
8
|
+
* (Telegram, Feishu/Lark, Slack). Durability is layered ON TOP by the caller: turn-store.ts
|
|
9
9
|
* persists an accepted turn's intent pre-ACK and replays a crash-surviving one on the next start (L1,
|
|
10
10
|
* process-crash recovery, at-least-once). Exactly-once / deterministic step-replay (L2) is the K-axis
|
|
11
11
|
* backend — an external queue with distributed locking (SPEC §11) — not this in-memory queue.
|
|
@@ -9,7 +9,7 @@ export interface TurnRecordBase {
|
|
|
9
9
|
attempts: number;
|
|
10
10
|
}
|
|
11
11
|
export interface TurnStore<T extends TurnRecordBase> {
|
|
12
|
-
/** Persist an accepted turn before the ACK. A failed write throws (→
|
|
12
|
+
/** Persist an accepted turn before the ACK. A failed write throws (→ HTTP response / WS ACK 500, so the platform redelivers). */
|
|
13
13
|
add(rec: T): void;
|
|
14
14
|
/** Remove a finished turn. Post-ACK: a failed write is logged, never thrown (must not abort delivery). */
|
|
15
15
|
remove(id: string): void;
|
|
@@ -31,8 +31,7 @@
|
|
|
31
31
|
*
|
|
32
32
|
* Channel-neutral, generic over the record shape: the channel supplies its own record type (everything
|
|
33
33
|
* its runner needs to re-execute the turn), the shape validator (state files are an IO boundary), the
|
|
34
|
-
* arrival ordering for recovery, and its log label.
|
|
35
|
-
* two consumers.
|
|
34
|
+
* arrival ordering for recovery, and its log label. Telegram, Feishu/Lark, and Slack consume it.
|
|
36
35
|
*
|
|
37
36
|
* ponytail: at-least-once with a per-turn EXECUTION ceiling. A poison turn that deterministically
|
|
38
37
|
* crashes the process would replay forever under a container restart policy. The counter is bumped at
|
|
@@ -84,7 +83,7 @@ export function createTurnStore(path, opts) {
|
|
|
84
83
|
return;
|
|
85
84
|
turns.set(rec.id, rec);
|
|
86
85
|
try {
|
|
87
|
-
persist(); // pre-ACK:
|
|
86
|
+
persist(); // pre-ACK: the transport maps this throw to HTTP/WS 500, so the platform redelivers
|
|
88
87
|
}
|
|
89
88
|
catch (e) {
|
|
90
89
|
// Roll the memory back so it matches disk (mirrors context-buffer.push): otherwise the phantom
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type FeishuGroupBehavior, type FeishuSubscriptionMode } from "../channels/feishu/setup-mode.ts";
|
|
2
|
+
import { type FeishuApi } from "../channels/feishu/feishu-api.ts";
|
|
3
|
+
import { type GroupBehaviorChoice } from "../scaffold/add-channel.ts";
|
|
4
|
+
export interface GroupBehaviorSetup {
|
|
5
|
+
/** Safe to proceed to version publishing now; false means Permissions still needs manual/admin work. */
|
|
6
|
+
publishReady: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare function configureGroupBehavior(input: {
|
|
9
|
+
kind: "feishu" | "lark";
|
|
10
|
+
appId: string;
|
|
11
|
+
apiBase: string;
|
|
12
|
+
api: Pick<FeishuApi, "listAppScopes" | "addAppScopes">;
|
|
13
|
+
behavior: FeishuGroupBehavior;
|
|
14
|
+
/** Whether the author chose the behavior (flag or prompt). A defaulted "context" inspects and
|
|
15
|
+
* reports only — it must never PATCH the sensitive scope into the app draft. */
|
|
16
|
+
explicit: boolean;
|
|
17
|
+
note?: (message: string) => void;
|
|
18
|
+
openUrl?: (url: string) => void;
|
|
19
|
+
}): Promise<GroupBehaviorSetup>;
|
|
20
|
+
/**
|
|
21
|
+
* Create or resume the platform app behind `add feishu` / `add lark`. Returns credentials for the
|
|
22
|
+
* caller's generic .env write (the guided Lark path), or undefined when nothing remains to write —
|
|
23
|
+
* the feishu path persists its own two credential stages internally (the App ID/Secret boundary is
|
|
24
|
+
* irreversible and must not wait for the caller). Throws on refusal (a committable .env, a
|
|
25
|
+
* non-interactive lark run); the caller surfaces that as a startup failure.
|
|
26
|
+
*/
|
|
27
|
+
export declare function onboardFeishuCloudApp(target: string, kind: "feishu" | "lark", envIgnored: boolean, ingress?: FeishuSubscriptionMode, groupBehavior?: GroupBehaviorChoice): Promise<Record<string, string> | undefined>;
|