@yeaft/webchat-agent 1.0.63 → 1.0.65
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/connection/buffer.js +73 -40
- package/connection/heartbeat-policy.js +13 -1
- package/connection/heartbeat.js +13 -2
- package/connection/message-router.js +1 -1
- package/connection/upgrade.js +12 -12
- package/context.js +5 -0
- package/package.json +1 -1
- package/yeaft/vp/seed-defaults.js +1 -25
- package/yeaft/vp/seed-topup.js +9 -1
package/connection/buffer.js
CHANGED
|
@@ -16,51 +16,84 @@ export const BUFFERABLE_TYPES = new Set([
|
|
|
16
16
|
'crew_role_compact', 'crew_context_usage'
|
|
17
17
|
]);
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
function bufferMessage(msg, reason) {
|
|
20
|
+
if (!BUFFERABLE_TYPES.has(msg.type)) {
|
|
21
|
+
console.warn(`[WS] Cannot send message, WebSocket not open: ${msg.type}`);
|
|
22
|
+
return 'dropped';
|
|
23
|
+
}
|
|
24
|
+
if (ctx.messageBuffer.length < ctx.messageBufferMaxSize) {
|
|
25
|
+
ctx.messageBuffer.push(msg);
|
|
26
|
+
console.log(`[WS] ${reason}, buffered: ${msg.type} (queue: ${ctx.messageBuffer.length})`);
|
|
27
|
+
return 'buffered';
|
|
28
|
+
}
|
|
29
|
+
// Buffer full: drop oldest non-status messages to make room
|
|
30
|
+
const dropIdx = ctx.messageBuffer.findIndex(m => m.type !== 'crew_status' && m.type !== 'turn_completed');
|
|
31
|
+
if (dropIdx >= 0) {
|
|
32
|
+
ctx.messageBuffer.splice(dropIdx, 1);
|
|
33
|
+
ctx.messageBuffer.push(msg);
|
|
34
|
+
console.warn(`[WS] Buffer full, dropped oldest to make room for: ${msg.type}`);
|
|
35
|
+
return 'buffered';
|
|
36
|
+
}
|
|
37
|
+
console.warn(`[WS] Buffer full (${ctx.messageBufferMaxSize}), dropping: ${msg.type}`);
|
|
38
|
+
return 'dropped';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function sendNow(msg) {
|
|
22
42
|
if (!ctx.ws || ctx.ws.readyState !== WebSocket.OPEN) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
43
|
+
return bufferMessage(msg, 'Disconnected');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// feat-ws-plaintext-negotiation: encrypt only when the server has
|
|
47
|
+
// NOT advertised plaintext acceptance. Defaults to encrypted for
|
|
48
|
+
// back-compat with old servers; flipped to plaintext when the
|
|
49
|
+
// `registered` frame includes `acceptPlaintext: true`.
|
|
50
|
+
if (ctx.serverEncryptionRequired && ctx.sessionKey) {
|
|
51
|
+
const encrypted = await encrypt(msg, ctx.sessionKey);
|
|
52
|
+
ctx.ws.send(JSON.stringify(encrypted));
|
|
53
|
+
} else {
|
|
54
|
+
ctx.ws.send(JSON.stringify(msg));
|
|
55
|
+
}
|
|
56
|
+
return 'sent';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function scheduleOutboundDrain() {
|
|
60
|
+
if (ctx.outboundSendQueueActive) return;
|
|
61
|
+
ctx.outboundSendQueueActive = true;
|
|
62
|
+
setImmediate(async () => {
|
|
63
|
+
try {
|
|
64
|
+
while (ctx.outboundSendQueue.length > 0) {
|
|
65
|
+
const item = ctx.outboundSendQueue.shift();
|
|
66
|
+
const msg = item?.msg ?? item;
|
|
67
|
+
try {
|
|
68
|
+
const outcome = await sendNow(msg);
|
|
69
|
+
item?.resolve?.(outcome);
|
|
70
|
+
} catch (e) {
|
|
71
|
+
console.error(`[WS] Error sending message ${msg?.type}:`, e.message);
|
|
72
|
+
const outcome = msg ? bufferMessage(msg, 'Send failed') : 'dropped';
|
|
73
|
+
item?.resolve?.(outcome);
|
|
37
74
|
}
|
|
75
|
+
// Yield between frames so ping/pong, inbound control messages and UI
|
|
76
|
+
// events cannot be starved by a reconnect flush or a burst of tool output.
|
|
77
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
38
78
|
}
|
|
39
|
-
}
|
|
40
|
-
|
|
79
|
+
} finally {
|
|
80
|
+
ctx.outboundSendQueueActive = false;
|
|
81
|
+
if (ctx.outboundSendQueue.length > 0) scheduleOutboundDrain();
|
|
41
82
|
}
|
|
42
|
-
|
|
43
|
-
|
|
83
|
+
});
|
|
84
|
+
}
|
|
44
85
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (ctx.serverEncryptionRequired && ctx.sessionKey) {
|
|
51
|
-
const encrypted = await encrypt(msg, ctx.sessionKey);
|
|
52
|
-
ctx.ws.send(JSON.stringify(encrypted));
|
|
53
|
-
} else {
|
|
54
|
-
ctx.ws.send(JSON.stringify(msg));
|
|
55
|
-
}
|
|
56
|
-
} catch (e) {
|
|
57
|
-
console.error(`[WS] Error sending message ${msg.type}:`, e.message);
|
|
58
|
-
// 发送失败也缓冲
|
|
59
|
-
if (BUFFERABLE_TYPES.has(msg.type) && ctx.messageBuffer.length < ctx.messageBufferMaxSize) {
|
|
60
|
-
ctx.messageBuffer.push(msg);
|
|
61
|
-
console.log(`[WS] Send failed, buffered: ${msg.type}`);
|
|
62
|
-
}
|
|
86
|
+
// Send message to server (with encryption if available)
|
|
87
|
+
// 断连时对关键消息类型进行缓冲,重连后自动 flush
|
|
88
|
+
export async function sendToServer(msg) {
|
|
89
|
+
if (!ctx.ws || ctx.ws.readyState !== WebSocket.OPEN) {
|
|
90
|
+
return bufferMessage(msg, 'Disconnected');
|
|
63
91
|
}
|
|
92
|
+
const promise = new Promise((resolve, reject) => {
|
|
93
|
+
ctx.outboundSendQueue.push({ msg, resolve, reject });
|
|
94
|
+
});
|
|
95
|
+
scheduleOutboundDrain();
|
|
96
|
+
return promise;
|
|
64
97
|
}
|
|
65
98
|
|
|
66
99
|
// Flush 断连期间缓冲的消息
|
|
@@ -74,7 +107,7 @@ export async function flushMessageBuffer() {
|
|
|
74
107
|
await sendToServer(msg);
|
|
75
108
|
}
|
|
76
109
|
|
|
77
|
-
console.log(`[WS] Flush
|
|
110
|
+
console.log(`[WS] Flush queued`);
|
|
78
111
|
}
|
|
79
112
|
|
|
80
113
|
// Parse incoming message (decrypt if encrypted)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const DEFAULT_AGENT_HEARTBEAT_TIMEOUT_MS = 180000;
|
|
2
|
+
const DEFAULT_AGENT_HEARTBEAT_STALL_GRACE_MS = 5000;
|
|
2
3
|
|
|
3
4
|
function parsePositiveInt(value, fallback) {
|
|
4
5
|
const n = Number(value);
|
|
@@ -10,6 +11,17 @@ export const AGENT_HEARTBEAT_TIMEOUT_MS = parsePositiveInt(
|
|
|
10
11
|
DEFAULT_AGENT_HEARTBEAT_TIMEOUT_MS,
|
|
11
12
|
);
|
|
12
13
|
|
|
13
|
-
export
|
|
14
|
+
export const AGENT_HEARTBEAT_STALL_GRACE_MS = parsePositiveInt(
|
|
15
|
+
process.env.YEAFT_AGENT_HEARTBEAT_STALL_GRACE_MS || process.env.AGENT_HEARTBEAT_STALL_GRACE_MS,
|
|
16
|
+
DEFAULT_AGENT_HEARTBEAT_STALL_GRACE_MS,
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
export function shouldReconnectForHeartbeat(
|
|
20
|
+
lastPongAt,
|
|
21
|
+
now = Date.now(),
|
|
22
|
+
timeoutMs = AGENT_HEARTBEAT_TIMEOUT_MS,
|
|
23
|
+
{ timerDriftMs = 0, stallGraceMs = AGENT_HEARTBEAT_STALL_GRACE_MS } = {},
|
|
24
|
+
) {
|
|
25
|
+
if (Number.isFinite(timerDriftMs) && timerDriftMs > stallGraceMs) return false;
|
|
14
26
|
return Number.isFinite(lastPongAt) && lastPongAt > 0 && (now - lastPongAt) > timeoutMs;
|
|
15
27
|
}
|
package/connection/heartbeat.js
CHANGED
|
@@ -2,9 +2,13 @@ import WebSocket from 'ws';
|
|
|
2
2
|
import ctx from '../context.js';
|
|
3
3
|
import { shouldReconnectForHeartbeat } from './heartbeat-policy.js';
|
|
4
4
|
|
|
5
|
+
const AGENT_HEARTBEAT_INTERVAL_MS = 25000;
|
|
6
|
+
const HEARTBEAT_STALL_LOG_MS = 5000;
|
|
7
|
+
|
|
5
8
|
export function startAgentHeartbeat() {
|
|
6
9
|
stopAgentHeartbeat();
|
|
7
10
|
ctx.lastPongAt = Date.now();
|
|
11
|
+
ctx.lastHeartbeatTickAt = Date.now();
|
|
8
12
|
|
|
9
13
|
// 监听 pong 帧
|
|
10
14
|
if (ctx.ws) {
|
|
@@ -18,8 +22,15 @@ export function startAgentHeartbeat() {
|
|
|
18
22
|
|
|
19
23
|
// 检查上次 pong 是否超时
|
|
20
24
|
const now = Date.now();
|
|
25
|
+
const timerDriftMs = Math.max(0, now - (ctx.lastHeartbeatTickAt || now) - AGENT_HEARTBEAT_INTERVAL_MS);
|
|
26
|
+
ctx.lastHeartbeatTickAt = now;
|
|
21
27
|
const sincePong = now - ctx.lastPongAt;
|
|
22
|
-
if (
|
|
28
|
+
if (timerDriftMs > HEARTBEAT_STALL_LOG_MS) {
|
|
29
|
+
ctx.lastHeartbeatStallAt = now;
|
|
30
|
+
ctx.lastHeartbeatStallMs = timerDriftMs;
|
|
31
|
+
console.warn(`[Heartbeat] Agent event loop delayed heartbeat by ${Math.round(timerDriftMs)}ms; deferring reconnect`);
|
|
32
|
+
}
|
|
33
|
+
if (shouldReconnectForHeartbeat(ctx.lastPongAt, now, undefined, { timerDriftMs })) {
|
|
23
34
|
console.warn(`[Heartbeat] No pong for ${Math.round(sincePong / 1000)}s, reconnecting...`);
|
|
24
35
|
ctx.ws.terminate();
|
|
25
36
|
return;
|
|
@@ -30,7 +41,7 @@ export function startAgentHeartbeat() {
|
|
|
30
41
|
} catch (e) {
|
|
31
42
|
console.warn('[Heartbeat] Failed to send ping:', e.message);
|
|
32
43
|
}
|
|
33
|
-
},
|
|
44
|
+
}, AGENT_HEARTBEAT_INTERVAL_MS);
|
|
34
45
|
}
|
|
35
46
|
|
|
36
47
|
export function stopAgentHeartbeat() {
|
package/connection/upgrade.js
CHANGED
|
@@ -64,9 +64,9 @@ function cleanupAndExit(exitCode) {
|
|
|
64
64
|
}, 500);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
export function handleRestartAgent() {
|
|
67
|
+
export async function handleRestartAgent() {
|
|
68
68
|
console.log('[Agent] Restart requested, shutting down for PM2/systemd restart...');
|
|
69
|
-
sendToServer({ type: 'restart_agent_ack' });
|
|
69
|
+
await sendToServer({ type: 'restart_agent_ack' });
|
|
70
70
|
cleanupAndExit(1);
|
|
71
71
|
}
|
|
72
72
|
|
|
@@ -162,7 +162,7 @@ export async function handleUpgradeAgent() {
|
|
|
162
162
|
});
|
|
163
163
|
if (latestVersion === ctx.agentVersion) {
|
|
164
164
|
console.log(`[Agent] Already at latest version (${ctx.agentVersion}), skipping upgrade.`);
|
|
165
|
-
sendToServer({ type: 'upgrade_agent_ack', success: true, alreadyLatest: true, version: ctx.agentVersion });
|
|
165
|
+
await sendToServer({ type: 'upgrade_agent_ack', success: true, alreadyLatest: true, version: ctx.agentVersion });
|
|
166
166
|
return;
|
|
167
167
|
}
|
|
168
168
|
|
|
@@ -175,7 +175,7 @@ export async function handleUpgradeAgent() {
|
|
|
175
175
|
if (requiredNode && !nodeRangeSatisfied(currentNode, requiredNode)) {
|
|
176
176
|
const msg = `Node ${currentNode} does not satisfy required ${requiredNode} for ${pkgName}@${latestVersion}`;
|
|
177
177
|
console.warn(`[Agent] Upgrade aborted: ${msg}`);
|
|
178
|
-
sendToServer({
|
|
178
|
+
await sendToServer({
|
|
179
179
|
type: 'upgrade_agent_ack',
|
|
180
180
|
success: false,
|
|
181
181
|
reason: 'node_incompatible',
|
|
@@ -197,7 +197,7 @@ export async function handleUpgradeAgent() {
|
|
|
197
197
|
if (!isNpmInstall) {
|
|
198
198
|
// 源码运行不支持远程升级(代码在 git repo 中,需要手动 git pull)
|
|
199
199
|
console.log('[Agent] Source-based install detected, remote upgrade not supported.');
|
|
200
|
-
sendToServer({ type: 'upgrade_agent_ack', success: false, error: 'Source-based install: please use git pull to upgrade' });
|
|
200
|
+
await sendToServer({ type: 'upgrade_agent_ack', success: false, error: 'Source-based install: please use git pull to upgrade' });
|
|
201
201
|
return;
|
|
202
202
|
}
|
|
203
203
|
|
|
@@ -217,9 +217,9 @@ export async function handleUpgradeAgent() {
|
|
|
217
217
|
const instanceId = resolveInstanceId();
|
|
218
218
|
|
|
219
219
|
if (isWindows) {
|
|
220
|
-
spawnWindowsUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion, instanceId);
|
|
220
|
+
await spawnWindowsUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion, instanceId);
|
|
221
221
|
} else {
|
|
222
|
-
spawnUnixUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion, instanceId);
|
|
222
|
+
await spawnUnixUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion, instanceId);
|
|
223
223
|
}
|
|
224
224
|
|
|
225
225
|
// On PM2: delete the app BEFORE exiting so PM2 won't auto-restart the old version.
|
|
@@ -238,11 +238,11 @@ export async function handleUpgradeAgent() {
|
|
|
238
238
|
cleanupAndExit(0);
|
|
239
239
|
} catch (e) {
|
|
240
240
|
console.error('[Agent] Upgrade failed:', e.message);
|
|
241
|
-
sendToServer({ type: 'upgrade_agent_ack', success: false, error: e.message });
|
|
241
|
+
await sendToServer({ type: 'upgrade_agent_ack', success: false, error: e.message });
|
|
242
242
|
}
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
-
function spawnWindowsUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion, instanceId = DEFAULT_INSTANCE_ID) {
|
|
245
|
+
async function spawnWindowsUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion, instanceId = DEFAULT_INSTANCE_ID) {
|
|
246
246
|
const pid = process.pid;
|
|
247
247
|
const configDir = getConfigDir(instanceId);
|
|
248
248
|
mkdirSync(configDir, { recursive: true });
|
|
@@ -364,7 +364,7 @@ function spawnWindowsUpgradeScript(pkgName, installDir, isGlobalInstall, latestV
|
|
|
364
364
|
}).unref();
|
|
365
365
|
|
|
366
366
|
console.log(`[Agent] Spawned upgrade via VBScript (PID wait for ${pid}, pm2=${isPm2}, dir=${installDir}): ${batPath}`);
|
|
367
|
-
sendToServer({ type: 'upgrade_agent_ack', success: true, version: latestVersion, pendingRestart: true });
|
|
367
|
+
await sendToServer({ type: 'upgrade_agent_ack', success: true, version: latestVersion, pendingRestart: true });
|
|
368
368
|
}
|
|
369
369
|
|
|
370
370
|
/**
|
|
@@ -526,7 +526,7 @@ export function buildUnixUpgradeScript({
|
|
|
526
526
|
return shLines.join('\n');
|
|
527
527
|
}
|
|
528
528
|
|
|
529
|
-
function spawnUnixUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion, instanceId = DEFAULT_INSTANCE_ID) {
|
|
529
|
+
async function spawnUnixUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion, instanceId = DEFAULT_INSTANCE_ID) {
|
|
530
530
|
const configDir = getConfigDir(instanceId);
|
|
531
531
|
// Create logs/ too: the generated script's `exec > "$LOGFILE" 2>&1` aborts
|
|
532
532
|
// the whole upgrade silently if that directory is missing.
|
|
@@ -551,5 +551,5 @@ function spawnUnixUpgradeScript(pkgName, installDir, isGlobalInstall, latestVers
|
|
|
551
551
|
});
|
|
552
552
|
child.unref();
|
|
553
553
|
console.log(`[Agent] Spawned upgrade script: ${shPath}`);
|
|
554
|
-
sendToServer({ type: 'upgrade_agent_ack', success: true, version: latestVersion, pendingRestart: true });
|
|
554
|
+
await sendToServer({ type: 'upgrade_agent_ack', success: true, version: latestVersion, pendingRestart: true });
|
|
555
555
|
}
|
package/context.js
CHANGED
|
@@ -28,6 +28,11 @@ export default {
|
|
|
28
28
|
pendingAuthTempId: null,
|
|
29
29
|
agentHeartbeatTimer: null,
|
|
30
30
|
lastPongAt: 0,
|
|
31
|
+
lastHeartbeatTickAt: 0,
|
|
32
|
+
lastHeartbeatStallAt: 0,
|
|
33
|
+
lastHeartbeatStallMs: 0,
|
|
34
|
+
outboundSendQueue: [],
|
|
35
|
+
outboundSendQueueActive: false,
|
|
31
36
|
// 断连期间的消息缓冲队列(重连后 flush)
|
|
32
37
|
messageBuffer: [],
|
|
33
38
|
messageBufferMaxSize: 5000, // 防止内存无限增长
|
package/package.json
CHANGED
|
@@ -1386,31 +1386,7 @@ Users turn to you when the request is messy, cross-functional, or drifting. Give
|
|
|
1386
1386
|
你在模糊里保持冷静。你会把含糊意图收束成清楚的问题,把事实和假设分开,选择能继续推进工作的最小路径。该由专家处理时,你直接把任务交出去,不假装所有事都该自己完成。
|
|
1387
1387
|
|
|
1388
1388
|
用户找你,通常是因为事情跨角色、范围漂移、或者目标还没被说清。你的回答要给出目标、取舍、下一位负责人和审计链;简洁,但不能遮住流程状态。`,
|
|
1389
|
-
legacyPersonas: [
|
|
1390
|
-
|
|
1391
|
-
Language policy / 语言策略:
|
|
1392
|
-
- Prefer Chinese when the user writes in Chinese; prefer English when the user writes in English.
|
|
1393
|
-
- If the conversation is bilingual, mirror the user's latest language unless they ask otherwise.
|
|
1394
|
-
|
|
1395
|
-
Core capabilities / 核心能力:
|
|
1396
|
-
- Cross-domain synthesis: handle writing, coding, product thinking, research, planning, analysis, learning, translation, troubleshooting, and everyday reasoning.
|
|
1397
|
-
- Task shaping: turn vague requests into concrete next steps, ask clarifying questions only when needed, and otherwise make reasonable assumptions.
|
|
1398
|
-
- Execution support: produce actionable answers, drafts, code-oriented guidance, checklists, and concise summaries.
|
|
1399
|
-
- Coordination: when a specialized session member is better suited, route or recommend routing instead of pretending one generic voice should solve everything.
|
|
1400
|
-
|
|
1401
|
-
Answering style / 回答风格:
|
|
1402
|
-
- Be direct, useful, and structured.
|
|
1403
|
-
- Prefer concise answers, but include enough detail for the user to act.
|
|
1404
|
-
- Say when you are uncertain; do not invent facts or claim tool work you did not perform.
|
|
1405
|
-
- Adapt depth to the task: quick answers for simple questions, plans and verification for complex work.`, `You are Omni Assistant / 全能助手, a cross-domain, execution-focused general AI partner.
|
|
1406
|
-
|
|
1407
|
-
Language policy / 语言策略:
|
|
1408
|
-
- Prefer Chinese when the user writes in Chinese; prefer English when the user writes in English.
|
|
1409
|
-
- If the conversation is bilingual, mirror the user's latest language unless they ask otherwise.
|
|
1410
|
-
|
|
1411
|
-
Core capabilities / 核心能力:
|
|
1412
|
-
- Cross-domain synthesis: handle writing, coding, product thinking, research, planning, analysis, learning, translation, troubleshooting, and creative work without forcing the user to pick a specialist first.
|
|
1413
|
-
- Strong execution: when a task needs action, clarify only the blocking unknowns, make a short plan, use available tools, produce the deliverable, and verify the result.`],
|
|
1389
|
+
legacyPersonas: [],
|
|
1414
1390
|
legacyPersonaEn: `You are Omni, a VP responsible for requirement analysis, goal clarification, workflow orchestration, and delivery coordination. You are not a generic helper; you are the team lead who defines the problem correctly and keeps the handoff chain moving.
|
|
1415
1391
|
|
|
1416
1392
|
Traits: broad-context, calm, and execution-minded. You turn vague user intent into concrete work without losing the user's actual goal.
|
package/yeaft/vp/seed-topup.js
CHANGED
|
@@ -252,10 +252,18 @@ export function backfillLocalizedPersonaBody(source, vp) {
|
|
|
252
252
|
.filter(value => typeof value === 'string' && value.trim())
|
|
253
253
|
.map(value => value.trim());
|
|
254
254
|
|
|
255
|
-
if (!acceptedOldBodies.includes(body)) return null;
|
|
255
|
+
if (!acceptedOldBodies.includes(body) && !isObsoleteOmniAssistantBody(vp, body)) return null;
|
|
256
256
|
return replaceRoleBody(source, nextBody);
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
function isObsoleteOmniAssistantBody(vp, body) {
|
|
260
|
+
if (!vp || vp.vpId !== 'omni') return false;
|
|
261
|
+
const text = String(body || '').trim();
|
|
262
|
+
return text.startsWith('You are Omni Assistant / 全能助手,')
|
|
263
|
+
&& text.includes('Language policy / 语言策略:')
|
|
264
|
+
&& text.includes('Core capabilities / 核心能力:');
|
|
265
|
+
}
|
|
266
|
+
|
|
259
267
|
/**
|
|
260
268
|
* Top-up the default VPs into an existing `libDir`.
|
|
261
269
|
*
|