@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 +14 -1
- package/package.json +5 -1
- package/src/cli.mjs +23 -3
- package/src/client.mjs +15 -2
- package/src/local/commands.mjs +6 -4
- package/src/local/history.mjs +98 -0
- package/src/local/http.mjs +36 -10
- package/src/local/mcp.mjs +92 -0
- package/src/local/reducer-provider.mjs +106 -0
- package/src/local/reducer-review.mjs +179 -0
- package/src/local/reducer.mjs +74 -0
- package/src/local/serve.mjs +15 -5
- package/src/local/store.mjs +40 -7
- package/src/local/work-ai-review.mjs +94 -0
- package/src/mcp-tools.json +582 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @davvidbaker/flambe-cli
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
"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
|
|
package/src/local/commands.mjs
CHANGED
|
@@ -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
|
-
'
|
|
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',
|
|
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',
|
|
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
|
+
}
|
package/src/local/http.mjs
CHANGED
|
@@ -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,
|
|
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:
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
+
}
|