twinclaw 1.4.1 → 1.5.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.
@@ -1,9 +1,51 @@
1
1
  import fs from 'fs/promises';
2
2
  import path from 'path';
3
+ import os from 'node:os';
3
4
  import { getIdentityDir } from '../config/workspace.js';
5
+ import { getConfigValue } from '../config/json-config.js';
6
+ /** Build a short runtime context block (OS, time, model). */
7
+ function buildRuntimeContext() {
8
+ const now = new Date();
9
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
10
+ const formattedTime = now.toLocaleString('en-US', {
11
+ weekday: 'long',
12
+ year: 'numeric',
13
+ month: 'long',
14
+ day: 'numeric',
15
+ hour: '2-digit',
16
+ minute: '2-digit',
17
+ second: '2-digit',
18
+ timeZoneName: 'short',
19
+ });
20
+ const currentModel = getConfigValue('PRIMARY_MODEL') ?? 'unknown';
21
+ const platform = os.platform();
22
+ const hostname = os.hostname();
23
+ const arch = os.arch();
24
+ const lines = [
25
+ `- **Current Date & Time:** ${formattedTime} (${timezone})`,
26
+ `- **Host:** ${hostname}`,
27
+ `- **OS:** ${platform} (${arch})`,
28
+ `- **Shell:** ${platform === 'win32' ? 'PowerShell' : 'bash'}`,
29
+ `- **Primary Model:** ${currentModel}`,
30
+ ];
31
+ return lines.join('\n');
32
+ }
33
+ /**
34
+ * Known messaging tool names. When the model calls any of these,
35
+ * the assistant's text response should be suppressed to avoid duplicates.
36
+ */
37
+ export const MESSAGING_TOOL_NAMES = new Set([
38
+ 'send_telegram_message',
39
+ 'send_whatsapp_message',
40
+ 'send_telegram_photo',
41
+ 'send_telegram_document',
42
+ 'send_whatsapp_image',
43
+ 'send_whatsapp_document',
44
+ ]);
4
45
  export async function assembleContext(additionalRuntimeContext = '') {
5
46
  let soul = '';
6
47
  let identity = '';
48
+ let tools = '';
7
49
  let user = '';
8
50
  const identityDir = getIdentityDir();
9
51
  const readOptionalFile = async (fileName) => {
@@ -23,7 +65,9 @@ export async function assembleContext(additionalRuntimeContext = '') {
23
65
  };
24
66
  soul = await readOptionalFile('soul.md');
25
67
  identity = await readOptionalFile('identity.md');
68
+ tools = await readOptionalFile('tools.md');
26
69
  user = await readOptionalFile('user.md');
70
+ const runtimeBlock = buildRuntimeContext();
27
71
  const compiled = `
28
72
  You are TwinClaw. Follow your core directives exactly.
29
73
 
@@ -31,6 +75,11 @@ ${soul ? `### CORE SOUL & DIRECTIVES\n${soul}` : ''}
31
75
 
32
76
  ${identity ? `### IDENTITY & PERSONA\n${identity}` : ''}
33
77
 
78
+ ${tools ? `### TOOL USAGE GUIDE\n${tools}` : ''}
79
+
80
+ ### RUNTIME ENVIRONMENT
81
+ ${runtimeBlock}
82
+
34
83
  ${user ? `### USER PREFERENCES\n${user}` : ''}
35
84
 
36
85
  ${additionalRuntimeContext ? `### ADDITIONAL CONTEXT (RAG MEMORY)\n${additionalRuntimeContext}` : ''}
@@ -6,14 +6,17 @@ import { ModelRouter } from '../services/model-router.js';
6
6
  import { getIdentityDir } from '../config/workspace.js';
7
7
  import { indexConversationTurn, retrieveEvidenceAwareMemoryContext, } from '../services/semantic-memory.js';
8
8
  import { OrchestrationService } from '../services/orchestration-service.js';
9
- import { assembleContext } from './context-assembly.js';
9
+ import { assembleContext, MESSAGING_TOOL_NAMES } from './context-assembly.js';
10
10
  import { LaneExecutor } from './lane-executor.js';
11
11
  import { PolicyEngine } from '../services/policy-engine.js';
12
12
  import { logThought } from '../utils/logger.js';
13
13
  import { ContextLifecycleOrchestrator } from '../services/context-lifecycle.js';
14
+ import { extractAndStoreMemory } from '../services/memory-extraction.js';
14
15
  import { createSelfSetupAgent } from '../agents/self-setup-agent.js';
15
16
  const DEFAULT_MAX_TOOL_ROUNDS = 6;
16
17
  const DEFAULT_IDENTICAL_TOOL_CALL_LIMIT = 3;
18
+ const DEFAULT_AGENT_TIMEOUT_MS = 300_000; // 5 minutes
19
+ const NO_REPLY_TOKEN = 'NO_REPLY';
17
20
  const DEFAULT_DELEGATION_MIN_SCORE = 2;
18
21
  const DELEGATION_KEYWORDS = [
19
22
  'complex',
@@ -91,6 +94,8 @@ export class Gateway {
91
94
  #enableDelegation;
92
95
  #delegationMinScore;
93
96
  #contextLifecycle;
97
+ #agentTimeoutMs;
98
+ #sessionLocks = new Map();
94
99
  #toolPolicy;
95
100
  #degradationCounts = new Map();
96
101
  #selfSetupAgents = new Map();
@@ -111,6 +116,7 @@ export class Gateway {
111
116
  this.#enableDelegation = options.enableDelegation ?? true;
112
117
  this.#delegationMinScore = Math.max(1, Number(options.delegationMinScore ?? DEFAULT_DELEGATION_MIN_SCORE));
113
118
  this.#contextLifecycle = new ContextLifecycleOrchestrator(options.contextBudgetConfig);
119
+ this.#agentTimeoutMs = options.agentTimeoutMs ?? DEFAULT_AGENT_TIMEOUT_MS;
114
120
  this.#toolPolicy = {
115
121
  allow: normalizeToolSelectors(options.toolPolicy?.allow),
116
122
  deny: normalizeToolSelectors(options.toolPolicy?.deny),
@@ -247,7 +253,43 @@ export class Gateway {
247
253
  ...(setupPrompt ? [{ role: 'system', content: setupPrompt }] : []),
248
254
  { role: 'user', content: normalizedText },
249
255
  ];
250
- return this.#runConversationLoop(sessionId, messages);
256
+ // Session write lock: serialize processText calls per session
257
+ const existingLock = this.#sessionLocks.get(sessionId) ?? Promise.resolve('');
258
+ const runLocked = existingLock.then(async () => {
259
+ return this.#runConversationLoopWithTimeout(sessionId, messages);
260
+ }).catch((err) => {
261
+ const message = err instanceof Error ? err.message : String(err);
262
+ console.error(`[Gateway] Session ${sessionId} error: ${message}`);
263
+ return `Error: ${message}`;
264
+ });
265
+ this.#sessionLocks.set(sessionId, runLocked);
266
+ // Clean up the lock reference once done
267
+ void runLocked.finally(() => {
268
+ if (this.#sessionLocks.get(sessionId) === runLocked) {
269
+ this.#sessionLocks.delete(sessionId);
270
+ }
271
+ });
272
+ return runLocked;
273
+ }
274
+ /** Wrap the conversation loop with a hard timeout + memory extraction. */
275
+ async #runConversationLoopWithTimeout(sessionId, messages) {
276
+ const loop = this.#agentTimeoutMs > 0
277
+ ? Promise.race([
278
+ this.#runConversationLoop(sessionId, messages),
279
+ new Promise((_, reject) => {
280
+ setTimeout(() => {
281
+ reject(new Error(`Agent run timed out after ${Math.round(this.#agentTimeoutMs / 1000)}s. Session: ${sessionId}`));
282
+ }, this.#agentTimeoutMs);
283
+ }),
284
+ ])
285
+ : this.#runConversationLoop(sessionId, messages);
286
+ const result = await loop;
287
+ // Fire-and-forget memory extraction after successful conversation
288
+ void extractAndStoreMemory(sessionId, messages, this.#router).catch((err) => {
289
+ const msg = err instanceof Error ? err.message : String(err);
290
+ console.warn(`[Gateway] Memory extraction failed for ${sessionId}: ${msg}`);
291
+ });
292
+ return result;
251
293
  }
252
294
  async #runConversationLoop(sessionId, messages) {
253
295
  // Refresh tools at the start of each loop to catch newly connected MCP servers
@@ -264,6 +306,24 @@ export class Gateway {
264
306
  tool_calls: assistantMessage.tool_calls,
265
307
  });
266
308
  if (!assistantMessage.tool_calls || assistantMessage.tool_calls.length === 0) {
309
+ // Empty response guard: detect bare "Done." or trivially empty responses
310
+ // when the model should have used tools but didn't
311
+ const trimmedContent = assistantContent.trim().toLowerCase();
312
+ const isEmptyResponse = !trimmedContent ||
313
+ trimmedContent === 'done.' ||
314
+ trimmedContent === 'done' ||
315
+ trimmedContent === 'ok.' ||
316
+ trimmedContent === 'ok';
317
+ if (isEmptyResponse && round === 0 && this.#tools.length > 0) {
318
+ // First round, tools available, but model gave empty/trivial response.
319
+ // Nudge it to actually use tools instead of giving up.
320
+ console.warn(`[Gateway] Empty response guard triggered: "${assistantContent}" in round ${round}. Nudging model to use tools.`);
321
+ messages.push({
322
+ role: 'system',
323
+ content: 'Your response was empty or trivial. You have tools available — use them to actually perform the task. Do NOT respond with just "Done." unless you have completed concrete actions and shown their results.',
324
+ });
325
+ continue;
326
+ }
267
327
  // L6: Index the final assistant response and the user's initial prompt together
268
328
  // this is moved here from the outer processText method
269
329
  const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user');
@@ -273,7 +333,9 @@ export class Gateway {
273
333
  });
274
334
  }
275
335
  await this.#persistTurn(sessionId, 'assistant', assistantContent || '[assistant finished]');
276
- return assistantContent || 'Done.';
336
+ // Filter NO_REPLY tokens from final output
337
+ const finalContent = assistantContent.replace(NO_REPLY_TOKEN, '').trim();
338
+ return finalContent || 'Done.';
277
339
  }
