kiku-agent 1.2.0 → 1.3.1
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/README.md +459 -407
- package/package.json +32 -3
- package/server/agent/agentCore.js +635 -267
- package/server/agent/appPaths.js +2 -0
- package/server/agent/catalogEntityResolver.js +637 -0
- package/server/agent/citations.js +0 -21
- package/server/agent/clarificationAnalytics.js +1 -1
- package/server/agent/clarifyingQuestions.js +286 -216
- package/server/agent/cli.js +532 -73
- package/server/agent/commandFeatures.js +123 -24
- package/server/agent/commandRegistry.js +128 -62
- package/server/agent/commands.js +1410 -95
- package/server/agent/config.js +253 -163
- package/server/agent/contextCompactor.js +239 -25
- package/server/agent/dynamicCatalog.js +192 -0
- package/server/agent/envBootstrap.js +46 -0
- package/server/agent/envManager.js +317 -0
- package/server/agent/eventBus.js +3 -3
- package/server/agent/feedbackLearning.js +0 -8
- package/server/agent/gpuMetrics.js +188 -55
- package/server/agent/ingestionWorker.js +339 -0
- package/server/agent/ink/App.jsx +348 -0
- package/server/agent/ink/Panel.jsx +54 -0
- package/server/agent/ink/PromptInput.jsx +321 -0
- package/server/agent/ink/TaskPlanPanel.jsx +88 -0
- package/server/agent/ink/ThinkingPanel.jsx +68 -0
- package/server/agent/ink/ToolResult.jsx +133 -0
- package/server/agent/ink/main.jsx +195 -0
- package/server/agent/ink/useEventBus.js +218 -0
- package/server/agent/lanDiscovery.js +369 -0
- package/server/agent/llmClient.js +111 -39
- package/server/agent/lmStudioWakeup.js +144 -0
- package/server/agent/mcpClient.js +2 -2
- package/server/agent/memory/contextAllocator.js +214 -0
- package/server/agent/memory/hierarchicalStore.js +523 -0
- package/server/agent/memory/reflectionWorker.js +110 -0
- package/server/agent/memory.js +635 -494
- package/server/agent/modelConfig.js +487 -0
- package/server/agent/newsIntelligence.js +219 -0
- package/server/agent/newsWatch.js +85 -0
- package/server/agent/prompts.js +146 -0
- package/server/agent/publicTools.js +7 -0
- package/server/agent/queryDetails.js +62 -1
- package/server/agent/research.js +314 -35
- package/server/agent/routingInterceptor.js +77 -0
- package/server/agent/rumoredProjects.js +15 -0
- package/server/agent/sanitizer.js +117 -17
- package/server/agent/subagent.js +52 -9
- package/server/agent/suggestionTui.js +33 -13
- package/server/agent/suggestions.js +29 -13
- package/server/agent/telemetry/hardwareTiers.js +332 -0
- package/server/agent/telemetry/runtimeWatchdog.js +66 -0
- package/server/agent/telemetry/techPowerUpClient.js +682 -0
- package/server/agent/telemetry/techpowerup-mcp.json +22 -0
- package/server/agent/telemetry.js +0 -11
- package/server/agent/terminalImage.js +0 -1
- package/server/agent/textToolProtocol.js +190 -0
- package/server/agent/tokenTracker.js +220 -0
- package/server/agent/toolRouter.js +362 -216
- package/server/agent/tools.js +1155 -194
- package/server/agent/ui.js +144 -41
- package/server/agent/vramService.js +572 -0
- package/server/agent/webSearch.js +2279 -273
- package/server/agent/websearch/adapters/duckduckgo.js +97 -0
- package/server/agent/websearch/adapters/jina.js +77 -0
- package/server/agent/websearch/adapters/searxng.js +57 -0
- package/server/agent/websearch/core/canonical.js +89 -0
- package/server/agent/websearch/core/fusion.js +81 -0
- package/server/agent/websearch/core/types.js +29 -0
- package/server/agent/websearch/extract.js +605 -0
- package/server/agent/websearch/index.js +43 -0
- package/server/agent/websearch/orchestrator.js +266 -0
- package/server/agent/websearch/resilience/breaker.js +97 -0
- package/server/agent/websearch/resilience/cache.js +59 -0
- package/server/agent/websearch/resilience/limiter.js +93 -0
- package/server/agent/websearch/security/filter.js +36 -0
- package/server/agent/websearch/security/ssrf.js +289 -0
- package/server/agent/websearch/telemetry.js +71 -0
- package/server/agent/youtubeAuth.js +82 -73
- package/server/agent/youtubeTranscript.js +209 -0
- package/server/apiRouter.js +390 -190
- package/server/mcp/data/brands.js +5 -5
- package/server/mcp/data/discography.js +56 -45
- package/server/mcp/data/gallery.js +9 -0
- package/server/mcp/data/index.js +1 -0
- package/server/mcp/data/industryIndex.js +251 -0
- package/server/mcp/data/profile.js +5 -5
- package/server/mcp/data/timeline.js +2 -2
- package/server/mcp/data/works.js +2 -2
- package/server/mcp/handler.js +0 -2
- package/server/mcp/prompts.js +42 -2
- package/server/mcp/resources.js +5 -1
- package/server/mcp/security.js +25 -14
- package/server/mcp/server.js +92 -3
- package/server/mcp/tools.js +20 -11
- package/server/routes/agent.js +55 -0
- package/server/web-chat/core/connection.js +218 -194
- package/server/web-chat/core/conversationContext.js +1 -1
- package/server/web-chat/core/directCatalogEngine.js +190 -184
- package/server/web-chat/core/followUpEngine.js +88 -11
- package/server/web-chat/core/security.js +7 -4
- package/server/web-chat/router.js +54 -17
- package/server/web-chat/routes/bilibiliRoute.js +34 -0
- package/server/web-chat/routes/chatRoute.js +488 -397
- package/server/web-chat/routes/clarifyConfigRoute.js +1 -2
- package/server/web-chat/routes/exportRoute.js +2 -1
- package/server/web-chat/routes/lanDiscoveryRoute.js +90 -0
- package/server/web-chat/routes/newsRoute.js +36 -6
- package/server/web-chat/routes/providerRoute.js +87 -0
- package/server/web-chat/routes/staticRoute.js +61 -13
- package/server/web-chat/routes/statusRoute.js +23 -2
- package/server/web-chat/routes/vramRoute.js +22 -0
- package/server/web-chat/server.js +135 -106
- package/server/web-chat/ui/htmlBuilder.js +8 -8
- package/server/web-chat/ui/previews/modelSettings.preview.html +41 -0
- package/server/web-chat/ui/scripts/agentBubbleSystem.js +0 -5
- package/server/web-chat/ui/scripts/client.js +10526 -8397
- package/server/web-chat/ui/scripts/webchatI18n.js +190 -138
- package/server/web-chat/ui/styles/main.css +7471 -4246
- package/server/web-chat/ui/templates/body.html +305 -179
- package/src/data/brandsData.js +29 -6
- package/src/data/discographyData.js +918 -900
- package/src/data/galleryData.js +66 -0
- package/src/data/industryIndexData.js +292 -0
- package/src/data/musicVideoData.js +48 -5
- package/src/data/profileData.js +15 -1
- package/src/data/timelineData.js +4 -3
- package/src/data/worksData.js +536 -535
- package/src/lib/vram/estimator.js +464 -0
- package/src/lib/vram/modelRegistry.js +387 -0
- package/tokens.css +33 -2
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* envManager — local .env lifecycle for kiku-agent.
|
|
3
|
+
*
|
|
4
|
+
* The npm package is consumed via a global install (`npm install -g kiku-agent`),
|
|
5
|
+
* so there is no project directory to host configuration. Environment files are
|
|
6
|
+
* resolved in this order:
|
|
7
|
+
*
|
|
8
|
+
* 1. package-root/.env — development checkouts (and legacy hand-made files)
|
|
9
|
+
* 2. <KIKU_DATA_DIR>/.env — user-owned location that survives npm upgrades
|
|
10
|
+
*
|
|
11
|
+
* Both files are loaded (package first, unset keys only). `/env` writes to the
|
|
12
|
+
* primary target: the package file when it exists, the user file otherwise.
|
|
13
|
+
*
|
|
14
|
+
* Only NECESSARY values are ever required; everything else is either
|
|
15
|
+
* auto-detected or left commented in the generated file.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { KIKU_DATA_DIR } from './appPaths.js';
|
|
23
|
+
|
|
24
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
|
|
26
|
+
export const PACKAGE_ENV_PATH = path.resolve(__dirname, '..', '..', '.env');
|
|
27
|
+
export const USER_ENV_PATH = path.join(KIKU_DATA_DIR, '.env');
|
|
28
|
+
|
|
29
|
+
export function resolveEnvTargets({ packageEnvPath = PACKAGE_ENV_PATH, userEnvPath = USER_ENV_PATH } = {}) {
|
|
30
|
+
const packageExists = fs.existsSync(packageEnvPath);
|
|
31
|
+
return {
|
|
32
|
+
mode: packageExists ? 'repo' : 'user',
|
|
33
|
+
packageExists,
|
|
34
|
+
packageEnvPath,
|
|
35
|
+
userEnvPath,
|
|
36
|
+
primaryPath: packageExists ? packageEnvPath : userEnvPath,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function parseEnvMap(text) {
|
|
41
|
+
const map = {};
|
|
42
|
+
for (const line of String(text || '').split(/\r?\n/)) {
|
|
43
|
+
const m = line.match(/^\s*([\w.-]+)\s*=\s*(.*)\s*$/);
|
|
44
|
+
if (!m || line.trim().startsWith('#')) continue;
|
|
45
|
+
map[m[1]] = m[2].trim().replace(/^["']|["']$/g, '');
|
|
46
|
+
}
|
|
47
|
+
return map;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function readEnvFile(filePath) {
|
|
51
|
+
try {
|
|
52
|
+
if (!fs.existsSync(filePath)) return null;
|
|
53
|
+
return parseEnvMap(fs.readFileSync(filePath, 'utf8'));
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function readMergedEnvMap({ packageEnvPath = PACKAGE_ENV_PATH, userEnvPath = USER_ENV_PATH } = {}) {
|
|
60
|
+
const map = {};
|
|
61
|
+
const sources = {};
|
|
62
|
+
const userMap = readEnvFile(userEnvPath) || {};
|
|
63
|
+
const packageMap = readEnvFile(packageEnvPath) || {};
|
|
64
|
+
for (const [key, value] of Object.entries(userMap)) {
|
|
65
|
+
map[key] = value;
|
|
66
|
+
sources[key] = userEnvPath;
|
|
67
|
+
}
|
|
68
|
+
for (const [key, value] of Object.entries(packageMap)) {
|
|
69
|
+
map[key] = value;
|
|
70
|
+
sources[key] = packageEnvPath;
|
|
71
|
+
}
|
|
72
|
+
return { map, sources, packageMap, userMap };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const SECRET_PATTERN = /(API_KEY|_KEYS|MASTER_KEY|SECRET|PEPPER|TOKEN|COOKIES|PASSWORD)/i;
|
|
76
|
+
|
|
77
|
+
export function isSecretKey(key) {
|
|
78
|
+
return SECRET_PATTERN.test(String(key || ''));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function maskValue(key, value) {
|
|
82
|
+
const str = String(value ?? '');
|
|
83
|
+
if (!str) return '(not set)';
|
|
84
|
+
if (!isSecretKey(key)) return str;
|
|
85
|
+
if (str.length <= 8) return '****';
|
|
86
|
+
return `${str.slice(0, 4)}…${str.slice(-3)} (${str.length} chars)`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
export const ENV_VAR_CATALOG = Object.freeze([
|
|
91
|
+
|
|
92
|
+
{ key: 'LMSTUDIO_BASE_URL', group: 'necessary', defaultValue: 'http://127.0.0.1:1234/v1', description: 'LM Studio (OpenAI-compatible) endpoint' },
|
|
93
|
+
{ key: 'VLLM_BASE_URL', group: 'optional', defaultValue: 'http://127.0.0.1:8000/v1', description: 'vLLM OpenAI-compatible endpoint (WSL/Linux)' },
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
{ key: 'KIKU_CONTEXT_LIMIT', group: 'recommended', auto: 'context', description: 'Context window for token telemetry (match LM Studio loaded_context_length)' },
|
|
97
|
+
{ key: 'LMSTUDIO_MODELS_DIR', group: 'recommended', auto: 'modelsDir', description: 'Folder scanned for .gguf checkpoints (VRAM estimator)' },
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
{ key: 'LLM_PROVIDER', group: 'optional', defaultValue: 'lmstudio', description: 'Default provider (lmstudio/vllm/openai/gemini/groq/deepseek/openrouter/custom)' },
|
|
101
|
+
{ key: 'KIKU_VRAM_TOTAL_GB', group: 'optional', description: 'Pin the VRAM budget when GPU detection is unavailable' },
|
|
102
|
+
{ key: 'KIKU_YT_COOKIES', group: 'optional', defaultValue: 'cookies.txt', description: 'Netscape cookies file for YouTube playback' },
|
|
103
|
+
{ key: 'KIKU_YT_BROWSER', group: 'optional', description: 'Browser profile for cookie extraction (firefox/chrome/edge/brave)' },
|
|
104
|
+
{ key: 'KIKU_YTDLP', group: 'optional', defaultValue: 'yt-dlp', description: 'yt-dlp binary path' },
|
|
105
|
+
{ key: 'KIKU_YTDLP_CLIENT', group: 'optional', defaultValue: 'tv', description: 'YouTube player client (tv/ios/web)' },
|
|
106
|
+
{ key: 'PORT_WEB_CHAT', group: 'optional', defaultValue: '3002', description: 'Web-chat server port' },
|
|
107
|
+
{ key: 'KIKU_WEB_CHAT_HOST', group: 'optional', defaultValue: '127.0.0.1', description: 'Web-chat bind host (keep loopback unless behind a proxy)' },
|
|
108
|
+
{ key: 'KIKU_THEME', group: 'optional', description: 'Terminal theme name' },
|
|
109
|
+
{ key: 'KIKU_ENABLE_CN_WEB_TOOLS', group: 'optional', defaultValue: 'true', description: 'Enable Chinese web tools (search_chinese_web, search_bilibili)' },
|
|
110
|
+
{ key: 'KIKU_MCP_ENABLE_WEB_TOOLS', group: 'optional', defaultValue: 'false', description: 'Expose web tools through the MCP server (KIKU_MCP_ENABLE_WEB_TOOLS=true)' },
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
{ key: 'OPENAI_API_KEY', group: 'cloud', description: 'OpenAI' },
|
|
114
|
+
{ key: 'GEMINI_API_KEY', group: 'cloud', description: 'Google Gemini' },
|
|
115
|
+
{ key: 'GROQ_API_KEY', group: 'cloud', description: 'Groq' },
|
|
116
|
+
{ key: 'DEEPSEEK_API_KEY', group: 'cloud', description: 'DeepSeek' },
|
|
117
|
+
{ key: 'OPENROUTER_API_KEY', group: 'cloud', description: 'OpenRouter' },
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
{ key: 'API_KEY_PEPPER', group: 'server', description: 'HMAC pepper for API-key hashing' },
|
|
121
|
+
{ key: 'QUIZ_SECRET', group: 'server', description: 'Quiz challenge token secret' },
|
|
122
|
+
{ key: 'KIKU_PROVIDER_KEY_ENCRYPTION_KEY', group: 'server', description: 'Encrypts provider keys at rest' },
|
|
123
|
+
{ key: 'KIKU_MASTER_KEYS', group: 'server', description: 'Admin keys for /keys/generate' },
|
|
124
|
+
{ key: 'KIKU_API_KEYS', group: 'server', description: 'Pre-seeded developer keys' },
|
|
125
|
+
{ key: 'KIKU_VIP_KEYS', group: 'server', description: 'Pre-seeded VIP keys' },
|
|
126
|
+
{ key: 'KIKU_AGENT_KEYS', group: 'server', description: 'Agent-execution keys' },
|
|
127
|
+
{ key: 'MCP_API_KEY', group: 'server', description: 'MCP HTTP auth' },
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
const GROUP_LABELS = Object.freeze({
|
|
131
|
+
necessary: 'Necessary',
|
|
132
|
+
recommended: 'Recommended (auto-detected)',
|
|
133
|
+
optional: 'Optional',
|
|
134
|
+
cloud: 'Cloud provider keys',
|
|
135
|
+
server: 'Server deployment only',
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
export function describeGroup(group) {
|
|
139
|
+
return GROUP_LABELS[group] || group;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
export async function detectAutoDefaults({ baseUrl, timeoutMs = 2500, signal, probe = true } = {}) {
|
|
144
|
+
const detected = {
|
|
145
|
+
lmStudioReachable: false,
|
|
146
|
+
loadedModel: null,
|
|
147
|
+
loadedModelType: null,
|
|
148
|
+
contextLength: null,
|
|
149
|
+
modelsDir: null,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
if (probe !== false) {
|
|
153
|
+
const root = String(baseUrl || process.env.LMSTUDIO_BASE_URL || 'http://127.0.0.1:1234/v1')
|
|
154
|
+
.replace(/\/v1\/?$/, '')
|
|
155
|
+
.replace(/\/+$/, '');
|
|
156
|
+
try {
|
|
157
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
158
|
+
const fetchSignal = signal ? AbortSignal.any([timeoutSignal, signal]) : timeoutSignal;
|
|
159
|
+
const res = await fetch(`${root}/api/v0/models`, { signal: fetchSignal });
|
|
160
|
+
if (res.ok) {
|
|
161
|
+
const data = await res.json();
|
|
162
|
+
const models = Array.isArray(data?.data) ? data.data : [];
|
|
163
|
+
if (models.length > 0) detected.lmStudioReachable = true;
|
|
164
|
+
const loaded = models.find(m => m.state === 'loaded' && (m.type === 'llm' || m.type === 'vlm'))
|
|
165
|
+
|| models.find(m => m.state === 'loaded');
|
|
166
|
+
if (loaded) {
|
|
167
|
+
detected.loadedModel = loaded.id || null;
|
|
168
|
+
detected.loadedModelType = loaded.type || null;
|
|
169
|
+
detected.contextLength = loaded.loaded_context_length ?? loaded.max_context_length ?? null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
} catch {
|
|
173
|
+
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const candidates = [
|
|
178
|
+
process.env.LMSTUDIO_MODELS_DIR,
|
|
179
|
+
path.join(os.homedir(), '.lmstudio', 'models'),
|
|
180
|
+
process.platform === 'win32' && process.env.LOCALAPPDATA
|
|
181
|
+
? path.join(process.env.LOCALAPPDATA, 'LM Studio', 'models')
|
|
182
|
+
: null,
|
|
183
|
+
].filter(Boolean);
|
|
184
|
+
detected.modelsDir = candidates.find(p => {
|
|
185
|
+
try { return fs.existsSync(p); } catch { return false; }
|
|
186
|
+
}) || null;
|
|
187
|
+
|
|
188
|
+
return detected;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
export function buildEnvTemplate({ values = {}, detected = null } = {}) {
|
|
193
|
+
const lines = [];
|
|
194
|
+
lines.push('# ==============================================================================');
|
|
195
|
+
lines.push('# Kiku Agent — local environment (.env)');
|
|
196
|
+
lines.push(`# Generated by \`/env init\` on ${new Date().toISOString().slice(0, 10)}.`);
|
|
197
|
+
lines.push('# Only necessary values are set; everything else stays commented until needed.');
|
|
198
|
+
lines.push('# ==============================================================================');
|
|
199
|
+
lines.push('');
|
|
200
|
+
|
|
201
|
+
const catalogByGroup = (group) => ENV_VAR_CATALOG.filter(entry => entry.group === group);
|
|
202
|
+
|
|
203
|
+
lines.push(`# --- ${describeGroup('necessary')} ----------------------------------------------------------`);
|
|
204
|
+
for (const entry of catalogByGroup('necessary')) {
|
|
205
|
+
lines.push(`# ${entry.description}`);
|
|
206
|
+
lines.push(`${entry.key}=${values[entry.key] ?? entry.defaultValue ?? ''}`);
|
|
207
|
+
}
|
|
208
|
+
lines.push('');
|
|
209
|
+
|
|
210
|
+
const recommended = catalogByGroup('recommended');
|
|
211
|
+
if (recommended.some(entry => values[entry.key])) {
|
|
212
|
+
lines.push(`# --- ${describeGroup('recommended')} ---------------------------------`);
|
|
213
|
+
for (const entry of recommended) {
|
|
214
|
+
lines.push(`# ${entry.description}`);
|
|
215
|
+
if (values[entry.key]) lines.push(`${entry.key}=${values[entry.key]}`);
|
|
216
|
+
else lines.push(`# ${entry.key}=`);
|
|
217
|
+
}
|
|
218
|
+
lines.push('');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
lines.push(`# --- ${describeGroup('optional')} — uncomment what you need --`);
|
|
222
|
+
for (const entry of catalogByGroup('optional')) {
|
|
223
|
+
if (values[entry.key]) lines.push(`${entry.key}=${values[entry.key]}`);
|
|
224
|
+
else lines.push(`# ${entry.key}=${entry.defaultValue ?? ''} # ${entry.description}`);
|
|
225
|
+
}
|
|
226
|
+
lines.push('');
|
|
227
|
+
|
|
228
|
+
lines.push(`# --- ${describeGroup('cloud')} (only if you use cloud models) ---`);
|
|
229
|
+
for (const entry of catalogByGroup('cloud')) {
|
|
230
|
+
if (values[entry.key]) lines.push(`${entry.key}=${values[entry.key]}`);
|
|
231
|
+
else lines.push(`# ${entry.key}= # ${entry.description}`);
|
|
232
|
+
}
|
|
233
|
+
lines.push('');
|
|
234
|
+
|
|
235
|
+
lines.push(`# --- ${describeGroup('server')} — never set by fans/CLI users ---`);
|
|
236
|
+
lines.push('# These credentials (Upstash rate limiting, Supabase storage, API signing');
|
|
237
|
+
lines.push('# secrets) belong to the hosted REST API / web-chat deployment and are');
|
|
238
|
+
lines.push('# already configured by the platform developer on that environment.');
|
|
239
|
+
lines.push('# /env init will never prompt for them and they are not needed locally.');
|
|
240
|
+
const serverKeys = catalogByGroup('server').map(entry => entry.key).join(', ');
|
|
241
|
+
lines.push(`# ${serverKeys}`);
|
|
242
|
+
lines.push('');
|
|
243
|
+
|
|
244
|
+
if (detected?.loadedModel) {
|
|
245
|
+
lines.push(`# Detected LM Studio model at generation time: ${detected.loadedModel}` +
|
|
246
|
+
(detected.contextLength ? ` (loaded context ${detected.contextLength} tokens)` : ''));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return lines.join('\n').replace(/\n{3,}/g, '\n\n') + '\n';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function writeEnvFile(filePath, content, { allowOverwrite = false } = {}) {
|
|
253
|
+
if (fs.existsSync(filePath) && !allowOverwrite) {
|
|
254
|
+
throw new Error(`${filePath} already exists (use /env set to change individual keys)`);
|
|
255
|
+
}
|
|
256
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
257
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
258
|
+
return filePath;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function generateEnvFile({ targetPath, values = {}, detected = null, allowOverwrite = true } = {}) {
|
|
262
|
+
const content = buildEnvTemplate({ values, detected });
|
|
263
|
+
writeEnvFile(targetPath, content, { allowOverwrite });
|
|
264
|
+
return { path: targetPath, keys: Object.keys(values) };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
export function upsertEnvKey(filePath, key, value) {
|
|
269
|
+
const cleanKey = String(key || '').trim();
|
|
270
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(cleanKey)) {
|
|
271
|
+
throw new Error(`Invalid environment variable name: ${key}`);
|
|
272
|
+
}
|
|
273
|
+
const cleanVal = String(value ?? '').replace(/[\r\n]+/g, '').trim();
|
|
274
|
+
const activeRe = new RegExp(`^${cleanKey}=`);
|
|
275
|
+
const commentedRe = new RegExp(`^#\\s*${cleanKey}=`);
|
|
276
|
+
const raw = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';
|
|
277
|
+
const lines = raw ? raw.split(/\r?\n/) : [];
|
|
278
|
+
|
|
279
|
+
let replaced = false;
|
|
280
|
+
const next = [];
|
|
281
|
+
for (const line of lines) {
|
|
282
|
+
if (activeRe.test(line)) {
|
|
283
|
+
if (!replaced) {
|
|
284
|
+
next.push(`${cleanKey}=${cleanVal}`);
|
|
285
|
+
replaced = true;
|
|
286
|
+
}
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (commentedRe.test(line)) {
|
|
290
|
+
if (!replaced) {
|
|
291
|
+
next.push(`${cleanKey}=${cleanVal}`);
|
|
292
|
+
replaced = true;
|
|
293
|
+
}
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
next.push(line);
|
|
297
|
+
}
|
|
298
|
+
if (!replaced) next.push(`${cleanKey}=${cleanVal}`);
|
|
299
|
+
|
|
300
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
301
|
+
fs.writeFileSync(filePath, next.join('\n'), 'utf8');
|
|
302
|
+
return { path: filePath, key: cleanKey, value: cleanVal };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function removeEnvKey(filePath, key) {
|
|
306
|
+
const cleanKey = String(key || '').trim();
|
|
307
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(cleanKey)) {
|
|
308
|
+
throw new Error(`Invalid environment variable name: ${key}`);
|
|
309
|
+
}
|
|
310
|
+
const activeRe = new RegExp(`^${cleanKey}=`);
|
|
311
|
+
const commentedRe = new RegExp(`^#\\s*${cleanKey}=`);
|
|
312
|
+
if (!fs.existsSync(filePath)) return { path: filePath, removed: false };
|
|
313
|
+
const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
|
|
314
|
+
const next = lines.filter(line => !activeRe.test(line) && !commentedRe.test(line));
|
|
315
|
+
fs.writeFileSync(filePath, next.join('\n'), 'utf8');
|
|
316
|
+
return { path: filePath, removed: next.length !== lines.length };
|
|
317
|
+
}
|
package/server/agent/eventBus.js
CHANGED
|
@@ -33,7 +33,7 @@ class EventBus {
|
|
|
33
33
|
const exactSet = this._listeners.get(event);
|
|
34
34
|
if (exactSet) {
|
|
35
35
|
for (const fn of exactSet) {
|
|
36
|
-
try { fn(data, event); } catch {
|
|
36
|
+
try { fn(data, event); } catch { }
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
|
|
@@ -42,14 +42,14 @@ class EventBus {
|
|
|
42
42
|
const wildcardSet = this._listeners.get(wildcardEvent);
|
|
43
43
|
if (wildcardSet) {
|
|
44
44
|
for (const fn of wildcardSet) {
|
|
45
|
-
try { fn(data, event); } catch {
|
|
45
|
+
try { fn(data, event); } catch { }
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
const globalSet = this._listeners.get('*');
|
|
50
50
|
if (globalSet) {
|
|
51
51
|
for (const fn of globalSet) {
|
|
52
|
-
try { fn(data, event); } catch {
|
|
52
|
+
try { fn(data, event); } catch { }
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
}
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
2
2
|
import { join, dirname } from 'node:path';
|
|
3
3
|
|
|
4
|
-
// Minimum feedback samples on a feature before it becomes a learned directive.
|
|
5
4
|
export const FEEDBACK_MIN_SAMPLES = 5;
|
|
6
|
-
// Like-rate at/above this promotes a "prefer" directive.
|
|
7
5
|
export const FEEDBACK_LIKE_THRESHOLD = 0.7;
|
|
8
|
-
// Like-rate at/below this promotes an "avoid" directive.
|
|
9
6
|
export const FEEDBACK_DISLIKE_THRESHOLD = 0.35;
|
|
10
7
|
export const FEEDBACK_MAX_DIRECTIVES = 6;
|
|
11
8
|
const MAX_EVENTS = 500;
|
|
@@ -46,10 +43,6 @@ export function extractAnswerFeatures(answer = '', toolsUsed = []) {
|
|
|
46
43
|
};
|
|
47
44
|
}
|
|
48
45
|
|
|
49
|
-
// Each definition maps one observable answer trait to an optional "prefer"
|
|
50
|
-
// directive (high like-rate) and/or an "avoid" directive (low like-rate).
|
|
51
|
-
// Traits whose dislike-side is ambiguous (e.g. disliking a short answer does
|
|
52
|
-
// not imply verbosity is wanted) only define the like side.
|
|
53
46
|
const FEATURE_DEFS = [
|
|
54
47
|
{
|
|
55
48
|
key: 'len:short',
|
|
@@ -183,7 +176,6 @@ export class FeedbackLearning {
|
|
|
183
176
|
out.push({ id: `avoid:${def.key}`, kind: 'avoid', text: def.dislike, support: samples, likeRate: Number(likeRate.toFixed(2)), updatedAt: new Date().toISOString() });
|
|
184
177
|
}
|
|
185
178
|
}
|
|
186
|
-
// Prefer stronger evidence first; cap so the prompt stays lean.
|
|
187
179
|
out.sort((a, b) => b.support - a.support || (b.kind === 'avoid' ? 1 : 0) - (a.kind === 'avoid' ? 1 : 0));
|
|
188
180
|
this.state.directives = out.slice(0, FEEDBACK_MAX_DIRECTIVES);
|
|
189
181
|
}
|
|
@@ -29,11 +29,97 @@ function runProcessCapture(cmd, args, timeoutMs = 2500) {
|
|
|
29
29
|
});
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
function detectGpuVendor(name) {
|
|
33
|
+
return name.includes('NVIDIA')
|
|
34
|
+
? 'NVIDIA'
|
|
35
|
+
: name.includes('AMD') || name.includes('Radeon')
|
|
36
|
+
? 'AMD'
|
|
37
|
+
: name.includes('Intel')
|
|
38
|
+
? 'Intel'
|
|
39
|
+
: 'Generic';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let cachedSharedMemory = null;
|
|
43
|
+
const SHARED_MEMORY_TTL_MS = 10_000;
|
|
44
|
+
|
|
45
|
+
export async function getSharedMemoryMetrics(processFilter = '') {
|
|
46
|
+
if (process.platform !== 'win32') {
|
|
47
|
+
return { available: false, sharedMemoryUsedMb: 0, hasSpillover: false };
|
|
48
|
+
}
|
|
49
|
+
if (cachedSharedMemory && (Date.now() - cachedSharedMemory.timestamp < SHARED_MEMORY_TTL_MS)) {
|
|
50
|
+
return cachedSharedMemory.data;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const script = processFilter
|
|
54
|
+
? `$p = @(Get-Process '*${processFilter}*' -ErrorAction SilentlyContinue).Id; if ($p -and $p.Count -gt 0) { $paths = $p | ForEach-Object { "\\GPU Process Memory(pid_" + $_ + "_*)\\Shared Usage" }; $s = (Get-Counter $paths -ErrorAction SilentlyContinue).CounterSamples; if ($s) { ($s | Measure-Object -Property CookedValue -Sum).Sum } else { 0 } } else { (Get-Counter '\\GPU Process Memory(*)\\Shared Usage' -ErrorAction SilentlyContinue).CounterSamples | Measure-Object -Property CookedValue -Sum | Select-Object -ExpandProperty Sum }`
|
|
55
|
+
: `(Get-Counter '\\GPU Process Memory(*)\\Shared Usage' -ErrorAction SilentlyContinue).CounterSamples | Measure-Object -Property CookedValue -Sum | Select-Object -ExpandProperty Sum`;
|
|
56
|
+
const res = await runProcessCapture('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], 2500);
|
|
57
|
+
if (res.ok && res.stdout) {
|
|
58
|
+
const bytes = parseFloat(res.stdout.trim()) || 0;
|
|
59
|
+
const mb = Number((bytes / (1024 * 1024)).toFixed(1));
|
|
60
|
+
const result = {
|
|
61
|
+
available: true,
|
|
62
|
+
sharedMemoryBytes: bytes,
|
|
63
|
+
sharedMemoryUsedMb: mb,
|
|
64
|
+
hasSpillover: mb > 5.0,
|
|
65
|
+
};
|
|
66
|
+
cachedSharedMemory = { data: result, timestamp: Date.now() };
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
} catch {}
|
|
70
|
+
return { available: false, sharedMemoryUsedMb: 0, hasSpillover: false };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let cachedGpuMetrics = null;
|
|
74
|
+
let lastGpuMetricsFetch = 0;
|
|
75
|
+
const GPU_METRICS_TTL_MS = 5_000;
|
|
76
|
+
|
|
77
|
+
let gpuMetricsInFlight = null;
|
|
78
|
+
let windowsFallbackCache = null;
|
|
79
|
+
let windowsFallbackInFlight = null;
|
|
80
|
+
const WINDOWS_FALLBACK_TTL_MS = 300_000;
|
|
81
|
+
|
|
82
|
+
export async function getGpuMetrics(options = {}) {
|
|
83
|
+
const hasCache = cachedGpuMetrics !== null;
|
|
84
|
+
const isFresh = hasCache && (Date.now() - lastGpuMetricsFetch < GPU_METRICS_TTL_MS);
|
|
85
|
+
|
|
86
|
+
if (isFresh && !options.noCache) {
|
|
87
|
+
return cachedGpuMetrics;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (hasCache && !options.noCache) {
|
|
91
|
+
if (!gpuMetricsInFlight) {
|
|
92
|
+
gpuMetricsInFlight = computeGpuMetrics(options)
|
|
93
|
+
.catch(() => cachedGpuMetrics)
|
|
94
|
+
.finally(() => { gpuMetricsInFlight = null; });
|
|
95
|
+
}
|
|
96
|
+
return cachedGpuMetrics;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (gpuMetricsInFlight) return gpuMetricsInFlight;
|
|
100
|
+
gpuMetricsInFlight = computeGpuMetrics(options);
|
|
101
|
+
try {
|
|
102
|
+
return await gpuMetricsInFlight;
|
|
103
|
+
} finally {
|
|
104
|
+
gpuMetricsInFlight = null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
33
107
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
108
|
+
async function computeGpuMetrics(options = {}) {
|
|
109
|
+
const result = await computeGpuMetricsUncached(options);
|
|
110
|
+
cachedGpuMetrics = result;
|
|
111
|
+
lastGpuMetricsFetch = Date.now();
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function computeGpuMetricsUncached(options = {}) {
|
|
116
|
+
const includeShared = options.includeShared !== false;
|
|
117
|
+
const [nv, shared] = await Promise.all([
|
|
118
|
+
runProcessCapture('nvidia-smi', [
|
|
119
|
+
'--query-gpu=name,memory.total,memory.used,memory.free,utilization.gpu,temperature.gpu,driver_version',
|
|
120
|
+
'--format=csv,noheader,nounits'
|
|
121
|
+
]),
|
|
122
|
+
includeShared ? getSharedMemoryMetrics('lm') : Promise.resolve(cachedSharedMemory?.data || { available: false, sharedMemoryUsedMb: 0, hasSpillover: false })
|
|
37
123
|
]);
|
|
38
124
|
|
|
39
125
|
if (nv.ok && nv.stdout) {
|
|
@@ -54,6 +140,8 @@ export async function getGpuMetrics() {
|
|
|
54
140
|
totalMb,
|
|
55
141
|
usedMb,
|
|
56
142
|
freeMb,
|
|
143
|
+
source: 'nvidia-smi (NVML)',
|
|
144
|
+
approximate: false,
|
|
57
145
|
totalGb: (totalMb / 1024).toFixed(1),
|
|
58
146
|
usedGb: (usedMb / 1024).toFixed(1),
|
|
59
147
|
freeGb: (freeMb / 1024).toFixed(1),
|
|
@@ -61,6 +149,8 @@ export async function getGpuMetrics() {
|
|
|
61
149
|
utilizationGpu: utilPercent,
|
|
62
150
|
temperatureC: tempC,
|
|
63
151
|
driverVersion: driver || 'N/A',
|
|
152
|
+
sharedMemoryUsedMb: shared.sharedMemoryUsedMb || 0,
|
|
153
|
+
hasSpillover: Boolean(shared.hasSpillover),
|
|
64
154
|
};
|
|
65
155
|
});
|
|
66
156
|
|
|
@@ -71,62 +161,26 @@ export async function getGpuMetrics() {
|
|
|
71
161
|
count: gpus.length,
|
|
72
162
|
primary: gpus[0],
|
|
73
163
|
gpus,
|
|
164
|
+
sharedMemory: shared,
|
|
74
165
|
};
|
|
75
166
|
}
|
|
76
167
|
}
|
|
77
168
|
|
|
78
|
-
|
|
79
|
-
if (process.platform === 'win32') {
|
|
80
|
-
const ps = await runProcessCapture('powershell', [
|
|
81
|
-
'-NoProfile',
|
|
82
|
-
'-NonInteractive',
|
|
83
|
-
'-Command',
|
|
84
|
-
'Get-CimInstance Win32_VideoController | Select-Object Name, AdapterRAM | ConvertTo-Json'
|
|
85
|
-
]);
|
|
86
|
-
|
|
87
|
-
if (ps.ok && ps.stdout) {
|
|
88
|
-
try {
|
|
89
|
-
let data = JSON.parse(ps.stdout);
|
|
90
|
-
if (!Array.isArray(data)) data = [data];
|
|
91
|
-
const gpus = data.filter(item => item?.Name).map((item, idx) => {
|
|
92
|
-
const ramBytes = item.AdapterRAM || 0;
|
|
93
|
-
const totalMb = Math.round(ramBytes / (1024 * 1024));
|
|
94
|
-
const name = String(item.Name);
|
|
95
|
-
const vendor = name.includes('NVIDIA')
|
|
96
|
-
? 'NVIDIA'
|
|
97
|
-
: name.includes('AMD') || name.includes('Radeon')
|
|
98
|
-
? 'AMD'
|
|
99
|
-
: name.includes('Intel')
|
|
100
|
-
? 'Intel'
|
|
101
|
-
: 'Generic';
|
|
102
169
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
};
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
if (gpus.length > 0) {
|
|
121
|
-
return {
|
|
122
|
-
available: true,
|
|
123
|
-
vendor: gpus[0].vendor,
|
|
124
|
-
count: gpus.length,
|
|
125
|
-
primary: gpus[0],
|
|
126
|
-
gpus,
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
} catch {}
|
|
170
|
+
if (process.platform === 'win32') {
|
|
171
|
+
if (windowsFallbackCache && Date.now() < windowsFallbackCache.expires) {
|
|
172
|
+
return windowsFallbackCache.data;
|
|
173
|
+
}
|
|
174
|
+
if (!windowsFallbackInFlight) windowsFallbackInFlight = probeWindowsGpuFallback();
|
|
175
|
+
let fallback = null;
|
|
176
|
+
try {
|
|
177
|
+
fallback = await windowsFallbackInFlight;
|
|
178
|
+
} finally {
|
|
179
|
+
windowsFallbackInFlight = null;
|
|
180
|
+
}
|
|
181
|
+
if (fallback) {
|
|
182
|
+
windowsFallbackCache = { expires: Date.now() + WINDOWS_FALLBACK_TTL_MS, data: fallback };
|
|
183
|
+
return fallback;
|
|
130
184
|
}
|
|
131
185
|
}
|
|
132
186
|
|
|
@@ -175,6 +229,85 @@ export async function getGpuMetrics() {
|
|
|
175
229
|
};
|
|
176
230
|
}
|
|
177
231
|
|
|
232
|
+
async function probeWindowsGpuFallback() {
|
|
233
|
+
const REGISTRY_SNIPPET = "$b='HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Class\\{4d36e968-e325-11ce-bfc1-08002be10318}'; Get-ChildItem $b -ErrorAction SilentlyContinue | ForEach-Object { $p = Get-ItemProperty -Path $_.PSPath -ErrorAction SilentlyContinue; if ($p.DriverDesc -and $p.'HardwareInformation.qwMemorySize') { $raw = $p.'HardwareInformation.qwMemorySize'; $n = [UInt64]0; if ($raw -is [byte[]]) { $n = [BitConverter]::ToUInt64([byte[]]$raw, 0) } elseif ($raw -is [long] -or $raw -is [int]) { $n = [UInt64]$raw }; if ($n -gt 0) { [PSCustomObject]@{ Name = $p.DriverDesc; Bytes = $n } } } } | ConvertTo-Json";
|
|
234
|
+
|
|
235
|
+
let gpus = [];
|
|
236
|
+
|
|
237
|
+
const ps = await runProcessCapture('powershell', [
|
|
238
|
+
'-NoProfile',
|
|
239
|
+
'-NonInteractive',
|
|
240
|
+
'-Command',
|
|
241
|
+
REGISTRY_SNIPPET,
|
|
242
|
+
]);
|
|
243
|
+
if (ps.stdout) {
|
|
244
|
+
try {
|
|
245
|
+
let data = JSON.parse(ps.stdout);
|
|
246
|
+
if (!Array.isArray(data)) data = [data];
|
|
247
|
+
gpus = data.filter(item => item?.Name && Number(item.Bytes) > 0).map((item, idx) => {
|
|
248
|
+
const name = String(item.Name);
|
|
249
|
+
return {
|
|
250
|
+
index: idx,
|
|
251
|
+
name,
|
|
252
|
+
vendor: detectGpuVendor(name),
|
|
253
|
+
totalMb: Math.round(Number(item.Bytes) / (1024 * 1024)),
|
|
254
|
+
usedMb: null,
|
|
255
|
+
freeMb: null,
|
|
256
|
+
source: 'windows-registry',
|
|
257
|
+
approximate: false,
|
|
258
|
+
};
|
|
259
|
+
});
|
|
260
|
+
} catch {}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (gpus.length === 0) {
|
|
264
|
+
const wmi = await runProcessCapture('powershell', [
|
|
265
|
+
'-NoProfile',
|
|
266
|
+
'-NonInteractive',
|
|
267
|
+
'-Command',
|
|
268
|
+
'Get-CimInstance Win32_VideoController | Select-Object Name, AdapterRAM | ConvertTo-Json'
|
|
269
|
+
]);
|
|
270
|
+
if (wmi.stdout) {
|
|
271
|
+
try {
|
|
272
|
+
let data = JSON.parse(wmi.stdout);
|
|
273
|
+
if (!Array.isArray(data)) data = [data];
|
|
274
|
+
gpus = data.filter(item => item?.Name).map((item, idx) => ({
|
|
275
|
+
index: idx,
|
|
276
|
+
name: String(item.Name),
|
|
277
|
+
vendor: detectGpuVendor(String(item.Name)),
|
|
278
|
+
totalMb: Math.round((item.AdapterRAM || 0) / (1024 * 1024)),
|
|
279
|
+
usedMb: null,
|
|
280
|
+
freeMb: null,
|
|
281
|
+
source: 'wmi-adapterram',
|
|
282
|
+
approximate: true,
|
|
283
|
+
}));
|
|
284
|
+
} catch {}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (gpus.length === 0) return null;
|
|
289
|
+
|
|
290
|
+
gpus.sort((a, b) => (b.totalMb || 0) - (a.totalMb || 0));
|
|
291
|
+
gpus.forEach((g, idx) => { g.index = idx; });
|
|
292
|
+
const primary = gpus[0];
|
|
293
|
+
return {
|
|
294
|
+
available: true,
|
|
295
|
+
vendor: primary.vendor,
|
|
296
|
+
count: gpus.length,
|
|
297
|
+
primary: {
|
|
298
|
+
...primary,
|
|
299
|
+
totalGb: (primary.totalMb / 1024).toFixed(1),
|
|
300
|
+
usedGb: null,
|
|
301
|
+
freeGb: null,
|
|
302
|
+
usedPercent: null,
|
|
303
|
+
utilizationGpu: null,
|
|
304
|
+
temperatureC: null,
|
|
305
|
+
driverVersion: 'N/A',
|
|
306
|
+
},
|
|
307
|
+
gpus,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
178
311
|
export function formatProgressBar(percent = 0, width = 16) {
|
|
179
312
|
const safePct = Math.max(0, Math.min(100, Number(percent) || 0));
|
|
180
313
|
const filled = Math.round((safePct / 100) * width);
|