@davvidbaker/flambe-cli 0.1.3 → 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.3",
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];
@@ -259,7 +268,7 @@ async function dispatch(store, req, url, body, { auth, agent }) {
259
268
  if (method === 'POST' && path === '/api/activities') {
260
269
  if (!Object.hasOwn(body, 'event') && auth.tokenName !== null) {
261
270
  const activity = body.activity ?? {};
262
- const planned = executeCommand(store, userId, 'plan', {
271
+ const planned = await runCommand(userId, 'plan', {
263
272
  trace_id: body.trace_id, name: activity.name, description: activity.description,
264
273
  weight: activity.weight, category_ids: activity.categories,
265
274
  scheduled_start: activity.scheduled_start_integer,
@@ -271,7 +280,7 @@ async function dispatch(store, req, url, body, { auth, agent }) {
271
280
  }
272
281
  if (auth.tokenName !== null) {
273
282
  const activity = body.activity ?? {};
274
- const folded = executeCommand(store, userId, 'start', {
283
+ const folded = await runCommand(userId, 'start', {
275
284
  trace_id: body.trace_id, thread_id: body.thread_id, name: activity.name,
276
285
  description: activity.description, category_ids: activity.categories,
277
286
  ...(Object.hasOwn(activity, 'parent_id') ? { parent_id: activity.parent_id } : {}),
@@ -283,7 +292,7 @@ async function dispatch(store, req, url, body, { auth, agent }) {
283
292
  phase: folded.state.activities.find(a => a.id === folded.activity_id)?.latestEvent.phase },
284
293
  reducer: folded.reducer ?? { actions_applied: folded.actions_applied },
285
294
  direction: folded.direction, reply: folded.reply, rules_fired: folded.rules_fired,
286
- actions_applied: folded.actions_applied,
295
+ actions_applied: folded.actions_applied, model_review: folded.model_review, review: folded.review,
287
296
  } } };
288
297
  }
289
298
  const result = store.createActivity(userId, {
@@ -327,7 +336,7 @@ async function dispatch(store, req, url, body, { auth, agent }) {
327
336
  const phase = body.event?.phase;
328
337
  const command = { E: 'end', J: 'end', V: 'end', S: 'suspend', R: 'resume' }[phase];
329
338
  if (auth.tokenName !== null && command) {
330
- const folded = executeCommand(store, userId, command, {
339
+ const folded = await runCommand(userId, command, {
331
340
  trace_id: body.trace_id, activity_id: body.activity_id,
332
341
  timestamp: body.event?.timestamp_integer, message: body.event?.message,
333
342
  phase, force: body.force ?? true,
@@ -336,7 +345,7 @@ async function dispatch(store, req, url, body, { auth, agent }) {
336
345
  id: folded.event_id, phase,
337
346
  reducer: { closed_descendants: folded.closed_descendants,
338
347
  rules_fired: folded.rules_fired, actions_applied: folded.actions_applied,
339
- direction: folded.direction, reply: folded.reply },
348
+ direction: folded.direction, reply: folded.reply, model_review: folded.model_review },
340
349
  } } };
341
350
  }
342
351
  const result = store.createEvent(userId, {
@@ -429,7 +438,8 @@ function tryStatic(staticDir, urlPath, res) {
429
438
  return true;
430
439
  }
431
440
 
432
- 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) } = {}) {
433
443
  const host = req.headers.host ?? '127.0.0.1';
434
444
  const url = new URL(req.url ?? '/', `http://${host}`);
435
445
  // A locally served UI must not send trace content to a compiled-in remote API.
@@ -448,6 +458,22 @@ export async function handleLocalRequest(store, req, res, { staticDir } = {}) {
448
458
  }
449
459
  }
450
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
+
451
477
  if (!url.pathname.startsWith('/api') && !url.pathname.startsWith('/auth')) {
452
478
  if (tryStatic(staticDir, url.pathname, res)) return;
453
479
  const { rawToken, traceId } = store.credentials();
@@ -477,7 +503,7 @@ FLAMBE_TRACE_ID=${traceId}</pre>
477
503
  const agent = identifyAgent(store, req, auth);
478
504
  let result;
479
505
  try {
480
- result = await dispatch(store, req, url, body, { auth, agent });
506
+ result = await dispatch(store, req, url, body, { auth, agent, runCommand });
481
507
  } catch (error) {
482
508
  if (!(error instanceof CommandError)) throw error;
483
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
+ }