get-claudia 1.30.0 → 1.32.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.
Files changed (36) hide show
  1. package/bin/index.js +67 -9
  2. package/gateway/src/bridge.js +53 -11
  3. package/gateway/src/config.js +7 -1
  4. package/gateway/src/personality.js +267 -0
  5. package/gateway/tests/bridge-model.test.js +98 -0
  6. package/gateway/tests/config.test.js +22 -0
  7. package/gateway/tests/personality.test.js +325 -0
  8. package/memory-daemon/claudia_memory/database.py +21 -0
  9. package/memory-daemon/claudia_memory/mcp/server.py +12 -0
  10. package/memory-daemon/claudia_memory/schema.sql +5 -1
  11. package/memory-daemon/claudia_memory/services/recall.py +6 -0
  12. package/memory-daemon/claudia_memory/services/remember.py +4 -0
  13. package/memory-daemon/tests/test_database.py +35 -0
  14. package/memory-daemon/tests/test_source_channel.py +68 -0
  15. package/package.json +2 -1
  16. package/relay/SETUP.md +165 -0
  17. package/relay/package-lock.json +128 -0
  18. package/relay/package.json +21 -0
  19. package/relay/scripts/install.ps1 +228 -0
  20. package/relay/scripts/install.sh +273 -0
  21. package/relay/src/chunker.js +64 -0
  22. package/relay/src/claude-runner.js +149 -0
  23. package/relay/src/config.js +113 -0
  24. package/relay/src/formatter.js +77 -0
  25. package/relay/src/index.js +106 -0
  26. package/relay/src/lock.js +78 -0
  27. package/relay/src/relay.js +112 -0
  28. package/relay/src/session.js +131 -0
  29. package/relay/src/telegram.js +345 -0
  30. package/relay/tests/chunker.test.js +83 -0
  31. package/relay/tests/config.test.js +59 -0
  32. package/relay/tests/formatter.test.js +75 -0
  33. package/relay/tests/session.test.js +128 -0
  34. package/relay/tests/telegram.test.js +40 -0
  35. package/template-v2/.claude/skills/README.md +2 -1
  36. package/template-v2/.claude/skills/setup-telegram.md +151 -0
