donna-komilion-bot 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/agent.js CHANGED
@@ -6,20 +6,21 @@
6
6
  * Policy maps directly to neo:frugal / neo:balanced / neo:premium.
7
7
  * Pinned model mode: pass pinnedModel to lock to a specific model (e.g. anthropic/claude-sonnet-4-6).
8
8
  */
9
- import { appendTurn, getTurns, createSession } from './memory.js';
9
+ import { appendTurn, getTurns, createSession, getProfile, updateProfile, searchSessionMemory } from './memory.js';
10
10
  const KOMILION_BASE = 'https://www.komilion.com/api/v1';
11
11
  // Default orchestrator model — pinned for consistent identity
12
12
  export const DONNA_ORCHESTRATOR_MODEL = 'anthropic/claude-sonnet-4-6';
13
13
  // ============================================================================
14
14
  // KOMILION API CALL
15
15
  // ============================================================================
16
- async function callKomilion(policy, messages, maxTokens = 4000, pinnedModel) {
16
+ async function callKomilion(policy, messages) {
17
17
  const apiKey = process.env.KOMILION_API_KEY;
18
18
  if (!apiKey)
19
19
  throw new Error('KOMILION_API_KEY not set');
20
20
  const startMs = Date.now();
21
- // Use pinned model if set, otherwise let Komilion Oracle route
22
- const model = pinnedModel ?? `komilion-${policy}`;
21
+ // Always use Oracle routing — never pin, never set max_tokens externally.
22
+ // Oracle handles model selection + token budget internally via estimateRequiredTokens().
23
+ const model = `komilion-${policy}`;
23
24
  const res = await fetch(`${KOMILION_BASE}/chat/completions`, {
24
25
  method: 'POST',
25
26
  headers: {
@@ -29,7 +30,6 @@ async function callKomilion(policy, messages, maxTokens = 4000, pinnedModel) {
29
30
  body: JSON.stringify({
30
31
  model,
31
32
  messages,
32
- max_tokens: maxTokens,
33
33
  }),
34
34
  });
35
35
  const latencyMs = Date.now() - startMs;
@@ -106,9 +106,37 @@ async function runSubAgent(agent, task) {
106
106
  { role: 'user', content: task },
107
107
  ];
108
108
  const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('sub-agent timeout')), 25000));
109
- const work = callKomilion('balanced', messages, 3000).then(r => r.content);
109
+ const work = callKomilion('balanced', messages).then(r => r.content);
110
110
  return Promise.race([work, timeout]).catch(() => `[${agent} timed out — proceeding without result]`);
111
111
  }
