@davvidbaker/flambe-cli 0.1.0 → 0.1.1

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.
@@ -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 { agentDisplayName } from './agents.mjs';
6
+ import { pickName, usableName } from './agents.mjs';
7
+
8
+ const LIFECYCLE_PHASES = ['B', 'E', 'S', 'R'];
9
+ const OPEN_PHASES = ['B', 'R'];
7
10
 
8
11
  const SCHEMA = `
9
12
  PRAGMA foreign_keys = ON;
@@ -112,6 +115,17 @@ CREATE TABLE IF NOT EXISTS search_terms (
112
115
  timestamp_ms INTEGER NOT NULL
113
116
  );
114
117
 
118
+ CREATE TABLE IF NOT EXISTS agents (
119
+ id INTEGER PRIMARY KEY,
120
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
121
+ agent_id TEXT NOT NULL,
122
+ name TEXT NOT NULL,
123
+ name_source TEXT NOT NULL,
124
+ platform TEXT,
125
+ last_seen_ms INTEGER NOT NULL,
126
+ UNIQUE (user_id, agent_id)
127
+ );
128
+
115
129
  CREATE TABLE IF NOT EXISTS observations (
116
130
  id INTEGER PRIMARY KEY,
117
131
  user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -127,6 +141,14 @@ CREATE TABLE IF NOT EXISTS observations (
127
141
  export const EXPORT_FORMAT = 'flambe-local-export';
128
142
  export const EXPORT_VERSION = 1;
129
143
 
144
+ export const DEFAULT_CATEGORIES = [
145
+ { name: 'coding', color_background: '#efc360', color_text: '#000000' },
146
+ { name: 'investigation', color_background: '#60a5fa', color_text: '#000000' },
147
+ { name: 'review', color_background: '#a78bfa', color_text: '#ffffff' },
148
+ { name: 'operations', color_background: '#34d399', color_text: '#000000' },
149
+ { name: 'failure', color_background: '#fb7185', color_text: '#000000' },
150
+ ];
151
+
130
152
  export function hashToken(rawToken) {
131
153
  return createHash('sha256').update(rawToken).digest('hex');
132
154
  }
@@ -225,6 +247,7 @@ export class LocalStore {
225
247
  this.db.prepare(
226
248
  'INSERT INTO threads (trace_id, name, rank, export_id) VALUES (?, ?, 0, ?)',
227
249
  ).run(traceId, 'Main', randomUUID());
250
+ this.#ensureDefaultCategories(userId);
228
251
  return this.getTrace(userId, traceId);
229
252
  }
230
253
 
@@ -329,34 +352,122 @@ export class LocalStore {
329
352
  return result.changes > 0;
330
353
  }
331
354
 
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' };
355
+ /**
356
+ * Resolve (and if needed name) the agent behind `agentId` for this user, mirroring
357
+ * FlambeNext.Agents.identify. `assigned` is true only when a name was coined now.
358
+ */
359
+ identifyAgent(userId, agentId, providedName, providedPlatform) {
360
+ if (typeof agentId !== 'string' || agentId.length === 0 || agentId.length > 200) return null;
361
+ const provided = usableName(providedName) ? providedName.trim() : null;
362
+ const platformGiven = usableName(providedPlatform) ? providedPlatform.trim() : null;
363
+ const now = Date.now();
364
+ const existing = this.db.prepare('SELECT * FROM agents WHERE user_id = ? AND agent_id = ?').get(userId, agentId);
365
+
366
+ if (existing) {
367
+ const name = provided && provided !== existing.name ? provided : existing.name;
368
+ const platform = platformGiven ?? existing.platform ?? null;
369
+ this.db.prepare('UPDATE agents SET name = ?, name_source = ?, platform = ?, last_seen_ms = ? WHERE id = ?')
370
+ .run(name, provided && provided !== existing.name ? 'provided' : existing.name_source, platform, now, existing.id);
371
+ return { agent_id: agentId, name, platform, assigned: false };
372
+ }
373
+
374
+ const taken = this.db.prepare('SELECT name FROM agents WHERE user_id = ?').all(userId).map(row => row.name);
375
+ const name = provided ?? pickName(agentId, taken);
376
+ this.db.prepare('INSERT INTO agents (user_id, agent_id, name, name_source, platform, last_seen_ms) VALUES (?, ?, ?, ?, ?, ?)')
377
+ .run(userId, agentId, name, provided ? 'provided' : 'assigned', platformGiven, now);
378
+ return { agent_id: agentId, name, platform: platformGiven, assigned: provided === null };
379
+ }
380
+
381
+ listAgents(userId) {
382
+ 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')
383
+ .all(userId);
384
+ }
385
+
386
+ /** Newest activity in the trace whose latest lifecycle event is B/R, optionally for one agent or thread. */
387
+ newestActiveActivity(traceId, { agentId, threadId } = {}) {
388
+ const placeholders = LIFECYCLE_PHASES.map(() => '?').join(',');
389
+ const open = OPEN_PHASES.map(() => '?').join(',');
390
+ const rows = this.db.prepare(`
391
+ WITH latest AS (
392
+ SELECT activity_id, phase, timestamp_ms, id,
393
+ ROW_NUMBER() OVER (PARTITION BY activity_id ORDER BY timestamp_ms DESC, id DESC) AS rn
394
+ FROM events
395
+ WHERE trace_id = ? AND activity_id IS NOT NULL AND phase IN (${placeholders})
396
+ )
397
+ SELECT activities.id, activities.parent_id, activities.thread_id, activities.agent_id
398
+ FROM latest
399
+ JOIN activities ON activities.id = latest.activity_id
400
+ WHERE latest.rn = 1 AND latest.phase IN (${open})
401
+ ${agentId ? 'AND activities.agent_id = ?' : ''}
402
+ ${threadId ? 'AND activities.thread_id = ?' : ''}
403
+ ORDER BY latest.timestamp_ms DESC, latest.id DESC
404
+ LIMIT 1
405
+ `).all(
406
+ Number(traceId), ...LIFECYCLE_PHASES, ...OPEN_PHASES,
407
+ ...(agentId ? [agentId] : []), ...(threadId ? [Number(threadId)] : []),
408
+ );
409
+ return rows[0] ?? null;
410
+ }
340
411
 
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;
412
+ /**
413
+ * Create an activity. With `reduce` (agent requests, ADR-012) the deterministic reducer
414
+ * rules apply: an absent parent_id infers this agent's newest active activity, a child
415
+ * lives in its parent's thread and inherits categories, and thread_id may be omitted.
416
+ */
417
+ createActivity(userId, { traceId, threadId, activity, event, agent, reduce = false }) {
418
+ const trace = this.db.prepare('SELECT id FROM traces WHERE id = ? AND user_id = ?').get(Number(traceId), userId);
419
+ if (!trace) return { error: 'not_found' };
420
+
421
+ const notes = { parent_source: 'root', thread_source: 'request', categories_source: 'none' };
422
+ let parentRow = null;
423
+ let parentId = null;
424
+
425
+ if (Object.hasOwn(activity, 'parent_id') && activity.parent_id != null) {
426
+ parentRow = this.db.prepare(`
427
+ SELECT activities.id, activities.thread_id FROM activities
428
+ JOIN threads ON threads.id = activities.thread_id
429
+ WHERE activities.id = ? AND threads.trace_id = ?
430
+ `).get(Number(activity.parent_id), trace.id);
431
+ if (!parentRow) return { error: 'not_found' };
432
+ notes.parent_source = 'explicit';
433
+ } else if (reduce && !Object.hasOwn(activity, 'parent_id')) {
434
+ const requestedThread = threadId == null ? this.#defaultThread(trace.id)?.id : Number(threadId);
435
+ parentRow = agent?.agent_id
436
+ ? this.newestActiveActivity(trace.id, { agentId: agent.agent_id })
437
+ : this.newestActiveActivity(trace.id, { threadId: requestedThread });
438
+ if (parentRow) notes.parent_source = 'inferred';
348
439
  }
349
440
 
350
- const categoryIds = Array.isArray(activity.categories) ? activity.categories.map(Number) : [];
441
+ let thread;
442
+ if (parentRow) {
443
+ thread = this.db.prepare('SELECT id, trace_id FROM threads WHERE id = ?').get(parentRow.thread_id);
444
+ notes.thread_source = 'parent';
445
+ parentId = parentRow.id;
446
+ } else if (threadId == null) {
447
+ if (!reduce) return { error: 'not_found' };
448
+ thread = this.#defaultThread(trace.id);
449
+ notes.thread_source = 'default';
450
+ } else {
451
+ thread = this.db.prepare('SELECT id, trace_id FROM threads WHERE id = ? AND trace_id = ?').get(Number(threadId), trace.id);
452
+ }
453
+ if (!thread) return { error: 'not_found' };
454
+ if (!reduce && parentRow && parentRow.thread_id !== thread.id) return { error: 'not_found' };
455
+
456
+ let categoryIds = Array.isArray(activity.categories) ? activity.categories.map(Number) : [];
351
457
  if (categoryIds.length > 0) {
352
458
  const found = this.db.prepare(
353
459
  `SELECT id FROM categories WHERE user_id = ? AND id IN (${categoryIds.map(() => '?').join(',')})`,
354
460
  ).all(userId, ...categoryIds);
355
461
  if (found.length !== new Set(categoryIds).size) return { error: 'not_found' };
462
+ notes.categories_source = 'request';
463
+ } else if (reduce && parentId != null) {
464
+ categoryIds = this.db.prepare('SELECT category_id FROM activities_categories WHERE activity_id = ?')
465
+ .all(parentId).map(row => row.category_id);
466
+ if (categoryIds.length > 0) notes.categories_source = 'parent';
356
467
  }
357
468
 
358
- const agent = agentId
359
- ? { agent_id: agentId, agent_name: agentDisplayName(agentId, agentName, tokenName) }
469
+ const agentColumns = agent?.agent_id
470
+ ? { agent_id: agent.agent_id, agent_name: agent.name }
360
471
  : { agent_id: null, agent_name: null };
361
472
 
362
473
  const inserted = this.db.prepare(`
