obol-ai 0.3.52 → 0.3.53

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,12 @@
1
+ ## 0.3.53
2
+ - revert memory to conditional retrieval with router-generated queries
3
+ - skip haiku compress, pass raw dated facts to sonnet + recency scoring
4
+ - unified router: always-on hybrid retrieval + haiku compress in single call
5
+ - lower memory threshold to 0.3 and tighten budget to 20/30 for 75% recall
6
+ - use concatenated conversation context for richer memory retrieval queries
7
+ - fix scheduled agentic events to use full tool pipeline instead of bare API call
8
+ - use conversation history for memory retrieval instead of router-generated queries
9
+
1
10
  ## 0.3.47
2
11
  - fix elapsed scope bug in background task completion
3
12
  - fix TTS summary to speak in first person
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "obol-ai",
3
- "version": "0.3.52",
3
+ "version": "0.3.53",
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": {
@@ -1,18 +1,4 @@
1
- const STOPWORDS = new Set([
2
- 'i', 'me', 'my', 'we', 'our', 'you', 'your', 'he', 'she', 'it', 'they', 'them',
3
- 'a', 'an', 'the', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
4
- 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should',
5
- 'can', 'may', 'might', 'shall', 'must',
6
- 'and', 'but', 'or', 'nor', 'not', 'no', 'so', 'if', 'then', 'than',
7
- 'of', 'in', 'on', 'at', 'to', 'for', 'with', 'from', 'by', 'about', 'into',
8
- 'that', 'this', 'these', 'those', 'what', 'which', 'who', 'whom', 'whose',
9
- 'when', 'where', 'how', 'why', 'all', 'any', 'some', 'just', 'also',
10
- 'up', 'out', 'off', 'over', 'there', 'here', 'very', 'much', 'more',
11
- 'know', 'think', 'tell', 'say', 'said', 'remember', 'talk', 'talked',
12
- 'yes', 'no', 'ok', 'sure', 'yeah', 'oh', 'still', 'yet',
13
- 'did', 'does', 'doing', 'done', 'going', 'went', 'come', 'came',
14
- 'last', 'new', 'currently', 'working',
15
- ]);
1
+ const { formatMemoryBlock } = require('./prompt');
16
2
 
