@davvidbaker/flambe-cli 0.1.0 → 0.1.2
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/README.md +63 -0
- package/package.json +17 -3
- package/src/agentNames.mjs +35 -0
- package/src/cli.mjs +124 -10
- package/src/client.mjs +359 -41
- package/src/local/agents.mjs +18 -12
- package/src/local/commands.mjs +291 -0
- package/src/local/http.mjs +116 -20
- package/src/local/serve.mjs +2 -1
- package/src/local/store.mjs +262 -39
- package/src/questions.mjs +48 -0
- package/src/queue.mjs +15 -3
- package/src/runtime.mjs +23 -0
- package/test/client.test.mjs +0 -951
- package/test/local.test.mjs +0 -136
- package/test/queue.test.mjs +0 -135
package/src/local/store.mjs
CHANGED
|
@@ -3,7 +3,10 @@ import { mkdirSync } from 'node:fs';
|
|
|
3
3
|
import { dirname } from 'node:path';
|
|
4
4
|
import { DatabaseSync } from 'node:sqlite';
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { pickName, usableName } from './agents.mjs';
|
|
7
|
+
|
|
8
|
+
const LIFECYCLE_PHASES = ['B', 'E', 'S', 'R', 'X', 'J', 'V'];
|
|
9
|
+
const OPEN_PHASES = ['B', 'R', 'X'];
|
|
7
10
|
|
|
8
11
|
const SCHEMA = `
|
|
9
12
|
PRAGMA foreign_keys = ON;
|
|
@@ -57,6 +60,10 @@ CREATE TABLE IF NOT EXISTS activities (
|
|
|
57
60
|
weight INTEGER,
|
|
58
61
|
agent_id TEXT,
|
|
59
62
|
agent_name TEXT,
|
|
63
|
+
scheduled_start_ms INTEGER,
|
|
64
|
+
scheduled_end_ms INTEGER,
|
|
65
|
+
proposed_by_agent_id TEXT,
|
|
66
|
+
proposed_by_agent_name TEXT,
|
|
60
67
|
export_id TEXT NOT NULL UNIQUE
|
|
61
68
|
);
|
|
62
69
|
|
|
@@ -76,13 +83,6 @@ CREATE TABLE IF NOT EXISTS events (
|
|
|
76
83
|
export_id TEXT NOT NULL UNIQUE
|
|
77
84
|
);
|
|
78
85
|
|
|
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
86
|
CREATE TABLE IF NOT EXISTS mantras (
|
|
87
87
|
id INTEGER PRIMARY KEY,
|
|
88
88
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
@@ -112,6 +112,17 @@ CREATE TABLE IF NOT EXISTS search_terms (
|
|
|
112
112
|
timestamp_ms INTEGER NOT NULL
|
|
113
113
|
);
|
|
114
114
|
|
|
115
|
+
CREATE TABLE IF NOT EXISTS agents (
|
|
116
|
+
id INTEGER PRIMARY KEY,
|
|
117
|
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
118
|
+
agent_id TEXT NOT NULL,
|
|
119
|
+
name TEXT NOT NULL,
|
|
120
|
+
name_source TEXT NOT NULL,
|
|
121
|
+
platform TEXT,
|
|
122
|
+
last_seen_ms INTEGER NOT NULL,
|
|
123
|
+
UNIQUE (user_id, agent_id)
|
|
124
|
+
);
|
|
125
|
+
|
|
115
126
|
CREATE TABLE IF NOT EXISTS observations (
|
|
116
127
|
id INTEGER PRIMARY KEY,
|
|
117
128
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
@@ -127,6 +138,28 @@ CREATE TABLE IF NOT EXISTS observations (
|
|
|
127
138
|
export const EXPORT_FORMAT = 'flambe-local-export';
|
|
128
139
|
export const EXPORT_VERSION = 1;
|
|
129
140
|
|
|
141
|
+
export const DEFAULT_CATEGORIES = [
|
|
142
|
+
{ name: 'bug fixing', color_background: '#ff4747', color_text: '#000000' },
|
|
143
|
+
{ name: 'research', color_background: '#cd94f1', color_text: '#000000' },
|
|
144
|
+
{ name: 'cleaning', color_background: '#ffbd69', color_text: '#000000' },
|
|
145
|
+
{ name: 'bug hunting', color_background: '#ef60ab', color_text: '#000000' },
|
|
146
|
+
{ name: 'design', color_background: '#dce7ea', color_text: '#000000' },
|
|
147
|
+
{ name: 'writing tests', color_background: '#50f035', color_text: '#000000' },
|
|
148
|
+
{ name: 'toil', color_background: '#8b6125', color_text: '#ffffff' },
|
|
149
|
+
{ name: 'analytics', color_background: '#f9f9f9', color_text: '#4598d6' },
|
|
150
|
+
{ name: 'oss', color_background: '#60ef6c', color_text: '#000000' },
|
|
151
|
+
{ name: 'enhancements', color_background: '#55defb', color_text: '#ffffff' },
|
|
152
|
+
{ name: 'refactoring components', color_background: '#ffd368', color_text: '#000000' },
|
|
153
|
+
{ name: 'Fundamentals', color_background: '#acefdb', color_text: '#000000' },
|
|
154
|
+
{ name: 'jarring ui', color_background: '#f22d6a', color_text: '#ffffff' },
|
|
155
|
+
{ name: 'writing', color_background: '#ffefce', color_text: '#000000' },
|
|
156
|
+
{ name: 'risky', color_background: '#ff2a22', color_text: '#ffffff' },
|
|
157
|
+
{ name: 'dependency upgrades', color_background: '#b58e38', color_text: '#ffffff' },
|
|
158
|
+
{ name: 'shiny', color_background: '#f5ff86', color_text: '#000000' },
|
|
159
|
+
{ name: 'performance', color_background: '#60efc7', color_text: '#000000' },
|
|
160
|
+
{ name: 'learning', color_background: '#d3f791', color_text: '#000000' },
|
|
161
|
+
];
|
|
162
|
+
|
|
130
163
|
export function hashToken(rawToken) {
|
|
131
164
|
return createHash('sha256').update(rawToken).digest('hex');
|
|
132
165
|
}
|
|
@@ -147,6 +180,17 @@ export class LocalStore {
|
|
|
147
180
|
this.db = new DatabaseSync(dbPath);
|
|
148
181
|
this.db.exec('PRAGMA foreign_keys = ON');
|
|
149
182
|
this.db.exec(SCHEMA);
|
|
183
|
+
this.db.exec('DROP TABLE IF EXISTS todos');
|
|
184
|
+
for (const column of [
|
|
185
|
+
'scheduled_start_ms INTEGER',
|
|
186
|
+
'scheduled_end_ms INTEGER',
|
|
187
|
+
'proposed_by_agent_id TEXT',
|
|
188
|
+
'proposed_by_agent_name TEXT',
|
|
189
|
+
]) {
|
|
190
|
+
const name = column.split(' ')[0];
|
|
191
|
+
const exists = this.db.prepare('PRAGMA table_info(activities)').all().some(row => row.name === name);
|
|
192
|
+
if (!exists) this.db.exec(`ALTER TABLE activities ADD COLUMN ${column}`);
|
|
193
|
+
}
|
|
150
194
|
this.#seed();
|
|
151
195
|
}
|
|
152
196
|
|
|
@@ -225,6 +269,7 @@ export class LocalStore {
|
|
|
225
269
|
this.db.prepare(
|
|
226
270
|
'INSERT INTO threads (trace_id, name, rank, export_id) VALUES (?, ?, 0, ?)',
|
|
227
271
|
).run(traceId, 'Main', randomUUID());
|
|
272
|
+
this.#ensureDefaultCategories(userId);
|
|
228
273
|
return this.getTrace(userId, traceId);
|
|
229
274
|
}
|
|
230
275
|
|
|
@@ -294,6 +339,7 @@ export class LocalStore {
|
|
|
294
339
|
}
|
|
295
340
|
|
|
296
341
|
listCategories(userId) {
|
|
342
|
+
this.#ensureDefaultCategories(userId);
|
|
297
343
|
return this.db.prepare(
|
|
298
344
|
'SELECT id, name, color_background, color_text FROM categories WHERE user_id = ? ORDER BY id',
|
|
299
345
|
).all(userId);
|
|
@@ -329,47 +375,142 @@ export class LocalStore {
|
|
|
329
375
|
return result.changes > 0;
|
|
330
376
|
}
|
|
331
377
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
378
|
+
/**
|
|
379
|
+
* Resolve (and if needed name) the agent behind `agentId` for this user, mirroring
|
|
380
|
+
* FlambeNext.Agents.identify. `assigned` is true only when a name was coined now.
|
|
381
|
+
*/
|
|
382
|
+
identifyAgent(userId, agentId, providedName, providedPlatform) {
|
|
383
|
+
if (typeof agentId !== 'string' || agentId.length === 0 || agentId.length > 200) return null;
|
|
384
|
+
const provided = usableName(providedName) ? providedName.trim() : null;
|
|
385
|
+
const platformGiven = usableName(providedPlatform) ? providedPlatform.trim() : null;
|
|
386
|
+
const now = Date.now();
|
|
387
|
+
const existing = this.db.prepare('SELECT * FROM agents WHERE user_id = ? AND agent_id = ?').get(userId, agentId);
|
|
388
|
+
|
|
389
|
+
if (existing) {
|
|
390
|
+
const name = provided && provided !== existing.name ? provided : existing.name;
|
|
391
|
+
const platform = platformGiven ?? existing.platform ?? null;
|
|
392
|
+
this.db.prepare('UPDATE agents SET name = ?, name_source = ?, platform = ?, last_seen_ms = ? WHERE id = ?')
|
|
393
|
+
.run(name, provided && provided !== existing.name ? 'provided' : existing.name_source, platform, now, existing.id);
|
|
394
|
+
return { agent_id: agentId, name, platform, assigned: false };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const taken = this.db.prepare('SELECT name FROM agents WHERE user_id = ?').all(userId).map(row => row.name);
|
|
398
|
+
const name = provided ?? pickName(agentId, taken);
|
|
399
|
+
this.db.prepare('INSERT INTO agents (user_id, agent_id, name, name_source, platform, last_seen_ms) VALUES (?, ?, ?, ?, ?, ?)')
|
|
400
|
+
.run(userId, agentId, name, provided ? 'provided' : 'assigned', platformGiven, now);
|
|
401
|
+
return { agent_id: agentId, name, platform: platformGiven, assigned: provided === null };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
listAgents(userId) {
|
|
405
|
+
return this.db.prepare('SELECT agent_id, name, name_source, platform, last_seen_ms FROM agents WHERE user_id = ? ORDER BY last_seen_ms DESC, id')
|
|
406
|
+
.all(userId);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Newest running activity in the trace, optionally for one agent or thread. */
|
|
410
|
+
newestActiveActivity(traceId, { agentId, threadId } = {}) {
|
|
411
|
+
const placeholders = LIFECYCLE_PHASES.map(() => '?').join(',');
|
|
412
|
+
const open = OPEN_PHASES.map(() => '?').join(',');
|
|
413
|
+
const rows = this.db.prepare(`
|
|
414
|
+
WITH latest AS (
|
|
415
|
+
SELECT activity_id, phase, timestamp_ms, id,
|
|
416
|
+
ROW_NUMBER() OVER (PARTITION BY activity_id ORDER BY timestamp_ms DESC, id DESC) AS rn
|
|
417
|
+
FROM events
|
|
418
|
+
WHERE trace_id = ? AND activity_id IS NOT NULL AND phase IN (${placeholders})
|
|
419
|
+
)
|
|
420
|
+
SELECT activities.id, activities.parent_id, activities.thread_id, activities.agent_id
|
|
421
|
+
FROM latest
|
|
422
|
+
JOIN activities ON activities.id = latest.activity_id
|
|
423
|
+
WHERE latest.rn = 1 AND latest.phase IN (${open})
|
|
424
|
+
${agentId ? 'AND activities.agent_id = ?' : ''}
|
|
425
|
+
${threadId ? 'AND activities.thread_id = ?' : ''}
|
|
426
|
+
ORDER BY latest.timestamp_ms DESC, latest.id DESC
|
|
427
|
+
LIMIT 1
|
|
428
|
+
`).all(
|
|
429
|
+
Number(traceId), ...LIFECYCLE_PHASES, ...OPEN_PHASES,
|
|
430
|
+
...(agentId ? [agentId] : []), ...(threadId ? [Number(threadId)] : []),
|
|
431
|
+
);
|
|
432
|
+
return rows[0] ?? null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Create an activity. With `reduce` (agent requests, ADR-012) the deterministic reducer
|
|
437
|
+
* rules apply: an absent parent_id infers this agent's newest active activity, a child
|
|
438
|
+
* lives in its parent's thread and inherits categories, and thread_id may be omitted.
|
|
439
|
+
*/
|
|
440
|
+
createActivity(userId, { traceId, threadId, activity, event, agent, reduce = false }) {
|
|
441
|
+
const trace = this.db.prepare('SELECT id FROM traces WHERE id = ? AND user_id = ?').get(Number(traceId), userId);
|
|
442
|
+
if (!trace) return { error: 'not_found' };
|
|
340
443
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
444
|
+
const notes = { parent_source: 'root', thread_source: 'request', categories_source: 'none' };
|
|
445
|
+
let parentRow = null;
|
|
446
|
+
let parentId = null;
|
|
447
|
+
|
|
448
|
+
if (Object.hasOwn(activity, 'parent_id') && activity.parent_id != null) {
|
|
449
|
+
parentRow = this.db.prepare(`
|
|
450
|
+
SELECT activities.id, activities.thread_id FROM activities
|
|
451
|
+
JOIN threads ON threads.id = activities.thread_id
|
|
452
|
+
WHERE activities.id = ? AND threads.trace_id = ?
|
|
453
|
+
`).get(Number(activity.parent_id), trace.id);
|
|
454
|
+
if (!parentRow) return { error: 'not_found' };
|
|
455
|
+
notes.parent_source = 'explicit';
|
|
456
|
+
} else if (reduce && !Object.hasOwn(activity, 'parent_id')) {
|
|
457
|
+
const requestedThread = threadId == null ? this.#defaultThread(trace.id)?.id : Number(threadId);
|
|
458
|
+
parentRow = agent?.agent_id
|
|
459
|
+
? this.newestActiveActivity(trace.id, { agentId: agent.agent_id })
|
|
460
|
+
: this.newestActiveActivity(trace.id, { threadId: requestedThread });
|
|
461
|
+
if (parentRow) notes.parent_source = 'inferred';
|
|
348
462
|
}
|
|
349
463
|
|
|
350
|
-
|
|
464
|
+
let thread;
|
|
465
|
+
if (parentRow) {
|
|
466
|
+
thread = this.db.prepare('SELECT id, trace_id FROM threads WHERE id = ?').get(parentRow.thread_id);
|
|
467
|
+
notes.thread_source = 'parent';
|
|
468
|
+
parentId = parentRow.id;
|
|
469
|
+
} else if (threadId == null) {
|
|
470
|
+
if (!reduce) return { error: 'not_found' };
|
|
471
|
+
thread = this.#defaultThread(trace.id);
|
|
472
|
+
notes.thread_source = 'default';
|
|
473
|
+
} else {
|
|
474
|
+
thread = this.db.prepare('SELECT id, trace_id FROM threads WHERE id = ? AND trace_id = ?').get(Number(threadId), trace.id);
|
|
475
|
+
}
|
|
476
|
+
if (!thread) return { error: 'not_found' };
|
|
477
|
+
if (!reduce && parentRow && parentRow.thread_id !== thread.id) return { error: 'not_found' };
|
|
478
|
+
|
|
479
|
+
let categoryIds = Array.isArray(activity.categories) ? activity.categories.map(Number) : [];
|
|
351
480
|
if (categoryIds.length > 0) {
|
|
352
481
|
const found = this.db.prepare(
|
|
353
482
|
`SELECT id FROM categories WHERE user_id = ? AND id IN (${categoryIds.map(() => '?').join(',')})`,
|
|
354
483
|
).all(userId, ...categoryIds);
|
|
355
484
|
if (found.length !== new Set(categoryIds).size) return { error: 'not_found' };
|
|
485
|
+
notes.categories_source = 'request';
|
|
486
|
+
} else if (reduce && parentId != null) {
|
|
487
|
+
categoryIds = this.db.prepare('SELECT category_id FROM activities_categories WHERE activity_id = ?')
|
|
488
|
+
.all(parentId).map(row => row.category_id);
|
|
489
|
+
if (categoryIds.length > 0) notes.categories_source = 'parent';
|
|
356
490
|
}
|
|
357
491
|
|
|
358
|
-
const
|
|
359
|
-
? { agent_id:
|
|
492
|
+
const agentColumns = agent?.agent_id
|
|
493
|
+
? { agent_id: agent.agent_id, agent_name: agent.name }
|
|
360
494
|
: { agent_id: null, agent_name: null };
|
|
361
495
|
|
|
362
496
|
const inserted = this.db.prepare(`
|
|
363
|
-
INSERT INTO activities (
|
|
364
|
-
|
|
497
|
+
INSERT INTO activities (
|
|
498
|
+
thread_id, parent_id, name, description, weight, agent_id, agent_name,
|
|
499
|
+
scheduled_start_ms, scheduled_end_ms, proposed_by_agent_id, proposed_by_agent_name, export_id
|
|
500
|
+
)
|
|
501
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
365
502
|
`).run(
|
|
366
503
|
thread.id,
|
|
367
504
|
parentId,
|
|
368
505
|
activity.name,
|
|
369
506
|
activity.description ?? null,
|
|
370
507
|
activity.weight ?? null,
|
|
371
|
-
|
|
372
|
-
|
|
508
|
+
agentColumns.agent_id,
|
|
509
|
+
agentColumns.agent_name,
|
|
510
|
+
activity.scheduled_start_ms ?? null,
|
|
511
|
+
activity.scheduled_end_ms ?? null,
|
|
512
|
+
activity.proposed_by_agent_id ?? agentColumns.agent_id,
|
|
513
|
+
activity.proposed_by_agent_name ?? agentColumns.agent_name,
|
|
373
514
|
randomUUID(),
|
|
374
515
|
);
|
|
375
516
|
const activityId = Number(inserted.lastInsertRowid);
|
|
@@ -395,9 +536,46 @@ export class LocalStore {
|
|
|
395
536
|
thread_id: thread.id,
|
|
396
537
|
},
|
|
397
538
|
event: { id: Number(eventRow.lastInsertRowid), phase: event.phase },
|
|
539
|
+
...(reduce ? { reducer: notes } : {}),
|
|
398
540
|
};
|
|
399
541
|
}
|
|
400
542
|
|
|
543
|
+
createUnstarted(userId, { traceId, name, description, weight, agent, scheduledStart, scheduledEnd }) {
|
|
544
|
+
const thread = this.#defaultThread(Number(traceId));
|
|
545
|
+
if (!thread) return { error: 'not_found' };
|
|
546
|
+
const trace = this.db.prepare('SELECT id FROM traces WHERE id = ? AND user_id = ?').get(thread.trace_id, userId);
|
|
547
|
+
if (!trace) return { error: 'not_found' };
|
|
548
|
+
const inserted = this.db.prepare(`
|
|
549
|
+
INSERT INTO activities (
|
|
550
|
+
thread_id, parent_id, name, description, weight, agent_id, agent_name,
|
|
551
|
+
scheduled_start_ms, scheduled_end_ms, proposed_by_agent_id, proposed_by_agent_name, export_id
|
|
552
|
+
) VALUES (?, NULL, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?)
|
|
553
|
+
`).run(
|
|
554
|
+
thread.id, name, description ?? null, weight ?? null,
|
|
555
|
+
scheduledStart ?? null, scheduledEnd ?? null,
|
|
556
|
+
agent?.agent_id ?? null, agent?.name ?? null, randomUUID(),
|
|
557
|
+
);
|
|
558
|
+
return { activity: { id: Number(inserted.lastInsertRowid), name, thread_id: thread.id, parent_id: null } };
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
listUnstarted(traceId) {
|
|
562
|
+
return this.db.prepare(`
|
|
563
|
+
SELECT activities.* FROM activities
|
|
564
|
+
JOIN threads ON threads.id = activities.thread_id
|
|
565
|
+
WHERE threads.trace_id = ?
|
|
566
|
+
AND NOT EXISTS (
|
|
567
|
+
SELECT 1 FROM events
|
|
568
|
+
WHERE events.activity_id = activities.id
|
|
569
|
+
AND events.phase IN ('B','R','X','S','E','J','V')
|
|
570
|
+
)
|
|
571
|
+
ORDER BY activities.id
|
|
572
|
+
`).all(Number(traceId));
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
#defaultThread(traceId) {
|
|
576
|
+
return this.db.prepare('SELECT id, trace_id FROM threads WHERE trace_id = ? ORDER BY rank, id LIMIT 1').get(traceId) ?? null;
|
|
577
|
+
}
|
|
578
|
+
|
|
401
579
|
getActivity(userId, id) {
|
|
402
580
|
const row = this.db.prepare(`
|
|
403
581
|
SELECT activities.*
|
|
@@ -414,6 +592,8 @@ export class LocalStore {
|
|
|
414
592
|
weight: row.weight,
|
|
415
593
|
parent_id: row.parent_id,
|
|
416
594
|
thread_id: row.thread_id,
|
|
595
|
+
agent_id: row.agent_id ?? null,
|
|
596
|
+
agent_name: row.agent_name ?? null,
|
|
417
597
|
};
|
|
418
598
|
}
|
|
419
599
|
|
|
@@ -429,6 +609,13 @@ export class LocalStore {
|
|
|
429
609
|
WHERE id = ?
|
|
430
610
|
`).run(attrs.name ?? null, attrs.description ?? null, attrs.weight ?? null, attrs.parent_id ?? null, current.id);
|
|
431
611
|
|
|
612
|
+
if (Object.prototype.hasOwnProperty.call(attrs, 'agent_id')) {
|
|
613
|
+
const agentId = attrs.agent_id == null || attrs.agent_id === '' ? null : String(attrs.agent_id);
|
|
614
|
+
const agentName = agentId == null ? null : this.#activityAgentName(userId, agentId, attrs);
|
|
615
|
+
this.db.prepare('UPDATE activities SET agent_id = ?, agent_name = ? WHERE id = ?')
|
|
616
|
+
.run(agentId, agentName, current.id);
|
|
617
|
+
}
|
|
618
|
+
|
|
432
619
|
if (Array.isArray(attrs.category_ids)) {
|
|
433
620
|
this.db.prepare('DELETE FROM activities_categories WHERE activity_id = ?').run(current.id);
|
|
434
621
|
for (const categoryId of attrs.category_ids) {
|
|
@@ -512,7 +699,6 @@ export class LocalStore {
|
|
|
512
699
|
username: user.username,
|
|
513
700
|
traces: this.listTraces(userId),
|
|
514
701
|
categories: this.listCategories(userId),
|
|
515
|
-
todos: this.db.prepare('SELECT id, name, description FROM todos WHERE user_id = ?').all(userId),
|
|
516
702
|
mantras: this.db.prepare('SELECT id, name, timestamp_ms FROM mantras WHERE user_id = ? ORDER BY id').all(userId)
|
|
517
703
|
.map(row => ({ id: row.id, name: row.name, timestamp: isoFromMs(row.timestamp_ms) })),
|
|
518
704
|
attentionShifts: this.db.prepare(
|
|
@@ -538,17 +724,14 @@ export class LocalStore {
|
|
|
538
724
|
timestamp: isoFromMs(row.timestamp_ms),
|
|
539
725
|
})),
|
|
540
726
|
observations: this.listObservations(userId),
|
|
727
|
+
agents: this.listAgents(userId).map(agent => ({
|
|
728
|
+
agent_id: agent.agent_id,
|
|
729
|
+
name: agent.name,
|
|
730
|
+
platform: agent.platform ?? null,
|
|
731
|
+
})),
|
|
541
732
|
};
|
|
542
733
|
}
|
|
543
734
|
|
|
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
735
|
createMantra(userId, attrs) {
|
|
553
736
|
const timestamp = Number(attrs.timestamp_integer ?? Date.now());
|
|
554
737
|
const result = this.db.prepare(
|
|
@@ -676,6 +859,24 @@ export class LocalStore {
|
|
|
676
859
|
};
|
|
677
860
|
}
|
|
678
861
|
|
|
862
|
+
#activityAgentName(userId, agentId, attrs) {
|
|
863
|
+
const known = this.db.prepare(
|
|
864
|
+
'SELECT name FROM agents WHERE user_id = ? AND agent_id = ?',
|
|
865
|
+
).get(userId, agentId);
|
|
866
|
+
if (known?.name) return known.name;
|
|
867
|
+
const snapshot = this.db.prepare(`
|
|
868
|
+
SELECT activities.agent_name FROM activities
|
|
869
|
+
JOIN threads ON threads.id = activities.thread_id
|
|
870
|
+
JOIN traces ON traces.id = threads.trace_id
|
|
871
|
+
WHERE traces.user_id = ? AND activities.agent_id = ?
|
|
872
|
+
AND activities.agent_name IS NOT NULL AND activities.agent_name != ''
|
|
873
|
+
LIMIT 1
|
|
874
|
+
`).get(userId, agentId);
|
|
875
|
+
if (snapshot?.agent_name) return snapshot.agent_name;
|
|
876
|
+
if (typeof attrs.agent_name === 'string' && attrs.agent_name !== '') return attrs.agent_name;
|
|
877
|
+
return agentId;
|
|
878
|
+
}
|
|
879
|
+
|
|
679
880
|
#eventPayload(event) {
|
|
680
881
|
const categoryIds = event.activity_id == null
|
|
681
882
|
? []
|
|
@@ -713,6 +914,27 @@ export class LocalStore {
|
|
|
713
914
|
};
|
|
714
915
|
}
|
|
715
916
|
|
|
917
|
+
#ensureDefaultCategories(userId) {
|
|
918
|
+
const have = new Set(
|
|
919
|
+
this.db.prepare('SELECT lower(name) AS name FROM categories WHERE user_id = ?')
|
|
920
|
+
.all(userId)
|
|
921
|
+
.map(row => row.name),
|
|
922
|
+
);
|
|
923
|
+
const insert = this.db.prepare(
|
|
924
|
+
'INSERT INTO categories (user_id, name, color_background, color_text, export_id) VALUES (?, ?, ?, ?, ?)',
|
|
925
|
+
);
|
|
926
|
+
for (const category of DEFAULT_CATEGORIES) {
|
|
927
|
+
if (have.has(category.name.toLowerCase())) continue;
|
|
928
|
+
insert.run(
|
|
929
|
+
userId,
|
|
930
|
+
category.name,
|
|
931
|
+
category.color_background,
|
|
932
|
+
category.color_text,
|
|
933
|
+
randomUUID(),
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
716
938
|
#seed() {
|
|
717
939
|
const user = this.db.prepare('SELECT id FROM users WHERE id = 1').get();
|
|
718
940
|
if (user) return;
|
|
@@ -730,6 +952,7 @@ export class LocalStore {
|
|
|
730
952
|
this.db.prepare(
|
|
731
953
|
'INSERT INTO threads (trace_id, name, rank, export_id) VALUES (?, ?, 0, ?)',
|
|
732
954
|
).run(Number(trace.lastInsertRowid), 'Main', randomUUID());
|
|
955
|
+
this.#ensureDefaultCategories(1);
|
|
733
956
|
this.db.exec('COMMIT');
|
|
734
957
|
} catch (error) {
|
|
735
958
|
this.db.exec('ROLLBACK');
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
// A question the reducer asked and the worker has not answered yet (ADR-014).
|
|
7
|
+
// One open question per trace. The CLI reminds; it never refuses the next command.
|
|
8
|
+
|
|
9
|
+
export function defaultQuestionsPath() {
|
|
10
|
+
return join(homedir(), '.flambe', 'open-questions.json');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function readOpenQuestion(traceId, path = defaultQuestionsPath()) {
|
|
14
|
+
if (!traceId) return undefined;
|
|
15
|
+
const stored = readAll(path)[String(traceId)];
|
|
16
|
+
if (!stored || typeof stored.reply !== 'string' || !stored.reply.trim()) return undefined;
|
|
17
|
+
return { activityId: stored.activityId, reply: stored.reply };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function rememberQuestion(traceId, { activityId, reply }, path = defaultQuestionsPath()) {
|
|
21
|
+
if (!traceId || !reply) return;
|
|
22
|
+
const all = { ...readAll(path), [String(traceId)]: { activityId, reply } };
|
|
23
|
+
writeAll(path, all);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function clearQuestion(traceId, path = defaultQuestionsPath()) {
|
|
27
|
+
if (!traceId) return;
|
|
28
|
+
const all = readAll(path);
|
|
29
|
+
if (!(String(traceId) in all)) return;
|
|
30
|
+
delete all[String(traceId)];
|
|
31
|
+
writeAll(path, all);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readAll(path) {
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
37
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
38
|
+
} catch {
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function writeAll(path, questions) {
|
|
44
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
45
|
+
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
46
|
+
writeFileSync(tmp, `${JSON.stringify(questions, null, 2)}\n`);
|
|
47
|
+
renameSync(tmp, path);
|
|
48
|
+
}
|
package/src/queue.mjs
CHANGED
|
@@ -28,14 +28,19 @@ function emptyState() {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
export class FlambeQueue {
|
|
31
|
-
constructor({ baseUrl, traceId, path = defaultQueuePath() }) {
|
|
31
|
+
constructor({ baseUrl, traceId, agentId, path = defaultQueuePath() }) {
|
|
32
32
|
this.baseUrl = baseUrl;
|
|
33
33
|
this.traceId = Number(traceId);
|
|
34
|
+
this.agentId = agentId;
|
|
34
35
|
this.path = path;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
matches(entry) {
|
|
38
|
-
return entry.baseUrl === this.baseUrl
|
|
39
|
+
return entry.baseUrl === this.baseUrl
|
|
40
|
+
&& entry.traceId === this.traceId
|
|
41
|
+
// Version-1 entries created before identity was recorded remain replayable.
|
|
42
|
+
// New entries may only be replayed by the worker that queued them.
|
|
43
|
+
&& (entry.agentId === undefined || entry.agentId === this.agentId);
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
async read() {
|
|
@@ -70,6 +75,7 @@ export class FlambeQueue {
|
|
|
70
75
|
type,
|
|
71
76
|
baseUrl: this.baseUrl,
|
|
72
77
|
traceId: this.traceId,
|
|
78
|
+
...(this.agentId ? { agentId: this.agentId } : {}),
|
|
73
79
|
...(localId ? { localId } : {}),
|
|
74
80
|
...payload,
|
|
75
81
|
});
|
|
@@ -107,7 +113,13 @@ export class FlambeQueue {
|
|
|
107
113
|
} else if (entry.type === 'end' || entry.type === 'suspend' || entry.type === 'resume') {
|
|
108
114
|
const activityId = state.aliases[entry.activityId] ?? entry.activityId;
|
|
109
115
|
const post = { end, suspend, resume }[entry.type];
|
|
110
|
-
await post({
|
|
116
|
+
await post({
|
|
117
|
+
activityId,
|
|
118
|
+
message: entry.message,
|
|
119
|
+
timestamp: entry.timestamp,
|
|
120
|
+
force: entry.force ?? false,
|
|
121
|
+
phase: entry.phase,
|
|
122
|
+
});
|
|
111
123
|
if (entry.type === 'end') delete state.aliases[entry.activityId];
|
|
112
124
|
} else if (entry.type === 'observe') {
|
|
113
125
|
await observe(entry.input);
|
package/src/runtime.mjs
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Explicit transport boundary; never infer local-only intent from a URL. */
|
|
2
|
+
export function runtimeConfig(env) {
|
|
3
|
+
const mode = env.FLAMBE_RUNTIME_MODE?.trim();
|
|
4
|
+
if (!mode) return {};
|
|
5
|
+
if (!['cloud', 'local_self_contained'].includes(mode)) {
|
|
6
|
+
throw new Error('FLAMBE_RUNTIME_MODE must be cloud or local_self_contained');
|
|
7
|
+
}
|
|
8
|
+
return { runtimeMode: mode };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function assertRuntimeUrl(value, mode) {
|
|
12
|
+
if (mode !== 'local_self_contained') return;
|
|
13
|
+
let url;
|
|
14
|
+
try { url = new URL(value); } catch {
|
|
15
|
+
throw new Error('Self-contained mode requires a loopback HTTP URL');
|
|
16
|
+
}
|
|
17
|
+
// Numeric addresses avoid DNS resolution changing the trust boundary.
|
|
18
|
+
if (!['http:', 'https:'].includes(url.protocol)
|
|
19
|
+
|| !['127.0.0.1', '[::1]'].includes(url.hostname)
|
|
20
|
+
|| url.username || url.password) {
|
|
21
|
+
throw new Error('Self-contained mode requires 127.0.0.1 or [::1]; remote destinations are disabled');
|
|
22
|
+
}
|
|
23
|
+
}
|