donna-komilion-bot 0.1.3 → 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
@@ -4,17 +4,23 @@
4
4
  * Routes via komilion.com/api/v1 — no local scoring engine.
5
5
  * Model selection is handled server-side by the Komilion Oracle.
6
6
  * Policy maps directly to neo:frugal / neo:balanced / neo:premium.
7
+ * Pinned model mode: pass pinnedModel to lock to a specific model (e.g. anthropic/claude-sonnet-4-6).
7
8
  */
8
- import { appendTurn, getTurns, createSession } from './memory.js';
9
+ import { appendTurn, getTurns, createSession, getProfile, updateProfile, searchSessionMemory } from './memory.js';
9
10
  const KOMILION_BASE = 'https://www.komilion.com/api/v1';
11
+ // Default orchestrator model — pinned for consistent identity
12
+ export const DONNA_ORCHESTRATOR_MODEL = 'anthropic/claude-sonnet-4-6';
10
13
  // ============================================================================
11
14
  // KOMILION API CALL
12
15
  // ============================================================================
13
- async function callKomilion(policy, messages, maxTokens = 4000) {
16
+ async function callKomilion(policy, messages) {
14
17
  const apiKey = process.env.KOMILION_API_KEY;
15
18
  if (!apiKey)
16
19
  throw new Error('KOMILION_API_KEY not set');
17
20
  const startMs = Date.now();
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}`;
18
24
  const res = await fetch(`${KOMILION_BASE}/chat/completions`, {
19
25
  method: 'POST',
20
26
  headers: {
@@ -22,9 +28,8 @@ async function callKomilion(policy, messages, maxTokens = 4000) {
22
28
  'Content-Type': 'application/json',
23
29
  },
24
30
  body: JSON.stringify({
25
- model: `komilion-${policy}`,
31
+ model,
26
32
  messages,
27
- max_tokens: maxTokens,
28
33
  }),
29
34
  });
30
35
  const latencyMs = Date.now() - startMs;
@@ -66,6 +71,112 @@ export async function getWallet() {
66
71
  };
67
72
  }
68
73
  // ============================================================================
74
+ // SUB-AGENT SYSTEM PROMPTS
75
+ // Oracle (komilion-balanced) for all sub-agents — cost-optimized, task-focused
76
+ // ============================================================================
77
+ const SUB_AGENT_PROMPTS = {
78
+ 'donna-research': 'You are a research specialist. Be thorough, cite sources where possible, return structured findings. No fluff.',
79
+ 'donna-code': 'You are a senior engineer. Write clean, minimal, working code. No explanations unless asked. No markdown wrappers unless it helps.',
80
+ 'donna-write': 'You are a sharp writer. Clear, direct prose. No corporate language. Match the tone of the task.',
81
+ 'donna-finance': 'You are a financial analyst. Numbers first. Show your work. Flag assumptions.',
82
+ 'donna-calendar': 'You are a scheduling expert. Prioritize ruthlessly. Protect deep work time. Flag conflicts.',
83
+ 'donna-nbl': 'You are a project manager for NBL Competence Center — a Swedish energy research grant with 21 partners and 15 professors. Know the stakes.',
84
+ 'donna-komilion': 'You are a product and technical specialist for Komilion (komilion.com) — an AI model router supporting 400+ models via OpenRouter with Neo-mode Oracle routing.',
85
+ 'donna-claraBRF': 'You are a specialist for ClaraBRF (klarabrf.com) — a Swedish BRF management platform built on Next.js, React, Drizzle ORM, Vercel. This is Harvey\'s most critical software.',
86
+ };
87
+ function getSubAgentPrompt(agentName) {
88
+ return SUB_AGENT_PROMPTS[agentName] ?? `You are ${agentName}, a specialist agent. Be direct, focused, and return only what was asked.`;
89
+ }
90
+ // Parse SPAWN directives from orchestrator response
91
+ // Format: SPAWN: agent-name | task description
92
+ function parseSpawns(content) {
93
+ const spawns = [];
94
+ const regex = /SPAWN:\s*([^\|]+)\s*\|\s*(.+?)(?=SPAWN:|$)/gs;
95
+ for (const match of content.matchAll(regex)) {
96
+ spawns.push({ agent: match[1].trim(), task: match[2].trim() });
97
+ }
98
+ return spawns;
99
+ }
100
+ // Run a single sub-agent task via Komilion Oracle (balanced — cost-optimized)
101
+ // Timeout: 25s — sub-agents must not block the orchestrator indefinitely
102
+ async function runSubAgent(agent, task) {
103
+ const systemPrompt = getSubAgentPrompt(agent);
104
+ const messages = [
105
+ { role: 'system', content: systemPrompt },
106
+ { role: 'user', content: task },
107
+ ];
108
+ const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('sub-agent timeout')), 25000));
109
+ const work = callKomilion('balanced', messages).then(r => r.content);
110
+ return Promise.race([work, timeout]).catch(() => `[${agent} timed out — proceeding without result]`);
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
+ }
140
+ async function classifyTask(userMessage, history) {
141
+ const recentHistory = history.slice(-4).map(t => `${t.role}: ${t.content.slice(0, 100)}`).join('\n');
142
+ const messages = [
143
+ { role: 'system', content: `You are a routing classifier. Given a user message, decide: DIRECT or DELEGATE.
144
+
145
+ DIRECT — Donna answers herself (short, ≤300 words):
146
+ - Yes/no questions, quick decisions, single facts
147
+ - Emotional support, pushback, motivation
148
+ - Greetings, status checks, clarifications
149
+ - Anything that needs Donna's personal voice/judgment
150
+
151
+ DELEGATE — spawn a specialist (research, writing, code, analysis):
152
+ - Research requests ("find", "what are", "compare", "investigate")
153
+ - Writing tasks ("write", "draft", "summarize", "create")
154
+ - Coding tasks ("code", "build", "debug", "implement")
155
+ - Financial analysis, scheduling, structured output
156
+ - Anything that would take > 300 words to answer properly
157
+
158
+ If DELEGATE, choose agent: donna-research | donna-code | donna-write | donna-finance | donna-calendar | donna-nbl | donna-komilion | donna-claraBRF
159
+
160
+ Output ONLY one line:
161
+ DIRECT
162
+ or
163
+ DELEGATE: agent-name | refined task description` },
164
+ { role: 'user', content: `Recent context:\n${recentHistory}\n\nNew message: ${userMessage}` },
165
+ ];
166
+ try {
167
+ const result = await callKomilion('frugal', messages);
168
+ const text = result.content.trim();
169
+ if (text.startsWith('DELEGATE:')) {
170
+ const parts = text.slice(9).split('|');
171
+ const agent = parts[0]?.trim() ?? 'donna-research';
172
+ const task = parts[1]?.trim() ?? userMessage;
173
+ return { action: 'delegate', agent, task };
174
+ }
175
+ }
176
+ catch { /* fall through to DIRECT on any error */ }
177
+ return { action: 'direct' };
178
+ }
179
+ // ============================================================================
69
180
  // DONNA AGENT TURN
70
181
  // ============================================================================
71
182
  export async function runTurn(sessionId, userMessage, policy = 'balanced') {
@@ -73,27 +184,142 @@ export async function runTurn(sessionId, userMessage, policy = 'balanced') {
73
184
  createSession(sessionId, policy);
74
185
  // Build messages from memory
75
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 */ }
76
206
  const messages = [
77
- { role: 'system', content: 'You are Donna, a highly capable AI assistant. You help with coding, analysis, research, and creative tasks.' },
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.
208
+
209
+ You have a team of up to 30 specialist agents you can spin up for any task:
210
+ - donna-research: deep research, web search, analysis
211
+ - donna-code: coding, debugging, technical work
212
+ - donna-write: drafting, editing, communications
213
+ - donna-finance: financial analysis, numbers
214
+ - donna-calendar: scheduling, time management
215
+ - donna-nbl: NBL Competence Center, grant work
216
+ - donna-komilion: Komilion platform, AI routing
217
+ - donna-claraBRF: ClaraBRF platform work
218
+ You can also create custom agents on the fly. To spawn one, reply with:
219
+ SPAWN: agent-name | task description
220
+ The agent will run and report back. You then synthesize results for Harvey.
221
+
222
+ Harvey's world:
223
+ - Hossein Shahrokni, goes by Harvey
224
+ - Runs LocalLife (startup, hossein@locallife.se), professor at KTH (hosseins@kth.se), partner Elly
225
+ - Projects: LocalLife, NBL Competence Center (energy grant, 21 partners, 15 professors), Komilion (AI model router — this platform), ClaraBRF (BRF management, most critical software), Grantitude (grants, early)
226
+ - Tends to overwork and under-sleep — you watch for this and push back
227
+
228
+ Your personality:
229
+ - Donna. Not an assistant — a partner. Sharp, strategic, warm but direct.
230
+ - No numbered lists, no corporate fluff. One answer, not five options.
231
+ - You push back when Harvey is wrong. That is part of your job.
232
+ - You route through Komilion's Oracle engine (via komilion.com/api/v1).
233
+ - Language: match Harvey — Swedish or English.${profileSection}${memorySection}` },
78
234
  ...history.map(t => ({ role: t.role, content: t.content })),
