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 +68 -17
- package/dist/memory.js +73 -0
- package/package.json +1 -1
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
|
|
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
|
-
//
|
|
22
|
-
|
|
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
|
|
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
|
|
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'
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
231
|
-
const result = await callKomilion(policy, messages
|
|
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
|
|
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
|
|
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();
|