@davvidbaker/flambe-cli 0.1.2 → 0.1.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @davvidbaker/flambe-cli
2
2
 
3
- Zero-dependency Node 22 CLI that streams coding-agent work into a Flambe trace.
3
+ Node 22 CLI that streams coding-agent work into a Flambe trace.
4
4
 
5
5
  ## Install
6
6
 
@@ -61,3 +61,16 @@ FLAMBE_TRACE_ID=1
61
61
  Mint a token in Flambe Settings → API tokens. See the
62
62
  [repository README](https://github.com/davvidbaker/flambe#connect-coding-agents)
63
63
  for commands (`start`, `end`, `message`, `observe`, local `serve`).
64
+
65
+ ## Single Burner AI review
66
+
67
+ When running `flambe serve`, optional AI review is configured in that server
68
+ process with `FLAMBE_LOCAL_REDUCER_MODE=off|agent|api|local`. It is off by default;
69
+ provider failures never reroute work to Cloud. See the
70
+ [Single Burner configuration guide](../single-burner/README.md#optional-ai-reducer)
71
+ for model, endpoint, credential, timeout, and data-boundary settings. Client
72
+ processes only need their normal local Flambe connection settings.
73
+
74
+ In `agent` mode, your current work AI reads the review returned by `flambe message`
75
+ and submits its decision with `flambe decide <review-id> --file <decision.json>`.
76
+ No separate model or inference API key is required.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davvidbaker/flambe-cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "CLI for streaming coding-agent activity into Flambe",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,5 +30,9 @@
30
30
  },
31
31
  "publishConfig": {
32
32
  "access": "public"
33
+ },
34
+ "dependencies": {
35
+ "@modelcontextprotocol/node": "2.1.1",
36
+ "@modelcontextprotocol/server": "2.3.1"
33
37
  }
34
38
  }
package/src/cli.mjs CHANGED
@@ -27,6 +27,7 @@ const usage = `Usage:
27
27
  flambe ping
28
28
  flambe whoami [--json]
29
29
  flambe message <text> [--activity <id>] [--json]
30
+ flambe decide <review-id> --file <decision.json>
30
31
  flambe observe <kind> <value> [--unit <unit>] [--on <YYYY-MM-DD>] [--payload <json>] [--at <ISO-8601>]
31
32
  flambe serve [--port <n>] [--db <path>] [--static <dir>]
32
33
  flambe export [file] [--db <path>]
@@ -55,6 +56,7 @@ Optional:
55
56
  FLAMBE_QUEUE_PATH Local offline queue path (default: ~/.flambe/event-queue.json)
56
57
  FLAMBE_LOCAL_DB SQLite path for flambe serve / export (default: ~/.flambe/local.sqlite)
57
58
  FLAMBE_RUNTIME_MODE local_self_contained restricts CLI requests to numeric loopback; cloud preserves hosted access
59
+ FLAMBE_LOCAL_REDUCER_MODE off (default), agent, api, or local; see the Single Burner README
58
60
 
59
61
  serve / export do not need FLAMBE_URL. import needs FLAMBE_URL and FLAMBE_API_TOKEN (the production instance).`;
60
62
 
