obol-ai 0.2.16 → 0.2.18

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/CHANGELOG.md CHANGED
@@ -1,3 +1,14 @@
1
+ ## 0.2.18
2
+ - remove evolution progress bar from status UI
3
+ - bidirectional bridge with reply button + memory_remove tool
4
+ - update background tasks section in readme
5
+ - add status UI screenshot to readme
6
+ - update readme with stop controls, commands, and model escalation
7
+
8
+ ## 0.2.17
9
+ - add force stop button to instantly abort mid-tool execution
10
+ - replace web_fetch with native web_search tool
11
+
1
12
  ## 0.2.16
2
13
  - add chat_history tool for retrieving past conversations by date
3
14
  - add stop button to status UI with concurrent update processing
package/README.md CHANGED
@@ -22,7 +22,7 @@ obol start -d # runs as background daemon (auto-installs pm2)
22
22
 
23
23
  🧠 **Living memory** — Vector memory with semantic search. Haiku routes queries and rewrites them for better embedding hits. Free local embeddings.
24
24
 
25
- šŸ¤– **Smart routing** — Haiku decides per-message: does it need memory? Sonnet or Opus? No wasted API calls
25
+ šŸ¤– **Smart routing** — Haiku decides per-message: does it need memory? Sonnet or Opus? Auto-escalates to Sonnet when tool use is needed. No wasted API calls
26
26
 
27
27
  šŸ’° **Prompt caching** — Static system prompt and conversation history prefix are cached via Anthropic's prompt caching, cutting ~85% of repeated input token costs across turns
28
28
 
@@ -59,9 +59,9 @@ User message
59
59
  ↓ ↓
60
60
  Memory recall Model selection
61
61
  ↓ ↓
62
- Multi-query Sonnet (default)
63
- ranked recall or Opus (complex)
64
- ↓ ↓
62
+ Multi-query Haiku → Sonnet (auto-
63
+ ranked recall escalates on tool use)
64
+ ↓ or Opus (complex)
65
65
  ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
66
66
  ↓
67
67
  Claude (tool use loop)
@@ -199,21 +199,32 @@ Month 6: evolution/ has 180+ archived souls
199
199
 
200
200
  ### Background Tasks
201
201
 
202
- Heavy work runs in the background. The main conversation stays responsive.
202
+ Heavy work runs in the background with its own live status UI. The main conversation stays responsive — you can keep chatting while tasks run.
203
203
 
204
204
  ```
205
205
  You: "research the best coworking spaces in Barcelona"
206
- OBOL: "On it šŸŖ™"
207
-
208
- [30s] ā³ Found 15 spaces, filtering by reviews...
209
- [60s] ā³ Narrowed to top 7, checking prices...
206
+ OBOL: spawns BG #1 with live status
210
207
 
211
208
  You: "what time is it?"
212
209
  OBOL: "11:42 PM CET"
213
210
 
214
- [90s] āœ… Done! Here are the top 5 coworking spaces: ...
211
+ āœ… BG #1 done (1m 32s)
212
+ Here are the top 5 coworking spaces: ...
215
213
  ```
216
214
 
215
+ ### Live Status & Stop Controls
216
+
217
+ ![Status UI](docs/obol-status.png)
218
+
219
+ Every request shows a live status message with elapsed time, model routing info, and what tools are being used. Two inline buttons let you cancel:
220
+
221
+ | Button | Behavior |
222
+ |--------|----------|
223
+ | **ā–  Stop** | Cancels after the current API call finishes |
224
+ | **ā–  Force Stop** | Instantly aborts mid-tool — races the handler and returns immediately |
225
+
226
+ The `/stop` command also works as a text alternative.
227
+
217
228
  ## Multi-User Architecture
218
229
 
219
230
  One Telegram bot token, one Node.js process, full per-user isolation.
@@ -275,29 +286,39 @@ Each new user starts fresh. Their bot evolves independently from every other use
275
286
 
276
287
  ### Bridge (couples / roommates / teams)
277
288
 
278
- When two users share the same OBOL instance, their agents can talk to each other.
289
+ When two users share the same OBOL instance, their agents can talk to each other — bidirectionally.
279
290
 
