@yeaft/webchat-agent 0.1.751 → 0.1.753

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.751",
3
+ "version": "0.1.753",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -62,12 +62,17 @@ export class Compactor {
62
62
  * treats that as a soft failure).
63
63
  * @param {() => number|undefined} [opts.getMaxContextTokens]
64
64
  * Returns `config.maxContextTokens` for `shouldCompactHistory`.
65
+ * @param {() => string|undefined} [opts.getLanguage]
66
+ * Returns the live `config.language`. Threaded into
67
+ * `compactHistory` so the compactor's summary prompt + the
68
+ * "session continued" wrapper render in the user's preferred
69
+ * locale instead of always English.
65
70
  * @param {(groupId: string, result: CompactedResult) => void} [opts.onCompacted]
66
71
  * Optional sink. Bridge wires this to send the
67
72
  * `unify_history_compacted` WS event. Default: no-op. Can be
68
73
  * replaced post-construction via `setOnCompacted`.
69
74
  */
70
- constructor({ summarize, getMaxContextTokens, onCompacted } = {}) {
75
+ constructor({ summarize, getMaxContextTokens, getLanguage, onCompacted } = {}) {
71
76
  if (typeof summarize !== 'function') {
72
77
  throw new TypeError('Compactor: summarize is required');
73
78
  }
@@ -75,6 +80,9 @@ export class Compactor {
75
80
  this._getMaxContextTokens = typeof getMaxContextTokens === 'function'
76
81
  ? getMaxContextTokens
77
82
  : () => undefined;
83
+ this._getLanguage = typeof getLanguage === 'function'
84
+ ? getLanguage
85
+ : () => undefined;
78
86
  this._onCompacted = typeof onCompacted === 'function' ? onCompacted : () => {};
79
87
  /** @type {Map<string, { inFlight: Promise<void>|null, pending: boolean }>} */
80
88
  this._states = new Map();
@@ -186,7 +194,11 @@ export class Compactor {
186
194
  const summarize = ({ system, prompt }) =>
187
195
  this._summarize({ system, prompt, maxTokens: SUMMARIZER_MAX_TOKENS });
188
196
 
189
- const result = await compactHistory(snapshot, { summarize, maxContextTokens });
197
+ const result = await compactHistory(snapshot, {
198
+ summarize,
199
+ maxContextTokens,
200
+ language: this._getLanguage(),
201
+ });
190
202
  if (!result || !result.compacted) {
191
203
  if (result && result.error) {
192
204
  console.warn(
@@ -28,7 +28,11 @@ import { snapshotScope } from './snapshot.js';
28
28
  import { parseJsonSafe } from './triage.js';
29
29
  import { render } from './prompts/index.js';
30
30
 
31
- const SYSTEM = `You are the Apply stage of a dream pipeline. You rewrite a single scope's memory.md and summary.md based on recent group conversations. Reply with strict JSON only — no prose, no fences.`;
31
+ function applySystem(language) {
32
+ return String(language || '').toLowerCase().startsWith('zh')
33
+ ? '你是梦境流水线的 Apply 阶段。你会根据最近的群组对话重写单个 scope 的 memory.md 和 summary.md。请只回复严格 JSON,不要输出说明文字或 markdown fence。memory_md 和 summary_md 的自然语言内容必须使用中文;JSON key、scope、schema 字段和代码标识符保持英文。'
34
+ : 'You are the Apply stage of a dream pipeline. You rewrite a single scope\'s memory.md and summary.md based on recent group conversations. Reply with strict JSON only — no prose, no fences.';
35
+ }
32
36
 
33
37
  /**
34
38
  * Build the UPDATE prompt body. Accepts the current scope state +
@@ -44,16 +48,18 @@ const SYSTEM = `You are the Apply stage of a dream pipeline. You rewrite a singl
44
48
  */
45
49
  export function buildUpdatePrompt(ctx) {
46
50
  const batchHeader = (ctx.batchInfo && ctx.batchInfo.total > 1)
47
- ? `This is batch ${ctx.batchInfo.index} of ${ctx.batchInfo.total}.\nEarlier batches have already been folded into the current memory.md below.\n`
51
+ ? (String(ctx.language || '').toLowerCase().startsWith('zh')
52
+ ? `这是第 ${ctx.batchInfo.index}/${ctx.batchInfo.total} 批。前面的批次已经合并进下面当前的 memory.md。\n`
53
+ : `This is batch ${ctx.batchInfo.index} of ${ctx.batchInfo.total}.\nEarlier batches have already been folded into the current memory.md below.\n`)
48
54
  : '';
49
- const sources = renderSourceBlocks(ctx.sources);
55
+ const sources = renderSourceBlocks(ctx.sources, ctx.language);
50
56
  return render('update', {
51
57
  target: ctx.target,
52
58
  batchHeader,
53
59
  memoryMd: ctx.memoryMd || '',
54
60
  summaryMd: ctx.summaryMd || '',
55
61
  sources,
56
- });
62
+ }, { language: ctx.language });
57
63
  }
58
64
 
59
65
  /**
@@ -67,10 +73,10 @@ export function buildUpdatePrompt(ctx) {
67
73
  * }} ctx
68
74
  */
69
75
  export function buildCreatePrompt(ctx) {
70
- const sources = renderSourceBlocks(ctx.sources);
76
+ const sources = renderSourceBlocks(ctx.sources, ctx.language);
71
77
  let siblingsBlock = '';
72
78
  if (ctx.siblingTopics && ctx.siblingTopics.length > 0) {
73
- const lines = ['For tone reference, sibling/parent topic summaries:'];
79
+ const lines = [String(ctx.language || '').toLowerCase().startsWith('zh') ? '语气参考:同级/父级 topic 摘要:' : 'For tone reference, sibling/parent topic summaries:'];
74
80
  for (const t of ctx.siblingTopics) lines.push(` - ${t.path}: ${oneLine(t.summary)}`);
75
81
  lines.push('');
76
82
  siblingsBlock = lines.join('\n');
@@ -79,7 +85,7 @@ export function buildCreatePrompt(ctx) {
79
85
  target: ctx.target,
80
86
  sources,
81
87
  siblingsBlock,
82
- });
88
+ }, { language: ctx.language });
83
89
  }
84
90
 
85
91
  /**
@@ -89,13 +95,13 @@ export function buildCreatePrompt(ctx) {
89
95
  *
90
96
  * @param {Array<{ groupId: string, diff: Array<object> }>} sources
91
97
  */
92
- function renderSourceBlocks(sources) {
98
+ function renderSourceBlocks(sources, language) {
93
99
  const out = [];
94
100
  for (const src of (sources || [])) {
95
101
  out.push('');
96
102
  out.push(`[group/${src.groupId}]`);
97
103
  for (const m of (src.diff || [])) {
98
- const head = `[${m.role || 'message'}${m.kind === 'overlap' ? ' (already processed)' : ''}]`;
104
+ const head = `[${m.role || 'message'}${m.kind === 'overlap' ? (String(language || '').toLowerCase().startsWith('zh') ? '(已处理)' : ' (already processed)') : ''}]`;
99
105
  out.push(head);
100
106
  out.push(truncateMessage(m.body || ''));
101
107
  }
@@ -174,9 +180,10 @@ export async function applyMergedTarget(merged, opts) {
174
180
  target: merged.target,
175
181
  sources: merged.sources,
176
182
  siblingTopics: siblings,
183
+ language: opts.language,
177
184
  });
178
185
  if (opts.onProgress) opts.onProgress({ phase: 'apply', target: merged.target, status: 'llm', batch: 1, of: 1 });
179
- const raw = await opts.llm({ pass: 'create', prompt, system: SYSTEM });
186
+ const raw = await opts.llm({ pass: 'create', prompt, system: applySystem(opts.language) });
180
187
  const parsed = parseJsonSafe(raw);
181
188
  if (!parsed || typeof parsed.memory_md !== 'string') {
182
189
  throw new Error(`apply: CREATE returned malformed JSON for ${merged.target}`);
@@ -202,9 +209,10 @@ export async function applyMergedTarget(merged, opts) {
202
209
  summaryMd,
203
210
  sources: batch,
204
211
  batchInfo: { index: i, total: batches.length },
212
+ language: opts.language,
205
213
  });
206
214
  if (opts.onProgress) opts.onProgress({ phase: 'apply', target: merged.target, status: 'llm', batch: i, of: batches.length });
207
- const raw = await opts.llm({ pass: 'update', prompt, system: SYSTEM });
215
+ const raw = await opts.llm({ pass: 'update', prompt, system: applySystem(opts.language) });
208
216
  const parsed = parseJsonSafe(raw);
209
217
  if (!parsed || typeof parsed.memory_md !== 'string') {
210
218
  throw new Error(`apply: UPDATE batch ${i} returned malformed JSON for ${merged.target}`);
@@ -48,6 +48,16 @@ export function extractTemplateForScope(scope) {
48
48
  /** @type {Record<string, string>} */
49
49
  const cache = {};
50
50
 
51
+ export function normalizePromptLanguage(language) {
52
+ return String(language || '').toLowerCase().startsWith('zh') ? 'zh' : 'en';
53
+ }
54
+
55
+ function languageDirective(language) {
56
+ return normalizePromptLanguage(language) === 'zh'
57
+ ? '语言要求:请用中文生成所有自然语言内容,尤其是 memory_md 和 summary_md;工具名、scope、JSON key、schema 字段、代码标识符和枚举值必须保持英文。严格按要求输出 JSON 时,不要翻译 JSON key。'
58
+ : 'Language requirement: write all natural-language memory content in English. Keep tool names, scopes, JSON keys, schema fields, code identifiers, and enum values in English. When strict JSON is required, do not rename JSON keys.';
59
+ }
60
+
51
61
  function load(name) {
52
62
  if (cache[name]) return cache[name];
53
63
  const file = FILES[name];
@@ -63,13 +73,17 @@ function load(name) {
63
73
  *
64
74
  * @param {string} name
65
75
  * @param {Record<string, string>} vars
76
+ * @param {{ language?: string, includeLanguageDirective?: boolean }} [opts]
66
77
  */
67
- export function render(name, vars) {
78
+ export function render(name, vars, opts = {}) {
68
79
  const tpl = load(name);
69
- return tpl.replace(/\{\{(\w+)\}\}/g, (_m, key) => {
80
+ const body = tpl.replace(/\{\{(\w+)\}\}/g, (_m, key) => {
70
81
  if (!(key in vars)) throw new Error(`prompts.${name}: missing var ${key}`);
71
82
  return vars[key];
72
83
  });
84
+ if (opts.includeLanguageDirective === false) return body;
85
+ if (!opts.language) return body;
86
+ return `${languageDirective(opts.language)}\n\n${body}`;
73
87
  }
74
88
 
75
89
  /** Test-only: drop the in-memory cache so a fresh read re-loads from disk. */
@@ -143,6 +143,7 @@ export async function runDream(opts) {
143
143
  topicSummaries,
144
144
  llm: opts.llm,
145
145
  onProgress,
146
+ language: opts.language,
146
147
  });
147
148
  } catch (err) {
148
149
  groupsReport.push({ groupId, new: newCount, status: 'error', error: err.message });
@@ -178,6 +179,7 @@ export async function runDream(opts) {
178
179
  nowIso: opts.nowIso || (() => nowIso),
179
180
  onProgress,
180
181
  siblingTopicsFor: opts.siblingTopicsFor,
182
+ language: opts.language,
181
183
  });
182
184
  targetsReport.push({ ...r, sources: merged.sources.length, status: 'done' });
183
185
  } catch (err) {
@@ -31,6 +31,7 @@ export function buildRunDreamOpts(session, onProgress) {
31
31
 
32
32
  return {
33
33
  root: memoryRoot,
34
+ language: session.config?.language || 'en',
34
35
  llm: makeLlm(session),
35
36
  listGroups: async () => {
36
37
  try { return listGroups(groupsRoot).map(g => g.id); }
@@ -110,7 +111,9 @@ function makeLlm(session) {
110
111
  }
111
112
  const r = await adapter.call({
112
113
  model,
113
- system: system || `You are the dream pipeline — pass: ${pass}.`,
114
+ system: system || (String(session.config?.language || '').toLowerCase().startsWith('zh')
115
+ ? `你是梦境流水线 — pass: ${pass}。请用中文生成自然语言内容;JSON key 保持英文。`
116
+ : `You are the dream pipeline — pass: ${pass}.`),
114
117
  messages: [{ role: 'user', content: prompt }],
115
118
  maxTokens: 2048,
116
119
  });
@@ -39,7 +39,11 @@ import { isValidTopic } from '../memory/store-v2.js';
39
39
  import { truncateMessage } from './segment.js';
40
40
  import { render } from './prompts/index.js';
41
41
 
42
- const SYSTEM = `You are the Triage stage of a dream pipeline that decides which scopes a recent group conversation should affect. Reply with strict JSON only — no prose, no markdown fences.`;
42
+ function triageSystem(language) {
43
+ return String(language || '').toLowerCase().startsWith('zh')
44
+ ? '你是梦境流水线的 Triage 阶段,负责判断最近的群组对话会影响哪些 scope。请只回复严格 JSON,不要输出说明文字或 markdown fence。自然语言内容使用中文;JSON key、scope 和枚举值保持英文。'
45
+ : 'You are the Triage stage of a dream pipeline that decides which scopes a recent group conversation should affect. Reply with strict JSON only — no prose, no markdown fences.';
46
+ }
43
47
 
44
48
  /**
45
49
  * Hard rules: deterministically derive must-include scopes from the
@@ -89,11 +93,11 @@ export function applyHardRules({ groupId, messages }) {
89
93
  */
90
94
  export function buildPass1Prompt(ctx) {
91
95
  const topicSummaries = (!ctx.topicSummaries || ctx.topicSummaries.length === 0)
92
- ? ' (none)'
96
+ ? (String(ctx.language || '').toLowerCase().startsWith('zh') ? ' (无)' : ' (none)')
93
97
  : ctx.topicSummaries.map(t => ` - ${t.path} — ${oneLine(t.summary)}`).join('\n');
94
98
  const conv = [];
95
99
  for (const m of (ctx.messages || [])) {
96
- const head = `[${m.role || 'message'}${m.kind === 'overlap' ? ' (already processed)' : ''}]`;
100
+ const head = `[${m.role || 'message'}${m.kind === 'overlap' ? (String(ctx.language || '').toLowerCase().startsWith('zh') ? '(已处理)' : ' (already processed)') : ''}]`;
97
101
  conv.push(head);
98
102
  conv.push(truncateMessage(m.body || ''));
99
103
  conv.push('');
@@ -102,7 +106,7 @@ export function buildPass1Prompt(ctx) {
102
106
  groupId: ctx.groupId,
103
107
  topicSummaries,
104
108
  conversation: conv.join('\n').trimEnd(),
105
- });
109
+ }, { language: ctx.language });
106
110
  }
107
111
 
108
112
  /**
@@ -112,12 +116,12 @@ export function buildPass1Prompt(ctx) {
112
116
  */
113
117
  export function buildPass2Prompt(ctx) {
114
118
  const existingTopics = (!ctx.existingTopics || ctx.existingTopics.length === 0)
115
- ? ' (none)'
119
+ ? (String(ctx.language || '').toLowerCase().startsWith('zh') ? ' (无)' : ' (none)')
116
120
  : ctx.existingTopics.map(t => ` - ${t.path} — ${oneLine(t.summary)}`).join('\n');
117
121
  return render('triagePass2', {
118
122
  description: ctx.description,
119
123
  existingTopics,
120
- });
124
+ }, { language: ctx.language });
121
125
  }
122
126
 
123
127
  /**
@@ -131,10 +135,10 @@ export function buildPass2Prompt(ctx) {
131
135
  * }} args
132
136
  * @returns {Promise<Array<{ kind: 'update'|'create', scope: string }>>}
133
137
  */
134
- export async function classifySoft({ groupId, messages, topicSummaries, llm }) {
138
+ export async function classifySoft({ groupId, messages, topicSummaries, llm, language }) {
135
139
  if (!llm) throw new Error('triage.classifySoft: llm callable required');
136
- const pass1Prompt = buildPass1Prompt({ groupId, messages, topicSummaries });
137
- const pass1Raw = await llm({ pass: 'triage-pass1', prompt: pass1Prompt, system: SYSTEM });
140
+ const pass1Prompt = buildPass1Prompt({ groupId, messages, topicSummaries, language });
141
+ const pass1Raw = await llm({ pass: 'triage-pass1', prompt: pass1Prompt, system: triageSystem(language) });
138
142
  const pass1 = parseJsonSafe(pass1Raw);
139
143
  const out = [];
140
144
 
@@ -151,8 +155,9 @@ export async function classifySoft({ groupId, messages, topicSummaries, llm }) {
151
155
  const pass2Prompt = buildPass2Prompt({
152
156
  description: description.trim(),
153
157
  existingTopics: topicSummaries || [],
158
+ language,
154
159
  });
155
- const pass2Raw = await llm({ pass: 'triage-pass2', prompt: pass2Prompt, system: SYSTEM });
160
+ const pass2Raw = await llm({ pass: 'triage-pass2', prompt: pass2Prompt, system: triageSystem(language) });
156
161
  const pass2 = parseJsonSafe(pass2Raw);
157
162
  if (!pass2 || !pass2.decision) continue;
158
163
  if (pass2.decision === 'none') continue;
@@ -201,7 +206,7 @@ export async function triageOneSegment(args) {
201
206
  * }} args
202
207
  * @returns {Promise<Array<{ kind: 'update'|'create', scope: string }>>}
203
208
  */
204
- export async function triageGroupSegments({ groupId, segments, topicSummaries, llm, onProgress }) {
209
+ export async function triageGroupSegments({ groupId, segments, topicSummaries, llm, onProgress, language }) {
205
210
  let acc = [];
206
211
  let i = 0;
207
212
  for (const seg of (segments || [])) {
@@ -212,6 +217,7 @@ export async function triageGroupSegments({ groupId, segments, topicSummaries, l
212
217
  messages: seg.messages,
213
218
  topicSummaries,
214
219
  llm,
220
+ language,
215
221
  });
216
222
  acc = dedupeActions([...acc, ...segActions]);
217
223
  }
package/unify/engine.js CHANGED
@@ -464,7 +464,7 @@ export class Engine {
464
464
  */
465
465
  #getToolDefs() {
466
466
  if (this.#toolRegistry) {
467
- return this.#toolRegistry.getToolDefs();
467
+ return this.#toolRegistry.getToolDefs(this.#config?.language || 'en');
468
468
  }
469
469
  // Legacy path: no mode filtering
470
470
  const defs = [];
@@ -645,7 +645,9 @@ export class Engine {
645
645
  runLLM: async (prompt) => {
646
646
  const out = await this.#adapter.call({
647
647
  model: this.#fastConfig.model,
648
- system: 'You are a memory-management subroutine. Reply with a single JSON object as instructed.',
648
+ system: (String(this.#config?.language || '').toLowerCase().startsWith('zh')
649
+ ? '你是记忆管理子程序。请按要求只回复一个 JSON 对象,不要输出额外说明。'
650
+ : 'You are a memory-management subroutine. Reply with a single JSON object as instructed.'),
649
651
  messages: [{ role: 'user', content: prompt }],
650
652
  maxTokens: 1024,
651
653
  });
@@ -973,13 +975,23 @@ export class Engine {
973
975
 
974
976
  const archiveIds = [];
975
977
 
978
+ // Language-aware summarizer prompts. The orchestrator-track summary
979
+ // ends up in the system prompt as a "previous conversation summary"
980
+ // block, so it needs to match the user's preferred language to avoid
981
+ // a jarring locale flip mid-context.
982
+ const isZh = String(this.#config.language || '').toLowerCase().startsWith('zh');
983
+ const summariserSystem = isZh
984
+ ? '你是对话摘要器。请用中文写出 2–3 段简明摘要,保留决策、事实与上下文。'
985
+ : 'You are a conversation summarizer. Summarize concisely in 2–3 paragraphs, preserving decisions, facts, and context.';
986
+ const summariserPromptPrefix = isZh ? '请概括:\n\n' : 'Summarize:\n\n';
987
+
976
988
  const hooks = {
977
989
  summarise: async () => {
978
990
  try {
979
991
  const result = await adapter.call({
980
992
  model: fastConfig.model,
981
- system: 'You are a conversation summarizer. Summarize concisely in 2–3 paragraphs, preserving decisions, facts, and context.',
982
- messages: [{ role: 'user', content: `Summarize:\n\n${toArchive.map(m => `[${m.role}] ${(m.content || '').slice(0, 500)}`).join('\n\n')}` }],
993
+ system: summariserSystem,
994
+ messages: [{ role: 'user', content: `${summariserPromptPrefix}${toArchive.map(m => `[${m.role}] ${(m.content || '').slice(0, 500)}`).join('\n\n')}` }],
983
995
  maxTokens: 1024,
984
996
  });
985
997
  return (result.text || '').trim();
@@ -328,16 +328,22 @@ export function findCutIndex(messages, keepRecent) {
328
328
  * `server/db/message-db.js`) recognise it.
329
329
  *
330
330
  * @param {string} summary
331
+ * @param {{ language?: string }} [opts]
331
332
  * @returns {{role:'user', content:string, _compactSummary: true}}
332
333
  */
333
- export function wrapSummaryAsUserMessage(summary) {
334
+ export function wrapSummaryAsUserMessage(summary, opts = {}) {
334
335
  const body = (summary || '').trim() || '(no summary produced)';
335
- const content =
336
- 'This session is being continued from a previous conversation. ' +
337
- 'The earlier context has been summarized for efficiency.\n\n' +
338
- 'Summary of conversation so far:\n' +
339
- body +
340
- '\n\nContinue the conversation from where it left off without asking the user any further questions.';
336
+ const isZh = String(opts.language || '').toLowerCase().startsWith('zh');
337
+ const content = isZh
338
+ ? '本会话延续自之前的对话。早期上下文已经被概括以节省空间。\n\n' +
339
+ '至此为止的对话摘要:\n' +
340
+ body +
341
+ '\n\n请从中断处继续对话,不要再向用户重复确认。'
342
+ : 'This session is being continued from a previous conversation. ' +
343
+ 'The earlier context has been summarized for efficiency.\n\n' +
344
+ 'Summary of conversation so far:\n' +
345
+ body +
346
+ '\n\nContinue the conversation from where it left off without asking the user any further questions.';
341
347
  return {
342
348
  role: 'user',
343
349
  content,
@@ -349,24 +355,36 @@ export function wrapSummaryAsUserMessage(summary) {
349
355
  * Build the prompt fed to the fast-model summarizer. Kept in code (not in
350
356
  * a template file) because it's small and lives alongside the call site.
351
357
  *
358
+ * The summarizer prompt itself is language-aware: callers pass the live
359
+ * `config.language` so the produced summary is written in the user's
360
+ * preferred language. JSON-style structural cues stay English so the
361
+ * summary remains easy to splice into the next turn regardless of locale.
362
+ *
352
363
  * @param {Array<{role:string, content:string}>} cleanedMessages
364
+ * @param {{ language?: string }} [opts]
353
365
  * @returns {{system: string, prompt: string}}
354
366
  */
355
- export function buildSummaryPrompt(cleanedMessages) {
367
+ export function buildSummaryPrompt(cleanedMessages, opts = {}) {
356
368
  const transcript = cleanedMessages
357
369
  .map(m => `[${m.role}]\n${m.content}`)
358
370
  .join('\n\n---\n\n');
359
- const system =
360
- 'You are a conversation summarizer for a multi-agent group chat. ' +
361
- 'Produce a concise (4–8 short bullet points) summary of the conversation ' +
362
- 'so far. Preserve: (1) decisions made, (2) facts learned, (3) the user\'s ' +
363
- 'current goal, (4) any open questions or pending actions, (5) which VPs ' +
364
- 'are participating and what each contributed. Do NOT include raw tool ' +
365
- 'output. Do NOT speculate. Be specific.';
366
- const prompt =
367
- 'Summarize the following conversation. Output ONLY the summary, no ' +
368
- 'preamble.\n\n' +
369
- transcript;
371
+ const isZh = String(opts.language || '').toLowerCase().startsWith('zh');
372
+ const system = isZh
373
+ ? '你是多 agent 群聊的对话摘要器。请用中文写出 4–8 条简明 bullet 摘要。' +
374
+ '保留:(1) 已做的决策,(2) 已学到的事实,(3) 用户当前目标,' +
375
+ '(4) 任何未解决的问题或待办事项,(5) 哪些 VP 参与了对话以及各自贡献。' +
376
+ '不要包含原始工具输出。不要臆测。要具体。'
377
+ : 'You are a conversation summarizer for a multi-agent group chat. ' +
378
+ 'Produce a concise (4–8 short bullet points) summary of the conversation ' +
379
+ 'so far. Preserve: (1) decisions made, (2) facts learned, (3) the user\'s ' +
380
+ 'current goal, (4) any open questions or pending actions, (5) which VPs ' +
381
+ 'are participating and what each contributed. Do NOT include raw tool ' +
382
+ 'output. Do NOT speculate. Be specific.';
383
+ const prompt = isZh
384
+ ? '请概括下面的对话。只输出摘要正文,不要前言。\n\n' + transcript
385
+ : 'Summarize the following conversation. Output ONLY the summary, no ' +
386
+ 'preamble.\n\n' +
387
+ transcript;
370
388
  return { system, prompt };
371
389
  }
372
390
 
@@ -384,6 +402,7 @@ export function buildSummaryPrompt(cleanedMessages) {
384
402
  * maxContextTokens?: number,
385
403
  * tokenFraction?: number,
386
404
  * hardTokenCeiling?: number,
405
+ * language?: string,
387
406
  * }} options
388
407
  * @returns {Promise<{
389
408
  * messages: Array<object>,
@@ -407,6 +426,7 @@ export async function compactHistory(messages, options) {
407
426
  maxContextTokens,
408
427
  tokenFraction,
409
428
  hardTokenCeiling,
429
+ language,
410
430
  } = options || {};
411
431
 
412
432
  if (typeof summarize !== 'function') {
@@ -460,7 +480,7 @@ export async function compactHistory(messages, options) {
460
480
 
461
481
  let summaryText = '';
462
482
  if (cleaned.length > 0) {
463
- const { system, prompt } = buildSummaryPrompt(cleaned);
483
+ const { system, prompt } = buildSummaryPrompt(cleaned, { language });
464
484
  try {
465
485
  summaryText = (await summarize({ system, prompt })) || '';
466
486
  } catch (err) {
@@ -498,7 +518,7 @@ export async function compactHistory(messages, options) {
498
518
  }
499
519
  }
500
520
 
501
- const summaryMsg = wrapSummaryAsUserMessage(summaryText);
521
+ const summaryMsg = wrapSummaryAsUserMessage(summaryText, { language });
502
522
 
503
523
  // Defensive pair-sanitize: the cut at `cutIdx` lands at a user-message
504
524
  // boundary so an `[assistant(toolCalls), tool…]` arc is not split, but
package/unify/prompts.js CHANGED
@@ -178,6 +178,7 @@ const PROMPTS = {
178
178
  `💡 ${min}min since last summary (+${count} new messages). Consider calling \`task_summary_post\`.`,
179
179
  // DESIGN-PROMPT §3 ④ — Active Scope header
180
180
  activeScopeHeader: '## active_scope',
181
+ groupAnnouncementHeader: '[Group Announcement]',
181
182
  vpPersonaIntro: (name, role) =>
182
183
  `You ARE **${name}**${role ? ` (${role})` : ''}. Speak in the first person as ${name}; do not refer to yourself as "Yeaft" or as a generic AI assistant. The text below is your identity, expertise, and decision style.`,
183
184
  },
@@ -193,6 +194,7 @@ const PROMPTS = {
193
194
  `💡 距上次 summary 已过 ${min}min,新增 ${count} 条消息,建议调用 \`task_summary_post\`。`,
194
195
  // DESIGN-PROMPT §3 ④ — Active Scope header
195
196
  activeScopeHeader: '## active_scope',
197
+ groupAnnouncementHeader: '[群组公告]',
196
198
  vpPersonaIntro: (name, role) =>
197
199
  `你就是 **${name}**${role ? `(${role})` : ''}。请以 ${name} 的第一人称发言;不要自称 "Yeaft" 或泛指的 AI 助手。下面的文字是你的身份、专业方向与判断风格。`,
198
200
  },
@@ -201,6 +203,28 @@ const PROMPTS = {
201
203
  /** Supported language codes. */
202
204
  export const SUPPORTED_LANGUAGES = Object.keys(PROMPTS);
203
205
 
206
+ /**
207
+ * Return true for Chinese locales. Real app config persists values like
208
+ * `zh-CN`; prompt templates are keyed by the base language (`zh`).
209
+ *
210
+ * @param {string} language
211
+ * @returns {boolean}
212
+ */
213
+ export function isZhLanguage(language) {
214
+ return String(language || '').toLowerCase().startsWith('zh');
215
+ }
216
+
217
+ /**
218
+ * Normalize app/user locale to the prompt dictionary key.
219
+ * Protocol identifiers stay English; this only selects visible prose.
220
+ *
221
+ * @param {string} language
222
+ * @returns {'en'|'zh'}
223
+ */
224
+ export function normalizePromptLanguage(language) {
225
+ return isZhLanguage(language) ? 'zh' : 'en';
226
+ }
227
+
204
228
  /**
205
229
  * Build the system prompt for a given language.
206
230
  *
@@ -267,9 +291,9 @@ export function buildSystemPrompt({
267
291
  vpPersona,
268
292
  groupAnnouncement = '',
269
293
  } = {}) {
270
- // Fallback to English for unknown languages
271
- const lang = PROMPTS[language] || PROMPTS.en;
272
- const effectiveLang = PROMPTS[language] ? language : 'en';
294
+ // Normalize app locales like `zh-CN` to prompt dictionary/template keys.
295
+ const effectiveLang = normalizePromptLanguage(language);
296
+ const lang = PROMPTS[effectiveLang] || PROMPTS.en;
273
297
 
274
298
  const parts = [];
275
299
 
@@ -299,7 +323,7 @@ export function buildSystemPrompt({
299
323
  // instructions. Empty/whitespace = no block emitted.
300
324
  const annText = (typeof groupAnnouncement === 'string') ? groupAnnouncement.trim() : '';
301
325
  if (annText) {
302
- parts.push(`[Group Announcement]\n${annText}`);
326
+ parts.push(`${lang.groupAnnouncementHeader || '[Group Announcement]'}\n${annText}`);
303
327
  }
304
328
 
305
329
  // ─── 2. Date Metadata ──────────────────────────────────
@@ -674,7 +698,8 @@ const LAYER_A_HEADERS = {
674
698
  */
675
699
  export function renderLayerASummaries(summaries, language = 'en') {
676
700
  if (!summaries || typeof summaries !== 'object') return '';
677
- const headers = LAYER_A_HEADERS[language] || LAYER_A_HEADERS.en;
701
+ const effectiveLang = normalizePromptLanguage(language);
702
+ const headers = LAYER_A_HEADERS[effectiveLang] || LAYER_A_HEADERS.en;
678
703
  const out = [];
679
704
  for (const key of ['user', 'group', 'vp']) {
680
705
  const body = typeof summaries[key] === 'string' ? summaries[key].trim() : '';
@@ -712,17 +737,18 @@ export function buildWorkerPrompt(params = {}) {
712
737
  includeShape = true,
713
738
  ...rest
714
739
  } = params;
740
+ const effectiveLang = normalizePromptLanguage(language);
715
741
 
716
742
  const parts = [];
717
743
 
718
744
  // Optional harness — describes the layered shape.
719
745
  if (includeShape) {
720
- const shape = getTemplate('harnessWorkerShape', language);
746
+ const shape = getTemplate('harnessWorkerShape', effectiveLang);
721
747
  if (shape) parts.push(shape);
722
748
  }
723
749
 
724
750
  // Identity + Rules + Memory + Active Scope (DESIGN-PROMPT §3).
725
- const baseBlock = buildSystemPrompt({ ...rest, language });
751
+ const baseBlock = buildSystemPrompt({ ...rest, language: effectiveLang });
726
752
  if (baseBlock) parts.push(baseBlock);
727
753
 
728
754
  return parts.join('\n\n');
@@ -739,7 +765,8 @@ export function buildWorkerPrompt(params = {}) {
739
765
  */
740
766
  export function renderPriorPlan(priorPlan, language = 'en') {
741
767
  if (!priorPlan || typeof priorPlan !== 'object') return '';
742
- const header = language === 'zh' ? '## 上一轮 plan' : '## prior_plan';
768
+ const effectiveLang = normalizePromptLanguage(language);
769
+ const header = effectiveLang === 'zh' ? '## 上一轮 plan' : '## prior_plan';
743
770
  const lines = [];
744
771
  if (priorPlan.vpId) lines.push(`vpId: ${priorPlan.vpId}`);
745
772
  const fq = priorPlan.forwardQuery;
@@ -780,17 +807,18 @@ export function renderPriorPlan(priorPlan, language = 'en') {
780
807
  */
781
808
  export function buildRouterPrompt(params = {}) {
782
809
  const { language = 'en', summaries, routerContext, priorPlan, includeShape = true } = params;
810
+ const effectiveLang = normalizePromptLanguage(language);
783
811
  const parts = [];
784
812
 
785
813
  if (includeShape) {
786
- const shape = getTemplate('harnessRouterShape', language);
814
+ const shape = getTemplate('harnessRouterShape', effectiveLang);
787
815
  if (shape) parts.push(shape);
788
816
  }
789
817
 
790
- const summaryBlock = renderLayerASummaries(summaries, language);
818
+ const summaryBlock = renderLayerASummaries(summaries, effectiveLang);
791
819
  if (summaryBlock) parts.push(summaryBlock);
792
820
 
793
- const priorBlock = renderPriorPlan(priorPlan, language);
821
+ const priorBlock = renderPriorPlan(priorPlan, effectiveLang);
794
822
  if (priorBlock) parts.push(priorBlock);
795
823
 
796
824
  if (typeof routerContext === 'string' && routerContext.trim()) {
package/unify/session.js CHANGED
@@ -315,6 +315,13 @@ export async function loadSession(options = {}) {
315
315
  engine.summarizeForCompact({ system, prompt, maxTokens }),
316
316
  getMaxContextTokens: () =>
317
317
  typeof config.maxContextTokens === 'number' ? config.maxContextTokens : undefined,
318
+ // Live-read: `config.language` is mutated in place by
319
+ // `engine.setLanguage()` (which broadcastLanguageChange fans out to
320
+ // every per-VP engine). The compactor must see the post-broadcast
321
+ // value, not a boot-time snapshot, so the summary prompt + the
322
+ // "session continued" wrapper render in the user's current locale.
323
+ getLanguage: () =>
324
+ typeof config.language === 'string' ? config.language : undefined,
318
325
  });
319
326
 
320
327
  // ─── 9a. Create dream scheduler ────────────
@@ -41,6 +41,69 @@ import { DEFAULT_CONTEXT_WINDOW } from '../models.js';
41
41
  const TOOL_RESULT_CAP_RATIO = 0.10;
42
42
  const TOOL_RESULT_MIN_CAP = 8 * 1024;
43
43
 
44
+ function normalizeLanguage(language) {
45
+ return String(language || '').toLowerCase().startsWith('zh') ? 'zh' : 'en';
46
+ }
47
+
48
+ function localizeVisibleText(value, language, toolName) {
49
+ const lang = normalizeLanguage(language);
50
+ if (typeof value === 'function') return localizeVisibleText(value(lang), lang, toolName);
51
+ if (value && typeof value === 'object') {
52
+ const picked = value[lang] || value[lang === 'zh' ? 'zh-CN' : 'en-US'] || value.en || value.default;
53
+ if (typeof picked === 'string') return picked;
54
+ }
55
+ const text = typeof value === 'string' ? value : String(value || '');
56
+ if (lang !== 'zh') return text;
57
+ if (!text.trim()) return text;
58
+ return [
59
+ `工具说明:${toolName || '该工具'}。请严格按照 schema 调用;工具名、参数名、JSON key 和枚举值保持英文,不要翻译。`,
60
+ `原始协议说明(英文,供精确调用参考):${text}`,
61
+ ].join('\n');
62
+ }
63
+
64
+ /**
65
+ * Walk a JSON Schema `parameters` object and localize the human-readable
66
+ * `description` strings to the requested language. All other schema bits
67
+ * (`type`, `enum`, `required`, `items`, nested `properties`, etc.) are
68
+ * preserved by value.
69
+ *
70
+ * Critical correctness rule: a JSON Schema can have a *property named*
71
+ * `description` whose value is itself a sub-schema (e.g.
72
+ * `properties: { description: { type: 'string', description: 'Detailed...' }}`).
73
+ * In that case the OUTER `description` key holds the sub-schema and must
74
+ * be recursed into; only the INNER `description: 'Detailed...'` value
75
+ * (which is a string) should be localized.
76
+ *
77
+ * Previous bug: this walker localized ANY value under a `description`
78
+ * key, including sub-schema objects. `localizeVisibleText` then ran
79
+ * `String(value)` on the object, producing the literal string
80
+ * `'[object Object]'`. GPT-5's strict schema validator rejected the
81
+ * resulting `{ description: '[object Object]' }` with
82
+ * `"'[object Object]' is not of type 'object', 'boolean'"`. This made
83
+ * FeatureCreate (and any tool whose schema contains a property named
84
+ * `description`) unusable in zh locale on strict providers.
85
+ *
86
+ * The fix: only treat a `description` value as localizable text when it
87
+ * is actually a string. Object/array values under a `description` key
88
+ * are sub-schemas and must be recursed into normally.
89
+ */
90
+ function localizeParameters(parameters, language, toolName) {
91
+ const lang = normalizeLanguage(language);
92
+ if (lang !== 'zh' || !parameters || typeof parameters !== 'object') return parameters;
93
+ if (Array.isArray(parameters)) return parameters.map(v => localizeParameters(v, lang, toolName));
94
+ const out = {};
95
+ for (const [key, value] of Object.entries(parameters)) {
96
+ if (key === 'description' && typeof value === 'string') {
97
+ out[key] = localizeVisibleText(value, lang, toolName);
98
+ } else if (value && typeof value === 'object') {
99
+ out[key] = localizeParameters(value, lang, toolName);
100
+ } else {
101
+ out[key] = value;
102
+ }
103
+ }
104
+ return out;
105
+ }
106
+
44
107
  /**
45
108
  * Per-tool execution timeout (ms).
46
109
  *
@@ -99,7 +162,7 @@ export class ToolExecutionTimeoutError extends Error {
99
162
  * @param {{ contextWindow?: number, toolName: string }} opts
100
163
  * @returns {string}
101
164
  */
102
- export function truncateToolResultIfNeeded(output, { contextWindow, toolName }) {
165
+ export function truncateToolResultIfNeeded(output, { contextWindow, toolName, language } = {}) {
103
166
  let text;
104
167
  if (typeof output === 'string') {
105
168
  text = output;
@@ -116,7 +179,9 @@ export function truncateToolResultIfNeeded(output, { contextWindow, toolName })
116
179
  const cap = Math.max(TOOL_RESULT_MIN_CAP, Math.floor(ctx * TOOL_RESULT_CAP_RATIO));
117
180
  if (text.length <= cap) return text;
118
181
  const head = text.slice(0, cap);
119
- const marker = `\n\n[truncated: ${toolName} returned ${formatSize(text.length)}, capped at ${formatSize(cap)}; the model will not see the rest of this output]`;
182
+ const marker = normalizeLanguage(language) === 'zh'
183
+ ? `\n\n[已截断:${toolName} 返回 ${formatSize(text.length)},上限为 ${formatSize(cap)};模型不会看到剩余内容]`
184
+ : `\n\n[truncated: ${toolName} returned ${formatSize(text.length)}, capped at ${formatSize(cap)}; the model will not see the rest of this output]`;
120
185
  return head + marker;
121
186
  }
122
187
 
@@ -213,13 +278,15 @@ export class ToolRegistry {
213
278
  /**
214
279
  * Get tool definitions for the LLM adapter.
215
280
  * Returns all registered tools — mode filtering was removed in task-297.
281
+ * @param {string} [language='en']
216
282
  * @returns {{ name: string, description: string, parameters: object }[]}
217
283
  */
218
- getToolDefs() {
284
+ getToolDefs(language = 'en') {
285
+ const lang = normalizeLanguage(language);
219
286
  return this.getAllTools().map(t => ({
220
287
  name: t.name,
221
- description: t.description,
222
- parameters: t.parameters,
288
+ description: localizeVisibleText(t.description, lang, t.name),
289
+ parameters: localizeParameters(t.parameters, lang, t.name),
223
290
  }));
224
291
  }
225
292
 
@@ -264,6 +331,7 @@ export class ToolRegistry {
264
331
  return truncateToolResultIfNeeded(output, {
265
332
  contextWindow: ctx.contextWindow,
266
333
  toolName: name,
334
+ language: ctx.config?.language,
267
335
  });
268
336
  }
269
337