@yeaft/webchat-agent 0.1.509 → 0.1.510

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.
@@ -153,8 +153,13 @@ export async function processRoleOutput(session, roleName, roleQuery, roleState)
153
153
  });
154
154
  }
155
155
 
156
- // 解析路由
157
- const routes = parseRoutes(roleState.accumulatedText);
156
+ // 解析路由 — task-328: parseRoutes returns a decorated Array
157
+ // (`.routes`/`.displayBody`/`.strippedRanges`). We keep treating it as
158
+ // an Array for routing iteration, but use `.displayBody` whenever we
159
+ // need the role's prose with ROUTE blocks accurately removed.
160
+ const parseResult = parseRoutes(roleState.accumulatedText);
161
+ const routes = parseResult;
162
+ const displayBody = parseResult.displayBody || roleState.accumulatedText;
158
163
  // Fallback: 如果 route summary 仍为空占位符,用 accumulatedText 末尾 500 字符
159
164
  for (const route of routes) {
160
165
  if (route.summary === '[该角色未提供消息摘要]' && roleState.accumulatedText) {
@@ -191,7 +196,12 @@ export async function processRoleOutput(session, roleName, roleQuery, roleState)
191
196
  }
192
197
 
193
198
  // 保存本 turn 文本(供 routing.js 预检时 saveRoleWorkSummary 使用)
199
+ // task-328: lastTurnText keeps the raw transcript (consumers may need
200
+ // ROUTE blocks for analysis); lastTurnDisplayBody is the parser-clean
201
+ // body used by auto-forward/UI logic to avoid sending broken ROUTE
202
+ // residue downstream.
194
203
  roleState.lastTurnText = roleState.accumulatedText;
204
+ roleState.lastTurnDisplayBody = displayBody;
195
205
  roleState.accumulatedText = '';
196
206
  roleState.turnActive = false;
197
207
 
@@ -262,10 +272,13 @@ export async function processRoleOutput(session, roleName, roleQuery, roleState)
262
272
 
263
273
  if (isNonPM && (hasActiveTask || hasRouteIntent)) {
264
274
  // Non-PM role with active task OR routing intent but no ROUTE block:
265
- // auto-forward to PM so the message doesn't get lost
275
+ // auto-forward to PM so the message doesn't get lost.
276
+ // task-328: forward parser-clean displayBody (ROUTE residue removed)
277
+ // so PM sees the actual prose, not stray END markers.
266
278
  const reason = hasActiveTask ? 'has active task' : 'has routing intent';
267
279
  console.log(`[Crew] ${roleName} turn ended without ROUTE (${reason}) — auto-forwarding to PM`);
268
- const autoSummary = `[auto-forward: ${roleName} turn 结束但未输出 ROUTE 块 (${reason})]\n${(roleState.lastTurnText || '').slice(-800).trim()}`;
280
+ const forwardSource = roleState.lastTurnDisplayBody || roleState.lastTurnText || '';
281
+ const autoSummary = `[auto-forward: ${roleName} turn 结束但未输出 ROUTE 块 (${reason})]\n${forwardSource.slice(-800).trim()}`;
269
282
  await executeRoute(session, roleName, {
270
283
  to: session.decisionMaker,
271
284
  summary: autoSummary,
package/crew/routing.js CHANGED
@@ -33,82 +33,257 @@ function _appendTextToContent(content, text) {
33
33
  }
34
34
 
35
35
  /**
36
- * 从累积文本中解析所有 ROUTE 块(支持多 ROUTE + task 字段)
37
- * @returns {Array<{ to, summary, taskId, taskTitle }>}
36
+ * 从累积文本中解析所有 ROUTE 块(支持多 ROUTE + task 字段)。
37
+ *
38
+ * task-328 — Returns a structured result:
39
+ * { routes, displayBody }
40
+ *
41
+ * - `routes` — Array<{to, summary, taskId, taskTitle}> (same shape as before)
42
+ * - `displayBody` — original text MINUS the exact matched ROUTE ranges
43
+ * (including any surrounding ```fence``` that wraps the
44
+ * ROUTE block), preserving everything else verbatim.
45
+ *
46
+ * Backward compatibility: the returned object is also iterable as an array
47
+ * of routes for any legacy caller that does `for (const r of parseRoutes(...))`
48
+ * or `parseRoutes(...).length` — we attach `[Symbol.iterator]`, `length`, and
49
+ * numeric index properties mirroring `routes`. New callers should use the
50
+ * named `.routes` / `.displayBody` fields.
51
+ *
52
+ * Scope A — ROUTE parser tolerance (task-328):
53
+ * (1) Markdown fence-wrapped ROUTE blocks are still parsed AND the fence
54
+ * lines are stripped from displayBody (so the user doesn't see an
55
+ * empty ```…```).
56
+ * (2) END variants accepted: ---END_ROUTE--- / ---END ROUTE--- /
57
+ * ---END--- / ---END:--- / ---END-ROUTE--- / ---endroute---.
58
+ * (3) `to:` accepts: `to:` `to :` `to:` `TO:` with any casing.
59
+ * (4) Phase 2 soft-end is a STRUCTURAL signal (blank line + `---`, or
60
+ * `<kanban>` / `<recent-routes>` / `<task-context>`), NOT a bare blank
61
+ * line — so multi-paragraph summaries are not truncated.
62
+ * (5) Pre-pass: fenced code is MASKED (positions preserved) but a fence
63
+ * that contains `---ROUTE---` is NOT masked — the ROUTE inside the
64
+ * fence is the real one (matches what users write).
65
+ *
66
+ * Scope B — non-ROUTE body preservation (task-328):
67
+ * - `displayBody` = original minus the EXACT matched ROUTE ranges.
68
+ * No greedy "strip-to-EOF" anymore — post-ROUTE text survives.
69
+ *
70
+ * @param {string} text - Raw role output (may contain 0+ ROUTE blocks)
71
+ * @returns {{ routes: Array<{to:string,summary:string,taskId:string|null,taskTitle:string|null}>, displayBody: string } & Iterable}
38
72
  */
39
73
  export function parseRoutes(text) {
74
+ const input = typeof text === 'string' ? text : '';
40
75
  const routes = [];
76
+ // Exact character ranges (in ORIGINAL `input`) to remove from displayBody.
77
+ // Each entry: { start, end } — half-open, end exclusive.
78
+ const strippedRanges = [];
79
+
80
+ if (!input) return _wrapParseResult(routes, '', strippedRanges);
41
81
 
42
- // ─── Pre-pass: Strip fenced code blocks to avoid parsing quoted ROUTE examples ──
43
- // Replaces ```...``` content with whitespace of same length to preserve positions
44
- text = text.replace(/```[\s\S]*?```/g, m => ' '.repeat(m.length));
82
+ // ─── Pre-pass §2: mask fenced code WITHOUT stripping from original ──
83
+ // We build a boolean mask the same length as `input`. Fences are walked
84
+ // left-to-right. A fence containing `---ROUTE---` is SKIPPED (not masked)
85
+ // so the real ROUTE inside it can be parsed by Phase 1. Non-ROUTE fences
86
+ // are masked so any ```example``` won't pollute Phase 1/2/3 matching.
87
+ //
88
+ // We also remember the start/end of each "ROUTE-carrying fence" so the
89
+ // displayBody calculation can extend a ROUTE match to cover its fence
90
+ // lines — otherwise the user would see an empty ```…``` left behind.
91
+ const masked = _maskNonRouteFences(input);
92
+ const maskedText = masked.text; // original chars or ' ' for masked regions
93
+ const routeFences = masked.routeFences; // [{start, end, innerStart, innerEnd}, ...]
45
94
 
46
95
  // ─── Phase 1: Standard ROUTE blocks (with closing marker) ─────
47
- // Tolerate closer variants:
48
- // ---END_ROUTE--- (underscore)
49
- // ---END ROUTE--- (space)
50
- // ---END--- (bare users / PM often write this)
51
- // Use negative lookahead to not cross another ---ROUTE--- boundary.
52
- const regex = /---ROUTE---\s*\n((?:(?!---ROUTE---)[\s\S])*?)---END(?:[_ ]ROUTE)?---/g;
96
+ // Accept END variants: END_ROUTE | END ROUTE | END-ROUTE | END: | END | endroute
97
+ // Body capture uses negative lookahead to avoid crossing another opener.
98
+ // We run regex on `maskedText` so quoted examples (in non-ROUTE fences)
99
+ // don't match, but we use match.index to index into the ORIGINAL input
100
+ // when computing the strip range.
101
+ const closedRegex = /---\s*ROUTE\s*---\s*\r?\n((?:(?!---\s*ROUTE\s*---)[\s\S])*?)---\s*(?:END[_ \-]?ROUTE|ENDROUTE|END)\s*:?\s*---/gi;
53
102
  let match;
54
- const matchedRanges = []; // track matched ranges to avoid double-parsing
55
-
56
- while ((match = regex.exec(text)) !== null) {
57
- matchedRanges.push({ start: match.index, end: match.index + match[0].length });
103
+ while ((match = closedRegex.exec(maskedText)) !== null) {
58
104
  const parsed = _parseRouteBlock(match[1]);
105
+ let rangeStart = match.index;
106
+ let rangeEnd = match.index + match[0].length;
107
+ // §5: if this match lives inside a ROUTE-carrying fence, extend the
108
+ // strip to cover the fence lines (so the UI doesn't see empty ```…```).
109
+ const fence = routeFences.find(f => rangeStart >= f.innerStart && rangeEnd <= f.innerEnd);
110
+ if (fence) { rangeStart = fence.start; rangeEnd = fence.end; }
111
+ strippedRanges.push({ start: rangeStart, end: rangeEnd });
59
112
  if (parsed) routes.push(parsed);
60
113
  }
61
114
 
62
- // ─── Phase 2: Fallback — ROUTE block missing any closing marker ──
63
- // Take content until (a) next ---ROUTE--- boundary, (b) first blank
64
- // line (summary is almost always a single paragraph anything after
65
- // a blank line is kanban/recent-routes/task-context noise injected
66
- // by the crew runtime), or (c) EOF. The blank-line cutoff prevents
67
- // the whole back-injected blob from being swallowed as the summary.
68
- const openRegex = /---ROUTE---\s*\n/g;
69
- while ((match = openRegex.exec(text)) !== null) {
70
- // Skip if this range was already captured by Phase 1
71
- const pos = match.index;
72
- if (matchedRanges.some(r => pos >= r.start && pos < r.end)) continue;
115
+ // ─── Phase 2: Fallback — ROUTE block with no closing marker ──
116
+ // Soft-end uses a STRUCTURAL signal, not a bare blank line. A summary
117
+ // can span multiple paragraphs it ends only when we see:
118
+ // (a) another ---ROUTE--- opener, or
119
+ // (b) EOF, or
120
+ // (c) a structural separator after ≥2 consecutive newlines:
121
+ // `\n\s*\n+---` (blank line + ---)
122
+ // `\n\s*\n+<(kanban|recent-routes|task-context|EOF)`
123
+ // (d) the 2048-char hard cap (safety valve for runaway blocks).
124
+ const openRegex = /---\s*ROUTE\s*---\s*\r?\n/gi;
125
+ while ((match = openRegex.exec(maskedText)) !== null) {
126
+ const openStart = match.index;
127
+ // Skip if this opener was already consumed by a Phase 1 match.
128
+ if (strippedRanges.some(r => openStart >= r.start && openStart < r.end)) continue;
129
+
130
+ const blockStart = openStart + match[0].length;
131
+ // (a) next opener?
132
+ const nextOpen = maskedText.indexOf('---ROUTE---', blockStart);
133
+ const hardEnd = nextOpen !== -1 ? nextOpen : maskedText.length;
134
+ const scope = maskedText.slice(blockStart, hardEnd);
135
+
136
+ // (c) structural cutoff — scan for the first structural signal after
137
+ // ≥2 consecutive newlines (blank line + structure).
138
+ const SOFT_END_RE = /\n[ \t]*\n+(?:---(?!\s*ROUTE)|<(?:kanban|recent-routes|task-context)\b)/;
139
+ const softMatch = scope.match(SOFT_END_RE);
140
+ let blockEnd = hardEnd;
141
+ if (softMatch && softMatch.index != null) {
142
+ blockEnd = blockStart + softMatch.index;
143
+ }
73
144
 
74
- const blockStart = pos + match[0].length;
75
- // End at next ---ROUTE--- or EOF
76
- const nextRoute = text.indexOf('---ROUTE---', blockStart);
77
- const hardEnd = nextRoute !== -1 ? nextRoute : text.length;
78
- // Soft end: first blank line (two or more newlines with only whitespace in between).
79
- const blank = text.slice(blockStart, hardEnd).search(/\n[ \t]*\n/);
80
- const blockEnd = blank !== -1 ? blockStart + blank : hardEnd;
81
- const block = text.slice(blockStart, blockEnd);
145
+ // (d) 2048-char hard cap — protect against runaway unclosed blocks.
146
+ const SUMMARY_CAP = 2048;
147
+ if (blockEnd - blockStart > SUMMARY_CAP) blockEnd = blockStart + SUMMARY_CAP;
82
148
 
149
+ const block = maskedText.slice(blockStart, blockEnd);
83
150
  const parsed = _parseRouteBlock(block);
151
+
152
+ let rangeStart = openStart;
153
+ let rangeEnd = blockEnd;
154
+ // Extend to fence if wrapped.
155
+ const fence = routeFences.find(f => rangeStart >= f.innerStart && rangeEnd <= f.innerEnd);
156
+ if (fence) { rangeStart = fence.start; rangeEnd = fence.end; }
157
+ strippedRanges.push({ start: rangeStart, end: rangeEnd });
84
158
  if (parsed) routes.push(parsed);
85
159
  }
86
160
 
87
161
  // ─── Phase 3: Shorthand — "ROUTE → target" / "ROUTE: target" ─
88
- // Matches single-line shorthands like: ROUTE dev-1: summary here
89
- // or: ROUTE: dev-1, summary here
162
+ // Only matches a single line and only outside any ROUTE block. We also
163
+ // run this on maskedText so shorthand inside quoted fences is ignored.
90
164
  const shorthandRegex = /^ROUTE\s*[→:]\s*(\S+)[,:\s]*(.*)$/gm;
91
- while ((match = shorthandRegex.exec(text)) !== null) {
92
- // Skip if inside an already-matched ROUTE block range
165
+ while ((match = shorthandRegex.exec(maskedText)) !== null) {
93
166
  const pos = match.index;
94
- if (matchedRanges.some(r => pos >= r.start && pos < r.end)) continue;
95
- // Also skip if the line is inside a ---ROUTE--- block (even unclosed)
96
- const precedingText = text.slice(0, pos);
97
- const lastRouteOpen = precedingText.lastIndexOf('---ROUTE---');
98
- const lastRouteClose = Math.max(
167
+ if (strippedRanges.some(r => pos >= r.start && pos < r.end)) continue;
168
+
169
+ // Also skip if inside an open ---ROUTE--- block (even unclosed).
170
+ const precedingText = maskedText.slice(0, pos);
171
+ const lastRouteOpen = precedingText.search(/---\s*ROUTE\s*---(?![\s\S]*---\s*ROUTE\s*---)/i);
172
+ const lastRouteOpenIdx = precedingText.lastIndexOf('---ROUTE---');
173
+ const lastRouteCloseIdx = Math.max(
99
174
  precedingText.lastIndexOf('---END_ROUTE---'),
100
175
  precedingText.lastIndexOf('---END ROUTE---'),
101
- precedingText.lastIndexOf('---END---')
176
+ precedingText.lastIndexOf('---END-ROUTE---'),
177
+ precedingText.lastIndexOf('---END---'),
102
178
  );
103
- if (lastRouteOpen > lastRouteClose) continue; // inside an open block
179
+ if (lastRouteOpenIdx > lastRouteCloseIdx) continue;
180
+ void lastRouteOpen; // silence unused
104
181
 
105
182
  const toRaw = match[1].trim().toLowerCase().replace(/[,;:!?。,;:!?]+$/, '');
106
183
  const summary = match[2] ? match[2].trim() : '[该角色未提供消息摘要]';
107
184
 
108
185
  routes.push({ to: toRaw, summary, taskId: null, taskTitle: null });
186
+ // Shorthand is a single line — strip the whole line.
187
+ const lineEnd = maskedText.indexOf('\n', pos);
188
+ strippedRanges.push({
189
+ start: pos,
190
+ end: lineEnd === -1 ? maskedText.length : lineEnd,
191
+ });
109
192
  }
110
193
 
111
- return routes;
194
+ const displayBody = _removeRanges(input, strippedRanges);
195
+ return _wrapParseResult(routes, displayBody, strippedRanges);
196
+ }
197
+
198
+ /**
199
+ * Wrap the parse result in an object that is ALSO iterable as an array
200
+ * of routes (for legacy `for (const r of parseRoutes(x))` callers) and
201
+ * supports `.length` / numeric index. New fields: `.routes`, `.displayBody`.
202
+ * @private
203
+ */
204
+ function _wrapParseResult(routes, displayBody, rangesForDebug) {
205
+ // Start from a real Array so `Array.isArray()` and iteration/indexing
206
+ // "just work". Decorate with named fields that new callers prefer.
207
+ const arr = routes.slice();
208
+ Object.defineProperty(arr, 'routes', { value: routes, enumerable: false });
209
+ Object.defineProperty(arr, 'displayBody', { value: displayBody, enumerable: false });
210
+ Object.defineProperty(arr, 'strippedRanges', { value: rangesForDebug, enumerable: false });
211
+ return arr;
212
+ }
213
+
214
+ /**
215
+ * §2 helper — build a mask of `input` that replaces non-ROUTE fenced
216
+ * code with spaces (length-preserving), and records the positions of
217
+ * fences that DO contain a ROUTE opener (so Phase 1/2 can extend their
218
+ * strip range to swallow the fence lines).
219
+ *
220
+ * @param {string} input
221
+ * @returns {{ text: string, routeFences: Array<{start:number,end:number,innerStart:number,innerEnd:number}> }}
222
+ * @private
223
+ */
224
+ function _maskNonRouteFences(input) {
225
+ const FENCE_RE = /```[^\n]*\n([\s\S]*?)```/g;
226
+ let m;
227
+ let out = '';
228
+ let lastIdx = 0;
229
+ const routeFences = [];
230
+ while ((m = FENCE_RE.exec(input)) !== null) {
231
+ const fenceStart = m.index;
232
+ const fenceEnd = m.index + m[0].length;
233
+ const innerStart = fenceStart + m[0].indexOf('\n') + 1;
234
+ const innerEnd = fenceEnd - 3; // strip trailing ```
235
+ const fenceContent = m[1];
236
+ const hasRoute = /---\s*ROUTE\s*---/i.test(fenceContent);
237
+ // Copy unchanged text up to fence start
238
+ out += input.slice(lastIdx, fenceStart);
239
+ if (hasRoute) {
240
+ // Keep the fence content intact so Phase 1 sees the ROUTE; record
241
+ // the fence range for the displayBody extender.
242
+ out += input.slice(fenceStart, fenceEnd);
243
+ routeFences.push({ start: fenceStart, end: fenceEnd, innerStart, innerEnd });
244
+ } else {
245
+ // Mask entire fence (including markers) with spaces of equal length
246
+ // so positions line up with the original string.
247
+ out += ' '.repeat(fenceEnd - fenceStart);
248
+ }
249
+ lastIdx = fenceEnd;
250
+ }
251
+ out += input.slice(lastIdx);
252
+ return { text: out, routeFences };
253
+ }
254
+
255
+ /**
256
+ * Remove a list of (possibly overlapping) character ranges from `input`.
257
+ * Also trims leading/trailing whitespace from the resulting blocks so the
258
+ * displayBody doesn't keep lonely blank lines where a ROUTE used to be.
259
+ *
260
+ * @param {string} input
261
+ * @param {Array<{start:number, end:number}>} ranges
262
+ * @returns {string}
263
+ * @private
264
+ */
265
+ function _removeRanges(input, ranges) {
266
+ if (!ranges || ranges.length === 0) return input;
267
+ // Merge overlapping/adjacent ranges.
268
+ const sorted = ranges.slice().sort((a, b) => a.start - b.start);
269
+ const merged = [sorted[0]];
270
+ for (let i = 1; i < sorted.length; i++) {
271
+ const prev = merged[merged.length - 1];
272
+ const cur = sorted[i];
273
+ if (cur.start <= prev.end) prev.end = Math.max(prev.end, cur.end);
274
+ else merged.push({ ...cur });
275
+ }
276
+ // Build output by keeping the gaps between merged ranges.
277
+ let out = '';
278
+ let cursor = 0;
279
+ for (const r of merged) {
280
+ out += input.slice(cursor, r.start);
281
+ cursor = r.end;
282
+ }
283
+ out += input.slice(cursor);
284
+ // Collapse 3+ consecutive newlines (left by a removal) to a double newline.
285
+ out = out.replace(/\n{3,}/g, '\n\n');
286
+ return out.trim();
112
287
  }
113
288
 
114
289
  /**
@@ -117,7 +292,10 @@ export function parseRoutes(text) {
117
292
  * @returns {{ to: string, summary: string, taskId: string|null, taskTitle: string|null } | null}
118
293
  */
119
294
  function _parseRouteBlock(block) {
120
- const toMatch = block.match(/to:\s*(.+)/i);
295
+ // task-328 §3: tolerate Chinese full-width colon (`to:` / `task:` / `summary:`)
296
+ // and stray whitespace before the colon (`to :`). All field separators accept
297
+ // either ASCII `:` or Chinese `:`.
298
+ const toMatch = block.match(/to\s*[::]\s*(.+)/i);
121
299
  if (!toMatch) return null;
122
300
 
123
301
  // ★ Clean `to` value: take only the first word (strip parenthetical notes, extra text)
@@ -126,10 +304,11 @@ function _parseRouteBlock(block) {
126
304
  // Strip trailing punctuation (commas, semicolons, colons, etc.)
127
305
  const toClean = toRaw.split(/[\s(]/)[0].replace(/[,;:!?。,;:!?]+$/, '');
128
306
 
129
- // ★ summary: match until next known field (task:/taskTitle:) or end of block
130
- const summaryMatch = block.match(/summary:\s*([\s\S]+?)(?=\n\s*(?:task|taskTitle)\s*:|$)/i);
131
- const taskMatch = block.match(/^task:\s*(.+)/im);
132
- const taskTitleMatch = block.match(/^taskTitle:\s*(.+)/im);
307
+ // ★ summary: match until next known field (task:/taskTitle:) or end of block.
308
+ // Field separator accepts ASCII `:` or Chinese `:`.
309
+ const summaryMatch = block.match(/summary\s*[::]\s*([\s\S]+?)(?=\n\s*(?:task|taskTitle)\s*[::]|$)/i);
310
+ const taskMatch = block.match(/^task\s*[::]\s*(.+)/im);
311
+ const taskTitleMatch = block.match(/^taskTitle\s*[::]\s*(.+)/im);
133
312
 
134
313
  let summary = summaryMatch ? summaryMatch[1].trim() : '';
135
314
 
@@ -137,7 +316,7 @@ function _parseRouteBlock(block) {
137
316
  // just write the message as free text AFTER the known fields. Collect
138
317
  // everything that is NOT a recognised field line as the body.
139
318
  if (!summary) {
140
- const KNOWN_FIELD = /^\s*(?:to|task|taskTitle|summary)\s*:/i;
319
+ const KNOWN_FIELD = /^\s*(?:to|task|taskTitle|summary)\s*[::]/i;
141
320
  const bare = block
142
321
  .split(/\r?\n/)
143
322
  .filter(line => !KNOWN_FIELD.test(line))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.509",
3
+ "version": "0.1.510",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",