@yeaft/webchat-agent 1.0.177 → 1.0.180

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.
@@ -8,12 +8,21 @@
8
8
  import { defineTool } from './types.js';
9
9
  import { spawn } from 'child_process';
10
10
  import { readdir, readFile, stat } from 'fs/promises';
11
+ import { StringDecoder } from 'string_decoder';
11
12
  import { existsSync } from 'fs';
12
13
  import { resolve, join, relative, extname } from 'path';
13
14
 
14
15
  /** Max output lines. */
15
16
  const MAX_LINES = 250;
16
17
 
18
+ /** Hard cap before Grep output reaches history, debug events, or WebSocket. */
19
+ const MAX_OUTPUT_BYTES = 512 * 1024;
20
+ const OUTPUT_TRUNCATED_MARKER = '\n\n[Output truncated]';
21
+ const MAX_CAPTURE_BYTES = MAX_OUTPUT_BYTES - Buffer.byteLength(OUTPUT_TRUNCATED_MARKER, 'utf8');
22
+
23
+ /** Keep one pathological source line from consuming the whole output budget. */
24
+ const MAX_LINE_BYTES = 16 * 1024;
25
+
17
26
  /** Binary extensions to skip. */
18
27
  const BINARY_EXTS = new Set([
19
28
  '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.webp',
@@ -25,6 +34,77 @@ const BINARY_EXTS = new Set([
25
34
  '.sqlite', '.db',
26
35
  ]);
27
36
 
37
+ const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
38
+
39
+ function decodeTextFile(buffer) {
40
+ // Extension lists are only a fast path. Generated artifacts and renamed
41
+ // binaries commonly have no useful extension, especially on Windows.
42
+ if (buffer.includes(0)) return null;
43
+ try { return utf8Decoder.decode(buffer); } catch { return null; }
44
+ }
45
+
46
+ function truncateUtf8(text, maxBytes) {
47
+ if (maxBytes <= 0) return '';
48
+ const buffer = Buffer.from(text, 'utf8');
49
+ if (buffer.length <= maxBytes) return text;
50
+ return new StringDecoder('utf8').write(buffer.subarray(0, maxBytes));
51
+ }
52
+
53
+ function boundToolOutput(text) {
54
+ if (Buffer.byteLength(text, 'utf8') <= MAX_OUTPUT_BYTES) return text;
55
+ return truncateUtf8(text, MAX_CAPTURE_BYTES) + OUTPUT_TRUNCATED_MARKER;
56
+ }
57
+
58
+ function formatGrepError(message) {
59
+ const errorMessage = `Grep failed: ${message}`;
60
+ const serialized = JSON.stringify({ error: errorMessage });
61
+ if (Buffer.byteLength(serialized, 'utf8') <= MAX_OUTPUT_BYTES) return serialized;
62
+
63
+ const markerBytes = Buffer.byteLength(OUTPUT_TRUNCATED_MARKER, 'utf8');
64
+ let low = 0;
65
+ let high = Math.max(0, Buffer.byteLength(errorMessage, 'utf8') - markerBytes);
66
+ let result = JSON.stringify({ error: OUTPUT_TRUNCATED_MARKER });
67
+
68
+ while (low <= high) {
69
+ const mid = Math.floor((low + high) / 2);
70
+ const candidate = JSON.stringify({
71
+ error: truncateUtf8(errorMessage, mid) + OUTPUT_TRUNCATED_MARKER,
72
+ });
73
+ if (Buffer.byteLength(candidate, 'utf8') <= MAX_OUTPUT_BYTES) {
74
+ result = candidate;
75
+ low = mid + 1;
76
+ } else {
77
+ high = mid - 1;
78
+ }
79
+ }
80
+
81
+ return result;
82
+ }
83
+
84
+ function createOutputCollector(maxBytes = MAX_OUTPUT_BYTES) {
85
+ const parts = [];
86
+ const contentBytes = Math.max(0, maxBytes - Buffer.byteLength(OUTPUT_TRUNCATED_MARKER, 'utf8'));
87
+ let bytes = 0;
88
+ let truncated = false;
89
+ return {
90
+ add(value) {
91
+ if (truncated) return false;
92
+ const normalized = String(value).replace(/\r/g, '');
93
+ const line = truncateUtf8(normalized, MAX_LINE_BYTES);
94
+ const lineWasTruncated = Buffer.byteLength(normalized, 'utf8') > Buffer.byteLength(line, 'utf8');
95
+ const separator = parts.length > 0 ? '\n' : '';
96
+ const remaining = contentBytes - bytes - Buffer.byteLength(separator, 'utf8');
97
+ if (remaining <= 0) { truncated = true; return false; }
98
+ const bounded = truncateUtf8(line, remaining);
99
+ parts.push(separator + bounded);
100
+ bytes += Buffer.byteLength(separator + bounded, 'utf8');
101
+ if (lineWasTruncated || bounded !== line) truncated = true;
102
+ return !truncated;
103
+ },
104
+ toString() { return parts.join('') + (truncated ? OUTPUT_TRUNCATED_MARKER : ''); },
105
+ };
106
+ }
107
+
28
108
  /**
29
109
  * Check if ripgrep is available.
30
110
  */
@@ -39,7 +119,7 @@ function hasRipgrep() {
39
119
  /**
40
120
  * Run ripgrep and return results.
41
121
  */
42
- function runRipgrep(pattern, searchPath, options) {
122
+ export function runRipgrep(pattern, searchPath, options, spawnProcess = spawn) {
43
123
  return new Promise((resolve, reject) => {
44
124
  const args = [
45
125
  pattern,
@@ -60,44 +140,72 @@ function runRipgrep(pattern, searchPath, options) {
60
140
  if (options.multiline) args.push('-U', '--multiline-dotall');
61
141
  args.push('--max-count', String(options.maxResults || 500));
62
142
 
63
- const proc = spawn('rg', args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
64
- let stdout = '';
65
- let stderr = '';
66
-
67
- proc.stdout.on('data', (chunk) => {
68
- stdout += chunk.toString();
69
- // Truncate early if way too large
70
- if (stdout.length > 512 * 1024) {
143
+ const proc = spawnProcess('rg', args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
144
+ const stdoutChunks = [];
145
+ const stderrChunks = [];
146
+ let capturedBytes = 0;
147
+ let truncatedStream = null;
148
+ let settled = false;
149
+
150
+ function capture(streamName, chunk, chunks) {
151
+ if (truncatedStream) return;
152
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
153
+ const remaining = MAX_CAPTURE_BYTES - capturedBytes;
154
+ if (buffer.length > remaining) {
155
+ if (remaining > 0) chunks.push(buffer.subarray(0, remaining));
156
+ capturedBytes = MAX_CAPTURE_BYTES;
157
+ truncatedStream = streamName;
71
158
  try { proc.kill(); } catch {}
159
+ return;
72
160
  }
73
- });
74
- proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
161
+ chunks.push(buffer);
162
+ capturedBytes += buffer.length;
163
+ }
164
+
165
+ function decodeCaptured(chunks, wasTruncated) {
166
+ // Buffer decoding normally expands each invalid byte to the three-byte
167
+ // U+FFFD replacement character. Use a one-byte replacement, then enforce
168
+ // the final encoded-byte boundary as a last line of defense.
169
+ const marker = wasTruncated ? OUTPUT_TRUNCATED_MARKER : '';
170
+ const maxTextBytes = MAX_OUTPUT_BYTES - Buffer.byteLength(marker, 'utf8');
171
+ const decoded = Buffer.concat(chunks).toString('utf8').replaceAll('\ufffd', '?').replace(/\r/g, '');
172
+ return truncateUtf8(decoded, maxTextBytes) + marker;
173
+ }
174
+
175
+ proc.stdout.on('data', (chunk) => capture('stdout', chunk, stdoutChunks));
176
+ proc.stderr.on('data', (chunk) => capture('stderr', chunk, stderrChunks));
75
177
  proc.on('close', (code) => {
76
- if (code === 0 || code === 1) {
77
- resolve(stdout);
78
- } else {
79
- reject(new Error(stderr || `rg exited with code ${code}`));
80
- }
178
+ if (settled) return;
179
+ settled = true;
180
+ const stdout = decodeCaptured(stdoutChunks, truncatedStream === 'stdout');
181
+ const stderr = decodeCaptured(stderrChunks, truncatedStream === 'stderr');
182
+ if (code === 0 || code === 1 || truncatedStream === 'stdout') resolve(stdout);
183
+ else reject(new Error(stderr || `rg exited with code ${code}`));
184
+ });
185
+ proc.on('error', (err) => {
186
+ if (settled) return;
187
+ settled = true;
188
+ reject(err);
81
189
  });
82
- proc.on('error', reject);
83
190
  });
84
191
  }
85
192
 
86
193
  /**
87
194
  * Fallback: Node.js grep implementation.
88
195
  */
89
- async function nodeGrep(pattern, searchPath, options) {
196
+ export async function nodeGrep(pattern, searchPath, options) {
90
197
  const regex = new RegExp(pattern, options.caseInsensitive ? 'gi' : 'g');
91
- const results = [];
198
+ const output = createOutputCollector();
199
+ let resultCount = 0;
92
200
  const SKIP = new Set(['node_modules', '.git', '__pycache__', '.next', 'dist', 'build', '.cache']);
93
201
 
94
202
  async function searchDir(dir) {
95
- if (results.length >= (options.maxResults || 500)) return;
203
+ if (resultCount >= (options.maxResults || 500)) return;
96
204
  let entries;
97
205
  try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
98
206
 
99
207
  for (const entry of entries) {
100
- if (results.length >= (options.maxResults || 500)) return;
208
+ if (resultCount >= (options.maxResults || 500)) return;
101
209
  const fullPath = join(dir, entry.name);
102
210
 
103
211
  if (entry.isDirectory()) {
@@ -111,22 +219,32 @@ async function nodeGrep(pattern, searchPath, options) {
111
219
  const fileStat = await stat(fullPath);
112
220
  if (fileStat.size > 1024 * 1024) continue; // skip files > 1MB
113
221
 
114
- const content = await readFile(fullPath, 'utf-8');
222
+ const buffer = await readFile(fullPath);
223
+ const content = decodeTextFile(buffer);
224
+ if (content == null) continue;
115
225
  const relPath = relative(searchPath, fullPath);
116
226
 
117
227
  if (options.filesOnly) {
118
- if (regex.test(content)) results.push(relPath);
228
+ if (regex.test(content)) {
229
+ resultCount += 1;
230
+ if (!output.add(relPath)) return;
231
+ }
119
232
  regex.lastIndex = 0;
120
233
  } else if (options.count) {
121
234
  const matches = content.match(regex);
122
- if (matches) results.push(`${relPath}:${matches.length}`);
235
+ if (matches) {
236
+ resultCount += 1;
237
+ if (!output.add(`${relPath}:${matches.length}`)) return;
238
+ }
123
239
  } else {
124
240
  const lines = content.split('\n');
125
241
  for (let i = 0; i < lines.length; i++) {
126
242
  if (regex.test(lines[i])) {
127
- results.push(`${relPath}:${i + 1}:${lines[i]}`);
243
+ resultCount += 1;
244
+ if (!output.add(`${relPath}:${i + 1}:${lines[i]}`)) return;
128
245
  }
129
246
  regex.lastIndex = 0;
247
+ if (resultCount >= (options.maxResults || 500)) return;
130
248
  }
131
249
  }
132
250
  } catch {
@@ -137,7 +255,7 @@ async function nodeGrep(pattern, searchPath, options) {
137
255
  }
138
256
 
139
257
  await searchDir(searchPath);
140
- return results.join('\n');
258
+ return output.toString();
141
259
  }
142
260
 
143
261
  export default defineTool({
@@ -302,15 +420,18 @@ Guidelines:
302
420
  return '(no matches)';
303
421
  }
304
422
 
305
- // Limit output lines
423
+ // Limit output lines, then enforce the byte budget at the actual tool
424
+ // boundary so prefixes, JSON escaping, and result markers are included.
306
425
  const lines = result.trim().split('\n');
307
426
  if (lines.length > head_limit) {
308
- return lines.slice(0, head_limit).join('\n') + `\n\n... (${lines.length - head_limit} more results)`;
427
+ return boundToolOutput(
428
+ lines.slice(0, head_limit).join('\n') + `\n\n... (${lines.length - head_limit} more results)`,
429
+ );
309
430
  }
310
431
 
311
- return result.trim();
432
+ return boundToolOutput(result.trim());
312
433
  } catch (err) {
313
- return JSON.stringify({ error: `Grep failed: ${err.message}` });
434
+ return formatGrepError(err.message);
314
435
  }
315
436
  },
316
437
  });
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { defineTool } from './types.js';
8
+ import { downloadRemoteImage } from '../remote-image-download.js';
8
9
 
9
10
  export default defineTool({
10
11
  name: 'ImageGeneration',
@@ -83,29 +84,26 @@ Guidelines:
83
84
 
84
85
  const data = await response.json();
85
86
 
86
- // If output_path specified, save the image
87
- if (output_path && data.url) {
87
+ if (!data.url) return JSON.stringify({ error: 'Image API returned no image URL' });
88
+ const { buffer, mimeType } = await downloadRemoteImage(data.url, {
89
+ signal: ctx?.signal,
90
+ ...(ctx?.remoteImageDownload || {}),
91
+ });
92
+ let savedPath = null;
93
+ if (output_path) {
88
94
  const { resolve: resolvePath } = await import('path');
89
95
  const { writeFile } = await import('fs/promises');
90
-
91
- const imgResponse = await fetch(data.url);
92
- const buffer = Buffer.from(await imgResponse.arrayBuffer());
93
- const absPath = resolvePath(ctx?.cwd || process.cwd(), output_path);
94
- await writeFile(absPath, buffer);
95
-
96
- return JSON.stringify({
97
- success: true,
98
- path: absPath,
99
- size,
100
- prompt: prompt.slice(0, 100),
101
- });
96
+ savedPath = resolvePath(ctx?.cwd || process.cwd(), output_path);
97
+ await writeFile(savedPath, buffer);
102
98
  }
103
-
104
99
  return JSON.stringify({
105
100
  success: true,
106
- url: data.url,
101
+ ...(savedPath ? { path: savedPath } : {}),
107
102
  size,
108
103
  prompt: prompt.slice(0, 100),
104
+ image: `data:${mimeType};base64,${buffer.toString('base64')}`,
105
+ mimeType,
106
+ filename: savedPath ? savedPath.split(/[/\\]/).pop() : `generated-${Date.now()}`,
109
107
  });
110
108
  } catch (err) {
111
109
  if (err.name === 'AbortError') return JSON.stringify({ error: 'Generation cancelled' });
@@ -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,