@worca/app 1.3.0 → 1.4.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.
Files changed (187) hide show
  1. package/README.md +85 -6
  2. package/agents/clarify.meta.json +1 -0
  3. package/agents/memoryDefragmenter.meta.json +2 -1
  4. package/agents/reviewer.meta.json +60 -0
  5. package/agents/worca-cc-code-reviewer.md +33 -0
  6. package/agents/worca-cc-memory-defragmenter.md +5 -3
  7. package/agents/workspaceScanner.meta.json +1 -0
  8. package/package.json +14 -10
  9. package/scripts/git-diff.mjs +25 -0
  10. package/scripts/gitDiff.meta.json +18 -0
  11. package/scripts/js-inline.mjs +11 -0
  12. package/scripts/js.meta.json +22 -0
  13. package/scripts/py-inline.py +27 -0
  14. package/scripts/py.meta.json +22 -0
  15. package/scripts/shell.meta.json +24 -0
  16. package/skills/worca/SKILL.md +3 -2
  17. package/src/cli/models.mjs +247 -0
  18. package/src/cli/render.mjs +72 -4
  19. package/src/cli/schedule.mjs +494 -0
  20. package/src/cli/worca-cc.mjs +1001 -22
  21. package/src/core/agent-registry.mjs +75 -23
  22. package/src/core/agent-store.mjs +51 -2
  23. package/src/core/artifacts.mjs +73 -10
  24. package/src/core/ask/events.mjs +119 -1
  25. package/src/core/ask/limits.mjs +32 -4
  26. package/src/core/ask/mcp-stdio.mjs +12 -0
  27. package/src/core/ask/model-deps.mjs +126 -0
  28. package/src/core/ask/model-proposal.mjs +370 -0
  29. package/src/core/ask/models.mjs +12 -0
  30. package/src/core/ask/policy-deps.mjs +124 -0
  31. package/src/core/ask/policy-proposal.mjs +363 -0
  32. package/src/core/ask/prompt.mjs +74 -9
  33. package/src/core/ask/proposal.mjs +54 -5
  34. package/src/core/ask/schedule-deps.mjs +83 -0
  35. package/src/core/ask/schedule-spec.mjs +310 -0
  36. package/src/core/ask/script-deps.mjs +357 -0
  37. package/src/core/ask/source-deps.mjs +52 -0
  38. package/src/core/ask/source-spec.mjs +157 -0
  39. package/src/core/ask/spawn.mjs +1 -0
  40. package/src/core/ask/store.mjs +6 -3
  41. package/src/core/ask/tool-deps.mjs +4 -0
  42. package/src/core/ask/tools.mjs +657 -37
  43. package/src/core/ask/turn.mjs +109 -2
  44. package/src/core/ask-files.mjs +406 -0
  45. package/src/core/ask-forms.mjs +195 -0
  46. package/src/core/ask-projection.mjs +72 -0
  47. package/src/core/bridge/errors.mjs +84 -0
  48. package/src/core/bridge/provider-ops.mjs +281 -0
  49. package/src/core/bridge/providers/copilot.mjs +269 -0
  50. package/src/core/bridge/providers/endpoint.mjs +257 -0
  51. package/src/core/bridge/registry.mjs +88 -0
  52. package/src/core/bridge/semaphore.mjs +73 -0
  53. package/src/core/bridge/server.mjs +184 -0
  54. package/src/core/bridge/telemetry.mjs +53 -0
  55. package/src/core/bridge/translate/request.mjs +252 -0
  56. package/src/core/bridge/translate/response.mjs +82 -0
  57. package/src/core/bridge/translate/stream.mjs +242 -0
  58. package/src/core/bridge/upstream.mjs +209 -0
  59. package/src/core/chat/command-router.mjs +58 -4
  60. package/src/core/chat/notifier.mjs +14 -1
  61. package/src/core/chat/renderers.mjs +35 -0
  62. package/src/core/claude-runner.mjs +126 -19
  63. package/src/core/config.mjs +212 -31
  64. package/src/core/cost-budget.mjs +3 -2
  65. package/src/core/db.mjs +169 -15
  66. package/src/core/failure-policy.mjs +10 -0
  67. package/src/core/fs-browse.mjs +16 -4
  68. package/src/core/git-info.mjs +22 -0
  69. package/src/core/graph/builtin-workflows.mjs +3 -1
  70. package/src/core/graph/exec-io.mjs +71 -0
  71. package/src/core/graph/executor.mjs +139 -70
  72. package/src/core/graph/human-evidence.mjs +131 -0
  73. package/src/core/graph/python-probe.mjs +172 -0
  74. package/src/core/graph/registry-ports.mjs +10 -6
  75. package/src/core/graph/scheduler.mjs +39 -24
  76. package/src/core/graph/script-child.mjs +81 -0
  77. package/src/core/graph/script-runner.mjs +597 -0
  78. package/src/core/graph/worca_script.py +207 -0
  79. package/src/core/guardrail-store.mjs +16 -0
  80. package/src/core/human-backfill.mjs +108 -0
  81. package/src/core/human-rate.mjs +17 -0
  82. package/src/core/index-html.mjs +6 -2
  83. package/src/core/memory-defrag-model.mjs +112 -0
  84. package/src/core/memory-store.mjs +70 -18
  85. package/src/core/memory-sync.mjs +22 -11
  86. package/src/core/metrics/read.mjs +4 -1
  87. package/src/core/metrics/record.mjs +50 -2
  88. package/src/core/metrics/sync.mjs +6 -4
  89. package/src/core/model-env.mjs +149 -0
  90. package/src/core/model-test.mjs +14 -1
  91. package/src/core/notifications.mjs +128 -0
  92. package/src/core/onboarding.mjs +8 -2
  93. package/src/core/orchestrator.mjs +357 -26
  94. package/src/core/phases.mjs +95 -6
  95. package/src/core/plugin-api.mjs +24 -7
  96. package/src/core/plugin-manifest.mjs +184 -18
  97. package/src/core/plugin-models.mjs +1 -0
  98. package/src/core/plugin-script-cases.mjs +118 -0
  99. package/src/core/plugin-store.mjs +163 -17
  100. package/src/core/plugin-workflows.mjs +71 -17
  101. package/src/core/policy/cache.mjs +116 -0
  102. package/src/core/policy/effective.mjs +175 -0
  103. package/src/core/policy/gate.mjs +91 -0
  104. package/src/core/policy/local.mjs +145 -0
  105. package/src/core/policy/registry.mjs +330 -0
  106. package/src/core/policy/scope.mjs +61 -0
  107. package/src/core/policy/state.mjs +79 -0
  108. package/src/core/policy/sync.mjs +513 -0
  109. package/src/core/protocol.mjs +43 -0
  110. package/src/core/run-harness.mjs +421 -72
  111. package/src/core/scheduler.mjs +980 -0
  112. package/src/core/script-bench.mjs +628 -0
  113. package/src/core/script-registry.mjs +116 -0
  114. package/src/core/script-store.mjs +563 -0
  115. package/src/core/settings.mjs +489 -14
  116. package/src/core/stats.mjs +33 -2
  117. package/src/core/workflow-export.mjs +94 -3
  118. package/src/core/workflow-share.mjs +67 -18
  119. package/src/core/workflows.mjs +47 -11
  120. package/src/core/workspaces.mjs +18 -12
  121. package/src/shared/forms/answer.mjs +164 -0
  122. package/src/shared/forms/catalog.mjs +91 -0
  123. package/src/shared/forms/form-def.mjs +290 -0
  124. package/src/shared/forms/layout.mjs +67 -0
  125. package/src/shared/forms/paths.mjs +47 -0
  126. package/src/shared/forms/project.mjs +309 -0
  127. package/src/shared/forms/schema.mjs +205 -0
  128. package/src/shared/graph/agent-meta.mjs +55 -5
  129. package/src/shared/graph/constants.mjs +14 -2
  130. package/src/shared/graph/flow-layout.mjs +2 -1
  131. package/src/shared/graph/isomorphic.mjs +5 -3
  132. package/src/shared/graph/manifest.mjs +22 -13
  133. package/src/shared/graph/ports.mjs +45 -19
  134. package/src/shared/graph/script-cases.mjs +257 -0
  135. package/src/shared/graph/script-icons.mjs +46 -0
  136. package/src/shared/graph/script-infer.mjs +259 -0
  137. package/src/shared/graph/script-meta.mjs +408 -0
  138. package/src/shared/graph/script-templates.mjs +201 -0
  139. package/src/shared/graph/template.mjs +4 -4
  140. package/src/shared/graph/validate.mjs +89 -16
  141. package/src/shared/human-estimate.mjs +100 -0
  142. package/src/shared/schedule/recurrence.mjs +353 -0
  143. package/src/shared/team-metrics/aggregate.mjs +51 -11
  144. package/{scripts → tools}/install.mjs +3 -3
  145. package/ui/public/app.js +4390 -683
  146. package/ui/public/artifact-picker.mjs +189 -0
  147. package/ui/public/ask/dom.mjs +121 -0
  148. package/ui/public/ask/form-preview.mjs +55 -0
  149. package/ui/public/ask/form-renderer.mjs +250 -0
  150. package/ui/public/ask/registry.mjs +53 -0
  151. package/ui/public/ask/widgets-display.mjs +370 -0
  152. package/ui/public/ask/widgets-input.mjs +624 -0
  153. package/ui/public/ask/widgets-layout.mjs +90 -0
  154. package/ui/public/ask-panel.mjs +401 -27
  155. package/ui/public/ask-run-card.mjs +1 -1
  156. package/ui/public/bridge-view.mjs +694 -0
  157. package/ui/public/chat-settings-view.mjs +24 -0
  158. package/ui/public/code-editor.mjs +181 -0
  159. package/ui/public/getting-started.mjs +34 -7
  160. package/ui/public/graph/composer.mjs +138 -10
  161. package/ui/public/graph/inspector.mjs +61 -58
  162. package/ui/public/graph/palette.mjs +27 -9
  163. package/ui/public/graph/run-decor.mjs +34 -17
  164. package/ui/public/graph/run-hosts.mjs +7 -1
  165. package/ui/public/graph/save-dialog.mjs +3 -0
  166. package/ui/public/graph/view.mjs +23 -7
  167. package/ui/public/guardrails-view.mjs +15 -3
  168. package/ui/public/guide-spot.mjs +87 -9
  169. package/ui/public/index.html +480 -141
  170. package/ui/public/memory-view.mjs +22 -4
  171. package/ui/public/models-view.mjs +162 -17
  172. package/ui/public/node-tunables.mjs +33 -4
  173. package/ui/public/plugins-view.mjs +23 -1
  174. package/ui/public/results-view.mjs +4 -2
  175. package/ui/public/schedule-sheet.mjs +430 -0
  176. package/ui/public/schedules-view.mjs +432 -0
  177. package/ui/public/script-bench-view.mjs +1154 -0
  178. package/ui/public/script-forms.mjs +282 -0
  179. package/ui/public/script-wizard.mjs +529 -0
  180. package/ui/public/scripts-view.mjs +868 -0
  181. package/ui/public/stats-view.mjs +159 -52
  182. package/ui/public/style.css +1461 -44
  183. package/ui/public/team-metrics-surfaces.mjs +77 -16
  184. package/ui/public/team-metrics-view.mjs +68 -5
  185. package/ui/public/team-policy-view.mjs +1402 -0
  186. package/ui/public/ui-level.mjs +237 -0
  187. package/ui/server.mjs +1827 -73
