@davvidbaker/flambe-cli 0.1.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.
@@ -0,0 +1,739 @@
1
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
+ import { mkdirSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+ import { DatabaseSync } from 'node:sqlite';
5
+
6
+ import { agentDisplayName } from './agents.mjs';
7
+
8
+ const SCHEMA = `
9
+ PRAGMA foreign_keys = ON;
10
+
11
+ CREATE TABLE IF NOT EXISTS users (
12
+ id INTEGER PRIMARY KEY,
13
+ name TEXT NOT NULL,
14
+ username TEXT NOT NULL UNIQUE
15
+ );
16
+
17
+ CREATE TABLE IF NOT EXISTS api_tokens (
18
+ id INTEGER PRIMARY KEY,
19
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
20
+ name TEXT NOT NULL,
21
+ token_hash TEXT NOT NULL UNIQUE,
22
+ raw_token TEXT NOT NULL
23
+ );
24
+
25
+ CREATE TABLE IF NOT EXISTS traces (
26
+ id INTEGER PRIMARY KEY,
27
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
28
+ name TEXT NOT NULL,
29
+ export_id TEXT NOT NULL UNIQUE,
30
+ UNIQUE (user_id, name)
31
+ );
32
+
33
+ CREATE TABLE IF NOT EXISTS threads (
34
+ id INTEGER PRIMARY KEY,
35
+ trace_id INTEGER NOT NULL REFERENCES traces(id) ON DELETE CASCADE,
36
+ name TEXT NOT NULL,
37
+ rank INTEGER NOT NULL DEFAULT 0,
38
+ export_id TEXT NOT NULL UNIQUE
39
+ );
40
+
41
+ CREATE TABLE IF NOT EXISTS categories (
42
+ id INTEGER PRIMARY KEY,
43
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
44
+ name TEXT NOT NULL,
45
+ color_background TEXT NOT NULL,
46
+ color_text TEXT,
47
+ export_id TEXT NOT NULL UNIQUE,
48
+ UNIQUE (user_id, name)
49
+ );
50
+
51
+ CREATE TABLE IF NOT EXISTS activities (
52
+ id INTEGER PRIMARY KEY,
53
+ thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
54
+ parent_id INTEGER REFERENCES activities(id) ON DELETE SET NULL,
55
+ name TEXT NOT NULL,
56
+ description TEXT,
57
+ weight INTEGER,
58
+ agent_id TEXT,
59
+ agent_name TEXT,
60
+ export_id TEXT NOT NULL UNIQUE
61
+ );
62
+
63
+ CREATE TABLE IF NOT EXISTS activities_categories (
64
+ activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
65
+ category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
66
+ PRIMARY KEY (activity_id, category_id)
67
+ );
68
+
69
+ CREATE TABLE IF NOT EXISTS events (
70
+ id INTEGER PRIMARY KEY,
71
+ trace_id INTEGER NOT NULL REFERENCES traces(id) ON DELETE CASCADE,
72
+ activity_id INTEGER REFERENCES activities(id) ON DELETE CASCADE,
73
+ timestamp_ms INTEGER NOT NULL,
74
+ phase TEXT NOT NULL,
75
+ message TEXT,
76
+ export_id TEXT NOT NULL UNIQUE
77
+ );
78
+
79
+ CREATE TABLE IF NOT EXISTS todos (
80
+ id INTEGER PRIMARY KEY,
81
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
82
+ name TEXT NOT NULL,
83
+ description TEXT
84
+ );
85
+
86
+ CREATE TABLE IF NOT EXISTS mantras (
87
+ id INTEGER PRIMARY KEY,
88
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
89
+ name TEXT NOT NULL,
90
+ timestamp_ms INTEGER NOT NULL
91
+ );
92
+
93
+ CREATE TABLE IF NOT EXISTS attentions (
94
+ id INTEGER PRIMARY KEY,
95
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
96
+ thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
97
+ timestamp_ms INTEGER NOT NULL
98
+ );
99
+
100
+ CREATE TABLE IF NOT EXISTS tabs (
101
+ id INTEGER PRIMARY KEY,
102
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
103
+ count INTEGER NOT NULL,
104
+ window_count INTEGER,
105
+ timestamp_ms INTEGER NOT NULL
106
+ );
107
+
108
+ CREATE TABLE IF NOT EXISTS search_terms (
109
+ id INTEGER PRIMARY KEY,
110
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
111
+ term TEXT NOT NULL,
112
+ timestamp_ms INTEGER NOT NULL
113
+ );
114
+
115
+ CREATE TABLE IF NOT EXISTS observations (
116
+ id INTEGER PRIMARY KEY,
117
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
118
+ kind TEXT NOT NULL,
119
+ value REAL NOT NULL,
120
+ unit TEXT,
121
+ payload TEXT NOT NULL DEFAULT '{}',
122
+ observed_on TEXT,
123
+ timestamp_ms INTEGER NOT NULL
124
+ );
125
+ `;
126
+
127
+ export const EXPORT_FORMAT = 'flambe-local-export';
128
+ export const EXPORT_VERSION = 1;
129
+
130
+ export function hashToken(rawToken) {
131
+ return createHash('sha256').update(rawToken).digest('hex');
132
+ }
133
+
134
+ export function generateToken() {
135
+ return `flb_${randomBytes(32).toString('base64url')}`;
136
+ }
137
+
138
+ export function isoFromMs(ms) {
139
+ const date = new Date(ms);
140
+ const [whole, fraction = '000'] = date.toISOString().slice(0, -1).split('.');
141
+ return `${whole}.${fraction.padEnd(6, '0')}Z`;
142
+ }
143
+
144
+ export class LocalStore {
145
+ constructor(dbPath) {
146
+ mkdirSync(dirname(dbPath), { recursive: true });
147
+ this.db = new DatabaseSync(dbPath);
148
+ this.db.exec('PRAGMA foreign_keys = ON');
149
+ this.db.exec(SCHEMA);
150
+ this.#seed();
151
+ }
152
+
153
+ close() {
154
+ this.db.close();
155
+ }
156
+
157
+ credentials() {
158
+ const user = this.db.prepare('SELECT * FROM users WHERE id = 1').get();
159
+ const token = this.db.prepare('SELECT raw_token FROM api_tokens WHERE user_id = 1 ORDER BY id LIMIT 1').get();
160
+ const trace = this.db.prepare('SELECT id, name FROM traces WHERE user_id = 1 ORDER BY id LIMIT 1').get();
161
+ return {
162
+ user,
163
+ rawToken: token.raw_token,
164
+ traceId: trace.id,
165
+ traceName: trace.name,
166
+ };
167
+ }
168
+
169
+ userById(id) {
170
+ return this.db.prepare('SELECT * FROM users WHERE id = ?').get(Number(id)) ?? null;
171
+ }
172
+
173
+ authenticateToken(rawToken) {
174
+ if (typeof rawToken !== 'string' || !rawToken.startsWith('flb_')) return null;
175
+ const row = this.db.prepare(`
176
+ SELECT api_tokens.*, users.id AS user_id, users.name AS user_name, users.username
177
+ FROM api_tokens
178
+ JOIN users ON users.id = api_tokens.user_id
179
+ WHERE token_hash = ?
180
+ `).get(hashToken(rawToken));
181
+ if (!row) return null;
182
+ return {
183
+ user: { id: row.user_id, name: row.user_name, username: row.username },
184
+ tokenName: row.name,
185
+ };
186
+ }
187
+
188
+ listTraces(userId) {
189
+ return this.db.prepare(
190
+ 'SELECT id, name FROM traces WHERE user_id = ? ORDER BY id',
191
+ ).all(userId).map(row => ({ id: row.id, name: row.name }));
192
+ }
193
+
194
+ getTrace(userId, traceId) {
195
+ const trace = this.db.prepare(
196
+ 'SELECT * FROM traces WHERE user_id = ? AND id = ?',
197
+ ).get(userId, Number(traceId));
198
+ if (!trace) return null;
199
+ const threads = this.db.prepare(
200
+ 'SELECT id, name, rank FROM threads WHERE trace_id = ? ORDER BY rank, id',
201
+ ).all(trace.id);
202
+ const events = this.db.prepare(`
203
+ SELECT events.*, activities.name AS activity_name, activities.parent_id,
204
+ activities.thread_id, activities.weight, activities.agent_id, activities.agent_name,
205
+ activities.description AS activity_description
206
+ FROM events
207
+ LEFT JOIN activities ON activities.id = events.activity_id
208
+ WHERE events.trace_id = ?
209
+ ORDER BY events.timestamp_ms, events.id
210
+ `).all(trace.id);
211
+
212
+ return {
213
+ id: trace.id,
214
+ name: trace.name,
215
+ threads: threads.map(thread => ({ id: thread.id, name: thread.name, rank: thread.rank })),
216
+ events: events.map(event => this.#eventPayload(event)),
217
+ };
218
+ }
219
+
220
+ createTrace(userId, name) {
221
+ const result = this.db.prepare(
222
+ 'INSERT INTO traces (user_id, name, export_id) VALUES (?, ?, ?)',
223
+ ).run(userId, name, randomUUID());
224
+ const traceId = Number(result.lastInsertRowid);
225
+ this.db.prepare(
226
+ 'INSERT INTO threads (trace_id, name, rank, export_id) VALUES (?, ?, 0, ?)',
227
+ ).run(traceId, 'Main', randomUUID());
228
+ return this.getTrace(userId, traceId);
229
+ }
230
+
231
+ updateTrace(userId, traceId, attrs) {
232
+ if (attrs?.name) {
233
+ this.db.prepare('UPDATE traces SET name = ? WHERE user_id = ? AND id = ?')
234
+ .run(attrs.name, userId, Number(traceId));
235
+ }
236
+ return this.getTrace(userId, traceId);
237
+ }
238
+
239
+ deleteTrace(userId, traceId) {
240
+ const result = this.db.prepare('DELETE FROM traces WHERE user_id = ? AND id = ?')
241
+ .run(userId, Number(traceId));
242
+ return result.changes > 0;
243
+ }
244
+
245
+ reorderThreads(userId, traceId, threadIds) {
246
+ const trace = this.getTrace(userId, traceId);
247
+ if (!trace) return { error: 'not_found' };
248
+ const existing = trace.threads.map(thread => thread.id);
249
+ const incoming = threadIds.map(Number);
250
+ if (incoming.length !== existing.length || new Set(incoming).size !== existing.length
251
+ || incoming.some(id => !existing.includes(id))) {
252
+ return { error: 'invalid' };
253
+ }
254
+ incoming.forEach((id, rank) => {
255
+ this.db.prepare('UPDATE threads SET rank = ? WHERE id = ?').run(rank, id);
256
+ });
257
+ return { threads: this.getTrace(userId, traceId).threads };
258
+ }
259
+
260
+ createThread(userId, traceId, attrs) {
261
+ const trace = this.db.prepare('SELECT id FROM traces WHERE user_id = ? AND id = ?')
262
+ .get(userId, Number(traceId));
263
+ if (!trace) return null;
264
+ const rank = Number.isInteger(attrs?.rank) ? attrs.rank : 0;
265
+ const result = this.db.prepare(
266
+ 'INSERT INTO threads (trace_id, name, rank, export_id) VALUES (?, ?, ?, ?)',
267
+ ).run(trace.id, attrs.name, rank, randomUUID());
268
+ return this.db.prepare('SELECT id, name, rank FROM threads WHERE id = ?')
269
+ .get(Number(result.lastInsertRowid));
270
+ }
271
+
272
+ getThread(userId, threadId) {
273
+ return this.db.prepare(`
274
+ SELECT threads.id, threads.name, threads.rank
275
+ FROM threads
276
+ JOIN traces ON traces.id = threads.trace_id
277
+ WHERE threads.id = ? AND traces.user_id = ?
278
+ `).get(Number(threadId), userId) ?? null;
279
+ }
280
+
281
+ updateThread(userId, threadId, attrs) {
282
+ const thread = this.getThread(userId, threadId);
283
+ if (!thread) return null;
284
+ this.db.prepare('UPDATE threads SET name = COALESCE(?, name), rank = COALESCE(?, rank) WHERE id = ?')
285
+ .run(attrs.name ?? null, attrs.rank ?? null, thread.id);
286
+ return this.getThread(userId, threadId);
287
+ }
288
+
289
+ deleteThread(userId, threadId) {
290
+ const thread = this.getThread(userId, threadId);
291
+ if (!thread) return false;
292
+ this.db.prepare('DELETE FROM threads WHERE id = ?').run(thread.id);
293
+ return true;
294
+ }
295
+
296
+ listCategories(userId) {
297
+ return this.db.prepare(
298
+ 'SELECT id, name, color_background, color_text FROM categories WHERE user_id = ? ORDER BY id',
299
+ ).all(userId);
300
+ }
301
+
302
+ createCategory(userId, attrs, activityIds = []) {
303
+ const result = this.db.prepare(
304
+ 'INSERT INTO categories (user_id, name, color_background, color_text, export_id) VALUES (?, ?, ?, ?, ?)',
305
+ ).run(userId, attrs.name, attrs.color_background, attrs.color_text ?? '#000000', randomUUID());
306
+ const id = Number(result.lastInsertRowid);
307
+ for (const activityId of activityIds) {
308
+ this.db.prepare(
309
+ 'INSERT OR IGNORE INTO activities_categories (activity_id, category_id) VALUES (?, ?)',
310
+ ).run(Number(activityId), id);
311
+ }
312
+ return this.db.prepare('SELECT id, name, color_background, color_text FROM categories WHERE id = ?').get(id);
313
+ }
314
+
315
+ updateCategory(userId, id, attrs) {
316
+ const existing = this.db.prepare(
317
+ 'SELECT * FROM categories WHERE user_id = ? AND id = ?',
318
+ ).get(userId, Number(id));
319
+ if (!existing) return null;
320
+ this.db.prepare(
321
+ 'UPDATE categories SET name = COALESCE(?, name), color_background = COALESCE(?, color_background), color_text = COALESCE(?, color_text) WHERE id = ?',
322
+ ).run(attrs.name ?? null, attrs.color_background ?? null, attrs.color_text ?? null, existing.id);
323
+ return this.db.prepare('SELECT id, name, color_background, color_text FROM categories WHERE id = ?').get(existing.id);
324
+ }
325
+
326
+ deleteCategory(userId, id) {
327
+ const result = this.db.prepare('DELETE FROM categories WHERE user_id = ? AND id = ?')
328
+ .run(userId, Number(id));
329
+ return result.changes > 0;
330
+ }
331
+
332
+ createActivity(userId, { traceId, threadId, activity, event, agentId, agentName, tokenName }) {
333
+ const thread = this.db.prepare(`
334
+ SELECT threads.id, threads.trace_id
335
+ FROM threads
336
+ JOIN traces ON traces.id = threads.trace_id
337
+ WHERE threads.id = ? AND threads.trace_id = ? AND traces.user_id = ?
338
+ `).get(Number(threadId), Number(traceId), userId);
339
+ if (!thread) return { error: 'not_found' };
340
+
341
+ let parentId = activity.parent_id ?? null;
342
+ if (parentId != null) {
343
+ const parent = this.db.prepare(
344
+ 'SELECT id FROM activities WHERE id = ? AND thread_id = ?',
345
+ ).get(Number(parentId), thread.id);
346
+ if (!parent) return { error: 'not_found' };
347
+ parentId = parent.id;
348
+ }
349
+
350
+ const categoryIds = Array.isArray(activity.categories) ? activity.categories.map(Number) : [];
351
+ if (categoryIds.length > 0) {
352
+ const found = this.db.prepare(
353
+ `SELECT id FROM categories WHERE user_id = ? AND id IN (${categoryIds.map(() => '?').join(',')})`,
354
+ ).all(userId, ...categoryIds);
355
+ if (found.length !== new Set(categoryIds).size) return { error: 'not_found' };
356
+ }
357
+
358
+ const agent = agentId
359
+ ? { agent_id: agentId, agent_name: agentDisplayName(agentId, agentName, tokenName) }
360
+ : { agent_id: null, agent_name: null };
361
+
362
+ const inserted = this.db.prepare(`
363
+ INSERT INTO activities (thread_id, parent_id, name, description, weight, agent_id, agent_name, export_id)
364
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
365
+ `).run(
366
+ thread.id,
367
+ parentId,
368
+ activity.name,
369
+ activity.description ?? null,
370
+ activity.weight ?? null,
371
+ agent.agent_id,
372
+ agent.agent_name,
373
+ randomUUID(),
374
+ );
375
+ const activityId = Number(inserted.lastInsertRowid);
376
+ for (const categoryId of categoryIds) {
377
+ this.db.prepare(
378
+ 'INSERT INTO activities_categories (activity_id, category_id) VALUES (?, ?)',
379
+ ).run(activityId, categoryId);
380
+ }
381
+
382
+ const timestamp = Number(event.timestamp_integer);
383
+ const eventRow = this.db.prepare(`
384
+ INSERT INTO events (trace_id, activity_id, timestamp_ms, phase, message, export_id)
385
+ VALUES (?, ?, ?, ?, ?, ?)
386
+ `).run(thread.trace_id, activityId, timestamp, event.phase, event.message ?? null, randomUUID());
387
+
388
+ return {
389
+ activity: {
390
+ id: activityId,
391
+ name: activity.name,
392
+ description: activity.description ?? null,
393
+ weight: activity.weight ?? null,
394
+ parent_id: parentId,
395
+ thread_id: thread.id,
396
+ },
397
+ event: { id: Number(eventRow.lastInsertRowid), phase: event.phase },
398
+ };
399
+ }
400
+
401
+ getActivity(userId, id) {
402
+ const row = this.db.prepare(`
403
+ SELECT activities.*
404
+ FROM activities
405
+ JOIN threads ON threads.id = activities.thread_id
406
+ JOIN traces ON traces.id = threads.trace_id
407
+ WHERE activities.id = ? AND traces.user_id = ?
408
+ `).get(Number(id), userId);
409
+ if (!row) return null;
410
+ return {
411
+ id: row.id,
412
+ name: row.name,
413
+ description: row.description,
414
+ weight: row.weight,
415
+ parent_id: row.parent_id,
416
+ thread_id: row.thread_id,
417
+ };
418
+ }
419
+
420
+ updateActivity(userId, id, attrs) {
421
+ const current = this.getActivity(userId, id);
422
+ if (!current) return null;
423
+ this.db.prepare(`
424
+ UPDATE activities SET
425
+ name = COALESCE(?, name),
426
+ description = COALESCE(?, description),
427
+ weight = COALESCE(?, weight),
428
+ parent_id = COALESCE(?, parent_id)
429
+ WHERE id = ?
430
+ `).run(attrs.name ?? null, attrs.description ?? null, attrs.weight ?? null, attrs.parent_id ?? null, current.id);
431
+
432
+ if (Array.isArray(attrs.category_ids)) {
433
+ this.db.prepare('DELETE FROM activities_categories WHERE activity_id = ?').run(current.id);
434
+ for (const categoryId of attrs.category_ids) {
435
+ this.db.prepare(
436
+ 'INSERT INTO activities_categories (activity_id, category_id) VALUES (?, ?)',
437
+ ).run(current.id, Number(categoryId));
438
+ }
439
+ }
440
+
441
+ if (attrs.thread_id != null && Number(attrs.thread_id) !== current.thread_id) {
442
+ const thread = this.getThread(userId, attrs.thread_id);
443
+ if (!thread) return { error: 'not_found' };
444
+ this.db.prepare('UPDATE activities SET thread_id = ?, parent_id = NULL WHERE id = ?')
445
+ .run(thread.id, current.id);
446
+ }
447
+
448
+ return this.getActivity(userId, current.id);
449
+ }
450
+
451
+ deleteActivity(userId, id) {
452
+ const current = this.getActivity(userId, id);
453
+ if (!current) return false;
454
+ this.db.prepare('DELETE FROM activities WHERE id = ?').run(current.id);
455
+ return true;
456
+ }
457
+
458
+ createEvent(userId, { traceId, activityId, event }) {
459
+ const activity = this.db.prepare(`
460
+ SELECT activities.id, threads.trace_id
461
+ FROM activities
462
+ JOIN threads ON threads.id = activities.thread_id
463
+ JOIN traces ON traces.id = threads.trace_id
464
+ WHERE activities.id = ? AND traces.id = ? AND traces.user_id = ?
465
+ `).get(Number(activityId), Number(traceId), userId);
466
+ if (!activity) return { error: 'not_found' };
467
+
468
+ const result = this.db.prepare(`
469
+ INSERT INTO events (trace_id, activity_id, timestamp_ms, phase, message, export_id)
470
+ VALUES (?, ?, ?, ?, ?, ?)
471
+ `).run(
472
+ activity.trace_id,
473
+ activity.id,
474
+ Number(event.timestamp_integer),
475
+ event.phase,
476
+ event.message ?? null,
477
+ randomUUID(),
478
+ );
479
+ return { id: Number(result.lastInsertRowid), phase: event.phase };
480
+ }
481
+
482
+ updateEvent(userId, id, attrs) {
483
+ const existing = this.db.prepare(`
484
+ SELECT events.id FROM events
485
+ JOIN traces ON traces.id = events.trace_id
486
+ WHERE events.id = ? AND traces.user_id = ?
487
+ `).get(Number(id), userId);
488
+ if (!existing) return null;
489
+ this.db.prepare(
490
+ 'UPDATE events SET phase = COALESCE(?, phase), message = COALESCE(?, message) WHERE id = ?',
491
+ ).run(attrs.phase ?? null, attrs.message ?? null, existing.id);
492
+ const row = this.db.prepare('SELECT id, phase FROM events WHERE id = ?').get(existing.id);
493
+ return { id: row.id, phase: row.phase };
494
+ }
495
+
496
+ deleteEvent(userId, id) {
497
+ const existing = this.db.prepare(`
498
+ SELECT events.id FROM events
499
+ JOIN traces ON traces.id = events.trace_id
500
+ WHERE events.id = ? AND traces.user_id = ?
501
+ `).get(Number(id), userId);
502
+ if (!existing) return false;
503
+ this.db.prepare('DELETE FROM events WHERE id = ?').run(existing.id);
504
+ return true;
505
+ }
506
+
507
+ userDashboard(userId) {
508
+ const user = this.userById(userId);
509
+ return {
510
+ id: user.id,
511
+ name: user.name,
512
+ username: user.username,
513
+ traces: this.listTraces(userId),
514
+ categories: this.listCategories(userId),
515
+ todos: this.db.prepare('SELECT id, name, description FROM todos WHERE user_id = ?').all(userId),
516
+ mantras: this.db.prepare('SELECT id, name, timestamp_ms FROM mantras WHERE user_id = ? ORDER BY id').all(userId)
517
+ .map(row => ({ id: row.id, name: row.name, timestamp: isoFromMs(row.timestamp_ms) })),
518
+ attentionShifts: this.db.prepare(
519
+ 'SELECT id, thread_id, timestamp_ms FROM attentions WHERE user_id = ? ORDER BY id',
520
+ ).all(userId).map(row => ({
521
+ id: row.id,
522
+ thread_id: row.thread_id,
523
+ timestamp: isoFromMs(row.timestamp_ms),
524
+ })),
525
+ tabs: this.db.prepare(
526
+ 'SELECT id, count, window_count, timestamp_ms FROM tabs WHERE user_id = ? ORDER BY id',
527
+ ).all(userId).map(row => ({
528
+ id: row.id,
529
+ count: row.count,
530
+ window_count: row.window_count,
531
+ timestamp: isoFromMs(row.timestamp_ms),
532
+ })),
533
+ searchTerms: this.db.prepare(
534
+ 'SELECT id, term, timestamp_ms FROM search_terms WHERE user_id = ? ORDER BY id',
535
+ ).all(userId).map(row => ({
536
+ id: row.id,
537
+ term: row.term,
538
+ timestamp: isoFromMs(row.timestamp_ms),
539
+ })),
540
+ observations: this.listObservations(userId),
541
+ };
542
+ }
543
+
544
+ createTodo(userId, attrs) {
545
+ const result = this.db.prepare(
546
+ 'INSERT INTO todos (user_id, name, description) VALUES (?, ?, ?)',
547
+ ).run(userId, attrs.name, attrs.description ?? null);
548
+ return this.db.prepare('SELECT id, name, description FROM todos WHERE id = ?')
549
+ .get(Number(result.lastInsertRowid));
550
+ }
551
+
552
+ createMantra(userId, attrs) {
553
+ const timestamp = Number(attrs.timestamp_integer ?? Date.now());
554
+ const result = this.db.prepare(
555
+ 'INSERT INTO mantras (user_id, name, timestamp_ms) VALUES (?, ?, ?)',
556
+ ).run(userId, attrs.name, timestamp);
557
+ return { id: Number(result.lastInsertRowid), name: attrs.name };
558
+ }
559
+
560
+ createAttention(userId, attrs) {
561
+ const thread = this.getThread(userId, attrs.thread_id);
562
+ if (!thread) return { error: 'not_found' };
563
+ const timestamp = Number(attrs.timestamp_integer ?? Date.now());
564
+ const result = this.db.prepare(
565
+ 'INSERT INTO attentions (user_id, thread_id, timestamp_ms) VALUES (?, ?, ?)',
566
+ ).run(userId, thread.id, timestamp);
567
+ return { id: Number(result.lastInsertRowid) };
568
+ }
569
+
570
+ upsertObservation(userId, attrs) {
571
+ const kind = String(attrs.kind ?? '').trim().toLowerCase();
572
+ const timestamp = Number(attrs.timestamp_integer ?? Date.now());
573
+ const observedOn = attrs.observed_on ?? null;
574
+ const payload = JSON.stringify(attrs.payload && typeof attrs.payload === 'object' ? attrs.payload : {});
575
+
576
+ if (observedOn) {
577
+ const existing = this.db.prepare(
578
+ 'SELECT * FROM observations WHERE user_id = ? AND kind = ? AND observed_on = ?',
579
+ ).get(userId, kind, observedOn);
580
+ if (existing) {
581
+ const merged = { ...JSON.parse(existing.payload), ...(attrs.payload ?? {}) };
582
+ this.db.prepare(
583
+ 'UPDATE observations SET value = ?, unit = COALESCE(?, unit), payload = ?, timestamp_ms = ? WHERE id = ?',
584
+ ).run(attrs.value, attrs.unit ?? null, JSON.stringify(merged), timestamp, existing.id);
585
+ return { observation: this.#observationPayload(this.db.prepare('SELECT * FROM observations WHERE id = ?').get(existing.id)), created: false };
586
+ }
587
+ }
588
+
589
+ const result = this.db.prepare(`
590
+ INSERT INTO observations (user_id, kind, value, unit, payload, observed_on, timestamp_ms)
591
+ VALUES (?, ?, ?, ?, ?, ?, ?)
592
+ `).run(userId, kind, attrs.value, attrs.unit ?? null, payload, observedOn, timestamp);
593
+ return {
594
+ observation: this.#observationPayload(
595
+ this.db.prepare('SELECT * FROM observations WHERE id = ?').get(Number(result.lastInsertRowid)),
596
+ ),
597
+ created: true,
598
+ };
599
+ }
600
+
601
+ listObservations(userId) {
602
+ return this.db.prepare(
603
+ 'SELECT * FROM observations WHERE user_id = ? ORDER BY id',
604
+ ).all(userId).map(row => this.#observationPayload(row));
605
+ }
606
+
607
+ exportBundle() {
608
+ const categories = this.db.prepare(
609
+ 'SELECT export_id, name, color_background, color_text FROM categories WHERE user_id = 1 ORDER BY id',
610
+ ).all();
611
+ const traces = this.db.prepare('SELECT * FROM traces WHERE user_id = 1 ORDER BY id').all();
612
+ const categoryById = new Map(
613
+ this.db.prepare('SELECT id, export_id FROM categories WHERE user_id = 1').all()
614
+ .map(row => [row.id, row.export_id]),
615
+ );
616
+
617
+ return {
618
+ format: EXPORT_FORMAT,
619
+ version: EXPORT_VERSION,
620
+ exported_at: isoFromMs(Date.now()),
621
+ categories: categories.map(row => ({
622
+ export_id: row.export_id,
623
+ name: row.name,
624
+ color_background: row.color_background,
625
+ color_text: row.color_text,
626
+ })),
627
+ traces: traces.map(trace => this.#exportTrace(trace, categoryById)),
628
+ observations: this.listObservations(1).map(({ id: _id, ...rest }) => rest),
629
+ };
630
+ }
631
+
632
+ #exportTrace(trace, categoryById) {
633
+ const threads = this.db.prepare(
634
+ 'SELECT * FROM threads WHERE trace_id = ? ORDER BY rank, id',
635
+ ).all(trace.id);
636
+ const threadExport = new Map(threads.map(thread => [thread.id, thread.export_id]));
637
+ const activities = this.db.prepare(`
638
+ SELECT activities.* FROM activities
639
+ JOIN threads ON threads.id = activities.thread_id
640
+ WHERE threads.trace_id = ?
641
+ ORDER BY activities.id
642
+ `).all(trace.id);
643
+ const activityExport = new Map(activities.map(activity => [activity.id, activity.export_id]));
644
+ const events = this.db.prepare(
645
+ 'SELECT * FROM events WHERE trace_id = ? ORDER BY timestamp_ms, id',
646
+ ).all(trace.id);
647
+
648
+ return {
649
+ export_id: trace.export_id,
650
+ name: trace.name,
651
+ threads: threads.map(thread => ({
652
+ export_id: thread.export_id,
653
+ name: thread.name,
654
+ rank: thread.rank,
655
+ })),
656
+ activities: activities.map(activity => ({
657
+ export_id: activity.export_id,
658
+ name: activity.name,
659
+ description: activity.description,
660
+ weight: activity.weight,
661
+ parent_export_id: activity.parent_id == null ? null : activityExport.get(activity.parent_id) ?? null,
662
+ thread_export_id: threadExport.get(activity.thread_id),
663
+ category_export_ids: this.db.prepare(
664
+ 'SELECT category_id FROM activities_categories WHERE activity_id = ?',
665
+ ).all(activity.id).map(row => categoryById.get(row.category_id)).filter(Boolean),
666
+ agent_id: activity.agent_id,
667
+ agent_name: activity.agent_name,
668
+ })),
669
+ events: events.map(event => ({
670
+ export_id: event.export_id,
671
+ activity_export_id: event.activity_id == null ? null : activityExport.get(event.activity_id) ?? null,
672
+ timestamp_integer: event.timestamp_ms,
673
+ phase: event.phase,
674
+ message: event.message,
675
+ })),
676
+ };
677
+ }
678
+
679
+ #eventPayload(event) {
680
+ const categoryIds = event.activity_id == null
681
+ ? []
682
+ : this.db.prepare(
683
+ 'SELECT category_id FROM activities_categories WHERE activity_id = ?',
684
+ ).all(event.activity_id).map(row => row.category_id);
685
+
686
+ return {
687
+ id: event.id,
688
+ timestamp: isoFromMs(event.timestamp_ms),
689
+ phase: event.phase,
690
+ message: event.message ?? null,
691
+ activity: event.activity_id == null ? null : {
692
+ id: event.activity_id,
693
+ name: event.activity_name,
694
+ agent_id: event.agent_id ?? null,
695
+ agent_name: event.agent_name ?? null,
696
+ parent_id: event.parent_id ?? null,
697
+ thread: { id: event.thread_id },
698
+ categories: categoryIds,
699
+ weight: event.weight ?? null,
700
+ },
701
+ };
702
+ }
703
+
704
+ #observationPayload(row) {
705
+ return {
706
+ id: row.id,
707
+ kind: row.kind,
708
+ value: row.value,
709
+ unit: row.unit,
710
+ payload: JSON.parse(row.payload || '{}'),
711
+ observed_on: row.observed_on,
712
+ timestamp: isoFromMs(row.timestamp_ms),
713
+ };
714
+ }
715
+
716
+ #seed() {
717
+ const user = this.db.prepare('SELECT id FROM users WHERE id = 1').get();
718
+ if (user) return;
719
+
720
+ this.db.exec('BEGIN');
721
+ try {
722
+ this.db.prepare('INSERT INTO users (id, name, username) VALUES (1, ?, ?)').run('Local', 'local');
723
+ const rawToken = generateToken();
724
+ this.db.prepare(
725
+ 'INSERT INTO api_tokens (user_id, name, token_hash, raw_token) VALUES (1, ?, ?, ?)',
726
+ ).run('local', hashToken(rawToken), rawToken);
727
+ const trace = this.db.prepare(
728
+ 'INSERT INTO traces (user_id, name, export_id) VALUES (1, ?, ?)',
729
+ ).run('Main', randomUUID());
730
+ this.db.prepare(
731
+ 'INSERT INTO threads (trace_id, name, rank, export_id) VALUES (?, ?, 0, ?)',
732
+ ).run(Number(trace.lastInsertRowid), 'Main', randomUUID());
733
+ this.db.exec('COMMIT');
734
+ } catch (error) {
735
+ this.db.exec('ROLLBACK');
736
+ throw error;
737
+ }
738
+ }
739
+ }