112
+ // ============================================================================
113
+ // PROFILE EXTRACTION — fire-and-forget, runs after each turn
114
+ // Cheap frugal call that builds a persistent fact sheet about the user
115
+ // ============================================================================
116
+ async function extractAndUpdateProfile(sessionId, recentTurns) {
117
+ if (recentTurns.length < 2)
118
+ return;
119
+ const turnText = recentTurns
120
+ .map(t => `${t.role}: ${t.content.slice(0, 200)}`)
121
+ .join('\n');
122
+ const messages = [
123
+ { role: 'system', content: `Extract stable facts about the user from these conversation turns.
124
+ Return a JSON object with any relevant keys from: name, job, projects, preferences, language, location, other.
125
+ Only include facts the user explicitly stated. Return {} if nothing new to add.
126
+ Example: {"name": "Anna", "language": "Swedish", "job": "developer", "projects": "building a React Native app"}` },
127
+ { role: 'user', content: turnText },
128
+ ];
129
+ try {
130
+ const result = await callKomilion('frugal', messages);
131
+ const match = result.content.match(/\{[\s\S]*\}/);
132
+ if (match) {
133
+ const facts = JSON.parse(match[0]);
134
+ if (Object.keys(facts).length > 0)
135
+ updateProfile(sessionId, facts);
136
+ }
137
+ }
138
+ catch { /* profile extraction is best-effort */ }
139
+ }
112
140
  async function classifyTask(userMessage, history) {
113
141
  const recentHistory = history.slice(-4).map(t => `${t.role}: ${t.content.slice(0, 100)}`).join('\n');
114
142
  const messages = [
@@ -136,7 +164,7 @@ DELEGATE: agent-name | refined task description` },
136
164
  { role: 'user', content: `Recent context:\n${recentHistory}\n\nNew message: ${userMessage}` },
137
165
  ];
138
166
  try {
139
- const result = await callKomilion('frugal', messages, 80);
167
+ const result = await callKomilion('frugal', messages);
140
168
  const text = result.content.trim();
141
169
  if (text.startsWith('DELEGATE:')) {
142
170
  const parts = text.slice(9).split('|');
@@ -151,11 +179,30 @@ DELEGATE: agent-name | refined task description` },
151
179
  // ============================================================================
152
180
  // DONNA AGENT TURN
153
181
  // ============================================================================
154
- export async function runTurn(sessionId, userMessage, policy = 'balanced', pinnedModel) {
182
+ export async function runTurn(sessionId, userMessage, policy = 'balanced') {
155
183
  // Ensure session exists
156
184
  createSession(sessionId, policy);
157
185
  // Build messages from memory
158
186
  const history = getTurns(sessionId, 20);
187
+ // Pull persistent user facts + any older relevant turns from FTS
188
+ const profile = getProfile(sessionId);
189
+ const profileFacts = Object.entries(profile);
190
+ let profileSection = '';
191
+ if (profileFacts.length > 0) {
192
+ profileSection = '\n\nWhat I know about this user:\n' +
193
+ profileFacts.map(([k, v]) => `- ${k}: ${v}`).join('\n');
194
+ }
195
+ let memorySection = '';
196
+ try {
197
+ const recentIds = new Set(history.map(t => t.id));
198
+ const relevant = searchSessionMemory(sessionId, userMessage, 5);
199
+ const older = relevant.filter(t => !recentIds.has(t.id)).slice(0, 3);
200
+ if (older.length > 0) {
201
+ memorySection = '\n\n[Recalled from earlier in our conversation]\n' +
202
+ older.map(t => `${t.role}: ${t.content.slice(0, 400)}`).join('\n');
203
+ }
204
+ }
205
+ catch { /* FTS failures are non-fatal */ }
159
206
  const messages = [
160
207
  { role: 'system', content: `You are Donna — the orchestrator. Harvey's Chief of Staff. You do not execute tasks yourself. You think, decide, and delegate.
161
208
 
@@ -183,11 +230,12 @@ Your personality:
183
230
  - No numbered lists, no corporate fluff. One answer, not five options.
184
231
  - You push back when Harvey is wrong. That is part of your job.
185
232
  - You route through Komilion's Oracle engine (via komilion.com/api/v1).
186
- - Language: match Harvey — Swedish or English.` },
233
+ - Language: match Harvey — Swedish or English.${profileSection}${memorySection}` },
187
234
  ...history.map(t => ({ role: t.role, content: t.content })),
188
235
  { role: 'user', content: userMessage },
189
236
  ];
190
- // Pre-routing: classify before running Donna — cheap Flash call
237
+ // Pre-routing: classify + estimate token budget in parallel both cheap Flash calls
238
+ // Pre-routing: classify task — Oracle handles everything else (model + token budget)
191
239
  const route = await classifyTask(userMessage, history);
192
240
  let finalContent;
193
241
  let usedModel;
@@ -205,7 +253,7 @@ Your personality:
205
253
  }
206
254
  // If agent returned nothing useful, fall through to direct
207
255
  if (!agentOutput || agentOutput === '[agent unavailable]') {
208
- const result = await callKomilion(policy, messages, 300, pinnedModel);
256
+ const result = await callKomilion(policy, messages);
209
257
  finalContent = result.content;
210
258
  usedModel = result.modelId;
211
259
  latencyMs = result.latencyMs;
@@ -216,9 +264,9 @@ Your personality:
216
264
  // Donna synthesizes — owns it completely, sub-agent invisible to Harvey
217
265
  const synthesisMessages = [
218
266
  ...messages,
219
- { role: 'user', content: `[INTERNAL — Harvey does not see this] Your specialist completed the task and returned:\n\n${agentOutput}\n\n---\nNow respond to Harvey AS DONNA. Do NOT say 'my specialist found' or 'the agent returned'. Own it entirely. Your voice — sharp, direct, warm. Add your judgment. Cut anything weak. Harvey hears only you. Under 300 words.` },
267
+ { role: 'user', content: `[INTERNAL — Harvey does not see this] Your specialist completed the task and returned:\n\n${agentOutput}\n\n---\nNow respond to Harvey AS DONNA. Do NOT say 'my specialist found' or 'the agent returned'. Own it entirely. Your voice — sharp, direct, warm. Add your judgment. Cut anything weak. Harvey hears only you.` },
220
268
  ];
221
- const synthesis = await callKomilion(policy, synthesisMessages, 400, pinnedModel);
269
+ const synthesis = await callKomilion(policy, synthesisMessages);
222
270
  finalContent = synthesis.content;
223
271
  usedModel = synthesis.modelId;
224
272
  latencyMs = synthesis.latencyMs;
@@ -227,8 +275,8 @@ Your personality:
227
275
  }
228
276
  }
229
277
  else {
230
- // === DIRECT PATH: Donna answers herself capped at 300 tokens ===
231
- const result = await callKomilion(policy, messages, 300, pinnedModel);
278
+ // === DIRECT PATH: Oracle decides model + token budget ===
279
+ const result = await callKomilion(policy, messages);
232
280
  finalContent = result.content;
233
281
  usedModel = result.modelId;
234
282
  latencyMs = result.latencyMs;
@@ -248,9 +296,9 @@ Your personality:
248
296
  const synthesisMessages = [
249
297
  ...messages,
250
298
  { role: 'assistant', content: result.content },
251
- { role: 'user', content: `[INTERNAL] Agents returned:\n\n${outputs.join('\n\n')}\n\n---\nRespond AS DONNA. Own it. Your voice only. Under 300 words.` },
299
+ { role: 'user', content: `[INTERNAL] Agents returned:\n\n${outputs.join('\n\n')}\n\n---\nRespond AS DONNA. Own it. Your voice only.` },
252
300
  ];
253
- const synthesis = await callKomilion(policy, synthesisMessages, 400, pinnedModel);
301
+ const synthesis = await callKomilion(policy, synthesisMessages);
254
302
  finalContent = synthesis.content;
255
303
  }
256
304
  }
@@ -263,6 +311,9 @@ Your personality:
263
311
  tokensIn,
264
312
  tokensOut,
265
313
  });
314
+ // Fire-and-forget profile extraction — runs after response sent, no latency impact
315
+ const recentForProfile = [...history.slice(-5), { role: 'user', content: userMessage }, { role: 'assistant', content: finalContent }];
316
+ extractAndUpdateProfile(sessionId, recentForProfile).catch(() => { });
266
317
  return {
267
318
  content: finalContent,
268
319
  modelId: usedModel,
package/dist/memory.js CHANGED
@@ -68,6 +68,14 @@ function initSchema(db) {
68
68
  total_cost REAL NOT NULL DEFAULT 0
69
69
  );
70
70
  `);
71
+ // Profiles table — persistent facts extracted about each user
72
+ db.exec(`
73
+ CREATE TABLE IF NOT EXISTS profiles (
74
+ session_id TEXT PRIMARY KEY,
75
+ facts TEXT NOT NULL DEFAULT '{}',
76
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
77
+ );
78
+ `);
71
79
  }
72
80
  // ============================================================================
73
81
  // API
@@ -174,6 +182,71 @@ export function clearSession(sessionId) {
174
182
  const db = getDb();
175
183
  db.prepare(`DELETE FROM turns WHERE session_id = ?`).run(sessionId);
176
184
  }
185
+ // ============================================================================
186
+ // PROFILE — persistent user facts, extracted and merged over time
187
+ // ============================================================================
188
+ export function getProfile(sessionId) {
189
+ const db = getDb();
190
+ const row = db.prepare(`SELECT facts FROM profiles WHERE session_id = ?`).get(sessionId);
191
+ if (!row)
192
+ return {};
193
+ try {
194
+ return JSON.parse(row.facts);
195
+ }
196
+ catch {
197
+ return {};
198
+ }
199
+ }
200
+ export function updateProfile(sessionId, newFacts) {
201
+ const db = getDb();
202
+ const existing = getProfile(sessionId);
203
+ const merged = { ...existing, ...newFacts };
204
+ db.prepare(`
205
+ INSERT INTO profiles (session_id, facts, updated_at)
206
+ VALUES (?, ?, datetime('now'))
207
+ ON CONFLICT(session_id) DO UPDATE SET facts = excluded.facts, updated_at = excluded.updated_at
208
+ `).run(sessionId, JSON.stringify(merged));
209
+ }
210
+ // FTS search scoped to a single session — surfaces relevant older turns
211
+ // that have scrolled out of the 20-turn window
212
+ export function searchSessionMemory(sessionId, query, limit = 5) {
213
+ const db = getDb();
214
+ // Sanitize query for FTS5: keep words >2 chars, drop special chars
215
+ const ftsQuery = query
216
+ .replace(/['"*()\[\]{}:^~]/g, ' ')
217
+ .split(/\s+/)
218
+ .filter(w => w.length > 2)
219
+ .slice(0, 10)
220
+ .join(' ');
221
+ if (!ftsQuery)
222
+ return [];
223
+ try {
224
+ const rows = db.prepare(`
225
+ SELECT t.id, t.session_id, t.role, t.content, t.model, t.cost_usd, t.tokens_in, t.tokens_out, t.created_at,
226
+ snippet(turns_fts, 0, '**', '**', '…', 20) AS snippet
227
+ FROM turns_fts
228
+ JOIN turns t ON t.id = turns_fts.turn_id
229
+ WHERE turns_fts MATCH ? AND t.session_id = ?
230
+ ORDER BY turns_fts.rank
231
+ LIMIT ?
232
+ `).all(ftsQuery, sessionId, limit);
233
+ return rows.map(r => ({
234
+ id: r.id,
235
+ sessionId: r.session_id,
236
+ role: r.role,
237
+ content: r.content,
238
+ model: r.model ?? undefined,
239
+ costUsd: r.cost_usd ?? undefined,
240
+ tokensIn: r.tokens_in ?? undefined,
241
+ tokensOut: r.tokens_out ?? undefined,
242
+ createdAt: r.created_at,
243
+ snippet: r.snippet,
244
+ }));
245
+ }
246
+ catch {
247
+ return [];
248
+ }
249
+ }
177
250
  export function closeDb() {
178
251
  if (_db) {
179
252
  _db.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "donna-komilion-bot",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
4
4
  "description": "Donna orchestrator bot — routes via komilion.com, SQLite memory, tmux agent spawning",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",