@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,195 @@
1
+ // src/core/ask-forms.mjs
2
+ // The two host-side gates of an ask form (spec §5).
3
+ //
4
+ // GATE 2 (ask time, prepareFormAsk): the agent wrote {form, data}. Resolve the
5
+ // form from its sidecar, cap the data, run P1's checkAskData (the data schema
6
+ // AND "the auto answer for THIS data must pass gate 3"), snapshot every file
7
+ // it references, resolve enumFrom/defaultFrom into a CLOSED answer schema, and
8
+ // hand back the ask the host gates on. A refusal carries the EXACT error list
9
+ // the agent is resumed with — never a throw.
10
+ //
11
+ // GATE 3 (answer time, formAnswerValidator): the human sent {values}. Drop the
12
+ // fields `when` hides, strip unknown keys, validate against the RESOLVED
13
+ // answer schema, and hand back {form, version, values}. A refusal carries the
14
+ // error list POST /api/answer returns as 422 — never a throw.
15
+ //
16
+ // Gate 3 runs off the PERSISTED ask alone (layout + answerSchema): by the time a
17
+ // server restart re-runs it, the agent's sidecar may have changed or its plugin
18
+ // been removed (§9). Nothing here reads the registry.
19
+ import { join } from 'node:path';
20
+
21
+ import { ASK_LIMITS } from '../shared/forms/catalog.mjs';
22
+ import { resolveAnswerSchema } from '../shared/forms/schema.mjs';
23
+ import { checkAskData, autoAnswer, collectAnswer, fileRefs } from '../shared/forms/answer.mjs';
24
+ import { FORM_SURFACES } from '../shared/forms/form-def.mjs';
25
+ import { snapshotAskFiles } from './ask-files.mjs';
26
+
27
+ /** The snapshot root inside a pipeline dir: <pipelineDir>/ask-files/<askId>/. */
28
+ export const ASK_FILES_DIR = 'ask-files';
29
+
30
+ /** Depth cap for anything that walks a posted value recursively. P1 caps only
31
+ * normalizeAskBlock (256 levels, form-def.mjs); its validate / collectAnswer /
32
+ * clone are recursive walkers and native JSON.stringify is one too — on Node 22
33
+ * a value ~9 000 levels deep throws RangeError out of collectAnswer, on Node 25
34
+ * it sails through into the DB and onto the socket. So BOTH gates count depth
35
+ * with a work list (never recursive) BEFORE any recursive step and refuse by
36
+ * name. Same figure as P1's block cap; it is not an ASK_LIMITS value. */
37
+ const MAX_JSON_DEPTH = 256;
38
+
39
+ /** Is `value` nested deeper than `max` object/array levels? Iterative on purpose. */
40
+ function nestsDeeperThan(value, max) {
41
+ const todo = [[value, 0]];
42
+ while (todo.length) {
43
+ const [cur, depth] = todo.pop();
44
+ if (cur === null || typeof cur !== 'object') continue;
45
+ if (depth > max) return true;
46
+ for (const child of Object.values(cur)) todo.push([child, depth + 1]);
47
+ }
48
+ return false;
49
+ }
50
+
51
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
52
+
53
+ /** The ask id, reduced to the token that is BOTH the snapshot dir name and the
54
+ * file route's `:askId` segment. Deterministic and total: the real ask id is
55
+ * `questions-x:n_impl:1-r1`, which is neither a legal Windows path segment nor
56
+ * something a route may take as a path. */
57
+ export function askIdToken(id) {
58
+ const token = String(id ?? '').replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 96);
59
+ return token || 'ask';
60
+ }
61
+
62
+ /**
63
+ * Gate 2. Never throws.
64
+ * @param {{agentMeta: object, payload: {form: string, data: unknown},
65
+ * cwd: string, pipelineDir: string, askId: string}} args
66
+ * @returns {Promise<{ok: true, ask: object, autoValues: object}|{ok: false, errors: Array}>}
67
+ */
68
+ export async function prepareFormAsk({ agentMeta, payload, cwd, pipelineDir, askId }) {
69
+ const forms = agentMeta?.ask?.forms || {};
70
+ const id = typeof payload?.form === 'string' ? payload.form.trim() : '';
71
+ const def = Object.hasOwn(forms, id) ? forms[id] : null;
72
+ if (!def) {
73
+ const known = Object.keys(forms);
74
+ return { ok: false, errors: [{ path: 'form', code: 'unknown-field',
75
+ message: `unknown form "${id}" — this agent declares ${known.length ? known.join(', ') : 'none'}` }] };
76
+ }
77
+ const data = payload?.data === undefined ? {} : payload.data;
78
+ // Gate 1 admits only a form with both object schemas; a def grafted past the
79
+ // registry may lack one, and resolveAnswerSchema(undefined) throws. Never throw.
80
+ if (!isObj(def.data) || !isObj(def.answer)) {
81
+ return { ok: false, errors: [{ path: 'form', code: 'unknown-field',
82
+ message: `form "${id}" has no data or answer schema` }] };
83
+ }
84
+
85
+ // The caps run FIRST, depth before bytes: a schema walk over a 50 MB blob is
86
+ // the denial of service the byte cap exists to prevent, and JSON.stringify
87
+ // (the byte count itself) is a recursive walker a deep value overflows on
88
+ // Node 22 — the depth count is iterative, so it goes first.
89
+ if (nestsDeeperThan(data, MAX_JSON_DEPTH)) {
90
+ return { ok: false, errors: [{ path: 'data', code: 'too-big',
91
+ message: `data is nested deeper than ${MAX_JSON_DEPTH} levels` }] };
92
+ }
93
+ let bytes;
94
+ try {
95
+ bytes = Buffer.byteLength(JSON.stringify(data ?? null) ?? 'null', 'utf8');
96
+ } catch {
97
+ return { ok: false, errors: [{ path: 'data', code: 'type', message: 'data must be JSON-serializable' }] };
98
+ }
99
+ if (bytes > ASK_LIMITS.dataBytes) {
100
+ return { ok: false, errors: [{ path: 'data', code: 'too-big',
101
+ message: `data is ${bytes} bytes; the limit is ${ASK_LIMITS.dataBytes}` }] };
102
+ }
103
+
104
+ // Ruling X4 / P1 C13: NOT a bare validate(def.data, data). checkAskData also
105
+ // proves that the auto answer built from THIS data passes gate 3 — which is
106
+ // what makes autoAnswer() below total, and D10 ("auto can never produce an
107
+ // invalid answer") true at run time rather than only for `example`. Its error
108
+ // paths are already prefixed `data.` / `answer.`; never prefix them again.
109
+ const shape = checkAskData(def, data);
110
+ if (!shape.ok) return { ok: false, errors: shape.errors };
111
+
112
+ const token = askIdToken(askId);
113
+ // ONE fileRefs() call: its result feeds the snapshot AND becomes the envelope's
114
+ // `fileRefs` (ruling X16). Calling P1 twice would let the two arrays disagree,
115
+ // and the renderer's "is this bound value a file?" lookup depends on them
116
+ // lining up index for index.
117
+ const refs = fileRefs(def.data, data);
118
+ const { files, errors } = await snapshotAskFiles({
119
+ refs,
120
+ // Resolution order is the spec's: the node's working tree, then the pipeline
121
+ // dir (§7.1). Both are already absolute.
122
+ roots: [cwd, pipelineDir].filter(Boolean),
123
+ destDir: join(pipelineDir, ASK_FILES_DIR, token),
124
+ });
125
+ if (errors.length) return { ok: false, errors };
126
+
127
+ const ask = {
128
+ kind: 'form',
129
+ // The route-safe snapshot token. NOT the question id (ruling X1): the caller
130
+ // keeps that and passes it as `_ask({ id })`; only this ever reaches a URL.
131
+ askId: token,
132
+ form: id,
133
+ version: Number.isInteger(def.version) ? def.version : 1,
134
+ title: typeof def.title === 'string' && def.title.trim() ? def.title.trim() : id,
135
+ // 'any' | 'web' (P1 FORM_SURFACES, C5). P2 never branches on it — it rides the
136
+ // envelope and the persisted ask so P3 and P4 can. An unrecognized value fails
137
+ // safe to 'any', the surface that refuses nothing.
138
+ surface: FORM_SURFACES.includes(def.surface) ? def.surface : 'any',
139
+ data,
140
+ layout: Array.isArray(def.layout) ? def.layout : [],
141
+ // Resolved HERE and stored with the ask, so gate 3 validates against a closed
142
+ // set even after the sidecar changed (§3.1, §9).
143
+ answerSchema: resolveAnswerSchema(def.answer, data),
144
+ // X16: the renderer's file lookup table — every `type:'file'` value's data
145
+ // path, in the same order as `files`. `accept` is dropped: it is a gate-2
146
+ // input, not something any surface renders.
147
+ fileRefs: refs.map((r) => ({ path: r.path, rel: r.rel })),
148
+ files,
149
+ };
150
+ // D10's auto answer travels BESIDE the ask, never inside it: the persisted
151
+ // shape (§9) is exactly `ask` plus `values`.
152
+ return { ok: true, ask, autoValues: autoAnswer(def, data) };
153
+ }
154
+
155
+ /**
156
+ * Gate 3, bound to one resolved ask. The returned validator is what
157
+ * RunHarness._ask hands to `pendingQuestion.validate`; a `{ ok: false }` result
158
+ * keeps the question OPEN and becomes the 422 body.
159
+ * @param {object} ask the resolved (or persisted) ask
160
+ * @returns {(payload: unknown) => {ok: true, payload: object}|{ok: false, errors: Array}}
161
+ */
162
+ export function formAnswerValidator(ask) {
163
+ // R1: collectAnswer reads only `def.layout`, so a persisted ask is enough.
164
+ const def = { layout: Array.isArray(ask?.layout) ? ask.layout : [] };
165
+ const schema = ask?.answerSchema || { type: 'object', properties: {} };
166
+ const form = ask?.form;
167
+ const version = ask?.version;
168
+ return (payload) => {
169
+ const raw = payload && typeof payload === 'object' && !Array.isArray(payload)
170
+ && payload.values && typeof payload.values === 'object' && !Array.isArray(payload.values)
171
+ ? payload.values
172
+ : null;
173
+ if (!raw) {
174
+ return { ok: false, errors: [{ path: '', code: 'type', message: 'an answer is { values: { … } }' }] };
175
+ }
176
+ // Depth before collectAnswer: its clone() is a JSON round trip, which a deep
177
+ // value overflows on Node 22 — a RangeError out of answer(), not a 422.
178
+ if (nestsDeeperThan(raw, MAX_JSON_DEPTH)) {
179
+ return { ok: false, errors: [{ path: '', code: 'too-big', message: `values are nested deeper than ${MAX_JSON_DEPTH} levels` }] };
180
+ }
181
+ const { values, errors } = collectAnswer(def, schema, raw);
182
+ if (errors.length) return { ok: false, errors };
183
+ return { ok: true, payload: { form, version, values } };
184
+ };
185
+ }
186
+
187
+ /**
188
+ * The generic question a twice-refused form downgrades to (§5 gate 2). The text
189
+ * is the form's own title — the host adds no prose of its own.
190
+ * @param {{form: string, title?: string}} args
191
+ */
192
+ export function downgradeQuestion({ form, title }) {
193
+ const text = typeof title === 'string' && title.trim() ? title.trim() : String(form || 'form');
194
+ return { id: `form-${askIdToken(form)}`, question: text, options: [], allowFreeText: true };
195
+ }
@@ -0,0 +1,72 @@
1
+ // src/core/ask-projection.mjs
2
+ // The two caps every non-web ask surface shares, and the one helper that renders a
3
+ // PERSISTED form ask's answer as text (spec D9, §9).
4
+ //
5
+ // The ask BODY is never formatted here: that is src/shared/forms/project.mjs's
6
+ // projectForm(). What lives here is what a pure, browser-safe module may not know —
7
+ // how many characters a chat message or an assistant read may spend — plus the
8
+ // values line, which is the named destination for the crude projection
9
+ // readPriorAnswers carries today.
10
+ import { promptFields, projectForm } from '../shared/forms/project.mjs';
11
+
12
+ /** Cap for Ask Worca's get_run_progress: an assistant read pays tokens per character. */
13
+ export const PROMPT_PROJECTION_MAX = 2000;
14
+ /** Cap for ONE chat message: Discord's 2000-char limit is the tightest shipped channel. */
15
+ export const CHAT_PROJECTION_MAX = 1400;
16
+
17
+ /** One answer VALUE as text. A review-list row is `<id>: <verdict> (<note>)`; nothing
18
+ * reaches String() as a bare object. */
19
+ function valueText(v) {
20
+ if (v === null || v === undefined) return '';
21
+ if (Array.isArray(v)) {
22
+ const rows = v.some((x) => x && typeof x === 'object' && !Array.isArray(x));
23
+ return v.map(valueText).join(rows ? '; ' : ', ');
24
+ }
25
+ if (typeof v === 'object') {
26
+ const id = v.id === undefined || v.id === null ? '' : String(v.id);
27
+ const verdict = v.verdict === undefined || v.verdict === null ? '' : String(v.verdict);
28
+ const note = v.note !== undefined && v.note !== null && String(v.note).trim()
29
+ ? ` (${String(v.note).trim()})` : '';
30
+ return `${id}${id && verdict ? ': ' : ''}${verdict}${note}`;
31
+ }
32
+ return String(v);
33
+ }
34
+
35
+ /**
36
+ * A persisted form ask (ruling X3) as the two strings Ask Worca's get_run_progress
37
+ * reports (ruling X17): the text projection, capped at PROMPT_PROJECTION_MAX, and the
38
+ * answered values. null for a legacy round, whose questions/answers arrays carry
39
+ * everything as they always have. Unredacted — the tool applies its own `redact`,
40
+ * because free text from an agent is DATA, never instructions. A projection that
41
+ * cannot be built (a junk persisted row) degrades to the title, never throws.
42
+ * @param {object|null} ask
43
+ * @returns {{projection: string, values: string}|null}
44
+ */
45
+ export function askProgress(ask) {
46
+ if (!ask || typeof ask !== 'object' || ask.kind !== 'form') return null;
47
+ let projection = '';
48
+ try { projection = projectForm(ask, { maxChars: PROMPT_PROJECTION_MAX }); } catch { projection = String(ask.title || ask.form || ''); }
49
+ return { projection, values: askValuesText(ask) };
50
+ }
51
+
52
+ /**
53
+ * The answered values of a PERSISTED form ask (P2 E13: the resolved ask with `values`
54
+ * merged in), as `Label: value` lines in promptFields order. Keys promptFields does not
55
+ * know — a value left over from an older version of the form — are printed last by
56
+ * their field name rather than dropped. Always a string; '' when nothing was answered.
57
+ * @param {object|null} ask
58
+ * @returns {string}
59
+ */
60
+ export function askValuesText(ask) {
61
+ const values = ask && typeof ask === 'object' && ask.values && typeof ask.values === 'object'
62
+ ? ask.values : null;
63
+ if (!values) return '';
64
+ let fields = [];
65
+ try { fields = promptFields(ask); } catch { fields = []; }
66
+ const labels = new Map(fields.map((f) => [f.field, f.label || f.field]));
67
+ const ordered = fields.map((f) => f.field).filter((k) => Object.hasOwn(values, k));
68
+ const rest = Object.keys(values).filter((k) => !labels.has(k));
69
+ const keys = [...ordered, ...rest];
70
+ if (!keys.length) return '';
71
+ return keys.map((k) => `${labels.get(k) || k}: ${valueText(values[k])}`).join('\n');
72
+ }
@@ -0,0 +1,84 @@
1
+ // src/core/bridge/errors.mjs
2
+ // Upstream failures -> the Anthropic error envelope the CLI understands
3
+ // (model-bridge-design.md §5.7). The status AND the `error.type` both matter:
4
+ // the CLI's retry/backoff keys on the status, worca's classifyError keys on
5
+ // the message text (auth / rate_limit / ...), and the Test button's hints on
6
+ // both. Pure.
7
+
8
+ /** Hard payload ceiling before forwarding (Copilot's observed limit). */
9
+ export const PAYLOAD_CEILING_BYTES = 2_500_000;
10
+
11
+ /** The phrase Claude Code matches on to trigger its own compaction. */
12
+ export const PROMPT_TOO_LONG = 'prompt is too long';
13
+
14
+ const CONTEXT_RE = /context.?length|too long|maximum context|token limit|exceeds? .*tokens|too many tokens|request too large|payload too large/i;
15
+
16
+ /** Envelope for a status/type/message triple. */
17
+ export function anthropicError(status, type, message) {
18
+ return { status, body: { type: 'error', error: { type, message: String(message || type) } } };
19
+ }
20
+
21
+ /** Pull a human message out of an upstream error body (JSON or text). */
22
+ export function upstreamMessage(text) {
23
+ if (!text) return '';
24
+ try {
25
+ const j = JSON.parse(text);
26
+ const e = j && (j.error || j);
27
+ if (e && typeof e === 'object') return e.message || e.msg || e.code || JSON.stringify(e);
28
+ if (typeof e === 'string') return e;
29
+ } catch { /* not JSON */ }
30
+ return String(text).slice(0, 500);
31
+ }
32
+
33
+ /**
34
+ * Map an upstream HTTP failure to the envelope.
35
+ * @param {number} status upstream status
36
+ * @param {string} text upstream body text
37
+ * @param {{provider?:string, retryAfter?:string}} [meta]
38
+ */
39
+ export function mapUpstreamError(status, text, { provider = 'upstream', retryAfter } = {}) {
40
+ const msg = upstreamMessage(text);
41
+ const who = provider;
42
+ if (status === 401 || status === 403) {
43
+ return anthropicError(401, 'authentication_error', `${who}: authentication failed (${status})${msg ? ` — ${msg}` : ''}`);
44
+ }
45
+ if (status === 429) {
46
+ const e = anthropicError(429, 'rate_limit_error', `${who}: rate limited (429)${msg ? ` — ${msg}` : ''}`);
47
+ if (retryAfter) e.headers = { 'retry-after': String(retryAfter) };
48
+ return e;
49
+ }
50
+ if (status === 413 || (status === 400 && CONTEXT_RE.test(msg))) {
51
+ return anthropicError(400, 'invalid_request_error', PROMPT_TOO_LONG);
52
+ }
53
+ if (status === 404) {
54
+ return anthropicError(400, 'invalid_request_error', `${who}: model or endpoint not found (404)${msg ? ` — ${msg}` : ''}`);
55
+ }
56
+ if (status >= 400 && status < 500) {
57
+ return anthropicError(400, 'invalid_request_error', `${who}: request rejected (${status})${msg ? ` — ${msg}` : ''}`);
58
+ }
59
+ if (status === 503 || status === 529) {
60
+ return anthropicError(529, 'overloaded_error', `${who}: overloaded (${status})${msg ? ` — ${msg}` : ''}`);
61
+ }
62
+ return anthropicError(502, 'api_error', `${who}: upstream error (${status})${msg ? ` — ${msg}` : ''}`);
63
+ }
64
+
65
+ /** A network-level failure (fetch threw) -> envelope. */
66
+ export function mapNetworkError(err, { provider = 'upstream' } = {}) {
67
+ const name = err && err.name;
68
+ if (name === 'AbortError') return anthropicError(499, 'api_error', `${provider}: request aborted`);
69
+ if (name === 'TimeoutError') return anthropicError(504, 'api_error', `${provider}: upstream timed out`);
70
+ const detail = err && (err.cause && err.cause.message || err.message) || String(err);
71
+ return anthropicError(502, 'api_error', `${provider}: endpoint unreachable — ${detail}`);
72
+ }
73
+
74
+ /** The bridge's own refusals (bad bearer, unknown model, feature gaps). */
75
+ export const bridgeErrors = {
76
+ unauthorized: () => anthropicError(401, 'authentication_error', 'bridge: invalid bearer token'),
77
+ unknownModel: (id) => anthropicError(404, 'not_found_error', `bridge: no bridged model ${JSON.stringify(id)} in the catalog`),
78
+ notSignedIn: (provider, hint) => anthropicError(401, 'authentication_error', `provider ${provider}: not signed in — ${hint}`),
79
+ termsNotAcknowledged: (provider) => anthropicError(401, 'authentication_error', `provider ${provider}: terms not acknowledged — open Settings › Providers`),
80
+ tooLarge: () => anthropicError(400, 'invalid_request_error', PROMPT_TOO_LONG),
81
+ badJson: () => anthropicError(400, 'invalid_request_error', 'bridge: request body is not JSON'),
82
+ unsupported: (message) => anthropicError(400, 'invalid_request_error', message),
83
+ notFound: () => anthropicError(404, 'not_found_error', 'bridge: unknown route'),
84
+ };
@@ -0,0 +1,281 @@
1
+ // src/core/bridge/provider-ops.mjs
2
+ // The operations the Providers card and `worca models …` share
3
+ // (model-bridge-design.md §8.1, §8.4, §9): sign-in sessions for the Copilot
4
+ // device flow, sign-out, the provider state payload (never a token), the
5
+ // Copilot models list for import, the import itself, and a connection test
6
+ // for the key-based providers. Import contract: settings.mjs + the bridge's
7
+ // own modules; config.mjs imports NOTHING from here (the bridge is below it).
8
+
9
+ import {
10
+ providerConfig, allProviders, updateProvider, resolveProviderSecret, providerSecretSet,
11
+ copilotTermsAcknowledged, acknowledgeCopilotTerms, clearCopilotSignIn,
12
+ listGlobalModels, addGlobalModel, updateGlobalModel,
13
+ } from '../settings.mjs';
14
+ import { modelEnvRef, maskModelEnvValue, COPILOT_TERMS_VERSION, UPSTREAM_PROVIDERS, isUpstreamBaseUrl } from '../model-env.mjs';
15
+ import {
16
+ startDeviceFlow, pollDeviceFlow, githubLogin, copilotToken, invalidateCopilotToken,
17
+ listCopilotModels, copilotUsage, catalogEntryForCopilotModel,
18
+ } from './providers/copilot.mjs';
19
+ import { keyOptional } from './registry.mjs';
20
+ import { listEndpointModels, catalogEntryForEndpointModel, importableModel } from './providers/endpoint.mjs';
21
+
22
+ // ── Copilot sign-in sessions (in memory; a device code lives ~15 min) ────────
23
+ const sessions = new Map(); // deviceCode -> { startedAt, expiresAt, interval, lastPoll }
24
+ const SESSION_TTL_MS = 20 * 60 * 1000;
25
+
26
+ function sweepSessions(now = Date.now()) {
27
+ for (const [k, s] of sessions) if (s.expiresAt < now) sessions.delete(k);
28
+ }
29
+
30
+ /**
31
+ * Begin a Copilot sign-in. Refuses until the terms are acknowledged (§8.2).
32
+ * @returns {Promise<{deviceCode, userCode, verificationUri, interval, expiresIn}>}
33
+ */
34
+ export async function beginCopilotLogin({ fetch: f } = {}) {
35
+ if (!copilotTermsAcknowledged()) {
36
+ throw Object.assign(new Error('acknowledge the GitHub Copilot notice before signing in'), { code: 'TERMS' });
37
+ }
38
+ sweepSessions();
39
+ const flow = await startDeviceFlow({ fetch: f });
40
+ sessions.set(flow.deviceCode, { startedAt: Date.now(), expiresAt: Date.now() + Math.min(flow.expiresIn * 1000, SESSION_TTL_MS), interval: flow.interval });
41
+ return flow;
42
+ }
43
+
44
+ /**
45
+ * One poll of a sign-in session. On approval the GitHub token and login are
46
+ * stored and the session ends.
47
+ * @returns {Promise<{pending:true, interval?:number}|{ok:true, login:string}|{error:string}>}
48
+ */
49
+ export async function pollCopilotLogin(deviceCode, { fetch: f } = {}) {
50
+ sweepSessions();
51
+ const s = sessions.get(deviceCode);
52
+ if (!s) return { error: 'unknown or expired sign-in session — start again' };
53
+ const r = await pollDeviceFlow(deviceCode, { fetch: f });
54
+ if (r.pending) { if (r.interval) s.interval = r.interval; return { pending: true, interval: s.interval }; }
55
+ sessions.delete(deviceCode);
56
+ if (r.error) return { error: r.error };
57
+ let login = '';
58
+ try { login = await githubLogin(r.token, { fetch: f }); } catch { login = ''; }
59
+ // Prove the account has Copilot before storing: a token without Copilot
60
+ // access would only fail later, mid-run.
61
+ try {
62
+ await copilotToken(r.token, { fetch: f, force: true });
63
+ } catch (err) {
64
+ return { error: err && err.code === 'EXCHANGE' ? `signed in as ${login || 'GitHub user'}, but the account has no Copilot access — ${err.message}` : (err.message || String(err)) };
65
+ }
66
+ await updateProvider('copilot', { githubToken: r.token, ...(login ? { login } : {}) });
67
+ return { ok: true, login };
68
+ }
69
+
70
+ /** Sign out of Copilot: forget the token, drop the cached exchange. */
71
+ export async function copilotLogout() {
72
+ const cfg = providerConfig('copilot');
73
+ const tok = resolveProviderSecret(cfg.githubToken);
74
+ if (tok) invalidateCopilotToken(tok);
75
+ await clearCopilotSignIn();
76
+ }
77
+
78
+ /** Record the terms acknowledgement (§8.2). */
79
+ export async function acknowledgeTerms() { return acknowledgeCopilotTerms(); }
80
+
81
+ // ── state payloads (never a secret) ──────────────────────────────────────────
82
+
83
+ const secretSource = (v) => (!v ? null : modelEnvRef(v) ? 'env' : 'stored');
84
+
85
+ /**
86
+ * The Providers card payload (§9). `quota` is fetched only when asked — it is
87
+ * a network call to GitHub — and is null when the account exposes none.
88
+ * @param {{quota?:boolean, fetch?:typeof fetch}} [opts]
89
+ */
90
+ export async function providersState({ quota = false, fetch: f } = {}) {
91
+ const all = allProviders();
92
+ const c = all.copilot;
93
+ const token = resolveProviderSecret(c.githubToken);
94
+ const copilot = {
95
+ connected: !!token,
96
+ login: c.login || null,
97
+ accountType: c.accountType,
98
+ acknowledgedTerms: c.acknowledgedTerms || null,
99
+ termsCurrent: copilotTermsAcknowledged(),
100
+ termsVersion: COPILOT_TERMS_VERSION,
101
+ maxConcurrent: c.maxConcurrent,
102
+ tokenSource: secretSource(c.githubToken),
103
+ tokenRef: modelEnvRef(c.githubToken) ? c.githubToken : null,
104
+ quota: null,
105
+ };
106
+ if (quota && token) {
107
+ try { copilot.quota = await copilotUsage(token, { fetch: f }); } catch { copilot.quota = null; }
108
+ }
109
+ const keyed = (name) => {
110
+ const p = all[name];
111
+ return {
112
+ configured: !!resolveProviderSecret(p.apiKey),
113
+ keyOptional: keyOptional(name, p.baseUrl),
114
+ keySet: providerSecretSet(name),
115
+ keySource: secretSource(p.apiKey),
116
+ keyRef: modelEnvRef(p.apiKey) ? p.apiKey : null,
117
+ keyMasked: p.apiKey && !modelEnvRef(p.apiKey) ? maskModelEnvValue(p.apiKey) : null,
118
+ baseUrl: p.baseUrl,
119
+ maxConcurrent: p.maxConcurrent,
120
+ };
121
+ };
122
+ return { copilot, openai: keyed('openai'), anthropic: keyed('anthropic') };
123
+ }
124
+
125
+ /** Patch a provider from the UI/CLI; masked key echoes are dropped ("keep"). */
126
+ export async function patchProvider(name, patch = {}) {
127
+ if (!UPSTREAM_PROVIDERS.includes(name)) throw new Error(`unknown provider ${JSON.stringify(name)}`);
128
+ const p = { ...patch };
129
+ for (const k of ['apiKey', 'githubToken']) if (typeof p[k] === 'string' && p[k].startsWith('••')) delete p[k];
130
+ return updateProvider(name, p);
131
+ }
132
+
133
+ // ── Copilot models: list for import, import ─────────────────────────────────
134
+
135
+ /** Copilot's models for the import sheet, each with `inCatalog` (§8.4). */
136
+ export async function copilotModelsForImport({ fetch: f } = {}) {
137
+ const c = providerConfig('copilot');
138
+ const token = resolveProviderSecret(c.githubToken);
139
+ if (!token) throw Object.assign(new Error('not signed in to GitHub Copilot'), { code: 'NOT_SIGNED_IN' });
140
+ const list = await listCopilotModels(token, { accountType: c.accountType, fetch: f });
141
+ const have = new Set(listGlobalModels().map((m) => m.id.toLowerCase()));
142
+ return list.map((m) => ({ ...m, catalogId: `copilot-${m.id}`, inCatalog: have.has(`copilot-${m.id}`.toLowerCase()) }))
143
+ .sort((a, b) => (Number(b.pickerEnabled) - Number(a.pickerEnabled)) || a.name.localeCompare(b.name));
144
+ }
145
+
146
+ /**
147
+ * Import Copilot models into the catalog (§8.4). A new id gets the full
148
+ * import shape; an existing `copilot-*` id only has its capabilities (and api,
149
+ * should the vendor route change) refreshed — a user-edited label, efforts or
150
+ * pricing is never overwritten.
151
+ * @param {string[]} ids Copilot model ids (not catalog ids)
152
+ * @returns {Promise<{created:string[], updated:string[], skipped:string[]}>}
153
+ */
154
+ export async function importCopilotModels(ids, { fetch: f } = {}) {
155
+ const wanted = new Set((Array.isArray(ids) ? ids : []).map((s) => String(s)));
156
+ if (!wanted.size) throw new Error('pick at least one model to import');
157
+ const list = await copilotModelsForImport({ fetch: f });
158
+ const byId = new Map(list.map((m) => [m.id, m]));
159
+ const created = []; const updated = []; const skipped = [];
160
+ for (const id of wanted) {
161
+ const m = byId.get(id);
162
+ if (!m) { skipped.push(id); continue; }
163
+ const entry = catalogEntryForCopilotModel(m);
164
+ if (m.inCatalog) {
165
+ const current = listGlobalModels().find((x) => x.id.toLowerCase() === entry.id.toLowerCase());
166
+ if (!current || !current.upstream || current.upstream.provider !== 'copilot') { skipped.push(id); continue; }
167
+ await updateGlobalModel(current.id, { upstream: { ...current.upstream, api: entry.upstream.api, model: entry.upstream.model, capabilities: entry.upstream.capabilities } });
168
+ updated.push(current.id);
169
+ } else {
170
+ await addGlobalModel(entry);
171
+ created.push(entry.id);
172
+ }
173
+ }
174
+ return { created, updated, skipped };
175
+ }
176
+
177
+ // ── OpenAI-compatible endpoints: discovery, import ──────────────────────────
178
+
179
+ /** The provider's own base URL when the caller names none, trailing slash trimmed. */
180
+ function endpointBase(baseUrl) {
181
+ const b = String(baseUrl || '').trim() || providerConfig('openai').baseUrl || '';
182
+ if (!isUpstreamBaseUrl(b)) throw new Error('baseUrl must be an http(s) URL with no query or fragment');
183
+ return b.replace(/\/+$/, '');
184
+ }
185
+
186
+ /**
187
+ * What an OpenAI-compatible endpoint serves, each row with `catalogId` and `inCatalog` (§8.4's
188
+ * Copilot import, for a server you run: llama.cpp, Ollama, LM Studio, vLLM, a gateway).
189
+ * @param {{baseUrl?:string, fetch?:typeof fetch}} [opts]
190
+ */
191
+ export async function endpointModelsForImport({ baseUrl, fetch: f } = {}) {
192
+ const base = endpointBase(baseUrl);
193
+ const p = providerConfig('openai');
194
+ const key = resolveProviderSecret(p.apiKey);
195
+ const out = await listEndpointModels(base, { apiKey: key, fetch: f });
196
+ const have = new Set(listGlobalModels().map((m) => m.id.toLowerCase()));
197
+ return {
198
+ ...out,
199
+ models: out.models.map((m) => {
200
+ const entry = catalogEntryForEndpointModel(m, { server: out.server, baseUrl: out.baseUrl, providerBaseUrl: p.baseUrl });
201
+ const usable = importableModel(m);
202
+ return { ...m, catalogId: entry.id, inCatalog: have.has(entry.id.toLowerCase()), importable: usable.ok, ...(usable.ok ? {} : { blocked: usable.why }) };
203
+ }),
204
+ };
205
+ }
206
+
207
+ /**
208
+ * Import endpoint models into the catalog. A new id gets the full entry; an existing one keeps the
209
+ * label, efforts and pricing you edited and only has its upstream refreshed — the Copilot rule.
210
+ * @param {string[]} ids model ids as the endpoint reports them (not catalog ids)
211
+ * @returns {Promise<{created:string[], updated:string[], skipped:Array<{id:string, why:string}>, server:string, baseUrl:string}>}
212
+ */
213
+ export async function importEndpointModels(ids, { baseUrl, fetch: f } = {}) {
214
+ const wanted = new Set((Array.isArray(ids) ? ids : []).map((s) => String(s)));
215
+ if (!wanted.size) throw new Error('pick at least one model to import');
216
+ const out = await endpointModelsForImport({ baseUrl, fetch: f });
217
+ const byId = new Map(out.models.map((m) => [m.id, m]));
218
+ const p = providerConfig('openai');
219
+ const created = []; const updated = []; const skipped = [];
220
+ for (const id of wanted) {
221
+ const m = byId.get(id);
222
+ if (!m) { skipped.push({ id, why: 'the endpoint does not serve it' }); continue; }
223
+ if (!m.importable) { skipped.push({ id, why: m.blocked || 'not usable in a pipeline' }); continue; }
224
+ const entry = catalogEntryForEndpointModel(m, { server: out.server, baseUrl: out.baseUrl, providerBaseUrl: p.baseUrl });
225
+ const current = listGlobalModels().find((x) => x.id.toLowerCase() === entry.id.toLowerCase());
226
+ if (current) {
227
+ if (!current.upstream || current.upstream.provider !== 'openai') { skipped.push({ id, why: `"${current.id}" already exists and is not an OpenAI-compatible entry` }); continue; }
228
+ await updateGlobalModel(current.id, { upstream: { ...current.upstream, model: entry.upstream.model, ...(entry.upstream.baseUrl ? { baseUrl: entry.upstream.baseUrl } : {}), ...(entry.upstream.capabilities ? { capabilities: entry.upstream.capabilities } : {}) } });
229
+ updated.push(current.id);
230
+ } else {
231
+ await addGlobalModel(entry);
232
+ created.push(entry.id);
233
+ }
234
+ }
235
+ return { created, updated, skipped, server: out.server, serverLabel: out.serverLabel, baseUrl: out.baseUrl, warnings: out.warnings };
236
+ }
237
+
238
+ // ── key-based providers: connection test ────────────────────────────────────
239
+
240
+ /**
241
+ * A cheap reachability + auth check for openai / anthropic (§8.1): GET the models list with the
242
+ * configured key. Never throws.
243
+ *
244
+ * `baseUrl` / `apiKey` test values that are NOT stored yet — what the user has typed into the
245
+ * Providers card. Testing the stored ones instead made the button lie: type a local llama.cpp URL,
246
+ * press Test, and the answer was "no API key configured", because it had tested api.openai.com.
247
+ * A masked echo (••…) means "keep what is stored" exactly as a save does.
248
+ * @returns {Promise<{ok:true, models?:number}|{ok:false, message:string}>}
249
+ */
250
+ export async function testProviderConnection(name, { fetch: f = globalThis.fetch, baseUrl = '', apiKey } = {}) {
251
+ if (name === 'copilot') {
252
+ const c = providerConfig('copilot');
253
+ const token = resolveProviderSecret(c.githubToken);
254
+ if (!token) return { ok: false, message: 'not signed in' };
255
+ try { await copilotToken(token, { fetch: f, force: true }); return { ok: true }; } catch (err) { return { ok: false, message: err.message || String(err) }; }
256
+ }
257
+ if (!UPSTREAM_PROVIDERS.includes(name)) return { ok: false, message: `unknown provider ${name}` };
258
+ const stored = providerConfig(name);
259
+ const typedKey = typeof apiKey === 'string' && !apiKey.startsWith('••') ? apiKey.trim() : null;
260
+ const p = {
261
+ ...stored,
262
+ ...(baseUrl && isUpstreamBaseUrl(baseUrl) ? { baseUrl: baseUrl.trim().replace(/\/+$/, '') } : {}),
263
+ ...(typedKey === null ? {} : { apiKey: typedKey }),
264
+ };
265
+ const key = resolveProviderSecret(p.apiKey);
266
+ const keyIsSet = typedKey === null ? providerSecretSet(name) : !!typedKey;
267
+ if (!key && (keyIsSet || !keyOptional(name, p.baseUrl))) return { ok: false, message: keyIsSet ? 'the key\'s ${VAR} is not set in worca\'s environment' : `no API key configured for ${p.baseUrl}` };
268
+ const base = (p.baseUrl || '').replace(/\/+$/, '');
269
+ const url = name === 'anthropic' ? (/\/v1$/.test(base) ? `${base}/models` : `${base}/v1/models`) : `${base}/models`;
270
+ const headers = name === 'anthropic' ? { 'x-api-key': key, 'anthropic-version': '2023-06-01' } : (key ? { authorization: `Bearer ${key}` } : {});
271
+ try {
272
+ const r = await f(url, { headers, signal: AbortSignal.timeout(15_000) });
273
+ if (r.status === 401 || r.status === 403) return { ok: false, message: `authentication failed (${r.status})` };
274
+ if (!r.ok) return { ok: false, message: `endpoint answered ${r.status}` };
275
+ const j = await r.json().catch(() => null);
276
+ const n = j && Array.isArray(j.data) ? j.data.length : undefined;
277
+ return { ok: true, ...(n !== undefined ? { models: n } : {}) };
278
+ } catch (err) {
279
+ return { ok: false, message: `endpoint unreachable — ${err && err.message ? err.message : String(err)}` };
280
+ }
281
+ }