@blockrun/franklin 3.26.1 → 3.27.1
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/dist/agent/llm.js +9 -1
- package/dist/agent/loop.js +14 -4
- package/dist/agent/streaming-executor.js +28 -2
- package/dist/agent/types.d.ts +4 -0
- package/dist/channel/slack.d.ts +57 -0
- package/dist/channel/slack.js +370 -0
- package/dist/channel/telegram.d.ts +4 -0
- package/dist/channel/telegram.js +85 -16
- package/dist/commands/config.d.ts +7 -0
- package/dist/commands/config.js +7 -0
- package/dist/commands/serve.d.ts +7 -0
- package/dist/commands/serve.js +7 -0
- package/dist/commands/slack.d.ts +15 -0
- package/dist/commands/slack.js +118 -0
- package/dist/commands/telegram.js +9 -0
- package/dist/index.js +23 -1
- package/dist/router/index.js +10 -7
- package/dist/serve/cloud-sync.d.ts +23 -0
- package/dist/serve/cloud-sync.js +120 -0
- package/dist/serve/server.d.ts +25 -0
- package/dist/serve/server.js +767 -0
- package/dist/stats/swap-log.d.ts +19 -0
- package/dist/stats/swap-log.js +33 -0
- package/dist/tools/zerox-base.js +36 -1
- package/dist/tools/zerox-gasless.js +17 -0
- package/package.json +2 -1
package/dist/agent/llm.js
CHANGED
|
@@ -402,7 +402,7 @@ export class ModelClient {
|
|
|
402
402
|
* default model.
|
|
403
403
|
*/
|
|
404
404
|
resolveVirtualModel(model) {
|
|
405
|
-
if (!model.startsWith('blockrun/'))
|
|
405
|
+
if (!model || !model.startsWith('blockrun/'))
|
|
406
406
|
return model;
|
|
407
407
|
try {
|
|
408
408
|
const profile = parseRoutingProfile(model);
|
|
@@ -440,6 +440,14 @@ export class ModelClient {
|
|
|
440
440
|
// Reset the per-call charge tracker. signBasePayment / signSolanaPayment
|
|
441
441
|
// will set it when the gateway demands a 402 settlement.
|
|
442
442
|
this.lastPaidUsd = 0;
|
|
443
|
+
// Guard: a missing/non-string model (e.g. a flaky-gateway fallback that
|
|
444
|
+
// produced undefined) must not hard-crash with a cryptic
|
|
445
|
+
// "reading 'startsWith'". Normalize to the routing profile, which resolves
|
|
446
|
+
// to a concrete model below.
|
|
447
|
+
if (!request.model || typeof request.model !== 'string') {
|
|
448
|
+
console.error('[franklin] request.model was missing — defaulting to blockrun/auto');
|
|
449
|
+
request = { ...request, model: 'blockrun/auto' };
|
|
450
|
+
}
|
|
443
451
|
// Resolve virtual models before any API call
|
|
444
452
|
const resolvedModel = this.resolveVirtualModel(request.model);
|
|
445
453
|
if (resolvedModel !== request.model) {
|
package/dist/agent/loop.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* The core reasoning-action cycle: prompt → model → extract capabilities → execute → repeat.
|
|
4
4
|
*/
|
|
5
5
|
import { ModelClient } from './llm.js';
|
|
6
|
-
import { autoCompactIfNeeded, forceCompact, microCompact } from './compact.js';
|
|
6
|
+
import { autoCompactIfNeeded, forceCompact, microCompact, projectCompactionSavings } from './compact.js';
|
|
7
7
|
import { estimateHistoryTokens, updateActualTokens, resetTokenAnchor, getAnchoredTokenCount, getContextWindow, setEstimationModel } from './tokens.js';
|
|
8
8
|
import { handleSlashCommand } from './commands.js';
|
|
9
9
|
import { loadBundledSkills, getSkillVars } from '../skills/bootstrap.js';
|
|
@@ -1054,10 +1054,20 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
1054
1054
|
// compacting (the compact itself runs on a cheaper model
|
|
1055
1055
|
// and costs <$0.05).
|
|
1056
1056
|
const TURN_COST_CAP_FOR_EARLY_COMPACT = 1.00;
|
|
1057
|
-
|
|
1057
|
+
// ROI gate: forceCompact (used below) has no savings check of its own, so
|
|
1058
|
+
// without this it fires even on a tiny history and reports "saved 1%" —
|
|
1059
|
+
// a wasted summarizer round-trip. Only compact when the projected savings
|
|
1060
|
+
// clear the floor (≥20%), which a small history can never do.
|
|
1061
|
+
// The ROI gate applies ONLY to the call-count trigger: the $1.00 cost cap
|
|
1062
|
+
// is an emergency brake (see the 2026-05-11 note above) and must fire
|
|
1063
|
+
// even when projected savings are low — gating it would reintroduce the
|
|
1064
|
+
// $9.45 runaway it was added to stop.
|
|
1065
|
+
const bloatTriggered = (turnToolCalls > 15 && turnCostUsd > 0.03 && projectCompactionSavings(history).worthIt) ||
|
|
1066
|
+
turnCostUsd > TURN_COST_CAP_FOR_EARLY_COMPACT;
|
|
1067
|
+
if (config.costSaver !== false &&
|
|
1068
|
+
!bloatCompactedThisTurn &&
|
|
1058
1069
|
compactFailures < 3 &&
|
|
1059
|
-
|
|
1060
|
-
turnCostUsd > TURN_COST_CAP_FOR_EARLY_COMPACT)) {
|
|
1070
|
+
bloatTriggered) {
|
|
1061
1071
|
try {
|
|
1062
1072
|
const beforeTokens = estimateHistoryTokens(history);
|
|
1063
1073
|
const { history: compacted, compacted: didCompact } = await forceCompact(history, config.model, client, config.debug);
|
|
@@ -285,8 +285,34 @@ export class StreamingExecutor {
|
|
|
285
285
|
case 'WebFetch':
|
|
286
286
|
case 'WebSearch':
|
|
287
287
|
return (input.url ?? input.query) || undefined;
|
|
288
|
-
default:
|
|
289
|
-
|
|
288
|
+
default: {
|
|
289
|
+
// Generic fallback so EVERY tool shows what it's doing. For enum/router
|
|
290
|
+
// tools (e.g. Surf*) the `endpoint` is the real action — show it, paired
|
|
291
|
+
// with the most relevant param, e.g. "market/etf · BTC". Otherwise pick
|
|
292
|
+
// the single most meaningful argument.
|
|
293
|
+
const PARAM_KEYS = [
|
|
294
|
+
'query', 'q', 'search', 'prompt', 'question', 'text',
|
|
295
|
+
'symbol', 'pair', 'metric', 'indicator', 'ticker', 'coin', 'asset', 'market',
|
|
296
|
+
'protocol', 'handle', 'chain', 'address', 'addresses', 'hash', 'conditionId',
|
|
297
|
+
'url', 'id', 'slug', 'name', 'path', 'pattern', 'to', 'number',
|
|
298
|
+
];
|
|
299
|
+
const firstParam = () => {
|
|
300
|
+
for (const k of PARAM_KEYS) {
|
|
301
|
+
const v = input[k];
|
|
302
|
+
if (typeof v === 'string' && v.trim())
|
|
303
|
+
return v.trim();
|
|
304
|
+
}
|
|
305
|
+
return '';
|
|
306
|
+
};
|
|
307
|
+
// The "action" field (endpoint / action) is the real verb — show it even
|
|
308
|
+
// when there's no param (e.g. PredictionMarket `leaderboard`).
|
|
309
|
+
const action = (typeof input.endpoint === 'string' && input.endpoint.trim()) ||
|
|
310
|
+
(typeof input.action === 'string' && input.action.trim()) || '';
|
|
311
|
+
const combined = [action, firstParam()].filter(Boolean).join(' · ');
|
|
312
|
+
if (!combined)
|
|
313
|
+
return undefined;
|
|
314
|
+
return combined.length > 80 ? combined.slice(0, 80) + '…' : combined;
|
|
315
|
+
}
|
|
290
316
|
}
|
|
291
317
|
}
|
|
292
318
|
}
|
package/dist/agent/types.d.ts
CHANGED
|
@@ -187,4 +187,8 @@ export interface AgentConfig {
|
|
|
187
187
|
maxSpendUsd?: number;
|
|
188
188
|
/** Show user-visible harness prefetch status lines (interactive UX only). */
|
|
189
189
|
showPrefetchStatus?: boolean;
|
|
190
|
+
/** Mid-turn "research-bloat" compaction — summarizes history when a turn
|
|
191
|
+
* racks up many tool calls + spend, to cut input-replay cost. Default on;
|
|
192
|
+
* set false to disable (the desktop exposes this as a toggle). */
|
|
193
|
+
costSaver?: boolean;
|
|
190
194
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slack ingress channel — drive Franklin from a Slack workspace.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: same motivation as the Telegram channel, but for teams.
|
|
5
|
+
* A persistent agent with a wallet is most useful when a whole channel can
|
|
6
|
+
* reach it. This module wraps Franklin's `interactiveSession` with a Slack
|
|
7
|
+
* Socket Mode connection: inbound @mentions (and DMs) → agent turn → streamed
|
|
8
|
+
* text deltas posted back into the originating thread.
|
|
9
|
+
*
|
|
10
|
+
* Multi-user: unlike Telegram's single-owner lock, Slack uses an allowlist of
|
|
11
|
+
* user ids (`SLACK_ALLOWED_USERS`). Anyone on the list can @mention the bot in
|
|
12
|
+
* a channel or DM it; everyone else is ignored. The wallet is real money, so
|
|
13
|
+
* an empty allowlist denies everyone by default.
|
|
14
|
+
*
|
|
15
|
+
* Session model (MVP v1): ONE shared session for the running bot, exactly like
|
|
16
|
+
* the Telegram channel. All authorized users share a single Franklin
|
|
17
|
+
* conversation. Replies always land in a thread so the channel stays tidy.
|
|
18
|
+
* NOTE: Hermes-style per-thread isolation (a separate concurrent session per
|
|
19
|
+
* Slack thread) is the planned v2 — it needs a session-manager that runs
|
|
20
|
+
* multiple `interactiveSession` instances at once, which this single-queue
|
|
21
|
+
* design intentionally does not do yet.
|
|
22
|
+
*
|
|
23
|
+
* Transport: Socket Mode (WebSocket via @slack/bolt), not Events API webhooks.
|
|
24
|
+
* Works behind NAT / through laptop sleep-wake without a public HTTPS endpoint.
|
|
25
|
+
*/
|
|
26
|
+
import type { AgentConfig } from '../agent/types.js';
|
|
27
|
+
export interface SlackOptions {
|
|
28
|
+
/** Bot User OAuth token (xoxb-…), from the Slack app's OAuth page. */
|
|
29
|
+
botToken: string;
|
|
30
|
+
/** App-level token (xapp-…) with connections:write, for Socket Mode. */
|
|
31
|
+
appToken: string;
|
|
32
|
+
/** Slack user ids allowed to drive the bot. Empty set denies everyone. */
|
|
33
|
+
allowedUsers: Set<string>;
|
|
34
|
+
/** Verbose: log every inbound event and turn on bolt's DEBUG logging. */
|
|
35
|
+
debug?: boolean;
|
|
36
|
+
/** Called with each user-facing log line so the CLI can print them. */
|
|
37
|
+
log?: (line: string) => void;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Split a long agent response into Slack-sized chunks. Prefers newline
|
|
41
|
+
* boundaries, falls back to a hard character split for pathological inputs.
|
|
42
|
+
* Short responses return a single chunk. Mirrors `splitForTelegram`.
|
|
43
|
+
*/
|
|
44
|
+
export declare function splitForSlack(text: string, max?: number): string[];
|
|
45
|
+
/**
|
|
46
|
+
* Progressive flush: given a growing buffer, return `{flush, keep}` where
|
|
47
|
+
* `flush` ends at a paragraph boundary and `keep` is the trailing partial to
|
|
48
|
+
* hold until more arrives. Identical strategy to the Telegram channel.
|
|
49
|
+
*/
|
|
50
|
+
export declare function takeProgressiveChunk(buffer: string, threshold?: number, hardCap?: number): {
|
|
51
|
+
flush: string;
|
|
52
|
+
keep: string;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Start the bot. Resolves only on fatal error; the outer CLI handles SIGINT.
|
|
56
|
+
*/
|
|
57
|
+
export declare function runSlackBot(agentConfig: AgentConfig, opts: SlackOptions): Promise<void>;
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slack ingress channel — drive Franklin from a Slack workspace.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: same motivation as the Telegram channel, but for teams.
|
|
5
|
+
* A persistent agent with a wallet is most useful when a whole channel can
|
|
6
|
+
* reach it. This module wraps Franklin's `interactiveSession` with a Slack
|
|
7
|
+
* Socket Mode connection: inbound @mentions (and DMs) → agent turn → streamed
|
|
8
|
+
* text deltas posted back into the originating thread.
|
|
9
|
+
*
|
|
10
|
+
* Multi-user: unlike Telegram's single-owner lock, Slack uses an allowlist of
|
|
11
|
+
* user ids (`SLACK_ALLOWED_USERS`). Anyone on the list can @mention the bot in
|
|
12
|
+
* a channel or DM it; everyone else is ignored. The wallet is real money, so
|
|
13
|
+
* an empty allowlist denies everyone by default.
|
|
14
|
+
*
|
|
15
|
+
* Session model (MVP v1): ONE shared session for the running bot, exactly like
|
|
16
|
+
* the Telegram channel. All authorized users share a single Franklin
|
|
17
|
+
* conversation. Replies always land in a thread so the channel stays tidy.
|
|
18
|
+
* NOTE: Hermes-style per-thread isolation (a separate concurrent session per
|
|
19
|
+
* Slack thread) is the planned v2 — it needs a session-manager that runs
|
|
20
|
+
* multiple `interactiveSession` instances at once, which this single-queue
|
|
21
|
+
* design intentionally does not do yet.
|
|
22
|
+
*
|
|
23
|
+
* Transport: Socket Mode (WebSocket via @slack/bolt), not Events API webhooks.
|
|
24
|
+
* Works behind NAT / through laptop sleep-wake without a public HTTPS endpoint.
|
|
25
|
+
*/
|
|
26
|
+
import { setupAgentWallet, setupAgentSolanaWallet } from '@blockrun/llm';
|
|
27
|
+
import { interactiveSession } from '../agent/loop.js';
|
|
28
|
+
import { ModelClient } from '../agent/llm.js';
|
|
29
|
+
import { extractBrainEntities } from '../brain/extract.js';
|
|
30
|
+
import { extractLearnings } from '../learnings/extractor.js';
|
|
31
|
+
// Slack's hard per-message cap is ~40 KB, but readability tanks long before
|
|
32
|
+
// that. Keep chunks small so a long answer arrives as a few tidy messages.
|
|
33
|
+
const CHUNK_MAX = 3500;
|
|
34
|
+
// Progressive flush: emit a partial message once the buffer crosses this and
|
|
35
|
+
// hits a paragraph boundary, mirroring the Telegram channel's behaviour.
|
|
36
|
+
const PROGRESSIVE_FLUSH_MIN = 1200;
|
|
37
|
+
/**
|
|
38
|
+
* Split a long agent response into Slack-sized chunks. Prefers newline
|
|
39
|
+
* boundaries, falls back to a hard character split for pathological inputs.
|
|
40
|
+
* Short responses return a single chunk. Mirrors `splitForTelegram`.
|
|
41
|
+
*/
|
|
42
|
+
export function splitForSlack(text, max = CHUNK_MAX) {
|
|
43
|
+
if (text.length <= max)
|
|
44
|
+
return [text];
|
|
45
|
+
const chunks = [];
|
|
46
|
+
let remaining = text;
|
|
47
|
+
while (remaining.length > max) {
|
|
48
|
+
const windowEnd = Math.min(max, remaining.length);
|
|
49
|
+
const nlIdx = remaining.lastIndexOf('\n', windowEnd - 1);
|
|
50
|
+
const cut = nlIdx > Math.floor(max * 0.5) ? nlIdx + 1 : windowEnd;
|
|
51
|
+
chunks.push(remaining.slice(0, cut));
|
|
52
|
+
remaining = remaining.slice(cut);
|
|
53
|
+
}
|
|
54
|
+
if (remaining.length > 0)
|
|
55
|
+
chunks.push(remaining);
|
|
56
|
+
return chunks;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Progressive flush: given a growing buffer, return `{flush, keep}` where
|
|
60
|
+
* `flush` ends at a paragraph boundary and `keep` is the trailing partial to
|
|
61
|
+
* hold until more arrives. Identical strategy to the Telegram channel.
|
|
62
|
+
*/
|
|
63
|
+
export function takeProgressiveChunk(buffer, threshold = PROGRESSIVE_FLUSH_MIN, hardCap = CHUNK_MAX) {
|
|
64
|
+
const mustFlush = buffer.length > hardCap;
|
|
65
|
+
if (!mustFlush && buffer.length < threshold) {
|
|
66
|
+
return { flush: '', keep: buffer };
|
|
67
|
+
}
|
|
68
|
+
const preferPos = buffer.lastIndexOf('\n\n', Math.min(buffer.length, hardCap) - 1);
|
|
69
|
+
if (preferPos > Math.floor(threshold * 0.5)) {
|
|
70
|
+
return { flush: buffer.slice(0, preferPos + 2), keep: buffer.slice(preferPos + 2) };
|
|
71
|
+
}
|
|
72
|
+
const nlPos = buffer.lastIndexOf('\n', Math.min(buffer.length, hardCap) - 1);
|
|
73
|
+
if (nlPos > Math.floor(threshold * 0.5)) {
|
|
74
|
+
return { flush: buffer.slice(0, nlPos + 1), keep: buffer.slice(nlPos + 1) };
|
|
75
|
+
}
|
|
76
|
+
if (mustFlush) {
|
|
77
|
+
return { flush: buffer.slice(0, hardCap), keep: buffer.slice(hardCap) };
|
|
78
|
+
}
|
|
79
|
+
return { flush: '', keep: buffer };
|
|
80
|
+
}
|
|
81
|
+
/** Strip a leading `<@BOTID>` (and any extra mentions) from an app_mention. */
|
|
82
|
+
function stripMentions(text) {
|
|
83
|
+
return text.replace(/<@[A-Z0-9]+>/g, '').replace(/\s+/g, ' ').trim();
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Start the bot. Resolves only on fatal error; the outer CLI handles SIGINT.
|
|
87
|
+
*/
|
|
88
|
+
export async function runSlackBot(agentConfig, opts) {
|
|
89
|
+
const log = opts.log ?? (() => { });
|
|
90
|
+
// Lazy import keeps @slack/bolt out of the load path for users who never
|
|
91
|
+
// run the Slack bot, matching how heavy optional deps are handled elsewhere.
|
|
92
|
+
const { App, LogLevel } = await import('@slack/bolt');
|
|
93
|
+
const state = {
|
|
94
|
+
inputQueue: [],
|
|
95
|
+
inputWaiters: [],
|
|
96
|
+
currentTarget: undefined,
|
|
97
|
+
responseBuffer: '',
|
|
98
|
+
running: true,
|
|
99
|
+
restartRequested: false,
|
|
100
|
+
botUserId: undefined,
|
|
101
|
+
// Tools the current turn has called — posted as ONE summary on turn_done,
|
|
102
|
+
// mirroring the Telegram channel (a per-tool message floods the thread).
|
|
103
|
+
toolsUsed: [],
|
|
104
|
+
};
|
|
105
|
+
const app = new App({
|
|
106
|
+
token: opts.botToken,
|
|
107
|
+
appToken: opts.appToken,
|
|
108
|
+
socketMode: true,
|
|
109
|
+
logLevel: opts.debug ? LogLevel.DEBUG : LogLevel.WARN,
|
|
110
|
+
});
|
|
111
|
+
// ── Slack send helpers ───────────────────────────────────────────────
|
|
112
|
+
const postMessage = async (target, text) => {
|
|
113
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
114
|
+
try {
|
|
115
|
+
await app.client.chat.postMessage({
|
|
116
|
+
channel: target.channel,
|
|
117
|
+
...(target.threadTs ? { thread_ts: target.threadTs } : {}),
|
|
118
|
+
text,
|
|
119
|
+
});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
if (attempt === 1) {
|
|
124
|
+
log(`[slack] postMessage failed: ${err.message}`);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
const postChunked = async (target, text) => {
|
|
132
|
+
const chunks = splitForSlack(text);
|
|
133
|
+
if (chunks.length === 1) {
|
|
134
|
+
await postMessage(target, chunks[0]);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
138
|
+
await postMessage(target, `[${i + 1}/${chunks.length}] ${chunks[i]}`);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
// ── Bot control commands (handled here, not by the agent) ─────────────
|
|
142
|
+
// Slack swallows unregistered "/foo" slash commands, but inside an
|
|
143
|
+
// @mention the text "@bot /new" reaches us intact, so these still work.
|
|
144
|
+
const handleControlCommand = async (target, text) => {
|
|
145
|
+
const cmd = text.trim().toLowerCase();
|
|
146
|
+
switch (cmd) {
|
|
147
|
+
case '/help':
|
|
148
|
+
case 'help':
|
|
149
|
+
await postMessage(target, 'Franklin bot\n' +
|
|
150
|
+
'• `/new` — start a fresh conversation (clears history)\n' +
|
|
151
|
+
'• `/balance` — show wallet USDC balance\n' +
|
|
152
|
+
'• `/status` — chain, model, permission mode\n' +
|
|
153
|
+
'Anything else is forwarded to the agent.');
|
|
154
|
+
return true;
|
|
155
|
+
case '/new':
|
|
156
|
+
state.restartRequested = true;
|
|
157
|
+
state.inputQueue.length = 0;
|
|
158
|
+
// Drop tools recorded by a turn this reset interrupts, so they don't
|
|
159
|
+
// leak into the new conversation's first summary.
|
|
160
|
+
state.toolsUsed = [];
|
|
161
|
+
{
|
|
162
|
+
const waiters = state.inputWaiters.splice(0);
|
|
163
|
+
for (const w of waiters)
|
|
164
|
+
w(null);
|
|
165
|
+
}
|
|
166
|
+
await postMessage(target, '🔄 Starting a new conversation…');
|
|
167
|
+
return true;
|
|
168
|
+
case '/balance': {
|
|
169
|
+
try {
|
|
170
|
+
if (agentConfig.chain === 'solana') {
|
|
171
|
+
const c = await setupAgentSolanaWallet({ silent: true });
|
|
172
|
+
const addr = await c.getWalletAddress();
|
|
173
|
+
const bal = await c.getBalance();
|
|
174
|
+
await postMessage(target, `Chain: solana\nWallet: ${addr}\nBalance: $${bal.toFixed(2)} USDC`);
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
const c = setupAgentWallet({ silent: true });
|
|
178
|
+
const addr = c.getWalletAddress();
|
|
179
|
+
const bal = await c.getBalance();
|
|
180
|
+
await postMessage(target, `Chain: base\nWallet: ${addr}\nBalance: $${bal.toFixed(2)} USDC`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
await postMessage(target, `Couldn't fetch balance: ${err.message}`);
|
|
185
|
+
}
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
case '/status':
|
|
189
|
+
await postMessage(target, `chain: ${agentConfig.chain}\n` +
|
|
190
|
+
`model: ${agentConfig.model}\n` +
|
|
191
|
+
`permission: ${agentConfig.permissionMode ?? 'default'}`);
|
|
192
|
+
return true;
|
|
193
|
+
default:
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
// ── Input queue (feeds interactiveSession's getUserInput) ─────────────
|
|
198
|
+
const enqueueInput = (target, text) => {
|
|
199
|
+
state.currentTarget = target;
|
|
200
|
+
if (state.inputWaiters.length > 0) {
|
|
201
|
+
const w = state.inputWaiters.shift();
|
|
202
|
+
w(text);
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
state.inputQueue.push(text);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
const waitNextInput = () => {
|
|
209
|
+
if (state.restartRequested)
|
|
210
|
+
return Promise.resolve(null);
|
|
211
|
+
if (state.inputQueue.length > 0) {
|
|
212
|
+
return Promise.resolve(state.inputQueue.shift());
|
|
213
|
+
}
|
|
214
|
+
if (!state.running)
|
|
215
|
+
return Promise.resolve(null);
|
|
216
|
+
return new Promise((resolve) => state.inputWaiters.push(resolve));
|
|
217
|
+
};
|
|
218
|
+
// ── Event sink — progressive flush with a final sweep on turn_done ────
|
|
219
|
+
const flushProgressive = () => {
|
|
220
|
+
if (!state.currentTarget)
|
|
221
|
+
return;
|
|
222
|
+
const { flush, keep } = takeProgressiveChunk(state.responseBuffer);
|
|
223
|
+
if (flush.trim()) {
|
|
224
|
+
const target = state.currentTarget;
|
|
225
|
+
state.responseBuffer = keep;
|
|
226
|
+
void postMessage(target, flush.trim());
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
const handleEvent = (event) => {
|
|
230
|
+
switch (event.kind) {
|
|
231
|
+
case 'text_delta':
|
|
232
|
+
state.responseBuffer += event.text;
|
|
233
|
+
if (state.responseBuffer.length >= PROGRESSIVE_FLUSH_MIN) {
|
|
234
|
+
flushProgressive();
|
|
235
|
+
}
|
|
236
|
+
break;
|
|
237
|
+
case 'capability_start':
|
|
238
|
+
// Record the tool (for the turn-end summary) and flush buffered text so
|
|
239
|
+
// narrative order reads right. No per-tool message — a multi-tool run
|
|
240
|
+
// otherwise floods the thread (same fix as the Telegram channel).
|
|
241
|
+
if (event.name)
|
|
242
|
+
state.toolsUsed.push(event.name);
|
|
243
|
+
if (state.currentTarget && state.responseBuffer.trim()) {
|
|
244
|
+
const target = state.currentTarget;
|
|
245
|
+
const text = state.responseBuffer.trim();
|
|
246
|
+
state.responseBuffer = '';
|
|
247
|
+
void postMessage(target, text);
|
|
248
|
+
}
|
|
249
|
+
break;
|
|
250
|
+
case 'turn_done': {
|
|
251
|
+
const target = state.currentTarget;
|
|
252
|
+
const text = state.responseBuffer.trim();
|
|
253
|
+
state.responseBuffer = '';
|
|
254
|
+
if (target && text)
|
|
255
|
+
void postChunked(target, text);
|
|
256
|
+
// One tool summary per turn, mirroring Telegram.
|
|
257
|
+
if (target && state.toolsUsed.length) {
|
|
258
|
+
const uniq = [...new Set(state.toolsUsed)];
|
|
259
|
+
void postMessage(target, `🔧 Used ${state.toolsUsed.length} tool${state.toolsUsed.length === 1 ? '' : 's'}: ${uniq.join(' · ')}`);
|
|
260
|
+
}
|
|
261
|
+
state.toolsUsed = [];
|
|
262
|
+
if (event.reason === 'error' && event.error && target) {
|
|
263
|
+
void postMessage(target, `❌ Error: ${event.error}`);
|
|
264
|
+
}
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
// ── Inbound routing ───────────────────────────────────────────────────
|
|
270
|
+
const authorized = (userId) => !!userId && opts.allowedUsers.has(userId);
|
|
271
|
+
const ingest = async (userId, channel, threadTs, rawText) => {
|
|
272
|
+
const target = { channel, threadTs };
|
|
273
|
+
if (!authorized(userId)) {
|
|
274
|
+
log(`[slack] rejected unauthorized sender id=${userId ?? 'n/a'}`);
|
|
275
|
+
await postMessage(target, 'Not authorized.');
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const text = stripMentions(rawText);
|
|
279
|
+
if (!text)
|
|
280
|
+
return;
|
|
281
|
+
log(`[slack] ← ${text.slice(0, 80)}${text.length > 80 ? '…' : ''}`);
|
|
282
|
+
if (text.startsWith('/') || text.toLowerCase() === 'help') {
|
|
283
|
+
state.currentTarget = target;
|
|
284
|
+
const handled = await handleControlCommand(target, text);
|
|
285
|
+
if (handled)
|
|
286
|
+
return;
|
|
287
|
+
// Unknown command falls through to the agent (it has its own slash
|
|
288
|
+
// handling for /retry, /model, /cost, …).
|
|
289
|
+
}
|
|
290
|
+
enqueueInput(target, text);
|
|
291
|
+
};
|
|
292
|
+
// app_mention: someone @mentioned the bot in a channel. Reply in-thread so
|
|
293
|
+
// the conversation stays grouped; a top-level mention starts a new thread.
|
|
294
|
+
app.event('app_mention', async ({ event }) => {
|
|
295
|
+
const e = event;
|
|
296
|
+
await ingest(e.user, e.channel, e.thread_ts ?? e.ts, e.text);
|
|
297
|
+
});
|
|
298
|
+
// Direct messages to the bot. Ignore the bot's own messages, edits, and
|
|
299
|
+
// any message that carries a subtype (joins, file shares, etc.).
|
|
300
|
+
app.message(async ({ message }) => {
|
|
301
|
+
const m = message;
|
|
302
|
+
if (opts.debug) {
|
|
303
|
+
log(`[slack] message event: channel_type=${m.channel_type} subtype=${m.subtype ?? '-'} ` +
|
|
304
|
+
`bot_id=${m.bot_id ?? '-'} user=${m.user ?? '-'} text=${(m.text ?? '').slice(0, 40)}`);
|
|
305
|
+
}
|
|
306
|
+
if (m.channel_type !== 'im')
|
|
307
|
+
return; // channel posts arrive via app_mention
|
|
308
|
+
if (m.subtype || m.bot_id || !m.text)
|
|
309
|
+
return;
|
|
310
|
+
if (m.user && m.user === state.botUserId)
|
|
311
|
+
return;
|
|
312
|
+
// DMs reply at top level; only stay threaded if the user is already in one.
|
|
313
|
+
await ingest(m.user, m.channel, m.thread_ts, m.text);
|
|
314
|
+
});
|
|
315
|
+
// ── Connect ────────────────────────────────────────────────────────────
|
|
316
|
+
try {
|
|
317
|
+
const auth = (await app.client.auth.test());
|
|
318
|
+
state.botUserId = auth.user_id;
|
|
319
|
+
await app.start();
|
|
320
|
+
log(`[slack] connected as bot ${auth.user_id ?? '(unknown)'} ` +
|
|
321
|
+
`team=${auth.team ?? '?'} — ${opts.allowedUsers.size} allowed user(s)`);
|
|
322
|
+
}
|
|
323
|
+
catch (err) {
|
|
324
|
+
throw new Error(`Slack connect failed: ${err.message}`);
|
|
325
|
+
}
|
|
326
|
+
// Shared LLM client for post-session extraction (built once).
|
|
327
|
+
const extractor = new ModelClient({
|
|
328
|
+
apiUrl: agentConfig.apiUrl,
|
|
329
|
+
chain: agentConfig.chain,
|
|
330
|
+
});
|
|
331
|
+
const harvestSession = async (history) => {
|
|
332
|
+
if (history.length < 4)
|
|
333
|
+
return;
|
|
334
|
+
const sid = `slack-${new Date().toISOString()}`;
|
|
335
|
+
try {
|
|
336
|
+
await Promise.race([
|
|
337
|
+
Promise.all([
|
|
338
|
+
extractLearnings(history, sid, extractor),
|
|
339
|
+
extractBrainEntities(history, sid, extractor),
|
|
340
|
+
]),
|
|
341
|
+
new Promise((r) => setTimeout(r, 15_000)),
|
|
342
|
+
]);
|
|
343
|
+
}
|
|
344
|
+
catch (err) {
|
|
345
|
+
log(`[slack] post-session extraction failed: ${err.message}`);
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
// ── Outer session loop (mirrors the Telegram channel) ─────────────────
|
|
349
|
+
try {
|
|
350
|
+
let firstSession = true;
|
|
351
|
+
while (state.running) {
|
|
352
|
+
state.restartRequested = false;
|
|
353
|
+
if (!firstSession)
|
|
354
|
+
agentConfig.resumeSessionId = undefined;
|
|
355
|
+
firstSession = false;
|
|
356
|
+
const history = await interactiveSession(agentConfig, waitNextInput, handleEvent);
|
|
357
|
+
void harvestSession(history);
|
|
358
|
+
if (!state.restartRequested)
|
|
359
|
+
break;
|
|
360
|
+
log('[slack] session reset by /new');
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
finally {
|
|
364
|
+
state.running = false;
|
|
365
|
+
const waiters = state.inputWaiters.splice(0);
|
|
366
|
+
for (const w of waiters)
|
|
367
|
+
w(null);
|
|
368
|
+
await app.stop().catch(() => { });
|
|
369
|
+
}
|
|
370
|
+
}
|
|
@@ -21,6 +21,10 @@ export interface TelegramOptions {
|
|
|
21
21
|
token: string;
|
|
22
22
|
/** Numeric Telegram user id that's allowed to drive the bot. Required. */
|
|
23
23
|
ownerId: number;
|
|
24
|
+
/** Extra numeric user ids allowed to drive the bot (e.g. other people in a
|
|
25
|
+
* group). The owner is always allowed; this widens access without dropping
|
|
26
|
+
* the lock. Empty/undefined → owner-only (original behaviour). */
|
|
27
|
+
allowedUsers?: Set<number>;
|
|
24
28
|
/** Called with each user-facing log line so the CLI can print them. */
|
|
25
29
|
log?: (line: string) => void;
|
|
26
30
|
}
|