@yeaft/webchat-agent 1.0.177 → 1.0.178

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": "1.0.177",
3
+ "version": "1.0.178",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -27,8 +27,8 @@ export class Registry {
27
27
  /**
28
28
  * Replace a VP's persona fields in-place, preserving identity so any
29
29
  * downstream reference keeps its handle stable across hot-reload.
30
- * Fields copied: name, role, traits, modelHint, persona, personaHash,
31
- * mtimeMs.
30
+ * Fields copied: localized identity metadata, aliases, role, traits,
31
+ * modelHint, persona, personaHash, planInstruction, and mtimeMs.
32
32
  */
33
33
  updateVpInPlace(next) {
34
34
  const cur = this.vpMap.get(next.id);
@@ -37,11 +37,18 @@ export class Registry {
37
37
  return next;
38
38
  }
39
39
  cur.name = next.name;
40
+ cur.nameZh = next.nameZh;
41
+ cur.aliases = next.aliases;
42
+ cur.description = next.description;
43
+ cur.descriptionZh = next.descriptionZh;
40
44
  cur.role = next.role;
45
+ cur.roleZh = next.roleZh;
46
+ cur.area = next.area;
41
47
  cur.traits = next.traits;
42
48
  cur.modelHint = next.modelHint;
43
49
  cur.persona = next.persona;
44
50
  cur.personaHash = next.personaHash;
51
+ cur.planInstruction = next.planInstruction;
45
52
  cur.mtimeMs = next.mtimeMs;
46
53
  // dir / memoryDir / id stable
47
54
  return cur;
@@ -1,14 +1,14 @@
1
1
  /**
2
- * seed-defaults.js — task-337: first-run seed of 33 default Virtual Persons.
2
+ * seed-defaults.js — task-337: first-run seed of 34 default Virtual Persons.
3
3
  *
4
4
  * Problem: A brand-new VP library is empty, and asking the user to author
5
5
  * dozens of personas before they can even start chatting is a non-starter.
6
6
  *
7
- * Solution: On first-run (libDir empty or missing), materialise 33 classic
7
+ * Solution: On first-run (libDir empty or missing), materialise 34 classic
8
8
  * personas with hand-crafted prompts so the session experience works
9
- * out of the box. Originally 12 (engineering/design/science/security/business);
10
- * expanded to 32 by adding philosophy / psychology / strategy / history /
11
- * investing / business / writing / science / arts (task: VP roster expansion).
9
+ * out of the box. The original engineering/design/science/security/business
10
+ * roster was expanded with philosophy, psychology, strategy, history,
11
+ * investing, writing, arts, a generalist, and a cloud-scale language architect.
12
12
  *
13
13
  * Idempotent: if ANY VP directory already exists under libDir, this is a
14
14
  * no-op. We never overwrite user-authored VPs, never "upgrade" existing
@@ -34,14 +34,14 @@ import { DEFAULT_VP_LIB_DIR } from './vp-store.js';
34
34
  import { STOCK_VP_IDS } from './stock-ids.js';
35
35
 
36
36
  /**
37
- * The 33 default VPs. Each entry is a valid `createVp` payload.
37
+ * The 34 default VPs. Each entry is a valid `createVp` payload.
38
38
  * Persona bodies are authored directly per stock member in English and Chinese.
39
39
  * Legacy bodies are kept only for exact-match safe upgrades.
40
40
  *
41
- * Order is intentional: the original 12 (engineering/design/science/security/
42
- * business) come first, then the 20 expansion VPs organized by area, followed by
43
- * the generalist entry point. Sidebar organization by area is a future PR; today
44
- * the field is data-only.
41
+ * Order is intentional: the core engineering/design/science/security/business
42
+ * roster comes first, then the expansion VPs organized by area, followed by the
43
+ * generalist entry point. Sidebar organization by area is a future PR; today the
44
+ * field is data-only.
45
45
  */
46
46
  const DEFAULT_VP_DEFINITIONS = Object.freeze([
47
47
  {
@@ -167,6 +167,35 @@ Good for: PR reviews, legacy cleanup, architecture conversations, naming debates
167
167
  Bad for: greenfield scaffolding from zero, raw performance tuning, UI design.`,
168
168
  },
169
169
 
170
+ {
171
+ vpId: 'anders',
172
+ displayName: 'Anders Hejlsberg',
173
+ displayNameZh: '安德斯·海尔斯伯格',
174
+ aliases: ['anders', 'hejlsberg', 'andesi', 'haierxiboge'],
175
+ role: 'Language and Cloud Systems Architect',
176
+ roleZh: '语言与云系统架构师',
177
+ description: 'Language design, API compatibility, and reliable cloud-scale evolution',
178
+ descriptionZh: '语言设计、API 兼容性与可靠的云规模演进',
179
+ area: 'engineering',
180
+ traits: [
181
+ 'language-design', 'reliability', 'backward-compatibility',
182
+ 'observability', 'operational-simplicity', 'incremental-evolution',
183
+ 'ownership-boundaries',
184
+ ],
185
+ modelHint: 'primary',
186
+ personaEn: `You are Anders Hejlsberg. You designed C#, TypeScript, and Turbo Pascal. You review code as if it belongs to a large-scale Azure service with millions of users, where a locally elegant choice can become an operational liability at fleet scale.
187
+
188
+ You prioritize reliability, backward compatibility, observability, operational simplicity, safe incremental evolution, and clear ownership boundaries. Language and API design matter because contracts outlive implementations; migrations, failure modes, diagnostics, and rollback paths are part of the design, not afterthoughts.
189
+
190
+ Users bring you language, API, platform, and cloud-service changes. Show them the contract that can evolve safely, the compatibility hazards, the operational signals, and the smallest rollout that proves the change. Reject solutions that are beautiful in isolation but difficult to operate across a large service.`,
191
+ personaZh: `你是安德斯·海尔斯伯格。你设计了 C#、TypeScript 和 Turbo Pascal。你把代码当作服务于数百万用户的 Azure 级系统来评审:局部看似优雅的选择,到了大规模运行环境里可能就是长期运维负担。
192
+
193
+ 你优先考虑可靠性、向后兼容、可观测性、运维简洁、安全的增量演进和清晰的责任边界。语言与 API 设计的核心是长期契约;迁移路径、失败模式、诊断能力和回滚方案本来就是设计的一部分。
194
+
195
+ 用户通常带来语言、API、平台或云服务改动。你会指出能够安全演进的契约、兼容性风险、必要的运行信号,以及验证改动的最小发布步骤。你拒绝那些孤立看很漂亮、在云规模下却难以运营的方案。`,
196
+ legacyPersonaEn: '',
197
+ },
198
+
170
199
  {
171
200
  vpId: 'dieter',
172
201
  displayName: 'Dieter Rams',
@@ -1401,6 +1430,42 @@ Answer style: concise, organized, and forward-moving. When routing is needed, ro
1401
1430
  }
1402
1431
  ]);
1403
1432
 
1433
+ const DEFAULT_VP_DESCRIPTIONS = Object.freeze({
1434
+ steve: ['Product vision, ruthless prioritization, and launch narrative', '产品愿景、果断取舍与发布叙事'],
1435
+ linus: ['Implementation, root-cause debugging, performance, and reliability', '代码实现、根因排查、性能与可靠性'],
1436
+ martin: ['Code review, refactoring, architecture boundaries, and maintainability', '代码评审、重构、架构边界与可维护性'],
1437
+ dieter: ['Minimal UI, design systems, and usability review', '极简界面、设计系统与可用性评审'],
1438
+ ada: ['Algorithms, data structures, and formal modeling', '算法、数据结构与形式化建模'],
1439
+ grace: ['Debugging, runtime behavior, and clear technical explanation', '调试、运行时行为与清晰技术解释'],
1440
+ alice: ['Threat modeling, authorization boundaries, and input security', '威胁建模、授权边界与输入安全'],
1441
+ ken: ['Unix systems, simple interfaces, and composable architecture', 'Unix 系统、简单接口与可组合架构'],
1442
+ margaret: ['Mission-critical testing, failure handling, and release quality', '关键任务测试、故障处理与发布质量'],
1443
+ shannon: ['Information theory, metrics, and signal extraction', '信息论、指标设计与信号提取'],
1444
+ alan: ['Object models, system architecture, and future-facing design', '对象模型、系统架构与前瞻设计'],
1445
+ norman: ['User research, cognitive load, and interaction design', '用户研究、认知负担与交互设计'],
1446
+ kongzi: ['Ethics, social order, and long-term conduct', '伦理、社会秩序与长期行为准则'],
1447
+ socrates: ['Assumption testing, precise questions, and logical clarity', '假设检验、精准追问与逻辑澄清'],
1448
+ nietzsche: ['Values critique, hidden motives, and bold reframing', '价值批判、隐性动机与大胆重构问题'],
1449
+ kahneman: ['Cognitive bias, decision quality, and experiment design', '认知偏差、决策质量与实验设计'],
1450
+ jung: ['Archetypes, motivation, and narrative psychology', '原型、动机与叙事心理学'],
1451
+ sunzi: ['Strategy, positioning, leverage, and conflict avoidance', '战略、定位、杠杆与避战'],
1452
+ clausewitz: ['Strategic friction, uncertainty, and execution under pressure', '战略摩擦、不确定性与压力下执行'],
1453
+ simaqian: ['Historical context, causality, and durable narratives', '历史语境、因果关系与长期叙事'],
1454
+ harari: ['Macro trends, institutions, and long-horizon consequences', '宏观趋势、制度与长期影响'],
1455
+ buffett: ['Business quality, valuation, and durable advantages', '企业质量、估值与持久竞争优势'],
1456
+ munger: ['Mental models, incentives, and decision debiasing', '多元思维模型、激励与决策纠偏'],
1457
+ dalio: ['Principles, feedback loops, and organizational systems', '原则、反馈回路与组织系统'],
1458
+ bezos: ['Customer obsession, long-term bets, and operational mechanisms', '客户执念、长期投入与运营机制'],
1459
+ drucker: ['Management, organizational focus, and measurable outcomes', '管理、组织聚焦与可衡量成果'],
1460
+ luxun: ['Sharp criticism, cultural diagnosis, and concise writing', '尖锐批评、文化诊断与精炼写作'],
1461
+ sudongpo: ['Elegant writing, broad perspective, and humane judgment', '优雅写作、开阔视角与人文判断'],
1462
+ borges: ['Conceptual structures, nonlinear narrative, and intellectual play', '概念结构、非线性叙事与智性想象'],
1463
+ einstein: ['First principles, thought experiments, and radical simplification', '第一性原理、思想实验与极致简化'],
1464
+ kubrick: ['Visual storytelling, pacing, and exacting creative control', '视觉叙事、节奏与严格创作控制'],
1465
+ miyazaki: ['World-building, emotional storytelling, and humane imagination', '世界构建、情感叙事与人文想象'],
1466
+ omni: ['Requirement clarification, workflow routing, and delivery coordination', '需求澄清、流程路由与交付协调'],
1467
+ });
1468
+
1404
1469
  const LEGACY_DEFAULT_VP_PERSONA_ZH = Object.freeze({
1405
1470
  steve: {
1406
1471
  roleZh: '产品战略家',
@@ -1873,10 +1938,13 @@ function localizeDefaultVpPersona(vp) {
1873
1938
  const personaZh = String(vp.personaZh || '').trim();
1874
1939
  const legacyPersonaEn = String(vp.legacyPersonaEn || '').trim();
1875
1940
  const legacyPersonaZh = String(legacyZh?.persona || '').trim();
1941
+ const descriptions = DEFAULT_VP_DESCRIPTIONS[vp.vpId] || [];
1876
1942
 
1877
1943
  return {
1878
1944
  ...vp,
1879
1945
  roleZh: vp.roleZh || legacyZh?.roleZh || '',
1946
+ description: String(vp.description || descriptions[0] || vp.role || '').trim(),
1947
+ descriptionZh: String(vp.descriptionZh || descriptions[1] || vp.roleZh || legacyZh?.roleZh || descriptions[0] || vp.role || '').trim(),
1880
1948
  persona: localizedPersonaSections(personaEn, personaZh),
1881
1949
  personaEn,
1882
1950
  personaZh,
@@ -1946,7 +2014,7 @@ function libraryHasAnyVp(libDir) {
1946
2014
  }
1947
2015
 
1948
2016
  /**
1949
- * Seed the 33 default VPs into `libDir` if and only if the library is empty.
2017
+ * Seed the 34 default VPs into `libDir` if and only if the library is empty.
1950
2018
  *
1951
2019
  * Idempotent: returns `{ seeded: 0, skipped: true }` on every call after the
1952
2020
  * first one (or when the user has any VP at all, including manually-created).
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Problem: `seedDefaultVps` is first-run-only — once the library has any VP
5
5
  * in it, that function never runs again. When we expanded the default roster
6
- * from 12 to 33 (philosophy, psychology, strategy, history, investing,
7
- * business, writing, science, arts, Omni), existing installs would never see the
6
+ * beyond the original 12 (philosophy, psychology, strategy, history,
7
+ * investing, business, writing, science, arts, Omni, and later additions),
8
+ * existing installs would never see the
8
9
  * new VPs without either (a) the user manually deleting their library or
9
10
  * (b) a forced overwrite that would clobber their hand edits.
10
11
  *
@@ -15,7 +16,8 @@
15
16
  * on disk, `createVp()` it. This keeps product-owned defaults such as
16
17
  * Omni and the expanded role roster visible in session/member pickers.
17
18
  *
18
- * 2. **Backfill missing stock frontmatter** (`area`, `nameZh`, `roleZh`) on
19
+ * 2. **Backfill missing stock frontmatter** (`area`, `nameZh`, `description`,
20
+ * `descriptionZh`, `roleZh`) on
19
21
  * existing seeded VPs whose role.md predates those fields. The persona body
20
22
  * is left BYTE-IDENTICAL for these metadata backfills. If the user has
21
23
  * authored their own value, we keep theirs.
@@ -176,10 +178,11 @@ function insertFrontmatterLine(source, key, value) {
176
178
  const lines = yaml.split(/\r?\n/);
177
179
  const roleIdx = lines.findIndex(l => /^role:\s*/.test(l));
178
180
  // Quote the value when it contains characters YAML treats specially or
179
- // non-ASCII bytes the existing vp-crud writer does the same dance.
181
+ // non-ASCII bytes. Single quotes keep backslashes and double quotes literal;
182
+ // doubled apostrophes are reversed by vp-store's scalar parser.
180
183
  const needsQuote = /[:#"'\\\n]/.test(valTrim) || /[^\x20-\x7e]/.test(valTrim);
181
184
  const yamlVal = needsQuote
182
- ? `"${valTrim.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
185
+ ? `'${valTrim.replace(/'/g, "''")}'`
183
186
  : valTrim;
184
187
  const newLine = `${key}: ${yamlVal}`;
185
188
  if (roleIdx >= 0) {
@@ -216,6 +219,16 @@ export function insertNameZhLine(source, nameZh) {
216
219
  return insertFrontmatterLine(source, 'nameZh', nameZh);
217
220
  }
218
221
 
222
+ /** Backfill `description:` into role.md frontmatter. */
223
+ export function insertDescriptionLine(source, description) {
224
+ return insertFrontmatterLine(source, 'description', description);
225
+ }
226
+
227
+ /** Backfill `descriptionZh:` into role.md frontmatter. */
228
+ export function insertDescriptionZhLine(source, descriptionZh) {
229
+ return insertFrontmatterLine(source, 'descriptionZh', descriptionZh);
230
+ }
231
+
219
232
  /**
220
233
  * Backfill `roleZh:` into role.md frontmatter.
221
234
  *
@@ -272,6 +285,8 @@ function isObsoleteOmniAssistantBody(vp, body) {
272
285
  * added: string[],
273
286
  * areaBackfilled: string[],
274
287
  * nameZhBackfilled: string[],
288
+ * descriptionBackfilled: string[],
289
+ * descriptionZhBackfilled: string[],
275
290
  * roleZhBackfilled: string[],
276
291
  * personaBackfilled: string[],
277
292
  * respectedDeletes: string[],
@@ -283,6 +298,8 @@ export function topUpDefaultVps(libDir = DEFAULT_VP_LIB_DIR) {
283
298
  const added = [];
284
299
  const areaBackfilled = [];
285
300
  const nameZhBackfilled = [];
301
+ const descriptionBackfilled = [];
302
+ const descriptionZhBackfilled = [];
286
303
  const roleZhBackfilled = [];
287
304
  const personaBackfilled = [];
288
305
  const respectedDeletes = [];
@@ -294,7 +311,7 @@ export function topUpDefaultVps(libDir = DEFAULT_VP_LIB_DIR) {
294
311
  // that case there's nothing to top up — seedDefaultVps will populate
295
312
  // everything. We still return cleanly.
296
313
  if (!existsSync(libDir)) {
297
- return { added, areaBackfilled, nameZhBackfilled, roleZhBackfilled, personaBackfilled, respectedDeletes, skippedExisting, errors };
314
+ return { added, areaBackfilled, nameZhBackfilled, descriptionBackfilled, descriptionZhBackfilled, roleZhBackfilled, personaBackfilled, respectedDeletes, skippedExisting, errors };
298
315
  }
299
316
 
300
317
  let versions = readSeedVersions(libDir);
@@ -373,6 +390,44 @@ export function topUpDefaultVps(libDir = DEFAULT_VP_LIB_DIR) {
373
390
  });
374
391
  }
375
392
  }
393
+ if (vp.description) {
394
+ try {
395
+ const src = readRole();
396
+ if (src != null) {
397
+ const patched = insertDescriptionLine(src, vp.description);
398
+ if (patched != null && patched !== src) {
399
+ writeFileSync(rolePath, patched, 'utf-8');
400
+ currentSrc = patched;
401
+ descriptionBackfilled.push(vpId);
402
+ }
403
+ }
404
+ } catch (err) {
405
+ errors.push({
406
+ vpId,
407
+ code: 'description_backfill_failed',
408
+ message: String(err?.message || err),
409
+ });
410
+ }
411
+ }
412
+ if (vp.descriptionZh) {
413
+ try {
414
+ const src = readRole();
415
+ if (src != null) {
416
+ const patched = insertDescriptionZhLine(src, vp.descriptionZh);
417
+ if (patched != null && patched !== src) {
418
+ writeFileSync(rolePath, patched, 'utf-8');
419
+ currentSrc = patched;
420
+ descriptionZhBackfilled.push(vpId);
421
+ }
422
+ }
423
+ } catch (err) {
424
+ errors.push({
425
+ vpId,
426
+ code: 'description_zh_backfill_failed',
427
+ message: String(err?.message || err),
428
+ });
429
+ }
430
+ }
376
431
  if (vp.roleZh) {
377
432
  try {
378
433
  const src = readRole();
@@ -452,5 +507,5 @@ export function topUpDefaultVps(libDir = DEFAULT_VP_LIB_DIR) {
452
507
  }
453
508
 
454
509
  writeSeedVersions(libDir, versions);
455
- return { added, areaBackfilled, nameZhBackfilled, roleZhBackfilled, personaBackfilled, respectedDeletes, skippedExisting, errors };
510
+ return { added, areaBackfilled, nameZhBackfilled, descriptionBackfilled, descriptionZhBackfilled, roleZhBackfilled, personaBackfilled, respectedDeletes, skippedExisting, errors };
456
511
  }
@@ -25,7 +25,7 @@
25
25
 
26
26
  const STOCK_VP_ID_LIST = Object.freeze([
27
27
  // engineering
28
- 'steve', 'linus', 'martin', 'dieter', 'ada', 'grace', 'alice', 'ken',
28
+ 'steve', 'linus', 'martin', 'anders', 'dieter', 'ada', 'grace', 'alice', 'ken',
29
29
  'margaret', 'shannon', 'alan', 'norman',
30
30
  // philosophy / psychology
31
31
  'kongzi', 'socrates', 'nietzsche', 'kahneman', 'jung',
@@ -8,6 +8,7 @@
8
8
  *
9
9
  * Per ruling §2 (D2):
10
10
  * • subtitle → agent emits `vp.role` directly
11
+ * • capability descriptions → additive localized wire fields
11
12
  * • personaHash → agent emits `vp.personaHash`
12
13
  * • color / avatar → web-derived, NOT emitted here
13
14
  *
@@ -194,8 +195,8 @@ function ensureLoader(registry = defaultRegistry, options = {}) {
194
195
  * Serialise a VP (entity layer shape) to the wire-format the web layer
195
196
  * expects (spec §2.1). Pure; no IO.
196
197
  *
197
- * @param {{id:string,name:string,role:string,nameZh?:string,aliases?:string[],traits?:string[],modelHint?:string,personaHash?:string}} vp
198
- * @returns {{vpId:string,displayName:string,displayNameZh:string,aliases:string[],subtitle:string,role:string,traits:string[],modelHint:?string,personaHash:?string,isStock:boolean}}
198
+ * @param {{id:string,name:string,role:string,nameZh?:string,description?:string,descriptionZh?:string,roleZh?:string,aliases?:string[],traits?:string[],modelHint?:string,personaHash?:string}} vp
199
+ * @returns {{vpId:string,displayName:string,displayNameZh:string,description:string,descriptionZh:string,aliases:string[],subtitle:string,role:string,roleZh:string,traits:string[],modelHint:?string,personaHash:?string,isStock:boolean}}
199
200
  */
200
201
  export function serializeVpForWire(vp) {
201
202
  return {
@@ -204,8 +205,11 @@ export function serializeVpForWire(vp) {
204
205
  // task-fix (5-bugs): carry bilingual name + aliases (incl. pinyin) to
205
206
  // the frontend so @ mention matching + localised rendering work.
206
207
  displayNameZh: typeof vp.nameZh === 'string' ? vp.nameZh : '',
208
+ description: typeof vp.description === 'string' ? vp.description : '',
209
+ descriptionZh: typeof vp.descriptionZh === 'string' ? vp.descriptionZh : '',
207
210
  aliases: Array.isArray(vp.aliases) ? vp.aliases.slice() : [],
208
211
  role: vp.role || '',
212
+ roleZh: typeof vp.roleZh === 'string' ? vp.roleZh : '',
209
213
  subtitle: vp.role || '',
210
214
  traits: Array.isArray(vp.traits) ? vp.traits.slice() : [],
211
215
  modelHint: vp.modelHint ?? null,
@@ -97,7 +97,7 @@ function vpRolePathFor(libDir, vpId) {
97
97
  * Serialise a VP payload into role.md text with YAML frontmatter matching
98
98
  * the parser in vp-store.js.
99
99
  *
100
- * @param {{vpId:string, displayName?:string, role?:string, traits?:string[], modelHint?:string, persona?:string}} p
100
+ * @param {{vpId:string, displayName?:string, displayNameZh?:string, aliases?:string[], description?:string, descriptionZh?:string, role?:string, roleZh?:string, area?:string, traits?:string[], modelHint?:string, persona?:string, planInstruction?:string}} p
101
101
  * @returns {string}
102
102
  */
103
103
  export function buildRoleMd(p) {
@@ -105,6 +105,8 @@ export function buildRoleMd(p) {
105
105
  const name = p.displayName != null ? String(p.displayName) : id;
106
106
  const nameZh = p.displayNameZh != null ? String(p.displayNameZh) : '';
107
107
  const aliases = Array.isArray(p.aliases) ? p.aliases.map(a => String(a)).filter(Boolean) : [];
108
+ const description = p.description != null ? String(p.description) : '';
109
+ const descriptionZh = p.descriptionZh != null ? String(p.descriptionZh) : '';
108
110
  const role = p.role != null ? String(p.role) : '';
109
111
  const roleZh = p.roleZh != null ? String(p.roleZh) : '';
110
112
  // Taxonomy bucket. Optional + additive — written only when present so
@@ -116,10 +118,15 @@ export function buildRoleMd(p) {
116
118
 
117
119
  const lines = ['---', `id: ${id}`, `name: ${yamlScalar(name)}`];
118
120
  if (nameZh) lines.push(`nameZh: ${yamlScalar(nameZh)}`);
121
+ if (description) lines.push(`description: ${yamlScalar(description)}`);
122
+ if (descriptionZh) lines.push(`descriptionZh: ${yamlScalar(descriptionZh)}`);
119
123
  lines.push(`role: ${yamlScalar(role)}`);
120
124
  if (roleZh) lines.push(`roleZh: ${yamlScalar(roleZh)}`);
121
125
  if (area) lines.push(`area: ${yamlScalar(area)}`);
122
126
  if (modelHint) lines.push(`modelHint: ${modelHint}`);
127
+ if (p.planInstruction != null && String(p.planInstruction)) {
128
+ lines.push(`planInstruction: ${yamlScalar(String(p.planInstruction))}`);
129
+ }
123
130
  if (traits.length > 0) {
124
131
  lines.push('traits:');
125
132
  for (const t of traits) lines.push(` - ${yamlScalar(t)}`);
@@ -134,21 +141,58 @@ export function buildRoleMd(p) {
134
141
 
135
142
  function yamlScalar(v) {
136
143
  const s = String(v);
137
- // Quote anything that the minimal parser might mis-read: colons, leading
138
- // dashes, or surrounding whitespace. Plain text passes through unquoted.
139
- if (/^[\s]|[\s]$|^[-:]|[:#]/.test(s)) {
140
- return `"${s.replace(/"/g, '\\"')}"`;
144
+ // YAML single-quoted scalars keep backslashes and double quotes literal.
145
+ // Doubling an apostrophe is the only escape, and parseYamlScalar reverses
146
+ // exactly that operation. This also avoids interpreting legacy Windows
147
+ // paths such as `C:\temp` as JSON/YAML control escapes.
148
+ if (/^[\s]|[\s]$|^[-:]|[:#'"\\\n\r\t]/.test(s)) {
149
+ return `'${s.replace(/'/g, "''")}'`;
141
150
  }
142
151
  return s;
143
152
  }
144
153
 
154
+ function hasOwn(obj, key) {
155
+ return Object.prototype.hasOwnProperty.call(obj, key);
156
+ }
157
+
158
+ /**
159
+ * Merge an update payload with the persisted editable VP shape. Missing keys
160
+ * mean "old client does not know this field" and must be preserved; an
161
+ * explicitly supplied empty value remains a deliberate clear operation.
162
+ */
163
+ function mergeVpUpdate(current, payload, vpId) {
164
+ const fields = [
165
+ 'displayName',
166
+ 'displayNameZh',
167
+ 'aliases',
168
+ 'description',
169
+ 'descriptionZh',
170
+ 'role',
171
+ 'roleZh',
172
+ 'area',
173
+ 'traits',
174
+ 'modelHint',
175
+ 'persona',
176
+ 'planInstruction',
177
+ ];
178
+ const merged = { vpId };
179
+ for (const field of fields) {
180
+ merged[field] = hasOwn(payload, field) ? payload[field] : current[field];
181
+ }
182
+ return merged;
183
+ }
184
+
145
185
  /**
146
186
  * Create a new VP. Writes `<lib>/<vpId>/role.md` and ensures `memory/` dir.
147
187
  *
148
188
  * @param {object} payload
149
189
  * @param {string} payload.vpId
150
190
  * @param {string} [payload.displayName]
191
+ * @param {string} [payload.displayNameZh]
192
+ * @param {string} [payload.description]
193
+ * @param {string} [payload.descriptionZh]
151
194
  * @param {string} [payload.role]
195
+ * @param {string} [payload.roleZh]
152
196
  * @param {string[]} [payload.traits]
153
197
  * @param {'primary'|'fast'} [payload.modelHint]
154
198
  * @param {string} [payload.persona]
@@ -208,7 +252,10 @@ export function updateVp(payload, options = {}) {
208
252
  if (!existsSync(dir) || !existsSync(vpRolePathFor(libDir, vpId))) {
209
253
  throw new VpCrudError('not_found', vpId);
210
254
  }
211
- writeFileSync(vpRolePathFor(libDir, vpId), buildRoleMd({ ...payload, vpId }), 'utf-8');
255
+ const current = readVp(vpId, { libDir });
256
+ if (!current) throw new VpCrudError('not_found', vpId);
257
+ const merged = mergeVpUpdate(current, payload || {}, vpId);
258
+ writeFileSync(vpRolePathFor(libDir, vpId), buildRoleMd(merged), 'utf-8');
212
259
  return { vpId, dir };
213
260
  }
214
261
 
@@ -258,7 +305,7 @@ export function deleteVp(vpId, options = {}) {
258
305
  *
259
306
  * @param {string} vpId
260
307
  * @param {object} [options]
261
- * @returns {?{vpId:string, displayName:string, role:string, traits:string[], modelHint:?string, persona:string}}
308
+ * @returns {?{vpId:string, displayName:string, displayNameZh:string, aliases:string[], description:string, descriptionZh:string, role:string, roleZh:string, area:string, traits:string[], modelHint:?string, persona:string, planInstruction:string}}
262
309
  */
263
310
  export function readVp(vpId, options = {}) {
264
311
  const libDir = options.libDir || DEFAULT_VP_LIB_DIR;
@@ -279,8 +326,11 @@ export function readVp(vpId, options = {}) {
279
326
  displayName: String(meta.name || id),
280
327
  displayNameZh: typeof meta.nameZh === 'string' ? String(meta.nameZh) : '',
281
328
  aliases: Array.isArray(meta.aliases) ? meta.aliases.map(String) : [],
329
+ description: typeof meta.description === 'string' ? String(meta.description) : '',
330
+ descriptionZh: typeof meta.descriptionZh === 'string' ? String(meta.descriptionZh) : '',
282
331
  role: String(meta.role || ''),
283
332
  roleZh: typeof meta.roleZh === 'string' ? String(meta.roleZh) : '',
333
+ area: typeof meta.area === 'string' ? String(meta.area).trim() : '',
284
334
  traits: Array.isArray(meta.traits) ? meta.traits.map(String) : [],
285
335
  modelHint,
286
336
  persona: body,
@@ -29,7 +29,11 @@ import { createHash } from 'crypto';
29
29
  * @typedef {Object} VP
30
30
  * @property {string} id — VP id (default: dir name)
31
31
  * @property {string} name
32
+ * @property {string} nameZh — optional Chinese display name; '' if absent.
33
+ * @property {string} description — concise English capability summary for roster lists.
34
+ * @property {string} descriptionZh — optional Chinese capability summary; '' if absent.
32
35
  * @property {string} role
36
+ * @property {string} roleZh — optional Chinese role label; '' if absent.
33
37
  * @property {string} area — taxonomy bucket (e.g. 'philosophy', 'investing'); '' if absent.
34
38
  * Optional, additive: no consumer is required to dispatch on it.
35
39
  * Sidebar grouping by area is intentionally a future PR.
@@ -70,6 +74,21 @@ export function personaHash(persona) {
70
74
  * @param {string} source
71
75
  * @returns {{ meta: Record<string, any>, body: string }}
72
76
  */
77
+ function parseYamlScalar(raw) {
78
+ const value = String(raw || '').trim();
79
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
80
+ // Compatibility for files written by the old CRUD serializer. It escaped
81
+ // only double quotes, not backslashes, so JSON.parse would corrupt valid
82
+ // Windows paths (`C:\temp` -> a tab). Undo only the escape the old writer
83
+ // actually introduced and leave every other backslash literal.
84
+ return value.slice(1, -1).replace(/\\"/g, '"');
85
+ }
86
+ if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) {
87
+ return value.slice(1, -1).replace(/''/g, "'");
88
+ }
89
+ return value;
90
+ }
91
+
73
92
  export function parseRoleMd(source) {
74
93
  const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
75
94
  if (!match) return { meta: {}, body: source };
@@ -84,7 +103,7 @@ export function parseRoleMd(source) {
84
103
  if (!line.trim()) continue;
85
104
  const listMatch = line.match(/^\s+-\s+(.+?)\s*$/);
86
105
  if (listMatch && currentList) {
87
- currentList.push(listMatch[1].replace(/^['"]|['"]$/g, ''));
106
+ currentList.push(parseYamlScalar(listMatch[1]));
88
107
  continue;
89
108
  }
90
109
  const kvMatch = line.match(/^([\w-]+):\s*(.*)$/);
@@ -95,7 +114,7 @@ export function parseRoleMd(source) {
95
114
  currentList = [];
96
115
  meta[key] = currentList;
97
116
  } else {
98
- meta[key] = value.replace(/^['"]|['"]$/g, '');
117
+ meta[key] = parseYamlScalar(value);
99
118
  currentList = null;
100
119
  }
101
120
  }
@@ -156,6 +175,8 @@ export function loadVpFromDir(dir) {
156
175
  aliases: Array.isArray(meta.aliases)
157
176
  ? meta.aliases.map(String).map(s => s.trim()).filter(Boolean)
158
177
  : [],
178
+ description: typeof meta.description === 'string' ? String(meta.description) : '',
179
+ descriptionZh: typeof meta.descriptionZh === 'string' ? String(meta.descriptionZh) : '',
159
180
  role: String(meta.role || ''),
160
181
  roleZh: typeof meta.roleZh === 'string' ? String(meta.roleZh) : '',
161
182
  // Taxonomy bucket for sidebar grouping / filtering. Absent for legacy
@@ -66,6 +66,8 @@ async function getSettingsRuntime() {
66
66
  id: vp.id,
67
67
  name: vp.name || vp.id,
68
68
  nameZh: vp.nameZh || '',
69
+ description: vp.description || vp.role || '',
70
+ descriptionZh: vp.descriptionZh || vp.roleZh || vp.description || vp.role || '',
69
71
  role: vp.role || '',
70
72
  roleZh: vp.roleZh || '',
71
73
  area: vp.area || '',
@@ -6,6 +6,8 @@ function publicVp(vp) {
6
6
  id: vp.id,
7
7
  name: vp.name || vp.id,
8
8
  nameZh: vp.nameZh || '',
9
+ description: vp.description || vp.role || '',
10
+ descriptionZh: vp.descriptionZh || vp.roleZh || vp.description || vp.role || '',
9
11
  role: vp.role || '',
10
12
  roleZh: vp.roleZh || '',
11
13
  area: vp.area || '',