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 +243 -17
- package/dist/index.js +1 -1
- package/dist/memory.js +73 -0
- package/package.json +1 -1
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
|
|
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
|
|
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:
|
|
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
|
-
//
|
|
82
|
-
|
|
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',
|
|
86
|
-
model:
|
|
87
|
-
costUsd: 0,
|
|
88
|
-
tokensIn
|
|
89
|
-
tokensOut
|
|
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:
|
|
93
|
-
modelId:
|
|
94
|
-
latencyMs
|
|
95
|
-
inputTokens:
|
|
96
|
-
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();
|