@@ -0,0 +1,73 @@
1
+ // src/core/bridge/semaphore.mjs
2
+ // Per-key counting semaphore for the bridge's upstream concurrency cap
3
+ // (model-bridge-design.md §7.4). A request over the cap WAITS in FIFO order;
4
+ // it never fails. The cap is read per acquire so a settings edit applies to
5
+ // the next request without a restart.
6
+
7
+ export class KeyedSemaphore {
8
+ constructor() { this.slots = new Map(); }
9
+
10
+ _slot(key) {
11
+ let s = this.slots.get(key);
12
+ if (!s) { s = { active: 0, queue: [] }; this.slots.set(key, s); }
13
+ return s;
14
+ }
15
+
16
+ /** Number of requests waiting for `key` right now. */
17
+ queued(key) { return this._slot(key).queue.length; }
18
+ /** Number of requests in flight for `key` right now. */
19
+ active(key) { return this._slot(key).active; }
20
+
21
+ /**
22
+ * Acquire one slot under `key` with `limit` concurrent holders. Resolves to
23
+ * a release function. `signal` aborts the wait (rejects with AbortError).
24
+ * @param {string} key
25
+ * @param {number} limit
26
+ * @param {{signal?:AbortSignal}} [opts]
27
+ * @returns {Promise<() => void>}
28
+ */
29
+ acquire(key, limit, { signal } = {}) {
30
+ const s = this._slot(key);
31
+ const max = Number.isInteger(limit) && limit > 0 ? limit : 1;
32
+ return new Promise((resolve, reject) => {
33
+ const grant = () => {
34
+ s.active += 1;
35
+ let released = false;
36
+ resolve(() => {
37
+ if (released) return;
38
+ released = true;
39
+ s.active -= 1;
40
+ this._drain(key);
41
+ });
42
+ };
43
+ if (s.active < max) { grant(); return; }
44
+ if (signal && signal.aborted) { reject(abortError()); return; }
45
+ const entry = { limit: max, grant: null };
46
+ const onAbort = () => {
47
+ const i = s.queue.indexOf(entry);
48
+ if (i !== -1) s.queue.splice(i, 1);
49
+ reject(abortError());
50
+ };
51
+ entry.grant = () => {
52
+ if (signal) signal.removeEventListener('abort', onAbort);
53
+ grant();
54
+ };
55
+ if (signal) signal.addEventListener('abort', onAbort, { once: true });
56
+ s.queue.push(entry);
57
+ });
58
+ }
59
+
60
+ _drain(key) {
61
+ const s = this._slot(key);
62
+ while (s.queue.length && s.active < s.queue[0].limit) {
63
+ const next = s.queue.shift();
64
+ next.grant();
65
+ }
66
+ }
67
+ }
68
+
69
+ function abortError() {
70
+ const e = new Error('aborted while waiting for an upstream slot');
71
+ e.name = 'AbortError';
72
+ return e;
73
+ }
@@ -0,0 +1,184 @@
1
+ // src/core/bridge/server.mjs
2
+ // The in-process loopback bridge (model-bridge-design.md §4.1): an http
3
+ // server on 127.0.0.1 that presents the Anthropic Messages API to the spawned
4
+ // `claude` CLI under /m/<catalogId>/v1/... and forwards each request to the
5
+ // entry's configured upstream. One per worca process (UI server or CLI),
6
+ // started lazily by resolveModelEnv on the first bridged dispatch — or ahead
7
+ // of time by startBridge() at boot — bound to an ephemeral port, unref'd so
8
+ // an idle bridge never keeps the process alive.
9
+ //
10
+ // Auth to the bridge is a per-process random secret handed to the CLI as
11
+ // ANTHROPIC_AUTH_TOKEN: the bridge holds real credentials, and loopback alone
12
+ // would let any local process (or a sub-agent shelling out to curl) borrow
13
+ // them. The secret is never persisted and never logged.
14
+
15
+ import http from 'node:http';
16
+ import { randomBytes } from 'node:crypto';
17
+ import { findBridgedEntry } from './registry.mjs';
18
+ import { handleMessages } from './upstream.mjs';
19
+ import { estimateInputTokens } from './translate/response.mjs';
20
+ import { bridgeErrors, PAYLOAD_CEILING_BYTES } from './errors.mjs';
21
+
22
+ let state = null; // { server, port, secret, listening: Promise<number>, log }
23
+ const ROUTE_RE = /^\/m\/([^/]+)(?:\/r\/([^/]+))?\/v1\/(messages|messages\/count_tokens|models)\/?$/;
24
+
25
+ /** The bridge's per-process bearer secret (created on first use). */
26
+ export function bridgeSecret() {
27
+ if (!state) state = createState();
28
+ return state.secret;
29
+ }
30
+
31
+ function createState() {
32
+ return { server: null, port: 0, secret: randomBytes(32).toString('hex'), listening: null, log: null };
33
+ }
34
+
35
+ /**
36
+ * Start the bridge if it is not running and resolve once it listens.
37
+ * @param {{log?:(line:string)=>void, fetch?:typeof fetch}} [opts]
38
+ * @returns {Promise<{port:number, secret:string}>}
39
+ */
40
+ export async function startBridge(opts = {}) {
41
+ ensureBridgeSync(opts);
42
+ await state.listening;
43
+ return { port: state.port, secret: state.secret };
44
+ }
45
+
46
+ /**
47
+ * The bridge's {port, secret}, starting it on a random loopback port when it
48
+ * is not running yet. Synchronous by contract (resolveModelEnv is sync): the
49
+ * server is listening by the time the spawned CLI makes its first request,
50
+ * and startBridge() at boot is the normal path that makes this a no-op.
51
+ */
52
+ export function ensureBridgeSync({ log, fetch: f } = {}) {
53
+ if (!state) state = createState();
54
+ if (log) state.log = log;
55
+ if (f) state.fetch = f;
56
+ if (state.server) return { port: state.port, secret: state.secret };
57
+ const server = http.createServer((req, res) => handle(req, res).catch((err) => {
58
+ try { if (!res.headersSent) res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ type: 'error', error: { type: 'api_error', message: `bridge: ${err && err.message ? err.message : String(err)}` } })); } catch { /* gone */ }
59
+ }));
60
+ server.keepAliveTimeout = 65_000;
61
+ server.headersTimeout = 70_000;
62
+ server.requestTimeout = 0; // long-running streams
63
+ state.server = server;
64
+ state.port = pickPort();
65
+ state.listening = new Promise((resolve) => {
66
+ let attempts = 0;
67
+ const tryListen = () => {
68
+ server.once('error', (err) => {
69
+ if (err && err.code === 'EADDRINUSE' && attempts < 10) {
70
+ attempts += 1;
71
+ state.port = pickPort();
72
+ (state.log || console.warn)(`[worca] bridge: port in use, retrying on ${state.port}`);
73
+ tryListen();
74
+ return;
75
+ }
76
+ (state.log || console.warn)(`[worca] bridge: failed to listen — ${err && err.message}`);
77
+ state.server = null;
78
+ resolve(0);
79
+ });
80
+ server.listen(state.port, '127.0.0.1', () => {
81
+ state.port = server.address().port;
82
+ server.unref();
83
+ resolve(state.port);
84
+ });
85
+ };
86
+ tryListen();
87
+ });
88
+ return { port: state.port, secret: state.secret };
89
+ }
90
+
91
+ /** A random port in the private range; the listen retry covers a collision. */
92
+ function pickPort() { return 20000 + Math.floor(Math.random() * 40000); }
93
+
94
+ /** The base URL the CLI gets for a catalog id (optionally tagged with a run's execution id). */
95
+ export function bridgeBaseUrl(catalogId, { tag } = {}) {
96
+ const { port } = ensureBridgeSync();
97
+ const t = tag ? `/r/${encodeURIComponent(String(tag))}` : '';
98
+ return `http://127.0.0.1:${port}/m/${encodeURIComponent(catalogId)}${t}`;
99
+ }
100
+
101
+ /** Whether the bridge is up (listening or about to). */
102
+ export function bridgeRunning() { return !!(state && state.server); }
103
+ export function bridgePort() { return state ? state.port : 0; }
104
+
105
+ /** Stop the bridge (tests, shutdown). */
106
+ export async function stopBridge() {
107
+ if (!state || !state.server) { state = null; return; }
108
+ const s = state.server;
109
+ state = null;
110
+ await new Promise((r) => { try { s.closeAllConnections?.(); } catch { /* n/a */ } s.close(() => r()); });
111
+ }
112
+
113
+ // ── request handling ─────────────────────────────────────────────────────────
114
+
115
+ function readBody(req, limit) {
116
+ return new Promise((resolve, reject) => {
117
+ const chunks = [];
118
+ let size = 0;
119
+ req.on('data', (c) => {
120
+ size += c.length;
121
+ if (size > limit) { reject(Object.assign(new Error('too large'), { code: 'TOO_LARGE' })); req.destroy(); return; }
122
+ chunks.push(c);
123
+ });
124
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
125
+ req.on('error', reject);
126
+ });
127
+ }
128
+
129
+ function sendJson(res, status, obj, headers) {
130
+ if (res.headersSent) { res.end(); return; }
131
+ res.writeHead(status, { 'content-type': 'application/json', ...(headers || {}) });
132
+ res.end(JSON.stringify(obj));
133
+ }
134
+
135
+ function authorized(req) {
136
+ const h = req.headers;
137
+ const bearer = typeof h.authorization === 'string' && /^bearer\s+/i.test(h.authorization) ? h.authorization.replace(/^bearer\s+/i, '').trim() : '';
138
+ const key = typeof h['x-api-key'] === 'string' ? h['x-api-key'].trim() : '';
139
+ return (bearer && bearer === state.secret) || (key && key === state.secret);
140
+ }
141
+
142
+ async function handle(req, res) {
143
+ const url = new URL(req.url || '/', 'http://127.0.0.1');
144
+ const m = ROUTE_RE.exec(url.pathname);
145
+ if (!m) { const e = bridgeErrors.notFound(); return sendJson(res, e.status, e.body); }
146
+ if (!authorized(req)) { const e = bridgeErrors.unauthorized(); return sendJson(res, e.status, e.body); }
147
+ const catalogId = decodeURIComponent(m[1]);
148
+ const tag = m[2] ? decodeURIComponent(m[2]) : '';
149
+ const route = m[3];
150
+ const entry = findBridgedEntry(catalogId);
151
+ if (!entry) { const e = bridgeErrors.unknownModel(catalogId); return sendJson(res, e.status, e.body); }
152
+
153
+ if (route === 'models') {
154
+ return sendJson(res, 200, { data: [{ id: entry.id, type: 'model', display_name: entry.label, created_at: '2026-01-01T00:00:00Z' }], has_more: false, first_id: entry.id, last_id: entry.id });
155
+ }
156
+ if (req.method !== 'POST') { const e = bridgeErrors.notFound(); return sendJson(res, e.status, e.body); }
157
+
158
+ let text;
159
+ try {
160
+ text = await readBody(req, PAYLOAD_CEILING_BYTES + 1024);
161
+ } catch (err) {
162
+ const e = err && err.code === 'TOO_LARGE' ? bridgeErrors.tooLarge() : bridgeErrors.badJson();
163
+ return sendJson(res, e.status, e.body);
164
+ }
165
+ let body;
166
+ try { body = JSON.parse(text); } catch { const e = bridgeErrors.badJson(); return sendJson(res, e.status, e.body); }
167
+ if (!body || typeof body !== 'object') { const e = bridgeErrors.badJson(); return sendJson(res, e.status, e.body); }
168
+
169
+ if (route === 'messages/count_tokens') {
170
+ return sendJson(res, 200, { input_tokens: estimateInputTokens(body) });
171
+ }
172
+
173
+ const ctrl = new AbortController();
174
+ res.on('close', () => { if (!res.writableFinished) ctrl.abort(); });
175
+ const reply = {
176
+ status: (code, headers) => { if (!res.headersSent) res.writeHead(code, headers || {}); },
177
+ write: (chunk) => { if (!res.destroyed) res.write(chunk); },
178
+ end: () => { if (!res.destroyed) res.end(); },
179
+ json: (code, obj, headers) => sendJson(res, code, obj, headers),
180
+ };
181
+ const headers = {};
182
+ for (const [k, v] of Object.entries(req.headers)) if (typeof v === 'string') headers[k.toLowerCase()] = v;
183
+ await handleMessages({ entry, body, requestHeaders: headers, tag, signal: ctrl.signal, fetch: state.fetch, log: state.log }, reply);
184
+ }
@@ -0,0 +1,53 @@
1
+ // src/core/bridge/telemetry.mjs
2
+ // What the bridge did, for the run surfaces (model-bridge-design.md §7.2,
3
+ // §8.6): a per-tag counter of premium-request-INITIATING calls (a user turn,
4
+ // as opposed to a tool-result continuation the upstream bills as part of the
5
+ // same request) and an event stream the harness can subscribe to. The tag is
6
+ // the run's execution id, threaded through resolveModelEnv → the bridge URL.
7
+
8
+ import { EventEmitter } from 'node:events';
9
+
10
+ /** Emits 'call' {tag, catalogId, provider, api, initiator} and 'failure'
11
+ * {tag, catalogId, status, message}. (Not 'error': an EventEmitter throws on
12
+ * an unlistened 'error' event, and telemetry must never fail a request.) */
13
+ export const bridgeEvents = new EventEmitter();
14
+ bridgeEvents.setMaxListeners(50);
15
+
16
+ const calls = new Map(); // tag -> { initiated, continued, errors }
17
+ const MAX_TAGS = 5000;
18
+
19
+ function slot(tag) {
20
+ const k = tag || '';
21
+ let s = calls.get(k);
22
+ if (!s) {
23
+ if (calls.size >= MAX_TAGS) calls.delete(calls.keys().next().value);
24
+ s = { initiated: 0, continued: 0, errors: 0 };
25
+ calls.set(k, s);
26
+ }
27
+ return s;
28
+ }
29
+
30
+ /** Book one upstream call. `initiator` is 'user' | 'agent' (§7.1). */
31
+ export function recordBridgeCall({ tag, catalogId, provider, api, initiator }) {
32
+ const s = slot(tag);
33
+ if (initiator === 'agent') s.continued += 1; else s.initiated += 1;
34
+ bridgeEvents.emit('call', { tag: tag || '', catalogId, provider, api, initiator });
35
+ }
36
+
37
+ /** Book one failed upstream call. */
38
+ export function recordBridgeError({ tag, catalogId, provider, status, message }) {
39
+ slot(tag).errors += 1;
40
+ bridgeEvents.emit('failure', { tag: tag || '', catalogId, provider, status, message });
41
+ }
42
+
43
+ /** Counters for a tag: {initiated, continued, errors}; zeros when unseen. */
44
+ export function bridgeCallsFor(tag) {
45
+ const s = calls.get(tag || '');
46
+ return s ? { ...s } : { initiated: 0, continued: 0, errors: 0 };
47
+ }
48
+
49
+ /** Forget a tag's counters (a finished run). */
50
+ export function forgetBridgeTag(tag) { calls.delete(tag || ''); }
51
+
52
+ /** Test hook. */
53
+ export function _resetBridgeTelemetry() { calls.clear(); }
@@ -0,0 +1,252 @@
1
+ // src/core/bridge/translate/request.mjs
2
+ // Anthropic Messages API request -> OpenAI chat/completions request
3
+ // (model-bridge-design.md §5.1–§5.3). Pure: no I/O, no globals. Every field
4
+ // the CLI sends that has no equivalent is DROPPED and named in `warnings`;
5
+ // anything that cannot be expressed at all (a server tool, tools on a model
6
+ // without tool calls) is a hard `error` — the bridge answers 400 with it, so a
7
+ // run fails with the fix instruction instead of silently running degraded.
8
+ //
9
+ // Scope is what Claude Code actually sends. Reference implementations for the
10
+ // edge cases: ericc-ch/copilot-api, voidsteed/copilot-proxy-api (both MIT).
11
+
12
+ /** Anthropic server-side tool types — no chat/completions equivalent (§5.3). */
13
+ export const SERVER_TOOL_RE = /^(web_search|web_fetch|computer|text_editor|bash|code_execution|memory)(_\d{8})?$/;
14
+
15
+ /** Efforts (CLI `--effort` -> `output_config.effort`) -> OpenAI reasoning_effort. */
16
+ const EFFORT_TO_REASONING = { low: 'low', medium: 'medium', high: 'high', xhigh: 'high', max: 'high' };
17
+
18
+ /** Thinking budget bands -> reasoning_effort (§5.1). */
19
+ export function budgetToReasoningEffort(budget) {
20
+ const n = Number(budget);
21
+ if (!Number.isFinite(n) || n <= 0) return 'medium';
22
+ if (n < 4000) return 'low';
23
+ if (n < 16000) return 'medium';
24
+ return 'high';
25
+ }
26
+
27
+ /** Text of a block list (text blocks only), joined with blank lines. */
28
+ function textOf(content) {
29
+ if (typeof content === 'string') return content;
30
+ if (!Array.isArray(content)) return '';
31
+ return content
32
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
33
+ .map((b) => b.text)
34
+ .join('\n\n');
35
+ }
36
+
37
+ /** An Anthropic image block -> chat/completions image_url part (data URI). */
38
+ function imagePart(block) {
39
+ const src = block.source || {};
40
+ if (src.type === 'base64' && src.media_type && src.data) {
41
+ return { type: 'image_url', image_url: { url: `data:${src.media_type};base64,${src.data}` } };
42
+ }
43
+ if (src.type === 'url' && typeof src.url === 'string') {
44
+ return { type: 'image_url', image_url: { url: src.url } };
45
+ }
46
+ return null;
47
+ }
48
+
49
+ /**
50
+ * Build chat/completions content from Anthropic user-side blocks. Returns
51
+ * `{ parts, warnings }` where parts is a string when it is text-only.
52
+ */
53
+ function userParts(blocks, caps, warn) {
54
+ const parts = [];
55
+ for (const b of blocks) {
56
+ if (!b || typeof b !== 'object') continue;
57
+ if (b.type === 'text') {
58
+ if (typeof b.text === 'string' && b.text) parts.push({ type: 'text', text: b.text });
59
+ } else if (b.type === 'image') {
60
+ if (caps.vision === false) {
61
+ warn('image');
62
+ parts.push({ type: 'text', text: '[image omitted: model has no vision]' });
63
+ } else {
64
+ const p = imagePart(b);
65
+ if (p) parts.push(p); else warn('image');
66
+ }
67
+ } else if (b.type === 'document') {
68
+ warn('document');
69
+ parts.push({ type: 'text', text: '[document omitted: not supported by this model]' });
70
+ } else {
71
+ warn(b.type);
72
+ }
73
+ }
74
+ return parts;
75
+ }
76
+
77
+ /** Collapse a parts array to a string when it is text-only (most compatible). */
78
+ function compact(parts) {
79
+ if (!parts.length) return '';
80
+ if (parts.every((p) => p.type === 'text')) return parts.map((p) => p.text).join('\n\n');
81
+ return parts;
82
+ }
83
+
84
+ /** Flatten a tool_result's content to text; images come back separately (§5.2). */
85
+ function toolResultContent(content, caps, warn) {
86
+ if (typeof content === 'string') return { text: content, images: [] };
87
+ if (!Array.isArray(content)) return { text: content == null ? '' : String(content), images: [] };
88
+ const texts = [];
89
+ const images = [];
90
+ for (const b of content) {
91
+ if (!b || typeof b !== 'object') continue;
92
+ if (b.type === 'text' && typeof b.text === 'string') texts.push(b.text);
93
+ // ToolSearch's result: the named tool is now in `tools` on this request.
94
+ else if (b.type === 'tool_reference') texts.push(`Tool loaded: ${b.tool_name || b.name || 'unknown'}`);
95
+ else if (b.type === 'image') {
96
+ if (caps.vision === false) { warn('image'); texts.push('[image omitted: model has no vision]'); }
97
+ else { const p = imagePart(b); if (p) images.push(p); else warn('image'); }
98
+ } else warn(b.type);
99
+ }
100
+ return { text: texts.join('\n\n'), images };
101
+ }
102
+
103
+ /**
104
+ * Translate one Messages API request body.
105
+ * @param {object} body the Anthropic request as the CLI sent it
106
+ * @param {{upstreamModel:string, capabilities?:{toolCalls?:boolean, vision?:boolean, reasoning?:boolean, maxOutputTokens?:number}}} opts
107
+ * @returns {{body?:object, warnings:string[], error?:{type:string,message:string}}}
108
+ */
109
+ export function toChatRequest(body, { upstreamModel, capabilities = {} } = {}) {
110
+ const caps = capabilities || {};
111
+ const warnings = [];
112
+ const seen = new Set();
113
+ const warn = (what) => { if (what && !seen.has(what)) { seen.add(what); warnings.push(what); } };
114
+ const fail = (message) => ({ warnings, error: { type: 'invalid_request_error', message } });
115
+
116
+ if (!body || typeof body !== 'object') return fail('request body must be an object');
117
+
118
+ const messages = [];
119
+
120
+ // ── system ──
121
+ const system = textOf(body.system);
122
+ if (system) messages.push({ role: 'system', content: system });
123
+ if (Array.isArray(body.system) && body.system.some((b) => b && b.cache_control)) warn('cache_control');
124
+
125
+ // ── messages ──
126
+ const src = Array.isArray(body.messages) ? body.messages : [];
127
+ for (const m of src) {
128
+ if (!m || typeof m !== 'object') continue;
129
+ const blocks = typeof m.content === 'string' ? [{ type: 'text', text: m.content }] : (Array.isArray(m.content) ? m.content : []);
130
+ if (blocks.some((b) => b && b.cache_control)) warn('cache_control');
131
+
132
+ if (m.role === 'assistant') {
133
+ const texts = [];
134
+ const toolCalls = [];
135
+ for (const b of blocks) {
136
+ if (!b || typeof b !== 'object') continue;
137
+ if (b.type === 'text') { if (typeof b.text === 'string' && b.text) texts.push(b.text); }
138
+ else if (b.type === 'tool_use') {
139
+ toolCalls.push({
140
+ id: String(b.id || ''),
141
+ type: 'function',
142
+ function: { name: String(b.name || ''), arguments: JSON.stringify(b.input ?? {}) },
143
+ });
144
+ } else if (b.type === 'thinking' || b.type === 'redacted_thinking') warn('thinking');
145
+ else warn(b.type);
146
+ }
147
+ if (!texts.length && !toolCalls.length) continue; // thinking-only turn: removed (§5.2)
148
+ const msg = { role: 'assistant', content: texts.length ? texts.join('\n\n') : null };
149
+ if (toolCalls.length) msg.tool_calls = toolCalls;
150
+ messages.push(msg);
151
+ continue;
152
+ }
153
+
154
+ // user: tool results first (they must directly follow the assistant turn),
155
+ // then the remaining blocks as one user message.
156
+ const results = blocks.filter((b) => b && b.type === 'tool_result');
157
+ const rest = blocks.filter((b) => b && b.type !== 'tool_result');
158
+ const relocated = [];
159
+ for (const r of results) {
160
+ const { text, images } = toolResultContent(r.content, caps, warn);
161
+ messages.push({ role: 'tool', tool_call_id: String(r.tool_use_id || ''), content: text || (r.is_error ? 'error' : '') });
162
+ relocated.push(...images);
163
+ }
164
+ const parts = userParts(rest, caps, warn);
165
+ parts.push(...relocated);
166
+ if (parts.length) {
167
+ const prev = messages[messages.length - 1];
168
+ const content = compact(parts);
169
+ if (prev && prev.role === 'user' && typeof prev.content === 'string' && typeof content === 'string') {
170
+ prev.content = `${prev.content}\n\n${content}`; // adjacent after a dropped assistant turn
171
+ } else {
172
+ messages.push({ role: 'user', content });
173
+ }
174
+ }
175
+ }
176
+
177
+ // ── tools ──
178
+ const referenced = new Set();
179
+ for (const m of src) {
180
+ if (!m || !Array.isArray(m.content)) continue;
181
+ for (const b of m.content) {
182
+ if (!b || b.type !== 'tool_result' || !Array.isArray(b.content)) continue;
183
+ for (const r of b.content) if (r && r.type === 'tool_reference' && r.tool_name) referenced.add(String(r.tool_name));
184
+ }
185
+ }
186
+ let tools;
187
+ if (Array.isArray(body.tools) && body.tools.length) {
188
+ if (caps.toolCalls === false) return fail(`model ${upstreamModel} does not support tool calls`);
189
+ tools = [];
190
+ for (const t of body.tools) {
191
+ if (!t || typeof t !== 'object') continue;
192
+ // Tool search: a deferred tool is sent only once a ToolSearch result has
193
+ // referenced it (the Anthropic API expands tool_reference blocks against
194
+ // these definitions server-side; chat/completions has no such step).
195
+ if (t.defer_loading === true && !referenced.has(String(t.name || ''))) continue;
196
+ if (t.type && t.type !== 'custom' && SERVER_TOOL_RE.test(String(t.type))) {
197
+ return fail(`tool ${JSON.stringify(t.name || t.type)} is an Anthropic server tool and cannot run through ${upstreamModel} (openai-chat bridge)`);
198
+ }
199
+ const fn = { name: String(t.name || ''), parameters: t.input_schema && typeof t.input_schema === 'object' ? t.input_schema : { type: 'object', properties: {} } };
200
+ if (typeof t.description === 'string' && t.description) fn.description = t.description;
201
+ tools.push({ type: 'function', function: fn });
202
+ }
203
+ if (!tools.length) tools = undefined;
204
+ }
205
+
206
+ // ── tool_choice ──
207
+ let toolChoice;
208
+ let parallel;
209
+ const tc = body.tool_choice;
210
+ if (tc && typeof tc === 'object') {
211
+ if (tc.type === 'auto') toolChoice = 'auto';
212
+ else if (tc.type === 'any') toolChoice = 'required';
213
+ else if (tc.type === 'none') toolChoice = 'none';
214
+ else if (tc.type === 'tool' && tc.name) toolChoice = { type: 'function', function: { name: String(tc.name) } };
215
+ if (tc.disable_parallel_tool_use === true) parallel = false;
216
+ }
217
+
218
+ // ── sampling / limits ──
219
+ const reasoning = caps.reasoning === true;
220
+ const out = { model: upstreamModel, messages };
221
+ if (tools) out.tools = tools;
222
+ if (toolChoice !== undefined && tools) out.tool_choice = toolChoice;
223
+ if (parallel === false && tools) out.parallel_tool_calls = false;
224
+
225
+ let maxTokens = Number(body.max_tokens);
226
+ if (Number.isFinite(maxTokens) && maxTokens > 0) {
227
+ const cap = Number(caps.maxOutputTokens);
228
+ if (Number.isFinite(cap) && cap > 0 && maxTokens > cap) { maxTokens = cap; warn('max_tokens clamped'); }
229
+ if (reasoning) out.max_completion_tokens = maxTokens; else out.max_tokens = maxTokens;
230
+ }
231
+ if (typeof body.temperature === 'number') { if (reasoning) warn('temperature'); else out.temperature = body.temperature; }
232
+ if (typeof body.top_p === 'number') { if (reasoning) warn('top_p'); else out.top_p = body.top_p; }
233
+ if (body.top_k !== undefined) warn('top_k');
234
+ if (Array.isArray(body.stop_sequences) && body.stop_sequences.length) out.stop = body.stop_sequences.slice(0, 4);
235
+ if (body.metadata !== undefined) warn('metadata');
236
+
237
+ // ── reasoning ──
238
+ let effort;
239
+ if (body.thinking && typeof body.thinking === 'object' && body.thinking.type === 'enabled') {
240
+ effort = budgetToReasoningEffort(body.thinking.budget_tokens);
241
+ }
242
+ const oc = body.output_config;
243
+ if (oc && typeof oc === 'object' && typeof oc.effort === 'string' && EFFORT_TO_REASONING[oc.effort]) {
244
+ effort = EFFORT_TO_REASONING[oc.effort];
245
+ }
246
+ if (effort) { if (reasoning) out.reasoning_effort = effort; else warn('thinking'); }
247
+
248
+ // ── stream ──
249
+ if (body.stream === true) { out.stream = true; out.stream_options = { include_usage: true }; }
250
+
251
+ return { body: out, warnings };
252
+ }
@@ -0,0 +1,82 @@
1
+ // src/core/bridge/translate/response.mjs
2
+ // Non-streaming chat/completions response -> Anthropic message object, and the
3
+ // local count_tokens estimate (model-bridge-design.md §5.4 buffered path, §5.9).
4
+
5
+ import { mapStopReason, mapUsage, newMessageId, newToolUseId, parsesAsJson, truncatedToolNote } from './stream.mjs';
6
+
7
+ /**
8
+ * @param {object} completion the upstream JSON body
9
+ * @param {{model:string}} opts catalog id the CLI expects back
10
+ */
11
+ export function toMessagesResponse(completion, { model } = {}) {
12
+ const choice = completion && Array.isArray(completion.choices) ? completion.choices[0] : null;
13
+ const msg = (choice && choice.message) || {};
14
+ const content = [];
15
+ if (typeof msg.content === 'string' && msg.content) content.push({ type: 'text', text: msg.content });
16
+ const calls = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
17
+ const finish = choice ? choice.finish_reason : null;
18
+ let toolUses = 0;
19
+ for (const tc of calls) {
20
+ if (!tc || typeof tc !== 'object') continue;
21
+ const fn = tc.function || {};
22
+ // Cut off by `length` mid-arguments: not runnable — say so instead (see stream.mjs).
23
+ if (finish === 'length' && !parsesAsJson(fn.arguments)) {
24
+ content.push({ type: 'text', text: truncatedToolNote(String(fn.name || 'tool')) });
25
+ continue;
26
+ }
27
+ let input = {};
28
+ if (typeof fn.arguments === 'string' && fn.arguments.trim()) {
29
+ try { input = JSON.parse(fn.arguments); } catch { input = { _raw: fn.arguments }; }
30
+ }
31
+ content.push({ type: 'tool_use', id: tc.id || newToolUseId(), name: String(fn.name || ''), input });
32
+ toolUses += 1;
33
+ }
34
+ let stop = mapStopReason(finish, { emitted: content.length > 0 }) || 'end_turn';
35
+ if (toolUses && stop === 'end_turn') stop = 'tool_use';
36
+ return {
37
+ id: newMessageId(),
38
+ type: 'message',
39
+ role: 'assistant',
40
+ model: model || 'bridge',
41
+ content,
42
+ stop_reason: stop,
43
+ stop_sequence: null,
44
+ usage: mapUsage(completion && completion.usage),
45
+ };
46
+ }
47
+
48
+ const IMAGE_TOKENS = 1200;
49
+ const TOOL_OVERHEAD = 20;
50
+
51
+ /**
52
+ * Local input-token estimate for /v1/messages/count_tokens: ~4 chars per
53
+ * token over system + messages + serialized tools, plus per-image and
54
+ * per-tool constants. ±15 % is enough for the CLI's context heuristics.
55
+ * @param {object} body
56
+ * @returns {number}
57
+ */
58
+ export function estimateInputTokens(body) {
59
+ if (!body || typeof body !== 'object') return 0;
60
+ let chars = 0;
61
+ let images = 0;
62
+ const walk = (content) => {
63
+ if (typeof content === 'string') { chars += content.length; return; }
64
+ if (!Array.isArray(content)) return;
65
+ for (const b of content) {
66
+ if (!b || typeof b !== 'object') continue;
67
+ if (b.type === 'image') images += 1;
68
+ else if (b.type === 'tool_result') walk(b.content);
69
+ else if (b.type === 'tool_use') chars += JSON.stringify(b.input ?? {}).length + (b.name || '').length;
70
+ else if (typeof b.text === 'string') chars += b.text.length;
71
+ else if (typeof b.thinking === 'string') chars += b.thinking.length;
72
+ }
73
+ };
74
+ walk(body.system);
75
+ for (const m of Array.isArray(body.messages) ? body.messages : []) if (m) walk(m.content);
76
+ let tools = 0;
77
+ if (Array.isArray(body.tools)) {
78
+ tools = body.tools.length;
79
+ chars += JSON.stringify(body.tools).length;
80
+ }
81
+ return Math.ceil(chars / 4) + images * IMAGE_TOKENS + tools * TOOL_OVERHEAD;
82
+ }