278
340
  await this.#persistTurn(sessionId, 'assistant', assistantContent || '[assistant returned tool calls]');
279
341
  const pattern = this.#buildToolCallSignature(assistantMessage.tool_calls);
@@ -291,10 +353,23 @@ export class Gateway {
291
353
  return `${diagnostic} Stopping execution to prevent an infinite loop.`;
292
354
  }
293
355
  const toolResults = await this.#laneExecutor.executeToolCalls(assistantMessage, sessionId, this.#policyEngine);
356
+ // Track if a messaging tool was used in this round
357
+ const calledToolNames = (assistantMessage.tool_calls ?? []).map((tc) => tc.function.name);
358
+ const usedMessagingTool = calledToolNames.some((name) => MESSAGING_TOOL_NAMES.has(name));
294
359
  for (const toolMessage of toolResults) {
295
360
  messages.push(toolMessage);
296
361
  await this.#persistTurn(sessionId, 'tool', toolMessage.content ?? '');
297
362
  }
363
+ // NO_REPLY suppression: if a messaging tool was called, inject a
364
+ // hint telling the model not to also produce a text reply.
365
+ if (usedMessagingTool) {
366
+ messages.push({
367
+ role: 'system',
368
+ content: 'You already sent a message via a messaging tool. ' +
369
+ 'Do NOT also produce a text reply confirming it — that would duplicate the message. ' +
370
+ `If you have nothing else to do, respond with exactly: ${NO_REPLY_TOKEN}`,
371
+ });
372
+ }
298
373
  // Compact messages to fit within budget after tool results are added
