@yeaft/webchat-agent 0.1.516 → 0.1.518

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/crew/routing.js CHANGED
@@ -242,7 +242,7 @@ export function parseRoutes(text) {
242
242
  const toRaw = match[1].trim().toLowerCase().replace(/[,;:!?。,;:!?]+$/, '');
243
243
  const summary = match[2] ? match[2].trim() : '[该角色未提供消息摘要]';
244
244
 
245
- routes.push({ to: toRaw, summary, taskId: null, taskTitle: null });
245
+ routes.push({ to: toRaw, toList: [toRaw], summary, taskId: null, taskTitle: null });
246
246
  // Shorthand is a single line — strip the whole line.
247
247
  const lineEnd = maskedText.indexOf('\n', pos);
248
248
  strippedRanges.push({
@@ -348,8 +348,21 @@ function _removeRanges(input, ranges) {
348
348
 
349
349
  /**
350
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
+ *
351
364
  * @param {string} block — raw block content
352
- * @returns {{ to: string, summary: string, taskId: string|null, taskTitle: string|null } | null}
365
+ * @returns {{ to: string, toList: string[], summary: string, taskId: string|null, taskTitle: string|null } | null}
353
366
  */
354
367
  function _parseRouteBlock(block) {
355
368
  // task-328 §3: tolerate Chinese full-width colon (`to:` / `task:` / `summary:`)
@@ -358,11 +371,29 @@ function _parseRouteBlock(block) {
358
371
  const toMatch = block.match(/to\s*[::]\s*(.+)/i);
359
372
  if (!toMatch) return null;
360
373
 
361
- // Clean `to` value: take only the first word (strip parenthetical notes, extra text)
362
- // e.g. "pm (决策者)" → "pm", "dev-1 // main dev" → "dev-1"
363
- const toRaw = toMatch[1].trim().toLowerCase();
364
- // Strip trailing punctuation (commas, semicolons, colons, etc.)
365
- const toClean = toRaw.split(/[\s(]/)[0].replace(/[,;:!?。,;:!?]+$/, '');
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;
366
397
 
367
398
  // ★ summary: match until next known field (task:/taskTitle:) or end of block.
368
399
  // Field separator accepts ASCII `:` or Chinese `:`.
@@ -390,7 +421,8 @@ function _parseRouteBlock(block) {
390
421
  }
391
422
 
392
423
  return {
393
- to: toClean,
424
+ to: cleaned[0],
425
+ toList: cleaned,
394
426
  summary,
395
427
  taskId: taskMatch ? taskMatch[1].trim() : null,
396
428
  taskTitle: taskTitleMatch ? taskTitleMatch[1].trim() : null
@@ -471,11 +503,101 @@ export function resolveRoleName(to, session, fromRole) {
471
503
 
472
504
  /**
473
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
+ *
474
521
  * @param {Array<{mimeType, data}>} [turnImages] - auto-attached images from the turn (max 3)
475
522
  */
476
523
  export async function executeRoute(session, fromRole, route, turnImages = []) {
477
- let { to, summary, taskId, taskTitle } = route;
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
+ }
478
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) {
479
601
  // ─── task-330a §A + task-330b §B: self-route hard-reject + metric ───
480
602
  // 福勒 Final Spec §A — `route.to` 等同于发送方时直接拒绝,不消费 turn、
481
603
  // 不写 kanban、不 dispatch、不 round++(round 已由 role-output 计数)。
@@ -486,21 +608,22 @@ export async function executeRoute(session, fromRole, route, turnImages = []) {
486
608
  // alias self-route 漏记 metric 已记入 PM backlog 作 follow-up(330b 的
487
609
  // raw 比较 `to === fromRole` 仅命中字面相同的情况;alias 形式由 330a
488
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.
489
615
  if (to !== 'human') {
490
616
  const resolvedSelfCheck = resolveRoleName(to, session, fromRole);
491
617
  const isSelf = resolvedSelfCheck === fromRole
492
618
  || (typeof to === 'string' && to.toLowerCase() === String(fromRole).toLowerCase());
493
619
  if (isSelf) {
494
- console.warn(`[Crew] Self-route rejected: ${fromRole} → ${to} (taskId=${taskId || '-'})`);
495
- // 330b path — persistent metric counter (routing-metrics.json + ring).
496
- // Always-safe; never throws (recordRoutingEvent degrades to console.warn).
620
+ console.warn(`[Crew] Self-route rejected: ${fromRole} → ${to} (taskId=${taskId || '-'}, batch=${batchSize})`);
497
621
  recordRoutingEvent(session, 'self-route', {
498
622
  fromRole,
499
623
  toRole: to,
500
624
  taskId: taskId || null,
501
625
  note: 'route.to === fromRole at executeRoute entry (rejected by §A)',
502
626
  });
503
- // 330a path — UI broadcast so the role sees rejection in transcript.
504
627
  try {
505
628
  sendCrewMessage({
506
629
  type: 'routing-metrics',
@@ -530,60 +653,13 @@ export async function executeRoute(session, fromRole, route, turnImages = []) {
530
653
  }
531
654
  // Do NOT decrement session.round — role-output.js already incremented
532
655
  // it for this whole turn batch; one rejected route doesn't undo the
533
- // turn (other routes in the same batch may still be valid).
656
+ // turn (other routes/targets in the same batch may still be valid).
534
657
  return;
535
658
  }
536
659
  }
537
660
 
538
- // task-330b §B item 1: state-stopped metric message arrived while
539
- // session was paused/stopped. Behaviour (auto-resume) is unchanged for
540
- // backward compat; this is observer-only.
541
- if (session.status === 'paused' || session.status === 'stopped') {
542
- recordRoutingEvent(session, 'state-stopped', {
543
- fromRole,
544
- toRole: to,
545
- taskId: taskId || null,
546
- note: `session.status=${session.status} at executeRoute entry`,
547
- });
548
- }
549
-
550
- // Auto-resume: paused/stopped → running (route execution means work should continue)
551
- if (session.status === 'paused' || session.status === 'stopped') {
552
- console.log(`[Crew] Auto-resuming session from ${session.status} to running (route from ${fromRole} to ${to})`);
553
- session.status = 'running';
554
- sendStatusUpdate(session);
555
- }
556
-
557
- // ─── task-321: taskId fallback chain ─────────────────────────────
558
- // When a ROUTE omits `task:` (shorthand, bare dispatch, human messages,
559
- // PM forgetting the field), fall back to:
560
- // (a) the sender's currentTask.taskId
561
- // (b) the most recent non-system entry in session.messageHistory
562
- // This keeps prev-* / designer / architect / shorthand messages from
563
- // becoming taskId=null orphans that never appear on any feature card.
564
- if (!taskId) {
565
- const fromRoleState = session.roleStates?.get(fromRole);
566
- if (fromRoleState?.currentTask?.taskId) {
567
- taskId = fromRoleState.currentTask.taskId;
568
- taskTitle = taskTitle || fromRoleState.currentTask.taskTitle || null;
569
- } else if (Array.isArray(session.messageHistory) && session.messageHistory.length > 0) {
570
- for (let i = session.messageHistory.length - 1; i >= 0; i--) {
571
- const h = session.messageHistory[i];
572
- if (h && h.from !== 'system' && h.taskId) {
573
- taskId = h.taskId;
574
- break;
575
- }
576
- }
577
- }
578
- // Mirror the fallback back into the route object so downstream
579
- // consumers (dispatchToRole / sendCrewOutput) see the inferred id.
580
- if (taskId) {
581
- route.taskId = taskId;
582
- if (taskTitle) route.taskTitle = taskTitle;
583
- }
584
- }
585
-
586
- // Task 文件自动管理(fire-and-forget)
661
+ // Task 文件自动管理(fire-and-forget) per-target so each target gets
662
+ // a kanban entry assigned to it.
587
663
  if (taskId && summary) {
588
664
  const fromRoleConfig = session.roles.get(fromRole);
589
665
  // task-321: Auto-create feature file even when a non-PM role is the
@@ -622,7 +698,8 @@ export async function executeRoute(session, fromRole, route, turnImages = []) {
622
698
  }).catch(e => console.warn(`[Crew] Failed to update kanban:`, e.message));
623
699
  }
624
700
 
625
- // 发送路由消息(UI 显示)
701
+ // 发送路由消息(UI 显示) — per-target so the transcript shows one
702
+ // route card per fan-out leg, mirroring how single-target ROUTEs render.
626
703
  sendCrewOutput(session, fromRole, 'route', null, {
627
704
  routeTo: to, routeSummary: summary,
628
705
  taskId: taskId || undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.516",
3
+ "version": "0.1.518",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -23,6 +23,7 @@
23
23
  import { readFileSync, readdirSync, statSync, mkdirSync, existsSync } from 'fs';
24
24
  import { homedir } from 'os';
25
25
  import { join } from 'path';
26
+ import { createHash } from 'crypto';
26
27
 
27
28
  /**
28
29
  * @typedef {Object} VP
@@ -32,6 +33,7 @@ import { join } from 'path';
32
33
  * @property {string[]} traits
33
34
  * @property {'fast'|'primary'|undefined} modelHint
34
35
  * @property {string} persona — markdown body (persona / system prompt seed)
36
+ * @property {string} personaHash — sha256(persona).slice(0,8); changes when persona body changes
35
37
  * @property {string} dir — absolute path to VP dir
36
38
  * @property {string} memoryDir — absolute path to VP memory dir
37
39
  * @property {number} mtimeMs — role.md mtime (for hot-reload)
@@ -116,6 +118,11 @@ export function loadVpFromDir(dir) {
116
118
  const modelHintRaw = typeof meta.modelHint === 'string' ? meta.modelHint : undefined;
117
119
  const modelHint = modelHintRaw === 'primary' || modelHintRaw === 'fast' ? modelHintRaw : undefined;
118
120
 
121
+ // personaHash: sync sha256 of persona body, first 8 hex chars.
122
+ // Computed at load time (not lazy) so downstream consumers (system prompt
123
+ // builders, web-bridge live-diff in 334h) can compare cheaply.
124
+ const personaHash = createHash('sha256').update(body).digest('hex').slice(0, 8);
125
+
119
126
  /** @type {VP} */
120
127
  return {
121
128
  id,
@@ -124,6 +131,7 @@ export function loadVpFromDir(dir) {
124
131
  traits: Array.isArray(meta.traits) ? meta.traits.map(String) : [],
125
132
  modelHint,
126
133
  persona: body,
134
+ personaHash,
127
135
  dir,
128
136
  memoryDir,
129
137
  mtimeMs: st.mtimeMs,