@yeaft/webchat-agent 1.0.76 → 1.0.78
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/connection/buffer.js +2 -6
- package/connection/message-router.js +1 -64
- package/conversation.js +4 -32
- package/history.js +2 -2
- package/index.js +0 -6
- package/package.json +1 -1
- package/providers/claude-code.js +1 -1
- package/yeaft/attachments.js +2 -2
- package/yeaft/config.js +1 -1
- package/yeaft/conversation/persist.js +458 -112
- package/yeaft/conversation/search.js +51 -15
- package/yeaft/session.js +27 -18
- package/yeaft/web-bridge.js +27 -15
- package/crew/builtin-actions.js +0 -154
- package/crew/context-loader.js +0 -171
- package/crew/control.js +0 -444
- package/crew/human-interaction.js +0 -195
- package/crew/persistence.js +0 -295
- package/crew/role-management.js +0 -182
- package/crew/role-output.js +0 -461
- package/crew/role-query.js +0 -406
- package/crew/role-states.js +0 -180
- package/crew/routing-fallback.js +0 -64
- package/crew/routing-metrics.js +0 -215
- package/crew/routing.js +0 -951
- package/crew/session.js +0 -648
- package/crew/shared-dir.js +0 -266
- package/crew/task-files.js +0 -554
- package/crew/ui-messages.js +0 -274
- package/crew/worktree.js +0 -130
- package/crew-i18n.js +0 -579
- package/crew.js +0 -48
package/crew/routing.js
DELETED
|
@@ -1,951 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Crew — 路由解析与执行
|
|
3
|
-
* parseRoutes, executeRoute, buildRoutePrompt, dispatchToRole
|
|
4
|
-
*
|
|
5
|
-
* task-330c — Greedy-strip guard:
|
|
6
|
-
* ⚠️ ROUTE-block stripping lives in `parseRoutes()` ONLY. Callers that
|
|
7
|
-
* want the role's prose without ROUTE blocks must consume
|
|
8
|
-
* `parseRoutes(text).displayBody` — never run a second
|
|
9
|
-
* `text.replace(/---ROUTE---[\s\S]*$/g, '')` style strip on already
|
|
10
|
-
* parser-cleaned text. A second strip would (a) re-process text
|
|
11
|
-
* that no longer has ROUTE markers (no-op at best, miscut at worst),
|
|
12
|
-
* (b) reintroduce the greedy tail-eating bug fixed by task-328.
|
|
13
|
-
* The summary-fallback in role-output.js and the recent-routes
|
|
14
|
-
* injector below both honour this contract.
|
|
15
|
-
*/
|
|
16
|
-
import { join } from 'path';
|
|
17
|
-
import { sendCrewMessage, sendCrewOutput, sendStatusUpdate } from './ui-messages.js';
|
|
18
|
-
import { ensureTaskFile, appendTaskRecord, readTaskFile, updateKanban, readKanban, saveRoleWorkSummary } from './task-files.js';
|
|
19
|
-
import { createRoleQuery, clearRoleSessionId } from './role-query.js';
|
|
20
|
-
import { saveSessionMeta } from './persistence.js';
|
|
21
|
-
import { recordRoutingEvent } from './routing-metrics.js';
|
|
22
|
-
import ctx from '../context.js';
|
|
23
|
-
|
|
24
|
-
/** Format role label */
|
|
25
|
-
function roleLabel(r) {
|
|
26
|
-
return r.icon ? `${r.icon} ${r.displayName}` : r.displayName;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* task-330c — Smart truncate for recent-routes / history snippets.
|
|
31
|
-
*
|
|
32
|
-
* Cuts at a sentence/line boundary when possible to avoid mid-sentence
|
|
33
|
-
* truncation; falls back to a hard cut when no good boundary exists in
|
|
34
|
-
* the candidate window. Always appends a marker so downstream readers
|
|
35
|
-
* (LLM roles seeing recent-routes context) know the full text lives
|
|
36
|
-
* elsewhere (feature file).
|
|
37
|
-
*
|
|
38
|
-
* Boundary detection: looks for the last period (`.` `。` `!` `?` `!` `?`)
|
|
39
|
-
* or newline inside the window `[Math.floor(max*0.7), max)`. The 70% lower
|
|
40
|
-
* bound is a quality floor — we don't want to cut so early that we lose
|
|
41
|
-
* meaningful tail context just to hit a clean boundary.
|
|
42
|
-
*
|
|
43
|
-
* Idempotent: text already short enough is returned unchanged (no marker).
|
|
44
|
-
*
|
|
45
|
-
* @param {string} text — input string (may be any length)
|
|
46
|
-
* @param {number} max — maximum chars before truncation
|
|
47
|
-
* @returns {string} — original text or `<truncated>…(truncated, full in feature file)`
|
|
48
|
-
*/
|
|
49
|
-
const TRUNCATE_MARKER = '…(truncated, full in feature file)';
|
|
50
|
-
export function smartTruncate(text, max) {
|
|
51
|
-
if (typeof text !== 'string') return '';
|
|
52
|
-
if (!Number.isFinite(max) || max <= 0) return '';
|
|
53
|
-
if (text.length <= max) return text;
|
|
54
|
-
|
|
55
|
-
// Search window: prefer cuts in the last 30% of the limit.
|
|
56
|
-
const windowStart = Math.floor(max * 0.7);
|
|
57
|
-
const windowSlice = text.slice(windowStart, max);
|
|
58
|
-
// Last sentence boundary in window — period family OR newline.
|
|
59
|
-
// We accept a boundary char and cut AFTER it so the sentence stays whole.
|
|
60
|
-
const BOUNDARY_RE = /[.。!?!?\n]/g;
|
|
61
|
-
let bestIdx = -1;
|
|
62
|
-
let m;
|
|
63
|
-
while ((m = BOUNDARY_RE.exec(windowSlice)) !== null) {
|
|
64
|
-
bestIdx = m.index;
|
|
65
|
-
}
|
|
66
|
-
let cutEnd;
|
|
67
|
-
if (bestIdx !== -1) {
|
|
68
|
-
cutEnd = windowStart + bestIdx + 1; // include the boundary char itself
|
|
69
|
-
} else {
|
|
70
|
-
cutEnd = max; // no boundary in window → hard cut
|
|
71
|
-
}
|
|
72
|
-
// Trim trailing whitespace from the cut piece for cleaner output.
|
|
73
|
-
const head = text.slice(0, cutEnd).replace(/\s+$/, '');
|
|
74
|
-
return `${head}${TRUNCATE_MARKER}`;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Append text to content — works for both string and multimodal array content.
|
|
79
|
-
* For arrays, appends to the last text block (or adds a new one).
|
|
80
|
-
*/
|
|
81
|
-
function _appendTextToContent(content, text) {
|
|
82
|
-
if (typeof content === 'string') return content + text;
|
|
83
|
-
// Multimodal array: find last text block and append
|
|
84
|
-
for (let i = content.length - 1; i >= 0; i--) {
|
|
85
|
-
if (content[i].type === 'text') {
|
|
86
|
-
content[i].text += text;
|
|
87
|
-
return content;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
// No text block found — add one
|
|
91
|
-
content.push({ type: 'text', text });
|
|
92
|
-
return content;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* 从累积文本中解析所有 ROUTE 块(支持多 ROUTE + task 字段)。
|
|
97
|
-
*
|
|
98
|
-
* task-328 — Returns a structured result:
|
|
99
|
-
* { routes, displayBody }
|
|
100
|
-
*
|
|
101
|
-
* - `routes` — Array<{to, summary, taskId, taskTitle}> (same shape as before)
|
|
102
|
-
* - `displayBody` — original text MINUS the exact matched ROUTE ranges
|
|
103
|
-
* (including any surrounding ```fence``` that wraps the
|
|
104
|
-
* ROUTE block), preserving everything else verbatim.
|
|
105
|
-
*
|
|
106
|
-
* Backward compatibility: the returned object is also iterable as an array
|
|
107
|
-
* of routes for any legacy caller that does `for (const r of parseRoutes(...))`
|
|
108
|
-
* or `parseRoutes(...).length` — we attach `[Symbol.iterator]`, `length`, and
|
|
109
|
-
* numeric index properties mirroring `routes`. New callers should use the
|
|
110
|
-
* named `.routes` / `.displayBody` fields.
|
|
111
|
-
*
|
|
112
|
-
* Scope A — ROUTE parser tolerance (task-328):
|
|
113
|
-
* (1) Markdown fence-wrapped ROUTE blocks are still parsed AND the fence
|
|
114
|
-
* lines are stripped from displayBody (so the user doesn't see an
|
|
115
|
-
* empty ```…```).
|
|
116
|
-
* (2) END variants accepted: ---END_ROUTE--- / ---END ROUTE--- /
|
|
117
|
-
* ---END--- / ---END:--- / ---END-ROUTE--- / ---endroute---.
|
|
118
|
-
* (3) `to:` accepts: `to:` `to :` `to:` `TO:` with any casing.
|
|
119
|
-
* (4) Phase 2 soft-end is a STRUCTURAL signal (blank line + `---`, or
|
|
120
|
-
* `<kanban>` / `<recent-routes>` / `<task-context>`), NOT a bare blank
|
|
121
|
-
* line — so multi-paragraph summaries are not truncated.
|
|
122
|
-
* (5) Pre-pass: fenced code is MASKED (positions preserved) but a fence
|
|
123
|
-
* that contains `---ROUTE---` is NOT masked — the ROUTE inside the
|
|
124
|
-
* fence is the real one (matches what users write).
|
|
125
|
-
*
|
|
126
|
-
* Scope B — non-ROUTE body preservation (task-328):
|
|
127
|
-
* - `displayBody` = original minus the EXACT matched ROUTE ranges.
|
|
128
|
-
* No greedy "strip-to-EOF" anymore — post-ROUTE text survives.
|
|
129
|
-
*
|
|
130
|
-
* @param {string} text - Raw role output (may contain 0+ ROUTE blocks)
|
|
131
|
-
* @returns {{ routes: Array<{to:string,summary:string,taskId:string|null,taskTitle:string|null}>, displayBody: string } & Iterable}
|
|
132
|
-
*/
|
|
133
|
-
export function parseRoutes(text) {
|
|
134
|
-
const input = typeof text === 'string' ? text : '';
|
|
135
|
-
const routes = [];
|
|
136
|
-
// Exact character ranges (in ORIGINAL `input`) to remove from displayBody.
|
|
137
|
-
// Each entry: { start, end } — half-open, end exclusive.
|
|
138
|
-
const strippedRanges = [];
|
|
139
|
-
|
|
140
|
-
if (!input) return _wrapParseResult(routes, '', strippedRanges);
|
|
141
|
-
|
|
142
|
-
// ─── Pre-pass §2: mask fenced code WITHOUT stripping from original ──
|
|
143
|
-
// We build a boolean mask the same length as `input`. Fences are walked
|
|
144
|
-
// left-to-right. A fence containing `---ROUTE---` is SKIPPED (not masked)
|
|
145
|
-
// so the real ROUTE inside it can be parsed by Phase 1. Non-ROUTE fences
|
|
146
|
-
// are masked so any ```example``` won't pollute Phase 1/2/3 matching.
|
|
147
|
-
//
|
|
148
|
-
// We also remember the start/end of each "ROUTE-carrying fence" so the
|
|
149
|
-
// displayBody calculation can extend a ROUTE match to cover its fence
|
|
150
|
-
// lines — otherwise the user would see an empty ```…``` left behind.
|
|
151
|
-
const masked = _maskNonRouteFences(input);
|
|
152
|
-
const maskedText = masked.text; // original chars or ' ' for masked regions
|
|
153
|
-
const routeFences = masked.routeFences; // [{start, end, innerStart, innerEnd}, ...]
|
|
154
|
-
|
|
155
|
-
// ─── Phase 1: Standard ROUTE blocks (with closing marker) ─────
|
|
156
|
-
// Accept END variants: END_ROUTE | END ROUTE | END-ROUTE | END: | END | endroute
|
|
157
|
-
// Body capture uses negative lookahead to avoid crossing another opener.
|
|
158
|
-
// We run regex on `maskedText` so quoted examples (in non-ROUTE fences)
|
|
159
|
-
// don't match, but we use match.index to index into the ORIGINAL input
|
|
160
|
-
// when computing the strip range.
|
|
161
|
-
const closedRegex = /---\s*ROUTE\s*---\s*\r?\n((?:(?!---\s*ROUTE\s*---)[\s\S])*?)---\s*(?:END[_ \-]?ROUTE|ENDROUTE|END)\s*:?\s*---/gi;
|
|
162
|
-
let match;
|
|
163
|
-
while ((match = closedRegex.exec(maskedText)) !== null) {
|
|
164
|
-
const parsed = _parseRouteBlock(match[1]);
|
|
165
|
-
let rangeStart = match.index;
|
|
166
|
-
let rangeEnd = match.index + match[0].length;
|
|
167
|
-
// §5: if this match lives inside a ROUTE-carrying fence, extend the
|
|
168
|
-
// strip to cover the fence lines (so the UI doesn't see empty ```…```).
|
|
169
|
-
const fence = routeFences.find(f => rangeStart >= f.innerStart && rangeEnd <= f.innerEnd);
|
|
170
|
-
if (fence) { rangeStart = fence.start; rangeEnd = fence.end; }
|
|
171
|
-
strippedRanges.push({ start: rangeStart, end: rangeEnd });
|
|
172
|
-
if (parsed) routes.push(parsed);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// ─── Phase 2: Fallback — ROUTE block with no closing marker ──
|
|
176
|
-
// Soft-end uses a STRUCTURAL signal, not a bare blank line. A summary
|
|
177
|
-
// can span multiple paragraphs — it ends only when we see:
|
|
178
|
-
// (a) another ---ROUTE--- opener, or
|
|
179
|
-
// (b) EOF, or
|
|
180
|
-
// (c) a structural separator after ≥2 consecutive newlines:
|
|
181
|
-
// `\n\s*\n+---` (blank line + ---)
|
|
182
|
-
// `\n\s*\n+<(kanban|recent-routes|task-context|EOF)`
|
|
183
|
-
// (d) the 2048-char hard cap (safety valve for runaway blocks).
|
|
184
|
-
const openRegex = /---\s*ROUTE\s*---\s*\r?\n/gi;
|
|
185
|
-
while ((match = openRegex.exec(maskedText)) !== null) {
|
|
186
|
-
const openStart = match.index;
|
|
187
|
-
// Skip if this opener was already consumed by a Phase 1 match.
|
|
188
|
-
if (strippedRanges.some(r => openStart >= r.start && openStart < r.end)) continue;
|
|
189
|
-
|
|
190
|
-
const blockStart = openStart + match[0].length;
|
|
191
|
-
// (a) next opener?
|
|
192
|
-
const nextOpen = maskedText.indexOf('---ROUTE---', blockStart);
|
|
193
|
-
const hardEnd = nextOpen !== -1 ? nextOpen : maskedText.length;
|
|
194
|
-
const scope = maskedText.slice(blockStart, hardEnd);
|
|
195
|
-
|
|
196
|
-
// (c) structural cutoff — scan for the first structural signal after
|
|
197
|
-
// ≥2 consecutive newlines (blank line + structure).
|
|
198
|
-
const SOFT_END_RE = /\n[ \t]*\n+(?:---(?!\s*ROUTE)|<(?:kanban|recent-routes|task-context)\b)/;
|
|
199
|
-
const softMatch = scope.match(SOFT_END_RE);
|
|
200
|
-
let blockEnd = hardEnd;
|
|
201
|
-
if (softMatch && softMatch.index != null) {
|
|
202
|
-
blockEnd = blockStart + softMatch.index;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// (d) 2048-char hard cap — protect against runaway unclosed blocks.
|
|
206
|
-
const SUMMARY_CAP = 2048;
|
|
207
|
-
if (blockEnd - blockStart > SUMMARY_CAP) blockEnd = blockStart + SUMMARY_CAP;
|
|
208
|
-
|
|
209
|
-
const block = maskedText.slice(blockStart, blockEnd);
|
|
210
|
-
const parsed = _parseRouteBlock(block);
|
|
211
|
-
|
|
212
|
-
let rangeStart = openStart;
|
|
213
|
-
let rangeEnd = blockEnd;
|
|
214
|
-
// Extend to fence if wrapped.
|
|
215
|
-
const fence = routeFences.find(f => rangeStart >= f.innerStart && rangeEnd <= f.innerEnd);
|
|
216
|
-
if (fence) { rangeStart = fence.start; rangeEnd = fence.end; }
|
|
217
|
-
strippedRanges.push({ start: rangeStart, end: rangeEnd });
|
|
218
|
-
if (parsed) routes.push(parsed);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// ─── Phase 3: Shorthand — "ROUTE → target" / "ROUTE: target" ─
|
|
222
|
-
// Only matches a single line and only outside any ROUTE block. We also
|
|
223
|
-
// run this on maskedText so shorthand inside quoted fences is ignored.
|
|
224
|
-
const shorthandRegex = /^ROUTE\s*[→:]\s*(\S+)[,:\s]*(.*)$/gm;
|
|
225
|
-
while ((match = shorthandRegex.exec(maskedText)) !== null) {
|
|
226
|
-
const pos = match.index;
|
|
227
|
-
if (strippedRanges.some(r => pos >= r.start && pos < r.end)) continue;
|
|
228
|
-
|
|
229
|
-
// Also skip if inside an open ---ROUTE--- block (even unclosed).
|
|
230
|
-
const precedingText = maskedText.slice(0, pos);
|
|
231
|
-
const lastRouteOpen = precedingText.search(/---\s*ROUTE\s*---(?![\s\S]*---\s*ROUTE\s*---)/i);
|
|
232
|
-
const lastRouteOpenIdx = precedingText.lastIndexOf('---ROUTE---');
|
|
233
|
-
const lastRouteCloseIdx = Math.max(
|
|
234
|
-
precedingText.lastIndexOf('---END_ROUTE---'),
|
|
235
|
-
precedingText.lastIndexOf('---END ROUTE---'),
|
|
236
|
-
precedingText.lastIndexOf('---END-ROUTE---'),
|
|
237
|
-
precedingText.lastIndexOf('---END---'),
|
|
238
|
-
);
|
|
239
|
-
if (lastRouteOpenIdx > lastRouteCloseIdx) continue;
|
|
240
|
-
void lastRouteOpen; // silence unused
|
|
241
|
-
|
|
242
|
-
const toRaw = match[1].trim().toLowerCase().replace(/[,;:!?。,;:!?]+$/, '');
|
|
243
|
-
const summary = match[2] ? match[2].trim() : '[该角色未提供消息摘要]';
|
|
244
|
-
|
|
245
|
-
routes.push({ to: toRaw, toList: [toRaw], summary, taskId: null, taskTitle: null });
|
|
246
|
-
// Shorthand is a single line — strip the whole line.
|
|
247
|
-
const lineEnd = maskedText.indexOf('\n', pos);
|
|
248
|
-
strippedRanges.push({
|
|
249
|
-
start: pos,
|
|
250
|
-
end: lineEnd === -1 ? maskedText.length : lineEnd,
|
|
251
|
-
});
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const displayBody = _removeRanges(input, strippedRanges);
|
|
255
|
-
return _wrapParseResult(routes, displayBody, strippedRanges);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* Wrap the parse result in an object that is ALSO iterable as an array
|
|
260
|
-
* of routes (for legacy `for (const r of parseRoutes(x))` callers) and
|
|
261
|
-
* supports `.length` / numeric index. New fields: `.routes`, `.displayBody`.
|
|
262
|
-
* @private
|
|
263
|
-
*/
|
|
264
|
-
function _wrapParseResult(routes, displayBody, rangesForDebug) {
|
|
265
|
-
// Start from a real Array so `Array.isArray()` and iteration/indexing
|
|
266
|
-
// "just work". Decorate with named fields that new callers prefer.
|
|
267
|
-
const arr = routes.slice();
|
|
268
|
-
Object.defineProperty(arr, 'routes', { value: routes, enumerable: false });
|
|
269
|
-
Object.defineProperty(arr, 'displayBody', { value: displayBody, enumerable: false });
|
|
270
|
-
Object.defineProperty(arr, 'strippedRanges', { value: rangesForDebug, enumerable: false });
|
|
271
|
-
return arr;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/**
|
|
275
|
-
* §2 helper — build a mask of `input` that replaces non-ROUTE fenced
|
|
276
|
-
* code with spaces (length-preserving), and records the positions of
|
|
277
|
-
* fences that DO contain a ROUTE opener (so Phase 1/2 can extend their
|
|
278
|
-
* strip range to swallow the fence lines).
|
|
279
|
-
*
|
|
280
|
-
* @param {string} input
|
|
281
|
-
* @returns {{ text: string, routeFences: Array<{start:number,end:number,innerStart:number,innerEnd:number}> }}
|
|
282
|
-
* @private
|
|
283
|
-
*/
|
|
284
|
-
function _maskNonRouteFences(input) {
|
|
285
|
-
const FENCE_RE = /```[^\n]*\n([\s\S]*?)```/g;
|
|
286
|
-
let m;
|
|
287
|
-
let out = '';
|
|
288
|
-
let lastIdx = 0;
|
|
289
|
-
const routeFences = [];
|
|
290
|
-
while ((m = FENCE_RE.exec(input)) !== null) {
|
|
291
|
-
const fenceStart = m.index;
|
|
292
|
-
const fenceEnd = m.index + m[0].length;
|
|
293
|
-
const innerStart = fenceStart + m[0].indexOf('\n') + 1;
|
|
294
|
-
const innerEnd = fenceEnd - 3; // strip trailing ```
|
|
295
|
-
const fenceContent = m[1];
|
|
296
|
-
const hasRoute = /---\s*ROUTE\s*---/i.test(fenceContent);
|
|
297
|
-
// Copy unchanged text up to fence start
|
|
298
|
-
out += input.slice(lastIdx, fenceStart);
|
|
299
|
-
if (hasRoute) {
|
|
300
|
-
// Keep the fence content intact so Phase 1 sees the ROUTE; record
|
|
301
|
-
// the fence range for the displayBody extender.
|
|
302
|
-
out += input.slice(fenceStart, fenceEnd);
|
|
303
|
-
routeFences.push({ start: fenceStart, end: fenceEnd, innerStart, innerEnd });
|
|
304
|
-
} else {
|
|
305
|
-
// Mask entire fence (including markers) with spaces of equal length
|
|
306
|
-
// so positions line up with the original string.
|
|
307
|
-
out += ' '.repeat(fenceEnd - fenceStart);
|
|
308
|
-
}
|
|
309
|
-
lastIdx = fenceEnd;
|
|
310
|
-
}
|
|
311
|
-
out += input.slice(lastIdx);
|
|
312
|
-
return { text: out, routeFences };
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* Remove a list of (possibly overlapping) character ranges from `input`.
|
|
317
|
-
* Also trims leading/trailing whitespace from the resulting blocks so the
|
|
318
|
-
* displayBody doesn't keep lonely blank lines where a ROUTE used to be.
|
|
319
|
-
*
|
|
320
|
-
* @param {string} input
|
|
321
|
-
* @param {Array<{start:number, end:number}>} ranges
|
|
322
|
-
* @returns {string}
|
|
323
|
-
* @private
|
|
324
|
-
*/
|
|
325
|
-
function _removeRanges(input, ranges) {
|
|
326
|
-
if (!ranges || ranges.length === 0) return input;
|
|
327
|
-
// Merge overlapping/adjacent ranges.
|
|
328
|
-
const sorted = ranges.slice().sort((a, b) => a.start - b.start);
|
|
329
|
-
const merged = [sorted[0]];
|
|
330
|
-
for (let i = 1; i < sorted.length; i++) {
|
|
331
|
-
const prev = merged[merged.length - 1];
|
|
332
|
-
const cur = sorted[i];
|
|
333
|
-
if (cur.start <= prev.end) prev.end = Math.max(prev.end, cur.end);
|
|
334
|
-
else merged.push({ ...cur });
|
|
335
|
-
}
|
|
336
|
-
// Build output by keeping the gaps between merged ranges.
|
|
337
|
-
let out = '';
|
|
338
|
-
let cursor = 0;
|
|
339
|
-
for (const r of merged) {
|
|
340
|
-
out += input.slice(cursor, r.start);
|
|
341
|
-
cursor = r.end;
|
|
342
|
-
}
|
|
343
|
-
out += input.slice(cursor);
|
|
344
|
-
// Collapse 3+ consecutive newlines (left by a removal) to a double newline.
|
|
345
|
-
out = out.replace(/\n{3,}/g, '\n\n');
|
|
346
|
-
return out.trim();
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
/**
|
|
350
|
-
* Parse fields from a ROUTE block body (the content between ---ROUTE--- and ---END_ROUTE---).
|
|
351
|
-
*
|
|
352
|
-
* task-335 — multi-target `to:` support. The `to:` line is split on
|
|
353
|
-
* common multi-target separators (`,`, `+`, `、`, `;`, `;`, ` and `) so
|
|
354
|
-
* `to: rev-1, rev-3` / `to: rev-1 + rev-3` / `to: rev-1、rev-3` all
|
|
355
|
-
* resolve to two distinct targets. Each target is normalised the same
|
|
356
|
-
* way single-target was normalised before (strip parenthetical notes,
|
|
357
|
-
* trailing punctuation, lowercase).
|
|
358
|
-
*
|
|
359
|
-
* Backward-compat shape: `route.to` is still a STRING (the first
|
|
360
|
-
* target) so the 20+ existing tests that assert `route.to === 'dev-1'`
|
|
361
|
-
* keep working. `route.toList` is the new authoritative list and is
|
|
362
|
-
* always at least one element. `executeRoute` consumes `toList`.
|
|
363
|
-
*
|
|
364
|
-
* @param {string} block — raw block content
|
|
365
|
-
* @returns {{ to: string, toList: string[], summary: string, taskId: string|null, taskTitle: string|null } | null}
|
|
366
|
-
*/
|
|
367
|
-
function _parseRouteBlock(block) {
|
|
368
|
-
// task-328 §3: tolerate Chinese full-width colon (`to:` / `task:` / `summary:`)
|
|
369
|
-
// and stray whitespace before the colon (`to :`). All field separators accept
|
|
370
|
-
// either ASCII `:` or Chinese `:`.
|
|
371
|
-
const toMatch = block.match(/to\s*[::]\s*(.+)/i);
|
|
372
|
-
if (!toMatch) return null;
|
|
373
|
-
|
|
374
|
-
const toLine = toMatch[1].trim();
|
|
375
|
-
// task-335: split multi-target `to:` lines. Separators recognised:
|
|
376
|
-
// ASCII comma `,`, ASCII plus `+`, ASCII semicolon `;`, ` and `
|
|
377
|
-
// Chinese full-width comma `,`, ideographic comma `、`, full-width
|
|
378
|
-
// semicolon `;`, slash `/`. Whitespace around separators is fine.
|
|
379
|
-
const MULTI_TARGET_SEP = /\s*(?:,|\+|;|、|,|;|\/|\s+and\s+)\s*/i;
|
|
380
|
-
const rawTargets = toLine.split(MULTI_TARGET_SEP);
|
|
381
|
-
|
|
382
|
-
const cleaned = [];
|
|
383
|
-
const seen = new Set();
|
|
384
|
-
for (const raw of rawTargets) {
|
|
385
|
-
const trimmed = raw.trim().toLowerCase();
|
|
386
|
-
if (!trimmed) continue;
|
|
387
|
-
// ★ Clean a single target value: take only the first word (strip
|
|
388
|
-
// parenthetical notes, trailing punctuation).
|
|
389
|
-
// e.g. "pm (决策者)" → "pm", "dev-1 // main dev" → "dev-1"
|
|
390
|
-
const oneWord = trimmed.split(/[\s(]/)[0].replace(/[,;:!?。,;:!?]+$/, '');
|
|
391
|
-
if (!oneWord) continue;
|
|
392
|
-
if (seen.has(oneWord)) continue;
|
|
393
|
-
seen.add(oneWord);
|
|
394
|
-
cleaned.push(oneWord);
|
|
395
|
-
}
|
|
396
|
-
if (cleaned.length === 0) return null;
|
|
397
|
-
|
|
398
|
-
// ★ summary: match until next known field (task:/taskTitle:) or end of block.
|
|
399
|
-
// Field separator accepts ASCII `:` or Chinese `:`.
|
|
400
|
-
const summaryMatch = block.match(/summary\s*[::]\s*([\s\S]+?)(?=\n\s*(?:task|taskTitle)\s*[::]|$)/i);
|
|
401
|
-
const taskMatch = block.match(/^task\s*[::]\s*(.+)/im);
|
|
402
|
-
const taskTitleMatch = block.match(/^taskTitle\s*[::]\s*(.+)/im);
|
|
403
|
-
|
|
404
|
-
let summary = summaryMatch ? summaryMatch[1].trim() : '';
|
|
405
|
-
|
|
406
|
-
// ★ Bare-body fallback — PM / humans often omit the `summary:` label and
|
|
407
|
-
// just write the message as free text AFTER the known fields. Collect
|
|
408
|
-
// everything that is NOT a recognised field line as the body.
|
|
409
|
-
if (!summary) {
|
|
410
|
-
const KNOWN_FIELD = /^\s*(?:to|task|taskTitle|summary)\s*[::]/i;
|
|
411
|
-
const bare = block
|
|
412
|
-
.split(/\r?\n/)
|
|
413
|
-
.filter(line => !KNOWN_FIELD.test(line))
|
|
414
|
-
.join('\n')
|
|
415
|
-
.trim();
|
|
416
|
-
if (bare) summary = bare;
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
if (!summary) {
|
|
420
|
-
summary = '[该角色未提供消息摘要]';
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
return {
|
|
424
|
-
to: cleaned[0],
|
|
425
|
-
toList: cleaned,
|
|
426
|
-
summary,
|
|
427
|
-
taskId: taskMatch ? taskMatch[1].trim() : null,
|
|
428
|
-
taskTitle: taskTitleMatch ? taskTitleMatch[1].trim() : null
|
|
429
|
-
};
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
/**
|
|
433
|
-
* Resolve a ROUTE `to` value to an actual role name in the session.
|
|
434
|
-
*
|
|
435
|
-
* Resolution order:
|
|
436
|
-
* 1. Exact match: `to` matches a role name directly (e.g. "dev-1")
|
|
437
|
-
* 2. roleType match: `to` matches a role's roleType (e.g. "developer" → "dev-1")
|
|
438
|
-
* 3. Short prefix match: `to` matches the SHORT_PREFIX of a roleType (e.g. "dev" → "dev-1")
|
|
439
|
-
* 4. Same-group dispatch: if sender is in a multi-instance group (e.g. dev-1),
|
|
440
|
-
* and `to` matches the roleType/prefix of another group (e.g. "reviewer"),
|
|
441
|
-
* route to the instance with matching groupIndex (e.g. rev-1)
|
|
442
|
-
*
|
|
443
|
-
* For multi-instance matches (2/3), prefer the instance with the same groupIndex
|
|
444
|
-
* as the sender. Falls back to the first instance if no groupIndex match.
|
|
445
|
-
*
|
|
446
|
-
* @param {string} to - raw route target from ROUTE block
|
|
447
|
-
* @param {object} session - crew session
|
|
448
|
-
* @param {string} [fromRole] - sending role name (for groupIndex matching)
|
|
449
|
-
* @returns {string|null} resolved role name, or null if unresolvable
|
|
450
|
-
*/
|
|
451
|
-
export function resolveRoleName(to, session, fromRole) {
|
|
452
|
-
// 1. Exact match
|
|
453
|
-
if (session.roles.has(to)) return to;
|
|
454
|
-
|
|
455
|
-
// Build candidate list by roleType and short prefix
|
|
456
|
-
const fromRoleConfig = fromRole ? session.roles.get(fromRole) : null;
|
|
457
|
-
const fromGroupIndex = fromRoleConfig?.groupIndex || 0;
|
|
458
|
-
|
|
459
|
-
let candidates = [];
|
|
460
|
-
|
|
461
|
-
for (const [name, config] of session.roles) {
|
|
462
|
-
// 2. roleType match (e.g. "developer" → dev-1, dev-2, dev-3)
|
|
463
|
-
if (config.roleType === to) {
|
|
464
|
-
candidates.push({ name, groupIndex: config.groupIndex || 0 });
|
|
465
|
-
}
|
|
466
|
-
// 3. Short prefix match (e.g. "dev" → developer roleType → dev-1)
|
|
467
|
-
// Match if the role name starts with `to-` (e.g. "dev" matches "dev-1", "dev-2")
|
|
468
|
-
else if (name.startsWith(to + '-') && /^\d+$/.test(name.slice(to.length + 1))) {
|
|
469
|
-
candidates.push({ name, groupIndex: config.groupIndex || 0 });
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
// 4. displayName match (e.g. "乔布斯" → pm)
|
|
474
|
-
if (candidates.length === 0) {
|
|
475
|
-
for (const [name, config] of session.roles) {
|
|
476
|
-
if (config.displayName && config.displayName.toLowerCase() === to) {
|
|
477
|
-
candidates.push({ name, groupIndex: config.groupIndex || 0 });
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
// 5. name-displayName compound match (e.g. "pm-乔布斯" → pm)
|
|
483
|
-
// Claude sometimes concatenates role name + display name with a hyphen
|
|
484
|
-
if (candidates.length === 0) {
|
|
485
|
-
for (const [name, config] of session.roles) {
|
|
486
|
-
if (to.startsWith(name + '-') && to.length > name.length + 1) {
|
|
487
|
-
candidates.push({ name, groupIndex: config.groupIndex || 0 });
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
if (candidates.length === 0) return null;
|
|
493
|
-
|
|
494
|
-
// 6. Prefer same groupIndex as sender
|
|
495
|
-
if (fromGroupIndex > 0) {
|
|
496
|
-
const sameGroup = candidates.find(c => c.groupIndex === fromGroupIndex);
|
|
497
|
-
if (sameGroup) return sameGroup.name;
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
// Fall back to first candidate
|
|
501
|
-
return candidates[0].name;
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
/**
|
|
505
|
-
* 执行路由
|
|
506
|
-
*
|
|
507
|
-
* task-335 — multi-target fan-out. `route.toList` (string[]) is the
|
|
508
|
-
* authoritative list of targets; `route.to` is kept as the first target
|
|
509
|
-
* for backward-compat with downstream consumers (kanban / sendCrewOutput
|
|
510
|
-
* still see a single primary `to`). Each target is dispatched as if it
|
|
511
|
-
* were its own ROUTE: self-route reject is per-target (one bad target
|
|
512
|
-
* does not kill the whole fan-out), unknown-target fallback is
|
|
513
|
-
* per-target, dispatch + kanban update + sendCrewOutput happen
|
|
514
|
-
* per-target.
|
|
515
|
-
*
|
|
516
|
-
* Side-effects done ONCE per call (not per target):
|
|
517
|
-
* - taskId fallback resolution
|
|
518
|
-
* - auto-resume from paused/stopped
|
|
519
|
-
* - state-stopped metric
|
|
520
|
-
*
|
|
521
|
-
* @param {Array<{mimeType, data}>} [turnImages] - auto-attached images from the turn (max 3)
|
|
522
|
-
*/
|
|
523
|
-
export async function executeRoute(session, fromRole, route, turnImages = []) {
|
|
524
|
-
let { summary, taskId, taskTitle } = route;
|
|
525
|
-
|
|
526
|
-
// task-335: normalise to a list. Accept legacy callers that still pass
|
|
527
|
-
// `to: 'pm'` (string) without `toList`.
|
|
528
|
-
let toList = Array.isArray(route.toList) && route.toList.length > 0
|
|
529
|
-
? route.toList.slice()
|
|
530
|
-
: (typeof route.to === 'string' ? [route.to] : []);
|
|
531
|
-
if (toList.length === 0) {
|
|
532
|
-
console.warn(`[Crew] executeRoute called with no targets (fromRole=${fromRole})`);
|
|
533
|
-
return;
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
// task-330b §B item 1: state-stopped metric — message arrived while
|
|
537
|
-
// session was paused/stopped. Behaviour (auto-resume) is unchanged for
|
|
538
|
-
// backward compat; this is observer-only. Recorded ONCE per fan-out.
|
|
539
|
-
if (session.status === 'paused' || session.status === 'stopped') {
|
|
540
|
-
recordRoutingEvent(session, 'state-stopped', {
|
|
541
|
-
fromRole,
|
|
542
|
-
toRole: toList.join(','),
|
|
543
|
-
taskId: taskId || null,
|
|
544
|
-
note: `session.status=${session.status} at executeRoute entry`,
|
|
545
|
-
});
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
// Auto-resume: paused/stopped → running (route execution means work should continue)
|
|
549
|
-
if (session.status === 'paused' || session.status === 'stopped') {
|
|
550
|
-
console.log(`[Crew] Auto-resuming session from ${session.status} to running (route from ${fromRole} to ${toList.join(',')})`);
|
|
551
|
-
session.status = 'running';
|
|
552
|
-
sendStatusUpdate(session);
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
// ─── task-321: taskId fallback chain ─────────────────────────────
|
|
556
|
-
// When a ROUTE omits `task:` (shorthand, bare dispatch, human messages,
|
|
557
|
-
// PM forgetting the field), fall back to:
|
|
558
|
-
// (a) the sender's currentTask.taskId
|
|
559
|
-
// (b) the most recent non-system entry in session.messageHistory
|
|
560
|
-
// This keeps prev-* / designer / architect / shorthand messages from
|
|
561
|
-
// becoming taskId=null orphans that never appear on any feature card.
|
|
562
|
-
// Done ONCE per fan-out — all targets share the same inferred taskId.
|
|
563
|
-
if (!taskId) {
|
|
564
|
-
const fromRoleState = session.roleStates?.get(fromRole);
|
|
565
|
-
if (fromRoleState?.currentTask?.taskId) {
|
|
566
|
-
taskId = fromRoleState.currentTask.taskId;
|
|
567
|
-
taskTitle = taskTitle || fromRoleState.currentTask.taskTitle || null;
|
|
568
|
-
} else if (Array.isArray(session.messageHistory) && session.messageHistory.length > 0) {
|
|
569
|
-
for (let i = session.messageHistory.length - 1; i >= 0; i--) {
|
|
570
|
-
const h = session.messageHistory[i];
|
|
571
|
-
if (h && h.from !== 'system' && h.taskId) {
|
|
572
|
-
taskId = h.taskId;
|
|
573
|
-
break;
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
// Mirror the fallback back into the route object so downstream
|
|
578
|
-
// consumers (dispatchToRole / sendCrewOutput) see the inferred id.
|
|
579
|
-
if (taskId) {
|
|
580
|
-
route.taskId = taskId;
|
|
581
|
-
if (taskTitle) route.taskTitle = taskTitle;
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
// ─── Fan-out over targets ─────────────────────────────────────────
|
|
586
|
-
for (const to of toList) {
|
|
587
|
-
await _dispatchOneTarget(session, fromRole, to, summary, taskId, taskTitle, toList.length, turnImages);
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
/**
|
|
592
|
-
* task-335 helper — dispatch ONE resolved target. Carries the per-target
|
|
593
|
-
* self-route reject + unknown-target fallback + kanban + UI emit +
|
|
594
|
-
* actual dispatchToRole. Failures on one target do NOT abort the rest.
|
|
595
|
-
*
|
|
596
|
-
* @param {number} batchSize — how many targets in the parent fan-out
|
|
597
|
-
* (used for log clarity; not behavioural)
|
|
598
|
-
* @private
|
|
599
|
-
*/
|
|
600
|
-
async function _dispatchOneTarget(session, fromRole, to, summary, taskId, taskTitle, batchSize, turnImages) {
|
|
601
|
-
// ─── task-330a §A + task-330b §B: self-route hard-reject + metric ───
|
|
602
|
-
// 福勒 Final Spec §A — `route.to` 等同于发送方时直接拒绝,不消费 turn、
|
|
603
|
-
// 不写 kanban、不 dispatch、不 round++(round 已由 role-output 计数)。
|
|
604
|
-
// 解析顺序:先尝试用 resolveRoleName 还原 alias(pm/dev/displayName/
|
|
605
|
-
// pm-乔布斯 等),命中即比较;未命中则退回原始字符串大小写不敏感比较。
|
|
606
|
-
// 拒绝时:先写 330b 的 routing-metrics.json 持久化 metric(observer 路径),
|
|
607
|
-
// 再 emit 330a 的 sendCrewMessage UI 卡片,最后 return(不消费 turn)。
|
|
608
|
-
// alias self-route 漏记 metric 已记入 PM backlog 作 follow-up(330b 的
|
|
609
|
-
// raw 比较 `to === fromRole` 仅命中字面相同的情况;alias 形式由 330a
|
|
610
|
-
// 的 isSelf 兜底,但 330b 的 raw 检查保留为快速路径 + 兼容)。
|
|
611
|
-
//
|
|
612
|
-
// task-335: now per-target. A self-route to one target in a multi-
|
|
613
|
-
// target ROUTE only rejects THAT target; the other targets fan out
|
|
614
|
-
// normally.
|
|
615
|
-
if (to !== 'human') {
|
|
616
|
-
const resolvedSelfCheck = resolveRoleName(to, session, fromRole);
|
|
617
|
-
const isSelf = resolvedSelfCheck === fromRole
|
|
618
|
-
|| (typeof to === 'string' && to.toLowerCase() === String(fromRole).toLowerCase());
|
|
619
|
-
if (isSelf) {
|
|
620
|
-
console.warn(`[Crew] Self-route rejected: ${fromRole} → ${to} (taskId=${taskId || '-'}, batch=${batchSize})`);
|
|
621
|
-
recordRoutingEvent(session, 'self-route', {
|
|
622
|
-
fromRole,
|
|
623
|
-
toRole: to,
|
|
624
|
-
taskId: taskId || null,
|
|
625
|
-
note: 'route.to === fromRole at executeRoute entry (rejected by §A)',
|
|
626
|
-
});
|
|
627
|
-
try {
|
|
628
|
-
sendCrewMessage({
|
|
629
|
-
type: 'routing-metrics',
|
|
630
|
-
sessionId: session.id,
|
|
631
|
-
event: 'route_rejected',
|
|
632
|
-
reason: 'self-route',
|
|
633
|
-
fromRole,
|
|
634
|
-
to,
|
|
635
|
-
taskId: taskId || null,
|
|
636
|
-
timestamp: Date.now(),
|
|
637
|
-
});
|
|
638
|
-
} catch (e) {
|
|
639
|
-
console.warn('[Crew] Failed to emit routing-metrics:', e.message);
|
|
640
|
-
}
|
|
641
|
-
try {
|
|
642
|
-
sendCrewMessage({
|
|
643
|
-
type: 'crew_route_rejected',
|
|
644
|
-
sessionId: session.id,
|
|
645
|
-
fromRole,
|
|
646
|
-
to,
|
|
647
|
-
reason: 'self-route',
|
|
648
|
-
message: `自路由被拒绝:${fromRole} 不能给自己发消息。请改用 task_close(taskId, summary) 关闭任务,或 role_standby(role) 进入待命,或选择其他角色作为 ROUTE 目标。`,
|
|
649
|
-
taskId: taskId || null,
|
|
650
|
-
});
|
|
651
|
-
} catch (e) {
|
|
652
|
-
console.warn('[Crew] Failed to emit crew_route_rejected:', e.message);
|
|
653
|
-
}
|
|
654
|
-
// Do NOT decrement session.round — role-output.js already incremented
|
|
655
|
-
// it for this whole turn batch; one rejected route doesn't undo the
|
|
656
|
-
// turn (other routes/targets in the same batch may still be valid).
|
|
657
|
-
return;
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
// Task 文件自动管理(fire-and-forget) — per-target so each target gets
|
|
662
|
-
// a kanban entry assigned to it.
|
|
663
|
-
if (taskId && summary) {
|
|
664
|
-
const fromRoleConfig = session.roles.get(fromRole);
|
|
665
|
-
// task-321: Auto-create feature file even when a non-PM role is the
|
|
666
|
-
// first to mention the taskId. Any role carrying a taskId (PM, devs,
|
|
667
|
-
// reviewers, designer, architect) now triggers creation — not just PM
|
|
668
|
-
// with explicit taskTitle. appendTaskRecord itself also creates the
|
|
669
|
-
// file if missing, so this is a best-effort fast path.
|
|
670
|
-
const effectiveTitle = taskTitle
|
|
671
|
-
|| session.features?.get(taskId)?.taskTitle
|
|
672
|
-
|| null;
|
|
673
|
-
if (effectiveTitle && to !== 'human') {
|
|
674
|
-
ensureTaskFile(session, taskId, effectiveTitle, fromRoleConfig?.isDecisionMaker ? to : fromRole, summary)
|
|
675
|
-
.catch(e => console.warn(`[Crew] Failed to create task file ${taskId}:`, e.message));
|
|
676
|
-
}
|
|
677
|
-
appendTaskRecord(session, taskId, fromRole, summary, { taskTitle: effectiveTitle })
|
|
678
|
-
.catch(e => console.warn(`[Crew] Failed to append task record ${taskId}:`, e.message));
|
|
679
|
-
|
|
680
|
-
// 更新工作看板:推断状态
|
|
681
|
-
const { getMessages } = await import('../crew-i18n.js');
|
|
682
|
-
const m = getMessages(session.language || 'zh-CN');
|
|
683
|
-
// ★ Use resolveRoleName for kanban status lookup too
|
|
684
|
-
const resolvedKanbanTo = resolveRoleName(to, session, fromRole);
|
|
685
|
-
const toRoleConfig = session.roles.get(resolvedKanbanTo || to);
|
|
686
|
-
let status = m.kanbanStatusDev;
|
|
687
|
-
if (toRoleConfig) {
|
|
688
|
-
switch (toRoleConfig.roleType) {
|
|
689
|
-
case 'reviewer': status = m.kanbanStatusReview; break;
|
|
690
|
-
case 'product-reviewer': status = m.kanbanStatusProductReview; break;
|
|
691
|
-
default:
|
|
692
|
-
if (toRoleConfig.isDecisionMaker) status = m.kanbanStatusDecision;
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
updateKanban(session, {
|
|
696
|
-
taskId, taskTitle, assignee: resolvedKanbanTo || to,
|
|
697
|
-
status, summary
|
|
698
|
-
}).catch(e => console.warn(`[Crew] Failed to update kanban:`, e.message));
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
// 发送路由消息(UI 显示) — per-target so the transcript shows one
|
|
702
|
-
// route card per fan-out leg, mirroring how single-target ROUTEs render.
|
|
703
|
-
sendCrewOutput(session, fromRole, 'route', null, {
|
|
704
|
-
routeTo: to, routeSummary: summary,
|
|
705
|
-
taskId: taskId || undefined,
|
|
706
|
-
taskTitle: taskTitle || undefined,
|
|
707
|
-
// ★ Auto-attach turn images (base64) — server will cache and convert to fileId/previewToken
|
|
708
|
-
routeImages: turnImages.length > 0 ? turnImages.map(img => ({
|
|
709
|
-
mimeType: img.mimeType,
|
|
710
|
-
data: img.data
|
|
711
|
-
})) : undefined
|
|
712
|
-
});
|
|
713
|
-
|
|
714
|
-
// 路由到 human
|
|
715
|
-
if (to === 'human') {
|
|
716
|
-
session.status = 'waiting_human';
|
|
717
|
-
session.waitingHumanContext = {
|
|
718
|
-
fromRole,
|
|
719
|
-
reason: 'requested',
|
|
720
|
-
message: summary
|
|
721
|
-
};
|
|
722
|
-
sendCrewMessage({
|
|
723
|
-
type: 'crew_human_needed',
|
|
724
|
-
sessionId: session.id,
|
|
725
|
-
fromRole,
|
|
726
|
-
reason: 'requested',
|
|
727
|
-
message: summary
|
|
728
|
-
});
|
|
729
|
-
sendStatusUpdate(session);
|
|
730
|
-
// Status changed to waiting_human — persist
|
|
731
|
-
saveSessionMeta(session).catch(e => console.warn('[Crew] Failed to save after →human:', e.message));
|
|
732
|
-
return;
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
// 路由到指定角色
|
|
736
|
-
const resolvedTo = resolveRoleName(to, session, fromRole);
|
|
737
|
-
if (resolvedTo) {
|
|
738
|
-
if (session.humanMessageQueue.length > 0) {
|
|
739
|
-
const { processHumanQueue } = await import('./human-interaction.js');
|
|
740
|
-
await processHumanQueue(session);
|
|
741
|
-
} else {
|
|
742
|
-
const taskPrompt = buildRoutePrompt(fromRole, summary, session, turnImages);
|
|
743
|
-
await dispatchToRole(session, resolvedTo, taskPrompt, fromRole, taskId, taskTitle);
|
|
744
|
-
}
|
|
745
|
-
} else {
|
|
746
|
-
const availableRoles = Array.from(session.roles.keys()).join(', ');
|
|
747
|
-
console.warn(`[Crew] Unknown route target: ${to} (available: ${availableRoles})`);
|
|
748
|
-
const errorMsg = `路由目标 "${to}" 不存在。可用角色: ${availableRoles}\n来自 ${fromRole} 的消息: ${summary}`;
|
|
749
|
-
await dispatchToRole(session, session.decisionMaker, errorMsg, 'system');
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
/**
|
|
754
|
-
* 构建路由转发的 prompt(支持多模态 — 自动附加 turn 截图)
|
|
755
|
-
* @param {Array<{mimeType, data}>} [turnImages] - auto-attached images
|
|
756
|
-
* @returns {string|Array} text string, or multimodal content array when images present
|
|
757
|
-
*/
|
|
758
|
-
export function buildRoutePrompt(fromRole, summary, session, turnImages = []) {
|
|
759
|
-
const fromRoleConfig = session.roles.get(fromRole);
|
|
760
|
-
const fromName = fromRoleConfig ? roleLabel(fromRoleConfig) : fromRole;
|
|
761
|
-
const text = `来自 ${fromName} 的消息:\n${summary}\n\n请开始你的工作。完成后通过 ROUTE 块传递给下一个角色。`;
|
|
762
|
-
|
|
763
|
-
if (turnImages.length === 0) return text;
|
|
764
|
-
|
|
765
|
-
// Build multimodal content: images first, then text
|
|
766
|
-
const blocks = [];
|
|
767
|
-
for (const img of turnImages) {
|
|
768
|
-
blocks.push({
|
|
769
|
-
type: 'image',
|
|
770
|
-
source: { type: 'base64', media_type: img.mimeType, data: img.data }
|
|
771
|
-
});
|
|
772
|
-
}
|
|
773
|
-
blocks.push({ type: 'text', text });
|
|
774
|
-
return blocks;
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
/**
|
|
778
|
-
* 向角色发送消息
|
|
779
|
-
*/
|
|
780
|
-
export async function dispatchToRole(session, roleName, content, fromSource, taskId, taskTitle) {
|
|
781
|
-
// Only block during initialization (roles not ready yet)
|
|
782
|
-
if (session.status === 'initializing') {
|
|
783
|
-
console.log(`[Crew] Session initializing, skipping dispatch to ${roleName}`);
|
|
784
|
-
return;
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
// Auto-resume: paused/stopped → running (dispatch means work should continue)
|
|
788
|
-
if (session.status === 'paused' || session.status === 'stopped') {
|
|
789
|
-
console.log(`[Crew] Auto-resuming session from ${session.status} to running (dispatch to ${roleName})`);
|
|
790
|
-
session.status = 'running';
|
|
791
|
-
sendStatusUpdate(session);
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
let roleState = session.roleStates.get(roleName);
|
|
795
|
-
|
|
796
|
-
// 如果角色没有 query 实例,创建一个(支持 resume)
|
|
797
|
-
if (!roleState || !roleState.query || !roleState.inputStream) {
|
|
798
|
-
roleState = await createRoleQuery(session, roleName);
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
// 设置 task
|
|
802
|
-
// task-321: keep currentTask sticky. A new taskId updates it; a dispatch
|
|
803
|
-
// without taskId preserves the previous currentTask so subsequent
|
|
804
|
-
// sendCrewOutput calls (which read roleState.currentTask.taskId) keep
|
|
805
|
-
// attaching to the right feature card — instead of falling back to null
|
|
806
|
-
// the moment the sender omits the `task:` field.
|
|
807
|
-
if (taskId) {
|
|
808
|
-
roleState.currentTask = { taskId, taskTitle: taskTitle || roleState.currentTask?.taskTitle || null };
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
// Task 上下文注入
|
|
812
|
-
const effectiveTaskId = taskId || roleState.currentTask?.taskId;
|
|
813
|
-
if (effectiveTaskId) {
|
|
814
|
-
const taskContent = await readTaskFile(session, effectiveTaskId);
|
|
815
|
-
if (taskContent) {
|
|
816
|
-
const ctx = `\n\n---\n<task-context file=".crew/context/features/${effectiveTaskId}.md">\n${taskContent}\n</task-context>`;
|
|
817
|
-
content = _appendTextToContent(content, ctx);
|
|
818
|
-
}
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
// 看板上下文注入(角色重启后知道全局状态)
|
|
822
|
-
{
|
|
823
|
-
const kanbanContent = await readKanban(session);
|
|
824
|
-
if (kanbanContent) {
|
|
825
|
-
const ctx = `\n\n---\n<kanban file=".crew/context/kanban.md">\n${kanbanContent}\n</kanban>`;
|
|
826
|
-
content = _appendTextToContent(content, ctx);
|
|
827
|
-
}
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
// 最近路由消息注入(帮助 clear 后的角色恢复上下文)
|
|
831
|
-
// task-330c: each entry is smart-truncated to 400 chars at a sentence
|
|
832
|
-
// boundary (period/newline) so we don't slice key info mid-sentence.
|
|
833
|
-
// The full content lives in the feature file — the marker tells the
|
|
834
|
-
// role where to look if they need more context. The pre-stored
|
|
835
|
-
// `m.content` was already truncated to 200 (history step below) until
|
|
836
|
-
// task-330c bumped it to 400 + smart boundary.
|
|
837
|
-
// ⚠️ DO NOT pass `m.content` through any greedy `.replace(/.../g, '')`
|
|
838
|
-
// here — it has already been derived from displayBody at message
|
|
839
|
-
// time (parser-stripped), and a second strip would re-process
|
|
840
|
-
// text that no longer holds ROUTE markers. See _appendHistory below.
|
|
841
|
-
if (session.messageHistory.length > 0) {
|
|
842
|
-
const recentRoutes = session.messageHistory
|
|
843
|
-
.filter(m => m.from !== 'system')
|
|
844
|
-
.slice(-5)
|
|
845
|
-
.map(m => `[${m.from} → ${m.to}${m.taskId ? ` (${m.taskId})` : ''}] ${smartTruncate(m.content, 400)}`)
|
|
846
|
-
.join('\n');
|
|
847
|
-
if (recentRoutes) {
|
|
848
|
-
const ctx = `\n\n---\n<recent-routes>\n${recentRoutes}\n</recent-routes>`;
|
|
849
|
-
content = _appendTextToContent(content, ctx);
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
// 记录消息历史
|
|
854
|
-
// task-330c: cap raised 200 → 400 to match the recent-routes injection
|
|
855
|
-
// window. Keeping the pre-store cap at 200 would pin every entry below
|
|
856
|
-
// smartTruncate's 400 threshold, making the smart-truncate boundary cut
|
|
857
|
-
// a permanent no-op in production. 400 here lets longer messages flow
|
|
858
|
-
// into history; smartTruncate trims them at sentence boundaries when
|
|
859
|
-
// injected into <recent-routes>.
|
|
860
|
-
const historyContent = typeof content === 'string'
|
|
861
|
-
? content.substring(0, 400)
|
|
862
|
-
: (Array.isArray(content) ? content.filter(b => b.type === 'text').map(b => b.text).join('').substring(0, 400) + (content.some(b => b.type === 'image') ? ' [+images]' : '') : '...');
|
|
863
|
-
session.messageHistory.push({
|
|
864
|
-
from: fromSource,
|
|
865
|
-
to: roleName,
|
|
866
|
-
content: historyContent,
|
|
867
|
-
taskId: taskId || roleState.currentTask?.taskId || null,
|
|
868
|
-
timestamp: Date.now()
|
|
869
|
-
});
|
|
870
|
-
|
|
871
|
-
// DISABLED (2026-03): Opus 4.6 has 200k context. Claude Code handles its own compaction.
|
|
872
|
-
// Keeping code for reference; re-enable if we ever need custom crew pre-send compact.
|
|
873
|
-
// ★ Pre-send compact check: estimate total tokens and clear+rebuild if needed
|
|
874
|
-
// const autoCompactThreshold = ctx.CONFIG?.autoCompactThreshold || 100000;
|
|
875
|
-
// const lastInputTokens = roleState.lastInputTokens || 0;
|
|
876
|
-
// const estimatedNewTokens = Math.ceil((typeof content === 'string' ? content.length : 0) / 3);
|
|
877
|
-
// const estimatedTotal = lastInputTokens + estimatedNewTokens;
|
|
878
|
-
//
|
|
879
|
-
// if (lastInputTokens > 0 && estimatedTotal > autoCompactThreshold) {
|
|
880
|
-
// console.log(`[Crew] Pre-send compact for ${roleName}: estimated ${estimatedTotal} tokens (last: ${lastInputTokens} + new: ~${estimatedNewTokens}) exceeds threshold ${autoCompactThreshold}`);
|
|
881
|
-
//
|
|
882
|
-
// // Save work summary before clearing (use lastTurnText since accumulatedText is cleared after result)
|
|
883
|
-
// await saveRoleWorkSummary(session, roleName, roleState.lastTurnText || roleState.accumulatedText || '').catch(e =>
|
|
884
|
-
// console.warn(`[Crew] Failed to save work summary for ${roleName}:`, e.message));
|
|
885
|
-
//
|
|
886
|
-
// // Clear role session and rebuild
|
|
887
|
-
// await clearRoleSessionId(session.sharedDir, roleName);
|
|
888
|
-
// roleState.claudeSessionId = null;
|
|
889
|
-
//
|
|
890
|
-
// if (roleState.abortController) roleState.abortController.abort();
|
|
891
|
-
// roleState.query = null;
|
|
892
|
-
// roleState.inputStream = null;
|
|
893
|
-
//
|
|
894
|
-
// sendCrewMessage({
|
|
895
|
-
// type: 'crew_role_cleared',
|
|
896
|
-
// sessionId: session.id,
|
|
897
|
-
// role: roleName,
|
|
898
|
-
// contextPercentage: Math.round((lastInputTokens / (ctx.CONFIG?.maxContextTokens || 128000)) * 100),
|
|
899
|
-
// reason: 'pre_send_compact'
|
|
900
|
-
// });
|
|
901
|
-
//
|
|
902
|
-
// // Recreate the query (fresh Claude process)
|
|
903
|
-
// roleState = await createRoleQuery(session, roleName);
|
|
904
|
-
// }
|
|
905
|
-
|
|
906
|
-
// P1-4: 守卫 stream.enqueue — stream 可能已被 abort 关闭
|
|
907
|
-
roleState.lastDispatchContent = content;
|
|
908
|
-
roleState.lastDispatchFrom = fromSource;
|
|
909
|
-
roleState.lastDispatchTaskId = taskId || null;
|
|
910
|
-
roleState.lastDispatchTaskTitle = taskTitle || null;
|
|
911
|
-
roleState.turnActive = true;
|
|
912
|
-
roleState.accumulatedText = '';
|
|
913
|
-
try {
|
|
914
|
-
if (roleState.inputStream && !roleState.inputStream.isDone) {
|
|
915
|
-
roleState.inputStream.enqueue({
|
|
916
|
-
type: 'user',
|
|
917
|
-
message: { role: 'user', content }
|
|
918
|
-
});
|
|
919
|
-
} else {
|
|
920
|
-
console.warn(`[Crew] Cannot enqueue to ${roleName}: stream closed or missing, recreating`);
|
|
921
|
-
roleState = await createRoleQuery(session, roleName);
|
|
922
|
-
roleState.lastDispatchContent = content;
|
|
923
|
-
roleState.lastDispatchFrom = fromSource;
|
|
924
|
-
roleState.lastDispatchTaskId = taskId || null;
|
|
925
|
-
roleState.lastDispatchTaskTitle = taskTitle || null;
|
|
926
|
-
roleState.turnActive = true;
|
|
927
|
-
roleState.accumulatedText = '';
|
|
928
|
-
roleState.inputStream.enqueue({
|
|
929
|
-
type: 'user',
|
|
930
|
-
message: { role: 'user', content }
|
|
931
|
-
});
|
|
932
|
-
}
|
|
933
|
-
} catch (enqueueErr) {
|
|
934
|
-
console.error(`[Crew] Failed to enqueue to ${roleName}:`, enqueueErr.message);
|
|
935
|
-
// Recreate query and retry once
|
|
936
|
-
roleState = await createRoleQuery(session, roleName);
|
|
937
|
-
roleState.lastDispatchContent = content;
|
|
938
|
-
roleState.lastDispatchFrom = fromSource;
|
|
939
|
-
roleState.lastDispatchTaskId = taskId || null;
|
|
940
|
-
roleState.lastDispatchTaskTitle = taskTitle || null;
|
|
941
|
-
roleState.turnActive = true;
|
|
942
|
-
roleState.accumulatedText = '';
|
|
943
|
-
roleState.inputStream.enqueue({
|
|
944
|
-
type: 'user',
|
|
945
|
-
message: { role: 'user', content }
|
|
946
|
-
});
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
sendStatusUpdate(session);
|
|
950
|
-
console.log(`[Crew] Dispatched to ${roleName} from ${fromSource}${taskId ? ` (task: ${taskId})` : ''}`);
|
|
951
|
-
}
|