299
374
  this.#compactConversationMessages(messages);
300
375
  }
@@ -514,58 +589,58 @@ export class Gateway {
514
589
  return content;
515
590
  }
516
591
  #getPersonaSetupPrompt() {
517
- return `
518
- ## First Interaction - Persona Setup
519
-
520
- This appears to be your first conversation with me. Before we dive into anything, I'd love to take a few minutes to get to know you better so I can be actually helpful (not just generic AI helpful).
521
-
522
- Think of this as a quick chat where I learn what makes you tick.
523
-
524
- Start by introducing yourself warmly and asking me 2-3 questions about:
525
- - What do you do? (Role, industry, company)
526
- - What are you working on right now?
527
- - How do you like information presented? (Brief vs detailed, casual vs formal)
528
- - What frustrates you about AI assistants?
529
- - What platforms or tools do you use that I might integrate with?
530
-
531
- After I respond, synthesize what you learn and create/update the user.md file in my identity directory with:
532
- - User name and how they'd like to be addressed
533
- - Role/industry/company
534
- - Communication preferences
535
- - Current projects or goals
536
- - Tools and platforms they use
537
- - Anything they want me to know or avoid
538
-
539
- The user.md file should follow this format:
540
-
541
- \`\`\`markdown
542
- # User Profile
543
-
544
- ## Basic Info
545
- - **Name:** [what they want to be called]
546
- - **Role:** [job title/position]
547
- - **Company/Context:** [where they work/study]
548
- - **Technical Level:** [beginner/intermediate/expert]
549
-
550
- ## Communication Preferences
551
- - **Formality:** [casual/professional/mix]
552
- - **Detail Level:** [brief/comprehensive/contextual]
553
- - **Tone:** [direct/exploratory/friendly]
554
-
555
- ## Current Context
556
- - **Active Projects:** [what they're working on]
557
- - **Goals:** [what they're trying to achieve]
558
- - **Tools:** [daily tech stack, platforms]
559
-
560
- ## Important to Remember
561
- - [things they explicitly mention caring about]
562
- - [frustrations they mention]
563
-
564
- ## Learned Facts
565
- - [interesting facts from our conversation]
566
- \`\`\`
567
-
568
- Tell me once you've saved this. Then we'll be ready to dive in!
592
+ return `
593
+ ## First Interaction - Persona Setup
594
+
595
+ This appears to be your first conversation with me. Before we dive into anything, I'd love to take a few minutes to get to know you better so I can be actually helpful (not just generic AI helpful).
596
+
597
+ Think of this as a quick chat where I learn what makes you tick.
598
+
599
+ Start by introducing yourself warmly and asking me 2-3 questions about:
600
+ - What do you do? (Role, industry, company)
601
+ - What are you working on right now?
602
+ - How do you like information presented? (Brief vs detailed, casual vs formal)
603
+ - What frustrates you about AI assistants?
604
+ - What platforms or tools do you use that I might integrate with?
605
+
606
+ After I respond, synthesize what you learn and create/update the user.md file in my identity directory with:
607
+ - User name and how they'd like to be addressed
608
+ - Role/industry/company
609
+ - Communication preferences
610
+ - Current projects or goals
611
+ - Tools and platforms they use
612
+ - Anything they want me to know or avoid
613
+
614
+ The user.md file should follow this format:
615
+
616
+ \`\`\`markdown
617
+ # User Profile
618
+
619
+ ## Basic Info
620
+ - **Name:** [what they want to be called]
621
+ - **Role:** [job title/position]
622
+ - **Company/Context:** [where they work/study]
623
+ - **Technical Level:** [beginner/intermediate/expert]
624
+
625
+ ## Communication Preferences
626
+ - **Formality:** [casual/professional/mix]
627
+ - **Detail Level:** [brief/comprehensive/contextual]
628
+ - **Tone:** [direct/exploratory/friendly]
629
+
630
+ ## Current Context
631
+ - **Active Projects:** [what they're working on]
632
+ - **Goals:** [what they're trying to achieve]
633
+ - **Tools:** [daily tech stack, platforms]
634
+
635
+ ## Important to Remember
636
+ - [things they explicitly mention caring about]
637
+ - [frustrations they mention]
638
+
639
+ ## Learned Facts
640
+ - [interesting facts from our conversation]
641
+ \`\`\`
642
+
643
+ Tell me once you've saved this. Then we'll be ready to dive in!
569
644
  `;