@@ -368,8 +479,8 @@ export class LocalStore {
368
479
  activity.name,
369
480
  activity.description ?? null,
370
481
  activity.weight ?? null,
371
- agent.agent_id,
372
- agent.agent_name,
482
+ agentColumns.agent_id,
483
+ agentColumns.agent_name,
373
484
  randomUUID(),
374
485
  );
375
486
  const activityId = Number(inserted.lastInsertRowid);
@@ -395,9 +506,14 @@ export class LocalStore {
395
506
  thread_id: thread.id,
396
507
  },
397
508
  event: { id: Number(eventRow.lastInsertRowid), phase: event.phase },
509
+ ...(reduce ? { reducer: notes } : {}),
398
510
  };
399
511
  }
400
512
 
513
+ #defaultThread(traceId) {
514
+ return this.db.prepare('SELECT id, trace_id FROM threads WHERE trace_id = ? ORDER BY rank, id LIMIT 1').get(traceId) ?? null;
515
+ }
516
+
401
517
  getActivity(userId, id) {
402
518
  const row = this.db.prepare(`
403
519
  SELECT activities.*
@@ -713,6 +829,26 @@ export class LocalStore {
713
829
  };
714
830
  }
715
831
 
832
+ #ensureDefaultCategories(userId) {
833
+ const existing = this.db.prepare(
834
+ 'SELECT COUNT(*) AS count FROM categories WHERE user_id = ?',
835
+ ).get(userId);
836
+ if (existing.count > 0) return;
837
+
838
+ const insert = this.db.prepare(
839
+ 'INSERT INTO categories (user_id, name, color_background, color_text, export_id) VALUES (?, ?, ?, ?, ?)',
840
+ );
841
+ for (const category of DEFAULT_CATEGORIES) {
842
+ insert.run(
843
+ userId,
844
+ category.name,
845
+ category.color_background,
846
+ category.color_text,
847
+ randomUUID(),
848
+ );
849
+ }
850
+ }
851
+
716
852
  #seed() {
717
853
  const user = this.db.prepare('SELECT id FROM users WHERE id = 1').get();
718
854
  if (user) return;
@@ -730,6 +866,7 @@ export class LocalStore {
730
866
  this.db.prepare(
731
867
  'INSERT INTO threads (trace_id, name, rank, export_id) VALUES (?, ?, 0, ?)',
732
868
  ).run(Number(trace.lastInsertRowid), 'Main', randomUUID());
869
+ this.#ensureDefaultCategories(1);
733
870
  this.db.exec('COMMIT');
734
871
  } catch (error) {
735
872
  this.db.exec('ROLLBACK');