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/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
+ }