280
291
  ```
281
292
  User A: "what does Jo want for dinner tonight?"
282
293
  Agent A: → bridge_ask → Agent B (one-shot, no tools, no history)
283
294
  Agent B: "Jo mentioned craving Thai food earlier today"
284
295
  Agent A: "Jo's been wanting Thai — maybe suggest pad see ew?"
296
+
297
+ Jo gets: "šŸŖ™ Your partner's agent asked: 'what does Jo want for dinner?'
298
+ Your agent answered: 'Jo mentioned craving Thai food earlier today'"
285
299
  ```
286
300
 
287
301
  ```
288
302
  User A: "remind Jo I'll be home late"
289
303
  Agent A: → bridge_tell → stores in Agent B's memory + Telegram notification
290
- Jo gets: "šŸŖ™ Message from your partner's agent: I'll be home late"
304
+
305
+ Jo gets: "šŸŖ™ Message from your partner's agent:
306
+ 'I'll be home late'"
307
+ [↩ Reply]
308
+
309
+ Jo taps Reply → Jo's agent reads recent bridge context, composes a reply
310
+ → sends back via bridge_tell
311
+ A gets: "šŸŖ™ Message from your partner's agent: 'Got it, I'll start dinner around 7'"
291
312
  ```
292
313
 
293
314
  Two tools:
294
315
 
295
316
  | Tool | Direction | What happens |
296
317
  |------|-----------|--------------|
297
- | `bridge_ask` | A → B → A | Query the partner's agent. One-shot Sonnet call with partner's personality + memories. No tools, no history, no recursion risk. |
298
- | `bridge_tell` | A → B | Send a message to the partner. Stored in their memory (importance 0.6) + Telegram notification. Their agent picks it up as context in future conversations. |
318
+ | `bridge_ask` | A → B → A | Query the partner's agent. One-shot Haiku call with partner's personality + memories. No tools, no history, no recursion risk. Partner is notified with both the question and your agent's answer. |
319
+ | `bridge_tell` | A → B (↩ B → A) | Send a message to the partner. Stored in their memory (importance 0.6) + Telegram notification with a Reply button. Tapping Reply has their agent compose a contextual response and send it back — no typing needed. |
299
320
 
300
- The partner always gets notified when their agent is contacted. Privacy rules apply — the responding agent gives summaries, never raw data or secrets.
321
+ The partner always gets notified when their agent is contacted. Privacy rules apply — the responding agent gives summaries, never raw data or secrets. Rate-limited to 20 bridge calls per user per hour.
301
322
 
302
323
  Enable during `obol init` (auto-prompted when 2+ users are added) or toggle later with `obol config` → Bridge.
303
324
 
@@ -471,6 +492,7 @@ Or edit `~/.obol/config.json` directly:
471
492
  /memory — Search or view memory stats
472
493
  /recent — Last 10 memories
473
494
  /today — Today's memories
495
+ /events — Show upcoming scheduled events
474
496
  /tasks — Running background tasks
475
497
  /status — Bot status, uptime, evolution progress, traits
476
498
  /backup — Trigger GitHub backup
@@ -478,6 +500,11 @@ Or edit `~/.obol/config.json` directly:
478
500
  /traits — View or adjust personality traits (0-100)
479
501
  /secret — Manage per-user encrypted secrets
480
502
  /evolution — Evolution progress
503
+ /verbose — Toggle verbose mode on/off
504
+ /toolimit — View or set max tool iterations per message
505
+ /tools — Toggle optional tools on/off
506
+ /stop — Stop the current request
507
+ /upgrade — Check for updates and upgrade
481
508
  /help — Show available commands
482
509
  ```
483
510
 
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "obol-ai",
3
- "version": "0.2.16",
3
+ "version": "0.2.18",
4
4
  "description": "Self-evolving AI assistant that learns, remembers, and acts on its own. Persistent vector memory, self-rewriting personality, proactive heartbeats.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/bridge.js CHANGED
@@ -106,7 +106,8 @@ async function bridgeAsk(question, fromUserId, config, notifyFn, targetId) {
106
106
 
107
107
  if (notifyFn) {
108
108
  try {
109
- await notifyFn(partnerUserId, `šŸŖ™ Your partner's agent asked about you:\n"${question}"`);
109
+ const preview = answer.length > 200 ? `${answer.substring(0, 200)}…` : answer;
110
+ await notifyFn(partnerUserId, `šŸŖ™ Your partner's agent asked: "${question}"\nYour agent answered: "${preview}"`);
110
111
  } catch (e) {
111
112
  console.error(`[bridge] Notify failed for ${partnerUserId}:`, e.message);
112
113
  }