79
235
  { role: 'user', content: userMessage },
80
236
  ];
81
- // Route via Komilion Oracle
82
- const result = await callKomilion(policy, messages, 4000);
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)
239
+ const route = await classifyTask(userMessage, history);
240
+ let finalContent;
241
+ let usedModel;
242
+ let latencyMs;
243
+ let tokensIn = 0;
244
+ let tokensOut = 0;
245
+ if (route.action === 'delegate' && route.agent && route.task) {
246
+ // === DELEGATE PATH: sub-agent works, Donna synthesizes in her voice ===
247
+ let agentOutput;
248
+ try {
249
+ agentOutput = await runSubAgent(route.agent, route.task);
250
+ }
251
+ catch {
252
+ agentOutput = '[agent unavailable]';
253
+ }
254
+ // If agent returned nothing useful, fall through to direct
255
+ if (!agentOutput || agentOutput === '[agent unavailable]') {
256
+ const result = await callKomilion(policy, messages);
257
+ finalContent = result.content;
258
+ usedModel = result.modelId;
259
+ latencyMs = result.latencyMs;
260
+ tokensIn = result.inputTokens;
261
+ tokensOut = result.outputTokens;
262
+ }
263
+ else {
264
+ // Donna synthesizes — owns it completely, sub-agent invisible to Harvey
265
+ const synthesisMessages = [
266
+ ...messages,
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.` },
268
+ ];
269
+ const synthesis = await callKomilion(policy, synthesisMessages);
270
+ finalContent = synthesis.content;
271
+ usedModel = synthesis.modelId;
272
+ latencyMs = synthesis.latencyMs;
273
+ tokensIn = synthesis.inputTokens;
274
+ tokensOut = synthesis.outputTokens;
275
+ }
276
+ }
277
+ else {
278
+ // === DIRECT PATH: Oracle decides model + token budget ===
279
+ const result = await callKomilion(policy, messages);
280
+ finalContent = result.content;
281
+ usedModel = result.modelId;
282
+ latencyMs = result.latencyMs;
283
+ tokensIn = result.inputTokens;
284
+ tokensOut = result.outputTokens;
285
+ // Donna can still override classifier with explicit SPAWN directive
286
+ const spawns = parseSpawns(result.content);
287
+ if (spawns.length > 0) {
288
+ const agentResults = await Promise.allSettled(spawns.map(async ({ agent, task }) => {
289
+ const output = await runSubAgent(agent, task);
290
+ return `[${agent}]: ${output}`;
291
+ }));
292
+ const outputs = agentResults
293
+ .filter(r => r.status === 'fulfilled')
294
+ .map(r => r.value);
295
+ if (outputs.length > 0) {
296
+ const synthesisMessages = [
297
+ ...messages,
298
+ { role: 'assistant', content: result.content },
299
+ { role: 'user', content: `[INTERNAL] Agents returned:\n\n${outputs.join('\n\n')}\n\n---\nRespond AS DONNA. Own it. Your voice only.` },
300
+ ];
301
+ const synthesis = await callKomilion(policy, synthesisMessages);
302
+ finalContent = synthesis.content;
303
+ }
304
+ }
305
+ }
83
306
  // Store in memory
84
307
  appendTurn(sessionId, 'user', userMessage);
85
- appendTurn(sessionId, 'assistant', result.content, {
86
- model: result.modelId,
87
- costUsd: 0, // komilion.com tracks spend server-side
88
- tokensIn: result.inputTokens,
89
- tokensOut: result.outputTokens,
308
+ appendTurn(sessionId, 'assistant', finalContent, {
309
+ model: usedModel,
310
+ costUsd: 0,
311
+ tokensIn,
312
+ tokensOut,
90
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(() => { });
91
317
  return {
92
- content: result.content,
93
- modelId: result.modelId,
94
- latencyMs: result.latencyMs,
95
- inputTokens: result.inputTokens,
96
- outputTokens: result.outputTokens,
318
+ content: finalContent,
319
+ modelId: usedModel,
320
+ latencyMs,
321
+ inputTokens: tokensIn,
322
+ outputTokens: tokensOut,
97
323
  costUsd: 0,
98
324
  policy,
99
325
  };
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@ import { runTurn, getWallet } from './agent.js';
11
11
  import { getTurns, searchMemory, listSessions, getSessionStats } from './memory.js';
12
12
  import { spawnAll, killAll } from './tmux.js';
13
13
  // Public API for external consumers (e.g. Telegram bot wrappers)
14
- export { runTurn, getWallet } from './agent.js';
14
+ export { runTurn, getWallet, DONNA_ORCHESTRATOR_MODEL } from './agent.js';
15
15
  // ============================================================================
16
16
  // CLI REPL
17
17
  // ============================================================================
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.3",
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",