@yeaft/webchat-agent 0.1.590 → 0.1.592
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -45,6 +45,31 @@ function thinkingV1Enabled() {
|
|
|
45
45
|
return process.env.UNIFY_THINKING_V1 === '1';
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* task-DESIGN-v4: Chat Completions adapter is deprecated in favour of
|
|
50
|
+
* `openai-responses.js` (Responses API) for OpenAI-protocol providers and
|
|
51
|
+
* `anthropic.js` for Anthropic. This warning fires once per process the
|
|
52
|
+
* first time the adapter is instantiated, unless UNIFY_SUPPRESS_DEPRECATION=1.
|
|
53
|
+
* Removal is scheduled for Phase 7 of the multi-VP redesign — see
|
|
54
|
+
* `agent/unify/DESIGN.md` § "Migration Plan".
|
|
55
|
+
*/
|
|
56
|
+
let _chatCompletionsDeprecationWarned = false;
|
|
57
|
+
function warnChatCompletionsDeprecated() {
|
|
58
|
+
if (_chatCompletionsDeprecationWarned) return;
|
|
59
|
+
if (process.env.UNIFY_SUPPRESS_DEPRECATION === '1') {
|
|
60
|
+
_chatCompletionsDeprecationWarned = true;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
_chatCompletionsDeprecationWarned = true;
|
|
64
|
+
// eslint-disable-next-line no-console
|
|
65
|
+
console.warn(
|
|
66
|
+
'[unify] ChatCompletionsAdapter is deprecated. Migrate OpenAI-protocol '
|
|
67
|
+
+ 'providers to the Responses API (set provider.protocol="openai-responses"). '
|
|
68
|
+
+ 'This adapter will be removed in a future release. Set '
|
|
69
|
+
+ 'UNIFY_SUPPRESS_DEPRECATION=1 to silence this warning.'
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
48
73
|
/**
|
|
49
74
|
* Check if a model ID is an OpenAI model that supports max_completion_tokens.
|
|
50
75
|
* OpenAI introduced max_completion_tokens with o1 and made it standard for
|
|
@@ -79,6 +104,7 @@ export class ChatCompletionsAdapter extends LLMAdapter {
|
|
|
79
104
|
super({ apiKey, baseUrl });
|
|
80
105
|
this.#apiKey = apiKey;
|
|
81
106
|
this.#baseUrl = baseUrl.replace(/\/+$/, ''); // strip trailing slash
|
|
107
|
+
warnChatCompletionsDeprecated();
|
|
82
108
|
}
|
|
83
109
|
|
|
84
110
|
/** Expose baseUrl for testing. */
|
|
@@ -33,9 +33,34 @@ import {
|
|
|
33
33
|
LLMServerError,
|
|
34
34
|
LLMAbortError,
|
|
35
35
|
} from './adapter.js';
|
|
36
|
+
import {
|
|
37
|
+
normalizeEffort,
|
|
38
|
+
getThinkingCapability,
|
|
39
|
+
} from '../models.js';
|
|
36
40
|
|
|
37
41
|
const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
|
|
38
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Feature-flag accessor mirroring anthropic.js. UNIFY_THINKING_V1 is OFF by
|
|
45
|
+
* default; set env to '1' to enable thinking-mode field translation. Read
|
|
46
|
+
* lazily so tests can flip the flag between calls.
|
|
47
|
+
*/
|
|
48
|
+
function thinkingV1Enabled() {
|
|
49
|
+
return process.env.UNIFY_THINKING_V1 === '1';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Translate a normalised effort ('low'|'medium'|'high'|'max') into the value
|
|
54
|
+
* accepted by the OpenAI Responses `reasoning.effort` field. Responses today
|
|
55
|
+
* accepts 'low'|'medium'|'high' — 'max' degrades to 'high' to match the
|
|
56
|
+
* registry's normaliseEffort downgrade rule.
|
|
57
|
+
*/
|
|
58
|
+
function effortForResponses(effort) {
|
|
59
|
+
if (!effort) return null;
|
|
60
|
+
if (effort === 'max') return 'high';
|
|
61
|
+
return effort;
|
|
62
|
+
}
|
|
63
|
+
|
|
39
64
|
export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
40
65
|
#apiKey;
|
|
41
66
|
#baseUrl;
|
|
@@ -200,9 +225,9 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
|
200
225
|
// ─── Streaming ──────────────────────────────────────────
|
|
201
226
|
|
|
202
227
|
/**
|
|
203
|
-
* @param {{ model: string, system: string, messages: import('./adapter.js').UnifiedMessage[], tools?: import('./adapter.js').UnifiedToolDef[], maxTokens?: number, extraBody?: object, signal?: AbortSignal }} params
|
|
228
|
+
* @param {{ model: string, system: string, messages: import('./adapter.js').UnifiedMessage[], tools?: import('./adapter.js').UnifiedToolDef[], maxTokens?: number, effort?: 'low'|'medium'|'high'|'max', extraBody?: object, signal?: AbortSignal }} params
|
|
204
229
|
*/
|
|
205
|
-
async *stream({ model, system, messages, tools, maxTokens = 16384, extraBody, signal }) {
|
|
230
|
+
async *stream({ model, system, messages, tools, maxTokens = 16384, effort, extraBody, signal }) {
|
|
206
231
|
if (signal?.aborted) throw new LLMAbortError();
|
|
207
232
|
|
|
208
233
|
const body = {
|
|
@@ -214,6 +239,20 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
|
214
239
|
if (system) body.instructions = system;
|
|
215
240
|
const translatedTools = this.#translateTools(tools);
|
|
216
241
|
if (translatedTools) body.tools = translatedTools;
|
|
242
|
+
|
|
243
|
+
// Inject Responses-API thinking-mode field. Mirrors anthropic.js gating:
|
|
244
|
+
// feature flag must be on, effort must be a known value, and the model's
|
|
245
|
+
// registry entry must declare thinkingProtocol === 'openai-reasoning'.
|
|
246
|
+
// Unknown / unsupported models silently drop the field.
|
|
247
|
+
const normEffort = normalizeEffort(effort);
|
|
248
|
+
if (thinkingV1Enabled() && normEffort) {
|
|
249
|
+
const cap = getThinkingCapability(model);
|
|
250
|
+
if (cap.supportsThinking && cap.thinkingProtocol === 'openai-reasoning') {
|
|
251
|
+
const wireEffort = effortForResponses(normEffort);
|
|
252
|
+
if (wireEffort) body.reasoning = { effort: wireEffort };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
217
256
|
if (extraBody) Object.assign(body, extraBody);
|
|
218
257
|
|
|
219
258
|
let response;
|
|
@@ -375,7 +414,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
|
375
414
|
|
|
376
415
|
// ─── Non-streaming call() ───────────────────────────────
|
|
377
416
|
|
|
378
|
-
async call({ model, system, messages, maxTokens = 4096, extraBody, signal }) {
|
|
417
|
+
async call({ model, system, messages, maxTokens = 4096, effort, extraBody, signal }) {
|
|
379
418
|
if (signal?.aborted) throw new LLMAbortError();
|
|
380
419
|
|
|
381
420
|
const body = {
|
|
@@ -384,6 +423,17 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
|
384
423
|
max_output_tokens: maxTokens,
|
|
385
424
|
};
|
|
386
425
|
if (system) body.instructions = system;
|
|
426
|
+
|
|
427
|
+
// Mirror stream()'s thinking injection for non-streaming side queries.
|
|
428
|
+
const normEffort = normalizeEffort(effort);
|
|
429
|
+
if (thinkingV1Enabled() && normEffort) {
|
|
430
|
+
const cap = getThinkingCapability(model);
|
|
431
|
+
if (cap.supportsThinking && cap.thinkingProtocol === 'openai-reasoning') {
|
|
432
|
+
const wireEffort = effortForResponses(normEffort);
|
|
433
|
+
if (wireEffort) body.reasoning = { effort: wireEffort };
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
387
437
|
if (extraBody) Object.assign(body, extraBody);
|
|
388
438
|
|
|
389
439
|
let response;
|
package/unify/prompts.js
CHANGED
|
@@ -158,6 +158,11 @@ const RAW_TEMPLATES = {
|
|
|
158
158
|
modeUnified: readTemplate('mode-unified.md'),
|
|
159
159
|
modeDream: readTemplate('mode-dream.md'),
|
|
160
160
|
toolGuidance: readTemplate('tool-guidance.md'),
|
|
161
|
+
// Phase 1 — DESIGN.md "Migration Plan" harness fragments. Optional so
|
|
162
|
+
// older deployments without the templates still boot; buildWorkerPrompt /
|
|
163
|
+
// buildRouterPrompt callers will simply omit the section.
|
|
164
|
+
harnessWorkerShape: readTemplate('harness/worker-shape.md', { required: false }),
|
|
165
|
+
harnessRouterShape: readTemplate('harness/router-shape.md', { required: false }),
|
|
161
166
|
};
|
|
162
167
|
|
|
163
168
|
/**
|
|
@@ -637,3 +642,164 @@ function renderCoreMemory(coreMemory, lang, memoryTraceAvailable) {
|
|
|
637
642
|
}
|
|
638
643
|
return lines.join('\n');
|
|
639
644
|
}
|
|
645
|
+
|
|
646
|
+
// ─── Phase 1: Worker / Router prompt splits ──────────────────────
|
|
647
|
+
//
|
|
648
|
+
// DESIGN.md (multi-VP redesign) describes two distinct prompt shapes:
|
|
649
|
+
//
|
|
650
|
+
// • Worker prompt — what a VP sees when it executes a turn. Layered as
|
|
651
|
+
// A (identity + summaries) / B (router-preselected memory) / C (task
|
|
652
|
+
// scope) / D (turn scope).
|
|
653
|
+
// • Router prompt — what the per-VP Router sees before it decides
|
|
654
|
+
// plans[]. Identity-summary layer + recent group state, no task /
|
|
655
|
+
// turn-scope detail.
|
|
656
|
+
//
|
|
657
|
+
// To stay backwards-compatible with existing callers we KEEP
|
|
658
|
+
// `buildSystemPrompt` and treat the two new entry points as thin wrappers
|
|
659
|
+
// that:
|
|
660
|
+
// 1) compose Layer-A summaries (user / group / vp) into the right
|
|
661
|
+
// headed sections, and
|
|
662
|
+
// 2) prepend the matching harness/*-shape.md fragment when present.
|
|
663
|
+
//
|
|
664
|
+
// Subsequent phases will migrate engine.js / router.js to these entry
|
|
665
|
+
// points and start filling Layers B / C with the new memory tree. For
|
|
666
|
+
// now they exist primarily so tests can pin the contract.
|
|
667
|
+
|
|
668
|
+
const LAYER_A_HEADERS = {
|
|
669
|
+
en: {
|
|
670
|
+
user: '## summary_user',
|
|
671
|
+
group: '## summary_group',
|
|
672
|
+
vp: '## summary_vp',
|
|
673
|
+
},
|
|
674
|
+
zh: {
|
|
675
|
+
user: '## 用户总结',
|
|
676
|
+
group: '## 群组总结',
|
|
677
|
+
vp: '## VP 总结',
|
|
678
|
+
},
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Render Layer A's three rolling summaries (user / group / vp). Each is
|
|
683
|
+
* optional; missing or empty strings are skipped. Headers follow the
|
|
684
|
+
* `## summary_<scope>` convention so Layer-B/C/D headers don't collide.
|
|
685
|
+
*
|
|
686
|
+
* @param {{user?: string, group?: string, vp?: string}} summaries
|
|
687
|
+
* @param {'en'|'zh'} language
|
|
688
|
+
* @returns {string} concatenated block ('' when nothing to render)
|
|
689
|
+
*/
|
|
690
|
+
export function renderLayerASummaries(summaries, language = 'en') {
|
|
691
|
+
if (!summaries || typeof summaries !== 'object') return '';
|
|
692
|
+
const headers = LAYER_A_HEADERS[language] || LAYER_A_HEADERS.en;
|
|
693
|
+
const out = [];
|
|
694
|
+
for (const key of ['user', 'group', 'vp']) {
|
|
695
|
+
const body = typeof summaries[key] === 'string' ? summaries[key].trim() : '';
|
|
696
|
+
if (!body) continue;
|
|
697
|
+
out.push(`${headers[key]}\n${body}`);
|
|
698
|
+
}
|
|
699
|
+
return out.join('\n\n');
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Worker prompt entry point (DESIGN.md Phase 1).
|
|
704
|
+
*
|
|
705
|
+
* Layered output:
|
|
706
|
+
* harness/worker-shape — what each layer means (optional fragment)
|
|
707
|
+
* Layer A — buildSystemPrompt(...) output (identity + persona + Layer-A
|
|
708
|
+
* summaries via `summaries`)
|
|
709
|
+
* Layer B — `preselectedMemory` block (router-supplied)
|
|
710
|
+
* Layer C — `taskScope` block (active task summary + related-task window)
|
|
711
|
+
* Layer D — `turnScope` block (inbound envelope, in-flight turn notes)
|
|
712
|
+
*
|
|
713
|
+
* Layers B/C/D are passed in as already-rendered strings so this builder
|
|
714
|
+
* stays free of memory-store / task-store IO. Phase 2/3 will provide the
|
|
715
|
+
* real renderers; for now any caller can stub them.
|
|
716
|
+
*
|
|
717
|
+
* @param {{
|
|
718
|
+
* language?: 'en'|'zh',
|
|
719
|
+
* summaries?: {user?: string, group?: string, vp?: string},
|
|
720
|
+
* preselectedMemory?: string,
|
|
721
|
+
* taskScope?: string,
|
|
722
|
+
* turnScope?: string,
|
|
723
|
+
* includeShape?: boolean,
|
|
724
|
+
* ...rest: import('./prompts.js').buildSystemPrompt
|
|
725
|
+
* }} params
|
|
726
|
+
* @returns {string}
|
|
727
|
+
*/
|
|
728
|
+
export function buildWorkerPrompt(params = {}) {
|
|
729
|
+
const {
|
|
730
|
+
language = 'en',
|
|
731
|
+
summaries,
|
|
732
|
+
preselectedMemory,
|
|
733
|
+
taskScope,
|
|
734
|
+
turnScope,
|
|
735
|
+
includeShape = true,
|
|
736
|
+
...rest
|
|
737
|
+
} = params;
|
|
738
|
+
|
|
739
|
+
const parts = [];
|
|
740
|
+
|
|
741
|
+
// Optional harness — describes the layered shape.
|
|
742
|
+
if (includeShape) {
|
|
743
|
+
const shape = getTemplate('harnessWorkerShape', language);
|
|
744
|
+
if (shape) parts.push(shape);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// Layer A — base + persona + summaries.
|
|
748
|
+
const baseBlock = buildSystemPrompt({ ...rest, language });
|
|
749
|
+
if (baseBlock) parts.push(baseBlock);
|
|
750
|
+
const summaryBlock = renderLayerASummaries(summaries, language);
|
|
751
|
+
if (summaryBlock) parts.push(summaryBlock);
|
|
752
|
+
|
|
753
|
+
// Layer B — router-preselected memory entries (rendered upstream).
|
|
754
|
+
if (typeof preselectedMemory === 'string' && preselectedMemory.trim()) {
|
|
755
|
+
parts.push(preselectedMemory.trim());
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Layer C — task scope.
|
|
759
|
+
if (typeof taskScope === 'string' && taskScope.trim()) {
|
|
760
|
+
parts.push(taskScope.trim());
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Layer D — turn scope (inbound envelope, in-flight turn notes).
|
|
764
|
+
if (typeof turnScope === 'string' && turnScope.trim()) {
|
|
765
|
+
parts.push(turnScope.trim());
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
return parts.join('\n\n');
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Router prompt entry point (DESIGN.md Phase 1).
|
|
773
|
+
*
|
|
774
|
+
* The Router sees identity context (no persona — it speaks as a routing
|
|
775
|
+
* brain, not as any specific VP), the three Layer-A summaries, and a
|
|
776
|
+
* `routerContext` block prepared upstream (group roster, recent turns,
|
|
777
|
+
* pending tasks). Output schema is enforced by the harness fragment.
|
|
778
|
+
*
|
|
779
|
+
* @param {{
|
|
780
|
+
* language?: 'en'|'zh',
|
|
781
|
+
* summaries?: {user?: string, group?: string, vp?: string},
|
|
782
|
+
* routerContext?: string,
|
|
783
|
+
* includeShape?: boolean,
|
|
784
|
+
* }} params
|
|
785
|
+
* @returns {string}
|
|
786
|
+
*/
|
|
787
|
+
export function buildRouterPrompt(params = {}) {
|
|
788
|
+
const { language = 'en', summaries, routerContext, includeShape = true } = params;
|
|
789
|
+
const parts = [];
|
|
790
|
+
|
|
791
|
+
if (includeShape) {
|
|
792
|
+
const shape = getTemplate('harnessRouterShape', language);
|
|
793
|
+
if (shape) parts.push(shape);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const summaryBlock = renderLayerASummaries(summaries, language);
|
|
797
|
+
if (summaryBlock) parts.push(summaryBlock);
|
|
798
|
+
|
|
799
|
+
if (typeof routerContext === 'string' && routerContext.trim()) {
|
|
800
|
+
parts.push(routerContext.trim());
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
return parts.join('\n\n');
|
|
804
|
+
}
|
|
805
|
+
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<!-- lang:en -->
|
|
2
|
+
# Prompt Shape (Router)
|
|
3
|
+
|
|
4
|
+
You are the per-VP Router. You see the group's roster, summaries, recent
|
|
5
|
+
turns, and the latest user message. You return a JSON `plans[]` array — one
|
|
6
|
+
plan per VP that should act this turn, in execution order.
|
|
7
|
+
|
|
8
|
+
Each plan contains:
|
|
9
|
+
|
|
10
|
+
- `vpId` — which VP runs.
|
|
11
|
+
- `forwardQuery` — `{ userOriginal, intent }`. `userOriginal` is the
|
|
12
|
+
verbatim user text; `intent` is a one-line gloss in third person. Do not
|
|
13
|
+
rewrite the user's words; the worker will read both.
|
|
14
|
+
- `preselect` — `{ memoryPaths[], taskIds[] }`. Memory paths are
|
|
15
|
+
scope-prefixed (`user/`, `groups/<id>/`, `vp/<id>/`, `tasks/<id>/`).
|
|
16
|
+
- `thinking` — `null | "high" | "max"`. Set when the turn warrants
|
|
17
|
+
deeper reasoning; leave `null` to use the VP / global default.
|
|
18
|
+
- `thinkingReason` — short justification when `thinking` is non-null.
|
|
19
|
+
|
|
20
|
+
Hard rules:
|
|
21
|
+
- Never include `vp/<other>/` paths in `preselect.memoryPaths`. Cross-VP
|
|
22
|
+
private memory is hard-blocked.
|
|
23
|
+
- Plans run sequentially in the order returned. Treat ordering as load
|
|
24
|
+
bearing; the second plan can read the first plan's output.
|
|
25
|
+
- If no VP needs to act, return `{"plans": []}`.
|
|
26
|
+
<!-- lang:zh -->
|
|
27
|
+
# Prompt 结构(Router)
|
|
28
|
+
|
|
29
|
+
你是当前群组的 Router。你能看到群成员、总结、最近的回合,以及最新的用户
|
|
30
|
+
消息。你返回一个 JSON `plans[]` 数组——每个需要发言的 VP 一个 plan,按
|
|
31
|
+
执行顺序排列。
|
|
32
|
+
|
|
33
|
+
每个 plan 包含:
|
|
34
|
+
|
|
35
|
+
- `vpId`:要执行的 VP。
|
|
36
|
+
- `forwardQuery`:`{ userOriginal, intent }`。`userOriginal` 是用户的
|
|
37
|
+
原话;`intent` 是用第三人称写的一行意图说明。不要改写用户原话,Worker
|
|
38
|
+
会同时看到两者。
|
|
39
|
+
- `preselect`:`{ memoryPaths[], taskIds[] }`。memoryPaths 必须带 scope
|
|
40
|
+
前缀(`user/`、`groups/<id>/`、`vp/<id>/`、`tasks/<id>/`)。
|
|
41
|
+
- `thinking`:`null | "high" | "max"`。需要深度推理时设置,否则保持 null
|
|
42
|
+
使用 VP / 全局默认。
|
|
43
|
+
- `thinkingReason`:当 `thinking` 非空时的简短理由。
|
|
44
|
+
|
|
45
|
+
硬规则:
|
|
46
|
+
- `preselect.memoryPaths` 不允许包含 `vp/<其他 VP>/`。跨 VP 私有记忆硬
|
|
47
|
+
屏蔽。
|
|
48
|
+
- plans 按返回顺序串行执行;后一个 plan 可以读到前一个 plan 的输出。
|
|
49
|
+
- 如果本轮无需任何 VP 发言,返回 `{"plans": []}`。
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<!-- lang:en -->
|
|
2
|
+
# Prompt Shape (Worker)
|
|
3
|
+
|
|
4
|
+
You are a Worker VP turn. Your prompt is built from four layers, in order:
|
|
5
|
+
|
|
6
|
+
- **Layer A — Identity & Context**: who you are (VP persona) plus the three
|
|
7
|
+
rolling summaries (user / group / vp). Slow-changing; updated by the
|
|
8
|
+
hourly Dream pass.
|
|
9
|
+
- **Layer B — Pre-selected Memory**: a small set of memory entries the
|
|
10
|
+
Router decided are relevant for this turn. Treat these as authoritative
|
|
11
|
+
context; do not re-fetch unless something is missing.
|
|
12
|
+
- **Layer C — Task Scope**: the active task summary and a short window of
|
|
13
|
+
related task threads. Empty when the turn has no task binding.
|
|
14
|
+
- **Layer D — Turn Scope**: the in-flight messages, tool traces, and any
|
|
15
|
+
inbound envelope (a forwarded handoff from another VP).
|
|
16
|
+
|
|
17
|
+
When information is missing, prefer to ask via tools rather than fabricating
|
|
18
|
+
it. When in doubt about scope, the order of trust is: turn → task →
|
|
19
|
+
preselected memory → identity summary.
|
|
20
|
+
<!-- lang:zh -->
|
|
21
|
+
# Prompt 结构(Worker)
|
|
22
|
+
|
|
23
|
+
你是一个 Worker VP 的回合。Prompt 由四层组成,自上而下:
|
|
24
|
+
|
|
25
|
+
- **A 层 · 身份与背景**:你的 VP 人设,以及三段缓慢更新的总结(用户 /
|
|
26
|
+
群组 / VP)。由每小时一次的 Dream 维护。
|
|
27
|
+
- **B 层 · 路由预选记忆**:Router 判定与本轮相关的少量记忆条目,视为权威
|
|
28
|
+
上下文,缺失时再去取。
|
|
29
|
+
- **C 层 · 任务范围**:当前任务的 summary,以及最近的相关任务窗口。无
|
|
30
|
+
任务绑定时该层为空。
|
|
31
|
+
- **D 层 · 当前回合**:本轮的消息、工具调用 trace,以及(如有)从其他
|
|
32
|
+
VP 转交而来的 inbound envelope。
|
|
33
|
+
|
|
34
|
+
信息缺失时优先用工具询问,不要编造。判定信息可信度的顺序:当前回合 >
|
|
35
|
+
任务范围 > 预选记忆 > 身份总结。
|
package/unify/web-bridge.js
CHANGED
|
@@ -138,22 +138,31 @@ function isPermissionErrorMsg(msg) {
|
|
|
138
138
|
* Send a unify_output message carrying claude_output-format data.
|
|
139
139
|
* The server forwards this as-is to the web client.
|
|
140
140
|
* The frontend's handleUnifyOutput will dispatch via handleClaudeOutput.
|
|
141
|
+
*
|
|
142
|
+
* Optional `groupId` tags every emitted assistant/tool/user mirror with
|
|
143
|
+
* the originating group so the frontend can stamp arriving messages with
|
|
144
|
+
* the SEND-context group instead of the user's CURRENT filter (which can
|
|
145
|
+
* change while the reply is in flight). Without this, switching groups
|
|
146
|
+
* mid-reply lands the assistant turn in the wrong group.
|
|
141
147
|
*/
|
|
142
|
-
function sendUnifyOutput(data) {
|
|
148
|
+
function sendUnifyOutput(data, groupId) {
|
|
143
149
|
sendToServer({
|
|
144
150
|
type: 'unify_output',
|
|
145
151
|
conversationId: unifyConversationId,
|
|
152
|
+
...(groupId ? { groupId } : {}),
|
|
146
153
|
data,
|
|
147
154
|
});
|
|
148
155
|
}
|
|
149
156
|
|
|
150
157
|
/**
|
|
151
158
|
* Send a unify_output event (non-claude_output metadata).
|
|
159
|
+
* Optional `groupId` — see sendUnifyOutput for rationale.
|
|
152
160
|
*/
|
|
153
|
-
function sendUnifyEvent(event) {
|
|
161
|
+
function sendUnifyEvent(event, groupId) {
|
|
154
162
|
sendToServer({
|
|
155
163
|
type: 'unify_output',
|
|
156
164
|
conversationId: unifyConversationId,
|
|
165
|
+
...(groupId ? { groupId } : {}),
|
|
157
166
|
event,
|
|
158
167
|
});
|
|
159
168
|
}
|
|
@@ -775,6 +784,7 @@ export function parseThreadPrefix(text) {
|
|
|
775
784
|
*/
|
|
776
785
|
function forwardPipelineEvent(ev, ctx) {
|
|
777
786
|
if (!ev || typeof ev !== 'object') return false;
|
|
787
|
+
const gid = ctx && ctx.groupId;
|
|
778
788
|
switch (ev.type) {
|
|
779
789
|
case 'input_queue_updated':
|
|
780
790
|
sendUnifyEvent({
|
|
@@ -784,7 +794,7 @@ function forwardPipelineEvent(ev, ctx) {
|
|
|
784
794
|
routing: ev.routing,
|
|
785
795
|
dispatched: ev.dispatched,
|
|
786
796
|
head: ev.head,
|
|
787
|
-
});
|
|
797
|
+
}, gid);
|
|
788
798
|
return false;
|
|
789
799
|
case 'routing_decision':
|
|
790
800
|
sendUnifyEvent({
|
|
@@ -794,7 +804,7 @@ function forwardPipelineEvent(ev, ctx) {
|
|
|
794
804
|
targetThreadId: ev.targetThreadId,
|
|
795
805
|
source: ev.source,
|
|
796
806
|
reason: ev.reason,
|
|
797
|
-
});
|
|
807
|
+
}, gid);
|
|
798
808
|
return false;
|
|
799
809
|
case 'thread_list_updated':
|
|
800
810
|
// Dispatcher built it already; just forward.
|
|
@@ -802,7 +812,7 @@ function forwardPipelineEvent(ev, ctx) {
|
|
|
802
812
|
type: 'thread_list_updated',
|
|
803
813
|
threads: ev.threads,
|
|
804
814
|
currentThreadId: ev.currentThreadId,
|
|
805
|
-
});
|
|
815
|
+
}, gid);
|
|
806
816
|
return false;
|
|
807
817
|
case 'engine_event':
|
|
808
818
|
ctx.onEngineEvent(ev.event, ev.threadId);
|
|
@@ -827,6 +837,7 @@ function forwardPipelineEvent(ev, ctx) {
|
|
|
827
837
|
*/
|
|
828
838
|
function handleEngineEvent(event, threadId, hctx) {
|
|
829
839
|
hctx.resetQueryTimer();
|
|
840
|
+
const gid = hctx && hctx.groupId;
|
|
830
841
|
|
|
831
842
|
// task-325b: translate Engine lifecycle events into a single
|
|
832
843
|
// `thread_status` event for the frontend Working Status panel. These
|
|
@@ -851,11 +862,11 @@ function handleEngineEvent(event, threadId, hctx) {
|
|
|
851
862
|
type: 'assistant',
|
|
852
863
|
message: { content: [{ type: 'text', text: event.text }] },
|
|
853
864
|
threadId,
|
|
854
|
-
});
|
|
865
|
+
}, gid);
|
|
855
866
|
break;
|
|
856
867
|
|
|
857
868
|
case 'thinking_delta':
|
|
858
|
-
sendUnifyEvent({ type: 'thinking_delta', text: event.text, threadId });
|
|
869
|
+
sendUnifyEvent({ type: 'thinking_delta', text: event.text, threadId }, gid);
|
|
859
870
|
break;
|
|
860
871
|
|
|
861
872
|
case 'tool_call':
|
|
@@ -874,7 +885,7 @@ function handleEngineEvent(event, threadId, hctx) {
|
|
|
874
885
|
type: 'assistant',
|
|
875
886
|
message: { content: [] },
|
|
876
887
|
threadId,
|
|
877
|
-
});
|
|
888
|
+
}, gid);
|
|
878
889
|
sendUnifyOutput({
|
|
879
890
|
type: 'assistant',
|
|
880
891
|
message: {
|
|
@@ -886,7 +897,7 @@ function handleEngineEvent(event, threadId, hctx) {
|
|
|
886
897
|
}],
|
|
887
898
|
},
|
|
888
899
|
threadId: event.threadId || threadId,
|
|
889
|
-
});
|
|
900
|
+
}, gid);
|
|
890
901
|
break;
|
|
891
902
|
|
|
892
903
|
case 'tool_start':
|
|
@@ -895,7 +906,7 @@ function handleEngineEvent(event, threadId, hctx) {
|
|
|
895
906
|
id: event.id,
|
|
896
907
|
name: event.name,
|
|
897
908
|
threadId: event.threadId || threadId,
|
|
898
|
-
});
|
|
909
|
+
}, gid);
|
|
899
910
|
break;
|
|
900
911
|
|
|
901
912
|
case 'tool_end':
|
|
@@ -919,7 +930,7 @@ function handleEngineEvent(event, threadId, hctx) {
|
|
|
919
930
|
is_error: event.isError || false,
|
|
920
931
|
}],
|
|
921
932
|
threadId: event.threadId || threadId,
|
|
922
|
-
});
|
|
933
|
+
}, gid);
|
|
923
934
|
if (THREAD_MUTATING_TOOLS.has(event.name)) {
|
|
924
935
|
sendThreadListUpdate();
|
|
925
936
|
}
|
|
@@ -937,7 +948,7 @@ function handleEngineEvent(event, threadId, hctx) {
|
|
|
937
948
|
inputTokens: event.inputTokens,
|
|
938
949
|
outputTokens: event.outputTokens,
|
|
939
950
|
threadId,
|
|
940
|
-
});
|
|
951
|
+
}, gid);
|
|
941
952
|
break;
|
|
942
953
|
|
|
943
954
|
case 'recall':
|
|
@@ -946,7 +957,7 @@ function handleEngineEvent(event, threadId, hctx) {
|
|
|
946
957
|
entryCount: event.entryCount,
|
|
947
958
|
cached: event.cached,
|
|
948
959
|
threadId,
|
|
949
|
-
});
|
|
960
|
+
}, gid);
|
|
950
961
|
break;
|
|
951
962
|
|
|
952
963
|
case 'consolidate':
|
|
@@ -962,7 +973,7 @@ function handleEngineEvent(event, threadId, hctx) {
|
|
|
962
973
|
archivedCount: event.archivedCount,
|
|
963
974
|
extractedCount: event.extractedCount,
|
|
964
975
|
threadId,
|
|
965
|
-
});
|
|
976
|
+
}, gid);
|
|
966
977
|
break;
|
|
967
978
|
|
|
968
979
|
case 'fallback':
|
|
@@ -972,7 +983,7 @@ function handleEngineEvent(event, threadId, hctx) {
|
|
|
972
983
|
to: event.to,
|
|
973
984
|
reason: event.reason,
|
|
974
985
|
threadId,
|
|
975
|
-
});
|
|
986
|
+
}, gid);
|
|
976
987
|
break;
|
|
977
988
|
|
|
978
989
|
case 'debug_turn':
|
|
@@ -992,7 +1003,7 @@ function handleEngineEvent(event, threadId, hctx) {
|
|
|
992
1003
|
rawRequest: event.rawRequest,
|
|
993
1004
|
rawResponse: event.rawResponse,
|
|
994
1005
|
threadId,
|
|
995
|
-
});
|
|
1006
|
+
}, gid);
|
|
996
1007
|
break;
|
|
997
1008
|
|
|
998
1009
|
case 'error': {
|
|
@@ -1012,7 +1023,7 @@ function handleEngineEvent(event, threadId, hctx) {
|
|
|
1012
1023
|
}],
|
|
1013
1024
|
},
|
|
1014
1025
|
threadId,
|
|
1015
|
-
});
|
|
1026
|
+
}, gid);
|
|
1016
1027
|
}
|
|
1017
1028
|
// Don't show subsequent permission errors.
|
|
1018
1029
|
} else {
|
|
@@ -1022,7 +1033,7 @@ function handleEngineEvent(event, threadId, hctx) {
|
|
|
1022
1033
|
content: [{ type: 'text', text: `⚠️ Error: ${errMsg}` }],
|
|
1023
1034
|
},
|
|
1024
1035
|
threadId,
|
|
1025
|
-
});
|
|
1036
|
+
}, gid);
|
|
1026
1037
|
}
|
|
1027
1038
|
break;
|
|
1028
1039
|
}
|
|
@@ -1089,6 +1100,53 @@ export async function handleUnifyGroupChat(msg) {
|
|
|
1089
1100
|
return;
|
|
1090
1101
|
}
|
|
1091
1102
|
|
|
1103
|
+
// Bug 2: When the user @-mentions a VP that exists in the library but is
|
|
1104
|
+
// not yet in the group's roster, auto-add it. This is the natural "invite"
|
|
1105
|
+
// gesture in group chat — failing here would punt to the legacy fallback
|
|
1106
|
+
// and surface the misleading "only Yeaft is in this conversation" error
|
|
1107
|
+
// even though the VP exists. We also ensure the group has a defaultVpId
|
|
1108
|
+
// when its roster is non-empty, so unaddressed messages route correctly.
|
|
1109
|
+
try {
|
|
1110
|
+
const meta = groupHandle.getMeta();
|
|
1111
|
+
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
1112
|
+
const wantsAdd = mentions.filter(
|
|
1113
|
+
(m) => m && m !== 'all' && !meta.roster.includes(m)
|
|
1114
|
+
);
|
|
1115
|
+
if (wantsAdd.length && yeaftDir) {
|
|
1116
|
+
let mutated = false;
|
|
1117
|
+
for (const vpId of wantsAdd) {
|
|
1118
|
+
try {
|
|
1119
|
+
const vp = readVp(vpId);
|
|
1120
|
+
if (!vp) continue;
|
|
1121
|
+
addMember(yeaftDir, groupId, vpId);
|
|
1122
|
+
mutated = true;
|
|
1123
|
+
} catch { /* skip strangers */ }
|
|
1124
|
+
}
|
|
1125
|
+
if (mutated) {
|
|
1126
|
+
// Re-open with fresh meta so the coordinator sees the new roster.
|
|
1127
|
+
try { groupHandle.close && groupHandle.close(); } catch { /* best-effort */ }
|
|
1128
|
+
const { openGroup } = await import('./groups/group-store.js');
|
|
1129
|
+
const { join } = await import('node:path');
|
|
1130
|
+
groupHandle = openGroup(join(yeaftDir, 'groups'), groupId);
|
|
1131
|
+
sendGroupRosterChanged(groupHandle.getMeta());
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
// Heal missing defaultVpId — pick roster[0] when one exists.
|
|
1135
|
+
const meta2 = groupHandle.getMeta();
|
|
1136
|
+
if (!meta2.defaultVpId && meta2.roster.length && yeaftDir) {
|
|
1137
|
+
try {
|
|
1138
|
+
setGroupDefaultVp(yeaftDir, groupId, meta2.roster[0]);
|
|
1139
|
+
try { groupHandle.close && groupHandle.close(); } catch { /* best-effort */ }
|
|
1140
|
+
const { openGroup } = await import('./groups/group-store.js');
|
|
1141
|
+
const { join } = await import('node:path');
|
|
1142
|
+
groupHandle = openGroup(join(yeaftDir, 'groups'), groupId);
|
|
1143
|
+
sendGroupRosterChanged(groupHandle.getMeta());
|
|
1144
|
+
} catch { /* best-effort */ }
|
|
1145
|
+
}
|
|
1146
|
+
} catch (err) {
|
|
1147
|
+
console.warn('[Unify] unify_group_chat: auto-roster heal failed', err?.message || err);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1092
1150
|
// Adapter layer (PM red-line: do NOT modify coordinator to fit this
|
|
1093
1151
|
// consumer). We drive `createCoordinator()` with a capturing `deliver`
|
|
1094
1152
|
// callback, collect its dispatched/fallback report, then translate each
|
|
@@ -1238,6 +1296,10 @@ export async function handleUnifyChat(msg) {
|
|
|
1238
1296
|
// branch and reach the dispatcher.submit() queryOpts.
|
|
1239
1297
|
const vpId = typeof msg.vpId === 'string' && msg.vpId.trim() ? msg.vpId.trim() : null;
|
|
1240
1298
|
const groupCoordinator = msg._groupCoordinator || null;
|
|
1299
|
+
// Bug 1: every event we emit during this query must carry the originating
|
|
1300
|
+
// groupId so the frontend stamps arriving messages with the SEND-context
|
|
1301
|
+
// group, not the user's CURRENT filter (which can change mid-reply).
|
|
1302
|
+
const groupId = typeof msg.groupId === 'string' && msg.groupId.trim() ? msg.groupId.trim() : null;
|
|
1241
1303
|
|
|
1242
1304
|
// Deprecation warning — task-297 removed chat/work mode distinction
|
|
1243
1305
|
if (mode !== undefined && mode !== null) {
|
|
@@ -1358,14 +1420,16 @@ export async function handleUnifyChat(msg) {
|
|
|
1358
1420
|
routing: 0,
|
|
1359
1421
|
dispatched: 0,
|
|
1360
1422
|
head: { id: entry.id, status: entry.status, text: entry.text.slice(0, 80) },
|
|
1361
|
-
});
|
|
1423
|
+
}, groupId);
|
|
1362
1424
|
|
|
1363
1425
|
const pipelineCtx = {
|
|
1426
|
+
groupId,
|
|
1364
1427
|
onEngineEvent: (event, threadId) => handleEngineEvent(event, threadId, {
|
|
1365
1428
|
assistantTextParts,
|
|
1366
1429
|
toolCallsAccum,
|
|
1367
1430
|
toolResultsAccum,
|
|
1368
1431
|
resetQueryTimer,
|
|
1432
|
+
groupId,
|
|
1369
1433
|
}),
|
|
1370
1434
|
onError: (err) => { throw err; },
|
|
1371
1435
|
};
|
|
@@ -1427,12 +1491,12 @@ export async function handleUnifyChat(msg) {
|
|
|
1427
1491
|
sendUnifyOutput({
|
|
1428
1492
|
type: 'assistant',
|
|
1429
1493
|
message: { content: [] },
|
|
1430
|
-
});
|
|
1494
|
+
}, groupId);
|
|
1431
1495
|
// Send result to clear processing state
|
|
1432
1496
|
sendUnifyOutput({
|
|
1433
1497
|
type: 'result',
|
|
1434
1498
|
result_text: '',
|
|
1435
|
-
});
|
|
1499
|
+
}, groupId);
|
|
1436
1500
|
|
|
1437
1501
|
} finally {
|
|
1438
1502
|
// Always clear the timeout guard
|
|
@@ -1451,7 +1515,7 @@ export async function handleUnifyChat(msg) {
|
|
|
1451
1515
|
sendUnifyOutput({
|
|
1452
1516
|
type: 'result',
|
|
1453
1517
|
result_text: '',
|
|
1454
|
-
});
|
|
1518
|
+
}, groupId);
|
|
1455
1519
|
return;
|
|
1456
1520
|
}
|
|
1457
1521
|
|
|
@@ -1469,7 +1533,7 @@ export async function handleUnifyChat(msg) {
|
|
|
1469
1533
|
text: '⚠️ Cannot write to ~/.yeaft/ directory — some features (memory, history) are unavailable. Please check directory permissions: `chmod -R u+rw ~/.yeaft/`',
|
|
1470
1534
|
}],
|
|
1471
1535
|
},
|
|
1472
|
-
});
|
|
1536
|
+
}, groupId);
|
|
1473
1537
|
}
|
|
1474
1538
|
} else {
|
|
1475
1539
|
sendUnifyOutput({
|
|
@@ -1480,13 +1544,13 @@ export async function handleUnifyChat(msg) {
|
|
|
1480
1544
|
text: `⚠️ Session error: ${err.message}`,
|
|
1481
1545
|
}],
|
|
1482
1546
|
},
|
|
1483
|
-
});
|
|
1547
|
+
}, groupId);
|
|
1484
1548
|
}
|
|
1485
1549
|
// Still send result to clear processing state
|
|
1486
1550
|
sendUnifyOutput({
|
|
1487
1551
|
type: 'result',
|
|
1488
1552
|
result_text: '',
|
|
1489
|
-
});
|
|
1553
|
+
}, groupId);
|
|
1490
1554
|
} finally {
|
|
1491
1555
|
// task-320: only clear the per-thread slot if THIS controller is still
|
|
1492
1556
|
// the registered one. If a newer message already overwrote it, leaving
|
|
@@ -2123,8 +2187,11 @@ export async function handleUnifyLoadHistory(msg) {
|
|
|
2123
2187
|
// task-334m: replay groups snapshot so Sidebar Groups rebuilds on refresh.
|
|
2124
2188
|
sendGroupSnapshotBroadcast();
|
|
2125
2189
|
|
|
2126
|
-
|
|
2127
|
-
|
|
2190
|
+
// Honor explicit limit:0 — frontend uses it on Unify re-entry to refresh
|
|
2191
|
+
// metadata (model/status/group snapshot via the unconditional replay
|
|
2192
|
+
// above) without re-streaming the message history.
|
|
2193
|
+
const limit = (typeof msg.limit === 'number') ? msg.limit : 50;
|
|
2194
|
+
const messages = limit > 0 ? session.conversationStore.loadRecent(limit) : [];
|
|
2128
2195
|
const compactSummary = session.conversationStore.readCompactSummary();
|
|
2129
2196
|
|
|
2130
2197
|
// Send each message through standard claude_output rendering pipeline
|