@blockrun/franklin 3.26.1 → 3.27.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/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) {
@@ -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
- if (!bloatCompactedThisTurn &&
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
- ((turnToolCalls > 15 && turnCostUsd > 0.03) ||
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
- return undefined;
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
  }
@@ -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
  }