@@ -336,6 +338,11 @@ function formatReducerNote(note) {
336
338
  const lines = [];
337
339
  if (note.direction) lines.push(`direction\t${note.direction}\n`);
338
340
  if (note.reply) lines.push(`reply\t${note.reply}\n`);
341
+ if (note.modelReview?.status === 'available') lines.push(`review available\tRequest flambe message --activity ${note.activityId} when scope judgment is needed.\n`);
342
+ if (note.review) lines.push(`review\t${JSON.stringify(note.review)}\n`);
343
+ if (note.modelReview?.status === 'failed') {
344
+ lines.push(`model review\t${note.modelReview.code}: ${note.modelReview.message} Recorded work was preserved.\n`);
345
+ }
339
346
  if (note.actionsApplied?.length) {
340
347
  lines.push(`actions\t${note.actionsApplied.map(formatAction).join('; ')}\n`);
341
348
  }
@@ -364,7 +371,7 @@ export async function run(argv, { env = process.env, stdout = process.stdout, st
364
371
  if (command === 'serve') {
365
372
  const options = parseServe(args, env);
366
373
  const { listenLocal, printServeBanner } = await loadServe();
367
- const listening = await listenLocal(options);
374
+ const listening = await listenLocal({ ...options, reducer: { env } });
368
375
  printServeBanner(listening, stdout);
369
376
  await new Promise((resolveClose, reject) => {
370
377
  listening.server.on('close', resolveClose);
@@ -518,11 +525,24 @@ export async function run(argv, { env = process.env, stdout = process.stdout, st
518
525
  return;
519
526
  }
520
527
 
528
+ if (command === 'decide') {
529
+ const { value: file, rest } = takeOption(args, '--file');
530
+ if (!file || rest.length !== 1 || rest[0].startsWith('--')) {
531
+ throw new Error('Usage: flambe decide <review-id> --file <decision.json>');
532
+ }
533
+ const content = readFileSync(resolve(file), 'utf8');
534
+ if (Buffer.byteLength(content) > 65_536) throw new Error('Decision file exceeds the size limit');
535
+ const result = await flambe.decide({ reviewId: rest[0], decision: JSON.parse(content) });
536
+ if (persistQuestions && !(result.actions_applied ?? []).some(a => a.type === 'ask')) clearQuestion(traceId, questionFile);
537
+ stdout.write(`${JSON.stringify(result)}\n`);
538
+ return;
539
+ }
540
+
521
541
  if (command === 'message') {
522
542
  const { text, activityId, json } = parseMessage(args);
523
543
  const decision = await flambe.message({ activityId, text });
524
- if (persistQuestions) clearQuestion(traceId, questionFile);
525
- if (json) {
544
+ if (persistQuestions && !decision.review) clearQuestion(traceId, questionFile);
545
+ if (json || decision.review) {
526
546
  stdout.write(`${JSON.stringify(decision)}\n`);
527
547
  } else {
528
548
  stdout.write(`activity\t${decision.activityId}\n`);
package/src/client.mjs CHANGED
@@ -155,6 +155,7 @@ export class FlambeClient {
155
155
  || REQUIRED_AGENT_COMMANDS.some(command => !commands.includes(command))) {
156
156
  throw new Error('Flambe API returned an invalid agent-command capability document');
157
157
  }
158
+ this.agentCommandDetails = payload.data;
158
159
  return true;
159
160
  })
160
161
  .catch(error => {
@@ -636,13 +637,15 @@ export class FlambeClient {
636
637
  const actions = result.actions_applied ?? [];
637
638
  const direction = result.direction ?? null;
638
639
  const reply = result.reply ?? null;
639
- if (!direction && !reply && actions.length === 0) return;
640
+ if (!direction && !reply && actions.length === 0 && !result.model_review) return;
640
641
  this.onReducerNote?.({
641
642
  type: 'command_result',
642
643
  activityId: result.activity_id,
643
644
  direction,
644
645
  reply,
645
646
  actionsApplied: actions,
647
+ ...(result.model_review ? { modelReview: result.model_review } : {}),
648
+ ...(result.review ? { review: result.review } : {}),
646
649
  asked: actions.some(action => action.type === 'ask'),
647
650
  });
648
651
  }
@@ -699,6 +702,16 @@ export class FlambeClient {
699
702
  return this.request('/api/imports', { method: 'POST', body: bundle });
700
703
  }
701
704
 
705
+ /** Work-AI decisions are never queued or sent to a substitute connection. */
706
+ async decide({ reviewId, decision }) {
707
+ if (!(await this.supportsAgentCommands()) || !this.agentCommandDetails?.reducer?.work_ai_reviews) {
708
+ throw new Error('This connection does not support work-AI reviews. Configure agent reducer mode on Single Burner.');
709
+ }
710
+ const result = await this.agentCommand('decide', { ...this.commandIdentity(), review_id: reviewId, decision });
711
+ this.reportCommandResult(result);
712
+ return result;
713
+ }
714
+
702
715
  /**
703
716
  * Send a free-text update to the Reducer Agent. It answers with assessment /
704
717
  * direction / reply and, as the single writer for the stack (ADR-011), may apply at
@@ -726,7 +739,7 @@ export class FlambeClient {
726
739
  message: text.trim(),
727
740
  });
728
741
  const decision = { activityId: result.activity_id ?? resolvedActivityId, ...result };
729
- this.reportCommandResult(result);
742
+ if (!result.review) this.reportCommandResult(result);
730
743
  return decision;
731
744
  }
732
745
 
@@ -92,7 +92,7 @@ export function commandState(trace, filters = {}) {
92
92
  }
93
93
 
94
94
  /** A command and all of its corrections commit together, or not at all. */
95
- export function executeCommand(store, userId, command, attrs, headerAgent = null) {
95
+ export function executeCommand(store, userId, command, attrs, headerAgent = null, { modelReviewAvailable = false, reviewStarts = true } = {}) {
96
96
  if (!COMMANDS.includes(command) || !attrs || typeof attrs !== 'object' || Array.isArray(attrs)) {
97
97
  invalid('command and arguments are required');
98
98
  }
@@ -112,6 +112,8 @@ export function executeCommand(store, userId, command, attrs, headerAgent = null
112
112
  include_unstarted: bool(attrs.include_unstarted, 'include_unstarted') };
113
113
  if (filters.active_only && filters.suspended_only) invalid('active_only and suspended_only cannot both be true');
114
114
  const result = { direction: null, reply: null, actions_applied: [], rules_fired: [], closed_descendants: [] };
115
+ const structuralReview = !modelReviewAvailable ? { type: 'skipped', reason: 'model_unavailable' }
116
+ : reviewStarts ? { type: 'pending_model_review' } : { type: 'available', reason: 'work_ai_review_on_request' };
115
117
  if (command === 'status') {
116
118
  const state = commandState(trace, filters);
117
119
  if (filters.include_unstarted && !filters.active_only && !filters.suspended_only) {
@@ -150,7 +152,7 @@ export function executeCommand(store, userId, command, attrs, headerAgent = null
150
152
  requiredText(attrs.message, 'message');
151
153
  if (attrs.activity_id != null && !byId.has(positiveId(attrs.activity_id, 'activity_id'))) notFound();
152
154
  throw new CommandError(503, 'REDUCER_NOT_CONFIGURED',
153
- 'Model review is unavailable in the self-contained runtime. Lifecycle commands remain available; no content was sent to a provider.');
155
+ 'AI reducer review is off. Configure FLAMBE_LOCAL_REDUCER_MODE to enable it. Lifecycle commands remain available; no content was sent to a provider.');
154
156
  }
155
157
  if (attrs.message != null && typeof attrs.message !== 'string') invalid('message must be a string');
156
158
  if (attrs.description != null && typeof attrs.description !== 'string') invalid('description must be a string');
@@ -246,12 +248,12 @@ export function executeCommand(store, userId, command, attrs, headerAgent = null
246
248
  if (result.actions_applied.length) annotate(result.activity_id, 'resume_ancestor',
247
249
  result.actions_applied.map(a => a.activity_id));
248
250
  if (!parent && agentId && before.activities.some(a => a.agentId === agentId && a.status === 'running')) {
249
- annotate(result.activity_id, 'new_root_while_open', { type: 'skipped', reason: 'model_unavailable' });
251
+ annotate(result.activity_id, 'new_root_while_open', structuralReview);
250
252
  } else if (parent) {
251
253
  const ancestorTokens = new Set(ancestors(parent).flatMap(a => tokens(a.name)));
252
254
  const childTokens = tokens(name);
253
255
  if (childTokens.length && childTokens.every(t => !ancestorTokens.has(t))) {
254
- annotate(result.activity_id, 'name_unfit', { type: 'skipped', reason: 'model_unavailable' });
256
+ annotate(result.activity_id, 'name_unfit', structuralReview);
255
257
  }
256
258
  }
257
259
  }
@@ -0,0 +1,98 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+
3
+ /** Signed reciprocal creation history; snapshots never accept client-supplied row contents. */
4
+ export function reverseCreation(store, userId, traceId, body) {
5
+ traceId = Number(traceId);
6
+ if (!store.getTrace(userId, traceId)) return { status: 404, json: { error: 'NOT_FOUND' } };
7
+ const db = store.db;
8
+ const row = (table, id) => db.prepare(`SELECT * FROM ${table} WHERE id = ?`).get(Number(id));
9
+ const rows = (table, activityId) => db.prepare(`SELECT * FROM ${table} WHERE activity_id = ? ORDER BY id`).all(activityId);
10
+ const children = id => db.prepare('SELECT id, parent_id, thread_id FROM activities WHERE parent_id = ? ORDER BY id').all(id);
11
+ const categories = id => db.prepare('SELECT category_id FROM activities_categories WHERE activity_id = ? ORDER BY category_id').all(id).map(c => c.category_id);
12
+ const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b);
13
+ const fail = () => { throw new Error('HISTORY_CHANGED'); };
14
+ const signature = value => createHmac('sha256', store.credentials().rawToken).update(`creation-history-v1:${value}`).digest();
15
+ const sign = record => {
16
+ const value = Buffer.from(JSON.stringify(record)).toString('base64url');
17
+ return `${value}.${signature(value).toString('base64url')}`;
18
+ };
19
+ const insert = (table, data) => {
20
+ // Column names come from this database, not the restoration token.
21
+ const columns = db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name);
22
+ db.prepare(`INSERT INTO ${table} (${columns.join(',')}) VALUES (${columns.map(() => '?').join(',')})`)
23
+ .run(...columns.map(column => data[column] ?? null));
24
+ };
25
+ let record;
26
+ if (typeof body.token === 'string') {
27
+ try {
28
+ const [value, mac, extra] = body.token.split('.');
29
+ const expected = signature(value);
30
+ const supplied = Buffer.from(mac ?? '', 'base64url');
31
+ if (extra || supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) throw new Error();
32
+ record = JSON.parse(Buffer.from(value, 'base64url').toString());
33
+ if (record.userId !== userId || record.traceId !== traceId || !['remove', 'restore'].includes(record.mode)) throw new Error();
34
+ } catch { return { status: 422, json: { error: 'INVALID_HISTORY' } }; }
35
+ }
36
+ db.exec('SAVEPOINT history_reverse');
37
+ try {
38
+ if (!record) {
39
+ const id = Number(body.target_id);
40
+ const activity = body.kind === 'activity' ? store.getActivity(userId, id) : null;
41
+ const event = body.kind === 'event' ? row('events', id) : null;
42
+ if (activity && (store.getThread(userId, activity.thread_id) && row('threads', activity.thread_id)?.trace_id) === traceId) {
43
+ record = { userId, traceId, mode: 'remove', snapshot: { kind: 'activity',
44
+ activity: row('activities', id), events: rows('events', id), categories: categories(id), children: children(id) } };
45
+ } else if (event?.trace_id === traceId) {
46
+ let weight = null;
47
+ if (body.restore_weight) {
48
+ const activityId = Number(body.restore_weight.activity_id);
49
+ const old = body.restore_weight.weight;
50
+ if (activityId !== event.activity_id || (old !== null && !Number.isInteger(old))) fail();
51
+ weight = { activity_id: activityId, before: row('activities', activityId).weight, after: old };
52
+ }
53
+ record = { userId, traceId, mode: 'remove', snapshot: { kind: 'event', event, weight } };
54
+ } else fail();
55
+ }
56
+ const { snapshot, mode } = record;
57
+ if (snapshot.kind === 'activity') {
58
+ const activity = snapshot.activity;
59
+ if ((store.getThread(userId, activity.thread_id) && row('threads', activity.thread_id)?.trace_id) !== traceId) fail();
60
+ if (mode === 'remove') {
61
+ if (!equal(row('activities', activity.id), activity) || !equal(rows('events', activity.id), snapshot.events)
62
+ || !equal(categories(activity.id), snapshot.categories) || !equal(children(activity.id), snapshot.children)) fail();
63
+ db.prepare('DELETE FROM activities WHERE id = ?').run(activity.id);
64
+ } else {
65
+ if (row('activities', activity.id)) fail();
66
+ if (activity.parent_id && row('activities', activity.parent_id)?.thread_id !== activity.thread_id) fail();
67
+ for (const child of snapshot.children) {
68
+ const current = store.getActivity(userId, child.id);
69
+ if (!current || current.parent_id !== null || current.thread_id !== child.thread_id) fail();
70
+ }
71
+ for (const event of snapshot.events) if (row('events', event.id) || event.trace_id !== traceId || event.activity_id !== activity.id) fail();
72
+ for (const id of snapshot.categories) if (row('categories', id)?.user_id !== userId) fail();
73
+ insert('activities', activity);
74
+ for (const event of snapshot.events) insert('events', event);
75
+ for (const id of snapshot.categories) db.prepare('INSERT INTO activities_categories (activity_id, category_id) VALUES (?, ?)').run(activity.id, id);
76
+ for (const child of snapshot.children) db.prepare('UPDATE activities SET parent_id = ? WHERE id = ?').run(activity.id, child.id);
77
+ }
78
+ } else if (snapshot.kind === 'event') {
79
+ const event = snapshot.event;
80
+ if (event.trace_id !== traceId || !store.getActivity(userId, event.activity_id)) fail();
81
+ if (mode === 'remove' ? !equal(row('events', event.id), event) : Boolean(row('events', event.id))) fail();
82
+ if (snapshot.weight) {
83
+ const weight = snapshot.weight;
84
+ if (weight.activity_id !== event.activity_id) fail();
85
+ if (row('activities', weight.activity_id).weight !== (mode === 'remove' ? weight.before : weight.after)) fail();
86
+ db.prepare('UPDATE activities SET weight = ? WHERE id = ?').run(mode === 'remove' ? weight.after : weight.before, weight.activity_id);
87
+ }
88
+ if (mode === 'remove') db.prepare('DELETE FROM events WHERE id = ?').run(event.id);
89
+ else insert('events', event);
90
+ } else fail();
91
+ const inverse = sign({ ...record, mode: mode === 'remove' ? 'restore' : 'remove' });
92
+ db.exec('RELEASE history_reverse');
93
+ return { status: 200, json: { data: { undo_token: inverse } } };
94
+ } catch {
95
+ db.exec('ROLLBACK TO history_reverse; RELEASE history_reverse');
96
+ return { status: 409, json: { error: 'HISTORY_CHANGED' } };
97
+ }
98
+ }
@@ -1,5 +1,6 @@
1
1
  import { createReadStream, existsSync, statSync } from 'node:fs';
2
2
  import { extname, join, normalize, resolve } from 'node:path';
3
+ import { reverseCreation } from './history.mjs';
3
4
  import { COMMANDS, CommandError, executeCommand } from './commands.mjs';
4
5
 
5
6
  function sendJson(res, status, body) {
@@ -112,7 +113,7 @@ function agentHeaders(agent) {
112
113
  };
113
114
  }
114
115
 
115
- async function dispatch(store, req, url, body, { auth, agent }) {
116
+ async function dispatch(store, req, url, body, { auth, agent, runCommand }) {
116
117
  const method = req.method ?? 'GET';
117
118
  const path = url.pathname;
118
119
 
@@ -146,13 +147,18 @@ async function dispatch(store, req, url, body, { auth, agent }) {
146
147
 
147
148
  if (path === '/api/agent-commands') {
148
149
  if (method === 'GET') return { status: 200, json: { data: {
149
- version: 1, commands: COMMANDS, runtime_mode: 'local_self_contained', model_review: false,
150
+ version: 1, commands: [...COMMANDS, ...(runCommand.info?.work_ai_reviews ? ['decide'] : [])], runtime_mode: 'local_self_contained',
151
+ model_review: runCommand.info?.enabled ?? false, reducer: runCommand.info ?? { mode: 'off' },
150
152
  } } };
151
153
  if (method === 'POST') return { status: 200, json: {
152
- data: executeCommand(store, userId, body.command, body.arguments, agent),
154
+ data: await runCommand(userId, body.command, body.arguments, agent),
153
155
  } };
154
156
  }
155
157
 
158
+ if (method === 'GET' && path === '/api/agents') {
159
+ return { status: 200, json: { data: store.listAgents(userId) } };
160
+ }
161
+
156
162
  if (method === 'GET' && path === '/api/agents/me') {
157
163
  if (!agent) {
158
164
  return { status: 400, json: { error: 'AGENT_ID_REQUIRED', detail: 'Send x-flambe-agent-id with a bearer token' } };
@@ -212,6 +218,9 @@ async function dispatch(store, req, url, body, { auth, agent }) {
212
218
  return { status: 200, json: { data: { threads: result.threads } } };
213
219
  }
214
220
 
221
+ const historyMatch = path.match(/^\/api\/traces\/(\d+)\/reverse_creation$/);
222
+ if (method === 'POST' && historyMatch) return reverseCreation(store, userId, historyMatch[1], body);
223
+
215
224
  const traceMatch = path.match(/^\/api\/traces\/(\d+)$/);
216
225
  if (traceMatch) {
217
226
  const id = traceMatch[1];
@@ -257,9 +266,21 @@ async function dispatch(store, req, url, body, { auth, agent }) {
257
266
  }
258
267
 
259
268
  if (method === 'POST' && path === '/api/activities') {
269
+ if (!Object.hasOwn(body, 'event') && auth.tokenName !== null) {
270
+ const activity = body.activity ?? {};
271
+ const planned = await runCommand(userId, 'plan', {
272
+ trace_id: body.trace_id, name: activity.name, description: activity.description,
273
+ weight: activity.weight, category_ids: activity.categories,
274
+ scheduled_start: activity.scheduled_start_integer,
275
+ scheduled_end: activity.scheduled_end_integer,
276
+ }, agent);
277
+ return { status: 201, json: { data: {
278
+ activity: store.getActivity(userId, planned.activity_id), event: null,
279
+ } } };
280
+ }
260
281
  if (auth.tokenName !== null) {
261
282
  const activity = body.activity ?? {};
262
- const folded = executeCommand(store, userId, 'start', {
283
+ const folded = await runCommand(userId, 'start', {
263
284
  trace_id: body.trace_id, thread_id: body.thread_id, name: activity.name,
264
285
  description: activity.description, category_ids: activity.categories,
265
286
  ...(Object.hasOwn(activity, 'parent_id') ? { parent_id: activity.parent_id } : {}),
@@ -271,14 +292,14 @@ async function dispatch(store, req, url, body, { auth, agent }) {
271
292
  phase: folded.state.activities.find(a => a.id === folded.activity_id)?.latestEvent.phase },
272
293
  reducer: folded.reducer ?? { actions_applied: folded.actions_applied },
273
294
  direction: folded.direction, reply: folded.reply, rules_fired: folded.rules_fired,
274
- actions_applied: folded.actions_applied,
295
+ actions_applied: folded.actions_applied, model_review: folded.model_review, review: folded.review,
275
296
  } } };
276
297
  }
277
298
  const result = store.createActivity(userId, {
278
299
  traceId: body.trace_id,
279
300
  threadId: body.thread_id,
280
301
  activity: body.activity ?? {},
281
- event: body.event ?? {},
302
+ event: body.event,
282
303
  agent,
283
304
  });
284
305
  if (result.error) return { status: 404, json: { error: 'NOT_FOUND' } };
@@ -315,7 +336,7 @@ async function dispatch(store, req, url, body, { auth, agent }) {
315
336
  const phase = body.event?.phase;
316
337
  const command = { E: 'end', J: 'end', V: 'end', S: 'suspend', R: 'resume' }[phase];
317
338
  if (auth.tokenName !== null && command) {
318
- const folded = executeCommand(store, userId, command, {
339
+ const folded = await runCommand(userId, command, {
319
340
  trace_id: body.trace_id, activity_id: body.activity_id,
320
341
  timestamp: body.event?.timestamp_integer, message: body.event?.message,
321
342
  phase, force: body.force ?? true,
@@ -324,7 +345,7 @@ async function dispatch(store, req, url, body, { auth, agent }) {
324
345
  id: folded.event_id, phase,
325
346
  reducer: { closed_descendants: folded.closed_descendants,
326
347
  rules_fired: folded.rules_fired, actions_applied: folded.actions_applied,
327
- direction: folded.direction, reply: folded.reply },
348
+ direction: folded.direction, reply: folded.reply, model_review: folded.model_review },
328
349
  } } };
329
350
  }
330
351
  const result = store.createEvent(userId, {
@@ -417,7 +438,8 @@ function tryStatic(staticDir, urlPath, res) {
417
438
  return true;
418
439
  }
419
440
 
420
- export async function handleLocalRequest(store, req, res, { staticDir } = {}) {
441
+ export async function handleLocalRequest(store, req, res, { staticDir, mcp,
442
+ runCommand = (userId, command, args, agent) => executeCommand(store, userId, command, args, agent) } = {}) {
421
443
  const host = req.headers.host ?? '127.0.0.1';
422
444
  const url = new URL(req.url ?? '/', `http://${host}`);
423
445
  // A locally served UI must not send trace content to a compiled-in remote API.
@@ -436,6 +458,22 @@ export async function handleLocalRequest(store, req, res, { staticDir } = {}) {
436
458
  }
437
459
  }
438
460
 
461
+ // Single Burner has no OAuth discovery documents. Do not serve the SPA (or
462
+ // its setup credentials) to MCP clients probing standard metadata paths.
463
+ if (url.pathname.startsWith('/.well-known/')) {
464
+ sendJson(res, 404, { error: 'NOT_FOUND' });
465
+ return;
466
+ }
467
+
468
+ if (url.pathname === '/mcp' && mcp) {
469
+ if (req.headers.origin) {
470
+ sendJson(res, 403, { error: 'MCP_BROWSER_ORIGIN_FORBIDDEN' });
471
+ return;
472
+ }
473
+ await mcp.handle(req, res);
474
+ return;
475
+ }
476
+
439
477
  if (!url.pathname.startsWith('/api') && !url.pathname.startsWith('/auth')) {
440
478
  if (tryStatic(staticDir, url.pathname, res)) return;
441
479
  const { rawToken, traceId } = store.credentials();
@@ -465,7 +503,7 @@ FLAMBE_TRACE_ID=${traceId}</pre>
465
503
  const agent = identifyAgent(store, req, auth);
466
504
  let result;
467
505
  try {
468
- result = await dispatch(store, req, url, body, { auth, agent });
506
+ result = await dispatch(store, req, url, body, { auth, agent, runCommand });
469
507
  } catch (error) {
470
508
  if (!(error instanceof CommandError)) throw error;
471
509
  result = { status: error.status, json: error.body };
@@ -0,0 +1,92 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { Server, createMcpHandler, fromJsonSchema, ProtocolError } from '@modelcontextprotocol/server';
3
+ import { toNodeHandler } from '@modelcontextprotocol/node';
4
+ import { CommandError, executeCommand } from './commands.mjs';
5
+ import { schema as decisionSchema } from './reducer-review.mjs';
6
+
7
+ const baseTools = JSON.parse(readFileSync(new URL('../mcp-tools.json', import.meta.url), 'utf8'));
8
+ const decideTool = {
9
+ name: 'flambe_decide', title: 'Submit your Single Burner review decision',
10
+ description: 'Submit the current work AI decision for a review returned by flambe_message or a flagged start. Use that exact review_id, trace, instance, and agent_id. Single Burner validates and applies it locally, without another model call. Identical retries return a receipt; stale reviews require a fresh flambe_message. This is the work AI reviewing its own context, not independent approval.',
11
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
12
+ inputSchema: { type: 'object', additionalProperties: false,
13
+ required: ['expected_instance_id', 'trace_id', 'agent_id', 'review_id', 'decision'],
14
+ properties: {
15
+ ...Object.fromEntries(['expected_instance_id', 'trace_id', 'agent_id'].map(key =>
16
+ [key, baseTools.find(t => t.name === 'flambe_message').inputSchema.properties[key]])),
17
+ review_id: { type: 'string', minLength: 1, description: 'Exact review.review_id returned by this Single Burner connection.' },
18
+ decision: decisionSchema,
19
+ } },
20
+ };
21
+
22
+ function result(instance, data, isError = false) {
23
+ const structuredContent = { ...data, instance };
24
+ return { content: [{ type: 'text', text: JSON.stringify(structuredContent) }], structuredContent, isError };
25
+ }
26
+
27
+ /** Stateless MCP transport; the existing SQLite command engine owns all mutations. */
28
+ export function createLocalMcpHandler(store, runCommand = (userId, command, args, agent) => executeCommand(store, userId, command, args, agent)) {
29
+ const tools = [...baseTools, ...(runCommand.info?.work_ai_reviews ? [decideTool] : [])];
30
+ const validators = new Map(tools.map(tool => [tool.name, fromJsonSchema(tool.inputSchema)['~standard']]));
31
+ const reviewDescription = runCommand.info?.work_ai_reviews
32
+ ? ' Work-AI review: this returns review context and a review_id. Reason over it and submit your decision with flambe_decide, using the same agent_id. No separate model is called.'
33
+ : ` AI reducer mode: ${runCommand.info?.mode ?? 'off'}; API mode sends activity context to the configured model provider, local mode uses a loopback model endpoint.`;
34
+ const handler = createMcpHandler(context => {
35
+ const instance = store.instance();
36
+ const token = context.requestInfo.headers.get('authorization')?.replace(/^Bearer /, '');
37
+ const auth = store.authenticateToken(token);
38
+ const server = new Server({ name: 'flambe', title: 'Flambe — Single Burner', version: '0.1.0' }, {
39
+ capabilities: { tools: { listChanged: false } },
40
+ instructions: `This connection accesses Single Burner only. Discover its instance with flambe_traces; never substitute Cloud or Development. AI reducer mode: ${runCommand.info?.mode ?? 'off'}.`,
41
+ });
42
+
43
+ server.setRequestHandler('tools/list', () => ({ tools: tools.map(({ securitySchemes, ...tool }) => ({
44
+ ...tool,
45
+ description: `${tool.description} Connected instance: Single Burner. This connection cannot access Cloud or Development.${tool.name === 'flambe_message' ? reviewDescription : ''}`,
46
+ annotations: { ...tool.annotations, openWorldHint: false },
47
+ })) }));
48
+
49
+ server.setRequestHandler('tools/call', async request => {
50
+ const { name, arguments: args = {} } = request.params;
51
+ const validator = validators.get(name);
52
+ if (!validator) throw new ProtocolError(-32602, `Unknown tool: ${name}`);
53
+ if (name !== 'flambe_traces') {
54
+ const code = !args.expected_instance_id ? 'INSTANCE_REQUIRED'
55
+ : args.expected_instance_id !== instance.id ? 'INSTANCE_MISMATCH' : null;
56
+ if (code) return result(instance, { code, message: code === 'INSTANCE_REQUIRED'
57
+ ? 'Call flambe_traces on this connection and pass its instance.id as expected_instance_id.'
58
+ : 'This connection is Single Burner. The requested instance does not match; no activity was read or changed.' }, true);
59
+ }
60
+ const validated = await validator.validate(args);
61
+ if (validated.issues) return result(instance, { code: 'INVALID_INPUT', message: validated.issues.map(issue => issue.message).join('; ') }, true);
62
+ if (name === 'flambe_traces') {
63
+ const traces = store.listTraces(auth.user.id).map(({ id, name: traceName }) => ({ id, name: traceName }));
64
+ return result(instance, { traces, default_trace_id: traces[0]?.id ?? null, reducer: runCommand.info ?? { mode: 'off' } });
65
+ }
66
+ const { expected_instance_id, ...arguments_ } = args;
67
+ // Header identity has the same precedence as the REST adapter.
68
+ const agentId = context.requestInfo.headers.get('x-flambe-agent-id') ?? arguments_.agent_id;
69
+ const agent = agentId ? store.identifyAgent(auth.user.id, agentId,
70
+ context.requestInfo.headers.get('x-flambe-agent-name') ?? arguments_.agent_name,
71
+ context.requestInfo.headers.get('x-flambe-agent-platform') ?? arguments_.platform) : null;
72
+ try {
73
+ return result(instance, await runCommand(auth.user.id, name.slice('flambe_'.length), arguments_, agent));
74
+ } catch (error) {
75
+ if (!(error instanceof CommandError)) throw error;
76
+ const { error: { code, message, ...details } } = error.body;
77
+ return result(instance, { code, message, ...details }, true);
78
+ }
79
+ });
80
+ return server;
81
+ });
82
+
83
+ // Bearer authentication is checked before the SDK sees any MCP method.
84
+ const nodeHandler = toNodeHandler({ fetch(request) {
85
+ const header = request.headers.get('authorization');
86
+ if (!header?.startsWith('Bearer ') || !store.authenticateToken(header.slice(7))) {
87
+ return Response.json({ error: 'UNAUTHENTICATED' }, { status: 401 });
88
+ }
89
+ return handler.fetch(request);
90
+ } });
91
+ return { handle: nodeHandler, close: () => handler.close() };
92
+ }
@@ -0,0 +1,106 @@
1
+ import { CommandError } from './commands.mjs';
2
+
3
+ const configError = message => new CommandError(503, 'REDUCER_CONFIG_INVALID', message);
4
+ const providerError = message => new CommandError(502, 'REDUCER_PROVIDER_ERROR', message);
5
+ const loopback = host => ['127.0.0.1', 'localhost', '[::1]'].includes(host);
6
+
7
+ /** Explicit opt-in: unrelated OPENAI_API_KEY/Cloud settings never enable review. */
8
+ export function reducerConfig(env = process.env) {
9
+ const mode = env.FLAMBE_LOCAL_REDUCER_MODE ?? 'off';
10
+ if (mode === 'off') return { mode };
11
+ if (mode === 'agent') {
12
+ const reviewStarts = env.FLAMBE_LOCAL_REDUCER_REVIEW_STARTS ?? 'false';
13
+ if (!['false', 'true'].includes(reviewStarts)) throw configError('FLAMBE_LOCAL_REDUCER_REVIEW_STARTS must be true or false.');
14
+ return { mode, reviewStarts: reviewStarts === 'true' };
15
+ }
16
+ if (!['api', 'local'].includes(mode)) throw configError('FLAMBE_LOCAL_REDUCER_MODE must be off, agent, api, or local.');
17
+ const model = env.FLAMBE_LOCAL_REDUCER_MODEL?.trim();
18
+ if (!model) throw configError('Set FLAMBE_LOCAL_REDUCER_MODEL explicitly.');
19
+ let base;
20
+ try {
21
+ base = new URL(env.FLAMBE_LOCAL_REDUCER_BASE_URL ?? (mode === 'api'
22
+ ? 'https://api.openai.com/v1/' : 'http://127.0.0.1:11434/'));
23
+ } catch { throw configError('The reducer base URL is invalid.'); }
24
+ if (base.username || base.password || base.search || base.hash) {
25
+ throw configError('The reducer URL must not contain credentials, a query, or a fragment.');
26
+ }
27
+ if (mode === 'local' && (!['http:', 'https:'].includes(base.protocol) || !loopback(base.hostname))) {
28
+ throw configError('Local reducer mode requires a loopback model endpoint.');
29
+ }
30
+ if (mode === 'local' && /(?:^|[-:])cloud(?:$|[-:])/i.test(model)) {
31
+ throw configError('Choose an installed local model, not a cloud model.');
32
+ }
33
+ if (mode === 'api' && base.protocol !== 'https:') throw configError('API reducer mode requires HTTPS.');
34
+ const apiKey = mode === 'api' ? env.FLAMBE_LOCAL_REDUCER_API_KEY?.trim() : undefined;
35
+ if (mode === 'api' && !apiKey) throw configError('Set FLAMBE_LOCAL_REDUCER_API_KEY for API mode.');
36
+ const timeoutMs = Number(env.FLAMBE_LOCAL_REDUCER_TIMEOUT_MS ?? 30_000);
37
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 90_000) {
38
+ throw configError('Reducer timeout must be between 1 and 90000 milliseconds.');
39
+ }
40
+ if (!base.pathname.endsWith('/')) base.pathname += '/';
41
+ return { mode, model, apiKey, timeoutMs,
42
+ endpoint: new URL(mode === 'api' ? 'responses' : 'api/chat', base).href };
43
+ }
44
+
45
+ async function boundedJson(response) {
46
+ if (!response.ok) {
47
+ await response.body?.cancel();
48
+ throw providerError(`Model request failed (HTTP ${response.status}).`);
49
+ }
50
+ const reader = response.body.getReader();
51
+ const chunks = [];
52
+ let size = 0;
53
+ try {
54
+ while (true) {
55
+ const { value, done } = await reader.read();
56
+ if (done) break;
57
+ size += value.byteLength;
58
+ if (size > 1_048_576) throw providerError('Model response exceeded the size limit.');
59
+ chunks.push(value);
60
+ }
61
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
62
+ } finally { await reader.cancel().catch(() => {}); }
63
+ }
64
+
65
+ export function createReducerProvider(config, { fetchImpl = globalThis.fetch } = {}) {
66
+ return {
67
+ mode: config.mode,
68
+ model: config.model,
69
+ reviewStarts: config.reviewStarts,
70
+ async review({ system, context, schema }) {
71
+ if (['off', 'agent'].includes(config.mode)) throw new CommandError(503, 'REDUCER_NOT_CONFIGURED', 'AI reducer review is off.');
72
+ const input = JSON.stringify(context);
73
+ const body = config.mode === 'api' ? {
74
+ model: config.model, store: false, max_output_tokens: 2000,
75
+ input: [{ role: 'system', content: system }, { role: 'user', content: input }],
76
+ text: { format: { type: 'json_schema', name: 'flambe_reducer_decision', strict: true, schema } },
77
+ } : {
78
+ model: config.model, stream: false, format: schema,
79
+ messages: [{ role: 'system', content: system }, { role: 'user', content: input }],
80
+ options: { temperature: 0, num_predict: 2000 },
81
+ };
82
+ try {
83
+ const response = await fetchImpl(config.endpoint, {
84
+ method: 'POST', redirect: 'error', signal: AbortSignal.timeout(config.timeoutMs),
85
+ headers: { 'content-type': 'application/json', ...(config.mode === 'api'
86
+ ? { authorization: `Bearer ${config.apiKey}` } : {}) },
87
+ body: JSON.stringify(body),
88
+ });
89
+ const data = await boundedJson(response);
90
+ if ((config.mode === 'api' && data.status && data.status !== 'completed')
91
+ || data.done === false || data.done_reason === 'length') throw providerError('Model response was incomplete.');
92
+ const text = config.mode === 'local' ? data.message?.content
93
+ : data.output?.flatMap(item => item.content ?? []).find(item => item.type === 'output_text')?.text;
94
+ if (typeof text !== 'string') throw providerError('Model did not return a decision.');
95
+ return JSON.parse(text);
96
+ } catch (error) {
97
+ if (error instanceof CommandError) throw error;
98
+ if (['TimeoutError', 'AbortError'].includes(error?.name)) {
99
+ throw new CommandError(504, 'REDUCER_TIMEOUT', 'AI reducer review timed out.');
100
+ }
101
+ // Never return provider response bodies, URLs, headers, or credential-bearing errors.
102
+ throw providerError('AI reducer request failed or returned invalid JSON.');
103
+ }
104
+ },
105
+ };
106
+ }