@yeaft/webchat-agent 0.1.709 → 0.1.711

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.709",
3
+ "version": "0.1.711",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,437 @@
1
+ /**
2
+ * feature-arc.js — Tracks a single VP turn's "is this heavy work?" arc.
3
+ *
4
+ * Design rationale
5
+ * ----------------
6
+ * The Unify group chat used to dump every tool call from every VP into
7
+ * one scrolling feed, which made anything beyond a one-shot Q&A
8
+ * unreadable. The fix is dual-layer:
9
+ *
10
+ * group chat → user prompt + a *pill* per heavy turn
11
+ * (active: "🔧 [vp] doing X…", done: "✅ [vp] X — summary")
12
+ * detail panel → the full unflattened timeline of whichever VP is
13
+ * selected
14
+ *
15
+ * For pills to exist we need to know **which VP turns are heavy**. The
16
+ * triage runs out of three signals (any-of):
17
+ *
18
+ * 1. Track A (quick-response) returned `intent: 'feature'`
19
+ * 2. Track B (main engine) has cycled ≥ FEATURE_TURN_THRESHOLD loops
20
+ * 3. Track B called any tool on KEY_TOOLS (work tools — bash, edits,
21
+ * sub-agent spawn, grep/find/glob — *not* pure read or web search)
22
+ *
23
+ * When any signal fires, this arc:
24
+ * - calls FeatureStore.create() with title := preview (or fallback)
25
+ * - stamps a `currentFeatureId` on the runVpTurn ctx so subsequent
26
+ * emits get featureId on their wire envelope
27
+ * - notifies the wire layer via a `feature_started` event so the
28
+ * frontend knows to fold prior messages into a pill
29
+ * - on turn close, runs a one-shot summarisation call against the
30
+ * accumulated assistant text, then writes status='completed' +
31
+ * result back through FeatureStore.update()
32
+ *
33
+ * The arc is **strictly additive** — it does not mutate engine state,
34
+ * does not consume engine events the dispatcher needs, and silently
35
+ * no-ops on any failure (logging only). A broken FeatureArc must never
36
+ * break the user's turn.
37
+ */
38
+
39
+ import { runQuickResponse } from './quick-response.js';
40
+
41
+ /**
42
+ * Tools that strongly signal "doing real work". Single-file Read and
43
+ * web search are intentionally excluded — they show up in trivial Q&A
44
+ * (one quick lookup, one fact check) and would over-trigger the pill.
45
+ *
46
+ * Codebase grep/glob/find are *included* because they signal multi-file
47
+ * investigation, which is exactly the "this got heavy" mode we want to
48
+ * surface to the user.
49
+ *
50
+ * @type {Set<string>}
51
+ */
52
+ export const KEY_TOOLS = new Set([
53
+ 'Bash',
54
+ 'FileEdit',
55
+ 'FileWrite',
56
+ 'FileCreate',
57
+ 'ApplyPatch',
58
+ 'NotebookEdit',
59
+ 'Agent',
60
+ 'Grep',
61
+ 'Glob',
62
+ 'Find',
63
+ 'JsRepl',
64
+ ]);
65
+
66
+ /** Track-B loop count that on its own counts as "this got heavy". */
67
+ export const FEATURE_TURN_THRESHOLD = 3;
68
+
69
+ /** Cap title length we store on the Feature. */
70
+ const TITLE_MAX = 60;
71
+ /** Cap summary stored on completion. */
72
+ const SUMMARY_MAX = 600;
73
+
74
+ /**
75
+ * Build a one-shot system prompt asking the LLM to summarise what it
76
+ * just did in 1–3 sentences. Bilingual.
77
+ *
78
+ * @param {string} language
79
+ */
80
+ function buildSummarySystem(language = 'en') {
81
+ const isZh = String(language || '').toLowerCase().startsWith('zh');
82
+ if (isZh) {
83
+ return [
84
+ '你刚刚完成了一段工作。请用中文写一条 1–3 句的总结,告诉用户你做了什么、关键结果是什么。',
85
+ '只输出总结正文,不要 markdown,不要前缀(如「总结:」),不超过 600 字符。',
86
+ ].join('\n');
87
+ }
88
+ return [
89
+ 'You just finished a piece of work. Write a 1–3 sentence summary in English describing what you did and the key outcome.',
90
+ 'Output only the summary prose. No markdown, no leading label like "Summary:", at most 600 characters.',
91
+ ].join('\n');
92
+ }
93
+
94
+ /**
95
+ * Drive one summary call. Fails-soft: returns '' on any error.
96
+ *
97
+ * @param {{
98
+ * adapter: object,
99
+ * model: string,
100
+ * prompt: string, // user's original prompt — supplied as context
101
+ * assistantText: string, // joined VP text output for this turn
102
+ * language?: string,
103
+ * signal?: AbortSignal,
104
+ * }} args
105
+ * @returns {Promise<string>}
106
+ */
107
+ async function runSummaryCall({ adapter, model, prompt, assistantText, language, signal }) {
108
+ if (!adapter || typeof adapter.stream !== 'function') return '';
109
+ if (!model) return '';
110
+ const text = (assistantText || '').trim();
111
+ if (!text) return '';
112
+ const system = buildSummarySystem(language);
113
+ // We feed the model BOTH the user request and what we said back, so
114
+ // a summary like "Looked at auth.js, found X, fixed it" is grounded.
115
+ const userMsg = [
116
+ 'USER REQUEST:',
117
+ String(prompt || '').slice(0, 4000),
118
+ '',
119
+ 'WHAT YOU DID / SAID:',
120
+ text.slice(0, 8000),
121
+ ].join('\n');
122
+ try {
123
+ const parts = [];
124
+ for await (const evt of adapter.stream({
125
+ model,
126
+ system,
127
+ messages: [{ role: 'user', content: userMsg }],
128
+ maxTokens: 400,
129
+ signal,
130
+ })) {
131
+ if (evt && evt.type === 'text_delta' && typeof evt.text === 'string') {
132
+ parts.push(evt.text);
133
+ } else if (evt && evt.type === 'error') {
134
+ return '';
135
+ }
136
+ }
137
+ return parts.join('').replace(/\s+/g, ' ').trim().slice(0, SUMMARY_MAX);
138
+ } catch {
139
+ return '';
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Make a feature title from preview / prompt. Strips trailing
145
+ * punctuation and clamps length.
146
+ */
147
+ function makeTitle({ preview, prompt }) {
148
+ const src = (preview || prompt || '').replace(/\s+/g, ' ').trim();
149
+ if (!src) return '(untitled task)';
150
+ const clipped = src.slice(0, TITLE_MAX);
151
+ return clipped.replace(/[.!?。!?…]+$/u, '').trim() || clipped;
152
+ }
153
+
154
+ /**
155
+ * @typedef {Object} FeatureArcEmits
156
+ * @property {(payload:{intent:'quick'|'feature', preview:string})=>void}
157
+ * [quickPreview] — Track A finished
158
+ * @property {(payload:{featureId:string, title:string, trigger:'quick'|'turns'|'tool', toolName?:string})=>void}
159
+ * [featureStarted] — auto-create fired, frontend should fold
160
+ * @property {(payload:{featureId:string, summary:string, status:'completed'|'aborted'|'error'})=>void}
161
+ * [featureCompleted] — turn ended, pill becomes done state
162
+ *
163
+ * @typedef {Object} FeatureArcDeps
164
+ * @property {object|null} adapter — LLMAdapter (session.adapter)
165
+ * @property {string|null} model — primaryModel
166
+ * @property {object|null} featureStore — FeatureStore instance (singleton)
167
+ * @property {string} prompt — user prompt that opened the turn
168
+ * @property {string} vpId
169
+ * @property {string|null} groupId
170
+ * @property {string} turnId
171
+ * @property {string} [vpDisplayName]
172
+ * @property {string} [language]
173
+ * @property {AbortSignal} [signal]
174
+ * @property {FeatureArcEmits} [emit]
175
+ * @property {Set<string>} [keyTools] — override for tests
176
+ * @property {number} [turnThreshold] — override for tests
177
+ */
178
+
179
+ /**
180
+ * Create a per-VP-turn arc tracker. Caller MUST:
181
+ * - call `arc.startTrackA()` once at the very beginning of runVpTurn
182
+ * (returns a Promise that backgrounds; do NOT await)
183
+ * - call `arc.observeEvent(event)` for every engine event before
184
+ * dispatching it to the existing handleEngineEvent
185
+ * - call `arc.collectAssistantText(chunk)` whenever a text_delta is
186
+ * forwarded (lets us seed the summary call without re-aggregating)
187
+ * - call `await arc.finalize({status})` AFTER the engine query
188
+ * generator drains, before sending the final 'result'.
189
+ *
190
+ * The arc is opinionated about ordering: featureId is published only
191
+ * once, the first time any signal fires. Subsequent fires are no-ops.
192
+ *
193
+ * @param {FeatureArcDeps} deps
194
+ */
195
+ export function createFeatureArc(deps = {}) {
196
+ const {
197
+ adapter = null,
198
+ model = null,
199
+ featureStore = null,
200
+ prompt = '',
201
+ vpId,
202
+ groupId = null,
203
+ turnId,
204
+ vpDisplayName,
205
+ language,
206
+ signal,
207
+ emit = {},
208
+ keyTools = KEY_TOOLS,
209
+ turnThreshold = FEATURE_TURN_THRESHOLD,
210
+ } = deps;
211
+
212
+ let trackAResult = null; // {intent, preview} | null
213
+ let trackADone = false;
214
+ let featureId = null;
215
+ let featureTitle = null;
216
+ let assistantText = ''; // accumulated for summary call
217
+ let loopCount = 0; // 'turn_open'/'loop'/'reflection' increments
218
+ let _finalised = false;
219
+
220
+ /** Internal: try to fire the auto-create. Idempotent. */
221
+ function maybeCreateFeature(signalKind, extra = {}) {
222
+ // Race guard: Track A is fire-and-forget, so it can resolve AFTER
223
+ // the engine generator has drained and finalize() has already
224
+ // closed the arc. Without this guard a late Track A would publish
225
+ // `feature_started` *after* `feature_completed` (or worse, with
226
+ // no `feature_completed` at all), leaving a dangling-active pill
227
+ // on the frontend.
228
+ if (_finalised) return;
229
+ if (featureId) return; // already created
230
+ if (!featureStore || typeof featureStore.create !== 'function') {
231
+ // No store — at least publish a synthetic id so the frontend can
232
+ // still render a pill. Use a deterministic prefix so it's obvious
233
+ // when something is wrong.
234
+ featureId = `feat-local-${turnId}`;
235
+ } else {
236
+ try {
237
+ // Use the FULL UUID / random-string. A previous version
238
+ // sliced to 8 chars (32 bits of entropy) — collisions in a
239
+ // multi-VP group ingest were observed because the
240
+ // Date.now()/random fallback's first chars are dominated by
241
+ // the ms-precision timestamp, so two VPs in the same
242
+ // millisecond would hash to the same 8-char prefix and the
243
+ // frontend's featureId-keyed map would silently overwrite.
244
+ const rand = globalThis.crypto?.randomUUID?.()
245
+ || (Date.now().toString(36) + Math.random().toString(36).slice(2));
246
+ const id = `feat-${rand}`;
247
+ const title = makeTitle({ preview: trackAResult?.preview, prompt });
248
+ featureTitle = title;
249
+ const record = {
250
+ id,
251
+ title,
252
+ description: prompt ? prompt.slice(0, 500) : '',
253
+ priority: 'medium',
254
+ status: 'in_progress',
255
+ parentId: null,
256
+ parentTaskId: null,
257
+ createdAt: Date.now(),
258
+ updatedAt: Date.now(),
259
+ };
260
+ if (groupId) {
261
+ record.groupId = groupId;
262
+ record.members = [vpId];
263
+ record.initiator = vpId;
264
+ }
265
+ featureStore.create(record);
266
+ featureId = id;
267
+ } catch (err) {
268
+ console.warn('[FeatureArc] create failed:', err?.message || err);
269
+ // Fallback: still publish a synthetic id so the UI gets a pill
270
+ // (matches the no-store branch above — a broken store should
271
+ // not silently disable the feature folding UX).
272
+ featureId = `feat-local-${turnId}`;
273
+ featureTitle = makeTitle({ preview: trackAResult?.preview, prompt });
274
+ }
275
+ }
276
+ if (typeof emit.featureStarted === 'function') {
277
+ try {
278
+ emit.featureStarted({
279
+ featureId,
280
+ title: featureTitle || makeTitle({ preview: trackAResult?.preview, prompt }),
281
+ trigger: signalKind,
282
+ toolName: extra.toolName,
283
+ });
284
+ } catch (err) {
285
+ console.warn('[FeatureArc] featureStarted emit failed:', err?.message || err);
286
+ }
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Launch Track A in the background. Returns the promise so callers can
292
+ * await it during shutdown if they need to (tests). In the hot path
293
+ * runVpTurn fires-and-forgets.
294
+ */
295
+ async function startTrackA() {
296
+ try {
297
+ const result = await runQuickResponse({
298
+ adapter,
299
+ model,
300
+ prompt,
301
+ language,
302
+ vpDisplayName,
303
+ signal,
304
+ });
305
+ trackAResult = result;
306
+ trackADone = true;
307
+ if (result && typeof emit.quickPreview === 'function') {
308
+ try {
309
+ emit.quickPreview({ intent: result.intent, preview: result.preview });
310
+ } catch (err) {
311
+ console.warn('[FeatureArc] quickPreview emit failed:', err?.message || err);
312
+ }
313
+ }
314
+ if (result && result.intent === 'feature') {
315
+ maybeCreateFeature('quick');
316
+ }
317
+ } catch (err) {
318
+ // runQuickResponse already swallows most things; log + continue.
319
+ trackADone = true;
320
+ console.warn('[FeatureArc] Track A failed:', err?.message || err);
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Observe one engine event, mutating internal counters and possibly
326
+ * firing the auto-create. Always called BEFORE the existing
327
+ * handleEngineEvent dispatch so the featureId is set in time for
328
+ * the wire envelope to pick it up.
329
+ *
330
+ * @param {{type:string, name?:string, text?:string}} event
331
+ */
332
+ function observeEvent(event) {
333
+ if (!event || typeof event !== 'object') return;
334
+ switch (event.type) {
335
+ // Only `loop` counts toward the heavy-turn threshold. The engine
336
+ // emits exactly one `turn_open` per turn (the bookkeeping marker
337
+ // that the turn started); `loop` is the per-iteration event.
338
+ // Counting both inflates by one and would cause
339
+ // FEATURE_TURN_THRESHOLD = 3 to fire after only 2 real loops.
340
+ case 'loop':
341
+ loopCount += 1;
342
+ if (loopCount >= turnThreshold) maybeCreateFeature('turns');
343
+ break;
344
+ case 'tool_call':
345
+ if (event.name && keyTools.has(event.name)) {
346
+ maybeCreateFeature('tool', { toolName: event.name });
347
+ }
348
+ break;
349
+ case 'text_delta':
350
+ if (typeof event.text === 'string') {
351
+ // Soft cap so a runaway VP doesn't balloon memory before
352
+ // summarisation. 50 KB is enough context for any 1–3 sentence
353
+ // summary.
354
+ if (assistantText.length < 50_000) {
355
+ assistantText += event.text;
356
+ }
357
+ }
358
+ break;
359
+ default:
360
+ break;
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Run summary + FeatureStore.update. Idempotent. Caller passes a
366
+ * status hint so we know whether to write 'completed' / 'aborted' /
367
+ * 'error'.
368
+ *
369
+ * @param {{status?:'completed'|'aborted'|'error'}} [opts]
370
+ */
371
+ async function finalize(opts = {}) {
372
+ if (_finalised) return;
373
+ _finalised = true;
374
+ if (!featureId) return; // never escalated; nothing to close
375
+
376
+ const status = opts.status || 'completed';
377
+ let summary = '';
378
+ if (status === 'completed') {
379
+ summary = await runSummaryCall({
380
+ adapter, model, prompt, assistantText, language, signal,
381
+ });
382
+ } else if (status === 'aborted') {
383
+ summary = '(turn aborted)';
384
+ } else {
385
+ summary = '(turn ended with error)';
386
+ }
387
+
388
+ if (!summary) {
389
+ // Fallback to a truncated tail of the assistant text so the pill
390
+ // is never a blank "✅ — ".
391
+ summary = (assistantText || '').replace(/\s+/g, ' ').trim().slice(0, 200) || '(no summary)';
392
+ }
393
+
394
+ // Skip the persistence call for synthetic ids — those exist
395
+ // precisely because the store was unavailable or threw on create,
396
+ // so any update against them would also throw on the unknown id
397
+ // (and the catch would silently swallow it). Wire emits still
398
+ // happen so the frontend gets a consistent close.
399
+ const isSynthetic = featureId.startsWith('feat-local-');
400
+ if (!isSynthetic && featureStore && typeof featureStore.update === 'function') {
401
+ try {
402
+ featureStore.update(featureId, {
403
+ status,
404
+ result: summary,
405
+ });
406
+ } catch (err) {
407
+ console.warn('[FeatureArc] update failed:', err?.message || err);
408
+ }
409
+ }
410
+
411
+ if (typeof emit.featureCompleted === 'function') {
412
+ try {
413
+ emit.featureCompleted({ featureId, summary, status });
414
+ } catch (err) {
415
+ console.warn('[FeatureArc] featureCompleted emit failed:', err?.message || err);
416
+ }
417
+ }
418
+ }
419
+
420
+ return {
421
+ startTrackA,
422
+ observeEvent,
423
+ finalize,
424
+ /** Mostly for tests / wire-tagging in the hot path. */
425
+ getFeatureId: () => featureId,
426
+ getTitle: () => featureTitle,
427
+ getTrackAResult: () => trackAResult,
428
+ isTrackADone: () => trackADone,
429
+ getLoopCount: () => loopCount,
430
+ };
431
+ }
432
+
433
+ // Test seams.
434
+ export const __test = {
435
+ makeTitle,
436
+ buildSummarySystem,
437
+ };
@@ -0,0 +1,229 @@
1
+ /**
2
+ * quick-response.js — Track A of the Unify dual-track turn.
3
+ *
4
+ * Purpose
5
+ * -------
6
+ * Run a single, non-looping LLM call against the user prompt that:
7
+ * 1. classifies the turn as `quick` (one-shot reply) vs `feature`
8
+ * (heavy multi-step work that should be surfaced as a feature pill);
9
+ * 2. emits a short `preview` sentence telling the user what the VP is
10
+ * about to do (e.g. "I'll grep the auth code, give me a sec").
11
+ *
12
+ * The result feeds the dual-track UI:
13
+ * - `intent === 'feature'` is one of the three signals that auto-create
14
+ * a Feature record, collapsing all subsequent VP output into a pill.
15
+ * - `preview` is rendered as an instant bubble under the user's message
16
+ * so the user sees something within ~1s, even if the main engine
17
+ * loop (Track B) takes longer.
18
+ *
19
+ * Properties
20
+ * ----------
21
+ * - **One LLM call**, no tools, no loop. The whole point is to be cheap
22
+ * and predictable. Uses the same `primaryModel` as the main engine
23
+ * per design ruling — there is no separate `fastModel` channel.
24
+ * - **Retries once on parse/transport failure** then gives up silently.
25
+ * A failed Track A is fine: signals 2 (≥3 turns) and 3 (key tool)
26
+ * still pick up real heavy turns.
27
+ * - **Hard timeout** of 8s wall-clock. Track B must not be held back
28
+ * waiting on Track A.
29
+ *
30
+ * Wire shape — what we emit to the frontend
31
+ * -----------------------------------------
32
+ * On success:
33
+ * { type: 'quick_preview', vpId, turnId, intent, preview }
34
+ *
35
+ * The preview is plain text, ≤ 140 chars, in the user's language.
36
+ *
37
+ * Failure mode
38
+ * ------------
39
+ * Returns `null`. Caller MUST tolerate this and not block on the result.
40
+ */
41
+
42
+ const QUICK_TIMEOUT_MS = 8000;
43
+ const PREVIEW_MAX_CHARS = 140;
44
+ const QUICK_MAX_TOKENS = 300;
45
+
46
+ /**
47
+ * Compose the system prompt that asks the LLM for a structured
48
+ * intent + preview. Bilingual to match the rest of Unify.
49
+ *
50
+ * @param {{ language?: string, vpDisplayName?: string }} opts
51
+ * @returns {string}
52
+ */
53
+ function buildQuickSystem({ language = 'en', vpDisplayName = 'assistant' } = {}) {
54
+ const isZh = String(language || '').toLowerCase().startsWith('zh');
55
+ if (isZh) {
56
+ return [
57
+ `你正在以「${vpDisplayName}」的身份做一次极简的"先回声"判断。这不是真正的回答,主回答会由另一条线并发产出。`,
58
+ '',
59
+ '只输出一行 JSON,不要 markdown、不要 ```、不要前后空行:',
60
+ '{"intent":"quick"|"feature","preview":"<不超过 80 个字符的中文,告诉用户你打算做什么>"}',
61
+ '',
62
+ 'intent 规则:',
63
+ '- "quick":用户是寒暄、问事实、要一句话答案,预计一次回复就够。',
64
+ '- "feature":需要查代码 / 改文件 / 调 bash / 跑测试 / 多步推理,预计要折腾若干轮。',
65
+ '',
66
+ 'preview 规则:',
67
+ '- 用第一人称简短陈述「我去做什么」,例如:「我去看看 auth 模块再回你」。',
68
+ '- 不要承诺结果,不要复述用户的话。',
69
+ '- 不要带表情、不要带 markdown。',
70
+ ].join('\n');
71
+ }
72
+ return [
73
+ `You are "${vpDisplayName}" giving a one-shot pre-reply. This is NOT the real answer; the real answer is being produced concurrently on another track.`,
74
+ '',
75
+ 'Output ONE line of strict JSON, no markdown, no fences, no leading/trailing whitespace:',
76
+ '{"intent":"quick"|"feature","preview":"<at most 80 chars telling the user what you are about to do>"}',
77
+ '',
78
+ 'intent rules:',
79
+ '- "quick": small talk / factual lookup / single-sentence answer.',
80
+ '- "feature": needs code reading, file edits, bash, tests, or multi-step reasoning.',
81
+ '',
82
+ 'preview rules:',
83
+ '- First-person, short. Example: "Let me grep the auth module and get back to you."',
84
+ '- Do NOT promise outcomes. Do NOT echo the user.',
85
+ '- No emoji, no markdown.',
86
+ ].join('\n');
87
+ }
88
+
89
+ /**
90
+ * Robust JSON extraction. Models occasionally wrap output in fences or
91
+ * leading prose despite instructions; we accept any single JSON object
92
+ * we can find.
93
+ *
94
+ * @param {string} raw
95
+ * @returns {{intent:string, preview:string}|null}
96
+ */
97
+ function parseQuickJson(raw) {
98
+ if (typeof raw !== 'string') return null;
99
+ let s = raw.trim();
100
+ if (!s) return null;
101
+ // Strip ``` fences if present.
102
+ if (s.startsWith('```')) {
103
+ s = s.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '').trim();
104
+ }
105
+ // First-pass direct parse.
106
+ let obj = null;
107
+ try { obj = JSON.parse(s); } catch { /* fall through */ }
108
+ // Second-pass: locate first `{` and last `}`.
109
+ if (!obj) {
110
+ const i = s.indexOf('{');
111
+ const j = s.lastIndexOf('}');
112
+ if (i >= 0 && j > i) {
113
+ try { obj = JSON.parse(s.slice(i, j + 1)); } catch { /* nope */ }
114
+ }
115
+ }
116
+ if (!obj || typeof obj !== 'object') return null;
117
+ const intent = obj.intent === 'feature' ? 'feature' : 'quick';
118
+ const previewRaw = typeof obj.preview === 'string' ? obj.preview : '';
119
+ const preview = previewRaw.replace(/\s+/g, ' ').trim().slice(0, PREVIEW_MAX_CHARS);
120
+ if (!preview) return null; // a preview-less response is useless
121
+ return { intent, preview };
122
+ }
123
+
124
+ /**
125
+ * Drive the adapter once. Collects text deltas, returns the assembled
126
+ * raw string. Throws on adapter error / abort / timeout.
127
+ *
128
+ * @param {object} adapter — LLMAdapter instance with .stream()
129
+ * @param {object} args — { model, system, messages, signal }
130
+ * @returns {Promise<string>}
131
+ */
132
+ async function callOnce(adapter, args) {
133
+ const parts = [];
134
+ for await (const event of adapter.stream(args)) {
135
+ if (!event || typeof event !== 'object') continue;
136
+ if (event.type === 'text_delta' && typeof event.text === 'string') {
137
+ parts.push(event.text);
138
+ } else if (event.type === 'error') {
139
+ throw event.error || new Error('adapter stream error');
140
+ }
141
+ // tool_call / thinking_delta / usage / stop are ignored; we
142
+ // explicitly do not pass any tools to the adapter.
143
+ }
144
+ return parts.join('');
145
+ }
146
+
147
+ /**
148
+ * Run Track A. One adapter call, retry-once on failure, hard 8s deadline.
149
+ *
150
+ * @param {{
151
+ * adapter: object,
152
+ * model: string,
153
+ * prompt: string,
154
+ * language?: string,
155
+ * vpDisplayName?: string,
156
+ * signal?: AbortSignal,
157
+ * }} args
158
+ * @returns {Promise<{intent:'quick'|'feature', preview:string}|null>}
159
+ */
160
+ export async function runQuickResponse({
161
+ adapter,
162
+ model,
163
+ prompt,
164
+ language,
165
+ vpDisplayName,
166
+ signal,
167
+ } = {}) {
168
+ if (!adapter || typeof adapter.stream !== 'function') return null;
169
+ if (typeof prompt !== 'string' || !prompt.trim()) return null;
170
+ if (!model) return null;
171
+
172
+ // Composite signal: caller's abort OR our timeout, whichever fires first.
173
+ const ctrl = new AbortController();
174
+ const onCallerAbort = () => ctrl.abort();
175
+ if (signal) {
176
+ if (signal.aborted) return null;
177
+ signal.addEventListener('abort', onCallerAbort, { once: true });
178
+ }
179
+ const timer = setTimeout(() => ctrl.abort(), QUICK_TIMEOUT_MS);
180
+
181
+ const system = buildQuickSystem({ language, vpDisplayName });
182
+ const messages = [{ role: 'user', content: prompt }];
183
+ const callArgs = {
184
+ model,
185
+ system,
186
+ messages,
187
+ maxTokens: QUICK_MAX_TOKENS,
188
+ signal: ctrl.signal,
189
+ };
190
+
191
+ try {
192
+ // Attempt 1.
193
+ let raw = '';
194
+ try {
195
+ raw = await callOnce(adapter, callArgs);
196
+ } catch (err) {
197
+ // Abort or external error — exit silently. Don't retry on abort.
198
+ if (err && (err.name === 'AbortError' || err.name === 'LLMAbortError')) return null;
199
+ // Otherwise fall through to retry.
200
+ raw = '';
201
+ }
202
+ let parsed = raw ? parseQuickJson(raw) : null;
203
+
204
+ if (!parsed) {
205
+ // Attempt 2 (retry once). Reuse the same args; adapter is stateless.
206
+ if (ctrl.signal.aborted) return null;
207
+ try {
208
+ const raw2 = await callOnce(adapter, callArgs);
209
+ parsed = raw2 ? parseQuickJson(raw2) : null;
210
+ } catch {
211
+ parsed = null;
212
+ }
213
+ }
214
+
215
+ return parsed;
216
+ } finally {
217
+ clearTimeout(timer);
218
+ if (signal) signal.removeEventListener('abort', onCallerAbort);
219
+ }
220
+ }
221
+
222
+ // Test seams — exported so tests can exercise pure helpers without
223
+ // spinning up an adapter.
224
+ export const __test = {
225
+ parseQuickJson,
226
+ buildQuickSystem,
227
+ QUICK_TIMEOUT_MS,
228
+ PREVIEW_MAX_CHARS,
229
+ };
@@ -51,6 +51,8 @@ import {
51
51
  compactHistory,
52
52
  trimSnapshotForBudget,
53
53
  } from './history-compact.js';
54
+ import { createFeatureArc } from './feature-arc.js';
55
+ import { getFeatureStore } from './tools/feature-tools.js';
54
56
 
55
57
  /** @type {import('./session.js').Session | null} */
56
58
  let session = null;
@@ -492,25 +494,27 @@ export async function __testResetVpState() {
492
494
  * Envelope fields: conversationId, groupId, vpId, turnId — the last two
493
495
  * let the frontend route incremental deltas to the correct per-VP message block.
494
496
  */
495
- function sendUnifyOutput(data, { groupId, vpId, turnId } = {}) {
497
+ function sendUnifyOutput(data, { groupId, vpId, turnId, featureId } = {}) {
496
498
  sendToServer({
497
499
  type: 'unify_output',
498
500
  conversationId: unifyConversationId,
499
501
  ...(groupId ? { groupId } : {}),
500
502
  ...(vpId ? { vpId } : {}),
501
503
  ...(turnId ? { turnId } : {}),
504
+ ...(featureId ? { featureId } : {}),
502
505
  data,
503
506
  });
504
507
  }
505
508
 
506
509
  /** Send a unify_output event (non-claude_output metadata). */
507
- function sendUnifyEvent(event, { groupId, vpId, turnId } = {}) {
510
+ function sendUnifyEvent(event, { groupId, vpId, turnId, featureId } = {}) {
508
511
  sendToServer({
509
512
  type: 'unify_output',
510
513
  conversationId: unifyConversationId,
511
514
  ...(groupId ? { groupId } : {}),
512
515
  ...(vpId ? { vpId } : {}),
513
516
  ...(turnId ? { turnId } : {}),
517
+ ...(featureId ? { featureId } : {}),
514
518
  event,
515
519
  });
516
520
  }
@@ -871,7 +875,16 @@ export function installUnifyRuntimeBridge(s) {
871
875
  */
872
876
  function handleEngineEvent(event, hctx) {
873
877
  hctx.resetQueryTimer();
874
- const envelope = { groupId: hctx.groupId, vpId: hctx.vpId, turnId: hctx.turnId };
878
+ // featureId may have just been published mid-turn by the FeatureArc
879
+ // (the arc's observeEvent runs before this dispatch); pull it fresh
880
+ // so the wire envelope tags every subsequent emit with the right id.
881
+ const featureId = typeof hctx.getFeatureId === 'function' ? hctx.getFeatureId() : null;
882
+ const envelope = {
883
+ groupId: hctx.groupId,
884
+ vpId: hctx.vpId,
885
+ turnId: hctx.turnId,
886
+ ...(featureId ? { featureId } : {}),
887
+ };
875
888
 
876
889
  switch (event.type) {
877
890
  case 'text_delta':
@@ -1572,6 +1585,11 @@ async function runVpTurn({ prompt, groupId, vpId, turnId, envelope: inboundEnvel
1572
1585
  if (!prompt?.trim()) return;
1573
1586
 
1574
1587
  const envelope = { groupId, vpId, turnId };
1588
+ // Arc is declared at outer-try scope so the catch / finally branches
1589
+ // below can call `arc.finalize({status:'aborted'|'error'})` after a
1590
+ // throw escaping the inner try. It's null until the inner try
1591
+ // populates it; all catch-side calls guard with `arc?.finalize?.`.
1592
+ let arc = null;
1575
1593
 
1576
1594
  try {
1577
1595
  if (session?.dreamScheduler) {
@@ -1611,6 +1629,63 @@ async function runVpTurn({ prompt, groupId, vpId, turnId, envelope: inboundEnvel
1611
1629
  groupId,
1612
1630
  envelope: inboundEnvelope,
1613
1631
  });
1632
+
1633
+ // ── Dual-track feature arc ──
1634
+ // Track A (quick-response) runs concurrently against the same
1635
+ // primary model with a non-looping single call; its preview is
1636
+ // surfaced to the user immediately via `quick_preview` so they
1637
+ // see *something* within ~1s. Three signals (Track A intent,
1638
+ // ≥3 engine loops, key tool call) auto-create a Feature record
1639
+ // and the wire envelope starts tagging emits with `featureId`,
1640
+ // letting the frontend fold subsequent messages into a pill.
1641
+ arc = createFeatureArc({
1642
+ adapter: session?.adapter || null,
1643
+ model: session?.config?.model || null,
1644
+ featureStore: getFeatureStore(),
1645
+ prompt,
1646
+ vpId,
1647
+ groupId: groupId || null,
1648
+ turnId,
1649
+ vpDisplayName: queryOpts?.vpPersona?.displayName || vpId,
1650
+ language: session?.config?.language || 'en',
1651
+ signal: vpAbort.signal,
1652
+ emit: {
1653
+ quickPreview: ({ intent, preview }) => {
1654
+ sendUnifyEvent({
1655
+ type: 'quick_preview',
1656
+ intent,
1657
+ preview,
1658
+ vpId,
1659
+ turnId,
1660
+ }, envelope);
1661
+ },
1662
+ featureStarted: ({ featureId, title, trigger, toolName }) => {
1663
+ sendUnifyEvent({
1664
+ type: 'feature_started',
1665
+ featureId,
1666
+ title,
1667
+ trigger, // 'quick' | 'turns' | 'tool'
1668
+ toolName: toolName || null,
1669
+ vpId,
1670
+ turnId,
1671
+ }, { ...envelope, featureId });
1672
+ },
1673
+ featureCompleted: ({ featureId, summary, status }) => {
1674
+ sendUnifyEvent({
1675
+ type: 'feature_completed',
1676
+ featureId,
1677
+ summary,
1678
+ status, // 'completed' | 'aborted' | 'error'
1679
+ vpId,
1680
+ turnId,
1681
+ }, { ...envelope, featureId });
1682
+ },
1683
+ },
1684
+ });
1685
+ // Fire-and-forget — Track A produces its preview / decision when
1686
+ // ready; the main engine loop must not be held back waiting for it.
1687
+ arc.startTrackA();
1688
+
1614
1689
  const handlerCtx = {
1615
1690
  assistantTextParts,
1616
1691
  toolCallsAccum,
@@ -1619,6 +1694,9 @@ async function runVpTurn({ prompt, groupId, vpId, turnId, envelope: inboundEnvel
1619
1694
  groupId,
1620
1695
  vpId,
1621
1696
  turnId,
1697
+ // Lets handleEngineEvent stamp the latest featureId on each
1698
+ // outgoing envelope; the arc may publish it mid-turn.
1699
+ getFeatureId: () => arc.getFeatureId(),
1622
1700
  };
1623
1701
  // Always trim the snapshot before passing to engine.query. This is
1624
1702
  // the second-line defense (history-compact only fires above 30K
@@ -1635,12 +1713,26 @@ async function runVpTurn({ prompt, groupId, vpId, turnId, envelope: inboundEnvel
1635
1713
  ...queryOpts,
1636
1714
  })) {
1637
1715
  resetQueryTimer();
1716
+ // Arc observes BEFORE dispatch so featureId (if just published)
1717
+ // is available when handleEngineEvent stamps the envelope.
1718
+ try { arc.observeEvent(event); } catch (err) {
1719
+ console.warn('[FeatureArc] observe failed:', err?.message || err);
1720
+ }
1638
1721
  handleEngineEvent(event, handlerCtx);
1639
1722
  }
1640
1723
 
1641
1724
  // Turn completed — atomically append this VP's output to shared history.
1642
1725
  appendTurnToHistory(prompt, assistantTextParts, toolCallsAccum, toolResultsAccum);
1643
1726
 
1727
+ // Close the arc: if a feature was opened during the turn, run the
1728
+ // summary call and write status='completed' back to FeatureStore.
1729
+ // Awaited so the `feature_completed` event reaches the frontend
1730
+ // before the final 'result' bubble (UI ordering matters: the pill
1731
+ // should reach its done state before the turn is marked done).
1732
+ try { await arc.finalize({ status: 'completed' }); } catch (err) {
1733
+ console.warn('[FeatureArc] finalize failed:', err?.message || err);
1734
+ }
1735
+
1644
1736
  sendUnifyOutput({
1645
1737
  type: 'assistant',
1646
1738
  message: { content: [] },
@@ -1655,6 +1747,9 @@ async function runVpTurn({ prompt, groupId, vpId, turnId, envelope: inboundEnvel
1655
1747
  } catch (err) {
1656
1748
  const isAbort = err && (err.name === 'AbortError' || err.name === 'LLMAbortError');
1657
1749
  if (isAbort) {
1750
+ // Best-effort close: mark the feature aborted so the frontend pill
1751
+ // settles into the right terminal state instead of staying active.
1752
+ try { await arc?.finalize?.({ status: 'aborted' }); } catch { /* ignore */ }
1658
1753
  sendUnifyOutput({
1659
1754
  type: 'result',
1660
1755
  result_text: '',
@@ -1664,6 +1759,7 @@ async function runVpTurn({ prompt, groupId, vpId, turnId, envelope: inboundEnvel
1664
1759
  }
1665
1760
 
1666
1761
  console.error('[Unify] query error:', err);
1762
+ try { await arc?.finalize?.({ status: 'error' }); } catch { /* ignore */ }
1667
1763
 
1668
1764
  if (isPermissionErrorMsg(err.message)) {
1669
1765
  if (!_permissionDiagnosticSent) {