17
3
  function buildRouterMessages(recentHistory, userMessage) {
18
4
  const context = recentHistory.slice(-20).map(m => ({
@@ -28,27 +14,6 @@ function buildRouterMessages(recentHistory, userMessage) {
28
14
  return [...trimmed, { role: 'user', content: userMessage }];
29
15
  }
30
16
 
31
- function buildConversationQueries(recentHistory, userMessage, count = 5) {
32
- const userMsgs = recentHistory
33
- .filter(m => m.role === 'user')
34
- .map(m => typeof m.content === 'string'
35
- ? m.content
36
- : m.content.filter(b => b.type === 'text').map(b => b.text).join(''))
37
- .filter(Boolean)
38
- .slice(-count);
39
-
40
- const queries = [userMessage];
41
- if (userMsgs.length > 0) {
42
- queries.push([...userMsgs, userMessage].join('\n'));
43
- }
44
- return queries;
45
- }
46
-
47
- function extractKeywords(text) {
48
- return text.toLowerCase().split(/\W+/)
49
- .filter(w => w.length > 1 && !STOPWORDS.has(w));
50
- }
51
-
52
17
  function tokenize(s) {
53
18
  return new Set(s.toLowerCase().split(/\W+/).filter(Boolean));
54
19
  }
@@ -59,129 +24,101 @@ function jaccardFromSets(setA, setB) {
59
24
  return inter / (setA.size + setB.size - inter);
60
25
  }
61
26
 
62
- function dedup(memories, threshold) {
63
- for (const m of memories) m._tokens = tokenize(m.content);
64
- const kept = [];
65
- for (const m of memories) {
66
- const isDup = kept.some(k => jaccardFromSets(k._tokens, m._tokens) > threshold);
67
- if (!isDup) kept.push(m);
68
- }
69
- return kept;
70
- }
27
+ async function routeMessage(client, memory, userMessage, { vlog, onRouteDecision, onRouteUpdate, recentHistory = [], selfMemory = null }) {
28
+ let memoryBlock = null;
29
+ let model = null;
71
30
 
72
- async function retrieveMemories(memory, userMessage, recentHistory) {
73
- const conversationQueries = buildConversationQueries(recentHistory, userMessage);
74
- const conversationText = [...recentHistory.filter(m => m.role === 'user').map(m =>
75
- typeof m.content === 'string' ? m.content : m.content.filter(b => b.type === 'text').map(b => b.text).join('')
76
- ), userMessage].join(' ');
77
-
78
- const keywords = extractKeywords(conversationText);
79
-
80
- const semanticP = Promise.all(
81
- conversationQueries.map(q => memory.search(q, { limit: 30, threshold: 0.3 }))
82
- );
83
-
84
- const keywordP = keywords.length > 0
85
- ? Promise.all(
86
- keywords.slice(0, 3).map(kw =>
87
- memory.query({ limit: 10, filters: { content: `ilike.*${kw}*` }, order: 'importance.desc' })
88
- )
89
- )
90
- : Promise.resolve([]);
91
-
92
- const [semanticResults, keywordResults] = await Promise.all([semanticP, keywordP]);
93
-
94
- const semanticFlat = semanticResults.flat();
95
- const keywordFlat = (keywordResults || []).flat();
96
- for (const m of keywordFlat) m.similarity = m.similarity || 0.5;
97
-
98
- let fallback = [];
99
- if (semanticFlat.length < 5 && keywordFlat.length < 3) {
100
- const categories = ['person', 'preference', 'project', 'fact'];
101
- const catResults = await Promise.all(
102
- categories.map(cat => memory.query({ limit: 3, category: cat, order: 'importance.desc' }))
103
- );
104
- fallback = catResults.flat();
105
- for (const m of fallback) m.similarity = m.similarity || 0.3;
106
- }
31
+ try {
32
+ const routerDecision = await client.messages.create({
33
+ model: 'claude-haiku-4-5',
34
+ max_tokens: 200,
35
+ system: `You are a router. Analyze the conversation and decide:
107
36
 
108
- const all = [...semanticFlat, ...keywordFlat, ...fallback];
109
- const seen = new Set();
110
- const combined = [];
111
- const now = Date.now();
112
- for (const m of all) {
113
- if (!seen.has(m.id)) {
114
- seen.add(m.id);
115
- const ageDays = m.created_at ? (now - new Date(m.created_at).getTime()) / 86400000 : 30;
116
- const recency = 1 / (1 + ageDays * 0.05);
117
- const accessBoost = Math.log((m.access_count || 0) + 1) * 0.05;
118
- m._score = (m.similarity || 0.5) * 0.55 + (m.importance || 0.5) * 0.20 + recency * 0.15 + accessBoost;
119
- combined.push(m);
120
- }
121
- }
122
- combined.sort((a, b) => b._score - a._score);
37
+ 1. Does it need memory context? (past conversations, facts, preferences, people, events)
38
+ 2. What model complexity does it need?
123
39
 
124
- return dedup(combined, 0.7).slice(0, 25);
125
- }
40
+ Reply with ONLY a JSON object:
41
+ {"need_memory": true/false, "search_queries": ["query1", "query2"], "model": "sonnet|opus"}
126
42
 
127
- function formatFacts(pool) {
128
- if (pool.length === 0) return null;
129
- const now = Date.now();
130
- const lines = pool.map(m => {
131
- const age = m.created_at ? Math.round((now - new Date(m.created_at).getTime()) / 86400000) : null;
132
- const dateTag = age !== null ? (age === 0 ? 'today' : age === 1 ? 'yesterday' : `${age}d ago`) : '';
133
- return `- [${m.category}${dateTag ? '|' + dateTag : ''}] ${m.content}`;
134
- });
135
- return `## Memory recall\n${lines.join('\n')}`;
136
- }
43
+ 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.
137
44
 
138
- async function routeMessage(client, memory, userMessage, { vlog, onRouteDecision, onRouteUpdate, recentHistory = [], selfMemory = null }) {
139
- let memoryBlock = null;
140
- let model = null;
45
+ Memory: casual messages (greetings, jokes, simple questions) false. References to past, people, projects, preferences true.
141
46
 
142
- try {
143
- const [pool, routerResult] = await Promise.all([
144
- memory ? retrieveMemories(memory, userMessage, recentHistory) : [],
145
- client.messages.create({
146
- model: 'claude-haiku-4-5',
147
- max_tokens: 20,
148
- system: `You are a model router. Reply with ONLY a JSON object: {"model": "sonnet|opus"}
149
- sonnet: general conversation, code generation, content creation, explanations, tool use, most exchanges
150
- opus: professional software engineering, complex multi-step reasoning, advanced coding, architecture decisions
151
- If recent context shows ongoing opus-level work, keep using opus for follow-ups.`,
152
- messages: buildRouterMessages(recentHistory, userMessage),
153
- }),
154
- ]);
155
-
156
- vlog(`[memory] retrieved ${pool.length} facts`);
157
- memoryBlock = formatFacts(pool);
158
-
159
- const text = routerResult.content[0]?.text || '';
47
+ Model: Default to "sonnet". Use "sonnet" for: general conversation, code generation, data analysis, content creation, explanations, creative writing, agentic tool use, questions, opinions, advice, memory-dependent questions, and most exchanges. 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.
48
+
49
+ 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.`,
50
+ messages: buildRouterMessages(recentHistory, userMessage),
51
+ });
52
+
53
+ const decisionText = routerDecision.content[0]?.text || '';
160
54
  let decision = {};
161
55
  try {
162
- const jsonStr = text.match(/\{[\s\S]*\}/)?.[0];
56
+ const jsonStr = decisionText.match(/\{[\s\S]*\}/)?.[0];
163
57
  if (jsonStr) decision = JSON.parse(jsonStr);
164
58
  } catch {}
165
59
 
60
+ const queries = Array.isArray(decision.search_queries) && decision.search_queries.length > 0
61
+ ? decision.search_queries.slice(0, 3)
62
+ : decision.search_query ? [decision.search_query] : [];
63
+
166
64
  if (decision.model !== 'sonnet' && decision.model !== 'opus') {
167
65
  decision.model = 'sonnet';
168
66
  }
169
67
 
170
- if (decision.model === 'opus') {
171
- model = 'claude-opus-4-6';
172
- }
173
-
174
- vlog(`[router] model=${decision.model} memory=${pool.length} facts`);
68
+ vlog(`[router] model=${decision.model} memory=${decision.need_memory || false}${queries.length ? ` queries=${JSON.stringify(queries)}` : ''}`);
175
69
 
176
70
  onRouteDecision?.({
177
71
  model: decision.model,
178
- needMemory: pool.length > 0,
179
- memoryCount: pool.length,
72
+ needMemory: decision.need_memory || false,
73
+ memoryCount: 0,
180
74
  });
181
- onRouteUpdate?.({ memoryCount: pool.length });
182
75
 
76
+ if (decision.model === 'opus') {
77
+ model = 'claude-opus-4-6';
78
+ }
79
+
80
+ if (decision.need_memory && memory) {
81
+ const budget = decision.model === 'opus' ? 60 : 40;
82
+ const poolPerQuery = decision.model === 'opus' ? 25 : 20;
83
+ const searchQueries = queries.length > 0 ? queries : [userMessage];
84
+
85
+ const recentMemories = await memory.byDate('7d', { limit: Math.ceil(budget / 3) });
86
+
87
+ const semanticResults = await Promise.all(
88
+ searchQueries.map(q => memory.search(q, { limit: poolPerQuery, threshold: 0.4 }))
89
+ );
90
+ const semanticMemories = semanticResults.flat();
91
+
92
+ const seen = new Set();
93
+ const combined = [];
94
+ for (const m of [...recentMemories, ...semanticMemories]) {
95
+ if (!seen.has(m.id)) {
96
+ seen.add(m.id);
97
+ const ageDays = m.created_at ? (Date.now() - new Date(m.created_at).getTime()) / 86400000 : 7;
98
+ const recencyBonus = Math.max(0, 1 - ageDays / 7) * 0.3;
99
+ m._score = (m.similarity || 0.5) * 0.5 + (m.importance || 0.5) * 0.2 + recencyBonus;
100
+ combined.push(m);
101
+ }
102
+ }
103
+
104
+ combined.sort((a, b) => b._score - a._score);
105
+
106
+ for (const m of combined) m._tokens = tokenize(m.content);
107
+
108
+ const topFacts = [];
109
+ for (const m of combined) {
110
+ if (topFacts.length >= budget) break;
111
+ const isDup = topFacts.some(kept => jaccardFromSets(kept._tokens, m._tokens) > 0.7);
112
+ if (!isDup) topFacts.push(m);
113
+ }
114
+
115
+ vlog(`[memory] ${topFacts.length} facts (${recentMemories.length} recent, ${semanticMemories.length} semantic, budget=${budget})`);
116
+ onRouteUpdate?.({ memoryCount: topFacts.length });
117
+
118
+ memoryBlock = formatMemoryBlock(topFacts);
119
+ }
183
120
  } catch (e) {
184
- console.error('[router] Route/memory failed:', e.message);
121
+ console.error('[router] Memory/routing decision failed:', e.message);
185
122
  vlog(`[router] ERROR: ${e.message}`);
186
123
  }
187
124