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.
- package/bin/index.js +67 -9
- package/gateway/src/bridge.js +53 -11
- package/gateway/src/config.js +7 -1
- package/gateway/src/personality.js +267 -0
- package/gateway/tests/bridge-model.test.js +98 -0
- package/gateway/tests/config.test.js +22 -0
- package/gateway/tests/personality.test.js +325 -0
- package/memory-daemon/claudia_memory/database.py +21 -0
- package/memory-daemon/claudia_memory/mcp/server.py +12 -0
- package/memory-daemon/claudia_memory/schema.sql +5 -1
- package/memory-daemon/claudia_memory/services/recall.py +6 -0
- package/memory-daemon/claudia_memory/services/remember.py +4 -0
- package/memory-daemon/tests/test_database.py +35 -0
- package/memory-daemon/tests/test_source_channel.py +68 -0
- package/package.json +2 -1
- package/relay/SETUP.md +165 -0
- package/relay/package-lock.json +128 -0
- package/relay/package.json +21 -0
- package/relay/scripts/install.ps1 +228 -0
- package/relay/scripts/install.sh +273 -0
- package/relay/src/chunker.js +64 -0
- package/relay/src/claude-runner.js +149 -0
- package/relay/src/config.js +113 -0
- package/relay/src/formatter.js +77 -0
- package/relay/src/index.js +106 -0
- package/relay/src/lock.js +78 -0
- package/relay/src/relay.js +112 -0
- package/relay/src/session.js +131 -0
- package/relay/src/telegram.js +345 -0
- package/relay/tests/chunker.test.js +83 -0
- package/relay/tests/config.test.js +59 -0
- package/relay/tests/formatter.test.js +75 -0
- package/relay/tests/session.test.js +128 -0
- package/relay/tests/telegram.test.js +40 -0
- package/template-v2/.claude/skills/README.md +2 -1
- 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/
|
|
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/
|
|
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
|
|
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) =>
|
|
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/
|
|
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}`);
|
package/gateway/src/bridge.js
CHANGED
|
@@ -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:
|
|
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
|
|
413
|
+
let prompt;
|
|
383
414
|
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
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
|
|
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
|
};
|
package/gateway/src/config.js
CHANGED
|
@@ -27,7 +27,11 @@ const DEFAULT_CONFIG = {
|
|
|
27
27
|
model: '', // Auto-detected from ~/.claudia/config.json language_model field
|
|
28
28
|
},
|
|
29
29
|
|
|
30
|
-
//
|
|
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
|
+
});
|