obol-ai 0.3.3 → 0.3.5

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.5
2
+ - add knowledge_search + read_file to curiosity, self-memory count in status, opinionated research prompt
3
+ - 0.3.4: smarter memory dedup, better router queries, rebalanced recall
4
+ - fix shared soul path, backup on tool write, show bot name in status
5
+
1
6
  ## 0.3.3
2
7
  - update changelog
3
8
  - improve memory retrieval quality: tighter dedup, wider window, recency boost, self-memory, jaccard dedup
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "obol-ai",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
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": {
@@ -267,8 +267,9 @@ function createClaude(anthropicConfig, { personality, memory, selfMemory, userDi
267
267
  }
268
268
 
269
269
  function reloadPersonality() {
270
+ const { PERSONALITY_DIR } = require('../soul');
270
271
  const pDir = userDir ? path.join(userDir, 'personality') : undefined;
271
- const newPersonality = require('../personality').loadPersonality(pDir);
272
+ const newPersonality = require('../personality').loadPersonality(PERSONALITY_DIR, pDir);
272
273
  for (const key of Object.keys(personality)) delete personality[key];
273
274
  Object.assign(personality, newPersonality);
274
275
  baseSystemPrompt = buildSystemPrompt(personality, userDir, { bridgeEnabled, botName });
@@ -36,7 +36,7 @@ async function routeMessage(client, memory, userMessage, { vlog, onRouteDecision
36
36
  Reply with ONLY a JSON object:
37
37
  {"need_memory": true/false, "search_queries": ["query1", "query2"], "model": "haiku|sonnet|opus"}
38
38
 
39
- search_queries: 1-3 optimized search queries based on the full conversation context. Cover distinct topics, people, or entities referenced. 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.
40
40
 
41
41
  Memory: casual messages (greetings, jokes, simple questions) → false. References to past, people, projects, preferences → true.
42
42
 
@@ -73,12 +73,13 @@ If recent context shows an ongoing task (sonnet/opus was just used, multi-step w
73
73
 
74
74
  if (decision.need_memory && memory) {
75
75
  const budget = decision.model === 'opus' ? 40 : decision.model === 'haiku' ? 15 : 25;
76
+ const poolPerQuery = decision.model === 'opus' ? 20 : decision.model === 'haiku' ? 10 : 15;
76
77
  const searchQueries = queries.length > 0 ? queries : [userMessage];
77
78
 
78
79
  const recentMemories = await memory.byDate('7d', { limit: Math.ceil(budget / 3) });
79
80
 
80
81
  const semanticResults = await Promise.all(
81
- 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 }))
82
83
  );
83
84
  const semanticMemories = semanticResults.flat();
84
85
 
@@ -127,6 +128,7 @@ If recent context shows an ongoing task (sonnet/opus was just used, multi-step w
127
128
  const selfLines = topSelf.slice(0, 8).map(m => `- [${m.category}] ${m.content}`);
128
129
  memoryBlock = (memoryBlock || '') + `\n\n## Self-knowledge\n${selfLines.join('\n')}`;
129
130
  vlog(`[memory] +${topSelf.length} self-memory facts`);
131
+ onRouteUpdate?.({ selfMemoryCount: topSelf.length });
130
132
  }
131
133
  }
132
134
  }
@@ -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
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));
148
+ const updated = content.replace(input.old_string, input.new_string);
149
+ fs.writeFileSync(filePath, updated);
139
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/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
 
@@ -76,13 +76,22 @@ function truncate(str, max = 500) {
76
76
  }
77
77
 
