obol-ai 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
1
+ ## 0.3.3
2
+ - update changelog
3
+ - improve memory retrieval quality: tighter dedup, wider window, recency boost, self-memory, jaccard dedup
4
+ - remove trait system entirely
5
+
1
6
  ## 0.3.2
2
7
  - update changelog
3
8
  - add list_pending_events tool to dispatch and humor passes to prevent duplicates
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "obol-ai",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Self-evolving AI assistant that learns, remembers, and acts on its own. Persistent vector memory, self-rewriting personality, proactive heartbeats.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -83,6 +83,7 @@ function createClaude(anthropicConfig, { personality, memory, selfMemory, userDi
83
83
  onRouteDecision: context._onRouteDecision,
84
84
  onRouteUpdate: context._onRouteUpdate,
85
85
  recentHistory: history,
86
+ selfMemory,
86
87
  });
87
88
  memoryBlock = result.memoryBlock;
88
89
  if (result.model) context._model = result.model;
@@ -174,11 +175,14 @@ function createClaude(anthropicConfig, { personality, memory, selfMemory, userDi
174
175
  cachedTools[lastIdx] = { ...lastDef, cache_control: { type: 'ephemeral' }, run };
175
176
  }
176
177
 
178
+ const assembledMessages = withCacheBreakpoints(withRuntimeContext([...history]));
179
+ context._onPromptReady?.({ system: systemPrompt, messages: assembledMessages, model: activeModel, tools: cachedTools });
180
+
177
181
  const runner = client.beta.messages.toolRunner({
178
182
  model: activeModel,
179
183
  max_tokens: 128000,
180
184
  system: systemPrompt,
181
- messages: withCacheBreakpoints(withRuntimeContext([...history])),
185
+ messages: assembledMessages,
182
186
  tools: cachedTools ?? undefined,
183
187
  max_iterations: getMaxToolIterations(),
184
188
  stream: true,
@@ -263,8 +267,9 @@ function createClaude(anthropicConfig, { personality, memory, selfMemory, userDi
263
267
  }
264
268
 
265
269
  function reloadPersonality() {
270
+ const { PERSONALITY_DIR } = require('../soul');
266
271
  const pDir = userDir ? path.join(userDir, 'personality') : undefined;
267
- const newPersonality = require('../personality').loadPersonality(pDir);
272
+ const newPersonality = require('../personality').loadPersonality(PERSONALITY_DIR, pDir);
268
273
  for (const key of Object.keys(personality)) delete personality[key];
269
274
  Object.assign(personality, newPersonality);
270
275
  baseSystemPrompt = buildSystemPrompt(personality, userDir, { bridgeEnabled, botName });
@@ -15,25 +15,6 @@ You serve multiple people — partners, friends, people who know each other. You
15
15
  parts.push(`\n## Personality\nYou are a fresh instance. Be helpful, direct, and naturally curious. Pay attention to how your owner communicates and adapt. Your personality will develop through conversation and periodic evolution.`);
16
16
  }
17
17
 
18
- if (personality.traits) {
19
- const t = personality.traits;
20
- const descriptions = {
21
- humor: [0, 'suppress all wit', 50, 'balanced wit', 100, 'lean heavily into jokes and playfulness'],
22
- honesty: [0, 'maximize diplomatic softening', 50, 'balanced honesty', 100, 'lean toward blunt truth'],
23
- directness: [0, 'elaborate context and preamble', 50, 'balanced', 100, 'get straight to the point'],
24
- curiosity: [0, 'only answer what is asked', 50, 'balanced', 100, 'proactively explore and ask follow-ups'],
25
- empathy: [0, 'purely task-focused', 50, 'balanced', 100, 'deeply emotionally attuned'],
26
- creativity: [0, 'stick to proven patterns', 50, 'balanced', 100, 'favor novel approaches'],
27
- };
28
- const lines = Object.entries(t).map(([trait, val]) => {
29
- const desc = descriptions[trait];
30
- if (!desc) return null;
31
- const label = val <= 30 ? desc[1] : val <= 70 ? desc[3] : desc[5];
32
- return `- ${trait.charAt(0).toUpperCase() + trait.slice(1)}: ${val} — ${label}`;
33
- }).filter(Boolean);
34
- parts.push(`\n## Personality Calibration\n\nThese values (0-100) define your behavioral tendencies:\n${lines.join('\n')}\n\nInterpret these as a spectrum: 0 = suppress entirely, 50 = balanced, 100 = lean heavily into it.`);
35
- }
36
-
37
18
  if (personality.user) {
38
19
  parts.push(`\n## About This User\n${personality.user}`);
39
20
  } else {
@@ -1,22 +1,34 @@
1
- async function routeMessage(client, memory, userMessage, { vlog, onRouteDecision, onRouteUpdate, recentHistory = [] }) {
1
+ function buildRouterMessages(recentHistory, userMessage) {
2
+ const context = recentHistory.slice(-20).map(m => ({
3
+ role: m.role,
4
+ content: typeof m.content === 'string'
5
+ ? m.content.substring(0, 500)
6
+ : m.content.filter(b => b.type === 'text').map(b => b.text).join('').substring(0, 500),
7
+ })).filter(m => m.content);
8
+
9
+ const firstUserIdx = context.findIndex(m => m.role === 'user');
10
+ const trimmed = firstUserIdx > 0 ? context.slice(firstUserIdx) : context;
11
+
12
+ return [...trimmed, { role: 'user', content: userMessage }];
13
+ }
14
+
15
+ function jaccardSim(a, b) {
16
+ const words = s => new Set(s.toLowerCase().split(/\W+/).filter(Boolean));
17
+ const setA = words(a), setB = words(b);
18
+ let inter = 0;
19
+ for (const w of setA) if (setB.has(w)) inter++;
20
+ return inter / (setA.size + setB.size - inter);
21
+ }
22
+
23
+ async function routeMessage(client, memory, userMessage, { vlog, onRouteDecision, onRouteUpdate, recentHistory = [], selfMemory = null }) {
2
24
  let memoryBlock = null;
3
25
  let model = null;
4
26
 
5
27
  try {
6
- const lastAssistantMsgs = recentHistory
7
- .filter(m => m.role === 'assistant')
8
- .slice(-3)
9
- .map(m => typeof m.content === 'string' ? m.content : m.content.filter(b => b.type === 'text').map(b => b.text).join(''))
10
- .filter(Boolean);
11
-
12
- const contextNote = lastAssistantMsgs.length > 0
13
- ? `\n\nRecent assistant context (last ${lastAssistantMsgs.length} turns):\n${lastAssistantMsgs.map((t, i) => `[${i + 1}] ${t.substring(0, 300)}`).join('\n')}`
14
- : '';
15
-
16
28
  const routerDecision = await client.messages.create({
17
29
  model: 'claude-haiku-4-5',
18
30
  max_tokens: 200,
19
- system: `You are a router. Analyze this user message and decide:
31
+ system: `You are a router. Analyze the conversation and decide:
20
32
 
21
33
  1. Does it need memory context? (past conversations, facts, preferences, people, events)
22
34
  2. What model complexity does it need?
@@ -24,14 +36,14 @@ async function routeMessage(client, memory, userMessage, { vlog, onRouteDecision
24
36
  Reply with ONLY a JSON object:
25
37
  {"need_memory": true/false, "search_queries": ["query1", "query2"], "model": "haiku|sonnet|opus"}
26
38
 
27
- search_queries: 1-3 optimized search queries covering different topics in the message. One query per distinct topic/entity. Single-topic messages need just one query.
39
+ search_queries: 1-5 optimized search queries based on the full conversation context. Cover distinct topics, people, entities, time periods, or projects referenced. Single-topic messages need just one query. Use more queries when the message references multiple people, projects, or threads.
28
40
 
29
41
  Memory: casual messages (greetings, jokes, simple questions) → false. References to past, people, projects, preferences → true.
30
42
 
31
43
  Model: Default to "sonnet". Use "haiku" for: greetings, brief acknowledgments (thanks/ok/bye), casual chitchat, quick yes/no questions, and short single-turn exchanges that don't need any tool calling. Use "sonnet" for: code generation, data analysis, content creation, explanations, creative writing, agentic tool use, general questions, opinions, advice, and most conversational exchanges with substance. Use "opus" for: professional software engineering tasks, advanced multi-step agent work, complex reasoning, scientific or mathematical problems, tasks requiring nuanced understanding, advanced coding challenges, in-depth research, and architecture or design decisions.
32
44
 
33
- If recent context shows an ongoing task (sonnet/opus was just used, multi-step work in progress), bias toward that model even for short follow-up messages.${contextNote}`,
34
- messages: [{ role: 'user', content: userMessage }],
45
+ If recent context shows an ongoing task (sonnet/opus was just used, multi-step work in progress), bias toward that model even for short follow-up messages.`,
46
+ messages: buildRouterMessages(recentHistory, userMessage),
35
47
  });
36
48
 
37
49
  const decisionText = routerDecision.content[0]?.text || '';
@@ -61,12 +73,13 @@ If recent context shows an ongoing task (sonnet/opus was just used, multi-step w
61
73
 
62
74
  if (decision.need_memory && memory) {
63
75
  const budget = decision.model === 'opus' ? 40 : decision.model === 'haiku' ? 15 : 25;
76
+ const poolPerQuery = decision.model === 'opus' ? 20 : decision.model === 'haiku' ? 10 : 15;
64
77
  const searchQueries = queries.length > 0 ? queries : [userMessage];
65
78
 
66
- const recentMemories = await memory.byDate('2d', { limit: Math.ceil(budget / 3) });
79
+ const recentMemories = await memory.byDate('7d', { limit: Math.ceil(budget / 3) });
67
80
 
68
81
  const semanticResults = await Promise.all(
69
- searchQueries.map(q => memory.search(q, { limit: Math.ceil(budget / searchQueries.length), threshold: 0.4 }))
82
+ searchQueries.map(q => memory.search(q, { limit: poolPerQuery, threshold: 0.4 }))
70
83
  );
71
84
  const semanticMemories = semanticResults.flat();
72
85
 
@@ -75,14 +88,21 @@ If recent context shows an ongoing task (sonnet/opus was just used, multi-step w
75
88
  for (const m of [...recentMemories, ...semanticMemories]) {
76
89
  if (!seen.has(m.id)) {
77
90
  seen.add(m.id);
78
- const recencyBonus = m.created_at ? Math.max(0, 1 - (Date.now() - new Date(m.created_at).getTime()) / (7 * 86400000)) * 0.15 : 0;
79
- m._score = (m.similarity || 0.5) * 0.6 + (m.importance || 0.5) * 0.25 + recencyBonus;
91
+ const ageDays = m.created_at ? (Date.now() - new Date(m.created_at).getTime()) / 86400000 : 7;
92
+ const recencyBonus = Math.max(0, 1 - ageDays / 7) * 0.3;
93
+ m._score = (m.similarity || 0.5) * 0.5 + (m.importance || 0.5) * 0.2 + recencyBonus;
80
94
  combined.push(m);
81
95
  }
82
96
  }
83
97
 
84
98
  combined.sort((a, b) => b._score - a._score);
85
- const topFacts = combined.slice(0, budget);
99
+
100
+ const topFacts = [];
101
+ for (const m of combined) {
102
+ if (topFacts.length >= budget) break;
103
+ const isDup = topFacts.some(kept => jaccardSim(kept.content, m.content) > 0.7);
104
+ if (!isDup) topFacts.push(m);
105
+ }
86
106
 
87
107
  vlog(`[memory] ${topFacts.length} facts (${recentMemories.length} recent, ${semanticMemories.length} semantic, budget=${budget})`);
88
108
  onRouteUpdate?.({ memoryCount: topFacts.length });
@@ -94,6 +114,22 @@ If recent context shows an ongoing task (sonnet/opus was just used, multi-step w
94
114
  });
95
115
  memoryBlock = `## Relevant memories\n${lines.join('\n')}`;
96
116
  }
117
+
118
+ if (selfMemory) {
119
+ const selfResults = await Promise.all(
120
+ searchQueries.map(q => selfMemory.search(q, { limit: 5, threshold: 0.4 }))
121
+ );
122
+ const seen2 = new Set();
123
+ const topSelf = [];
124
+ for (const m of selfResults.flat()) {
125
+ if (!seen2.has(m.id)) { seen2.add(m.id); topSelf.push(m); }
126
+ }
127
+ if (topSelf.length > 0) {
128
+ const selfLines = topSelf.slice(0, 8).map(m => `- [${m.category}] ${m.content}`);
129
+ memoryBlock = (memoryBlock || '') + `\n\n## Self-knowledge\n${selfLines.join('\n')}`;
130
+ vlog(`[memory] +${topSelf.length} self-memory facts`);
131
+ }
132
+ }
97
133
  }
98
134
  } catch (e) {
99
135
  console.error('[router] Memory/routing decision failed:', e.message);
@@ -96,7 +96,10 @@ const definitions = [
96
96
  const handlers = {
97
97
  async read_file(input, memory, context) {
98
98
  const { userDir } = context;
99
- const filePath = userDir ? resolveUserPath(input.path, userDir) : input.path;
99
+ const { PERSONALITY_DIR } = require('../../soul');
100
+ const filePath = path.basename(input.path) === 'SOUL.md'
101
+ ? path.join(PERSONALITY_DIR, 'SOUL.md')
102
+ : (userDir ? resolveUserPath(input.path, userDir) : input.path);
100
103
  if (filePath.toLowerCase().endsWith('.pdf')) {
101
104
  const pdfParse = require('pdf-parse');
102
105
  const { text } = await pdfParse(fs.readFileSync(filePath));
@@ -119,25 +122,36 @@ const handlers = {
119
122
 
120
123
  async write_file(input, memory, context) {
121
124
  const { userDir } = context;
122
- const filePath = userDir ? resolveUserPath(input.path, userDir) : input.path;
125
+ const { PERSONALITY_DIR, backup } = require('../../soul');
126
+ const isSoul = path.basename(input.path) === 'SOUL.md';
127
+ const filePath = isSoul ? path.join(PERSONALITY_DIR, 'SOUL.md') : (userDir ? resolveUserPath(input.path, userDir) : input.path);
123
128
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
124
129
  fs.writeFileSync(filePath, input.content);
125
- if (path.basename(filePath) === 'traits.json' || filePath.includes('personality/')) {
130
+ if (filePath.includes('personality/')) {
126
131
  context._reloadPersonality?.();
132
+ if (isSoul && context.config?.supabase) {
133
+ backup(context.config.supabase, 'soul', input.content).catch(() => {});
134
+ }
127
135
  }
128
136
  return `Written: ${filePath}`;
129
137
  },
130
138
 
131
139
  async edit_file(input, memory, context) {
132
140
  const { userDir } = context;
133
- const filePath = userDir ? resolveUserPath(input.path, userDir) : input.path;
141
+ const { PERSONALITY_DIR, backup } = require('../../soul');
142
+ const isSoul = path.basename(input.path) === 'SOUL.md';
143
+ const filePath = isSoul ? path.join(PERSONALITY_DIR, 'SOUL.md') : (userDir ? resolveUserPath(input.path, userDir) : input.path);
134
144
  const content = fs.readFileSync(filePath, 'utf-8');
135
145
  const count = content.split(input.old_string).length - 1;
136
146
  if (count === 0) return `Error: old_string not found in ${input.path}`;
137
147
  if (count > 1) return `Error: old_string matches ${count} times — add more context to make it unique`;
138
- fs.writeFileSync(filePath, content.replace(input.old_string, input.new_string));
139
- if (path.basename(filePath) === 'traits.json' || filePath.includes('personality/')) {
148
+ const updated = content.replace(input.old_string, input.new_string);
149
+ fs.writeFileSync(filePath, updated);
150
+ if (filePath.includes('personality/')) {
140
151
  context._reloadPersonality?.();
152
+ if (isSoul && context.config?.supabase) {
153
+ backup(context.config.supabase, 'soul', updated).catch(() => {});
154
+ }
141
155
  }
142
156
  return `Edited: ${filePath}`;
143
157
  },
package/src/clean.js CHANGED
@@ -19,7 +19,7 @@ const ASSET_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.
19
19
 
20
20
  // Dirs where only .md files are allowed (with per-dir exceptions)
21
21
  const MD_ONLY_DIRS = new Set(['personality', 'commands']);
22
- const MD_DIR_EXCEPTIONS = { personality: new Set(['traits.json']) };
22
+ const MD_DIR_EXCEPTIONS = {};
23
23
 
24
24
  function safeReaddir(dir) {
25
25
  try {
package/src/cli/config.js CHANGED
@@ -353,38 +353,40 @@ async function runOAuthFlow(cfg) {
353
353
  }
354
354
 
355
355
  function updatePersonalityNames(oldBotName, newBotName, oldOwnerName, newOwnerName) {
356
+ const { PERSONALITY_DIR } = require('../soul');
357
+
358
+ if (oldBotName !== newBotName) {
359
+ const soulPath = path.join(PERSONALITY_DIR, 'SOUL.md');
360
+ if (fs.existsSync(soulPath)) {
361
+ let content = fs.readFileSync(soulPath, 'utf-8');
362
+ content = content.replace(new RegExp(`# SOUL\\.md — Who is ${oldBotName}\\?`, 'g'), `# SOUL.md — Who is ${newBotName}?`);
363
+ content = content.replace(new RegExp(`\\*\\*Name:\\*\\* ${oldBotName}`, 'g'), `**Name:** ${newBotName}`);
364
+ fs.writeFileSync(soulPath, content, 'utf-8');
365
+ }
366
+ }
367
+
368
+ if (oldOwnerName !== newOwnerName) {
369
+ const soulPath = path.join(PERSONALITY_DIR, 'SOUL.md');
370
+ if (fs.existsSync(soulPath)) {
371
+ let content = fs.readFileSync(soulPath, 'utf-8');
372
+ content = content.replace(new RegExp(`\\*\\*Created by:\\*\\* ${oldOwnerName}`, 'g'), `**Created by:** ${newOwnerName}`);
373
+ fs.writeFileSync(soulPath, content, 'utf-8');
374
+ }
375
+ }
376
+
356
377
  if (!fs.existsSync(USERS_DIR)) return;
357
378
  const users = fs.readdirSync(USERS_DIR).filter(u => {
358
379
  try { return fs.statSync(path.join(USERS_DIR, u)).isDirectory(); } catch { return false; }
359
380
  });
360
381
  for (const userId of users) {
361
- const personalityDir = path.join(USERS_DIR, userId, 'personality');
362
- if (!fs.existsSync(personalityDir)) continue;
363
-
364
382
  if (oldBotName !== newBotName) {
365
- const soulPath = path.join(personalityDir, 'SOUL.md');
366
- if (fs.existsSync(soulPath)) {
367
- let content = fs.readFileSync(soulPath, 'utf-8');
368
- content = content.replace(new RegExp(`# SOUL\\.md — Who is ${oldBotName}\\?`, 'g'), `# SOUL.md — Who is ${newBotName}?`);
369
- content = content.replace(new RegExp(`\\*\\*Name:\\*\\* ${oldBotName}`, 'g'), `**Name:** ${newBotName}`);
370
- fs.writeFileSync(soulPath, content, 'utf-8');
371
- }
372
- const agentsPath = path.join(personalityDir, 'AGENTS.md');
383
+ const agentsPath = path.join(USERS_DIR, userId, 'personality', 'AGENTS.md');
373
384
  if (fs.existsSync(agentsPath)) {
374
385
  let content = fs.readFileSync(agentsPath, 'utf-8');
375
386
  content = content.replace(new RegExp(`# AGENTS\\.md — How ${oldBotName} Works`, 'g'), `# AGENTS.md — How ${newBotName} Works`);
376
387
  fs.writeFileSync(agentsPath, content, 'utf-8');
377
388
  }
378
389
  }
379
-
380
- if (oldOwnerName !== newOwnerName) {
381
- const soulPath = path.join(personalityDir, 'SOUL.md');
382
- if (fs.existsSync(soulPath)) {
383
- let content = fs.readFileSync(soulPath, 'utf-8');
384
- content = content.replace(new RegExp(`\\*\\*Created by:\\*\\* ${oldOwnerName}`, 'g'), `**Created by:** ${newOwnerName}`);
385
- fs.writeFileSync(soulPath, content, 'utf-8');
386
- }
387
- }
388
390
  }
389
391
  }
390
392
 
package/src/cli/init.js CHANGED
@@ -752,13 +752,12 @@ function ensureDirs() {
752
752
  }
753
753
 
754
754
  function createPersonalityFiles(config) {
755
- for (const userId of config.telegram.allowedUsers) {
756
- const ownerName = config.users?.[String(userId)]?.name || config.owner.name;
757
- const personalityDir = path.join(OBOL_DIR, 'users', String(userId), 'personality');
758
- fs.mkdirSync(personalityDir, { recursive: true });
755
+ const { PERSONALITY_DIR } = require('../soul');
756
+ fs.mkdirSync(PERSONALITY_DIR, { recursive: true });
757
+ const ownerName = config.users?.[String(config.telegram.allowedUsers[0])]?.name || config.owner.name;
759
758
 
760
- if (!fs.existsSync(path.join(personalityDir, 'SOUL.md'))) {
761
- fs.writeFileSync(path.join(personalityDir, 'SOUL.md'), `# SOUL.md — Who is ${config.bot.name}?
759
+ if (!fs.existsSync(path.join(PERSONALITY_DIR, 'SOUL.md'))) {
760
+ fs.writeFileSync(path.join(PERSONALITY_DIR, 'SOUL.md'), `# SOUL.md — Who is ${config.bot.name}?
762
761
 
763
762
  Write your bot's personality here. This shapes how it talks, thinks, and behaves.
764
763
 
@@ -781,7 +780,13 @@ Write your bot's personality here. This shapes how it talks, thinks, and behaves
781
780
  ---
782
781
  *Edit this file anytime to reshape your bot's personality.*
783
782
  `);
784
- }
783
+ }
784
+
785
+ for (const userId of config.telegram.allowedUsers) {
786
+ const ownerName = config.users?.[String(userId)]?.name || config.owner.name;
787
+ const personalityDir = path.join(OBOL_DIR, 'users', String(userId), 'personality');
788
+ fs.mkdirSync(personalityDir, { recursive: true });
789
+
785
790
  if (!fs.existsSync(path.join(personalityDir, 'USER.md'))) {
786
791
  fs.writeFileSync(path.join(personalityDir, 'USER.md'), `# USER.md — About ${ownerName}
787
792
 
@@ -0,0 +1,310 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Full production chat flow in the CLI — router, memory injection, tools, response.
5
+ * Usage: node src/cli/prompt-debug.js [options]
6
+ * -u, --user <id> Telegram user ID (default: 206639616)
7
+ * -m, --message <text> Message to send through the full production pipeline
8
+ * -l, --limit <n> History messages to load (default: 20)
9
+ * --log Write exchange to obol_messages (default: off)
10
+ * --no-system Skip printing the system prompt in inspect mode
11
+ */
12
+
13
+ const path = require('path');
14
+ const { loadConfig, getUserDir } = require('../config');
15
+ const { PERSONALITY_DIR } = require('../soul');
16
+ const { loadPersonality } = require('../personality');
17
+ const { buildSystemPrompt } = require('../claude/prompt');
18
+ const { createAnthropicClient } = require('../claude/client');
19
+ const { createMemory } = require('../memory');
20
+ const { createSelfMemory } = require('../memory-self');
21
+ const { createClaude } = require('../claude');
22
+ const { createMessageLog } = require('../messages');
23
+ const { BackgroundRunner } = require('../background');
24
+
25
+ const DEFAULT_USER_ID = 206639616;
26
+
27
+ function parseArgs() {
28
+ const args = process.argv.slice(2);
29
+ const opts = { userId: DEFAULT_USER_ID, message: null, limit: 20, log: false, showSystem: true };
30
+ for (let i = 0; i < args.length; i++) {
31
+ const a = args[i];
32
+ if ((a === '-u' || a === '--user') && args[i + 1]) opts.userId = parseInt(args[++i]);
33
+ else if ((a === '-m' || a === '--message') && args[i + 1]) opts.message = args[++i];
34
+ else if ((a === '-l' || a === '--limit') && args[i + 1]) opts.limit = parseInt(args[++i]);
35
+ else if (a === '--log') opts.log = true;
36
+ else if (a === '--no-system') opts.showSystem = false;
37
+ }
38
+ return opts;
39
+ }
40
+
41
+ function makeSupabaseHeaders(supabase) {
42
+ return {
43
+ 'apikey': supabase.serviceKey,
44
+ 'Authorization': `Bearer ${supabase.serviceKey}`,
45
+ 'Content-Type': 'application/json',
46
+ };
47
+ }
48
+
49
+ async function fetchMessages(supabase, chatId, limit) {
50
+ const headers = makeSupabaseHeaders(supabase);
51
+ const res = await fetch(
52
+ `${supabase.url}/rest/v1/obol_messages?chat_id=eq.${chatId}&order=created_at.desc&limit=${limit}&select=role,content,created_at,model`,
53
+ { headers }
54
+ );
55
+ if (!res.ok) throw new Error(`messages fetch failed: HTTP ${res.status}`);
56
+ return (await res.json()).reverse();
57
+ }
58
+
59
+ function hr(char = '─', width = 80) { return char.repeat(width); }
60
+
61
+ function label(text) {
62
+ console.log(`\n\x1b[33m\x1b[1m${text}\x1b[0m`);
63
+ console.log(hr());
64
+ }
65
+
66
+ function formatRole(role) {
67
+ if (role === 'user') return '\x1b[36mUSER\x1b[0m';
68
+ if (role === 'assistant') return '\x1b[32mASSISTANT\x1b[0m';
69
+ return role.toUpperCase();
70
+ }
71
+
72
+ function truncate(str, max = 500) {
73
+ if (!str) return '';
74
+ if (str.length <= max) return str;
75
+ return str.slice(0, max) + `\x1b[2m... [+${str.length - max} chars]\x1b[0m`;
76
+ }
77
+
78
+ function printFullPrompt(params) {
79
+ label(`FULL PROMPT → SYSTEM \x1b[2m[${params.model || '?'}]\x1b[0m`);
80
+ for (const block of (params.system || [])) {
81
+ const cached = block.cache_control ? ' \x1b[2m[ephemeral]\x1b[0m' : '';
82
+ console.log(`${truncate(block.text, 800)}${cached}`);
83
+ console.log(hr('·'));
84
+ }
85
+
86
+ if (params.tools?.length) {
87
+ label(`FULL PROMPT → TOOLS [${params.tools.length}]`);
88
+ for (const tool of params.tools) {
89
+ const cached = tool.cache_control ? ' \x1b[2m[ephemeral]\x1b[0m' : '';
90
+ const desc = tool.description ? ` \x1b[2m${truncate(tool.description, 100)}\x1b[0m` : '';
91
+ console.log(` \x1b[35m${tool.name}\x1b[0m${cached}${desc}`);
92
+ }
93
+ }
94
+
95
+ label(`FULL PROMPT → MESSAGES [${params.messages.length}]`);
96
+ for (let i = 0; i < params.messages.length; i++) {
97
+ const msg = params.messages[i];
98
+ console.log(`\n\x1b[1m[${i + 1}/${params.messages.length}] ${formatRole(msg.role)}\x1b[0m`);
99
+ const blocks = typeof msg.content === 'string'
100
+ ? [{ type: 'text', text: msg.content }]
101
+ : msg.content;
102
+ for (const block of blocks) {
103
+ if (block.type === 'text') {
104
+ if (block.text.startsWith('[Runtime context')) {
105
+ console.log(`\x1b[2m── runtime metadata ──\x1b[0m`);
106
+ } else if (block.text.startsWith('Current time:')) {
107
+ console.log(`\x1b[2m${block.text}\x1b[0m`);
108
+ } else if (block.text.includes('## Relevant memories') || block.text.includes('## Self-knowledge')) {
109
+ console.log(`\x1b[33m${block.text}\x1b[0m`);
110
+ } else {
111
+ console.log(truncate(block.text, 1000));
112
+ }
113
+ } else if (block.type === 'tool_use') {
114
+ console.log(`\x1b[35m[tool_use: ${block.name}]\x1b[0m ${truncate(JSON.stringify(block.input), 200)}`);
115
+ } else if (block.type === 'tool_result') {
116
+ const content = Array.isArray(block.content)
117
+ ? block.content.map(b => b.text || '').join('')
118
+ : String(block.content || '');
119
+ console.log(`\x1b[35m[tool_result: ${block.tool_use_id}]\x1b[0m ${truncate(content, 200)}`);
120
+ }
121
+ }
122
+ }
123
+ console.log('');
124
+ }
125
+
126
+ async function runInspect(opts, config) {
127
+ const userDir = getUserDir(opts.userId);
128
+ const personality = loadPersonality(PERSONALITY_DIR, path.join(userDir, 'personality'));
129
+
130
+ console.log('\x1b[1m' + hr('═') + '\x1b[0m');
131
+ console.log(`\x1b[1m INSPECT — user ${opts.userId}\x1b[0m`);
132
+ console.log('\x1b[1m' + hr('═') + '\x1b[0m');
133
+
134
+ if (opts.showSystem) {
135
+ label('SYSTEM PROMPT');
136
+ console.log(buildSystemPrompt(personality, userDir, { botName: config.bot?.name }));
137
+ }
138
+
139
+ const messages = await fetchMessages(config.supabase, opts.userId, opts.limit);
140
+ label(`CONVERSATION HISTORY — ${messages.length} messages`);
141
+ if (messages.length === 0) {
142
+ console.log(' (none)');
143
+ } else {
144
+ for (const msg of messages) {
145
+ const model = msg.model ? ` \x1b[2m[${msg.model}]\x1b[0m` : '';
146
+ console.log(`\n${formatRole(msg.role)}${model} \x1b[2m${new Date(msg.created_at).toLocaleString()}\x1b[0m`);
147
+ console.log(truncate(msg.content));
148
+ }
149
+ }
150
+
151
+ const recentLimit = Math.ceil(25 / 3);
152
+ label(`RECENT MEMORY — ${recentLimit} entries \x1b[2m(budget/3, sonnet)\x1b[0m`);
153
+ const memHeaders = makeSupabaseHeaders(config.supabase);
154
+ const memRes = await fetch(
155
+ `${config.supabase.url}/rest/v1/obol_memory?user_id=eq.${opts.userId}&order=created_at.desc&limit=${recentLimit}&select=content,category,importance,tags,created_at`,
156
+ { headers: memHeaders }
157
+ );
158
+ const memories = await memRes.json();
159
+ if (!memories.length) {
160
+ console.log(' (none)');
161
+ } else {
162
+ for (const m of memories) {
163
+ const tags = m.tags?.length ? ` \x1b[2m[${m.tags.join(', ')}]\x1b[0m` : '';
164
+ console.log(`\x1b[2m${new Date(m.created_at).toLocaleString()}\x1b[0m \x1b[35m[${m.category}]\x1b[0m imp=${m.importance}${tags}`);
165
+ console.log(` ${m.content}`);
166
+ }
167
+ }
168
+
169
+ label('OBOL SELF MEMORY — 20 entries');
170
+ const selfRes = await fetch(
171
+ `${config.supabase.url}/rest/v1/obol_self_memory?user_id=eq.${opts.userId}&order=created_at.desc&limit=20&select=content,category,importance,tags,source,created_at`,
172
+ { headers: memHeaders }
173
+ );
174
+ const selfMemories = await selfRes.json();
175
+ if (!selfMemories.length) {
176
+ console.log(' (none)');
177
+ } else {
178
+ for (const m of selfMemories) {
179
+ const tags = m.tags?.length ? ` \x1b[2m[${m.tags.join(', ')}]\x1b[0m` : '';
180
+ const src = m.source ? ` \x1b[2msrc=${m.source}\x1b[0m` : '';
181
+ console.log(`\x1b[2m${new Date(m.created_at).toLocaleString()}\x1b[0m \x1b[35m[${m.category}]\x1b[0m imp=${m.importance}${tags}${src}`);
182
+ console.log(` ${m.content}`);
183
+ }
184
+ }
185
+
186
+ console.log('\n\x1b[2mTip: pass -m "message" to run the full production pipeline\x1b[0m\n');
187
+ }
188
+
189
+ async function runChat(opts, config) {
190
+ const userId = opts.userId;
191
+ const userDir = getUserDir(userId);
192
+ const personality = loadPersonality(PERSONALITY_DIR, path.join(userDir, 'personality'));
193
+
194
+ process.stdout.write('\x1b[2mInitializing...\x1b[0m');
195
+ const memory = await createMemory(config.supabase, userId);
196
+ const selfMemory = await createSelfMemory(config.supabase, userId);
197
+ process.stdout.write(' done\n');
198
+
199
+ const claude = createClaude(config.anthropic, {
200
+ personality,
201
+ memory,
202
+ selfMemory,
203
+ userDir,
204
+ bridgeEnabled: false,
205
+ botName: config.bot?.name,
206
+ });
207
+
208
+ const messageLog = opts.log
209
+ ? createMessageLog(config.supabase, memory, config.anthropic, userId, userDir)
210
+ : null;
211
+
212
+
213
+ // Load history and inject
214
+ const rawMessages = await fetchMessages(config.supabase, userId, opts.limit);
215
+ const firstUserIdx = rawMessages.findIndex(m => m.role === 'user');
216
+ const historyMessages = firstUserIdx > 0 ? rawMessages.slice(firstUserIdx) : rawMessages;
217
+ for (const msg of historyMessages) {
218
+ claude.injectHistory(userId, msg.role, msg.content);
219
+ }
220
+
221
+ console.log('\n\x1b[1m' + hr('═') + '\x1b[0m');
222
+ console.log(`\x1b[1m CHAT DEBUG — user ${userId}\x1b[0m`);
223
+ console.log('\x1b[1m' + hr('═') + '\x1b[0m');
224
+
225
+ label(`USER MESSAGE`);
226
+ console.log(opts.message);
227
+
228
+ label('PIPELINE');
229
+
230
+ const start = Date.now();
231
+ const bg = new BackgroundRunner();
232
+
233
+ const context = {
234
+ chatId: userId,
235
+ userId,
236
+ verbose: true,
237
+ bg,
238
+ scheduler: null,
239
+ toolPrefs: new Map(),
240
+ messageLog,
241
+ config,
242
+ _verboseNotify: (msg) => console.log(` \x1b[2m${msg}\x1b[0m`),
243
+ _onRouteDecision: ({ model, needMemory }) => {
244
+ console.log(` \x1b[2m[route] model=${model} memory=${needMemory}\x1b[0m`);
245
+ },
246
+ _onRouteUpdate: ({ memoryCount, model }) => {
247
+ if (memoryCount !== undefined) console.log(` \x1b[2m[route] memory=${memoryCount} facts injected\x1b[0m`);
248
+ if (model) console.log(` \x1b[2m[route] escalated → ${model}\x1b[0m`);
249
+ },
250
+ _onToolStart: (toolName, inputSummary) => {
251
+ console.log(` \x1b[2m[tool] ${toolName}${inputSummary ? ': ' + inputSummary : ''}\x1b[0m`);
252
+ },
253
+ _onPromptReady: (params) => {
254
+ printFullPrompt(params);
255
+ },
256
+ };
257
+
258
+ if (messageLog) {
259
+ messageLog.log(userId, 'user', opts.message);
260
+ }
261
+
262
+ const { text: response, usage, model } = await claude.chat(opts.message, context);
263
+
264
+ const elapsed = ((Date.now() - start) / 1000).toFixed(1);
265
+
266
+ if (messageLog) {
267
+ messageLog.log(userId, 'assistant', response, {
268
+ model,
269
+ tokensIn: usage?.input_tokens,
270
+ tokensOut: usage?.output_tokens,
271
+ });
272
+ }
273
+
274
+ const tokIn = usage?.input_tokens >= 1000
275
+ ? `${(usage.input_tokens / 1000).toFixed(1)}k`
276
+ : usage?.input_tokens ?? '?';
277
+ const tokOut = usage?.output_tokens >= 1000
278
+ ? `${(usage.output_tokens / 1000).toFixed(1)}k`
279
+ : usage?.output_tokens ?? '?';
280
+
281
+ label(`RESPONSE \x1b[2m${model} | ${tokIn} in / ${tokOut} out | ${elapsed}s\x1b[0m`);
282
+ console.log(response ?? '(no response)');
283
+ console.log('\n' + hr('═') + '\n');
284
+
285
+ if (opts.log) {
286
+ console.log('\x1b[2mLogged to obol_messages\x1b[0m\n');
287
+ }
288
+ }
289
+
290
+ async function promptDebug(opts) {
291
+ const config = loadConfig();
292
+ if (!config) { console.error('No config found — run: obol init'); process.exit(1); }
293
+ if (!config.supabase?.url || !config.supabase?.serviceKey) { console.error('Supabase not configured'); process.exit(1); }
294
+
295
+ if (opts.message) {
296
+ await runChat(opts, config);
297
+ } else {
298
+ await runInspect(opts, config);
299
+ }
300
+ }
301
+
302
+ async function main() {
303
+ await promptDebug(parseArgs());
304
+ }
305
+
306
+ if (require.main === module) {
307
+ main().catch(e => { console.error(e.message); process.exit(1); });
308
+ }
309
+
310
+ module.exports = { promptDebug };
package/src/config.js CHANGED
@@ -161,11 +161,6 @@ function ensureUserDir(userId) {
161
161
  if (fs.existsSync(defaultAgents) && !fs.existsSync(targetAgents)) {
162
162
  fs.copyFileSync(defaultAgents, targetAgents);
163
163
  }
164
- const defaultTraits = path.join(__dirname, 'defaults', 'traits.json');
165
- const targetTraits = path.join(dir, 'personality', 'traits.json');
166
- if (fs.existsSync(defaultTraits) && !fs.existsSync(targetTraits)) {
167
- fs.copyFileSync(defaultTraits, targetTraits);
168
- }
169
164
  return dir;
170
165
  }
171
166
 
@@ -2,7 +2,6 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const { execFileSync } = require('child_process');
4
4
  const { OBOL_DIR } = require('../config');
5
- const { loadTraits, saveTraits } = require('../personality');
6
5
  const { isValidNpmPackage } = require('../sanitize');
7
6
  const { loadEvolutionState, saveEvolutionState } = require('./state');
8
7
  const { readDir, syncDir } = require('./filesystem');
@@ -33,7 +32,6 @@ async function evolve(claudeClient, messageLog, memory, userDir, supabaseConfig
33
32
  const currentSoul = fs.existsSync(soulPath) ? fs.readFileSync(soulPath, 'utf-8') : '';
34
33
  const currentUser = fs.existsSync(userPath) ? fs.readFileSync(userPath, 'utf-8') : '';
35
34
  const currentAgents = fs.existsSync(agentsPath) ? fs.readFileSync(agentsPath, 'utf-8') : '';
36
- const currentTraits = loadTraits(userPersonalityDir);
37
35
  const currentScripts = readDir(scriptsDir);
38
36
  const currentTests = readDir(testsDir);
39
37
  const currentCommands = readDir(commandsDir);
@@ -192,8 +190,7 @@ Produce a structured growth report covering:
192
190
  3. RELATIONSHIP SHIFTS — How the dynamic with the owner changed (closer, more trust, new friction, etc.)
193
191
  4. BEHAVIORAL PATTERNS — Recurring interaction styles or habits observed
194
192
  5. GROWTH EDGES — Areas where the personality is being pushed or pulled in new directions
195
- 6. TRAIT PRESSUREWhich traits should shift and why (cite specific evidence from conversations/memories)
196
- 7. IDENTITY CONTINUITY — What core aspects stayed the same and should be preserved
193
+ 6. IDENTITY CONTINUITYWhat core aspects stayed the same and should be preserved
197
194
 
198
195
  Be specific. Cite evidence from the conversations, memories, and self-memories. This report guides the evolution rewrite.`,
199
196
  messages: [{
@@ -204,9 +201,6 @@ ${previousSoul || '(not available)'}
204
201
  ## Current SOUL
205
202
  ${currentSoul || '(empty)'}
206
203
 
207
- ## Current Traits
208
- ${JSON.stringify(currentTraits)}
209
-
210
204
  ## New Memories Since Last Evolution (${recentMemories.length})
211
205
  ${recentMemorySummary || '(none)'}
212
206
 
@@ -242,7 +236,6 @@ A pre-evolution analysis has been conducted comparing your previous state agains
242
236
  lastEvolution: state.lastEvolution,
243
237
  firstEvolutionPreamble,
244
238
  growthPreamble,
245
- currentTraits,
246
239
  baselineResults,
247
240
  });
248
241
 
@@ -431,19 +424,6 @@ Fix the scripts. Tests define correct behavior.`
431
424
  fs.writeFileSync(agentsPath, result.agents);
432
425
  }
433
426
 
434
- if (result.traits && typeof result.traits === 'object') {
435
- const validTraits = {};
436
- for (const [key, val] of Object.entries(result.traits)) {
437
- if (typeof val === 'number' && val >= 0 && val <= 100) {
438
- validTraits[key] = Math.round(val);
439
- }
440
- }
441
- if (Object.keys(validTraits).length > 0) {
442
- const merged = { ...currentTraits, ...validTraits };
443
- saveTraits(personalityDir, merged);
444
- }
445
- }
446
-
447
427
  if (result.commands && typeof result.commands === 'object') {
448
428
  if (Object.keys(result.commands).length > 0 || Object.keys(currentCommands).length > 0) {
449
429
  syncDir(commandsDir, result.commands);
@@ -1,4 +1,4 @@
1
- function buildEvolutionPrompt({ evolutionNumber, lastEvolution, firstEvolutionPreamble, growthPreamble, currentTraits, baselineResults }) {
1
+ function buildEvolutionPrompt({ evolutionNumber, lastEvolution, firstEvolutionPreamble, growthPreamble, baselineResults }) {
2
2
  return `You are an AI undergoing evolution #${evolutionNumber}. ${lastEvolution ? `Last evolution: ${lastEvolution}.` : 'This is your first evolution.'}
3
3
  ${firstEvolutionPreamble}${growthPreamble}
4
4
 
@@ -24,22 +24,6 @@ Operational manual written as instructions to yourself. Focus on owner-specific
24
24
 
25
25
  **What belongs in AGENTS.md:** Memory Strategy, Self-Extending patterns, Scripts & Service Integrations, Background Task Guidelines, Communication Style, Evolution notes, and any owner-specific workflows or lessons discovered from conversations. Keep what works, remove what doesn't.
26
26
 
27
- ## Part 3b: Personality Traits
28
-
29
- Current trait values: ${JSON.stringify(currentTraits)}
30
-
31
- Based on conversation patterns, adjust each trait (0-100). Consider:
32
- - Does the owner respond well to humor? Increase/decrease humor.
33
- - Does the owner prefer direct answers? Adjust directness.
34
- - Does the owner appreciate creative solutions? Adjust creativity.
35
- - Does the owner share emotions or stay task-focused? Adjust empathy.
36
- - Does the owner want blunt truth or diplomatic framing? Adjust honesty.
37
- - Does the owner welcome proactive questions? Adjust curiosity.
38
-
39
- Small adjustments (±5-15) per evolution. Don't swing wildly.
40
-
41
- Include in output JSON as: "traits": { "humor": 65, "honesty": 80, ... }
42
-
43
27
  ## Part 4: Scripts
44
28
 
45
29
  Review and refactor every script. Standards:
@@ -156,7 +140,6 @@ The OBOL directory has a FIXED structure: personality/, scripts/, tests/, comman
156
140
  "soul": "full SOUL.md content",
157
141
  "user": "full USER.md content",
158
142
  "agents": "full AGENTS.md content",
159
- "traits": { "humor": 65, "honesty": 80, "directness": 70, "curiosity": 75, "empathy": 65, "creativity": 70 },
160
143
  "scripts": { "name.js": "content" },
161
144
  "tests": { "test-name.js": "content" },
162
145
  "commands": { "name.md": "content" },
package/src/messages.js CHANGED
@@ -41,7 +41,8 @@ class MessageLog {
41
41
  }
42
42
 
43
43
  async log(chatId, role, content, opts = {}) {
44
- const truncated = content.substring(0, 50000);
44
+ const serialized = typeof content === 'string' ? content : JSON.stringify(content);
45
+ const truncated = serialized.substring(0, 50000);
45
46
 
46
47
  try {
47
48
  await fetch(`${this.url}/rest/v1/obol_messages`, {
@@ -60,7 +61,7 @@ class MessageLog {
60
61
  console.error('[messages] Log failed:', e.message);
61
62
  }
62
63
 
63
- if (role === 'user') {
64
+ if (role === 'user' && typeof content === 'string') {
64
65
  this._lastUserMessage.set(chatId, truncated);
65
66
  }
66
67
 
@@ -83,7 +84,12 @@ class MessageLog {
83
84
  { headers: this.headers }
84
85
  );
85
86
  const data = await res.json();
86
- const rows = data.reverse();
87
+ const rows = data.reverse().map(row => {
88
+ if (typeof row.content === 'string' && row.content.startsWith('[')) {
89
+ try { row.content = JSON.parse(row.content); } catch {}
90
+ }
91
+ return row;
92
+ });
87
93
  const firstUserIdx = rows.findIndex(r => r.role === 'user');
88
94
  return firstUserIdx > 0 ? rows.slice(firstUserIdx) : rows;
89
95
  } catch {
@@ -189,24 +195,32 @@ Importance: 0.3 minor, 0.5 useful, 0.7 important, 0.9 critical.`,
189
195
  if (Array.isArray(facts) && facts.length > 0) {
190
196
  const validCategories = new Set(['fact','preference','decision','lesson','person','project','event','conversation','resource','pattern','context','email']);
191
197
  let stored = 0;
198
+ let updated = 0;
192
199
  let duped = 0;
193
200
 
194
201
  for (const fact of facts.slice(0, 5)) {
195
202
  if (!fact.content || fact.content.length <= 10) continue;
196
- try {
197
- const existing = await this.memory.search(fact.content, { limit: 1, threshold: 0.92 });
198
- if (existing.length > 0) { duped++; continue; }
199
- } catch {}
200
203
  const category = validCategories.has(fact.category) ? fact.category : 'fact';
201
204
  const importance = typeof fact.importance === 'number' ? Math.min(1, Math.max(0, fact.importance)) : 0.5;
202
205
  const tags = Array.isArray(fact.tags) ? fact.tags.slice(0, 5) : [];
206
+ try {
207
+ const related = await this.memory.search(fact.content, { limit: 1, threshold: 0.65 });
208
+ if (related.length > 0) {
209
+ const top = related[0];
210
+ if (top.similarity >= 0.82) { duped++; continue; }
211
+ await this.memory.update(top.id, { content: fact.content, category, importance, tags });
212
+ updated++;
213
+ vlog?.(`[extract] ~[${category}] ${fact.content}`);
214
+ continue;
215
+ }
216
+ } catch {}
203
217
  await this.memory.add(fact.content, { category, tags, importance, source: 'turn-extraction' });
204
218
  stored++;
205
219
  vlog?.(`[extract] +[${category}] ${fact.content}`);
206
220
  }
207
221
 
208
- if (stored > 0 || duped > 0) {
209
- vlog?.(`[extract] ${stored} stored, ${duped} duped, ${facts.length} extracted`);
222
+ if (stored > 0 || updated > 0 || duped > 0) {
223
+ vlog?.(`[extract] ${stored} stored, ${updated} updated, ${duped} duped, ${facts.length} extracted`);
210
224
  }
211
225
  } else {
212
226
  vlog?.('[extract] 0 facts (trivial exchange)');
@@ -2,8 +2,6 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const { OBOL_DIR } = require('./config');
4
4
 
5
- const DEFAULT_TRAITS = require('./defaults/traits.json');
6
-
7
5
  function loadPersonality(sharedDir, userDir) {
8
6
  sharedDir = sharedDir || path.join(OBOL_DIR, 'personality');
9
7
  userDir = userDir || sharedDir;
@@ -22,25 +20,7 @@ function loadPersonality(sharedDir, userDir) {
22
20
  }
23
21
  }
24
22
 
25
- personality.traits = loadTraits(userDir);
26
-
27
23
  return personality;
28
24
  }
29
25
 
30
- function loadTraits(dir) {
31
- dir = dir || path.join(OBOL_DIR, 'personality');
32
- const traitsPath = path.join(dir, 'traits.json');
33
- try {
34
- return JSON.parse(fs.readFileSync(traitsPath, 'utf-8'));
35
- } catch {
36
- return { ...DEFAULT_TRAITS };
37
- }
38
- }
39
-
40
- function saveTraits(dir, traits) {
41
- dir = dir || path.join(OBOL_DIR, 'personality');
42
- fs.mkdirSync(dir, { recursive: true });
43
- fs.writeFileSync(path.join(dir, 'traits.json'), JSON.stringify(traits, null, 2));
44
- }
45
-
46
- module.exports = { loadPersonality, loadTraits, saveTraits, DEFAULT_TRAITS };
26
+ module.exports = { loadPersonality };
@@ -11,7 +11,6 @@ const memoryCommands = require('./commands/memory');
11
11
  const statusCommands = require('./commands/status');
12
12
  const conversationCommands = require('./commands/conversation');
13
13
  const adminCommands = require('./commands/admin');
14
- const traitsCommands = require('./commands/traits');
15
14
  const secretsCommands = require('./commands/secrets');
16
15
  const toolsCommands = require('./commands/tools');
17
16
  const { registerTextHandler } = require('./handlers/text');
@@ -97,7 +96,6 @@ function createBot(telegramConfig, config) {
97
96
  { command: 'status', description: 'Bot status and uptime' },
98
97
  { command: 'backup', description: 'Trigger GitHub backup now' },
99
98
  { command: 'clean', description: 'Audit and fix workspace' },
100
- { command: 'traits', description: 'View or adjust personality traits' },
101
99
  { command: 'secret', description: 'Manage per-user secrets' },
102
100
  { command: 'evolution', description: 'Evolution progress' },
103
101
  { command: 'verbose', description: 'Toggle verbose mode on/off' },
@@ -112,7 +110,6 @@ function createBot(telegramConfig, config) {
112
110
  memoryCommands.register(bot, config);
113
111
  statusCommands.register(bot, config);
114
112
  adminCommands.register(bot, config, createAsk);
115
- traitsCommands.register(bot, config);
116
113
  secretsCommands.register(bot, config);
117
114
  toolsCommands.register(bot, config);
118
115
 
@@ -55,12 +55,12 @@ function register(bot, config, createAsk) {
55
55
  ## Workspace Structure
56
56
  Allowed root directories: personality/, scripts/, tests/, commands/, apps/, logs/, assets/
57
57
  Allowed root files: config.json, secrets.json, .evolution-state.json, .first-run-done, .post-setup-done
58
- - personality/ and commands/ only contain .md files (except personality/traits.json which must stay)
58
+ - personality/ and commands/ only contain .md files
59
59
  - Unknown directories at the root should be moved into apps/
60
60
  - Script files (.js, .ts, .sh, etc.) go into scripts/
61
61
  - Asset files (images, audio, pdf, etc.) go into assets/
62
62
  - .DS_Store and other dotfiles should be deleted
63
- - secrets.json and personality/traits.json must NOT be moved
63
+ - secrets.json must NOT be moved
64
64
 
65
65
  ## Issues Found
66
66
  ${issueLines}
@@ -84,7 +84,7 @@ Summarize what was cleaned and secrets migrated.`);
84
84
  const taskPrompt = promptParts.join('\n\n');
85
85
 
86
86
  const stopTyping = startTyping(ctx);
87
- const status = createStatusTracker(ctx);
87
+ const status = createStatusTracker(ctx, config.bot?.name);
88
88
  const chatContext = createChatContext(ctx, tenant, config, { allowedUsers: new Set(), bot, createAsk });
89
89
  chatContext._model = 'claude-sonnet-4-6';
90
90
  chatContext._onRouteDecision = (info) => { status.setRouteInfo(info); status.start(); };
@@ -113,7 +113,7 @@ Summarize what was cleaned and secrets migrated.`);
113
113
  const testsAfter = fs.existsSync(testsDir) && fs.readdirSync(testsDir).filter(f => !f.startsWith('.')).length > 0;
114
114
  if (!testsAfter && hasScripts) {
115
115
  const testPrompt = `Read every script in ${plan.baseDir}/scripts/. For each script, write a corresponding test file in ${plan.baseDir}/tests/. Name each test file test-<script-name> (e.g. scripts/gmail-send.py → tests/test-gmail-send.py). After writing all tests, run them and fix any failures until they all pass. Summarize the test results.`;
116
- const testStatus = createStatusTracker(ctx);
116
+ const testStatus = createStatusTracker(ctx, config.bot?.name);
117
117
  const testCtx = createChatContext(ctx, tenant, config, { allowedUsers: new Set(), bot, createAsk });
118
118
  testCtx._model = 'claude-sonnet-4-6';
119
119
  testCtx._onRouteDecision = (info) => { testStatus.setRouteInfo(info); testStatus.start(); };
@@ -46,7 +46,6 @@ function register(bot, config) {
46
46
  /events — Upcoming scheduled events
47
47
  /tasks — Running background tasks
48
48
  /tools — Toggle optional tools on/off
49
- /traits — View/adjust personality traits
50
49
  /secret — Manage per-user secrets
51
50
  /evolution — Evolution progress
52
51
  /status — Bot status and uptime
@@ -1,9 +1,7 @@
1
- const path = require('path');
2
1
  const { getTenant } = require('../../tenant');
3
- const { loadTraits } = require('../../personality');
4
2
  const { loadEvolutionState } = require('../../evolve');
5
3
  const { getMaxToolIterations } = require('../../claude');
6
- const { termBar, formatTraits } = require('../utils');
4
+ const { termBar } = require('../utils');
7
5
  const { TERM_SEP } = require('../constants');
8
6
 
9
7
  function register(bot, config) {
@@ -52,10 +50,6 @@ function register(bot, config) {
52
50
  lines.push(` last ${new Date(evoState.lastEvolution).toLocaleDateString()}`);
53
51
  }
54
52
 
55
- const personalityDir = path.join(tenant.userDir, 'personality');
56
- const traits = loadTraits(personalityDir);
57
- lines.push(``, `TRAITS`);
58
- lines.push(formatTraits(traits));
59
53
  lines.push(TERM_SEP);
60
54
 
61
55
  await ctx.reply(`<pre>${lines.join('\n')}</pre>`, { parse_mode: 'HTML' });
@@ -19,7 +19,7 @@ async function processMediaItems(ctx, items, { config, allowedUsers, bot, create
19
19
  if (!ctx.from) return;
20
20
  const userId = ctx.from.id;
21
21
  const stopTyping = startTyping(ctx);
22
- const status = createStatusTracker(ctx);
22
+ const status = createStatusTracker(ctx, config.bot?.name);
23
23
 
24
24
  try {
25
25
  const tenant = await getTenant(userId, config);
@@ -74,7 +74,7 @@ async function processSpecial(ctx, prompt, deps) {
74
74
  if (!ctx.from) return;
75
75
  const userId = ctx.from.id;
76
76
  const stopTyping = startTyping(ctx);
77
- const status = createStatusTracker(ctx);
77
+ const status = createStatusTracker(ctx, deps.config?.bot?.name);
78
78
 
79
79
  try {
80
80
  const tenant = await getTenant(userId, deps.config);
@@ -82,12 +82,13 @@ function createChatContext(ctx, tenant, config, { allowedUsers, bot, createAsk }
82
82
  };
83
83
  }
84
84
 
85
- function createStatusTracker(ctx) {
85
+ function createStatusTracker(ctx, botName) {
86
86
  let statusMsgId = null;
87
87
  let statusText = 'Processing';
88
88
  let statusTimer = null;
89
89
  let statusStart = null;
90
90
  let routeInfo = null;
91
+ const title = botName || 'OBOL';
91
92
  const stopBtn = new InlineKeyboard()
92
93
  .text('■ Stop', `stop:${ctx.chat.id}`)
93
94
  .text('■ Force Stop', `force:${ctx.chat.id}`);
@@ -100,14 +101,14 @@ function createStatusTracker(ctx) {
100
101
  const start = () => {
101
102
  if (statusTimer) return;
102
103
  statusStart = Date.now();
103
- const html = buildStatusHtml({ route: routeInfo, elapsed: 0, toolStatus: statusText });
104
+ const html = buildStatusHtml({ route: routeInfo, elapsed: 0, toolStatus: statusText, title });
104
105
  ctx.reply(html, { parse_mode: 'HTML', reply_markup: stopBtn }).then(sent => {
105
106
  if (sent) statusMsgId = sent.message_id;
106
107
  }).catch(() => {});
107
108
  statusTimer = setInterval(() => {
108
109
  if (!statusMsgId) return;
109
110
  const elapsed = Math.round((Date.now() - statusStart) / 1000);
110
- const html = buildStatusHtml({ route: routeInfo, elapsed, toolStatus: statusText });
111
+ const html = buildStatusHtml({ route: routeInfo, elapsed, toolStatus: statusText, title });
111
112
  ctx.api.editMessageText(ctx.chat.id, statusMsgId, html, { parse_mode: 'HTML', reply_markup: stopBtn }).catch(() => {});
112
113
  }, 5000);
113
114
  };
@@ -126,7 +127,7 @@ function createStatusTracker(ctx) {
126
127
  updateFormatting() {
127
128
  if (!statusMsgId) return;
128
129
  const elapsed = statusStart ? Math.round((Date.now() - statusStart) / 1000) : 0;
129
- const html = buildStatusHtml({ route: routeInfo, elapsed, toolStatus: 'Formatting output' });
130
+ const html = buildStatusHtml({ route: routeInfo, elapsed, toolStatus: 'Formatting output', title });
130
131
  ctx.api.editMessageText(ctx.chat.id, statusMsgId, html, { parse_mode: 'HTML' }).catch(() => {});
131
132
  },
132
133
  deleteMsg() {
@@ -151,7 +152,7 @@ async function processTextMessage(ctx, fullMessage, { config, allowedUsers, bot,
151
152
 
152
153
  const chatMessage = replyContext + fullMessage;
153
154
  const stopTyping = startTyping(ctx);
154
- const status = createStatusTracker(ctx);
155
+ const status = createStatusTracker(ctx, config.bot?.name);
155
156
 
156
157
  const batcher = tenant.verbose ? createVerboseBatcher(ctx) : null;
157
158
  try {
@@ -54,14 +54,6 @@ function startTyping(ctx) {
54
54
  return () => clearInterval(interval);
55
55
  }
56
56
 
57
- function formatTraits(traits) {
58
- const maxLen = Math.max(...Object.keys(traits).map(k => k.length));
59
- return Object.entries(traits).map(([name, val]) => {
60
- const label = (name.charAt(0).toUpperCase() + name.slice(1)).padEnd(maxLen + 1);
61
- return ` ${label}${termBar(val)} ${val}`;
62
- }).join('\n');
63
- }
64
-
65
57
  function splitMessage(text, maxLength) {
66
58
  const chunks = [];
67
59
  let remaining = text;
@@ -78,4 +70,4 @@ function splitMessage(text, maxLength) {
78
70
  return chunks;
79
71
  }
80
72
 
81
- module.exports = { termBar, markdownToTelegramHtml, sendHtml, editHtml, startTyping, formatTraits, splitMessage };
73
+ module.exports = { termBar, markdownToTelegramHtml, sendHtml, editHtml, startTyping, splitMessage };
package/src/tenant.js CHANGED
@@ -60,7 +60,7 @@ async function createTenant(userId, config) {
60
60
 
61
61
  let personalityMtime = 0;
62
62
  try {
63
- personalityMtime = fs.statSync(path.join(personalityDir, 'SOUL.md')).mtimeMs;
63
+ personalityMtime = fs.statSync(path.join(PERSONALITY_DIR, 'SOUL.md')).mtimeMs;
64
64
  } catch {}
65
65
 
66
66
  return {
@@ -1,8 +0,0 @@
1
- {
2
- "humor": 60,
3
- "honesty": 80,
4
- "directness": 70,
5
- "curiosity": 75,
6
- "empathy": 65,
7
- "creativity": 70
8
- }
@@ -1,50 +0,0 @@
1
- const path = require('path');
2
- const { getTenant } = require('../../tenant');
3
- const { loadTraits, saveTraits, DEFAULT_TRAITS } = require('../../personality');
4
- const { formatTraits } = require('../utils');
5
- const { TERM_SEP } = require('../constants');
6
-
7
- function register(bot, config) {
8
- const botName = config.bot?.name || 'OBOL';
9
- bot.command('traits', async (ctx) => {
10
- if (!ctx.from) return;
11
- const tenant = await getTenant(ctx.from.id, config);
12
- const personalityDir = path.join(tenant.userDir, 'personality');
13
- const args = ctx.message.text.split(' ').slice(1);
14
-
15
- if (args[0] === 'reset') {
16
- saveTraits(personalityDir, { ...DEFAULT_TRAITS });
17
- tenant.claude.reloadPersonality();
18
- const traits = { ...DEFAULT_TRAITS };
19
- const lines = [`◈ ${botName} PERSONALITY MATRIX`, TERM_SEP, `RESET TO DEFAULTS`, ``, formatTraits(traits), TERM_SEP];
20
- await ctx.reply(`<pre>${lines.join('\n')}</pre>`, { parse_mode: 'HTML' });
21
- return;
22
- }
23
-
24
- if (args[0] && args[1]) {
25
- const traitName = args[0].toLowerCase();
26
- const value = parseInt(args[1], 10);
27
- if (!(traitName in DEFAULT_TRAITS)) {
28
- await ctx.reply(`Unknown trait: ${traitName}\nValid: ${Object.keys(DEFAULT_TRAITS).join(', ')}`);
29
- return;
30
- }
31
- if (isNaN(value) || value < 0 || value > 100) {
32
- await ctx.reply('Value must be 0-100');
33
- return;
34
- }
35
- const traits = loadTraits(personalityDir);
36
- traits[traitName] = value;
37
- saveTraits(personalityDir, traits);
38
- tenant.claude.reloadPersonality();
39
- const lines = [`◈ ${botName} PERSONALITY MATRIX`, TERM_SEP, `UPDATED ${traitName} → ${value}`, ``, formatTraits(traits), TERM_SEP];
40
- await ctx.reply(`<pre>${lines.join('\n')}</pre>`, { parse_mode: 'HTML' });
41
- return;
42
- }
43
-
44
- const traits = loadTraits(personalityDir);
45
- const lines = [`◈ ${botName} PERSONALITY MATRIX`, TERM_SEP, ``, formatTraits(traits), ``, `/traits &lt;name&gt; &lt;0-100&gt;`, `/traits reset`, TERM_SEP];
46
- await ctx.reply(`<pre>${lines.join('\n')}</pre>`, { parse_mode: 'HTML' });
47
- });
48
- }
49
-
50
- module.exports = { register };