lumencode 0.4.3

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.
@@ -0,0 +1,183 @@
1
+ // 基于工具调用 + 用户文本关键词的使用场景分类引擎
2
+
3
+ const TOOL_SCENARIO_MAP = {
4
+ // Claude Code
5
+ Write: 'coding',
6
+ Edit: 'coding',
7
+ NotebookEdit: 'coding',
8
+ Bash: 'execution',
9
+ Read: 'reading',
10
+ Glob: 'reading',
11
+ Grep: 'reading',
12
+ TaskCreate: 'planning',
13
+ TaskUpdate: 'planning',
14
+ TaskList: 'planning',
15
+ TaskGet: 'planning',
16
+ Agent: 'planning',
17
+ EnterPlanMode: 'planning',
18
+ ExitPlanMode: 'planning',
19
+ AskUserQuestion: 'interaction',
20
+ Skill: 'skill',
21
+ WebSearch: 'research',
22
+ WebFetch: 'research',
23
+ CronCreate: 'automation',
24
+ CronDelete: 'automation',
25
+ // Codex
26
+ shell_command: 'execution',
27
+ update_plan: 'planning',
28
+ view_image: 'reading',
29
+ browser_navigate: 'testing',
30
+ browser_run_code_unsafe: 'testing',
31
+ browser_console_messages: 'testing',
32
+ // OpenCode
33
+ write: 'coding',
34
+ edit: 'coding',
35
+ bash: 'execution',
36
+ glob: 'reading',
37
+ question: 'interaction',
38
+ todowrite: 'planning',
39
+ // Codex (serena MCP)
40
+ activate_project: 'coding',
41
+ initial_instructions: 'reading',
42
+ check_onboarding_performed: 'reading',
43
+ onboarding: 'reading',
44
+ find_symbol: 'reading',
45
+ find_declaration: 'reading',
46
+ find_referencing_symbols: 'reading',
47
+ find_implementations: 'reading',
48
+ get_symbols_overview: 'reading',
49
+ replace_symbol_body: 'coding',
50
+ replace_content: 'coding',
51
+ insert_before_symbol: 'coding',
52
+ insert_after_symbol: 'coding',
53
+ get_diagnostics_for_file: 'testing',
54
+ };
55
+
56
+ const MCP_TOOL_SCENARIOS = {
57
+ 'mcp__Playwright': 'testing',
58
+ 'mcp__context7': 'research',
59
+ 'mcp__open-websearch': 'research',
60
+ 'mcp__serena': 'coding',
61
+ 'mcp__mcp-deepwiki': 'research',
62
+ 'mcp__web_reader': 'research',
63
+ };
64
+
65
+ export function classifyRecord(record, scenarioKeywords) {
66
+ const scenarios = {};
67
+ // 兼容 UsageRecord 格式(metadata.toolCalls)和原始格式(toolCalls)
68
+ const toolCalls = record.metadata?.toolCalls || record.toolCalls || [];
69
+ const tools = toolCalls.map(t => t.name);
70
+
71
+ // 基于工具调用的分类
72
+ const toolScenarios = new Set();
73
+ for (const tool of tools) {
74
+ if (TOOL_SCENARIO_MAP[tool]) {
75
+ toolScenarios.add(TOOL_SCENARIO_MAP[tool]);
76
+ }
77
+ // MCP 工具前缀匹配
78
+ for (const [prefix, scenario] of Object.entries(MCP_TOOL_SCENARIOS)) {
79
+ if (tool.startsWith(prefix)) {
80
+ toolScenarios.add(scenario);
81
+ }
82
+ }
83
+ }
84
+
85
+ // 重新构建工具场景计数,确保每个工具都被计数
86
+ for (const tool of tools) {
87
+ if (TOOL_SCENARIO_MAP[tool]) {
88
+ scenarios[TOOL_SCENARIO_MAP[tool]] = (scenarios[TOOL_SCENARIO_MAP[tool]] || 0) + 1;
89
+ }
90
+ // MCP 工具前缀匹配
91
+ for (const [prefix, scenario] of Object.entries(MCP_TOOL_SCENARIOS)) {
92
+ if (tool.startsWith(prefix)) {
93
+ scenarios[scenario] = (scenarios[scenario] || 0) + 1;
94
+ break; // 只匹配第一个前缀,避免重复计数
95
+ }
96
+ }
97
+ }
98
+
99
+ // 基于用户文本的关键词分类(仅对 user 消息)
100
+ // 兼容 UsageRecord 格式(metadata.type/text)和原始格式(type/text)
101
+ const recordType = record.metadata?.type || record.type;
102
+ const recordText = record.metadata?.text || record.text;
103
+ if (recordType === 'user' && recordText && scenarioKeywords) {
104
+ const lowerText = recordText.toLowerCase();
105
+ for (const [scenario, keywords] of Object.entries(scenarioKeywords)) {
106
+ for (const kw of keywords) {
107
+ const lowerKw = kw.toLowerCase();
108
+ // 简化正则表达式,对于中文使用简单的包含匹配
109
+ if (lowerText.includes(lowerKw)) {
110
+ scenarios[scenario] = (scenarios[scenario] || 0) + 1;
111
+ break;
112
+ }
113
+ }
114
+ }
115
+ }
116
+
117
+ return scenarios;
118
+ }
119
+
120
+ // 将内部场景映射为用户友好的分类
121
+ export function mapToDisplayScenarios(internalScenarios) {
122
+ const display = {
123
+ '编码': 0,
124
+ '测试/QA': 0,
125
+ '调试/排错': 0,
126
+ '文档': 0,
127
+ '阅读/研究': 0,
128
+ '规划/设计': 0,
129
+ '代码审查': 0,
130
+ '其他': 0,
131
+ };
132
+
133
+ for (const [key, count] of Object.entries(internalScenarios)) {
134
+ switch (key) {
135
+ case 'coding':
136
+ display['编码'] += count;
137
+ break;
138
+ case 'testing':
139
+ display['测试/QA'] += count;
140
+ break;
141
+ case 'debugging':
142
+ display['调试/排错'] += count;
143
+ break;
144
+ case 'documentation':
145
+ display['文档'] += count;
146
+ break;
147
+ case 'reading':
148
+ case 'research':
149
+ display['阅读/研究'] += count;
150
+ break;
151
+ case 'planning':
152
+ display['规划/设计'] += count;
153
+ break;
154
+ case 'review':
155
+ display['代码审查'] += count;
156
+ break;
157
+ case 'execution':
158
+ display['编码'] += count;
159
+ break;
160
+ case 'interaction':
161
+ case 'skill':
162
+ case 'automation':
163
+ default:
164
+ display['其他'] += count;
165
+ break;
166
+ }
167
+ }
168
+
169
+ return display;
170
+ }
171
+
172
+ export function aggregateScenarios(records, scenarioKeywords) {
173
+ const total = {};
174
+
175
+ for (const record of records) {
176
+ const scenarios = classifyRecord(record, scenarioKeywords);
177
+ for (const [key, count] of Object.entries(scenarios)) {
178
+ total[key] = (total[key] || 0) + count;
179
+ }
180
+ }
181
+
182
+ return mapToDisplayScenarios(total);
183
+ }
package/lib/server.js ADDED
@@ -0,0 +1,412 @@
1
+ import { createServer } from 'http';
2
+ import { readFileSync, existsSync } from 'fs';
3
+ import { join, extname, resolve, sep } from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import { saveConfig } from './config.js';
6
+ import { generateWorkReport, generateFeishuCard } from './report.js';
7
+ import { collectAllRecords, filterRecordsByPeriod, groupBySessions } from './aggregate.js';
8
+ import { normalizeProjectPath } from './aggregate.js';
9
+ import { invalidateFileCache } from './cache.js';
10
+ import { invalidateGitCache, getGitStatsForMultipleReposAsync, finalizeGitStats } from './git.js';
11
+ import { identifyBillingBlocks } from './blocks.js';
12
+ import { detectAvailableTools, parseAllEnabledTools } from './parsers/index.js';
13
+
14
+ // basename 提取,兼容不同路径格式
15
+ function getProjectBaseName(p) {
16
+ if (!p) return '';
17
+ return p.replace(/\\/g, '/').replace(/\/$/, '').split('/').pop() || '';
18
+ }
19
+
20
+ const __dirname = fileURLToPath(new URL('..', import.meta.url));
21
+
22
+ const MIME = {
23
+ '.html': 'text/html',
24
+ '.css': 'text/css',
25
+ '.js': 'application/javascript; charset=utf-8',
26
+ '.json': 'application/json',
27
+ '.png': 'image/png',
28
+ '.svg': 'image/svg+xml',
29
+ '.ico': 'image/x-icon',
30
+ };
31
+
32
+ export function startServer(config, effectiveIncludeProjects, buildReportData, configPath) {
33
+ function computeIncludeProjects(cfg) {
34
+ if (cfg.repos && cfg.repos.length > 0) {
35
+ return cfg.repos.map(r => normalizeProjectPath(r));
36
+ }
37
+ return null;
38
+ }
39
+
40
+ const PORT = process.env.LUMENCODE_PORT || 4567;
41
+
42
+ const server = createServer(async (req, res) => {
43
+ const url = new URL(req.url, `http://localhost:${PORT}`);
44
+
45
+ // 安全响应头
46
+ res.setHeader('X-Content-Type-Options', 'nosniff');
47
+ res.setHeader('X-Frame-Options', 'DENY');
48
+ res.setHeader('Referrer-Policy', 'no-referrer');
49
+
50
+ // API endpoint
51
+ if (url.pathname === '/api/tools') {
52
+ try {
53
+ const tools = await detectAvailableTools(config);
54
+ const enabled = config.enabledTools || tools.filter(t => t.detected).map(t => t.name);
55
+ const result = tools.map(({ name, displayName, detected, version }) => ({
56
+ name, displayName, detected, version,
57
+ enabled: enabled.includes(name),
58
+ }));
59
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://localhost:' + PORT });
60
+ res.end(JSON.stringify(result));
61
+ } catch (err) {
62
+ res.writeHead(500, { 'Content-Type': 'application/json' });
63
+ console.error('API error:', err.message);
64
+ res.end(JSON.stringify({ error: '服务器内部错误' }));
65
+ }
66
+ return;
67
+ }
68
+
69
+ if (url.pathname === '/api/report') {
70
+ const period = url.searchParams.get('period') || 'daily';
71
+ const date = url.searchParams.get('date') || new Date().toISOString().slice(0, 10);
72
+ const format = url.searchParams.get('format') || 'json';
73
+ const tool = url.searchParams.get('tool') || 'all';
74
+
75
+ // 未配置时返回友好提示
76
+ if (!config.claudeDir || !existsSync(config.claudeDir)) {
77
+ res.writeHead(200, { 'Content-Type': 'application/json' });
78
+ res.end(JSON.stringify({
79
+ error: '未配置',
80
+ hint: '尚未配置 Claude 日志目录,请在下方完成初始设置',
81
+ }));
82
+ return;
83
+ }
84
+
85
+ try {
86
+ const data = await buildReportData(period, date, config, computeIncludeProjects(config), tool);
87
+ if (!data) {
88
+ res.writeHead(200, { 'Content-Type': 'application/json' });
89
+ res.end(JSON.stringify({
90
+ error: '未找到数据',
91
+ hint: '请检查 Claude 日志目录配置是否正确,确认目录下有 projects/ 子目录',
92
+ }));
93
+ return;
94
+ }
95
+
96
+ if (format === 'work') {
97
+ const platform = url.searchParams.get('platform') || 'default';
98
+ const level = url.searchParams.get('level') || 'detailed';
99
+ const feishuCard = url.searchParams.get('feishuCard') === 'true';
100
+
101
+ if (feishuCard) {
102
+ const card = generateFeishuCard(data.usageStats, data.gitStats, period, data.start, data.end, tool);
103
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://localhost:' + PORT });
104
+ res.end(JSON.stringify(card));
105
+ return;
106
+ }
107
+
108
+ const markdown = generateWorkReport(data.usageStats, data.gitStats, period, data.start, data.end, data.prevStats, { level, platform, tool });
109
+ res.writeHead(200, {
110
+ 'Content-Type': 'text/plain; charset=utf-8',
111
+ 'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
112
+ });
113
+ res.end(markdown);
114
+ return;
115
+ }
116
+
117
+ // 按工具替换 aiContribution
118
+ if (tool !== 'all' && data.gitStats?.aiContributionByTool) {
119
+ const toolAi = data.gitStats.aiContributionByTool[tool];
120
+ if (toolAi) {
121
+ data.gitStats.aiContribution = toolAi;
122
+ }
123
+ }
124
+
125
+ // 添加费用分解
126
+ if (data.usageStats?.models) {
127
+ const modelEntries = Object.entries(data.usageStats.models)
128
+ .sort((a, b) => b[1].cost - a[1].cost);
129
+ const totalCost = modelEntries.reduce((s, [, d]) => s + (d.cost || 0), 0);
130
+ // 缓存节省 = (inputTokens - cacheRead) * avgInputRate - 已通过 cacheRead 低价体现
131
+ // 简化:缓存节省 = cacheRead * avgInputRate * (1 - cacheReadRate/inputRate)
132
+ const cacheRead = data.usageStats.cacheRead || 0;
133
+ const cacheCreate = data.usageStats.cacheCreate || 0;
134
+ let cacheSaving = 0;
135
+ if (totalCost > 0 && (cacheRead + cacheCreate) > 0) {
136
+ const totalInput = data.usageStats.inputTokens || 1;
137
+ const avgInputCostPerToken = totalCost / (totalInput + data.usageStats.outputTokens + cacheRead + cacheCreate || 1);
138
+ cacheSaving = Math.round(cacheRead * avgInputCostPerToken * 0.9 * 100) / 100;
139
+ }
140
+ data.costBreakdown = {
141
+ models: modelEntries.map(([name, d]) => ({
142
+ name,
143
+ cost: d.cost || 0,
144
+ mode: d.costMode || 'unknown',
145
+ requests: d.count,
146
+ inputTokens: d.inputTokens,
147
+ outputTokens: d.outputTokens,
148
+ })),
149
+ cacheSaving,
150
+ total: Math.round(totalCost * 100) / 100,
151
+ };
152
+ }
153
+
154
+ res.writeHead(200, {
155
+ 'Content-Type': 'application/json',
156
+ 'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
157
+ });
158
+ res.end(JSON.stringify(data));
159
+ } catch (err) {
160
+ res.writeHead(500, { 'Content-Type': 'application/json' });
161
+ console.error('API error:', err.message);
162
+ res.end(JSON.stringify({ error: '服务器内部错误' }));
163
+ }
164
+ return;
165
+ }
166
+
167
+ if (url.pathname === '/api/sessions') {
168
+ const period = url.searchParams.get('period') || 'daily';
169
+ const date = url.searchParams.get('date') || new Date().toISOString().slice(0, 10);
170
+ const project = url.searchParams.get('project') || '';
171
+ const tool = url.searchParams.get('tool') || '';
172
+ try {
173
+ const { records: allRecords } = await parseAllEnabledTools(config, {
174
+ excludeProjects: config.excludeProjects,
175
+ includeProjects: computeIncludeProjects(config),
176
+ });
177
+ const { filtered, start, end } = filterRecordsByPeriod(allRecords, period, date);
178
+ const tooledRecords = tool ? filtered.filter(r => r.tool === tool) : filtered;
179
+ // basename 匹配,兼容不同工具的路径格式差异
180
+ const projected = project ? tooledRecords.filter(r => getProjectBaseName(r.project) === project) : tooledRecords;
181
+ const sessions = groupBySessions(projected);
182
+
183
+ // 附加 commits 信息(若配置了 repos),按覆盖项目过滤,扩展窗口匹配跨天提交
184
+ if (config.repos?.length) {
185
+ try {
186
+ const coveredBases = new Set(projected.map(r => {
187
+ const p = r.project || '';
188
+ return p.replace(/\\/g, '/').replace(/\/$/, '').split('/').pop();
189
+ }).filter(Boolean));
190
+ const sessionRepos = config.repos.filter(r => coveredBases.has(r.replace(/\\/g, '/').replace(/\/$/, '').split('/').pop()));
191
+ if (sessionRepos.length > 0) {
192
+ const extEnd = new Date(end);
193
+ extEnd.setDate(extEnd.getDate() + 2);
194
+ const gitStats = await getGitStatsForMultipleReposAsync(sessionRepos, start, extEnd.toISOString().slice(0, 10) + 'T23:59:59');
195
+ finalizeGitStats(gitStats, sessions);
196
+ }
197
+ } catch {}
198
+ }
199
+
200
+ // 精简返回字段,保留效率指标
201
+ const slim = sessions.map(s => {
202
+ const startMs = Date.parse(s.startTime);
203
+ const endMs = Date.parse(s.endTime);
204
+ const duration = Number.isFinite(startMs) && Number.isFinite(endMs) ? Math.round((endMs - startMs) / 1000) : 0;
205
+ return {
206
+ id: s.id,
207
+ project: s.project,
208
+ startTime: s.startTime,
209
+ endTime: s.endTime,
210
+ duration,
211
+ requests: s.requests,
212
+ userMessages: s.userMessages,
213
+ inputTokens: s.inputTokens,
214
+ outputTokens: s.outputTokens,
215
+ models: s.models,
216
+ primaryTool: s.primaryTool || null,
217
+ touchedFileCount: (s.touchedFiles || []).length,
218
+ toolSequence: (s.toolSequence || []).map(tc => tc.name),
219
+ shellCommandCount: (s.shellCommands || []).length,
220
+ commits: s.commits || [],
221
+ };
222
+ });
223
+
224
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://localhost:' + PORT });
225
+ res.end(JSON.stringify(slim));
226
+ } catch (err) {
227
+ res.writeHead(500, { 'Content-Type': 'application/json' });
228
+ console.error('API error:', err.message);
229
+ res.end(JSON.stringify({ error: '服务器内部错误' }));
230
+ }
231
+ return;
232
+ }
233
+
234
+ if (url.pathname === '/api/details') {
235
+ const period = url.searchParams.get('period') || 'daily';
236
+ const date = url.searchParams.get('date') || new Date().toISOString().slice(0, 10);
237
+ const dimension = url.searchParams.get('dimension') || '';
238
+ const key = url.searchParams.get('key') || '';
239
+ try {
240
+ const { records: allRecords } = await parseAllEnabledTools(config, {
241
+ excludeProjects: config.excludeProjects,
242
+ includeProjects: computeIncludeProjects(config),
243
+ });
244
+ const { filtered } = filterRecordsByPeriod(allRecords, period, date);
245
+ let result = [];
246
+ if (dimension === 'model') {
247
+ const modelRecords = filtered.filter(r => {
248
+ const isAssistant = r.metadata?.type === 'assistant' || (r.tool === 'codex') || (r.tool === 'opencode' && r.metadata?.role !== 'user') || (r.type === 'assistant' && !r.tool);
249
+ return isAssistant && (r.model || '') === key;
250
+ });
251
+ const dailyMap = {};
252
+ for (const r of modelRecords) {
253
+ const d = r.timestamp.slice(0, 10);
254
+ if (!dailyMap[d]) dailyMap[d] = { date: d, requests: 0, inputTokens: 0, outputTokens: 0 };
255
+ dailyMap[d].requests++;
256
+ dailyMap[d].inputTokens += r.inputTokens || r.tokens?.input || 0;
257
+ dailyMap[d].outputTokens += r.outputTokens || r.tokens?.output || 0;
258
+ }
259
+ result = Object.values(dailyMap).sort((a, b) => a.date.localeCompare(b.date));
260
+ } else if (dimension === 'scenario') {
261
+ const matched = filtered.filter(r => {
262
+ const text = r.metadata?.text || r.text || '';
263
+ const type = r.metadata?.type || r.type;
264
+ return type === 'user' && text;
265
+ });
266
+ for (const r of matched) {
267
+ const text = r.metadata?.text || r.text || '';
268
+ const lower = text.toLowerCase();
269
+ const keywords = config.scenarioKeywords?.[key] || [];
270
+ for (const kw of keywords) {
271
+ if (lower.includes(kw.toLowerCase())) {
272
+ result.push({ text: text.slice(0, 200), timestamp: r.timestamp, project: r.project });
273
+ break;
274
+ }
275
+ }
276
+ if (result.length >= 10) break;
277
+ }
278
+ }
279
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://localhost:' + PORT });
280
+ res.end(JSON.stringify(result));
281
+ } catch (err) {
282
+ res.writeHead(500, { 'Content-Type': 'application/json' });
283
+ console.error('API error:', err.message);
284
+ res.end(JSON.stringify({ error: '服务器内部错误' }));
285
+ }
286
+ return;
287
+ }
288
+
289
+ // Billing blocks endpoint
290
+ if (url.pathname === '/api/blocks') {
291
+ const period = url.searchParams.get('period') || 'daily';
292
+ const date = url.searchParams.get('date') || new Date().toISOString().slice(0, 10);
293
+ try {
294
+ const { records: allRecords } = await parseAllEnabledTools(config, {
295
+ excludeProjects: config.excludeProjects,
296
+ includeProjects: computeIncludeProjects(config),
297
+ });
298
+ const { filtered } = filterRecordsByPeriod(allRecords, period, date);
299
+ const blocks = identifyBillingBlocks(filtered, 5, config.costMode);
300
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://localhost:' + PORT });
301
+ res.end(JSON.stringify(blocks));
302
+ } catch (err) {
303
+ res.writeHead(500, { 'Content-Type': 'application/json' });
304
+ console.error('API error:', err.message);
305
+ res.end(JSON.stringify({ error: '服务器内部错误' }));
306
+ }
307
+ return;
308
+ }
309
+
310
+ // Config endpoint
311
+ if (url.pathname === '/api/config') {
312
+ if (req.method === 'GET') {
313
+ res.writeHead(200, {
314
+ 'Content-Type': 'application/json',
315
+ 'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
316
+ });
317
+ res.end(JSON.stringify({
318
+ claudeDir: config.claudeDir,
319
+ codexDir: config.codexDir || '',
320
+ opencodeDir: config.opencodeDir || '',
321
+ enabledTools: config.enabledTools || [],
322
+ repos: config.repos || [],
323
+ excludeProjects: config.excludeProjects || [],
324
+ scenarioKeywords: config.scenarioKeywords || {},
325
+ }));
326
+ return;
327
+ }
328
+
329
+ if (req.method === 'POST') {
330
+ let body = '';
331
+ let bodySize = 0;
332
+ const MAX_BODY = 1024 * 1024; // 1MB
333
+ req.on('data', chunk => {
334
+ bodySize += chunk.length;
335
+ if (bodySize > MAX_BODY) { req.destroy(); return; }
336
+ body += chunk;
337
+ });
338
+ req.on('end', () => {
339
+ if (bodySize > MAX_BODY) {
340
+ res.writeHead(413, { 'Content-Type': 'application/json' });
341
+ res.end(JSON.stringify({ error: '请求体过大' }));
342
+ return;
343
+ }
344
+ try {
345
+ const newConfig = JSON.parse(body);
346
+ // 路径字段验证:必须是字符串且路径存在或为空
347
+ const validatePath = (v) => typeof v === 'string';
348
+ if (newConfig.claudeDir !== undefined) { if (!validatePath(newConfig.claudeDir)) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'claudeDir 格式无效' })); return; } config.claudeDir = newConfig.claudeDir; }
349
+ if (newConfig.codexDir !== undefined) { if (!validatePath(newConfig.codexDir)) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'codexDir 格式无效' })); return; } config.codexDir = newConfig.codexDir; }
350
+ if (newConfig.opencodeDir !== undefined) { if (!validatePath(newConfig.opencodeDir)) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'opencodeDir 格式无效' })); return; } config.opencodeDir = newConfig.opencodeDir; }
351
+ if (newConfig.enabledTools !== undefined) config.enabledTools = newConfig.enabledTools;
352
+ if (newConfig.repos !== undefined) { if (!Array.isArray(newConfig.repos)) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'repos 格式无效' })); return; } config.repos = newConfig.repos; }
353
+ if (newConfig.excludeProjects !== undefined) config.excludeProjects = newConfig.excludeProjects;
354
+ if (newConfig.scenarioKeywords !== undefined) config.scenarioKeywords = newConfig.scenarioKeywords;
355
+ invalidateFileCache();
356
+ invalidateGitCache();
357
+ saveConfig(config, configPath);
358
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://localhost:' + PORT });
359
+ res.end(JSON.stringify({ success: true }));
360
+ } catch {
361
+ res.writeHead(400, { 'Content-Type': 'application/json' });
362
+ res.end(JSON.stringify({ error: 'JSON 解析失败' }));
363
+ }
364
+ });
365
+ return;
366
+ }
367
+
368
+ res.writeHead(405, { 'Content-Type': 'application/json' });
369
+ res.end(JSON.stringify({ error: 'Method not allowed' }));
370
+ return;
371
+ }
372
+
373
+ // Favicon - 返回空响应避免 404 控制台报错
374
+ if (url.pathname === '/favicon.ico') {
375
+ res.writeHead(204);
376
+ res.end();
377
+ return;
378
+ }
379
+
380
+ // Static files
381
+ let filePath = url.pathname === '/' ? '/index.html' : decodeURIComponent(url.pathname);
382
+ const resolved = resolve(__dirname, 'public', filePath.replace(/^\//, ''));
383
+ const publicDir = resolve(__dirname, 'public');
384
+
385
+ if (!resolved.startsWith(publicDir + sep) && resolved !== publicDir) {
386
+ res.writeHead(403);
387
+ res.end('Forbidden');
388
+ return;
389
+ }
390
+
391
+ if (!existsSync(resolved)) {
392
+ res.writeHead(404);
393
+ res.end('Not Found');
394
+ return;
395
+ }
396
+
397
+ const content = readFileSync(resolved);
398
+ const type = MIME[extname(resolved)] || 'application/octet-stream';
399
+ res.writeHead(200, { 'Content-Type': type });
400
+ res.end(content);
401
+ });
402
+
403
+ server.listen(PORT, '127.0.0.1', () => {
404
+ console.log(`\n LumenCode server running at http://localhost:${PORT}\n`);
405
+
406
+ // Auto-open browser
407
+ const openCmd = process.platform === 'win32' ? 'start' : process.platform === 'darwin' ? 'open' : 'xdg-open';
408
+ import('child_process').then(({ exec }) => {
409
+ exec(`${openCmd} http://localhost:${PORT}`, () => {});
410
+ });
411
+ });
412
+ }
package/lib/table.js ADDED
@@ -0,0 +1,67 @@
1
+ // 轻量级终端表格渲染器
2
+
3
+ export class Table {
4
+ constructor({ columns }) {
5
+ this.columns = columns;
6
+ this.rows = [];
7
+ }
8
+
9
+ addRow(cells) {
10
+ this.rows.push(cells);
11
+ }
12
+
13
+ render() {
14
+ const colWidths = this.columns.map((col, i) => {
15
+ const maxDataLen = Math.max(...this.rows.map(r => this._visualWidth(String(r[i] || ''))));
16
+ return Math.max(this._visualWidth(col.title), maxDataLen, col.width || 10);
17
+ });
18
+
19
+ const sep = (l, m, r) => l + colWidths.map(w => '─'.repeat(w + 2)).join(m) + r;
20
+
21
+ const lines = [];
22
+ lines.push(sep('┌', '┬', '┐'));
23
+
24
+ // Header
25
+ lines.push('│ ' + this.columns.map((col, i) => this._pad(col.title, colWidths[i])).join(' │ ') + ' │');
26
+ lines.push(sep('├', '┼', '┤'));
27
+
28
+ // Rows
29
+ for (const row of this.rows) {
30
+ lines.push('│ ' + row.map((cell, i) => this._pad(String(cell || ''), colWidths[i])).join(' │ ') + ' │');
31
+ }
32
+
33
+ lines.push(sep('└', '┴', '┘'));
34
+ return lines.join('\n');
35
+ }
36
+
37
+ _visualWidth(str) {
38
+ let width = 0;
39
+ for (const ch of str) {
40
+ const code = ch.codePointAt(0);
41
+ if (code >= 0x1100 && (
42
+ code <= 0x115F ||
43
+ code === 0x2329 || code === 0x232A ||
44
+ (code >= 0x2E80 && code <= 0xA4CF && code !== 0x303F) ||
45
+ (code >= 0xAC00 && code <= 0xD7A3) ||
46
+ (code >= 0xF900 && code <= 0xFAFF) ||
47
+ (code >= 0xFE10 && code <= 0xFE19) ||
48
+ (code >= 0xFE30 && code <= 0xFE6F) ||
49
+ (code >= 0xFF01 && code <= 0xFF60) ||
50
+ (code >= 0xFFE0 && code <= 0xFFE6) ||
51
+ (code >= 0x20000 && code <= 0x2FFFD) ||
52
+ (code >= 0x30000 && code <= 0x3FFFD)
53
+ )) {
54
+ width += 2;
55
+ } else {
56
+ width += 1;
57
+ }
58
+ }
59
+ return width;
60
+ }
61
+
62
+ _pad(str, width) {
63
+ const visual = this._visualWidth(str);
64
+ const pad = Math.max(0, width - visual);
65
+ return str + ' '.repeat(pad);
66
+ }
67
+ }