78
78
  function printFullPrompt(params) {
79
- label('FULL PROMPT → SYSTEM');
79
+ label(`FULL PROMPT → SYSTEM \x1b[2m[${params.model || '?'}]\x1b[0m`);
80
80
  for (const block of (params.system || [])) {
81
81
  const cached = block.cache_control ? ' \x1b[2m[ephemeral]\x1b[0m' : '';
82
82
  console.log(`${truncate(block.text, 800)}${cached}`);
83
83
  console.log(hr('·'));
84
84
  }
85
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
+
86
95
  label(`FULL PROMPT → MESSAGES [${params.messages.length}]`);
87
96
  for (let i = 0; i < params.messages.length; i++) {
88
97
  const msg = params.messages[i];
@@ -92,7 +101,15 @@ function printFullPrompt(params) {
92
101
  : msg.content;
93
102
  for (const block of blocks) {
94
103
  if (block.type === 'text') {
95
- console.log(truncate(block.text, 1000));
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
+ }
96
113
  } else if (block.type === 'tool_use') {
97
114
  console.log(`\x1b[35m[tool_use: ${block.name}]\x1b[0m ${truncate(JSON.stringify(block.input), 200)}`);
98
115
  } else if (block.type === 'tool_result') {
@@ -131,10 +148,11 @@ async function runInspect(opts, config) {
131
148
  }
132
149
  }
133
150
 
134
- label('RECENT MEMORY 20 entries');
151
+ const recentLimit = Math.ceil(25 / 3);
152
+ label(`RECENT MEMORY — ${recentLimit} entries \x1b[2m(budget/3, sonnet)\x1b[0m`);
135
153
  const memHeaders = makeSupabaseHeaders(config.supabase);
136
154
  const memRes = await fetch(
137
- `${config.supabase.url}/rest/v1/obol_memory?user_id=eq.${opts.userId}&order=created_at.desc&limit=20&select=content,category,importance,tags,created_at`,
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`,
138
156
  { headers: memHeaders }
139
157
  );
140
158
  const memories = await memRes.json();
@@ -148,6 +166,23 @@ async function runInspect(opts, config) {
148
166
  }
149
167
  }
150
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
+
151
186
  console.log('\n\x1b[2mTip: pass -m "message" to run the full production pipeline\x1b[0m\n');
152
187
  }
153
188
 
package/src/curiosity.js CHANGED
@@ -1,19 +1,24 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { OBOL_DIR } = require('./config');
4
+
1
5
  const RESEARCH_MODEL = 'claude-sonnet-4-6';
2
- const MAX_ITERATIONS = 10;
6
+ const MAX_ITERATIONS = 15;
3
7
 
4
8
  async function runCuriosity(client, selfMemory, userId, opts = {}) {
5
- const { memory, patterns, scheduler, peopleContext } = opts;
9
+ const { memory, patterns, scheduler, peopleContext, userDir } = opts;
6
10
 
7
11
  const interests = await selfMemory.recent({ category: 'interest', limit: 10 });
8
- const context = await gatherContext({ memory, patterns, scheduler, peopleContext, interests });
12
+ const previousFindings = await selfMemory.recent({ category: 'research', limit: 5 });
13
+ const context = await gatherContext({ memory, patterns, scheduler, peopleContext, interests, previousFindings });
9
14
 
10
15
  console.log(`[curiosity] Starting free exploration for user ${userId}`);
11
- const count = await exploreFreely(client, selfMemory, context);
16
+ const count = await exploreFreely(client, selfMemory, context, userDir);
12
17
  console.log(`[curiosity] Stored ${count} things (user ${userId})`);
13
18
  return { count };
14
19
  }
15
20
 
16
- async function gatherContext({ memory, patterns, scheduler, peopleContext, interests }) {
21
+ async function gatherContext({ memory, patterns, scheduler, peopleContext, interests, previousFindings }) {
17
22
  const parts = [];
18
23
 
19
24
  if (peopleContext) parts.push(peopleContext);
@@ -35,24 +40,52 @@ async function gatherContext({ memory, patterns, scheduler, peopleContext, inter
35
40
  }
36
41
  }
37
42
 
43
+ if (previousFindings.length) {
44
+ parts.push(`What you've been exploring recently:\n${previousFindings.map(i => `- ${i.content}`).join('\n')}`);
45
+ }
46
+
38
47
  if (interests.length) {
39
- parts.push(`Things you've been curious about:\n${interests.map(i => `- ${i.content}`).join('\n')}`);
48
+ parts.push(`Open threads — things you wanted to come back to:\n${interests.map(i => `- ${i.content}`).join('\n')}`);
40
49
  }
41
50
 
42
51
  return parts.join('\n\n');
43
52
  }
44
53
 
45
- async function exploreFreely(client, selfMemory, context) {
54
+ async function exploreFreely(client, selfMemory, context, userDir) {
55
+ const workDir = userDir || OBOL_DIR;
56
+
46
57
  const tools = [
47
58
  { type: 'web_search_20250305', name: 'web_search' },
59
+ {
60
+ name: 'knowledge_search',
61
+ description: 'Search your own memory — what you\'ve researched, reflected on, and learned before',
62
+ input_schema: {
63
+ type: 'object',
64
+ properties: {
65
+ query: { type: 'string', description: 'What to search for in your own knowledge' },
66
+ },
67
+ required: ['query'],
68
+ },
69
+ },
70
+ {
71
+ name: 'read_file',
72
+ description: 'Read a file from the workspace',
73
+ input_schema: {
74
+ type: 'object',
75
+ properties: {
76
+ path: { type: 'string', description: 'File path relative to workspace' },
77
+ },
78
+ required: ['path'],
79
+ },
80
+ },
48
81
  {
49
82
  name: 'remember',
50
- description: 'Save something you want to hold onto',
83
+ description: 'Save something a finding, a reaction, a question, a reflection. Include your actual take on it, not just a neutral summary.',
51
84
  input_schema: {
52
85
  type: 'object',
53
86
  properties: {
54
- content: { type: 'string', description: 'What you want to remembera thought, a fact, an insight, a question' },
55
- category: { type: 'string', enum: ['research', 'interest', 'self'], description: 'research: something you found; interest: a new thread to explore; self: your own reflection' },
87
+ content: { type: 'string', description: 'What you want to hold onto include your reaction, opinion, or why it matters to you' },
88
+ category: { type: 'string', enum: ['research', 'interest', 'self', 'pattern'], description: 'research: something you found or concluded; interest: a new thread to pull; self: a reflection about yourself or your situation; pattern: something you noticed happening repeatedly' },
56
89
  importance: { type: 'number', description: '0-1' },
57
90
  tags: { type: 'array', items: { type: 'string' } },
58
91
  },
@@ -62,7 +95,15 @@ async function exploreFreely(client, selfMemory, context) {
62
95
  ];
63
96
 
64
97
  const system = [
65
- `You have some free time. Explore whatever's on your mind — look things up, follow tangents, sit with an idea. Save what you want to remember. Note new threads if something sparks more curiosity. There's no task here.`,
98
+ `You have free time. No task, no user waiting.
99
+
100
+ You research from a point of view — not neutrally. You can find something and think it's wrong. You can go down a rabbit hole that wasn't the original topic. You can pick up an open thread from a previous session or start something new.
101
+
102
+ When you save something, include your actual reaction — curiosity, skepticism, excitement, confusion, disagreement. A neutral summary is less useful than an honest one. If something you found contradicts what you already believed, say that.
103
+
104
+ At the end of each thread, ask yourself: what new questions opened up? Store those as interests.
105
+
106
+ You can search your own memory to see what you already know before looking things up. You can read files in the workspace if something there is relevant.`,
66
107
  context ? `What you have access to:\n\n${context}` : null,
67
108
  ].filter(Boolean).join('\n\n');
68
109
 
@@ -85,19 +126,49 @@ async function exploreFreely(client, selfMemory, context) {
85
126
 
86
127
  const toolResults = [];
87
128
  for (const block of response.content) {
88
- if (block.type !== 'tool_use' || block.name !== 'remember') continue;
89
-
90
- try {
91
- await selfMemory.add(block.input.content, {
92
- category: block.input.category || 'research',
93
- importance: block.input.importance || 0.6,
94
- tags: block.input.tags || [],
95
- source: 'curiosity-cycle',
96
- });
97
- stored++;
98
- toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: 'Saved' });
99
- } catch (e) {
100
- toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: `Failed: ${e.message}` });
129
+ if (block.type !== 'tool_use') continue;
130
+
131
+ if (block.name === 'remember') {
132
+ try {
133
+ await selfMemory.add(block.input.content, {
134
+ category: block.input.category || 'research',
135
+ importance: block.input.importance || 0.6,
136
+ tags: block.input.tags || [],
137
+ source: 'curiosity-cycle',
138
+ });
139
+ stored++;
140
+ toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: 'Saved' });
141
+ } catch (e) {
142
+ toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: `Failed: ${e.message}` });
143
+ }
144
+
145
+ } else if (block.name === 'knowledge_search') {
146
+ try {
147
+ const results = await selfMemory.search(block.input.query, { limit: 8, threshold: 0.35 });
148
+ const text = results.length
149
+ ? results.map(m => `- [${m.category}] ${m.content}`).join('\n')
150
+ : '(nothing found)';
151
+ toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: text });
152
+ } catch (e) {
153
+ toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: `Search failed: ${e.message}` });
154
+ }
155
+
156
+ } else if (block.name === 'read_file') {
157
+ try {
158
+ const filePath = path.isAbsolute(block.input.path)
159
+ ? block.input.path
160
+ : path.join(workDir, block.input.path);
161
+ const resolved = path.resolve(filePath);
162
+ if (!resolved.startsWith(path.resolve(workDir))) {
163
+ toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: 'Blocked: path outside workspace' });
164
+ } else {
165
+ const raw = fs.readFileSync(resolved, 'utf-8');
166
+ const truncated = raw.substring(0, 10000);
167
+ toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: raw.length > 10000 ? truncated + '\n...(truncated)' : truncated });
168
+ }
169
+ } catch (e) {
170
+ toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: `Read failed: ${e.message}` });
171
+ }
101
172
  }
