@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 +141 -64
- package/package.json +1 -1
- package/unify/vp/vp-store.js +8 -0
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
|
-
|
|
362
|
-
//
|
|
363
|
-
|
|
364
|
-
//
|
|
365
|
-
|
|
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:
|
|
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 {
|
|
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
|
-
//
|
|
539
|
-
//
|
|
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
package/unify/vp/vp-store.js
CHANGED
|
@@ -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,
|