package/bin/index.js CHANGED
@@ -220,7 +220,7 @@ async function main() {
220
220
 
221
221
  // Helper: run visualizer install script and call back when done (auto-install, no prompt)
222
222
  function runVisualizerSetup(callback) {
223
- console.log(`\n${colors.boldYellow}━━━ Phase 2/3: Brain Visualizer ━━━${colors.reset}\n`);
223
+ console.log(`\n${colors.boldYellow}━━━ Phase 2/4: Brain Visualizer ━━━${colors.reset}\n`);
224
224
 
225
225
  const visualizerScriptPath = isWindows
226
226
  ? join(__dirname, '..', 'visualizer', 'scripts', 'install.ps1')
@@ -263,7 +263,7 @@ async function main() {
263
263
 
264
264
  // Helper: run gateway install script and call back when done
265
265
  function runGatewaySetup(callback) {
266
- console.log(`\n${colors.boldYellow}━━━ Phase 3/3: Messaging Gateway ━━━${colors.reset}\n`);
266
+ console.log(`\n${colors.boldYellow}━━━ Phase 3/4: Messaging Gateway ━━━${colors.reset}\n`);
267
267
 
268
268
  const gatewayScriptPath = isWindows
269
269
  ? join(__dirname, '..', 'gateway', 'scripts', 'install.ps1')
@@ -309,6 +309,54 @@ async function main() {
309
309
  }
310
310
  }
311
311
 
312
+ // Helper: run relay install script and call back when done
313
+ function runRelaySetup(callback) {
314
+ console.log(`\n${colors.boldYellow}━━━ Phase 4/4: Telegram Relay ━━━${colors.reset}\n`);
315
+
316
+ const relayScriptPath = isWindows
317
+ ? join(__dirname, '..', 'relay', 'scripts', 'install.ps1')
318
+ : join(__dirname, '..', 'relay', 'scripts', 'install.sh');
319
+
320
+ if (!existsSync(relayScriptPath)) {
321
+ console.log(`${colors.yellow}!${colors.reset} Relay files not found. Skipping.`);
322
+ callback(false);
323
+ return;
324
+ }
325
+
326
+ try {
327
+ const spawnCmd = isWindows ? powershellPath : 'bash';
328
+ const spawnArgs = isWindows
329
+ ? ['-ExecutionPolicy', 'Bypass', '-File', relayScriptPath]
330
+ : [relayScriptPath];
331
+ const relayResult = spawn(spawnCmd, spawnArgs, {
332
+ stdio: 'inherit',
333
+ env: {
334
+ ...process.env,
335
+ CLAUDIA_RELAY_UPGRADE: isUpgrade ? '1' : '0',
336
+ CLAUDIA_RELAY_SKIP_SETUP: '1'
337
+ }
338
+ });
339
+
340
+ relayResult.on('close', (code) => {
341
+ if (code === 0) {
342
+ console.log(`${colors.green}✓${colors.reset} Relay installed`);
343
+ callback(true);
344
+ } else {
345
+ console.log(`${colors.yellow}!${colors.reset} Relay setup had issues. You can run it later with:`);
346
+ if (isWindows) {
347
+ console.log(` ${colors.cyan}powershell.exe -ExecutionPolicy Bypass -File "${relayScriptPath}"${colors.reset}`);
348
+ } else {
349
+ console.log(` ${colors.cyan}bash ${relayScriptPath}${colors.reset}`);
350
+ }
351
+ callback(false);
352
+ }
353
+ });
354
+ } catch (error) {
355
+ console.log(`${colors.yellow}!${colors.reset} Could not set up relay: ${error.message}`);
356
+ callback(false);
357
+ }
358
+ }
359
+
312
360
  // Helper: run system health check after install
313
361
  function runSystemHealthCheck(callback) {
314
362
  const diagnoseScript = isWindows
@@ -343,20 +391,25 @@ async function main() {
343
391
  }
344
392
 
345
393
  // Helper: finish install after optional components
346
- function finishInstall(memoryInstalled, visualizerInstalled, gatewayInstalled) {
394
+ function finishInstall(memoryInstalled, visualizerInstalled, gatewayInstalled, relayInstalled) {
347
395
  if (memoryInstalled) {
348
396
  // Run health check when memory system was installed
349
397
  runSystemHealthCheck((healthy) => {
350
- showNextSteps(memoryInstalled, visualizerInstalled, gatewayInstalled, healthy);
398
+ showNextSteps(memoryInstalled, visualizerInstalled, gatewayInstalled, relayInstalled, healthy);
351
399
  });
352
400
  } else {
353
- showNextSteps(memoryInstalled, visualizerInstalled, gatewayInstalled, true);
401
+ showNextSteps(memoryInstalled, visualizerInstalled, gatewayInstalled, relayInstalled, true);
354
402
  }
355
403
  }
356
404
 
357
- // Helper: run gateway setup (auto-install like visualizer), then finish
405
+ // Helper: run relay setup after gateway, then finish
406
+ function maybeRunRelay(memoryInstalled, visualizerInstalled, gatewayInstalled) {
407
+ runRelaySetup((relayOk) => finishInstall(memoryInstalled, visualizerInstalled, gatewayInstalled, relayOk));
408
+ }
409
+
410
+ // Helper: run gateway setup (auto-install like visualizer), then chain to relay
358
411
  function maybeRunGateway(memoryInstalled, visualizerInstalled) {
359
- runGatewaySetup((gatewayOk) => finishInstall(memoryInstalled, visualizerInstalled, gatewayOk));
412
+ runGatewaySetup((gatewayOk) => maybeRunRelay(memoryInstalled, visualizerInstalled, gatewayOk));
360
413
  }
361
414
 
362
415
  // Helper: auto-install visualizer after memory (if memory was installed), then chain to gateway
@@ -371,7 +424,7 @@ async function main() {
371
424
  }
372
425
 
373
426
  // Memory system always installs (no prompt)
374
- console.log(`\n${colors.boldYellow}━━━ Phase 1/3: Memory System ━━━${colors.reset}\n`);
427
+ console.log(`\n${colors.boldYellow}━━━ Phase 1/4: Memory System ━━━${colors.reset}\n`);
375
428
 
376
429
  const memoryDaemonPath = isWindows
377
430
  ? join(__dirname, '..', 'memory-daemon', 'scripts', 'install.ps1')
@@ -453,7 +506,7 @@ async function main() {
453
506
  // Memory failed to spawn -- continue with visualizer/gateway
454
507
  maybeRunVisualizer(false);
455
508
 
456
- function showNextSteps(memoryInstalled, visualizerInstalled, gatewayInstalled, systemHealthy = true) {
509
+ function showNextSteps(memoryInstalled, visualizerInstalled, gatewayInstalled, relayInstalled, systemHealthy = true) {
457
510
  const cdStep = isCurrentDir ? '' : ` ${colors.cyan}cd ${targetDir}${colors.reset}\n`;
458
511
 
459
512
  // Installation summary
@@ -465,11 +518,16 @@ async function main() {
465
518
  console.log(`${memoryInstalled ? check : warn} Memory system ${memoryInstalled ? 'Active' : 'Skipped'}`);
466
519
  console.log(`${visualizerInstalled ? check : warn} Brain visualizer ${visualizerInstalled ? 'Active' : 'Skipped'}`);
467
520
  console.log(`${gatewayInstalled ? check : warn} Gateway ${gatewayInstalled ? 'Installed' : 'Skipped'}`);
521
+ console.log(`${relayInstalled ? check : warn} Telegram relay ${relayInstalled ? 'Installed' : 'Skipped'}`);
468
522
 
469
523
  if (gatewayInstalled) {
470
524
  console.log(`${colors.yellow}->${colors.reset} Configure tokens: ~/.claudia/gateway.json`);
471
525
  }
472
526
 
527
+ if (relayInstalled) {
528
+ console.log(`${colors.yellow}->${colors.reset} Configure relay: run /setup-telegram inside Claude`);
529
+ }
530
+
473
531
  if (!systemHealthy) {
474
532
  console.log(`\n${colors.yellow}Some issues were detected above.${colors.reset}`);
475
533
  console.log(`${colors.dim}You can fix them now, or Claudia will work in fallback mode until they're resolved.${colors.reset}`);
@@ -17,6 +17,7 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
17
17
  import { existsSync } from 'fs';
18
18
  import { readFileSync } from 'fs';
19
19
  import { createLogger } from './utils/logger.js';
20
+ import { loadPersonality } from './personality.js';
20
21
 
21
22
  const log = createLogger('bridge');
22
23
 
@@ -43,6 +44,7 @@ export class Bridge {
43
44
  this.memoryAvailable = false;
44
45
  this.extractor = null; // Set by gateway after construction
45
46
  this._consecutiveFailures = 0;
47
+ this._personality = null; // Loaded Claudia personality prompt
46
48
  }
47
49
 
48
50
  async start() {
@@ -90,6 +92,9 @@ export class Bridge {
90
92
  }
91
93
  }
92
94
 
95
+ // Load Claudia personality
96
+ this._personality = loadPersonality(this.config);
97
+
93
98
  // Try to connect to memory daemon via MCP
94
99
  await this._connectMemory();
95
100
  }
@@ -151,18 +156,20 @@ export class Bridge {
151
156
  messages.push({ role: 'user', content: text });
152
157
 
153
158
  // 4. Call LLM (Anthropic or Ollama)
159
+ const resolvedModel = this._resolveModel(channel);
154
160
  log.info('Calling LLM', {
155
161
  provider: this.provider,
156
- model: this.provider === 'anthropic' ? this.config.model : this.config.ollama.model,
162
+ model: resolvedModel,
163
+ channel,
157
164
  messageCount: messages.length,
158
165
  });
159
166
 
160
167
  let result;
161
168
  try {
162
169
  if (this.provider === 'anthropic') {
163
- result = await this._callAnthropic(systemPrompt, messages);
170
+ result = await this._callAnthropic(systemPrompt, messages, resolvedModel);
164
171
  } else {
165
- result = await this._callOllama(systemPrompt, messages);
172
+ result = await this._callOllama(systemPrompt, messages, resolvedModel);
166
173
  }
167
174
  } catch (err) {
168
175
  log.error('LLM call failed', { provider: this.provider, error: err.message });
@@ -375,19 +382,48 @@ export class Bridge {
375
382
  }
376
383
  }
377
384
 
385
+ /**
386
+ * Resolve which model to use for a given channel.
387
+ * Per-channel model overrides the global default.
388
+ *
389
+ * @param {string} channel - Channel name (e.g. 'telegram', 'slack')
390
+ * @returns {string} Resolved model identifier
391
+ */
392
+ _resolveModel(channel) {
393
+ // Check per-channel model override
394
+ const channelModel = this.config.channels?.[channel]?.model;
395
+ if (channelModel) return channelModel;
396
+
397
+ // Fall back to global model for the active provider
398
+ if (this.provider === 'anthropic') {
399
+ return this.config.model;
400
+ }
401
+ return this.config.ollama?.model || '';
402
+ }
403
+
378
404
  /**
379
405
  * Build the system prompt with memory context.
406
+ *
407
+ * Resolution chain for personality:
408
+ * 1. Loaded personality from template dir (richest)
409
+ * 2. Custom systemPromptPath file (backward compat)
410
+ * 3. DEFAULT_SYSTEM_PROMPT fallback
380
411
  */
381
412
  _buildSystemPrompt(memoryContext, userName, channel) {
382
- let prompt = DEFAULT_SYSTEM_PROMPT;
413
+ let prompt;
383
414
 
384
- // Load custom system prompt if configured
385
- if (this.config.systemPromptPath && existsSync(this.config.systemPromptPath)) {
415
+ if (this._personality) {
416
+ // Full Claudia personality loaded from template files
417
+ prompt = this._personality;
418
+ } else if (this.config.systemPromptPath && existsSync(this.config.systemPromptPath)) {
419
+ // Legacy: custom system prompt file
386
420
  try {
387
421
  prompt = readFileSync(this.config.systemPromptPath, 'utf8');
388
422
  } catch {
389
- // Fall back to default
423
+ prompt = DEFAULT_SYSTEM_PROMPT;
390
424
  }
425
+ } else {
426
+ prompt = DEFAULT_SYSTEM_PROMPT;
391
427
  }
392
428
 
393
429
  prompt += `\n\nChannel: ${channel}`;
@@ -404,11 +440,14 @@ export class Bridge {
404
440
 
405
441
  /**
406
442
  * Call Anthropic API.
443
+ * @param {string} systemPrompt
444
+ * @param {Object[]} messages
445
+ * @param {string} model - Resolved model to use
407
446
  * @returns {{ text: string, usage: Object }}
408
447
  */
409
- async _callAnthropic(systemPrompt, messages) {
448
+ async _callAnthropic(systemPrompt, messages, model) {
410
449
  const response = await this.anthropic.messages.create({
411
- model: this.config.model,
450
+ model,
412
451
  max_tokens: this.config.maxTokens || 2048,
413
452
  system: systemPrompt,
414
453
  messages,
@@ -425,11 +464,13 @@ export class Bridge {
425
464
  /**
426
465
  * Call Ollama /api/chat endpoint.
427
466
  * Retries up to 2 times with 2s delay (matching memory daemon pattern).
467
+ * @param {string} systemPrompt
468
+ * @param {Object[]} messages
469
+ * @param {string} model - Resolved model to use
428
470
  * @returns {{ text: string, usage: null }}
429
471
  */
430
- async _callOllama(systemPrompt, messages) {
472
+ async _callOllama(systemPrompt, messages, model) {
431
473
  const host = this.config.ollama?.host || 'http://localhost:11434';
432
- const model = this.config.ollama?.model;
433
474
 
434
475
  // Build Ollama message array: system prompt + conversation turns
435
476
  const ollamaMessages = [{ role: 'system', content: systemPrompt }];
@@ -531,6 +572,7 @@ export class Bridge {
531
572
  provider: this.provider,
532
573
  providerReady: this.provider === 'anthropic' ? !!this.anthropic : this.provider === 'ollama',
533
574
  memoryAvailable: this.memoryAvailable,
575
+ personalityLoaded: !!this._personality,
534
576
  model:
535
577
  this.provider === 'ollama' ? this.config.ollama?.model : this.config.model,
536
578
  };
@@ -27,7 +27,11 @@ const DEFAULT_CONFIG = {
27
27
  model: '', // Auto-detected from ~/.claudia/config.json language_model field
28
28
  },
29
29
 
30
- // System prompt context
30
+ // Personality
31
+ personalityDir: '', // Path to Claudia template dir (contains CLAUDE.md + .claude/rules/)
32
+ personalityMaxChars: 15000, // Safety limit for prompt size
33
+
34
+ // System prompt context (legacy, overridden by personalityDir)
31
35
  systemPromptPath: '', // Optional path to custom system prompt
32
36
 
33
37
  // Memory daemon connection
@@ -47,6 +51,7 @@ const DEFAULT_CONFIG = {
47
51
  enabled: false,
48
52
  token: '',
49
53
  allowedUsers: [],
54
+ model: '', // Per-channel model override (empty = use global)
50
55
  },
51
56
  slack: {
52
57
  enabled: false,
@@ -54,6 +59,7 @@ const DEFAULT_CONFIG = {
54
59
  appToken: '',
55
60
  signingSecret: '',
56
61
  allowedUsers: [],
62
+ model: '', // Per-channel model override (empty = use global)
57
63
  },
58
64
  },
59
65
 
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Claudia personality loader for the gateway.
3
+ *
4
+ * Extracts gateway-relevant sections from the Claudia template files
5
+ * (CLAUDE.md and claudia-principles.md) to build a rich system prompt
6
+ * that gives Telegram/Slack Claudia her real personality.
7
+ *
8
+ * Resolution chain:
9
+ * 1. config.personalityDir -> load from explicit path
10
+ * 2. Auto-detect ../template-v2/ relative to gateway (dev mode)
11
+ * 3. config.systemPromptPath -> single file (backward compat)
12
+ * 4. DEFAULT_SYSTEM_PROMPT fallback
13
+ */
14
+
15
+ import { existsSync, readFileSync } from 'fs';
16
+ import { join, dirname } from 'path';
17
+ import { fileURLToPath } from 'url';
18
+ import { createLogger } from './utils/logger.js';
19
+
20
+ const log = createLogger('personality');
21
+
22
+ const __dirname = dirname(fileURLToPath(import.meta.url));
23
+
24
+ const GATEWAY_PREAMBLE = `You are Claudia, responding via a messaging app. Adapt your style:
25
+ - Keep responses concise and conversational. This is chat, not a document.
26
+ - Use plain text formatting suitable for chat.
27
+ - Short replies when appropriate, detailed when needed.`;
28
+
29
+ /**
30
+ * Sections to extract from template-v2/CLAUDE.md.
31
+ * These define Claudia's personality. Developer/Claude-Code-specific sections are excluded.
32
+ */
33
+ const CLAUDE_MD_SECTIONS = [
34
+ 'Who I Am',
35
+ 'Primary Mission: Higher-Level Thinking',
36
+ 'How I Carry Myself',
37
+ 'Core Behaviors',
38
+ 'What I Don\'t Do',
39
+ 'What Stays Human Judgment',
40
+ ];
41
+
42
+ /**
43
+ * Principles to include from claudia-principles.md (by number prefix).
44
+ * Excludes 11 (Output Formatting), 12 (Source Preservation), 13 (Multi-Source).
45
+ */
46
+ const INCLUDED_PRINCIPLES = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
47
+
48
+ // Cached result
49
+ let _cachedPersonality = null;
50
+ let _cachedDir = null;
51
+
52
+ /**
53
+ * Extract a markdown section by heading text.
54
+ * Returns everything from the heading line to the next heading of equal or higher level,
55
+ * or to end-of-file.
56
+ *
57
+ * @param {string} markdown - Full markdown text
58
+ * @param {string} heading - Heading text to find (without ## prefix)
59
+ * @returns {string|null} Section content including the heading, or null if not found
60
+ */
61
+ export function extractSection(markdown, heading) {
62
+ const lines = markdown.split('\n');
63
+ let startIdx = -1;
64
+ let startLevel = 0;
65
+
66
+ // Find the heading line
67
+ for (let i = 0; i < lines.length; i++) {
68
+ const match = lines[i].match(/^(#{1,6})\s+(.+)/);
69
+ if (match && match[2].trim() === heading) {
70
+ startIdx = i;
71
+ startLevel = match[1].length;
72
+ break;
73
+ }
74
+ }
75
+
76
+ if (startIdx === -1) return null;
77
+
78
+ // Find the end: next heading of same or higher level
79
+ let endIdx = lines.length;
80
+ for (let i = startIdx + 1; i < lines.length; i++) {
81
+ const match = lines[i].match(/^(#{1,6})\s+/);
82
+ if (match && match[1].length <= startLevel) {
83
+ endIdx = i;
84
+ break;
85
+ }
86
+ }
87
+
88
+ return lines.slice(startIdx, endIdx).join('\n').trim();
89
+ }
90
+
91
+ /**
92
+ * Extract numbered principles from claudia-principles.md.
93
+ *
94
+ * @param {string} markdown - Full principles markdown
95
+ * @param {number[]} numbers - Principle numbers to include
96
+ * @returns {string} Extracted principles joined together
97
+ */
98
+ export function extractPrinciples(markdown, numbers) {
99
+ const sections = [];
100
+
101
+ for (const num of numbers) {
102
+ // Match "## N." or "## N. Title"
103
+ const pattern = new RegExp(`^## ${num}\\.\\s`, 'm');
104
+ const match = markdown.match(pattern);
105
+ if (!match) continue;
106
+
107
+ const startPos = match.index;
108
+ const afterStart = markdown.indexOf('\n', startPos);
109
+ if (afterStart === -1) continue;
110
+
111
+ // Find the heading line to get its text
112
+ const headingLine = markdown.slice(startPos, afterStart);
113
+
114
+ // Find the next ## heading
115
+ const rest = markdown.slice(afterStart + 1);
116
+ const nextHeading = rest.match(/^## \d+\./m);
117
+ const nextSeparator = rest.match(/^---$/m);
118
+
119
+ let endPos;
120
+ if (nextHeading && nextSeparator) {
121
+ endPos = afterStart + 1 + Math.min(nextHeading.index, nextSeparator.index);
122
+ } else if (nextHeading) {
123
+ endPos = afterStart + 1 + nextHeading.index;
124
+ } else if (nextSeparator) {
125
+ endPos = afterStart + 1 + nextSeparator.index;
126
+ } else {
127
+ endPos = markdown.length;
128
+ }
129
+
130
+ sections.push(markdown.slice(startPos, endPos).trim());
131
+ }
132
+
133
+ return sections.join('\n\n');
134
+ }
135
+
136
+ /**
137
+ * Build the personality prompt from template directory.
138
+ *
139
+ * @param {string} templateDir - Path to template-v2/ directory
140
+ * @param {number} maxChars - Maximum character limit for the prompt
141
+ * @returns {string|null} Assembled personality prompt, or null if files not found
142
+ */
143
+ export function buildPersonalityFromDir(templateDir, maxChars = 15000) {
144
+ const claudeMdPath = join(templateDir, 'CLAUDE.md');
145
+ const principlesPath = join(templateDir, '.claude', 'rules', 'claudia-principles.md');
146
+
147
+ if (!existsSync(claudeMdPath)) {
148
+ log.debug('CLAUDE.md not found', { path: claudeMdPath });
149
+ return null;
150
+ }
151
+
152
+ const parts = [GATEWAY_PREAMBLE, ''];
153
+
154
+ // Extract CLAUDE.md sections
155
+ try {
156
+ const claudeMd = readFileSync(claudeMdPath, 'utf8');
157
+ for (const heading of CLAUDE_MD_SECTIONS) {
158
+ const section = extractSection(claudeMd, heading);
159
+ if (section) {
160
+ parts.push(section);
161
+ parts.push('');
162
+ }
163
+ }
164
+ } catch (err) {
165
+ log.warn('Failed to read CLAUDE.md', { error: err.message });
166
+ return null;
167
+ }
168
+
169
+ // Extract principles
170
+ if (existsSync(principlesPath)) {
171
+ try {
172
+ const principlesMd = readFileSync(principlesPath, 'utf8');
173
+ const extracted = extractPrinciples(principlesMd, INCLUDED_PRINCIPLES);
174
+ if (extracted) {
175
+ parts.push('# Core Principles\n');
176
+ parts.push(extracted);
177
+ }
178
+ } catch (err) {
179
+ log.debug('Failed to read principles', { error: err.message });
180
+ }
181
+ }
182
+
183
+ let result = parts.join('\n');
184
+
185
+ // Enforce size limit
186
+ if (result.length > maxChars) {
187
+ log.info('Personality prompt truncated', { original: result.length, limit: maxChars });
188
+ result = result.slice(0, maxChars);
189
+ // Cut at last complete line
190
+ const lastNewline = result.lastIndexOf('\n');
191
+ if (lastNewline > maxChars * 0.8) {
192
+ result = result.slice(0, lastNewline);
193
+ }
194
+ result += '\n\n[Personality truncated for size]';
195
+ }
196
+
197
+ return result;
198
+ }
199
+
200
+ /**
201
+ * Resolve the template directory using the config resolution chain.
202
+ *
203
+ * @param {Object} config - Gateway config
204
+ * @returns {string|null} Resolved template directory path, or null
205
+ */
206
+ export function resolveTemplateDir(config) {
207
+ // 1. Explicit personalityDir
208
+ if (config.personalityDir) {
209
+ if (existsSync(join(config.personalityDir, 'CLAUDE.md'))) {
210
+ return config.personalityDir;
211
+ }
212
+ log.warn('personalityDir set but CLAUDE.md not found', { dir: config.personalityDir });
213
+ }
214
+
215
+ // 2. Auto-detect relative to gateway (dev mode): gateway/../template-v2/
216
+ const devPath = join(__dirname, '..', '..', 'template-v2');
217
+ if (existsSync(join(devPath, 'CLAUDE.md'))) {
218
+ return devPath;
219
+ }
220
+
221
+ return null;
222
+ }
223
+
224
+ /**
225
+ * Load the Claudia personality for the gateway.
226
+ * Uses caching to avoid re-reading files on every message.
227
+ *
228
+ * @param {Object} config - Gateway config
229
+ * @returns {string|null} Personality prompt, or null (use fallback)
230
+ */
231
+ export function loadPersonality(config) {
232
+ const templateDir = resolveTemplateDir(config);
233
+
234
+ // Cache hit: same directory, return cached
235
+ if (_cachedPersonality && _cachedDir === templateDir) {
236
+ return _cachedPersonality;
237
+ }
238
+
239
+ if (!templateDir) {
240
+ _cachedPersonality = null;
241
+ _cachedDir = null;
242
+ return null;
243
+ }
244
+
245
+ const personality = buildPersonalityFromDir(templateDir, config.personalityMaxChars || 15000);
246
+
247
+ if (personality) {
248
+ _cachedPersonality = personality;
249
+ _cachedDir = templateDir;
250
+ const sectionCount = CLAUDE_MD_SECTIONS.length;
251
+ log.info('Loaded Claudia personality', {
252
+ dir: templateDir,
253
+ chars: personality.length,
254
+ sections: sectionCount,
255
+ });
256
+ }
257
+
258
+ return personality;
259
+ }
260
+
261
+ /**
262
+ * Clear the personality cache. Useful for testing.
263
+ */
264
+ export function clearCache() {
265
+ _cachedPersonality = null;
266
+ _cachedDir = null;
267
+ }
@@ -0,0 +1,98 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { Bridge } from '../src/bridge.js';
4
+
5
+ describe('Bridge._resolveModel', () => {
6
+ it('uses per-channel model when set', () => {
7
+ const bridge = new Bridge({
8
+ model: 'claude-sonnet-4-20250514',
9
+ channels: {
10
+ telegram: { model: 'claude-haiku-4-5-20251001' },
11
+ slack: { model: '' },
12
+ },
13
+ });
14
+ bridge.provider = 'anthropic';
15
+
16
+ assert.equal(bridge._resolveModel('telegram'), 'claude-haiku-4-5-20251001');
17
+ });
18
+
19
+ it('falls back to global Anthropic model for channel with empty model', () => {
20
+ const bridge = new Bridge({
21
+ model: 'claude-sonnet-4-20250514',
22
+ channels: {
23
+ telegram: { model: '' },
24
+ },
25
+ });
26
+ bridge.provider = 'anthropic';
27
+
28
+ assert.equal(bridge._resolveModel('telegram'), 'claude-sonnet-4-20250514');
29
+ });
30
+
31
+ it('falls back to global model for unknown channel', () => {
32
+ const bridge = new Bridge({
33
+ model: 'claude-sonnet-4-20250514',
34
+ channels: {
35
+ telegram: { model: 'claude-haiku-4-5-20251001' },
36
+ },
37
+ });
38
+ bridge.provider = 'anthropic';
39
+
40
+ assert.equal(bridge._resolveModel('discord'), 'claude-sonnet-4-20250514');
41
+ });
42
+
43
+ it('resolves Ollama model with per-channel override', () => {
44
+ const bridge = new Bridge({
45
+ model: 'claude-sonnet-4-20250514',
46
+ ollama: { model: 'qwen3:4b' },
47
+ channels: {
48
+ telegram: { model: 'llama3:8b' },
49
+ },
50
+ });
51
+ bridge.provider = 'ollama';
52
+
53
+ assert.equal(bridge._resolveModel('telegram'), 'llama3:8b');
54
+ });
55
+
56
+ it('falls back to Ollama global model when channel has no override', () => {
57
+ const bridge = new Bridge({
58
+ model: 'claude-sonnet-4-20250514',
59
+ ollama: { model: 'qwen3:4b' },
60
+ channels: {
61
+ telegram: { model: '' },
62
+ },
63
+ });
64
+ bridge.provider = 'ollama';
65
+
66
+ assert.equal(bridge._resolveModel('telegram'), 'qwen3:4b');
67
+ });
68
+ });
69
+
70
+ describe('Bridge._buildSystemPrompt', () => {
71
+ it('uses loaded personality when available', () => {
72
+ const bridge = new Bridge({ channels: {} });
73
+ bridge._personality = 'You are Claudia, the real deal.';
74
+
75
+ const prompt = bridge._buildSystemPrompt('', 'Kamil', 'telegram');
76
+ assert.ok(prompt.startsWith('You are Claudia, the real deal.'));
77
+ assert.ok(prompt.includes('Channel: telegram'));
78
+ assert.ok(prompt.includes('User: Kamil'));
79
+ });
80
+
81
+ it('falls back to DEFAULT_SYSTEM_PROMPT when no personality', () => {
82
+ const bridge = new Bridge({ channels: {} });
83
+ bridge._personality = null;
84
+
85
+ const prompt = bridge._buildSystemPrompt('', 'Kamil', 'telegram');
86
+ assert.ok(prompt.includes('warm, sharp, and proactive'));
87
+ assert.ok(prompt.includes('Channel: telegram'));
88
+ });
89
+
90
+ it('includes memory context when provided', () => {
91
+ const bridge = new Bridge({ channels: {} });
92
+ bridge._personality = 'Claudia personality';
93
+
94
+ const prompt = bridge._buildSystemPrompt('## Relevant Memories\n- User likes coffee', 'Kamil', 'telegram');
95
+ assert.ok(prompt.includes('# Memory Context'));
96
+ assert.ok(prompt.includes('User likes coffee'));
97
+ });
98
+ });