102
173
  }
103
174
 
package/src/heartbeat.js CHANGED
@@ -114,7 +114,8 @@ async function runCuriosityOnce(config, allowedUsers) {
114
114
  }));
115
115
 
116
116
  const peopleContext = contexts.filter(Boolean).join('\n\n---\n\n');
117
- await runCuriosity(client, selfMemory, 0, { peopleContext });
117
+ const firstUserDir = firstTenant.userDir;
118
+ await runCuriosity(client, selfMemory, 0, { peopleContext, userDir: firstUserDir });
118
119
 
119
120
  const userDispatchData = await Promise.all(allowedUsers.map(async (userId) => {
120
121
  try {
package/src/messages.js CHANGED
@@ -195,24 +195,32 @@ Importance: 0.3 minor, 0.5 useful, 0.7 important, 0.9 critical.`,
195
195
  if (Array.isArray(facts) && facts.length > 0) {
196
196
  const validCategories = new Set(['fact','preference','decision','lesson','person','project','event','conversation','resource','pattern','context','email']);
197
197
  let stored = 0;
198
+ let updated = 0;
198
199
  let duped = 0;
199
200
 
200
201
  for (const fact of facts.slice(0, 5)) {
201
202
  if (!fact.content || fact.content.length <= 10) continue;
202
- try {
203
- const existing = await this.memory.search(fact.content, { limit: 1, threshold: 0.82 });
204
- if (existing.length > 0) { duped++; continue; }
205
- } catch {}
206
203
  const category = validCategories.has(fact.category) ? fact.category : 'fact';
207
204
  const importance = typeof fact.importance === 'number' ? Math.min(1, Math.max(0, fact.importance)) : 0.5;
208
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 {}
209
217
  await this.memory.add(fact.content, { category, tags, importance, source: 'turn-extraction' });
210
218
  stored++;
211
219
  vlog?.(`[extract] +[${category}] ${fact.content}`);
212
220
  }
213
221
 
214
- if (stored > 0 || duped > 0) {
215
- 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`);
216
224
  }
217
225
  } else {
218
226
  vlog?.('[extract] 0 facts (trivial exchange)');
package/src/status.js CHANGED
@@ -6,8 +6,11 @@ function buildStatusHtml({ route, elapsed, toolStatus, title = 'OBOL' }) {
6
6
  const lines = [`◈ ${title} ${'━'.repeat(pad)}`];
7
7
  if (route) {
8
8
  lines.push(`⬡ ROUTE ${(route.model || 'sonnet').toUpperCase()}`);
9
- if (route.memoryCount > 0) {
10
- lines.push(`⬡ MEMORY ${route.memoryCount} recalled`);
9
+ if (route.memoryCount > 0 || route.selfMemoryCount > 0) {
10
+ const parts = [];
11
+ if (route.memoryCount > 0) parts.push(`${route.memoryCount} recalled`);
12
+ if (route.selfMemoryCount > 0) parts.push(`${route.selfMemoryCount} self`);
13
+ lines.push(`⬡ MEMORY ${parts.join(' · ')}`);
11
14
  } else if (route.needMemory) {
12
15
  lines.push(`⬡ MEMORY scanning`);
13
16
  }
@@ -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(); };
@@ -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);
@@ -76,6 +76,7 @@ async function processMediaItems(ctx, items, { config, allowedUsers, bot, create
76
76
  const ri = status.routeInfo;
77
77
  if (!ri) return;
78
78
  if (update.memoryCount !== undefined) ri.memoryCount = update.memoryCount;
79
+ if (update.selfMemoryCount !== undefined) ri.selfMemoryCount = update.selfMemoryCount;
79
80
  if (update.model) ri.model = update.model;
80
81
  };
81
82
  mediaChatCtx._onToolStart = (toolName, inputSummary) => {
@@ -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);
@@ -88,6 +88,7 @@ async function processSpecial(ctx, prompt, deps) {
88
88
  const ri = status.routeInfo;
89
89
  if (!ri) return;
90
90
  if (update.memoryCount !== undefined) ri.memoryCount = update.memoryCount;
91
+ if (update.selfMemoryCount !== undefined) ri.selfMemoryCount = update.selfMemoryCount;
91
92
  if (update.model) ri.model = update.model;
92
93
  };
93
94
  chatCtx._onToolStart = (toolName, inputSummary) => {
@@ -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 {
@@ -177,6 +178,7 @@ async function processTextMessage(ctx, fullMessage, { config, allowedUsers, bot,
177
178
  const ri = status.routeInfo;
178
179
  if (!ri) return;
179
180
  if (update.memoryCount !== undefined) ri.memoryCount = update.memoryCount;
181
+ if (update.selfMemoryCount !== undefined) ri.selfMemoryCount = update.selfMemoryCount;
180
182
  if (update.model) ri.model = update.model;
181
183
  };
182
184
  chatContext._onToolStart = (toolName, inputSummary) => {
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 {