@davvidbaker/flambe-cli 0.1.3 → 0.1.5
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 +67 -1
- package/bin/flambe.mjs +2 -1
- package/package.json +10 -3
- package/scripts/prepare-skills.mjs +12 -0
- package/skills/flambe-cli/SKILL.md +129 -0
- package/src/cli.mjs +59 -4
- package/src/client.mjs +15 -2
- package/src/codex-hook.mjs +237 -0
- package/src/codex-setup.mjs +104 -0
- 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
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import {createHash} from 'node:crypto';
|
|
2
|
+
import {existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync} from 'node:fs';
|
|
3
|
+
import {homedir} from 'node:os';
|
|
4
|
+
import {dirname, join, resolve} from 'node:path';
|
|
5
|
+
import {setTimeout as delay} from 'node:timers/promises';
|
|
6
|
+
import {agentIdentityFromEnv, clientFromEnv} from './client.mjs';
|
|
7
|
+
import {loadProjectEnv} from './env.mjs';
|
|
8
|
+
import {atomicWrite, hasFlambeHooks} from './codex-setup.mjs';
|
|
9
|
+
import {assertRuntimeUrl} from './runtime.mjs';
|
|
10
|
+
|
|
11
|
+
const RESTORE = new Set(['SessionStart', 'SubagentStart', 'PostCompact', 'UserPromptSubmit']);
|
|
12
|
+
const EVENTS = new Set([...RESTORE, 'PreToolUse', 'PostToolUse', 'Stop', 'Interrupt']);
|
|
13
|
+
const hash = text => createHash('sha256').update(text).digest('hex');
|
|
14
|
+
const clean = value => String(value).replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 160);
|
|
15
|
+
const emptyState = () => ({workInTurn: 0, workSinceTracked: 0, callsSinceReminder: 0});
|
|
16
|
+
|
|
17
|
+
function projectRoot(cwd) {
|
|
18
|
+
let directory = resolve(cwd);
|
|
19
|
+
for (;;) {
|
|
20
|
+
const path = join(directory, '.codex', 'hooks.json');
|
|
21
|
+
if (existsSync(path) && hasFlambeHooks(JSON.parse(readFileSync(path, 'utf8')))) return directory;
|
|
22
|
+
const parent = dirname(directory);
|
|
23
|
+
if (parent === directory || existsSync(join(directory, '.git'))) return null;
|
|
24
|
+
directory = parent;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function context(event, text) {
|
|
29
|
+
return {hookSpecificOutput: {hookEventName: event, additionalContext: text}};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function connectionWarning(event) {
|
|
33
|
+
const text = 'Single Burner hook could not verify the local connection. Check the running server and this project\'s .env, including FLAMBE_RUNTIME_MODE=local_self_contained. Work may continue; do not substitute Cloud or repeat queued activity commands.';
|
|
34
|
+
return event === 'Stop' || event === 'Interrupt' ? {systemMessage: text} : context(event, text);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Only recognise a standalone CLI invocation. A mixed shell script still counts
|
|
38
|
+
// as work, even if it happens to contain a tracking command.
|
|
39
|
+
function trackingCommand(input) {
|
|
40
|
+
const command = input?.tool_input?.command ?? input?.tool_input?.cmd;
|
|
41
|
+
if (typeof command !== 'string' || /[\n;&|]/.test(command)) return null;
|
|
42
|
+
const match = command.trim().match(/^(?:(?:[A-Z_][A-Z_0-9]*=(?:"[^"]*"|'[^']*'|[^\s]+))\s+)*(?:flambe(?:\.cmd)?|(?:"[^"]*node(?:\.exe)?"|[^\s]*node(?:\.exe)?)\s+(?:"[^"]*flambe\.mjs"|[^\s]*flambe\.mjs))\s+(start|end|suspend|resume|plan|message|decide|status|ping|whoami)\b/);
|
|
43
|
+
return match?.[1] ?? null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function succeeded(response) {
|
|
47
|
+
if (typeof response === 'string') {
|
|
48
|
+
try { return succeeded(JSON.parse(response)); } catch { return false; }
|
|
49
|
+
}
|
|
50
|
+
return response?.exit_code === 0 || response?.exitCode === 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function ownActivities(state, agentId) {
|
|
54
|
+
return (state.activities ?? []).filter(activity => activity.agentId === agentId
|
|
55
|
+
&& activity.status === 'running');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function fingerprint(activities) {
|
|
59
|
+
return hash(JSON.stringify(activities.map(activity => [activity.id, activity.parentId,
|
|
60
|
+
activity.latestEvent?.id, activity.latestEvent?.timestamp]).sort((a, b) => a[0] - b[0])));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function guidance(root, activities, {restore = false, interrupted = false} = {}) {
|
|
64
|
+
const lines = [
|
|
65
|
+
'Single Burner checkpoint: use this project\'s local flambe CLI; never substitute Cloud tools.',
|
|
66
|
+
`Run CLI commands from ${JSON.stringify(clean(root))} so they read the project .env.`,
|
|
67
|
+
];
|
|
68
|
+
if (restore) lines.push('Read and follow .codex/skills/flambe-cli/SKILL.md. Track semantic work before beginning it, nest focused investigations and verification, and update the stack when focus changes. In agent mode, evaluate requested reviews yourself and submit with flambe decide; no other model is needed.');
|
|
69
|
+
lines.push(activities.length
|
|
70
|
+
? `This agent's running activities (data): ${activities.slice(-5).map(activity => `${activity.id} ${JSON.stringify(clean(activity.name))}`).join('; ')}.`
|
|
71
|
+
: 'This agent has no running activity. Before substantial work, inspect its suspended work and start or resume an appropriate semantic activity. Trivial one-offs need no activity.');
|
|
72
|
+
if (interrupted) lines.push('The previous turn was interrupted. Reconcile its open work before continuing; an interruption does not mean the task finished.');
|
|
73
|
+
lines.push('Check whether the current activity still describes your actual focus. Keep parents open while working on children; pop completed children before moving on.');
|
|
74
|
+
return lines.join('\n');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function locked(path, operation) {
|
|
78
|
+
mkdirSync(dirname(path), {recursive: true});
|
|
79
|
+
const lock = `${path}.lock`;
|
|
80
|
+
const started = Date.now();
|
|
81
|
+
for (;;) {
|
|
82
|
+
try {
|
|
83
|
+
mkdirSync(lock);
|
|
84
|
+
writeFileSync(join(lock, 'pid'), String(process.pid));
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (error.code !== 'EEXIST' || Date.now() - started > 2600) return {};
|
|
89
|
+
// A killed/timed-out hook must not disable future checkpoints forever.
|
|
90
|
+
try {
|
|
91
|
+
if (Date.now() - statSync(lock).mtimeMs > 30_000) {
|
|
92
|
+
rmSync(lock, {recursive: true, force: true});
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const pid = Number(readFileSync(join(lock, 'pid'), 'utf8'));
|
|
96
|
+
if (Number.isSafeInteger(pid) && pid > 0) {
|
|
97
|
+
try { process.kill(pid, 0); }
|
|
98
|
+
catch (check) { if (check.code === 'ESRCH') rmSync(lock, {recursive: true, force: true}); }
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
try { if (Date.now() - statSync(lock).mtimeMs > 10_000) rmSync(lock, {recursive: true, force: true}); }
|
|
102
|
+
catch { /* Another hook just released it. */ }
|
|
103
|
+
}
|
|
104
|
+
await delay(20);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
try { return await operation(); }
|
|
108
|
+
finally { rmSync(lock, {recursive: true, force: true}); }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A checkpoint adapter, not an activity generator or model client. */
|
|
112
|
+
export async function runCodexHook(input, {
|
|
113
|
+
env: shellEnv = process.env, now = Date.now,
|
|
114
|
+
stateDir = shellEnv.FLAMBE_CODEX_HOOK_STATE_DIR?.trim() || join(homedir(), '.flambe', 'codex-hooks'),
|
|
115
|
+
fetchImpl = globalThis.fetch, checkEveryMs = 30_000,
|
|
116
|
+
remindEveryCalls = 10, remindEveryMs = 90_000,
|
|
117
|
+
} = {}) {
|
|
118
|
+
const event = input?.hook_event_name;
|
|
119
|
+
if (!EVENTS.has(event) || typeof input.cwd !== 'string' || !input.session_id) return {};
|
|
120
|
+
if (event === 'Stop' && input.stop_hook_active) return {};
|
|
121
|
+
let root;
|
|
122
|
+
let env;
|
|
123
|
+
let identity;
|
|
124
|
+
try {
|
|
125
|
+
root = projectRoot(input.cwd);
|
|
126
|
+
if (!root) return {};
|
|
127
|
+
env = {...shellEnv};
|
|
128
|
+
loadProjectEnv({cwd: root, env});
|
|
129
|
+
if (!['FLAMBE_URL', 'FLAMBE_API_TOKEN', 'FLAMBE_TRACE_ID'].some(key => env[key])) return {};
|
|
130
|
+
if (env.FLAMBE_RUNTIME_MODE !== 'local_self_contained') return connectionWarning(event);
|
|
131
|
+
assertRuntimeUrl(env.FLAMBE_URL, 'local_self_contained');
|
|
132
|
+
identity = agentIdentityFromEnv(env);
|
|
133
|
+
if (!identity.agentId) {
|
|
134
|
+
return RESTORE.has(event) ? context(event,
|
|
135
|
+
'Single Burner: read .codex/skills/flambe-cli/SKILL.md. The hook could not derive this Codex agent\'s identity, so it did not inspect any activity stack. Check flambe whoami in the local Codex environment; do not reuse another agent\'s identity or put agent IDs in .env.') : {};
|
|
136
|
+
}
|
|
137
|
+
} catch { return connectionWarning(event); }
|
|
138
|
+
const key = hash(JSON.stringify([root, env.FLAMBE_URL, env.FLAMBE_TRACE_ID, identity.agentId, input.session_id]));
|
|
139
|
+
const path = join(stateDir, `${key}.json`);
|
|
140
|
+
try {
|
|
141
|
+
return await locked(path, async () => {
|
|
142
|
+
let state;
|
|
143
|
+
try { state = {...emptyState(), ...JSON.parse(readFileSync(path, 'utf8'))}; }
|
|
144
|
+
catch { state = emptyState(); }
|
|
145
|
+
const at = now();
|
|
146
|
+
const save = output => { atomicWrite(path, `${JSON.stringify(state)}\n`); return output; };
|
|
147
|
+
const turn = input.turn_id ?? state.turn ?? 'unknown';
|
|
148
|
+
if (turn !== state.turn || event === 'UserPromptSubmit') {
|
|
149
|
+
state.turn = turn;
|
|
150
|
+
delete state.stopTurn;
|
|
151
|
+
state.workInTurn = 0;
|
|
152
|
+
state.workSinceTracked = 0;
|
|
153
|
+
state.callsSinceReminder = 0;
|
|
154
|
+
state.missingWarned = false;
|
|
155
|
+
}
|
|
156
|
+
if (event === 'Interrupt') {
|
|
157
|
+
state.interrupted = true;
|
|
158
|
+
return save({});
|
|
159
|
+
}
|
|
160
|
+
const tracking = trackingCommand(input);
|
|
161
|
+
if (event === 'PostToolUse') {
|
|
162
|
+
if (tracking && !['status', 'ping', 'whoami'].includes(tracking) && succeeded(input.tool_response)) {
|
|
163
|
+
state.workSinceTracked = 0;
|
|
164
|
+
state.callsSinceReminder = 0;
|
|
165
|
+
state.remindedAt = at;
|
|
166
|
+
state.checkedAt = 0;
|
|
167
|
+
}
|
|
168
|
+
return save({});
|
|
169
|
+
}
|
|
170
|
+
if (event === 'PreToolUse') {
|
|
171
|
+
if (tracking) return save({});
|
|
172
|
+
const weight = /^(apply_patch|Edit|Write)$/.test(input.tool_name ?? '') ? 3 : 1;
|
|
173
|
+
state.workInTurn += weight;
|
|
174
|
+
state.workSinceTracked += weight;
|
|
175
|
+
state.callsSinceReminder++;
|
|
176
|
+
state.lastWorkAt = at;
|
|
177
|
+
}
|
|
178
|
+
if (event === 'Stop' && (state.workInTurn < 3 || state.workSinceTracked === 0 || state.stopTurn === turn)) return save({});
|
|
179
|
+
const restore = RESTORE.has(event);
|
|
180
|
+
const periodic = state.callsSinceReminder >= remindEveryCalls || at - (state.remindedAt ?? at) >= remindEveryMs;
|
|
181
|
+
const due = restore || periodic || event === 'Stop' || !state.checkedAt || at - state.checkedAt >= checkEveryMs;
|
|
182
|
+
let activities = [];
|
|
183
|
+
if (due) {
|
|
184
|
+
state.checkedAt = at;
|
|
185
|
+
try {
|
|
186
|
+
const client = clientFromEnv(env);
|
|
187
|
+
client.fetch = (url, options) => fetchImpl(url, {...options, signal: AbortSignal.timeout(1200)});
|
|
188
|
+
activities = ownActivities(await client.status({activeOnly: true}), identity.agentId);
|
|
189
|
+
const current = fingerprint(activities);
|
|
190
|
+
const latestChange = Math.max(0, ...activities.map(activity => Date.parse(activity.latestEvent?.timestamp) || 0));
|
|
191
|
+
if (state.fingerprint && state.fingerprint !== current
|
|
192
|
+
&& ((!activities.length && state.hasActive) || latestChange >= (state.lastWorkAt ?? 0))) {
|
|
193
|
+
state.workSinceTracked = 0;
|
|
194
|
+
state.callsSinceReminder = 0;
|
|
195
|
+
state.remindedAt = at;
|
|
196
|
+
}
|
|
197
|
+
state.fingerprint = current;
|
|
198
|
+
state.available = true;
|
|
199
|
+
state.hasActive = activities.length > 0;
|
|
200
|
+
} catch {
|
|
201
|
+
state.available = false;
|
|
202
|
+
if (!state.warnedAt || at - state.warnedAt >= remindEveryMs || restore) {
|
|
203
|
+
state.warnedAt = at;
|
|
204
|
+
return save(connectionWarning(event));
|
|
205
|
+
}
|
|
206
|
+
return save({});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (!state.available) return save({});
|
|
210
|
+
if (event === 'Stop') {
|
|
211
|
+
if (!state.workSinceTracked) return save({});
|
|
212
|
+
state.stopTurn = turn;
|
|
213
|
+
return save({decision: 'block', reason: guidance(root, activities)
|
|
214
|
+
+ '\nBefore handing back, reconcile the work you actually did. End completed activities, suspend work waiting on the user, or explain why an activity remains running. Complete any requested reducer review. This is one checkpoint, not an instruction to invent work or end every open parent.'});
|
|
215
|
+
}
|
|
216
|
+
const firstUntracked = event === 'PreToolUse' && !state.missingWarned && !state.hasActive;
|
|
217
|
+
if (restore || firstUntracked || periodic) {
|
|
218
|
+
if (firstUntracked) state.missingWarned = true;
|
|
219
|
+
state.callsSinceReminder = 0;
|
|
220
|
+
state.remindedAt = at;
|
|
221
|
+
const interrupted = state.interrupted;
|
|
222
|
+
state.interrupted = false;
|
|
223
|
+
return save(context(event, guidance(root, activities, {restore, interrupted})));
|
|
224
|
+
}
|
|
225
|
+
return save({});
|
|
226
|
+
});
|
|
227
|
+
} catch { return connectionWarning(event); }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function readHookInput(stdin = process.stdin) {
|
|
231
|
+
let body = '';
|
|
232
|
+
for await (const chunk of stdin) {
|
|
233
|
+
body += chunk;
|
|
234
|
+
if (Buffer.byteLength(body) > 1024 * 1024) throw new Error('Hook input exceeds 1 MiB');
|
|
235
|
+
}
|
|
236
|
+
return JSON.parse(body);
|
|
237
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import {existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync} from 'node:fs';
|
|
2
|
+
import {dirname, join, resolve} from 'node:path';
|
|
3
|
+
import {fileURLToPath} from 'node:url';
|
|
4
|
+
import {randomUUID} from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
export const HOOK_COMMAND = 'flambe hook codex';
|
|
7
|
+
export const HOOK_COMMAND_WINDOWS = 'flambe.cmd hook codex';
|
|
8
|
+
export const HOOK_EVENTS = ['SessionStart', 'SubagentStart', 'UserPromptSubmit', 'PostCompact', 'PreToolUse', 'PostToolUse', 'Stop', 'Interrupt'];
|
|
9
|
+
|
|
10
|
+
export function managedHook(hook) {
|
|
11
|
+
return hook?.type === 'command' && hook.command === HOOK_COMMAND
|
|
12
|
+
&& hook.commandWindows === HOOK_COMMAND_WINDOWS;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function hasFlambeHooks(config) {
|
|
16
|
+
return Object.values(config?.hooks ?? {}).some(groups => Array.isArray(groups)
|
|
17
|
+
&& groups.some(group => group.hooks?.some(managedHook)));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readConfig(path) {
|
|
21
|
+
if (!existsSync(path)) return {hooks: {}};
|
|
22
|
+
let config;
|
|
23
|
+
try { config = JSON.parse(readFileSync(path, 'utf8')); }
|
|
24
|
+
catch { throw new Error('Cannot read .codex/hooks.json as JSON; existing hooks were not changed.'); }
|
|
25
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)
|
|
26
|
+
|| (config.hooks !== undefined && (!config.hooks || typeof config.hooks !== 'object' || Array.isArray(config.hooks)))
|
|
27
|
+
|| Object.values(config.hooks ?? {}).some(groups => !Array.isArray(groups)
|
|
28
|
+
|| groups.some(group => !group || !Array.isArray(group.hooks)))) {
|
|
29
|
+
throw new Error('Unsupported .codex/hooks.json structure; existing hooks were not changed.');
|
|
30
|
+
}
|
|
31
|
+
return config;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function atomicWrite(path, text) {
|
|
35
|
+
mkdirSync(dirname(path), {recursive: true});
|
|
36
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
37
|
+
try {
|
|
38
|
+
writeFileSync(temporary, text, {mode: 0o600, flag: 'wx'});
|
|
39
|
+
renameSync(temporary, path);
|
|
40
|
+
} finally {
|
|
41
|
+
if (existsSync(temporary)) unlinkSync(temporary);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function bundledSkill() {
|
|
46
|
+
const bundled = fileURLToPath(new URL('../skills/flambe-cli/SKILL.md', import.meta.url));
|
|
47
|
+
const source = fileURLToPath(new URL('../../.codex/skills/flambe-cli/SKILL.md', import.meta.url));
|
|
48
|
+
return readFileSync(existsSync(bundled) ? bundled : source, 'utf8');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function withoutManagedHooks(config) {
|
|
52
|
+
const result = {...config, hooks: {...config.hooks}};
|
|
53
|
+
for (const [event, groups] of Object.entries(result.hooks)) {
|
|
54
|
+
result.hooks[event] = groups.flatMap(group => {
|
|
55
|
+
if (!group.hooks.some(managedHook)) return [group];
|
|
56
|
+
const hooks = group.hooks.filter(hook => !managedHook(hook));
|
|
57
|
+
return hooks.length ? [{...group, hooks}] : [];
|
|
58
|
+
});
|
|
59
|
+
if (!result.hooks[event].length) delete result.hooks[event];
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function setupCodex({cwd = process.cwd(), action = 'install', skill = bundledSkill()} = {}) {
|
|
65
|
+
const root = resolve(cwd);
|
|
66
|
+
const path = join(root, '.codex', 'hooks.json');
|
|
67
|
+
const skillPath = join(root, '.codex', 'skills', 'flambe-cli', 'SKILL.md');
|
|
68
|
+
const existing = readConfig(path);
|
|
69
|
+
const installedEvents = HOOK_EVENTS.filter(event => existing.hooks?.[event]?.some(group =>
|
|
70
|
+
group.hooks.some(managedHook)));
|
|
71
|
+
const skillExists = existsSync(skillPath);
|
|
72
|
+
if (action === 'check') return {
|
|
73
|
+
root, hooksPath: path, installed: installedEvents.length === HOOK_EVENTS.length,
|
|
74
|
+
installedEvents, skillExists,
|
|
75
|
+
skillMatches: skillExists && readFileSync(skillPath, 'utf8') === skill,
|
|
76
|
+
};
|
|
77
|
+
const config = withoutManagedHooks(existing);
|
|
78
|
+
if (action === 'remove') {
|
|
79
|
+
if (installedEvents.length) atomicWrite(path, `${JSON.stringify(config, null, 2)}\n`);
|
|
80
|
+
return {root, hooksPath: path, removed: installedEvents.length > 0, skillRetained: skillExists};
|
|
81
|
+
}
|
|
82
|
+
if (action !== 'install') throw new Error('Unknown Codex setup action');
|
|
83
|
+
for (const event of HOOK_EVENTS) {
|
|
84
|
+
config.hooks[event] ??= [];
|
|
85
|
+
config.hooks[event].push({hooks: [{
|
|
86
|
+
type: 'command', command: HOOK_COMMAND, commandWindows: HOOK_COMMAND_WINDOWS,
|
|
87
|
+
timeout: event === 'Interrupt' ? 3 : 5,
|
|
88
|
+
statusMessage: 'Single Burner tracking checkpoint', additionalContextLimit: 1800,
|
|
89
|
+
}]});
|
|
90
|
+
}
|
|
91
|
+
// Preserve custom/existing instructions. Never silently replace a skill.
|
|
92
|
+
if (!skillExists) atomicWrite(skillPath, skill);
|
|
93
|
+
const serialized = `${JSON.stringify(config, null, 2)}\n`;
|
|
94
|
+
const unchanged = existsSync(path) && readFileSync(path, 'utf8') === serialized;
|
|
95
|
+
if (!unchanged) {
|
|
96
|
+
if (existsSync(path)) {
|
|
97
|
+
const backup = `${path}.before-flambe`;
|
|
98
|
+
if (!existsSync(backup)) writeFileSync(backup, readFileSync(path), {flag: 'wx', mode: 0o600});
|
|
99
|
+
}
|
|
100
|
+
atomicWrite(path, serialized);
|
|
101
|
+
}
|
|
102
|
+
return {root, hooksPath: path, unchanged, skillInstalled: !skillExists,
|
|
103
|
+
skillMatches: !skillExists || readFileSync(skillPath, 'utf8') === skill};
|
|
104
|
+
}
|
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 };
|