@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/src/client.mjs ADDED
@@ -0,0 +1,549 @@
1
+ import { FlambeQueue } from './queue.mjs';
2
+
3
+ export function hostConfigFromEnv(env = process.env) {
4
+ const required = ['FLAMBE_URL', 'FLAMBE_API_TOKEN'];
5
+ const missing = required.filter(key => !env[key]?.trim());
6
+
7
+ if (missing.length > 0) {
8
+ throw new Error(`Missing required environment variable${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}`);
9
+ }
10
+
11
+ return {
12
+ baseUrl: env.FLAMBE_URL.replace(/\/+$/, ''),
13
+ token: env.FLAMBE_API_TOKEN,
14
+ };
15
+ }
16
+
17
+ export function configFromEnv(env = process.env) {
18
+ const required = ['FLAMBE_URL', 'FLAMBE_API_TOKEN', 'FLAMBE_TRACE_ID'];
19
+ const missing = required.filter(key => !env[key]?.trim());
20
+
21
+ if (missing.length > 0) {
22
+ throw new Error(`Missing required environment variable${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}`);
23
+ }
24
+
25
+ const traceId = Number(env.FLAMBE_TRACE_ID);
26
+ if (!Number.isInteger(traceId) || traceId <= 0) {
27
+ throw new Error('FLAMBE_TRACE_ID must be a positive integer');
28
+ }
29
+
30
+ return {
31
+ baseUrl: env.FLAMBE_URL.replace(/\/+$/, ''),
32
+ ...agentIdentityFromEnv(env),
33
+ token: env.FLAMBE_API_TOKEN,
34
+ traceId,
35
+ };
36
+ }
37
+
38
+ export function agentIdentityFromEnv(env = process.env) {
39
+ const agentId = env.FLAMBE_AGENT_ID?.trim()
40
+ || (env.CODEX_SESSION_ID?.trim() && env.CODEX_THREAD_ID?.trim()
41
+ ? `codex:${env.CODEX_SESSION_ID.trim()}:${env.CODEX_THREAD_ID.trim()}`
42
+ : undefined)
43
+ || (env.CURSOR_AGENT?.trim() && env.CURSOR_CONVERSATION_ID?.trim()
44
+ ? `cursor:${env.CURSOR_CONVERSATION_ID.trim()}`
45
+ : undefined)
46
+ || claudeAgentId(env);
47
+ const agentName = env.FLAMBE_AGENT_NAME?.trim() || undefined;
48
+
49
+ return {
50
+ ...(agentId ? { agentId } : {}),
51
+ ...(agentName ? { agentName } : {}),
52
+ };
53
+ }
54
+
55
+ function firstPresent(...values) {
56
+ for (const value of values) {
57
+ const trimmed = value?.trim();
58
+ if (trimmed) return trimmed;
59
+ }
60
+ }
61
+
62
+ function claudeAgentId(env) {
63
+ const inClaude = env.CLAUDECODE?.trim() || env.CLAUDE_CODE_CHILD_SESSION?.trim();
64
+ if (!inClaude) return undefined;
65
+
66
+ const sessionId = firstPresent(
67
+ env.CLAUDE_CODE_SESSION_ID,
68
+ env.CLAUDE_SESSION_ID,
69
+ env.CLAUDE_CODE_REMOTE_SESSION_ID,
70
+ env.CLAUDE_CODE_BRIDGE_SESSION_ID,
71
+ );
72
+ return sessionId ? `claude:${sessionId}` : undefined;
73
+ }
74
+
75
+ function errorDetail(payload) {
76
+ if (!payload) return null;
77
+ if (typeof payload.error === 'string') return payload.error;
78
+ if (payload.errors) return JSON.stringify(payload.errors);
79
+ return null;
80
+ }
81
+
82
+ export class FlambeClient {
83
+ constructor({ baseUrl, token, traceId, agentId, agentName, fetchImpl = globalThis.fetch, now = Date.now, queuePath, queue, onReducerNote }) {
84
+ this.baseUrl = baseUrl.replace(/\/+$/, '');
85
+ this.token = token;
86
+ this.agentId = agentId;
87
+ this.agentName = agentName;
88
+ this.traceId = Number(traceId);
89
+ this.fetch = fetchImpl;
90
+ this.now = now;
91
+ this.onReducerNote = onReducerNote;
92
+ this.queue = queue ?? new FlambeQueue({ baseUrl: this.baseUrl, traceId: this.traceId, path: queuePath });
93
+ }
94
+
95
+ async request(path, { method = 'GET', body } = {}) {
96
+ let response;
97
+ try {
98
+ response = await this.fetch(`${this.baseUrl}${path}`, {
99
+ method,
100
+ headers: {
101
+ authorization: `Bearer ${this.token}`,
102
+ ...(this.agentId ? { 'x-flambe-agent-id': this.agentId } : {}),
103
+ ...(this.agentId && this.agentName ? { 'x-flambe-agent-name': this.agentName } : {}),
104
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }),
105
+ },
106
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
107
+ });
108
+ } catch (error) {
109
+ error.retryable = true;
110
+ throw error;
111
+ }
112
+
113
+ const text = await response.text();
114
+ let payload = null;
115
+
116
+ if (text) {
117
+ try {
118
+ payload = JSON.parse(text);
119
+ } catch {
120
+ payload = null;
121
+ }
122
+ }
123
+
124
+ if (!response.ok) {
125
+ const detail = errorDetail(payload);
126
+ const error = new Error(`Flambe API request failed (${response.status})${detail ? `: ${detail}` : ''}`);
127
+ error.status = response.status;
128
+ error.retryable = response.status >= 500;
129
+ throw error;
130
+ }
131
+
132
+ return payload;
133
+ }
134
+
135
+ async getTrace() {
136
+ const payload = await this.request(`/api/traces/${this.traceId}`);
137
+ return payload.data;
138
+ }
139
+
140
+ async threads() {
141
+ const trace = await this.getTrace();
142
+ const threads = [...(trace.threads ?? [])]
143
+ .sort((a, b) => (a.rank - b.rank) || (a.id - b.id));
144
+
145
+ return threads.map((thread, index) => ({
146
+ id: thread.id,
147
+ name: thread.name,
148
+ rank: thread.rank,
149
+ default: index === 0,
150
+ }));
151
+ }
152
+
153
+ async categories() {
154
+ const payload = await this.request('/api/categories');
155
+ return payload.data;
156
+ }
157
+
158
+ async status({ activeOnly = false, suspendedOnly = false } = {}) {
159
+ if (activeOnly && suspendedOnly) throw new Error('--active and --suspended cannot be used together');
160
+ const trace = await this.getTrace();
161
+ return this.statusFromTrace(trace, { activeOnly, suspendedOnly });
162
+ }
163
+
164
+ statusFromTrace(trace, { activeOnly = false, suspendedOnly = false } = {}) {
165
+ const threadNames = new Map((trace.threads ?? []).map(thread => [thread.id, thread.name]));
166
+ const latestByActivity = new Map();
167
+ const startedAtByActivity = new Map();
168
+
169
+ for (const event of trace.events ?? []) {
170
+ if (!event.activity) continue;
171
+
172
+ if (event.phase === 'B') {
173
+ const startedAt = startedAtByActivity.get(event.activity.id);
174
+ if (!startedAt || compareEvents(event, startedAt) < 0) startedAtByActivity.set(event.activity.id, event);
175
+ }
176
+
177
+ const current = latestByActivity.get(event.activity.id);
178
+ if (!current || compareEvents(event, current) > 0) {
179
+ latestByActivity.set(event.activity.id, event);
180
+ }
181
+ }
182
+
183
+ const pathsByActivity = new Map();
184
+ const activityById = new Map([...latestByActivity.values()].map(event => [event.activity.id, event.activity]));
185
+ const pathFor = activity => {
186
+ if (pathsByActivity.has(activity.id)) return pathsByActivity.get(activity.id);
187
+ const parent = activity.parent_id == null ? null : activityById.get(activity.parent_id);
188
+ const path = [...(parent ? pathFor(parent) : []), activity.name];
189
+ pathsByActivity.set(activity.id, path);
190
+ return path;
191
+ };
192
+
193
+ const activities = [...latestByActivity.values()]
194
+ .filter(event => !activeOnly || event.phase === 'B' || event.phase === 'R')
195
+ .filter(event => !suspendedOnly || event.phase === 'S')
196
+ .sort(compareEvents)
197
+ .map(event => ({
198
+ id: event.activity.id,
199
+ name: event.activity.name,
200
+ threadId: event.activity.thread.id,
201
+ threadName: threadNames.get(event.activity.thread.id) ?? null,
202
+ parentId: event.activity.parent_id ?? null,
203
+ agentId: event.activity.agent_id ?? null,
204
+ path: pathFor(event.activity),
205
+ categoryIds: event.activity.categories ?? [],
206
+ startedAt: startedAtByActivity.get(event.activity.id)?.timestamp ?? null,
207
+ latestEvent: {
208
+ id: event.id,
209
+ phase: event.phase,
210
+ timestamp: event.timestamp,
211
+ ...(event.message ? { message: event.message } : {}),
212
+ },
213
+ }));
214
+
215
+ return {
216
+ trace: { id: trace.id, name: trace.name },
217
+ activities,
218
+ };
219
+ }
220
+
221
+ resolveThreadId(explicitThreadId, trace) {
222
+ if (explicitThreadId !== undefined && explicitThreadId !== null) {
223
+ const id = Number(explicitThreadId);
224
+ if (!Number.isInteger(id) || id <= 0) throw new Error('--thread must be a positive integer');
225
+ return id;
226
+ }
227
+
228
+ const threads = [...(trace.threads ?? [])].sort((a, b) => (a.rank - b.rank) || (a.id - b.id));
229
+ if (threads.length === 0) throw new Error(`Trace ${this.traceId} has no threads`);
230
+ return threads[0].id;
231
+ }
232
+
233
+ resolveCategoryIds(categoryIds = []) {
234
+ const resolved = categoryIds.map(categoryId => Number(categoryId));
235
+ if (resolved.some(id => !Number.isInteger(id) || id <= 0)) {
236
+ throw new Error('--category must be a positive integer');
237
+ }
238
+ return [...new Set(resolved)];
239
+ }
240
+
241
+ resolveParentId(parentId) {
242
+ if (parentId === undefined || parentId === null) return parentId;
243
+ if (String(parentId).startsWith('offline-')) return parentId;
244
+ const id = Number(parentId);
245
+ if (!Number.isInteger(id) || id <= 0) throw new Error('--parent must be a positive integer');
246
+ return id;
247
+ }
248
+
249
+ resolveStartTimestamp(startedAt) {
250
+ if (startedAt === undefined || startedAt === null) return this.now();
251
+ if (!/(?:Z|[+-]\d{2}:\d{2})$/i.test(startedAt)) {
252
+ throw new Error('--started-at must be an ISO-8601 timestamp with a timezone');
253
+ }
254
+
255
+ const timestamp = Date.parse(startedAt);
256
+ if (!Number.isFinite(timestamp)) {
257
+ throw new Error('--started-at must be a valid ISO-8601 timestamp');
258
+ }
259
+
260
+ return timestamp;
261
+ }
262
+
263
+ async start({ name, description, threadId, parentId, categoryIds, startedAt }) {
264
+ if (!name?.trim()) throw new Error('Activity name is required');
265
+ const timestamp = this.resolveStartTimestamp(startedAt);
266
+ const resolvedCategoryIds = this.resolveCategoryIds(categoryIds);
267
+
268
+ const input = {
269
+ name: name.trim(),
270
+ description,
271
+ threadId,
272
+ parentId: this.resolveParentId(parentId),
273
+ categoryIds: resolvedCategoryIds,
274
+ timestamp,
275
+ };
276
+
277
+ try {
278
+ return await this.postStart(input);
279
+ } catch (error) {
280
+ if (!error?.retryable) throw error;
281
+ return this.queue.enqueue('start', { input });
282
+ }
283
+ }
284
+
285
+ async postStart({ name, description, threadId, parentId, categoryIds, timestamp }) {
286
+ const trace = await this.getTrace();
287
+ const resolvedThreadId = this.resolveThreadId(threadId, trace);
288
+ const activeActivities = this.statusFromTrace(trace, { activeOnly: true }).activities;
289
+ const inferredParentId = activeActivities
290
+ .filter(activity => activity.threadId === resolvedThreadId)
291
+ .at(-1)?.id;
292
+ const resolvedParentId = parentId === undefined
293
+ ? inferredParentId
294
+ : await this.queue.resolveActivityId(parentId);
295
+
296
+ if (String(resolvedParentId).startsWith('offline-')) {
297
+ const error = new Error('Parent activity is queued for offline delivery');
298
+ error.retryable = true;
299
+ throw error;
300
+ }
301
+
302
+ const payload = await this.request('/api/activities', {
303
+ method: 'POST',
304
+ body: {
305
+ trace_id: this.traceId,
306
+ thread_id: resolvedThreadId,
307
+ activity: {
308
+ name,
309
+ ...(description ? { description } : {}),
310
+ ...(resolvedParentId ? { parent_id: resolvedParentId } : {}),
311
+ categories: categoryIds,
312
+ },
313
+ event: {
314
+ timestamp_integer: timestamp,
315
+ phase: 'B',
316
+ },
317
+ },
318
+ });
319
+
320
+ return payload.data.activity.id;
321
+ }
322
+
323
+ async end({ activityId, message, force = false }) {
324
+ if (!force) await this.assertNoOpenChildren(activityId);
325
+ return this.lifecycleEvent({ activityId, message, phase: 'E', queueType: 'end' });
326
+ }
327
+
328
+ /**
329
+ * Worker-side nudge to pop explicitly: refuse ending a parent while any direct child
330
+ * is still active (B/R). Nothing has been proposed yet, so nothing is dropped. With
331
+ * `force` the event is sent and the reducer closes the open descendants (ADR-011).
332
+ * Skipped for offline-* ids (no server truth yet).
333
+ */
334
+ async assertNoOpenChildren(activityId) {
335
+ if (String(activityId).startsWith('offline-')) return;
336
+
337
+ const id = Number(activityId);
338
+ if (!Number.isInteger(id) || id <= 0) return;
339
+
340
+ const { activities } = await this.status({ activeOnly: true });
341
+ const openChildren = activities
342
+ .filter(activity => Number(activity.parentId) === id)
343
+ .sort((left, right) => left.id - right.id);
344
+
345
+ if (openChildren.length === 0) return;
346
+
347
+ const list = openChildren
348
+ .map(activity => `${activity.id} (${activity.name})`)
349
+ .join(', ');
350
+ const ids = openChildren.map(activity => activity.id).join(', ');
351
+ throw new Error(
352
+ `Cannot end activity ${id} while open children remain: ${list}. `
353
+ + `End ${ids} first (or pass --force).`,
354
+ );
355
+ }
356
+
357
+ async suspend({ activityId, message }) {
358
+ return this.lifecycleEvent({ activityId, message, phase: 'S', queueType: 'suspend' });
359
+ }
360
+
361
+ async resume({ activityId, message }) {
362
+ return this.lifecycleEvent({ activityId, message, phase: 'R', queueType: 'resume' });
363
+ }
364
+
365
+ async lifecycleEvent({ activityId, message, phase, queueType }) {
366
+ if (!String(activityId).startsWith('offline-')) {
367
+ const id = Number(activityId);
368
+ if (!Number.isInteger(id) || id <= 0) throw new Error('Activity id must be a positive integer');
369
+ }
370
+
371
+ const timestamp = this.now();
372
+ const input = { activityId, message, timestamp };
373
+
374
+ try {
375
+ const id = await this.queue.resolveActivityId(activityId);
376
+ if (String(id).startsWith('offline-')) {
377
+ await this.queue.enqueue(queueType, input);
378
+ return 'queued';
379
+ }
380
+ const eventId = await this.postLifecycleEvent({ ...input, activityId: id, phase });
381
+ if (phase === 'E') await this.queue.removeAlias(activityId);
382
+ return eventId;
383
+ } catch (error) {
384
+ if (!error?.retryable) throw error;
385
+ await this.queue.enqueue(queueType, input);
386
+ return 'queued';
387
+ }
388
+ }
389
+
390
+ async postLifecycleEvent({ activityId, message, timestamp, phase }) {
391
+ const payload = await this.request('/api/events', {
392
+ method: 'POST',
393
+ body: {
394
+ trace_id: this.traceId,
395
+ activity_id: Number(activityId),
396
+ event: {
397
+ timestamp_integer: timestamp,
398
+ phase,
399
+ ...(message ? { message } : {}),
400
+ },
401
+ },
402
+ });
403
+
404
+ const closed = payload.data.reducer?.closed_descendants ?? [];
405
+ if (closed.length > 0) {
406
+ this.onReducerNote?.({
407
+ type: 'closed_descendants',
408
+ activityId: Number(activityId),
409
+ closedDescendants: closed.map(item => ({
410
+ activityId: item.activity_id,
411
+ activityName: item.activity_name,
412
+ eventId: item.event_id,
413
+ })),
414
+ });
415
+ }
416
+
417
+ return payload.data.id;
418
+ }
419
+
420
+ async flushQueue() {
421
+ return this.queue.flush({
422
+ start: input => this.postStart(input),
423
+ end: input => this.postLifecycleEvent({ ...input, phase: 'E' }),
424
+ suspend: input => this.postLifecycleEvent({ ...input, phase: 'S' }),
425
+ resume: input => this.postLifecycleEvent({ ...input, phase: 'R' }),
426
+ observe: input => this.postObservation(input),
427
+ });
428
+ }
429
+
430
+ async observe({ kind, value, unit, observedOn, payload, at }) {
431
+ if (!kind?.trim()) throw new Error('Observation kind is required');
432
+ const timestamp = this.resolveStartTimestamp(at);
433
+ const input = {
434
+ kind: kind.trim(),
435
+ value,
436
+ unit,
437
+ observedOn,
438
+ payload,
439
+ timestamp,
440
+ };
441
+
442
+ try {
443
+ return await this.postObservation(input);
444
+ } catch (error) {
445
+ if (!error?.retryable) throw error;
446
+ await this.queue.enqueue('observe', { input });
447
+ return 'queued';
448
+ }
449
+ }
450
+
451
+ async importBundle(bundle) {
452
+ return this.request('/api/imports', { method: 'POST', body: bundle });
453
+ }
454
+
455
+ /**
456
+ * Send a free-text update to the Reducer Agent. It answers with assessment /
457
+ * direction / reply and, as the single writer for the stack (ADR-011), may apply at
458
+ * most one activity change, reported in `actions_applied`. Never queued: a stale
459
+ * answer is useless.
460
+ */
461
+ async message({ activityId, text }) {
462
+ if (!text?.trim()) throw new Error('Message text is required');
463
+
464
+ const resolvedActivityId = activityId === undefined
465
+ ? await this.currentActivityId()
466
+ : await this.queue.resolveActivityId(activityId);
467
+
468
+ if (String(resolvedActivityId).startsWith('offline-')) {
469
+ throw new Error('Activity is still queued for offline delivery; retry once Flambe is reachable');
470
+ }
471
+
472
+ const id = Number(resolvedActivityId);
473
+ if (!Number.isInteger(id) || id <= 0) throw new Error('--activity must be a positive integer');
474
+
475
+ const payload = await this.request('/mcp', {
476
+ method: 'POST',
477
+ body: {
478
+ jsonrpc: '2.0',
479
+ id: 1,
480
+ method: 'tools/call',
481
+ params: {
482
+ name: 'flambe_message',
483
+ arguments: {
484
+ trace_id: this.traceId,
485
+ activity_id: id,
486
+ ...(this.agentId ? { agent_id: this.agentId } : {}),
487
+ message: text.trim(),
488
+ },
489
+ },
490
+ },
491
+ });
492
+
493
+ if (payload?.error) {
494
+ throw new Error(`Reducer request failed: ${payload.error.message ?? JSON.stringify(payload.error)}`);
495
+ }
496
+
497
+ const result = payload?.result;
498
+ if (!result || result.isError) {
499
+ const detail = result?.content?.map(item => item.text).filter(Boolean).join(' ');
500
+ throw new Error(detail || 'Reducer returned an error');
501
+ }
502
+
503
+ return { activityId: id, ...result.structuredContent };
504
+ }
505
+
506
+ /** Newest active activity for this agent, falling back to the newest active activity in the flame. */
507
+ async currentActivityId() {
508
+ const { activities } = await this.status({ activeOnly: true });
509
+ const mine = this.agentId ? activities.filter(activity => activity.agentId === this.agentId) : [];
510
+ const current = (mine.length > 0 ? mine : activities).at(-1);
511
+ if (!current) throw new Error('No active activity to message about; pass --activity <id> or run flambe start first');
512
+ return current.id;
513
+ }
514
+
515
+ async postObservation({ kind, value, unit, observedOn, payload, timestamp }) {
516
+ const body = {
517
+ observation: {
518
+ kind,
519
+ value,
520
+ timestamp_integer: timestamp,
521
+ ...(unit ? { unit } : {}),
522
+ ...(observedOn ? { observed_on: observedOn } : {}),
523
+ ...(payload ? { payload } : {}),
524
+ },
525
+ };
526
+
527
+ const response = await this.request('/api/observations', { method: 'POST', body });
528
+ return response.data.id;
529
+ }
530
+ }
531
+
532
+ export function clientFromEnv(env = process.env, overrides = {}) {
533
+ return new FlambeClient({ ...configFromEnv(env), queuePath: env.FLAMBE_QUEUE_PATH, ...overrides });
534
+ }
535
+
536
+ export function clientFromHostEnv(env = process.env, overrides = {}) {
537
+ return new FlambeClient({
538
+ ...hostConfigFromEnv(env),
539
+ ...agentIdentityFromEnv(env),
540
+ traceId: 1,
541
+ queuePath: env.FLAMBE_QUEUE_PATH,
542
+ ...overrides,
543
+ });
544
+ }
545
+
546
+ function compareEvents(a, b) {
547
+ const timestampDifference = Date.parse(a.timestamp) - Date.parse(b.timestamp);
548
+ return timestampDifference || (a.id - b.id);
549
+ }
package/src/env.mjs ADDED
@@ -0,0 +1,21 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { parseEnv } from 'node:util';
4
+
5
+ export function loadProjectEnv({ cwd = process.cwd(), env = process.env } = {}) {
6
+ const envPath = resolve(cwd, '.env');
7
+ let values;
8
+
9
+ try {
10
+ values = parseEnv(readFileSync(envPath, 'utf8'));
11
+ } catch (error) {
12
+ if (error?.code === 'ENOENT') return null;
13
+ throw error;
14
+ }
15
+
16
+ for (const [key, value] of Object.entries(values)) {
17
+ if (env[key] === undefined) env[key] = value;
18
+ }
19
+
20
+ return envPath;
21
+ }
@@ -0,0 +1,24 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ const NAMES = [
4
+ 'Steve', 'Belinda', 'Juniper', 'Marcel', 'Priya', 'Otis', 'Nia', 'Theo',
5
+ 'Carmen', 'Felix', 'Imani', 'Rory', 'Greta', 'Miles', 'Suki',
6
+ ];
7
+
8
+ export function agentDisplayName(instanceId, providedName, tokenName) {
9
+ if (usableName(providedName)) return providedName.trim();
10
+ if (usableName(tokenName)) return tokenName.trim();
11
+ return nameFor(instanceId);
12
+ }
13
+
14
+ function usableName(name) {
15
+ if (typeof name !== 'string') return false;
16
+ const trimmed = name.trim();
17
+ return trimmed !== '' && Buffer.byteLength(trimmed) <= 100
18
+ && !trimmed.includes('\n') && !trimmed.includes('\r');
19
+ }
20
+
21
+ function nameFor(instanceId) {
22
+ const first = createHash('sha256').update(instanceId).digest()[0];
23
+ return NAMES[first % NAMES.length];
24
+ }