@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.
- package/README.md +85 -6
- package/agents/clarify.meta.json +1 -0
- package/agents/memoryDefragmenter.meta.json +2 -1
- package/agents/reviewer.meta.json +60 -0
- package/agents/worca-cc-code-reviewer.md +33 -0
- package/agents/worca-cc-memory-defragmenter.md +5 -3
- package/agents/workspaceScanner.meta.json +1 -0
- package/package.json +14 -10
- package/scripts/git-diff.mjs +25 -0
- package/scripts/gitDiff.meta.json +18 -0
- package/scripts/js-inline.mjs +11 -0
- package/scripts/js.meta.json +22 -0
- package/scripts/py-inline.py +27 -0
- package/scripts/py.meta.json +22 -0
- package/scripts/shell.meta.json +24 -0
- package/skills/worca/SKILL.md +3 -2
- package/src/cli/models.mjs +247 -0
- package/src/cli/render.mjs +72 -4
- package/src/cli/schedule.mjs +494 -0
- package/src/cli/worca-cc.mjs +1001 -22
- package/src/core/agent-registry.mjs +75 -23
- package/src/core/agent-store.mjs +51 -2
- package/src/core/artifacts.mjs +73 -10
- package/src/core/ask/events.mjs +119 -1
- package/src/core/ask/limits.mjs +32 -4
- package/src/core/ask/mcp-stdio.mjs +12 -0
- package/src/core/ask/model-deps.mjs +126 -0
- package/src/core/ask/model-proposal.mjs +370 -0
- package/src/core/ask/models.mjs +12 -0
- package/src/core/ask/policy-deps.mjs +124 -0
- package/src/core/ask/policy-proposal.mjs +363 -0
- package/src/core/ask/prompt.mjs +74 -9
- package/src/core/ask/proposal.mjs +54 -5
- package/src/core/ask/schedule-deps.mjs +83 -0
- package/src/core/ask/schedule-spec.mjs +310 -0
- package/src/core/ask/script-deps.mjs +357 -0
- package/src/core/ask/source-deps.mjs +52 -0
- package/src/core/ask/source-spec.mjs +157 -0
- package/src/core/ask/spawn.mjs +1 -0
- package/src/core/ask/store.mjs +6 -3
- package/src/core/ask/tool-deps.mjs +4 -0
- package/src/core/ask/tools.mjs +657 -37
- package/src/core/ask/turn.mjs +109 -2
- package/src/core/ask-files.mjs +406 -0
- package/src/core/ask-forms.mjs +195 -0
- package/src/core/ask-projection.mjs +72 -0
- package/src/core/bridge/errors.mjs +84 -0
- package/src/core/bridge/provider-ops.mjs +281 -0
- package/src/core/bridge/providers/copilot.mjs +269 -0
- package/src/core/bridge/providers/endpoint.mjs +257 -0
- package/src/core/bridge/registry.mjs +88 -0
- package/src/core/bridge/semaphore.mjs +73 -0
- package/src/core/bridge/server.mjs +184 -0
- package/src/core/bridge/telemetry.mjs +53 -0
- package/src/core/bridge/translate/request.mjs +252 -0
- package/src/core/bridge/translate/response.mjs +82 -0
- package/src/core/bridge/translate/stream.mjs +242 -0
- package/src/core/bridge/upstream.mjs +209 -0
- package/src/core/chat/command-router.mjs +58 -4
- package/src/core/chat/notifier.mjs +14 -1
- package/src/core/chat/renderers.mjs +35 -0
- package/src/core/claude-runner.mjs +126 -19
- package/src/core/config.mjs +212 -31
- package/src/core/cost-budget.mjs +3 -2
- package/src/core/db.mjs +169 -15
- package/src/core/failure-policy.mjs +10 -0
- package/src/core/fs-browse.mjs +16 -4
- package/src/core/git-info.mjs +22 -0
- package/src/core/graph/builtin-workflows.mjs +3 -1
- package/src/core/graph/exec-io.mjs +71 -0
- package/src/core/graph/executor.mjs +139 -70
- package/src/core/graph/human-evidence.mjs +131 -0
- package/src/core/graph/python-probe.mjs +172 -0
- package/src/core/graph/registry-ports.mjs +10 -6
- package/src/core/graph/scheduler.mjs +39 -24
- package/src/core/graph/script-child.mjs +81 -0
- package/src/core/graph/script-runner.mjs +597 -0
- package/src/core/graph/worca_script.py +207 -0
- package/src/core/guardrail-store.mjs +16 -0
- package/src/core/human-backfill.mjs +108 -0
- package/src/core/human-rate.mjs +17 -0
- package/src/core/index-html.mjs +6 -2
- package/src/core/memory-defrag-model.mjs +112 -0
- package/src/core/memory-store.mjs +70 -18
- package/src/core/memory-sync.mjs +22 -11
- package/src/core/metrics/read.mjs +4 -1
- package/src/core/metrics/record.mjs +50 -2
- package/src/core/metrics/sync.mjs +6 -4
- package/src/core/model-env.mjs +149 -0
- package/src/core/model-test.mjs +14 -1
- package/src/core/notifications.mjs +128 -0
- package/src/core/onboarding.mjs +8 -2
- package/src/core/orchestrator.mjs +357 -26
- package/src/core/phases.mjs +95 -6
- package/src/core/plugin-api.mjs +24 -7
- package/src/core/plugin-manifest.mjs +184 -18
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-script-cases.mjs +118 -0
- package/src/core/plugin-store.mjs +163 -17
- package/src/core/plugin-workflows.mjs +71 -17
- package/src/core/policy/cache.mjs +116 -0
- package/src/core/policy/effective.mjs +175 -0
- package/src/core/policy/gate.mjs +91 -0
- package/src/core/policy/local.mjs +145 -0
- package/src/core/policy/registry.mjs +330 -0
- package/src/core/policy/scope.mjs +61 -0
- package/src/core/policy/state.mjs +79 -0
- package/src/core/policy/sync.mjs +513 -0
- package/src/core/protocol.mjs +43 -0
- package/src/core/run-harness.mjs +421 -72
- package/src/core/scheduler.mjs +980 -0
- package/src/core/script-bench.mjs +628 -0
- package/src/core/script-registry.mjs +116 -0
- package/src/core/script-store.mjs +563 -0
- package/src/core/settings.mjs +489 -14
- package/src/core/stats.mjs +33 -2
- package/src/core/workflow-export.mjs +94 -3
- package/src/core/workflow-share.mjs +67 -18
- package/src/core/workflows.mjs +47 -11
- package/src/core/workspaces.mjs +18 -12
- package/src/shared/forms/answer.mjs +164 -0
- package/src/shared/forms/catalog.mjs +91 -0
- package/src/shared/forms/form-def.mjs +290 -0
- package/src/shared/forms/layout.mjs +67 -0
- package/src/shared/forms/paths.mjs +47 -0
- package/src/shared/forms/project.mjs +309 -0
- package/src/shared/forms/schema.mjs +205 -0
- package/src/shared/graph/agent-meta.mjs +55 -5
- package/src/shared/graph/constants.mjs +14 -2
- package/src/shared/graph/flow-layout.mjs +2 -1
- package/src/shared/graph/isomorphic.mjs +5 -3
- package/src/shared/graph/manifest.mjs +22 -13
- package/src/shared/graph/ports.mjs +45 -19
- package/src/shared/graph/script-cases.mjs +257 -0
- package/src/shared/graph/script-icons.mjs +46 -0
- package/src/shared/graph/script-infer.mjs +259 -0
- package/src/shared/graph/script-meta.mjs +408 -0
- package/src/shared/graph/script-templates.mjs +201 -0
- package/src/shared/graph/template.mjs +4 -4
- package/src/shared/graph/validate.mjs +89 -16
- package/src/shared/human-estimate.mjs +100 -0
- package/src/shared/schedule/recurrence.mjs +353 -0
- package/src/shared/team-metrics/aggregate.mjs +51 -11
- package/{scripts → tools}/install.mjs +3 -3
- package/ui/public/app.js +4390 -683
- package/ui/public/artifact-picker.mjs +189 -0
- package/ui/public/ask/dom.mjs +121 -0
- package/ui/public/ask/form-preview.mjs +55 -0
- package/ui/public/ask/form-renderer.mjs +250 -0
- package/ui/public/ask/registry.mjs +53 -0
- package/ui/public/ask/widgets-display.mjs +370 -0
- package/ui/public/ask/widgets-input.mjs +624 -0
- package/ui/public/ask/widgets-layout.mjs +90 -0
- package/ui/public/ask-panel.mjs +401 -27
- package/ui/public/ask-run-card.mjs +1 -1
- package/ui/public/bridge-view.mjs +694 -0
- package/ui/public/chat-settings-view.mjs +24 -0
- package/ui/public/code-editor.mjs +181 -0
- package/ui/public/getting-started.mjs +34 -7
- package/ui/public/graph/composer.mjs +138 -10
- package/ui/public/graph/inspector.mjs +61 -58
- package/ui/public/graph/palette.mjs +27 -9
- package/ui/public/graph/run-decor.mjs +34 -17
- package/ui/public/graph/run-hosts.mjs +7 -1
- package/ui/public/graph/save-dialog.mjs +3 -0
- package/ui/public/graph/view.mjs +23 -7
- package/ui/public/guardrails-view.mjs +15 -3
- package/ui/public/guide-spot.mjs +87 -9
- package/ui/public/index.html +480 -141
- package/ui/public/memory-view.mjs +22 -4
- package/ui/public/models-view.mjs +162 -17
- package/ui/public/node-tunables.mjs +33 -4
- package/ui/public/plugins-view.mjs +23 -1
- package/ui/public/results-view.mjs +4 -2
- package/ui/public/schedule-sheet.mjs +430 -0
- package/ui/public/schedules-view.mjs +432 -0
- package/ui/public/script-bench-view.mjs +1154 -0
- package/ui/public/script-forms.mjs +282 -0
- package/ui/public/script-wizard.mjs +529 -0
- package/ui/public/scripts-view.mjs +868 -0
- package/ui/public/stats-view.mjs +159 -52
- package/ui/public/style.css +1461 -44
- package/ui/public/team-metrics-surfaces.mjs +77 -16
- package/ui/public/team-metrics-view.mjs +68 -5
- package/ui/public/team-policy-view.mjs +1402 -0
- package/ui/public/ui-level.mjs +237 -0
- package/ui/server.mjs +1827 -73
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// src/core/bridge/translate/stream.mjs
|
|
2
|
+
// OpenAI chat/completions stream chunks -> Anthropic Messages SSE events
|
|
3
|
+
// (model-bridge-design.md §5.4/§5.5/§5.8). Pure state machine: feed parsed
|
|
4
|
+
// chunk objects to `push`, call `finish` at end of stream; both return the
|
|
5
|
+
// events to write, as `{event, data}` pairs. `serializeSse` renders them.
|
|
6
|
+
//
|
|
7
|
+
// Text streams live, one content block per contiguous run. Tool calls are
|
|
8
|
+
// BUFFERED per tool_calls index and emitted as a complete block when the next
|
|
9
|
+
// tool call (or text) begins, and at finish: Anthropic blocks are strictly
|
|
10
|
+
// sequential and cannot be reopened, while upstreams may (rarely) interleave
|
|
11
|
+
// argument deltas across indexes — buffering makes that case correct at the
|
|
12
|
+
// cost of a tool block appearing a moment later than its first delta.
|
|
13
|
+
|
|
14
|
+
import { randomBytes } from 'node:crypto';
|
|
15
|
+
|
|
16
|
+
/** finish_reason -> stop_reason (§5.5). */
|
|
17
|
+
export function mapStopReason(finish, { emitted = false } = {}) {
|
|
18
|
+
switch (finish) {
|
|
19
|
+
case 'stop': return 'end_turn';
|
|
20
|
+
case 'length': return 'max_tokens';
|
|
21
|
+
case 'tool_calls':
|
|
22
|
+
case 'function_call': return 'tool_use';
|
|
23
|
+
case 'content_filter': return 'end_turn';
|
|
24
|
+
default: return emitted ? 'end_turn' : null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** chat/completions usage -> Anthropic usage (§5.8). */
|
|
29
|
+
export function mapUsage(usage) {
|
|
30
|
+
const u = usage && typeof usage === 'object' ? usage : {};
|
|
31
|
+
const prompt = Number(u.prompt_tokens) || 0;
|
|
32
|
+
const cached = Number(u.prompt_tokens_details && u.prompt_tokens_details.cached_tokens) || 0;
|
|
33
|
+
return {
|
|
34
|
+
input_tokens: Math.max(0, prompt - cached),
|
|
35
|
+
output_tokens: Number(u.completion_tokens) || 0,
|
|
36
|
+
cache_read_input_tokens: cached,
|
|
37
|
+
cache_creation_input_tokens: 0,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Whether a tool call's accumulated arguments are complete JSON ('' = no args). */
|
|
42
|
+
export function parsesAsJson(s) {
|
|
43
|
+
if (typeof s !== 'string' || !s.trim()) return true;
|
|
44
|
+
try { JSON.parse(s); return true; } catch { return false; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The text that replaces a tool call cut off by finish_reason `length`. */
|
|
48
|
+
export function truncatedToolNote(name) {
|
|
49
|
+
return `[bridge: the ${name} call was cut off by the model's output or context limit before its arguments were complete, so it was not run. Split the work into smaller steps — e.g. write a large file in several shorter parts.]`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function newMessageId() { return `msg_bridge_${randomBytes(12).toString('hex')}`; }
|
|
53
|
+
export function newToolUseId() { return `toolu_bridge_${randomBytes(12).toString('hex')}`; }
|
|
54
|
+
|
|
55
|
+
export class ChatStreamTranslator {
|
|
56
|
+
/** @param {{model:string}} opts the model name the CLI expects back (catalog id) */
|
|
57
|
+
constructor({ model } = {}) {
|
|
58
|
+
this.model = model || 'bridge';
|
|
59
|
+
this.id = newMessageId();
|
|
60
|
+
this.started = false;
|
|
61
|
+
this.nextIndex = 0;
|
|
62
|
+
this.textIndex = null; // open text block index, or null
|
|
63
|
+
this.tools = new Map(); // tool_calls index -> {id, name, args, order}
|
|
64
|
+
this.toolOrder = [];
|
|
65
|
+
this.flushedTools = 0; // how many of toolOrder have been emitted
|
|
66
|
+
this.finishReason = null;
|
|
67
|
+
this.usage = null;
|
|
68
|
+
this.emittedAny = false;
|
|
69
|
+
this.contentFilter = false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
_start() {
|
|
73
|
+
if (this.started) return [];
|
|
74
|
+
this.started = true;
|
|
75
|
+
return [{
|
|
76
|
+
event: 'message_start',
|
|
77
|
+
data: {
|
|
78
|
+
type: 'message_start',
|
|
79
|
+
message: {
|
|
80
|
+
id: this.id, type: 'message', role: 'assistant', model: this.model, content: [],
|
|
81
|
+
stop_reason: null, stop_sequence: null,
|
|
82
|
+
usage: { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
}];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
_closeText() {
|
|
89
|
+
if (this.textIndex === null) return [];
|
|
90
|
+
const i = this.textIndex;
|
|
91
|
+
this.textIndex = null;
|
|
92
|
+
return [{ event: 'content_block_stop', data: { type: 'content_block_stop', index: i } }];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Emit every buffered tool call not yet emitted (in first-seen order). */
|
|
96
|
+
_flushTools(upTo = this.toolOrder.length) {
|
|
97
|
+
const out = [];
|
|
98
|
+
while (this.flushedTools < upTo) {
|
|
99
|
+
const key = this.toolOrder[this.flushedTools++];
|
|
100
|
+
const t = this.tools.get(key);
|
|
101
|
+
const index = this.nextIndex++;
|
|
102
|
+
out.push({ event: 'content_block_start', data: { type: 'content_block_start', index, content_block: { type: 'tool_use', id: t.id, name: t.name, input: {} } } });
|
|
103
|
+
out.push({ event: 'content_block_delta', data: { type: 'content_block_delta', index, delta: { type: 'input_json_delta', partial_json: t.args || '{}' } } });
|
|
104
|
+
out.push({ event: 'content_block_stop', data: { type: 'content_block_stop', index } });
|
|
105
|
+
this.emittedAny = true;
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Feed one parsed chunk object. Returns the SSE events to write.
|
|
112
|
+
* @param {object} chunk
|
|
113
|
+
*/
|
|
114
|
+
push(chunk) {
|
|
115
|
+
const out = this._start();
|
|
116
|
+
if (!chunk || typeof chunk !== 'object') return out;
|
|
117
|
+
if (chunk.error) {
|
|
118
|
+
const msg = typeof chunk.error === 'string' ? chunk.error : (chunk.error.message || JSON.stringify(chunk.error));
|
|
119
|
+
out.push({ event: 'error', data: { type: 'error', error: { type: 'api_error', message: `upstream stream error: ${msg}` } } });
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
if (chunk.usage && typeof chunk.usage === 'object') this.usage = chunk.usage;
|
|
123
|
+
const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : null;
|
|
124
|
+
if (!choice) return out;
|
|
125
|
+
const delta = choice.delta || {};
|
|
126
|
+
|
|
127
|
+
if (typeof delta.content === 'string' && delta.content.length) {
|
|
128
|
+
// Text after tool calls: the tools are complete — flush them first.
|
|
129
|
+
if (this.toolOrder.length > this.flushedTools) { out.push(...this._closeText(), ...this._flushTools()); }
|
|
130
|
+
if (this.textIndex === null) {
|
|
131
|
+
this.textIndex = this.nextIndex++;
|
|
132
|
+
out.push({ event: 'content_block_start', data: { type: 'content_block_start', index: this.textIndex, content_block: { type: 'text', text: '' } } });
|
|
133
|
+
}
|
|
134
|
+
out.push({ event: 'content_block_delta', data: { type: 'content_block_delta', index: this.textIndex, delta: { type: 'text_delta', text: delta.content } } });
|
|
135
|
+
this.emittedAny = true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (Array.isArray(delta.tool_calls)) {
|
|
139
|
+
for (const tc of delta.tool_calls) {
|
|
140
|
+
if (!tc || typeof tc !== 'object') continue;
|
|
141
|
+
const key = Number.isInteger(tc.index) ? tc.index : (tc.id || this.toolOrder.length);
|
|
142
|
+
let t = this.tools.get(key);
|
|
143
|
+
if (!t) {
|
|
144
|
+
// A new tool call: text before it is complete; earlier tool calls are complete.
|
|
145
|
+
out.push(...this._closeText());
|
|
146
|
+
out.push(...this._flushTools());
|
|
147
|
+
t = { id: tc.id || newToolUseId(), name: '', args: '' };
|
|
148
|
+
this.tools.set(key, t);
|
|
149
|
+
this.toolOrder.push(key);
|
|
150
|
+
} else if (tc.id && !t.id) t.id = tc.id;
|
|
151
|
+
const fn = tc.function || {};
|
|
152
|
+
if (typeof fn.name === 'string' && fn.name) t.name = t.name ? t.name : fn.name;
|
|
153
|
+
if (typeof fn.arguments === 'string') t.args += fn.arguments;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (choice.finish_reason) {
|
|
158
|
+
this.finishReason = choice.finish_reason;
|
|
159
|
+
if (choice.finish_reason === 'content_filter') this.contentFilter = true;
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** End of stream: close blocks, emit message_delta + message_stop. */
|
|
165
|
+
finish() {
|
|
166
|
+
const out = this._start();
|
|
167
|
+
// Cut off mid tool call (finish_reason `length` — the output cap, or on a
|
|
168
|
+
// small local model the context window filling up): the last call's
|
|
169
|
+
// arguments are unterminated JSON. Forwarded, the CLI rejects it as an
|
|
170
|
+
// InputValidationError and the model retries the same oversized call; so
|
|
171
|
+
// drop it and say why, and the CLI's max_tokens recovery asks for smaller
|
|
172
|
+
// steps. Earlier calls were complete when the next one began.
|
|
173
|
+
let truncated = null;
|
|
174
|
+
if (this.finishReason === 'length' && this.toolOrder.length > this.flushedTools) {
|
|
175
|
+
const key = this.toolOrder[this.toolOrder.length - 1];
|
|
176
|
+
const t = this.tools.get(key);
|
|
177
|
+
if (!parsesAsJson(t.args)) {
|
|
178
|
+
this.toolOrder.pop();
|
|
179
|
+
this.tools.delete(key);
|
|
180
|
+
truncated = t.name || 'tool';
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
out.push(...this._flushTools());
|
|
184
|
+
if (truncated) {
|
|
185
|
+
if (this.textIndex === null) {
|
|
186
|
+
this.textIndex = this.nextIndex++;
|
|
187
|
+
out.push({ event: 'content_block_start', data: { type: 'content_block_start', index: this.textIndex, content_block: { type: 'text', text: '' } } });
|
|
188
|
+
}
|
|
189
|
+
out.push({ event: 'content_block_delta', data: { type: 'content_block_delta', index: this.textIndex, delta: { type: 'text_delta', text: truncatedToolNote(truncated) } } });
|
|
190
|
+
this.emittedAny = true;
|
|
191
|
+
}
|
|
192
|
+
out.push(...this._closeText());
|
|
193
|
+
const hadTools = this.toolOrder.length > 0;
|
|
194
|
+
let stop = mapStopReason(this.finishReason, { emitted: this.emittedAny });
|
|
195
|
+
if (stop === null) {
|
|
196
|
+
out.push({ event: 'error', data: { type: 'error', error: { type: 'api_error', message: 'upstream stream ended without content or finish_reason' } } });
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
if (hadTools && stop === 'end_turn') stop = 'tool_use';
|
|
200
|
+
out.push({ event: 'message_delta', data: { type: 'message_delta', delta: { stop_reason: stop, stop_sequence: null }, usage: mapUsage(this.usage) } });
|
|
201
|
+
out.push({ event: 'message_stop', data: { type: 'message_stop' } });
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Render `{event, data}` pairs as SSE text. */
|
|
207
|
+
export function serializeSse(events) {
|
|
208
|
+
let s = '';
|
|
209
|
+
for (const e of events) s += `event: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`;
|
|
210
|
+
return s;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Incremental SSE `data:` parser for an upstream chat stream. Feed text
|
|
215
|
+
* chunks; get back the parsed JSON objects (the `[DONE]` sentinel ends the
|
|
216
|
+
* stream and sets `done`). Non-JSON data lines are skipped.
|
|
217
|
+
*/
|
|
218
|
+
export class SseDataParser {
|
|
219
|
+
constructor() { this.buf = ''; this.done = false; }
|
|
220
|
+
/** @param {string} text @returns {object[]} */
|
|
221
|
+
feed(text) {
|
|
222
|
+
if (this.done) return [];
|
|
223
|
+
this.buf += text;
|
|
224
|
+
const out = [];
|
|
225
|
+
let idx;
|
|
226
|
+
while ((idx = this.buf.search(/\r?\n\r?\n/)) !== -1) {
|
|
227
|
+
const raw = this.buf.slice(0, idx);
|
|
228
|
+
this.buf = this.buf.slice(idx).replace(/^\r?\n\r?\n/, '');
|
|
229
|
+
const data = raw.split(/\r?\n/).filter((l) => l.startsWith('data:')).map((l) => l.slice(5).trim()).join('\n');
|
|
230
|
+
if (!data) continue;
|
|
231
|
+
if (data === '[DONE]') { this.done = true; break; }
|
|
232
|
+
try { out.push(JSON.parse(data)); } catch { /* skip non-JSON */ }
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
/** Drain a trailing event with no terminating blank line. */
|
|
237
|
+
end() {
|
|
238
|
+
if (this.done || !this.buf.trim()) return [];
|
|
239
|
+
const rest = this.buf; this.buf = '';
|
|
240
|
+
return this.feed(`${rest}\n\n`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// src/core/bridge/upstream.mjs
|
|
2
|
+
// One Messages request in, one upstream call out (model-bridge-design.md §5).
|
|
3
|
+
// `handleMessages` is transport-agnostic: it takes the parsed body and a
|
|
4
|
+
// writer for the reply so router.mjs (real http) and the tests (in-memory)
|
|
5
|
+
// share every line of the forwarding, translation and error logic.
|
|
6
|
+
|
|
7
|
+
import { toChatRequest } from './translate/request.mjs';
|
|
8
|
+
import { ChatStreamTranslator, SseDataParser, serializeSse } from './translate/stream.mjs';
|
|
9
|
+
import { toMessagesResponse } from './translate/response.mjs';
|
|
10
|
+
import { mapUpstreamError, mapNetworkError, bridgeErrors, PAYLOAD_CEILING_BYTES } from './errors.mjs';
|
|
11
|
+
import { copilotToken, invalidateCopilotToken, copilotApiHost, copilotHeaders, bodyHasImage, requestInitiator } from './providers/copilot.mjs';
|
|
12
|
+
import { upstreamSettings, providerReadiness } from './registry.mjs';
|
|
13
|
+
import { KeyedSemaphore } from './semaphore.mjs';
|
|
14
|
+
import { recordBridgeCall, recordBridgeError } from './telemetry.mjs';
|
|
15
|
+
|
|
16
|
+
export const semaphore = new KeyedSemaphore();
|
|
17
|
+
const PING_INTERVAL_MS = 15_000;
|
|
18
|
+
const warned = new Set();
|
|
19
|
+
|
|
20
|
+
/** Once-per-process warning (dropped fields, queue notices). */
|
|
21
|
+
function warnOnce(key, line, log) {
|
|
22
|
+
if (warned.has(key)) return;
|
|
23
|
+
warned.add(key);
|
|
24
|
+
(log || console.warn)(line);
|
|
25
|
+
}
|
|
26
|
+
export function _resetBridgeWarnings() { warned.clear(); }
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve auth + URL + headers for an upstream call.
|
|
30
|
+
* @returns {Promise<{url:string, headers:object, provider:string, retryAuth?:() => Promise<object>}>}
|
|
31
|
+
*/
|
|
32
|
+
async function prepareUpstream(us, body, { fetch: f, requestHeaders }) {
|
|
33
|
+
if (us.provider === 'copilot') {
|
|
34
|
+
const initiator = requestInitiator(body);
|
|
35
|
+
const vision = bodyHasImage(body);
|
|
36
|
+
const build = async (force) => {
|
|
37
|
+
const { token, apiHost } = await copilotToken(us.githubToken, { fetch: f, force });
|
|
38
|
+
const host = apiHost || copilotApiHost(us.accountType);
|
|
39
|
+
const path = us.api === 'anthropic' ? '/v1/messages' : '/chat/completions';
|
|
40
|
+
const headers = { ...copilotHeaders(token, { vision, initiator }), ...us.headers };
|
|
41
|
+
if (us.api === 'anthropic') {
|
|
42
|
+
headers['anthropic-version'] = requestHeaders['anthropic-version'] || '2023-06-01';
|
|
43
|
+
if (requestHeaders['anthropic-beta']) headers['anthropic-beta'] = requestHeaders['anthropic-beta'];
|
|
44
|
+
}
|
|
45
|
+
return { url: `${host}${path}`, headers, initiator };
|
|
46
|
+
};
|
|
47
|
+
const first = await build(false);
|
|
48
|
+
return { ...first, provider: 'copilot', retryAuth: () => { invalidateCopilotToken(us.githubToken); return build(true); } };
|
|
49
|
+
}
|
|
50
|
+
const initiator = requestInitiator(body);
|
|
51
|
+
if (us.api === 'anthropic') {
|
|
52
|
+
const base = (us.baseUrl || 'https://api.anthropic.com').replace(/\/+$/, '');
|
|
53
|
+
const url = /\/v1$/.test(base) ? `${base}/messages` : `${base}/v1/messages`;
|
|
54
|
+
const headers = {
|
|
55
|
+
'content-type': 'application/json',
|
|
56
|
+
'x-api-key': us.apiKey,
|
|
57
|
+
'anthropic-version': requestHeaders['anthropic-version'] || '2023-06-01',
|
|
58
|
+
...(requestHeaders['anthropic-beta'] ? { 'anthropic-beta': requestHeaders['anthropic-beta'] } : {}),
|
|
59
|
+
...us.headers,
|
|
60
|
+
};
|
|
61
|
+
return { url, headers, provider: us.provider, initiator };
|
|
62
|
+
}
|
|
63
|
+
const base = (us.baseUrl || 'https://api.openai.com/v1').replace(/\/+$/, '');
|
|
64
|
+
return {
|
|
65
|
+
url: `${base}/chat/completions`,
|
|
66
|
+
headers: { 'content-type': 'application/json', ...(us.apiKey ? { authorization: `Bearer ${us.apiKey}` } : {}), ...us.headers },
|
|
67
|
+
provider: us.provider,
|
|
68
|
+
initiator,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Serve one /v1/messages request.
|
|
74
|
+
* @param {object} args
|
|
75
|
+
* @param {object} args.entry findBridgedEntry() result
|
|
76
|
+
* @param {object} args.body parsed Messages request
|
|
77
|
+
* @param {Record<string,string>} args.requestHeaders lower-cased incoming headers
|
|
78
|
+
* @param {string} [args.tag] telemetry tag (execution id)
|
|
79
|
+
* @param {AbortSignal} [args.signal] aborts when the CLI disconnects
|
|
80
|
+
* @param {typeof fetch} [args.fetch]
|
|
81
|
+
* @param {(line:string)=>void} [args.log]
|
|
82
|
+
* @param {object} reply { status(code, headers), write(chunk), end(), json(status, obj, headers?) }
|
|
83
|
+
*/
|
|
84
|
+
export async function handleMessages({ entry, body, requestHeaders = {}, tag = '', signal, fetch: f = globalThis.fetch, log }, reply) {
|
|
85
|
+
const upstream = entry.upstream;
|
|
86
|
+
const ready = providerReadiness(upstream);
|
|
87
|
+
if (!ready.ok) {
|
|
88
|
+
const e = ready.reason === 'terms' ? bridgeErrors.termsNotAcknowledged(upstream.provider) : bridgeErrors.notSignedIn(upstream.provider, ready.message.split('— ')[1] || ready.message);
|
|
89
|
+
recordBridgeError({ tag, catalogId: entry.id, provider: upstream.provider, status: e.status, message: e.body.error.message });
|
|
90
|
+
return reply.json(e.status, e.body);
|
|
91
|
+
}
|
|
92
|
+
const us = upstreamSettings(upstream);
|
|
93
|
+
|
|
94
|
+
// Body → upstream body.
|
|
95
|
+
let outBody;
|
|
96
|
+
if (us.api === 'anthropic') {
|
|
97
|
+
outBody = { ...body, model: us.model };
|
|
98
|
+
} else {
|
|
99
|
+
const t = toChatRequest(body, { upstreamModel: us.model, capabilities: us.capabilities });
|
|
100
|
+
if (t.error) {
|
|
101
|
+
const e = bridgeErrors.unsupported(t.error.message);
|
|
102
|
+
recordBridgeError({ tag, catalogId: entry.id, provider: us.provider, status: e.status, message: e.body.error.message });
|
|
103
|
+
return reply.json(e.status, e.body);
|
|
104
|
+
}
|
|
105
|
+
for (const w of t.warnings) {
|
|
106
|
+
const line = w === 'max_tokens clamped'
|
|
107
|
+
? 'max_tokens clamped to the model\'s output limit'
|
|
108
|
+
: `${w} has no chat/completions equivalent — dropped`;
|
|
109
|
+
warnOnce(`${entry.id}:${w}`, `[worca] bridge: model ${JSON.stringify(entry.id)}: ${line}`, log);
|
|
110
|
+
}
|
|
111
|
+
outBody = t.body;
|
|
112
|
+
}
|
|
113
|
+
const payload = JSON.stringify(outBody);
|
|
114
|
+
if (Buffer.byteLength(payload) > PAYLOAD_CEILING_BYTES) {
|
|
115
|
+
const e = bridgeErrors.tooLarge();
|
|
116
|
+
return reply.json(e.status, e.body);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Concurrency cap (§7.4).
|
|
120
|
+
if (semaphore.active(us.provider) >= us.maxConcurrent) {
|
|
121
|
+
warnOnce(`queue:${us.provider}:${tag}`, `[worca] bridge: requests queued for ${us.provider} (cap ${us.maxConcurrent}) — raise it under Settings › Providers if this is slow`, log);
|
|
122
|
+
}
|
|
123
|
+
let release;
|
|
124
|
+
try {
|
|
125
|
+
release = await semaphore.acquire(us.provider, us.maxConcurrent, { signal });
|
|
126
|
+
} catch {
|
|
127
|
+
return reply.end(); // the CLI went away while waiting
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
let prep;
|
|
132
|
+
try {
|
|
133
|
+
prep = await prepareUpstream(us, body, { fetch: f, requestHeaders });
|
|
134
|
+
} catch (err) {
|
|
135
|
+
const status = err && err.code === 'AUTH' ? 401 : 502;
|
|
136
|
+
const e = status === 401
|
|
137
|
+
? bridgeErrors.notSignedIn(us.provider, err.message)
|
|
138
|
+
: mapNetworkError(err, { provider: us.provider });
|
|
139
|
+
recordBridgeError({ tag, catalogId: entry.id, provider: us.provider, status: e.status, message: e.body.error.message });
|
|
140
|
+
return reply.json(e.status, e.body);
|
|
141
|
+
}
|
|
142
|
+
recordBridgeCall({ tag, catalogId: entry.id, provider: us.provider, api: us.api, initiator: prep.initiator });
|
|
143
|
+
|
|
144
|
+
const doFetch = (p) => f(p.url, { method: 'POST', headers: p.headers, body: payload, signal });
|
|
145
|
+
let res;
|
|
146
|
+
try {
|
|
147
|
+
res = await doFetch(prep);
|
|
148
|
+
if (res.status === 401 && prep.retryAuth) {
|
|
149
|
+
const again = await prep.retryAuth();
|
|
150
|
+
res = await doFetch(again);
|
|
151
|
+
}
|
|
152
|
+
} catch (err) {
|
|
153
|
+
if (signal && signal.aborted) return reply.end();
|
|
154
|
+
const e = mapNetworkError(err, { provider: us.provider });
|
|
155
|
+
recordBridgeError({ tag, catalogId: entry.id, provider: us.provider, status: e.status, message: e.body.error.message });
|
|
156
|
+
return reply.json(e.status, e.body);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!res.ok) {
|
|
160
|
+
const text = await res.text().catch(() => '');
|
|
161
|
+
const e = mapUpstreamError(res.status, text, { provider: us.provider, retryAfter: res.headers.get('retry-after') });
|
|
162
|
+
recordBridgeError({ tag, catalogId: entry.id, provider: us.provider, status: res.status, message: e.body.error.message });
|
|
163
|
+
if (log) log(`[worca] bridge: ${us.provider} answered ${res.status} for ${JSON.stringify(entry.id)}: ${e.body.error.message}`);
|
|
164
|
+
return reply.json(e.status, e.body, e.headers);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const streaming = body.stream === true;
|
|
168
|
+
if (us.api === 'anthropic') {
|
|
169
|
+
// Byte passthrough: the SSE stream (or JSON body) is the CLI's own dialect.
|
|
170
|
+
const ct = res.headers.get('content-type') || (streaming ? 'text/event-stream' : 'application/json');
|
|
171
|
+
reply.status(200, { 'content-type': ct, 'cache-control': 'no-cache' });
|
|
172
|
+
if (!res.body) return reply.end();
|
|
173
|
+
for await (const chunk of res.body) reply.write(chunk);
|
|
174
|
+
return reply.end();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!streaming) {
|
|
178
|
+
const j = await res.json();
|
|
179
|
+
return reply.json(200, toMessagesResponse(j, { model: entry.id }));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
reply.status(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });
|
|
183
|
+
const translator = new ChatStreamTranslator({ model: entry.id });
|
|
184
|
+
const parser = new SseDataParser();
|
|
185
|
+
const decoder = new TextDecoder();
|
|
186
|
+
let ping = setInterval(() => reply.write('event: ping\ndata: {"type":"ping"}\n\n'), PING_INTERVAL_MS);
|
|
187
|
+
if (ping.unref) ping.unref();
|
|
188
|
+
const bump = () => { clearInterval(ping); ping = setInterval(() => reply.write('event: ping\ndata: {"type":"ping"}\n\n'), PING_INTERVAL_MS); if (ping.unref) ping.unref(); };
|
|
189
|
+
try {
|
|
190
|
+
if (res.body) {
|
|
191
|
+
for await (const chunk of res.body) {
|
|
192
|
+
const text = decoder.decode(chunk, { stream: true });
|
|
193
|
+
for (const obj of parser.feed(text)) {
|
|
194
|
+
const events = translator.push(obj);
|
|
195
|
+
if (events.length) { reply.write(serializeSse(events)); bump(); }
|
|
196
|
+
}
|
|
197
|
+
if (parser.done) break;
|
|
198
|
+
}
|
|
199
|
+
for (const obj of parser.end()) reply.write(serializeSse(translator.push(obj)));
|
|
200
|
+
}
|
|
201
|
+
reply.write(serializeSse(translator.finish()));
|
|
202
|
+
} finally {
|
|
203
|
+
clearInterval(ping);
|
|
204
|
+
}
|
|
205
|
+
return reply.end();
|
|
206
|
+
} finally {
|
|
207
|
+
release();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -14,6 +14,7 @@ import { parseCommand } from './parser.mjs';
|
|
|
14
14
|
import { BOOKEND_EXECUTION_IDS } from '../../shared/graph/constants.mjs';
|
|
15
15
|
import { createAllowlistGuard, parseIdList } from './allowlist.mjs';
|
|
16
16
|
import { runRef, fmtUsd, fmtMs } from './renderers.mjs';
|
|
17
|
+
import { promptFields, parseAnswerLine } from '../../shared/forms/project.mjs';
|
|
17
18
|
import { giveUpOption, describePauseReason, pauseConsequences } from '../failure-policy.mjs';
|
|
18
19
|
|
|
19
20
|
const md = (value) => ({ kind: 'markdown', value });
|
|
@@ -47,6 +48,7 @@ const HELP_TEXT = [
|
|
|
47
48
|
'`/approve [*ref]` — continue past a gate · `/retry [*ref]` — another cycle',
|
|
48
49
|
'`/abort [*ref]` — give up on a recovery prompt (pauses the run; nothing is discarded)',
|
|
49
50
|
'`/answer [*ref] <n|text> [| …]` — answer clarify questions (option number, or text for free-text)',
|
|
51
|
+
'`/answer [*ref] field=value [| field2=a,b]` — answer a form (escape a literal `|`, `,`, `=` or `:` with `\\`)',
|
|
50
52
|
'`/projects` · `/use <name>` — scope commands to one project',
|
|
51
53
|
'`/mute 30m|2h|1d` · `/unmute` — silence notifications for this chat',
|
|
52
54
|
'`/whoami` · `/help`',
|
|
@@ -106,7 +108,8 @@ export function lastPathSegment(p) {
|
|
|
106
108
|
* @param {{actions:object, chatContext:object, logger?:(l:string,m:string)=>void}} deps
|
|
107
109
|
* actions: listRuns(), runState(runId), pendingQuestion(runId),
|
|
108
110
|
* answer(runId, id, payload), stop(runId), pause(runId),
|
|
109
|
-
* resume(pipelineId), history({limit}), listProjects()
|
|
111
|
+
* resume(pipelineId), history({limit}), listProjects(),
|
|
112
|
+
* listScheduled?() -> [{id, title, runAt, status, projectDir, workspaceName?}] (optional)
|
|
110
113
|
*/
|
|
111
114
|
export function createCommandRouter({ actions, chatContext, logger = () => {} }) {
|
|
112
115
|
const projectOf = (chatKey) => chatContext.get(chatKey).active_project;
|
|
@@ -152,8 +155,19 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
|
|
|
152
155
|
|
|
153
156
|
runs: async ({ chatKey }) => {
|
|
154
157
|
const live = scopedRuns(chatKey);
|
|
155
|
-
|
|
156
|
-
|
|
158
|
+
// Scheduled runs (tickets, not pipelines): the next few, scoped like the live list.
|
|
159
|
+
const scope = projectOf(chatKey);
|
|
160
|
+
let soon = [];
|
|
161
|
+
try {
|
|
162
|
+
soon = (typeof actions.listScheduled === 'function' ? actions.listScheduled() : [])
|
|
163
|
+
.filter((t) => !scope || lastPathSegment(t.projectDir) === scope)
|
|
164
|
+
.slice(0, 5);
|
|
165
|
+
} catch { soon = []; }
|
|
166
|
+
const sched = soon.map((t) => `\u{1F552} ${t.status === 'missed' ? '**missed** · ' : ''}${t.title || 'Scheduled run'} · ${t.when || t.runAt}`);
|
|
167
|
+
if (!live.length && !sched.length) return reply('No live runs. `/last` shows the latest finished pipeline.');
|
|
168
|
+
const lines = live.length ? ['**Live runs:**', ...live.map(runLine)] : ['No live runs.'];
|
|
169
|
+
if (sched.length) lines.push('', '**Scheduled:**', ...sched);
|
|
170
|
+
return reply(lines.join('\n'));
|
|
157
171
|
},
|
|
158
172
|
|
|
159
173
|
last: async () => {
|
|
@@ -197,7 +211,11 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
|
|
|
197
211
|
if (cost) lines.push(` **Cost:** ${cost}`);
|
|
198
212
|
}
|
|
199
213
|
const pq = actions.pendingQuestion(r.runId);
|
|
200
|
-
if (pq)
|
|
214
|
+
if (pq) {
|
|
215
|
+
lines.push(pq.kind === 'form'
|
|
216
|
+
? ` ❓ waiting on the \`${pq.form}\` form — \`/answer ${runRef(r.runId)} <field>=<value>\``
|
|
217
|
+
: ` ❓ waiting on you — \`/approve ${runRef(r.runId)}\` or \`/answer ${runRef(r.runId)} <n>\``);
|
|
218
|
+
}
|
|
201
219
|
return reply(lines.join('\n'));
|
|
202
220
|
},
|
|
203
221
|
|
|
@@ -250,6 +268,42 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
|
|
|
250
268
|
if (t.error) return t.error;
|
|
251
269
|
const pq = actions.pendingQuestion(t.run.runId);
|
|
252
270
|
if (!pq) return reply(`\`${runRef(t.run.runId)}\` is not waiting on a question.`, 'warning');
|
|
271
|
+
const formRef = runRef(t.run.runId);
|
|
272
|
+
if (pq.kind === 'form') {
|
|
273
|
+
// Spec §8: `/answer <ref> field=value | field2=a,b`. The grammar itself —
|
|
274
|
+
// escapes, type-driven comma splitting, `id:verdict[:note]` for a
|
|
275
|
+
// review-list, the bare positional — is P1's parseAnswerLine (rulings X7,
|
|
276
|
+
// X8). What lives here is the ref, the refusal, the usage line and the
|
|
277
|
+
// gate-3 reply. Gate 3 THROWS INVALID_ANSWER (X2) and leaves the ask open.
|
|
278
|
+
if (pq.surface === 'web') {
|
|
279
|
+
return reply(`\`${formRef}\` is waiting on the \`${pq.form}\` form, which is answered in the worca web UI.`, 'warning');
|
|
280
|
+
}
|
|
281
|
+
const fields = promptFields(pq);
|
|
282
|
+
const hasRefArg = !!(args[0] && args[0].startsWith('*'));
|
|
283
|
+
const line = (hasRefArg ? args.slice(1) : args).join(' ').trim();
|
|
284
|
+
const example = fields.filter((f) => !f.when).slice(0, 3)
|
|
285
|
+
.map((f) => `${f.field}=${f.type === 'array' ? '<a,b>' : '<value>'}`).join(' | ')
|
|
286
|
+
|| '<field>=<value>';
|
|
287
|
+
const usage = () => reply(
|
|
288
|
+
`Reply: \`/answer ${formRef} ${example}\`\nSeparate fields with \`|\`, list values with \`,\`, escape a literal \`|\`, \`,\`, \`=\` or \`:\` with \`\\\`.`,
|
|
289
|
+
'warning');
|
|
290
|
+
if (!line) return usage();
|
|
291
|
+
const parsed = parseAnswerLine(line, pq, fields.length === 1 ? { bareField: fields[0].field } : {});
|
|
292
|
+
if (parsed.errors.length) {
|
|
293
|
+
return reply(['Could not read that answer:',
|
|
294
|
+
...parsed.errors.map((e) => `• ${e.path ? `\`${e.path}\`: ` : ''}${e.message}`),
|
|
295
|
+
'', `Reply: \`/answer ${formRef} ${example}\``].join('\n'), 'warning');
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
await actions.answer(t.run.runId, pq.id, { values: parsed.values });
|
|
299
|
+
} catch (err) {
|
|
300
|
+
if (!err || err.code !== 'INVALID_ANSWER') throw err; // the handler's catch owns everything else
|
|
301
|
+
return reply([`\`${formRef}\` — that answer was rejected:`,
|
|
302
|
+
...(Array.isArray(err.errors) ? err.errors : []).map((e) => `• ${e.path ? `\`${e.path}\`: ` : ''}${e.message}`),
|
|
303
|
+
'', 'The question is still open.'].join('\n'), 'warning');
|
|
304
|
+
}
|
|
305
|
+
return reply(`✅ Answered the \`${pq.form}\` form on \`${formRef}\`.`, 'success');
|
|
306
|
+
}
|
|
253
307
|
if (pq.kind !== 'clarify' && pq.kind !== 'questions') {
|
|
254
308
|
return reply(`\`${runRef(t.run.runId)}\` is waiting on ${pq.kind} — use \`/approve\` or \`/retry\`.`, 'warning');
|
|
255
309
|
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { readPluginConfig } from '../plugin-config.mjs';
|
|
14
14
|
import { parseIdList } from './allowlist.mjs';
|
|
15
15
|
import { createRateLimiter } from './rate-limiter.mjs';
|
|
16
|
-
import { renderDone, renderError, renderQuestion } from './renderers.mjs';
|
|
16
|
+
import { renderDone, renderError, renderQuestion, renderSchedule } from './renderers.mjs';
|
|
17
17
|
import { pauseConsequences } from '../failure-policy.mjs';
|
|
18
18
|
|
|
19
19
|
/**
|
|
@@ -103,6 +103,19 @@ export function createNotifier({ channelHost, getPrefs, chatContext, logger = ()
|
|
|
103
103
|
}
|
|
104
104
|
},
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* A scheduled-run notification. Run outcomes are skipped: the fired run's own
|
|
108
|
+
* done/error events already reached chat through attach().
|
|
109
|
+
*/
|
|
110
|
+
notifySchedule(notification) {
|
|
111
|
+
try {
|
|
112
|
+
if (!notification || ['completed', 'run_error', 'run_paused', 'retrying'].includes(notification.kind)) return;
|
|
113
|
+
let prefs; try { prefs = getPrefs(); } catch { prefs = { notify: {} }; }
|
|
114
|
+
if (prefs.notify?.schedule === false) return;
|
|
115
|
+
deliver(renderSchedule(notification));
|
|
116
|
+
} catch (err) { logger('error', `chat notifier: ${err?.message || err}`); }
|
|
117
|
+
},
|
|
118
|
+
|
|
106
119
|
/** The Test button / smoke path: send one message to one channel's
|
|
107
120
|
* notifyChatIds directly (no prefs gating — an explicit user action). */
|
|
108
121
|
async sendTest(plugin, channelId, message) {
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
// using the run-id wildcard-suffix convention the command router resolves.
|
|
11
11
|
|
|
12
12
|
import { pauseConsequences, describePauseReason, giveUpOption } from '../failure-policy.mjs';
|
|
13
|
+
import { projectForm } from '../../shared/forms/project.mjs';
|
|
14
|
+
import { CHAT_PROJECTION_MAX } from '../ask-projection.mjs';
|
|
13
15
|
|
|
14
16
|
const md = (value) => ({ kind: 'markdown', value });
|
|
15
17
|
|
|
@@ -120,6 +122,27 @@ export function renderQuestion(meta, payload = {}) {
|
|
|
120
122
|
return mdMsg(parts.join('\n'), 'warning');
|
|
121
123
|
}
|
|
122
124
|
|
|
125
|
+
if (kind === 'form') {
|
|
126
|
+
// A form ask (spec §8). The projection is P1's — display widgets as text, files
|
|
127
|
+
// as `rel (mime, size)` — capped so one message fits the tightest shipped
|
|
128
|
+
// platform (Discord: 2000 chars); the cap drops display text only and never the
|
|
129
|
+
// prompts or the reply command. A surface:'web' form stays OPEN here: a chat run
|
|
130
|
+
// lives in ui/server.mjs's runs Map, so a browser really can answer it.
|
|
131
|
+
parts.push(` **Status:** waiting on a form${payload.agent ? ` from ${payload.agent}` : ''}`);
|
|
132
|
+
const webOnly = payload.surface === 'web';
|
|
133
|
+
let projection;
|
|
134
|
+
try {
|
|
135
|
+
projection = projectForm(payload, { ...(webOnly ? {} : { ref }), maxChars: CHAT_PROJECTION_MAX });
|
|
136
|
+
} catch {
|
|
137
|
+
// A junk envelope must still NOTIFY — the notifier's guard would otherwise
|
|
138
|
+
// swallow the throw and the user would never learn a run is waiting.
|
|
139
|
+
projection = `${String(payload.title || payload.form || 'form')}${payload.agent ? ` — ${payload.agent}` : ''}`;
|
|
140
|
+
}
|
|
141
|
+
for (const line of projection.split('\n')) parts.push(line ? ` ${line}` : '');
|
|
142
|
+
if (webOnly) parts.push(' Answer this form in the worca web UI.');
|
|
143
|
+
return mdMsg(parts.join('\n'), 'warning');
|
|
144
|
+
}
|
|
145
|
+
|
|
123
146
|
parts.push(` **Status:** has questions${payload.agent ? ` from ${payload.agent}` : ''}`);
|
|
124
147
|
const questions = Array.isArray(payload.questions) ? payload.questions : [];
|
|
125
148
|
questions.forEach((q, qi) => {
|
|
@@ -134,6 +157,18 @@ export function renderQuestion(meta, payload = {}) {
|
|
|
134
157
|
}
|
|
135
158
|
|
|
136
159
|
/** Canned message for the "Test" button / `worca plugin channel` smoke. */
|
|
160
|
+
/**
|
|
161
|
+
* A scheduled-run notification (src/core/notifications.mjs row). Run OUTCOMES are
|
|
162
|
+
* not rendered here — the run's own done/error events already went out.
|
|
163
|
+
*/
|
|
164
|
+
export function renderSchedule(n = {}) {
|
|
165
|
+
const icon = n.severity === 'info' ? '\u{1F552}' : '\u{26A0}\u{FE0F}';
|
|
166
|
+
const title = String(n.title || 'Scheduled run').trim();
|
|
167
|
+
const name = title.length > 60 ? `${title.slice(0, 60)}…` : title;
|
|
168
|
+
const msg = String(n.message || '');
|
|
169
|
+
return mdMsg(`${icon} **Schedule:** ${name}\n ${msg.length > 300 ? `${msg.slice(0, 300)}…` : msg}`, n.severity === 'info' ? 'info' : 'warning');
|
|
170
|
+
}
|
|
171
|
+
|
|
137
172
|
export function renderTest() {
|
|
138
173
|
return {
|
|
139
174
|
title: 'worca-cc test message',
|