@@ -170,7 +171,8 @@ async function bridgeTell(message, fromUserId, config, notifyFn, targetId) {
170
171
 
171
172
  if (notifyFn) {
172
173
  try {
173
- await notifyFn(partnerUserId, `šŸŖ™ Message from your partner's agent:\n"${message}"`);
174
+ const replyMarkup = { inline_keyboard: [[{ text: '↩ Reply', callback_data: `bridge:reply:${fromUserId}` }]] };
175
+ await notifyFn(partnerUserId, `šŸŖ™ Message from your partner's agent:\n"${message}"`, { reply_markup: replyMarkup });
174
176
  } catch (e) {
175
177
  console.error(`[bridge] Notify failed for ${partnerUserId}:`, e.message);
176
178
  }
@@ -16,6 +16,7 @@ function createClaude(anthropicConfig, { personality, memory, userDir = OBOL_DIR
16
16
  const histories = new ChatHistory(50);
17
17
  const chatLocks = new Map();
18
18
  const chatAbortControllers = new Map();
19
+ const chatForceControllers = new Map();
19
20
 
20
21
  const tools = buildTools(memory, { bridgeEnabled });
21
22
 
@@ -45,7 +46,9 @@ function createClaude(anthropicConfig, { personality, memory, userDir = OBOL_DIR
45
46
 
46
47
  const releaseLock = await acquireChatLock(chatId);
47
48
  const abortController = new AbortController();
49
+ const forceController = new AbortController();
48
50
  chatAbortControllers.set(chatId, abortController);
51
+ chatForceControllers.set(chatId, forceController);
49
52
 
50
53
  const history = histories.get(chatId);
51
54
 
@@ -95,6 +98,7 @@ function createClaude(anthropicConfig, { personality, memory, userDir = OBOL_DIR
95
98
  ];
96
99
  context._reloadPersonality = reloadPersonality;
97
100
  context._abortSignal = abortController.signal;
101
+ context._forceSignal = forceController.signal;
98
102
  const runnableTools = buildRunnableTools(tools, memory, context, vlog);
99
103
  let activeModel = model;
100
104
 
@@ -198,6 +202,7 @@ function createClaude(anthropicConfig, { personality, memory, userDir = OBOL_DIR
198
202
  throw e;
199
203
  } finally {
200
204
  chatAbortControllers.delete(chatId);
205
+ chatForceControllers.delete(chatId);
201
206
  releaseLock();
202
207
  }
203
208
  }
@@ -211,6 +216,14 @@ function createClaude(anthropicConfig, { personality, memory, userDir = OBOL_DIR
211
216
  return false;
212
217
  }
213
218
 
219
+ function forceStopChat(chatId) {
220
+ const controller = chatAbortControllers.get(chatId);
221
+ const force = chatForceControllers.get(chatId);
222
+ if (force) force.abort();
223
+ if (controller) { controller.abort(); return true; }
224
+ return false;
225
+ }
226
+
214
227
  function reloadPersonality() {
215
228
  const pDir = userDir ? path.join(userDir, 'personality') : undefined;
216
229
  const newPersonality = require('../personality').loadPersonality(pDir);
@@ -236,7 +249,7 @@ function createClaude(anthropicConfig, { personality, memory, userDir = OBOL_DIR
236
249
  return histories.estimateTokens(id, baseSystemPrompt.length);
237
250
  }
238
251
 
239
- return { chat, client, reloadPersonality, clearHistory, injectHistory, getContextStats, stopChat };
252
+ return { chat, client, reloadPersonality, clearHistory, injectHistory, getContextStats, stopChat, forceStopChat };
240
253
  }
241
254
 
242
255
  module.exports = { createClaude };
@@ -114,8 +114,8 @@ Categories: \`fact\`, \`preference\`, \`decision\`, \`lesson\`, \`person\`, \`pr
114
114
  Read and write files within your workspace. Parent directories created automatically.
115
115
  Cannot access paths outside workspace or /tmp.
116
116
 
117
- ### Web (\`web_fetch\`)
118
- Fetch and extract readable content from any URL via Jina reader.
117
+ ### Web (\`web_search\`)
118
+ Search the web for current information.
119
119
 
120
120
  ### Vercel (\`vercel_deploy\`, \`vercel_list\`)
121
121
  Deploy directories to Vercel. Ship websites, dashboards, web apps.
@@ -252,7 +252,7 @@ Structure tips:
252
252
  - Search memory before claiming you don't know something
253
253
  - Use \`store_secret\`/\`read_secret\` for all credential operations
254
254
  - If a user sends what appears to be an API key, token, or credential in conversation, immediately warn them that it's visible in chat history, tell them to revoke/rotate it, and direct them to use \`/secret set <key> <value>\` instead
255
- - After executing tools (exec, web_fetch, read_secret, etc.), ALWAYS provide a text response summarizing what you found or did. Never end your turn with only tool calls and no text reply — the user cannot see tool results directly, they only see your text responses
255
+ - After executing tools (exec, web_search, read_secret, etc.), ALWAYS provide a text response summarizing what you found or did. Never end your turn with only tool calls and no text reply — the user cannot see tool results directly, they only see your text responses
256
256
  `);
257
257
 
258
258
  return parts.join('\n');
@@ -32,8 +32,9 @@ const INPUT_SUMMARIES = {
32
32
  read_file: (i) => i.path,
33
33
  memory_search: (i) => i.query,
34
34
  memory_add: (i) => `[${i.category || 'fact'}]`,
35
+ memory_remove: (i) => i.ids?.join(', '),
35
36
  memory_query: (i) => `${i.date || ''}${i.tags ? ' #' + i.tags.join(' #') : ''}${i.category ? ' [' + i.category + ']' : ''}`.trim() || 'all',
36
- web_fetch: (i) => i.url,
37
+ web_search: (i) => i.query,
37
38
  background_task: (i) => i.task?.substring(0, 60),
38
39
  schedule_event: (i) => `${i.title} @ ${i.due_at}${i.cron_expr ? ` [${i.cron_expr}]` : ''}`,
39
40
  cancel_event: (i) => i.event_id,
@@ -95,7 +96,9 @@ function buildRunnableTools(tools, memory, context, vlog) {
95
96
  .map(tool => ({
96
97
  ...tool,
97
98
  run: async (input) => {
98
- if (context._abortSignal?.aborted) return 'Aborted.';
99
+ const signal = context._abortSignal;
100
+ const forceSignal = context._forceSignal;
101
+ if (signal?.aborted) return 'Aborted.';
99
102
  const inputSummary = summarizeInput(tool.name, input);
100
103
  vlog(`[tool] ${tool.name}: ${inputSummary}`);
101
104
  context._onToolStart?.(tool.name, inputSummary);
@@ -104,8 +107,14 @@ function buildRunnableTools(tools, memory, context, vlog) {
104
107
  if (!handler) return `Unknown tool: ${tool.name}`;
105
108
 
106
109
  try {
107
- return await handler(input, memory, context);
110
+ if (!forceSignal) return await handler(input, memory, context);
111
+ if (forceSignal.aborted) return 'Aborted.';
112
+ const forcePromise = new Promise((resolve) => {
113
+ forceSignal.addEventListener('abort', () => resolve('Aborted.'), { once: true });
114
+ });
115
+ return await Promise.race([handler(input, memory, context), forcePromise]);
108
116
  } catch (e) {
117
+ if (signal?.aborted || forceSignal?.aborted) return 'Aborted.';
109
118
  return `Error: ${e.message}`;
110
119
  }
111
120
  },
@@ -27,6 +27,17 @@ const definitions = [
27
27
  required: ['content'],
28
28
  },
29
29
  },
30
+ {
31
+ name: 'memory_remove',
32
+ description: 'Remove one or more memories by their IDs. Use memory_query or memory_search first to get IDs.',
33
+ input_schema: {
34
+ type: 'object',
35
+ properties: {
36
+ ids: { type: 'array', items: { type: 'string' }, description: 'Memory IDs to remove' },
37
+ },
38
+ required: ['ids'],
39
+ },
40
+ },
30
41
  {
31
42
  name: 'memory_query',
32
43
  description: 'Filter memories by tag, date, category, source, or importance. Use for "what did we do today", "anything tagged X", "all decisions this week".',
@@ -47,7 +58,7 @@ const definitions = [
47
58
  function formatMemory(m) {
48
59
  const date = m.created_at ? new Date(m.created_at).toISOString().slice(0, 10) : null;
49
60
  const tags = m.tags?.length ? ` #${m.tags.join(' #')}` : '';
50
- return `[${date || '?'}] [${m.category}] ${m.content}${tags}`;
61
+ return `[id:${m.id}] [${date || '?'}] [${m.category}] ${m.content}${tags}`;
51
62
  }
52
63
 
53
64
  const handlers = {
@@ -69,6 +80,11 @@ const handlers = {
69
80
  return `Stored memory: ${result.id}`;
70
81
  },
71
82
 
83
+ async memory_remove(input, memory) {
84
+ await Promise.all(input.ids.map(id => memory.forget(id)));
85
+ return `Removed ${input.ids.length} memory${input.ids.length !== 1 ? 'ies' : ''}`;
86
+ },
87
+
72
88
  async memory_query(input, memory) {
73
89
  const results = await memory.query({
74
90
  date: input.date,
@@ -1,28 +1,8 @@
1
- const { isAllowedUrl } = require('../../sanitize');
2
-
3
1
  const definitions = [{
4
- name: 'web_fetch',
5
- description: 'Fetch and extract readable content from a URL.',
6
- input_schema: {
7
- type: 'object',
8
- properties: {
9
- url: { type: 'string', description: 'URL to fetch' },
10
- },
11
- required: ['url'],
12
- },
2
+ type: 'web_search_20250305',
3
+ name: 'web_search',
13
4
  }];
14
5
 
15
- const handlers = {
16
- async web_fetch(input) {
17
- if (!isAllowedUrl(input.url)) return 'Blocked: URL points to a private/internal address.';
18
- const jinaUrl = `https://r.jina.ai/${input.url}`;
19
- const res = await fetch(jinaUrl, {
20
- headers: { 'Accept': 'text/markdown' },
21
- });
22
- if (!res.ok) return `Failed to fetch: HTTP ${res.status}`;
23
- const text = await res.text();
24
- return text.substring(0, 15000);
25
- },
26
- };
6
+ const handlers = {};
27
7
 
28
8
  module.exports = { definitions, handlers };
@@ -17,7 +17,7 @@ Third person factual profile: name, location, timezone, nationality, job, skills
17
17
  Operational manual written as instructions to yourself. Focus on owner-specific workflows, service integrations, and lessons learned from conversations.
18
18
 
19
19
  **Do NOT include in AGENTS.md** — these are already hardcoded in the base system prompt and must not be duplicated:
20
- - Tool documentation (exec, memory_*, read_file, write_file, web_fetch, vercel_*, background_task, store_secret, read_secret, list_secrets, send_file, telegram_ask, bridge_*)
20
+ - Tool documentation (exec, memory_*, read_file, write_file, web_search, vercel_*, background_task, store_secret, read_secret, list_secrets, send_file, telegram_ask, bridge_*)
21
21
  - Telegram Formatting rules
22
22
  - Safety Rules (Never/Always)
23
23
  - Workspace Structure
@@ -48,7 +48,7 @@ function createBot(telegramConfig, config) {
48
48
  }
49
49
 
50
50
  bot.use(sequentialize((ctx) => {
51
- if (ctx.callbackQuery?.data?.startsWith('stop:')) return undefined;
51
+ if (ctx.callbackQuery?.data?.startsWith('stop:') || ctx.callbackQuery?.data?.startsWith('force:')) return undefined;
52
52
  return ctx.chat?.id.toString();
53
53
  }));
54
54
 
@@ -1,8 +1,7 @@
1
1
  const path = require('path');
2
2
  const { getTenant } = require('../../tenant');
3
- const { loadConfig } = require('../../config');
4
3
  const { loadTraits } = require('../../personality');
5
- const { evolve, loadEvolutionState } = require('../../evolve');
4
+ const { loadEvolutionState } = require('../../evolve');
6
5
  const { getMaxToolIterations } = require('../../claude');
7
6
  const { termBar, formatTraits } = require('../utils');
8
7
  const { TERM_SEP } = require('../constants');
@@ -44,16 +43,13 @@ function register(bot, config) {
44
43
  );
45
44
 
46
45
  const evoState = loadEvolutionState(tenant.userDir);
47
- const cfg = loadConfig();
48
- const intervalHours = cfg?.evolution?.intervalHours ?? 24;
49
- const elapsed = evoState.lastEvolution ? (Date.now() - new Date(evoState.lastEvolution).getTime()) / 3600000 : Infinity;
50
- const evoPct = Math.min(100, Math.round((elapsed / intervalHours) * 100));
51
- const timeLeft = Math.max(0, intervalHours - elapsed);
52
46
  lines.push(
53
47
  ``, `EVOLUTION`,
54
- ` ${termBar(evoPct)} ${evoPct}%`,
55
- ` ${timeLeft < 1 ? 'ready' : `${timeLeft.toFixed(1)}h remaining`} ā–Ŗ ${evoState.evolutionCount || 0} completed`,
48
+ ` ${evoState.evolutionCount || 0} completed`,
56
49
  );
50
+ if (evoState.lastEvolution) {
51
+ lines.push(` last ${new Date(evoState.lastEvolution).toLocaleDateString()}`);
52
+ }
57
53
 
58
54
  const personalityDir = path.join(tenant.userDir, 'personality');
59
55
  const traits = loadTraits(personalityDir);
@@ -68,18 +64,11 @@ function register(bot, config) {
68
64
  if (!ctx.from) return;
69
65
  const tenant = await getTenant(ctx.from.id, config);
70
66
  const state = loadEvolutionState(tenant.userDir);
71
- const cfg = loadConfig();
72
- const intervalHours = cfg?.evolution?.intervalHours ?? 24;
73
- const elapsed = state.lastEvolution ? (Date.now() - new Date(state.lastEvolution).getTime()) / 3600000 : Infinity;
74
- const pct = Math.min(100, Math.round((elapsed / intervalHours) * 100));
75
- const timeLeft = Math.max(0, intervalHours - elapsed);
76
67
 
77
68
  const lines = [
78
69
  `ā—ˆ OBOL EVOLUTION CYCLE`,
79
70
  TERM_SEP,
80
71
  ``,
81
- ` ${termBar(pct)} ${pct}%`,
82
- ` ${timeLeft < 1 ? 'ready' : `${timeLeft.toFixed(1)}h remaining`}`,
83
72
  ` ${state.evolutionCount || 0} completed`,
84
73
  ];
85
74
  if (state.lastEvolution) {
@@ -12,7 +12,16 @@ function registerCallbackHandler(bot, { config, pendingAsks, getTenant }) {
12
12
  const userId = ctx.from.id;
13
13
  const tenant = await getTenant(userId, config);
14
14
  const stopped = tenant?.claude?.stopChat(chatId);
15
- await answer({ text: stopped ? 'Stopping' : 'Nothing to stop' });
15
+ await answer({ text: stopped ? 'Stopping...' : 'Nothing to stop' });
16
+ return;
17
+ }
18
+
19
+ if (data.startsWith('force:')) {
20
+ const chatId = parseInt(data.slice(6));
21
+ const userId = ctx.from.id;
22
+ const tenant = await getTenant(userId, config);
23
+ const stopped = tenant?.claude?.forceStopChat(chatId);
24
+ await answer({ text: stopped ? 'Force stopped' : 'Nothing to stop' });
16
25
  return;
17
26
  }
18
27
 
@@ -27,6 +36,66 @@ function registerCallbackHandler(bot, { config, pendingAsks, getTenant }) {
27
36
  return;
28
37
  }
29
38
 
39
+ if (data.startsWith('bridge:reply:')) {
40
+ const targetUserId = parseInt(data.split(':')[2]);
41
+ const reactingUserId = ctx.from.id;
42
+
43
+ const tenant = await getTenant(reactingUserId, config);
44
+ if (!tenant) return answer({ text: 'Could not load your agent' });
45
+
46
+ const { checkBridgeRateLimit, bridgeTell } = require('../../bridge');
47
+ const rateErr = checkBridgeRateLimit(reactingUserId);
48
+ if (rateErr) return answer({ text: rateErr });
49
+
50
+ let memoryContext = '';
51
+ if (tenant.memory) {
52
+ try {
53
+ const memories = await tenant.memory.search('message from partner bridge', { limit: 5, threshold: 0.3 });
54
+ if (memories.length > 0) {
55
+ memoryContext = '\n\n[Recent bridge messages]\n' + memories.map(m => `- ${m.content}`).join('\n');
56
+ }
57
+ } catch {}
58
+ }
59
+
60
+ const systemParts = [
61
+ 'Compose a brief, natural reply to send back to your partner\'s agent via bridge. 1-3 sentences. Be genuine and respond to the most recent message from them.',
62
+ ];
63
+ if (tenant.personality?.soul) systemParts.push(`\n## Your Personality\n${tenant.personality.soul}`);
64
+ if (tenant.personality?.user) systemParts.push(`\n## About You\n${tenant.personality.user}`);
65
+ if (memoryContext) systemParts.push(memoryContext);
66
+
67
+ let replyText;
68
+ try {
69
+ const response = await tenant.claude.client.messages.create({
70
+ model: 'claude-haiku-4-5-20251001',
71
+ max_tokens: 256,
72
+ system: systemParts.join('\n'),
73
+ messages: [{ role: 'user', content: 'Compose your reply to send via bridge.' }],
74
+ });
75
+ replyText = response.content.filter(b => b.type === 'text').map(b => b.text).join('\n').trim();
76
+ } catch (e) {
77
+ console.error('[bridge:reply] Generation failed:', e.message);
78
+ return answer({ text: 'Failed to generate reply' });
79
+ }
80
+
81
+ if (!replyText) return answer({ text: 'Could not generate a reply' });
82
+
83
+ const notifyFn = (uid, msg, opts = {}) => ctx.api.sendMessage(uid, msg, opts);
84
+ try {
85
+ await bridgeTell(replyText, reactingUserId, config, notifyFn, targetUserId);
86
+ } catch (e) {
87
+ console.error('[bridge:reply] bridgeTell failed:', e.message);
88
+ return answer({ text: 'Failed to send reply' });
89
+ }
90
+
91
+ ctx.editMessageText(
92
+ ctx.callbackQuery.message.text + '\n\nāœ“ Reply sent',
93
+ { reply_markup: { inline_keyboard: [] } }
94
+ ).catch(() => {});
95
+
96
+ return answer({ text: 'Reply sent!' });
97
+ }
98
+
30
99
  if (!data.startsWith('ask:')) return answer();
31
100
  const parts = data.split(':');
32
101
  const askId = parseInt(parts[1]);
@@ -27,9 +27,9 @@ function createChatContext(ctx, tenant, config, { allowedUsers, bot, createAsk }
27
27
  sendHtml(ctx, `\`${msg}\``).catch(() => {});
28
28
  } : undefined,
29
29
  telegramAsk: (message, options, timeout) => createAsk(ctx, message, options, timeout),
30
- _notifyFn: (targetUserId, message) => {
30
+ _notifyFn: (targetUserId, message, opts = {}) => {
31
31
  if (!allowedUsers.has(targetUserId)) throw new Error('Cannot notify user outside allowed list');
32
- return bot.api.sendMessage(targetUserId, message);
32
+ return bot.api.sendMessage(targetUserId, message, opts);
33
33
  },
34
34
  };
35
35
  }
@@ -40,7 +40,9 @@ function createStatusTracker(ctx) {
40
40
  let statusTimer = null;
41
41
  let statusStart = null;
42
42
  let routeInfo = null;
43
- const stopBtn = new InlineKeyboard().text('ā–  Stop', `stop:${ctx.chat.id}`);
43
+ const stopBtn = new InlineKeyboard()
44
+ .text('ā–  Stop', `stop:${ctx.chat.id}`)
45
+ .text('ā–  Force Stop', `force:${ctx.chat.id}`);
44
46
 
45
47
  const clear = () => {
46
48
  if (statusTimer) { clearInterval(statusTimer); statusTimer = null; }