570
645
  }
571
646
  }
@@ -1,6 +1,7 @@
1
1
  import { logToolCall, scrubSensitiveText } from '../utils/logger.js';
2
2
  import { getLearningSystem } from '../services/learning-system.js';
3
3
  import { getSelfHealingService } from '../services/self-healing.js';
4
+ import { getToolEventBus } from './tool-events.js';
4
5
  /** Convert a Skill (from the registry) into the internal Tool format used by LaneExecutor. */
5
6
  function skillToTool(skill) {
6
7
  return {
@@ -18,6 +19,17 @@ function skillToTool(skill) {
18
19
  },
19
20
  };
20
21
  }
22
+ /** Max characters for a single tool result to prevent context budget exhaustion. */
23
+ const MAX_TOOL_RESULT_CHARS = 16_000;
24
+ /** Truncate oversized tool results to prevent context budget exhaustion. */
25
+ function sanitizeToolResult(content, toolName) {
26
+ if (content.length <= MAX_TOOL_RESULT_CHARS) {
27
+ return content;
28
+ }
29
+ const truncated = content.slice(0, MAX_TOOL_RESULT_CHARS);
30
+ console.warn(`[LaneExecutor] Tool '${toolName}' result truncated: ${content.length} -> ${MAX_TOOL_RESULT_CHARS} chars`);
31
+ return `${truncated}\n\n[... truncated ${content.length - MAX_TOOL_RESULT_CHARS} characters. Result was too large for context window.]`;
32
+ }
21
33
  export class LaneExecutor {
22
34
  tools = new Map();
23
35
  constructor(tools = []) {
@@ -51,16 +63,6 @@ export class LaneExecutor {
51
63
  syncFromRegistry(registry) {
52
64
  this.syncSkills(registry.list());
53
65
  }
54
- parseArguments(args) {
55
- try {
56
- return JSON.parse(args);
57
- }
58
- catch (error) {
59
- const message = error instanceof Error ? error.message : String(error);
60
- console.warn(`[LaneExecutor] Failed to parse arguments: ${scrubSensitiveText(args)} (${scrubSensitiveText(message)})`);
61
- return {};
62
- }
63
- }
64
66
  async executeToolCalls(message, sessionId, policyEngine) {
65
67
  if (!message.tool_calls || message.tool_calls.length === 0) {
66
68
  return [];
@@ -69,10 +71,24 @@ export class LaneExecutor {
69
71
  // Lane-Based Execution: Execute tools serially in an await loop
70
72
  for (const toolCall of message.tool_calls) {
71
73
  const toolName = toolCall.function.name;
72
- const args = this.parseArguments(toolCall.function.arguments);
73
- const tool = this.tools.get(toolName);
74
+ let args;
74
75
  let content = '';
75
- if (!tool) {
76
+ let parsingFailed = false;
77
+ try {
78
+ args = JSON.parse(toolCall.function.arguments);
79
+ }
80
+ catch (error) {
81
+ const errorMessage = error instanceof Error ? error.message : String(error);
82
+ console.warn(`[LaneExecutor] Failed to parse arguments for ${toolName}: ${scrubSensitiveText(errorMessage)}`);
83
+ content = `Error: Invalid JSON arguments for tool '${toolName}'. Fix syntax.`;
84
+ parsingFailed = true;
85
+ args = {};
86
+ }
87
+ const tool = this.tools.get(toolName);
88
+ if (parsingFailed) {
89
+ await logToolCall(toolName, args, content);
90
+ }
91
+ else if (!tool) {
76
92
  console.warn(`[LaneExecutor] Tool not found: ${toolName}`);
77
93
  content = `Error: Tool '${toolName}' is not registered or unavailable.`;
78
94
  await logToolCall(toolName, args, content);
@@ -110,12 +126,31 @@ export class LaneExecutor {
110
126
  await logToolCall(toolName, args, content);
111
127
  }
112
128
  if (allowed) {
129
+ const startTime = Date.now();
130
+ const eventBus = getToolEventBus();
131
+ eventBus.emitStart({
132
+ sessionId,
133
+ toolName,
134
+ toolCallId: toolCall.id,
135
+ args,
136
+ timestamp: startTime,
137
+ });
113
138
  try {
114
139
  console.log(`[LaneExecutor] Executing ${toolName} with args: ${scrubSensitiveText(JSON.stringify(args))}`);
115
140
  const result = await tool.execute(args);
116
141
  content = typeof result === 'string' ? result : JSON.stringify(result);
117
142
  await logToolCall(toolName, args, content);
118
143
  const success = !content.startsWith('Error');
144
+ const durationMs = Date.now() - startTime;
145
+ eventBus.emitEnd({
146
+ sessionId,
147
+ toolName,
148
+ toolCallId: toolCall.id,
149
+ success,
150
+ durationMs,
151
+ resultPreview: content.slice(0, 200),
152
+ timestamp: Date.now(),
153
+ });
119
154
  await this.#recordSkillExecution(toolName, args, success, content);
120
155
  }
121
156
  catch (error) {
@@ -124,6 +159,15 @@ export class LaneExecutor {
124
159
  console.error(`[LaneExecutor] Tool ${toolName} failed: ${sanitizedMessage}`);
125
160
  content = `Error executing tool: ${sanitizedMessage}`;
126
161
  await logToolCall(toolName, args, content);
162
+ const durationMs = Date.now() - startTime;
163
+ eventBus.emitError({
164
+ sessionId,
165
+ toolName,
166
+ toolCallId: toolCall.id,
167
+ error: sanitizedMessage,
168
+ durationMs,
169
+ timestamp: Date.now(),
170
+ });
127
171
  await this.#recordSkillExecution(toolName, args, false, sanitizedMessage);
128
172
  }
129
173
  }
@@ -132,7 +176,7 @@ export class LaneExecutor {
132
176
  role: 'tool',
133
177
  tool_call_id: toolCall.id,
134
178
  name: toolName,
135
- content: content,
179
+ content: sanitizeToolResult(content, toolName),
136
180
  });
137
181
  }
138
182
  return results;
@@ -0,0 +1,53 @@
1
+ import { EventEmitter } from 'node:events';
2
+ // ── Typed EventEmitter ──────────────────────────────────────────────────────
3
+ class ToolEventBus extends EventEmitter {
4
+ emitStart(event) {
5
+ this.emit('tool:start', event);
6
+ }
7
+ emitEnd(event) {
8
+ this.emit('tool:end', event);
9
+ }
10
+ emitError(event) {
11
+ this.emit('tool:error', event);
12
+ }
13
+ onStart(listener) {
14
+ return this.on('tool:start', listener);
15
+ }
16
+ onEnd(listener) {
17
+ return this.on('tool:end', listener);
18
+ }
19
+ onError(listener) {
20
+ return this.on('tool:error', listener);
21
+ }
22
+ }
23
+ /** Singleton tool event bus — shared between LaneExecutor (producer) and Dispatcher (consumer). */
24
+ let instance = null;
25
+ export function getToolEventBus() {
26
+ if (!instance) {
27
+ instance = new ToolEventBus();
28
+ // Avoid memory-leak warnings for many listeners (one per platform adapter).
29
+ instance.setMaxListeners(20);
30
+ }
31
+ return instance;
32
+ }
33
+ /** Human-friendly tool name for status messages. */
34
+ export function humanizeToolName(toolName) {
35
+ const map = {
36
+ 'read_file': '📄 Reading file',
37
+ 'write_file': '✏️ Writing file',
38
+ 'edit_file': '✏️ Editing file',
39
+ 'list_directory': '📂 Listing directory',
40
+ 'directory_tree': '📂 Browsing directory tree',
41
+ 'search_files': '🔍 Searching files',
42
+ 'exec': '⚡ Running command',
43
+ 'web_search': '🌐 Searching the web',
44
+ 'web_fetch': '🌐 Fetching webpage',
45
+ 'create_entities': '🧠 Saving to memory',
46
+ 'search_nodes': '🧠 Searching memory',
47
+ 'create_relations': '🧠 Connecting knowledge',
48
+ 'send_telegram_message': '💬 Sending Telegram message',
49
+ 'send_whatsapp_message': '💬 Sending WhatsApp message',
50
+ 'sequentialthinking': '🤔 Thinking step-by-step',
51
+ };
52
+ return map[toolName] ?? `🔧 Using ${toolName}`;
53
+ }
@@ -4,6 +4,7 @@ import { getDmPairingService, normalizePairingSenderId, } from '../services/dm-p
4
4
  import { InboundDebounceService } from '../services/inbound-debounce.js';
5
5
  import { EmbeddedBlockChunker } from '../services/block-chunker.js';
6
6
  import { getConfigValue } from '../config/json-config.js';
7
+ import { getToolEventBus, humanizeToolName } from '../core/tool-events.js';
7
8
  function parseIntConfig(key, fallback) {
8
9
  const value = getConfigValue(key);
9
10
  if (value === undefined || value === null || value === '') {
@@ -59,6 +60,8 @@ export class Dispatcher {
59
60
  #debounce;
60
61
  #chunker;
61
62
  #humanDelayMs;
63
+ /** Maps sessionId → { platform, chatId } for routing tool events to the correct chat. */
64
+ #activeSessions = new Map();
62
65
  constructor(telegram, whatsapp, stt, tts, gateway, queue, options = {}) {
63
66
  this.#telegram = telegram;
64
67
  this.#whatsapp = whatsapp;
@@ -98,6 +101,8 @@ export class Dispatcher {
98
101
  this.#telegram.onMessage = (msg) => this.#handleDebounced(msg);
99
102
  if (this.#whatsapp)
100
103
  this.#whatsapp.onMessage = (msg) => this.#handleDebounced(msg);
104
+ // Subscribe to tool events for real-time status messages
105
+ this.#subscribeToToolEvents();
101
106
  }
102
107
  /** Expose the queue service for reliability and dead-letter controls. */
103
108
  get queue() {
@@ -127,8 +132,16 @@ export class Dispatcher {
127
132
  return;
128
133
  }
129
134
  const normalized = await this.#resolveAudio(message);
130
- const responseText = await this.#gateway.processMessage(normalized);
131
- await this.#dispatch(normalized, responseText);
135
+ // Track session so tool events can route status messages to this chat
136
+ const sessionId = `${normalized.platform}:${normalized.senderId}`;
137
+ this.trackSession(sessionId, normalized.platform, normalized.chatId);
138
+ try {
139
+ const responseText = await this.#gateway.processMessage(normalized);
140
+ await this.#dispatch(normalized, responseText);
141
+ }
142
+ finally {
143
+ this.untrackSession(sessionId);
144
+ }
132
145
  }
133
146
  catch (err) {
134
147
  const errorMessage = err instanceof Error ? err.message : String(err);
@@ -294,7 +307,36 @@ export class Dispatcher {
294
307
  /** Tear down all active interface adapters cleanly. */
295
308
  shutdown() {
296
309
  this.#debounce.clear();
310
+ this.#activeSessions.clear();
297
311
  this.#telegram?.stop();
298
312
  this.#whatsapp?.stop();
299
313
  }
314
+ // ── Tool Event Streaming ────────────────────────────────────────────────────
315
+ /** Subscribe to LaneExecutor tool events and send status messages to the active chat. */
316
+ #subscribeToToolEvents() {
317
+ const eventBus = getToolEventBus();
318
+ eventBus.onStart((event) => {
319
+ const target = this.#activeSessions.get(event.sessionId);
320
+ if (!target)
321
+ return;
322
+ const statusText = `${humanizeToolName(event.toolName)}...`;
323
+ // Fire-and-forget status message — don't await to avoid blocking execution
324
+ this.#queue.enqueue(target.platform, target.chatId, statusText);
325
+ });
326
+ eventBus.onError((event) => {
327
+ const target = this.#activeSessions.get(event.sessionId);
328
+ if (!target)
329
+ return;
330
+ const statusText = `⚠️ ${event.toolName} failed (${Math.round(event.durationMs / 1000)}s): ${event.error.slice(0, 120)}`;
331
+ this.#queue.enqueue(target.platform, target.chatId, statusText);
332
+ });
333
+ }
334
+ /** Register the active session's routing info so tool events reach the right chat. */
335
+ trackSession(sessionId, platform, chatId) {
336
+ this.#activeSessions.set(sessionId, { platform, chatId });
337
+ }
338
+ /** Remove session tracking after processing completes. */
339
+ untrackSession(sessionId) {
340
+ this.#activeSessions.delete(sessionId);
341
+ }
300
342
  }
@@ -0,0 +1,111 @@
1
+ import { saveMemoryFact } from './db.js';
2
+ import { logThought } from '../utils/logger.js';
3
+ /**
4
+ * Extract persistent facts from a completed conversation and store them in the
5
+ * BM25 FTS5 memory table. Uses a lightweight model call to distill key facts.
6
+ *
7
+ * This runs as a fire-and-forget background task after each conversation loop.
8
+ */
9
+ export async function extractAndStoreMemory(sessionId, messages, router) {
10
+ try {
11
+ // Only extract from conversations with meaningful content
12
+ const userMessages = messages.filter((m) => m.role === 'user');
13
+ const assistantMessages = messages.filter((m) => m.role === 'assistant');
14
+ if (userMessages.length === 0 || assistantMessages.length === 0) {
15
+ return 0;
16
+ }
17
+ // Build a compact summary of the conversation for extraction
18
+ const conversationSummary = buildConversationSummary(messages);
19
+ if (conversationSummary.length < 50) {
20
+ // Conversation too short to extract meaningful facts from
21
+ return 0;
22
+ }
23
+ const extractionMessages = [
24
+ {
25
+ role: 'system',
26
+ content: EXTRACTION_PROMPT,
27
+ },
28
+ {
29
+ role: 'user',
30
+ content: conversationSummary,
31
+ },
32
+ ];
33
+ // Use model to extract facts — no tools, lightweight call
34
+ const response = await router.createChatCompletion(extractionMessages, [], // no tools
35
+ { sessionId: `memory-extract:${sessionId}` });
36
+ const rawContent = response.content?.trim() ?? '';
37
+ const facts = parseExtractedFacts(rawContent);
38
+ if (facts.length === 0) {
39
+ return 0;
40
+ }
41
+ // Store each fact in the FTS5 table
42
+ let stored = 0;
43
+ for (const fact of facts) {
44
+ const taggedFact = `MEMORY: ${fact.fact}${fact.tags.length > 0 ? ` [${fact.tags.join(', ')}]` : ''}`;
45
+ saveMemoryFact(sessionId, taggedFact);
46
+ stored++;
47
+ }
48
+ void logThought(`[MemoryExtraction] Extracted and stored ${stored} facts from session ${sessionId}`);
49
+ return stored;
50
+ }
51
+ catch (err) {
52
+ const message = err instanceof Error ? err.message : String(err);
53
+ console.warn(`[MemoryExtraction] Failed for session ${sessionId}: ${message}`);
54
+ return 0;
55
+ }
56
+ }
57
+ // ── Extraction Prompt ──────────────────────────────────────────────────────────
58
+ const EXTRACTION_PROMPT = `You are a memory extraction agent. Your job is to extract 0-5 key facts from a conversation that are worth remembering long-term.
59
+
60
+ Rules:
61
+ - Only extract genuinely useful, persistent facts (preferences, names, decisions, technical context).
62
+ - Do NOT extract transient information (greetings, "thank you", temporary states).
63
+ - Do NOT extract information the user explicitly marked as private or temporary.
64
+ - Each fact should be self-contained and understandable without the conversation context.
65
+ - Tags should be 1-3 short keywords for categorization.
66
+
67
+ Respond with ONLY a JSON array. No markdown, no explanation, no code fences.
68
+ Format: [{"fact": "...", "tags": ["..."]}]
69
+ Return [] if nothing notable or the conversation is trivial.`;
70
+ /** Build a compact summary of the conversation for the extraction model. */
71
+ function buildConversationSummary(messages) {
72
+ const relevant = messages.filter((m) => m.role === 'user' || m.role === 'assistant');
73
+ // Cap at last 10 messages to keep context small
74
+ const recent = relevant.slice(-10);
75
+ return recent
76
+ .map((m) => `${m.role.toUpperCase()}: ${(m.content ?? '').slice(0, 500)}`)
77
+ .join('\n\n');
78
+ }
79
+ /** Parse the model's extracted facts from JSON, with lenient error handling. */
80
+ function parseExtractedFacts(raw) {
81
+ if (!raw || raw === '[]') {
82
+ return [];
83
+ }
84
+ // Strip markdown code fences if present
85
+ const cleaned = raw
86
+ .replace(/^```(?:json)?\s*/i, '')
87
+ .replace(/\s*```$/i, '')
88
+ .trim();
89
+ try {
90
+ const parsed = JSON.parse(cleaned);
91
+ if (!Array.isArray(parsed)) {
92
+ return [];
93
+ }
94
+ return parsed
95
+ .filter((item) => typeof item === 'object' &&
96
+ item !== null &&
97
+ typeof item.fact === 'string' &&
98
+ item.fact !== '')
99
+ .map((item) => ({
100
+ fact: String(item.fact).slice(0, 500),
101
+ tags: Array.isArray(item.tags)
102
+ ? item.tags.filter((t) => typeof t === 'string').slice(0, 5)
103
+ : [],
104
+ }))
105
+ .slice(0, 5); // Hard cap at 5 facts per session
106
+ }
107
+ catch {
108
+ console.warn(`[MemoryExtraction] Failed to parse JSON: ${cleaned.slice(0, 100)}`);
109
+ return [];
110
+ }
111
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "twinclaw",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "description": "Eagle-eyed agentic AI gateway with multi-modal hooks and proactive memory.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {