@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.
- package/bin/flambe.mjs +13 -0
- package/package.json +20 -0
- package/src/cli.mjs +427 -0
- package/src/client.mjs +549 -0
- package/src/env.mjs +21 -0
- package/src/local/agents.mjs +24 -0
- package/src/local/http.mjs +391 -0
- package/src/local/serve.mjs +61 -0
- package/src/local/store.mjs +739 -0
- package/src/queue.mjs +161 -0
- package/test/client.test.mjs +951 -0
- package/test/local.test.mjs +136 -0
- package/test/queue.test.mjs +135 -0
|
@@ -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
|
+
}
|