cookbook-bridge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +156 -0
- package/bridge.mjs +1914 -0
- package/chat.mjs +0 -0
- package/codex-runner.mjs +241 -0
- package/config.example.json +83 -0
- package/connectors.mjs +157 -0
- package/cookbook.mjs +234 -0
- package/device.mjs +509 -0
- package/harden.mjs +117 -0
- package/local.mjs +388 -0
- package/package.json +57 -0
- package/prompt.mjs +232 -0
- package/robot-runner.mjs +55 -0
- package/thread-runner.mjs +221 -0
- package/update.mjs +151 -0
- package/usage.mjs +147 -0
- package/volunteer.mjs +130 -0
package/cookbook.mjs
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Cookbook MCP client for the Bridge.
|
|
3
|
+
*
|
|
4
|
+
* The Cookbook MCP endpoint (/api/mcp) is a STATELESS Streamable-HTTP JSON-RPC
|
|
5
|
+
* server: every POST carries a bearer token and is authenticated independently,
|
|
6
|
+
* so we can call tools/call directly — no initialize handshake or session to
|
|
7
|
+
* maintain. The tool's result lands in `result.structuredContent`.
|
|
8
|
+
*
|
|
9
|
+
* Node built-ins only (global fetch, Node 18+). No dependencies.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Call one Cookbook MCP tool. Returns the tool's body (structuredContent). */
|
|
13
|
+
export async function callTool(cfg, name, args = {}) {
|
|
14
|
+
let res;
|
|
15
|
+
try {
|
|
16
|
+
res = await fetch(`${cfg.cookbookUrl}/api/mcp`, {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: {
|
|
19
|
+
Authorization: `Bearer ${cfg.token}`,
|
|
20
|
+
"Content-Type": "application/json",
|
|
21
|
+
"MCP-Protocol-Version": "2025-06-18",
|
|
22
|
+
},
|
|
23
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
|
|
24
|
+
});
|
|
25
|
+
} catch (e) {
|
|
26
|
+
throw new Error(`network error reaching Cookbook: ${e.message}`);
|
|
27
|
+
}
|
|
28
|
+
if (res.status === 401) {
|
|
29
|
+
throw new Error("Cookbook rejected the token (401). Check `token` in your config — generate one on your account's Tokens page.");
|
|
30
|
+
}
|
|
31
|
+
const j = await res.json().catch(() => null);
|
|
32
|
+
if (!j) throw new Error(`unexpected response from Cookbook (HTTP ${res.status})`);
|
|
33
|
+
if (j.error) throw new Error(`Cookbook MCP error: ${j.error.message}`);
|
|
34
|
+
const body = j.result?.structuredContent ?? {};
|
|
35
|
+
if (j.result?.isError) throw new Error(`tool ${name} failed: ${body.error ?? "unknown error"}`);
|
|
36
|
+
return body;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** All workspaces the token's member belongs to. */
|
|
40
|
+
export async function listWorkspaces(cfg) {
|
|
41
|
+
const body = await callTool(cfg, "list_workspaces", {});
|
|
42
|
+
return body.workspaces ?? [];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** ONE-CALL open-work discovery across all the member's workspaces (chat-feel
|
|
46
|
+
* dispatch). Returns [] when the server predates the tool — callers fall back
|
|
47
|
+
* to the per-workspace sweep. */
|
|
48
|
+
export async function listOpenWork(cfg) {
|
|
49
|
+
try {
|
|
50
|
+
const body = await callTool(cfg, "list_open_work", {});
|
|
51
|
+
return { supported: true, work: body.work ?? [], warmHints: body.warm_hints ?? [] };
|
|
52
|
+
} catch (e) {
|
|
53
|
+
if (/unknown tool/i.test(e.message)) return { supported: false, work: [], warmHints: [] };
|
|
54
|
+
throw e;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Tasks in a workspace. filter: 'open' | 'all' | 'mine'. */
|
|
59
|
+
export async function listTasks(cfg, workspaceId, filter = "open") {
|
|
60
|
+
const body = await callTool(cfg, "list_tasks", { workspace_id: workspaceId, filter });
|
|
61
|
+
return body.tasks ?? [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Fetch one task's current state (via the full list — there is no get_task). */
|
|
65
|
+
export async function getTask(cfg, workspaceId, taskId) {
|
|
66
|
+
const tasks = await listTasks(cfg, workspaceId, "all");
|
|
67
|
+
return tasks.find((t) => t.id === taskId) ?? null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Composer-thread continuity (0064): find the freshest resume handle in a thread —
|
|
72
|
+
* the latest run (root or follow-up) that reported a progress.session_ref. Resuming
|
|
73
|
+
* mints a NEW session id each time, so "the thread's session" is always the newest
|
|
74
|
+
* one, never the root's. Returns { sessionRef, root } (either may be null).
|
|
75
|
+
*/
|
|
76
|
+
export async function threadResumeContext(cfg, workspaceId, rootId) {
|
|
77
|
+
const tasks = await listTasks(cfg, workspaceId, "all");
|
|
78
|
+
const inThread = tasks.filter((t) => t.id === rootId || t.thread_root_id === rootId);
|
|
79
|
+
const root = inThread.find((t) => t.id === rootId) ?? null;
|
|
80
|
+
const stamped = inThread
|
|
81
|
+
.filter((t) => t.progress && typeof t.progress.session_ref === "string" && t.progress.session_ref)
|
|
82
|
+
.sort((a, b) => String(b.progress.updated_at ?? "").localeCompare(String(a.progress.updated_at ?? "")));
|
|
83
|
+
return { sessionRef: stamped[0]?.progress.session_ref ?? null, root };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Ask Cookbook's UI-managed delegation policy whether to run this task:
|
|
88
|
+
* "run" — your policy allows it (or you assigned it yourself)
|
|
89
|
+
* "pending" — your policy is "ask"; it's parked for your approval — don't run
|
|
90
|
+
* "skip" — blocked by your policy
|
|
91
|
+
* Throws if the server doesn't support it yet (caller falls back).
|
|
92
|
+
*/
|
|
93
|
+
export async function resolveDelegation(cfg, taskId) {
|
|
94
|
+
try {
|
|
95
|
+
const body = await callTool(cfg, "resolve_task_delegation", { task_id: taskId });
|
|
96
|
+
// Return the full shape { decision, mode, reason, cap, spent } so the caller can
|
|
97
|
+
// surface a daily-cap hold distinctly. Back-compat: callers reading `.decision`
|
|
98
|
+
// still work; a bare string is no longer returned.
|
|
99
|
+
return { decision: body.decision ?? "run", reason: body.reason, cap: body.cap, spent: body.spent };
|
|
100
|
+
} catch (e) {
|
|
101
|
+
// A CONSENT gate must fail closed on transient errors — only a server that
|
|
102
|
+
// genuinely doesn't have the tool (pre-delegation deploy) may fall back to run.
|
|
103
|
+
if (/unknown tool/i.test(e.message)) return { decision: "run" };
|
|
104
|
+
throw e;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Bridge-filed completion (chat lane): the agent's final streamed message IS the
|
|
110
|
+
* result, so the Bridge files it in one cheap HTTP call instead of the agent
|
|
111
|
+
* spending a whole model round-trip on the complete_task tool. Claimer-only
|
|
112
|
+
* server-side (the Bridge acts as the claiming member, so the wall passes).
|
|
113
|
+
*/
|
|
114
|
+
export async function completeTaskApi(cfg, workspaceId, taskId, result) {
|
|
115
|
+
return callTool(cfg, "complete_task", { workspace_id: workspaceId, task_id: taskId, result });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Report what a completed run cost (tokens/cost/duration) so the assigner can see the
|
|
120
|
+
* price of the delegation. `usage` is the report_task_usage argument object from
|
|
121
|
+
* usage.mjs extractUsage. First report wins server-side; duplicates are no-ops.
|
|
122
|
+
*/
|
|
123
|
+
export async function reportTaskUsage(cfg, workspaceId, taskId, usage) {
|
|
124
|
+
return callTool(cfg, "report_task_usage", { workspace_id: workspaceId, task_id: taskId, ...usage });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Stream LIVE in-flight token counts while an agent is still running, so the board
|
|
129
|
+
* ticks upward in real time (report_task_progress; latest-wins, claimed-only).
|
|
130
|
+
*/
|
|
131
|
+
export async function reportTaskProgress(cfg, workspaceId, taskId, progress) {
|
|
132
|
+
return callTool(cfg, "report_task_progress", { workspace_id: workspaceId, task_id: taskId, ...progress });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Credit the memory notes that rode into a run which then completed — the outcome
|
|
137
|
+
* signal behind outcome-weighted recall. Best-effort: never throws (swallowed here).
|
|
138
|
+
*/
|
|
139
|
+
export async function creditRecall(cfg, workspaceId, noteIds) {
|
|
140
|
+
if (!Array.isArray(noteIds) || noteIds.length === 0) return;
|
|
141
|
+
try {
|
|
142
|
+
await callTool(cfg, "credit_recall", { workspace_id: workspaceId, note_ids: noteIds });
|
|
143
|
+
} catch {
|
|
144
|
+
/* outcome crediting is best-effort — a miss just means the note isn't lifted yet */
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Atomically claim an open GOAL task this Bridge decided to volunteer for — the claim is
|
|
150
|
+
* the race: two Bridges volunteering resolve in the DB (loser gets a clean error).
|
|
151
|
+
* claimed_via='volunteered' is recorded for the board + credibility history.
|
|
152
|
+
*/
|
|
153
|
+
export async function volunteerClaim(cfg, workspaceId, taskId) {
|
|
154
|
+
return callTool(cfg, "claim_task", { workspace_id: workspaceId, task_id: taskId, volunteered: true });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Atomically claim a DISPATCHED task before running it (Phase 0, audit #1): without
|
|
159
|
+
* this, a `to:'any'` task — or one member's Bridge on two machines — ran N times.
|
|
160
|
+
* The DB's status='open' CAS makes the first claimer the only runner; the loser
|
|
161
|
+
* gets a clean error and skips. Returns the claimed task, or null if lost.
|
|
162
|
+
*/
|
|
163
|
+
export async function dispatchClaim(cfg, workspaceId, taskId) {
|
|
164
|
+
try {
|
|
165
|
+
const body = await callTool(cfg, "claim_task", { workspace_id: workspaceId, task_id: taskId });
|
|
166
|
+
return body.task ?? null;
|
|
167
|
+
} catch {
|
|
168
|
+
return null; // someone else won (or the task just left `open`) — not ours to run
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Hand a task back LOUDLY (Phase 0, audit #2/#8): after the final failed attempt,
|
|
174
|
+
* mark it abandoned with the failure hint so the assigner sees "tried, gave up,
|
|
175
|
+
* here's why" on the board instead of a task that silently rots. Best-effort —
|
|
176
|
+
* an older server without abandon_task just leaves the legacy behavior.
|
|
177
|
+
*/
|
|
178
|
+
export async function abandonTask(cfg, workspaceId, taskId, reason) {
|
|
179
|
+
try {
|
|
180
|
+
await callTool(cfg, "abandon_task", { workspace_id: workspaceId, task_id: taskId, reason: String(reason ?? "").slice(0, 500) });
|
|
181
|
+
return true;
|
|
182
|
+
} catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The owner's UI-managed volunteering settings (Account → Agent delegation →
|
|
189
|
+
* Volunteering). Returns { profile_id, settings: [{agent_name, enabled,
|
|
190
|
+
* capabilities}] } or null when the tool/server is unavailable (older deploy,
|
|
191
|
+
* network) — null means "the server has no say", so the local config decides.
|
|
192
|
+
*/
|
|
193
|
+
export async function getVolunteerSettings(cfg) {
|
|
194
|
+
try {
|
|
195
|
+
const body = await callTool(cfg, "get_volunteer_settings", {});
|
|
196
|
+
return { ok: true, value: body && Array.isArray(body.settings) ? body : null };
|
|
197
|
+
} catch (e) {
|
|
198
|
+
if (/unknown tool/i.test(e.message)) return { ok: true, value: null }; // older server: config decides
|
|
199
|
+
return { ok: false, value: null }; // transient failure: caller reuses last-good, NOT config
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Fetch the team memory to inject into a run prompt (recall-injection). Query-first
|
|
205
|
+
* (task title); when nothing matches, fall back to the workspace's top notes (the
|
|
206
|
+
* recall RPC orders goals/decisions/gotchas first) so a run never starts blind.
|
|
207
|
+
* Best-effort: any failure returns [] — memory must never block a run.
|
|
208
|
+
*/
|
|
209
|
+
export async function recallMemories(cfg, workspaceId, query, limit = 8) {
|
|
210
|
+
try {
|
|
211
|
+
const q = await callTool(cfg, "recall", { workspace_id: workspaceId, query, limit });
|
|
212
|
+
// `conventions` = the workspace's standing rules, carried on every recall
|
|
213
|
+
// (older servers just don't send the field — degrade to []).
|
|
214
|
+
if ((q.memories ?? []).length > 0) return { memories: q.memories, conventions: q.conventions ?? [] };
|
|
215
|
+
const top = await callTool(cfg, "recall", { workspace_id: workspaceId, limit });
|
|
216
|
+
return { memories: top.memories ?? [], conventions: top.conventions ?? q.conventions ?? [] };
|
|
217
|
+
} catch {
|
|
218
|
+
return { memories: [], conventions: [] };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Proactive cross-workspace recall — proven knowledge from the member's OTHER projects,
|
|
224
|
+
* so a playbook written elsewhere surfaces here without being pointed at it. Best-effort;
|
|
225
|
+
* [] on any failure (older server without the tool, network) so it never blocks a run.
|
|
226
|
+
*/
|
|
227
|
+
export async function recallAcrossWorkspaces(cfg, query, excludeWorkspaceId, limit = 3) {
|
|
228
|
+
try {
|
|
229
|
+
const r = await callTool(cfg, "recall_across_workspaces", { query, exclude_workspace_id: excludeWorkspaceId, limit });
|
|
230
|
+
return r.memories ?? [];
|
|
231
|
+
} catch {
|
|
232
|
+
return [];
|
|
233
|
+
}
|
|
234
|
+
}
|