get-claudia 1.32.0 → 1.34.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.
@@ -18,6 +18,7 @@ import { existsSync } from 'fs';
18
18
  import { readFileSync } from 'fs';
19
19
  import { createLogger } from './utils/logger.js';
20
20
  import { loadPersonality } from './personality.js';
21
+ import { ToolManager } from './tools.js';
21
22
 
22
23
  const log = createLogger('bridge');
23
24
 
@@ -45,6 +46,8 @@ export class Bridge {
45
46
  this.extractor = null; // Set by gateway after construction
46
47
  this._consecutiveFailures = 0;
47
48
  this._personality = null; // Loaded Claudia personality prompt
49
+ this._toolManager = null; // ToolManager instance (or null if tool_use disabled)
50
+ this._toolUseEnabled = false;
48
51
  }
49
52
 
50
53
  async start() {
@@ -97,6 +100,16 @@ export class Bridge {
97
100
 
98
101
  // Try to connect to memory daemon via MCP
99
102
  await this._connectMemory();
103
+
104
+ // Initialize tool_use if memory is available and tool_use is enabled
105
+ if (this.memoryAvailable && this._isToolUseEnabled()) {
106
+ this._toolManager = new ToolManager();
107
+ await this._toolManager.initialize(this.mcpClient);
108
+ this._toolUseEnabled = this._toolManager.isReady();
109
+ if (this._toolUseEnabled) {
110
+ log.info('Tool use enabled', { toolCount: this._toolManager.toolCount });
111
+ }
112
+ }
100
113
  }
101
114
 
102
115
  async stop() {
@@ -127,9 +140,12 @@ export class Bridge {
127
140
  async processMessage(message, conversationHistory = [], episodeId = null) {
128
141
  const { text, userId, userName, channel } = message;
129
142
 
130
- // 1. Recall relevant memories (skip if memory is failing repeatedly)
143
+ const useTools = this._toolUseEnabled && this._isToolUseEnabled(channel);
144
+
145
+ // 1. Recall relevant memories (skip if tool_use is active and preRecall is off)
131
146
  let memoryContext = '';
132
- if (this.memoryAvailable && this._consecutiveFailures < 3) {
147
+ const doPreRecall = this.config.preRecall !== false;
148
+ if (this.memoryAvailable && this._consecutiveFailures < 3 && (doPreRecall || !useTools)) {
133
149
  try {
134
150
  memoryContext = await this._recallContext(text, userName);
135
151
  this._consecutiveFailures = 0;
@@ -143,7 +159,7 @@ export class Bridge {
143
159
  }
144
160
 
145
161
  // 2. Build the prompt
146
- const systemPrompt = this._buildSystemPrompt(memoryContext, userName, channel);
162
+ const systemPrompt = this._buildSystemPrompt(memoryContext, userName, channel, useTools);
147
163
 
148
164
  // 3. Build message history
149
165
  const messages = [];
@@ -155,21 +171,30 @@ export class Bridge {
155
171
  }
156
172
  messages.push({ role: 'user', content: text });
157
173
 
158
- // 4. Call LLM (Anthropic or Ollama)
174
+ // 4. Call LLM (Anthropic or Ollama), with or without tool loop
159
175
  const resolvedModel = this._resolveModel(channel);
160
176
  log.info('Calling LLM', {
161
177
  provider: this.provider,
162
178
  model: resolvedModel,
163
179
  channel,
164
180
  messageCount: messages.length,
181
+ toolUse: useTools,
165
182
  });
166
183
 
167
184
  let result;
168
185
  try {
169
- if (this.provider === 'anthropic') {
170
- result = await this._callAnthropic(systemPrompt, messages, resolvedModel);
186
+ if (useTools) {
187
+ if (this.provider === 'anthropic') {
188
+ result = await this._callAnthropicWithTools(systemPrompt, messages, resolvedModel, channel);
189
+ } else {
190
+ result = await this._callOllamaWithTools(systemPrompt, messages, resolvedModel, channel);
191
+ }
171
192
  } else {
172
- result = await this._callOllama(systemPrompt, messages, resolvedModel);
193
+ if (this.provider === 'anthropic') {
194
+ result = await this._callAnthropic(systemPrompt, messages, resolvedModel);
195
+ } else {
196
+ result = await this._callOllama(systemPrompt, messages, resolvedModel);
197
+ }
173
198
  }
174
199
  } catch (err) {
175
200
  log.error('LLM call failed', { provider: this.provider, error: err.message });
@@ -409,7 +434,7 @@ export class Bridge {
409
434
  * 2. Custom systemPromptPath file (backward compat)
410
435
  * 3. DEFAULT_SYSTEM_PROMPT fallback
411
436
  */
412
- _buildSystemPrompt(memoryContext, userName, channel) {
437
+ _buildSystemPrompt(memoryContext, userName, channel, toolUse = false) {
413
438
  let prompt;
414
439
 
415
440
  if (this._personality) {
@@ -435,6 +460,16 @@ export class Bridge {
435
460
  prompt += `\n\n# Memory Context\n${memoryContext}`;
436
461
  }
437
462
 
463
+ if (toolUse) {
464
+ prompt += `\n\n# Memory Tools
465
+ You have access to memory tools to search, store, and manage your persistent knowledge. Use them naturally during conversation:
466
+ - Search for more context when a name, topic, or project comes up that you don't have enough info on
467
+ - Store new facts when the user mentions something important worth remembering
468
+ - Correct or invalidate memories when the user tells you something is wrong or outdated
469
+ - Trace provenance when asked where you learned something
470
+ Don't announce tool usage -- just use the tools and incorporate results naturally into your response.`;
471
+ }
472
+
438
473
  return prompt;
439
474
  }
440
475
 
@@ -515,6 +550,237 @@ export class Bridge {
515
550
  throw lastError;
516
551
  }
517
552
 
553
+ /**
554
+ * Check if tool_use is enabled for a given channel.
555
+ * Resolution: per-channel override → global config → auto-detect by provider.
556
+ *
557
+ * @param {string} [channel] - Channel name
558
+ * @returns {boolean}
559
+ */
560
+ _isToolUseEnabled(channel) {
561
+ // Per-channel override (must be explicit boolean, not undefined)
562
+ if (channel) {
563
+ const channelToolUse = this.config.channels?.[channel]?.toolUse;
564
+ if (typeof channelToolUse === 'boolean') return channelToolUse;
565
+ }
566
+
567
+ // Global config override
568
+ if (typeof this.config.toolUse === 'boolean') return this.config.toolUse;
569
+
570
+ // Auto-detect: enabled for Anthropic, disabled for Ollama
571
+ return this.provider === 'anthropic';
572
+ }
573
+
574
+ /**
575
+ * Call Anthropic API with tool_use loop.
576
+ * Runs up to toolUseMaxIterations rounds, executing tool calls and feeding
577
+ * results back until the model produces a final text response.
578
+ *
579
+ * @param {string} systemPrompt
580
+ * @param {Object[]} messages - Conversation messages (will be mutated with tool results)
581
+ * @param {string} model
582
+ * @param {string} channel
583
+ * @returns {{ text: string, usage: Object }}
584
+ */
585
+ async _callAnthropicWithTools(systemPrompt, messages, model, channel) {
586
+ const tools = this._toolManager.getAnthropicTools();
587
+ const maxIterations = this.config.toolUseMaxIterations || 5;
588
+ let totalUsage = { input_tokens: 0, output_tokens: 0 };
589
+
590
+ for (let iteration = 0; iteration < maxIterations; iteration++) {
591
+ const response = await this.anthropic.messages.create({
592
+ model,
593
+ max_tokens: this.config.maxTokens || 2048,
594
+ system: systemPrompt,
595
+ messages,
596
+ tools,
597
+ });
598
+
599
+ // Accumulate usage
600
+ if (response.usage) {
601
+ totalUsage.input_tokens += response.usage.input_tokens || 0;
602
+ totalUsage.output_tokens += response.usage.output_tokens || 0;
603
+ }
604
+
605
+ // If model didn't request tool use, extract text and return
606
+ if (response.stop_reason !== 'tool_use') {
607
+ const text = response.content
608
+ .filter((block) => block.type === 'text')
609
+ .map((block) => block.text)
610
+ .join('\n');
611
+ return { text, usage: totalUsage };
612
+ }
613
+
614
+ // Process tool calls
615
+ const toolUseBlocks = response.content.filter((b) => b.type === 'tool_use');
616
+ const toolResults = [];
617
+
618
+ for (const block of toolUseBlocks) {
619
+ log.debug('Tool call', {
620
+ iteration,
621
+ tool: block.name,
622
+ id: block.id,
623
+ });
624
+
625
+ const result = await this._executeToolCall(block.name, block.input, channel);
626
+ toolResults.push({
627
+ type: 'tool_result',
628
+ tool_use_id: block.id,
629
+ content: result,
630
+ });
631
+ }
632
+
633
+ // Append assistant response + tool results for next iteration
634
+ messages.push({ role: 'assistant', content: response.content });
635
+ messages.push({ role: 'user', content: toolResults });
636
+ }
637
+
638
+ // Exhausted iterations -- make one final call without tools to force a text response
639
+ log.warn('Tool loop exhausted max iterations', { maxIterations });
640
+ const finalResponse = await this.anthropic.messages.create({
641
+ model,
642
+ max_tokens: this.config.maxTokens || 2048,
643
+ system: systemPrompt,
644
+ messages,
645
+ });
646
+
647
+ if (finalResponse.usage) {
648
+ totalUsage.input_tokens += finalResponse.usage.input_tokens || 0;
649
+ totalUsage.output_tokens += finalResponse.usage.output_tokens || 0;
650
+ }
651
+
652
+ const text = finalResponse.content
653
+ .filter((block) => block.type === 'text')
654
+ .map((block) => block.text)
655
+ .join('\n');
656
+
657
+ return { text, usage: totalUsage };
658
+ }
659
+
660
+ /**
661
+ * Call Ollama /api/chat with tool_use loop.
662
+ *
663
+ * @param {string} systemPrompt
664
+ * @param {Object[]} messages
665
+ * @param {string} model
666
+ * @param {string} channel
667
+ * @returns {{ text: string, usage: null }}
668
+ */
669
+ async _callOllamaWithTools(systemPrompt, messages, model, channel) {
670
+ const tools = this._toolManager.getOllamaTools();
671
+ const host = this.config.ollama?.host || 'http://localhost:11434';
672
+ const maxIterations = this.config.toolUseMaxIterations || 5;
673
+
674
+ const ollamaMessages = [{ role: 'system', content: systemPrompt }];
675
+ for (const msg of messages) {
676
+ ollamaMessages.push({ role: msg.role, content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content) });
677
+ }
678
+
679
+ for (let iteration = 0; iteration < maxIterations; iteration++) {
680
+ const res = await fetch(`${host}/api/chat`, {
681
+ method: 'POST',
682
+ headers: { 'Content-Type': 'application/json' },
683
+ body: JSON.stringify({
684
+ model,
685
+ messages: ollamaMessages,
686
+ stream: false,
687
+ tools,
688
+ options: { temperature: 0.7 },
689
+ }),
690
+ signal: AbortSignal.timeout(60000),
691
+ });
692
+
693
+ if (!res.ok) {
694
+ const body = await res.text().catch(() => '');
695
+ throw new Error(`Ollama HTTP ${res.status}: ${body}`);
696
+ }
697
+
698
+ const data = await res.json();
699
+
700
+ // If no tool calls, return the text response
701
+ if (!data.message?.tool_calls?.length) {
702
+ return { text: data.message?.content || '', usage: null };
703
+ }
704
+
705
+ // Append assistant message with tool calls
706
+ ollamaMessages.push(data.message);
707
+
708
+ // Execute each tool call and add results
709
+ for (const tc of data.message.tool_calls) {
710
+ const toolName = tc.function?.name;
711
+ const toolInput = tc.function?.arguments || {};
712
+
713
+ log.debug('Ollama tool call', { iteration, tool: toolName });
714
+
715
+ const result = await this._executeToolCall(toolName, toolInput, channel);
716
+ ollamaMessages.push({
717
+ role: 'tool',
718
+ content: result,
719
+ });
720
+ }
721
+ }
722
+
723
+ // Exhausted iterations -- final call without tools
724
+ log.warn('Ollama tool loop exhausted max iterations', { maxIterations });
725
+ const finalRes = await fetch(`${host}/api/chat`, {
726
+ method: 'POST',
727
+ headers: { 'Content-Type': 'application/json' },
728
+ body: JSON.stringify({
729
+ model,
730
+ messages: ollamaMessages,
731
+ stream: false,
732
+ options: { temperature: 0.7 },
733
+ }),
734
+ signal: AbortSignal.timeout(60000),
735
+ });
736
+
737
+ if (!finalRes.ok) {
738
+ const body = await finalRes.text().catch(() => '');
739
+ throw new Error(`Ollama HTTP ${finalRes.status}: ${body}`);
740
+ }
741
+
742
+ const finalData = await finalRes.json();
743
+ return { text: finalData.message?.content || '', usage: null };
744
+ }
745
+
746
+ /**
747
+ * Execute a single tool call from the LLM against the MCP daemon.
748
+ *
749
+ * Safety: rejects calls to non-exposed tools.
750
+ * Auto-injects source_channel for write operations.
751
+ *
752
+ * @param {string} toolName
753
+ * @param {Object} toolInput
754
+ * @param {string} channel
755
+ * @returns {string} JSON string result (always valid, errors wrapped)
756
+ */
757
+ async _executeToolCall(toolName, toolInput, channel) {
758
+ // Safety gate: only execute exposed tools
759
+ if (!this._toolManager.isExposed(toolName)) {
760
+ log.warn('LLM attempted to call non-exposed tool', { tool: toolName });
761
+ return JSON.stringify({ error: `Tool "${toolName}" is not available` });
762
+ }
763
+
764
+ try {
765
+ // Auto-inject source_channel for write operations
766
+ const writeTools = new Set(['memory.remember', 'memory.batch', 'memory.correct']);
767
+ if (writeTools.has(toolName) && channel) {
768
+ toolInput = { ...toolInput, source_channel: channel };
769
+ }
770
+
771
+ const result = await this.mcpClient.callTool({
772
+ name: toolName,
773
+ arguments: toolInput,
774
+ });
775
+
776
+ const parsed = this._parseMcpResult(result);
777
+ return JSON.stringify(parsed ?? { ok: true });
778
+ } catch (err) {
779
+ log.warn('Tool execution failed', { tool: toolName, error: err.message });
780
+ return JSON.stringify({ error: err.message });
781
+ }
782
+ }
783
+
518
784
  /**
519
785
  * Connect to the memory daemon via MCP stdio.
520
786
  */
@@ -575,6 +841,8 @@ export class Bridge {
575
841
  personalityLoaded: !!this._personality,
576
842
  model:
577
843
  this.provider === 'ollama' ? this.config.ollama?.model : this.config.model,
844
+ toolUseEnabled: this._toolUseEnabled,
845
+ toolCount: this._toolManager?.toolCount || 0,
578
846
  };
579
847
  }
580
848
  }
@@ -18,7 +18,7 @@ const PID_PATH = join(CONFIG_DIR, 'gateway.pid');
18
18
  const DEFAULT_CONFIG = {
19
19
  // Anthropic API
20
20
  anthropicApiKey: '',
21
- model: 'claude-sonnet-4-20250514',
21
+ model: 'claude-haiku-4-5-20251001',
22
22
  maxTokens: 2048,
23
23
 
24
24
  // Ollama (local model, auto-detected from ~/.claudia/config.json)
@@ -27,6 +27,11 @@ const DEFAULT_CONFIG = {
27
27
  model: '', // Auto-detected from ~/.claudia/config.json language_model field
28
28
  },
29
29
 
30
+ // Tool use (LLM-driven memory tool calls)
31
+ toolUse: undefined, // true/false/undefined (undefined = auto: true for Anthropic, false for Ollama)
32
+ toolUseMaxIterations: 5, // Max tool loop rounds per message
33
+ preRecall: true, // Keep programmatic pre-call recall alongside tool_use
34
+
30
35
  // Personality
31
36
  personalityDir: '', // Path to Claudia template dir (contains CLAUDE.md + .claude/rules/)
32
37
  personalityMaxChars: 15000, // Safety limit for prompt size
@@ -52,6 +57,7 @@ const DEFAULT_CONFIG = {
52
57
  token: '',
53
58
  allowedUsers: [],
54
59
  model: '', // Per-channel model override (empty = use global)
60
+ toolUse: undefined, // Per-channel tool_use override (undefined = use global)
55
61
  },
56
62
  slack: {
57
63
  enabled: false,
@@ -60,6 +66,7 @@ const DEFAULT_CONFIG = {
60
66
  signingSecret: '',
61
67
  allowedUsers: [],
62
68
  model: '', // Per-channel model override (empty = use global)
69
+ toolUse: undefined, // Per-channel tool_use override (undefined = use global)
63
70
  },
64
71
  },
65
72
 
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Tool Schema Manager for Claudia Gateway
3
+ *
4
+ * Fetches MCP tool schemas from the memory daemon, filters to a curated
5
+ * subset safe for LLM use, and converts them to Anthropic/Ollama formats.
6
+ *
7
+ * Design: rather than hardcoding 14 tool definitions in JavaScript
8
+ * (duplicating the Python schemas), we fetch at startup, filter to the
9
+ * curated set, and convert the format. When daemon schemas change, the
10
+ * gateway automatically picks them up.
11
+ */
12
+
13
+ import { createLogger } from './utils/logger.js';
14
+
15
+ const log = createLogger('tools');
16
+
17
+ /**
18
+ * Tools exposed to the LLM for agentic use.
19
+ *
20
+ * Criteria for inclusion:
21
+ * - Read-only or safe writes (remember, correct, invalidate)
22
+ * - Useful in a conversational context
23
+ * - No destructive/admin operations
24
+ * - No session lifecycle tools (gateway manages those internally)
25
+ */
26
+ const EXPOSED_TOOLS = new Set([
27
+ 'memory.recall',
28
+ 'memory.about',
29
+ 'memory.remember',
30
+ 'memory.relate',
31
+ 'memory.entity',
32
+ 'memory.search_entities',
33
+ 'memory.batch',
34
+ 'memory.correct',
35
+ 'memory.invalidate',
36
+ 'memory.trace',
37
+ 'memory.reflections',
38
+ 'memory.project_network',
39
+ 'memory.find_path',
40
+ 'memory.briefing',
41
+ ]);
42
+
43
+ export class ToolManager {
44
+ constructor() {
45
+ this._mcpTools = []; // Raw MCP tool schemas (filtered)
46
+ this._anthropicTools = []; // Converted to Anthropic format
47
+ this._ollamaTools = []; // Converted to Ollama format
48
+ this._initialized = false;
49
+ }
50
+
51
+ /**
52
+ * Fetch tool schemas from the MCP daemon and prepare provider-specific formats.
53
+ *
54
+ * @param {import('@modelcontextprotocol/sdk/client/index.js').Client} mcpClient
55
+ */
56
+ async initialize(mcpClient) {
57
+ try {
58
+ const result = await mcpClient.listTools();
59
+ const allTools = result?.tools || [];
60
+
61
+ // Filter to the curated exposed set
62
+ this._mcpTools = allTools.filter((t) => EXPOSED_TOOLS.has(t.name));
63
+
64
+ // Convert to provider formats
65
+ this._anthropicTools = this._mcpTools.map((t) => this._toAnthropicSchema(t));
66
+ this._ollamaTools = this._mcpTools.map((t) => this._toOllamaSchema(t));
67
+
68
+ this._initialized = true;
69
+ log.info('Tool schemas loaded', {
70
+ total: allTools.length,
71
+ exposed: this._mcpTools.length,
72
+ });
73
+ } catch (err) {
74
+ log.warn('Failed to load tool schemas', { error: err.message });
75
+ this._mcpTools = [];
76
+ this._anthropicTools = [];
77
+ this._ollamaTools = [];
78
+ this._initialized = false;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Get tools in Anthropic API format.
84
+ * @returns {Object[]} Array of { name, description, input_schema }
85
+ */
86
+ getAnthropicTools() {
87
+ return this._anthropicTools;
88
+ }
89
+
90
+ /**
91
+ * Get tools in Ollama API format.
92
+ * @returns {Object[]} Array of { type: 'function', function: { name, description, parameters } }
93
+ */
94
+ getOllamaTools() {
95
+ return this._ollamaTools;
96
+ }
97
+
98
+ /**
99
+ * Check whether a tool name is in the exposed set.
100
+ * Used as a safety gate before executing tool calls from the LLM.
101
+ *
102
+ * @param {string} toolName
103
+ * @returns {boolean}
104
+ */
105
+ isExposed(toolName) {
106
+ return EXPOSED_TOOLS.has(toolName);
107
+ }
108
+
109
+ /**
110
+ * @returns {boolean} Whether initialization succeeded and tools are available
111
+ */
112
+ isReady() {
113
+ return this._initialized && this._anthropicTools.length > 0;
114
+ }
115
+
116
+ /**
117
+ * @returns {number} Number of exposed tools loaded
118
+ */
119
+ get toolCount() {
120
+ return this._anthropicTools.length;
121
+ }
122
+
123
+ // --- Private conversion methods ---
124
+
125
+ /**
126
+ * Convert an MCP tool schema to Anthropic format.
127
+ *
128
+ * MCP: { name, description, inputSchema: { type, properties, required } }
129
+ * Anthropic: { name, description, input_schema: { type, properties, required } }
130
+ */
131
+ _toAnthropicSchema(mcpTool) {
132
+ const inputSchema = this._normalizeSchema(mcpTool.inputSchema || { type: 'object', properties: {} });
133
+ return {
134
+ name: mcpTool.name,
135
+ description: mcpTool.description || '',
136
+ input_schema: inputSchema,
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Convert an MCP tool schema to Ollama format.
142
+ *
143
+ * Ollama: { type: 'function', function: { name, description, parameters } }
144
+ */
145
+ _toOllamaSchema(mcpTool) {
146
+ const parameters = this._normalizeSchema(mcpTool.inputSchema || { type: 'object', properties: {} });
147
+ return {
148
+ type: 'function',
149
+ function: {
150
+ name: mcpTool.name,
151
+ description: mcpTool.description || '',
152
+ parameters,
153
+ },
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Normalize JSON Schema types for provider compatibility.
159
+ *
160
+ * MCP/Python sometimes emits union types like `"type": ["array", "string"]`
161
+ * which aren't valid in Anthropic's tool schema. We pick the first type
162
+ * and note the alternative in the description.
163
+ *
164
+ * @param {Object} schema - JSON Schema object
165
+ * @returns {Object} Normalized schema (deep copy)
166
+ */
167
+ _normalizeSchema(schema) {
168
+ if (!schema || typeof schema !== 'object') return schema;
169
+
170
+ const normalized = Array.isArray(schema) ? [...schema] : { ...schema };
171
+
172
+ // Normalize union types at this level
173
+ if (Array.isArray(normalized.type)) {
174
+ const types = normalized.type.filter((t) => t !== 'null');
175
+ normalized.type = types[0] || 'string';
176
+ if (types.length > 1) {
177
+ const altTypes = types.slice(1).join(', ');
178
+ normalized.description = normalized.description
179
+ ? `${normalized.description} (also accepts: ${altTypes})`
180
+ : `Also accepts: ${altTypes}`;
181
+ }
182
+ }
183
+
184
+ // Recursively normalize nested properties
185
+ if (normalized.properties && typeof normalized.properties === 'object') {
186
+ const normalizedProps = {};
187
+ for (const [key, value] of Object.entries(normalized.properties)) {
188
+ normalizedProps[key] = this._normalizeSchema(value);
189
+ }
190
+ normalized.properties = normalizedProps;
191
+ }
192
+
193
+ // Normalize items schema (for arrays)
194
+ if (normalized.items && typeof normalized.items === 'object') {
195
+ normalized.items = this._normalizeSchema(normalized.items);
196
+ }
197
+
198
+ return normalized;
199
+ }
200
+ }
201
+
202
+ export { EXPOSED_TOOLS };