@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.
@@ -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', '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
- 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' };
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
- 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;
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
- const categoryIds = Array.isArray(activity.categories) ? activity.categories.map(Number) : [];
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 agent = agentId
359
- ? { agent_id: agentId, agent_name: agentDisplayName(agentId, agentName, tokenName) }
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 (thread_id, parent_id, name, description, weight, agent_id, agent_name, export_id)
364
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
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
- agent.agent_id,
372
- agent.agent_name,
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 && entry.traceId === this.traceId;
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({ activityId, message: entry.message, timestamp: entry.timestamp });
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);
@@ -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
+ }