cc-insight 0.1.0
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 +106 -0
- package/README.zh.md +106 -0
- package/bin/cc-insight.js +51 -0
- package/package.json +51 -0
- package/public/index.html +239 -0
- package/public/js/app.js +129 -0
- package/public/js/charts.js +0 -0
- package/public/js/heatmap.js +0 -0
- package/public/js/insights.js +326 -0
- package/public/js/mcp.js +143 -0
- package/public/js/overview.js +421 -0
- package/public/js/poster.js +1025 -0
- package/public/js/skills.js +637 -0
- package/public/js/theme.js +4 -0
- package/src/api.js +501 -0
- package/src/classifiers/topic-rules.js +100 -0
- package/src/config.js +28 -0
- package/src/db/db.js +39 -0
- package/src/db/queries.js +314 -0
- package/src/db/schema.js +46 -0
- package/src/indexer.js +158 -0
- package/src/parsers/jsonl.js +70 -0
- package/src/parsers/security.js +23 -0
- package/src/parsers/skill-md.js +38 -0
- package/src/poster.js +265 -0
- package/src/server.js +67 -0
- package/src/watcher.js +28 -0
package/src/api.js
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
// src/api.js
|
|
2
|
+
import fs from 'fs'
|
|
3
|
+
import os from 'os'
|
|
4
|
+
import path from 'path'
|
|
5
|
+
import { Router } from 'express'
|
|
6
|
+
import {
|
|
7
|
+
getSessionCount, getTotalDurationSec, getAvgDailyDurationSec,
|
|
8
|
+
getPeakPeriod, getSilentDays, getHeatmapData, get24hDistribution,
|
|
9
|
+
getInvocationsByTool, getAllTools, getToolUsageStats, deleteTool, getDustToolNames,
|
|
10
|
+
getToolDistribution, getPluginSubskillStats,
|
|
11
|
+
getTopicsOverview, getTopicKeywords,
|
|
12
|
+
getAvgRoundsByTopic, getDurationByTopic, getToolDensityByTopic,
|
|
13
|
+
getTimeTopicHeatmap, getOutlierSessions, getProjectDist,
|
|
14
|
+
} from './db/queries.js'
|
|
15
|
+
import { getDb } from './db/db.js'
|
|
16
|
+
import { getConfigPath, getAppDir, getClaudeDir } from './config.js'
|
|
17
|
+
import { runFullIndex, getScanPaths } from './indexer.js'
|
|
18
|
+
import { extractNickname, generatePosterText } from './poster.js'
|
|
19
|
+
|
|
20
|
+
// ── 工具路径校验(导出供测试) ──
|
|
21
|
+
const TYPE_DIR = {
|
|
22
|
+
skill: 'skills',
|
|
23
|
+
agent: 'skills',
|
|
24
|
+
plugin: 'plugins',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function validateToolPath(name, type, claudeDir = getClaudeDir()) {
|
|
28
|
+
if (!TYPE_DIR[type]) throw new Error(`未知工具类型: ${type}`)
|
|
29
|
+
if (!name || name.includes('..') || name.includes('/') || name.includes('\\')) {
|
|
30
|
+
throw new Error(`非法工具名称: ${name}`)
|
|
31
|
+
}
|
|
32
|
+
const base = path.join(claudeDir, TYPE_DIR[type])
|
|
33
|
+
const target = path.resolve(base, name)
|
|
34
|
+
if (!target.startsWith(base + path.sep) && target !== base) {
|
|
35
|
+
throw new Error(`路径越界: ${target}`)
|
|
36
|
+
}
|
|
37
|
+
return target
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createRouter({ sendProgress = () => {}, sendRefresh = () => {} } = {}) {
|
|
41
|
+
const router = Router()
|
|
42
|
+
|
|
43
|
+
// 将时间范围字符串转换为 timestamp(毫秒)
|
|
44
|
+
function rangeToAfter(range) {
|
|
45
|
+
const now = Date.now()
|
|
46
|
+
if (range === '7d') return now - 7 * 86400 * 1000
|
|
47
|
+
if (range === '30d') return now - 30 * 86400 * 1000
|
|
48
|
+
if (range === '90d') return now - 90 * 86400 * 1000
|
|
49
|
+
return 0 // 'all'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// --- 状态检测 & 重新索引 ---
|
|
53
|
+
router.get('/api/status', (req, res) => {
|
|
54
|
+
const scanPaths = getScanPaths()
|
|
55
|
+
const claudeDir = getClaudeDir()
|
|
56
|
+
const hasClaude = fs.existsSync(claudeDir)
|
|
57
|
+
const hasData = getSessionCount({ after: 0 }) > 0
|
|
58
|
+
res.json({ hasClaude, hasData, scanPaths })
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
let _reindexing = false
|
|
62
|
+
router.post('/api/reindex', async (req, res) => {
|
|
63
|
+
if (_reindexing) return res.json({ ok: false, reason: 'already running' })
|
|
64
|
+
_reindexing = true
|
|
65
|
+
res.json({ ok: true })
|
|
66
|
+
try {
|
|
67
|
+
await runFullIndex(pct => sendProgress(pct))
|
|
68
|
+
} finally {
|
|
69
|
+
_reindexing = false
|
|
70
|
+
sendRefresh()
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// --- 主题 1:使用概览 ---
|
|
75
|
+
router.get('/api/overview', (req, res) => {
|
|
76
|
+
const after = rangeToAfter(req.query.range ?? '7d')
|
|
77
|
+
res.json({
|
|
78
|
+
sessions: getSessionCount({ after }),
|
|
79
|
+
totalDurationSec: getTotalDurationSec({ after }),
|
|
80
|
+
avgDailyDurationSec: getAvgDailyDurationSec({ after }),
|
|
81
|
+
peakPeriod: getPeakPeriod({ after }),
|
|
82
|
+
silentDays: getSilentDays(),
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
router.get('/api/heatmap', (req, res) => {
|
|
87
|
+
const after = rangeToAfter(req.query.range ?? '7d')
|
|
88
|
+
res.json(getHeatmapData({ after }))
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
router.get('/api/distribution', (req, res) => {
|
|
92
|
+
const after = rangeToAfter(req.query.range ?? '7d')
|
|
93
|
+
res.json(get24hDistribution({ after }))
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
router.get('/api/tool-distribution', (req, res) => {
|
|
97
|
+
const after = rangeToAfter(req.query.range ?? '7d')
|
|
98
|
+
res.json(getToolDistribution({ after }))
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
router.get('/api/insights', (req, res) => {
|
|
102
|
+
const after = rangeToAfter(req.query.range ?? '7d')
|
|
103
|
+
res.json(buildInsights({ after }))
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
router.get('/api/topics', (req, res) => {
|
|
107
|
+
const after = rangeToAfter(req.query.range ?? '7d')
|
|
108
|
+
res.json({
|
|
109
|
+
categories: getTopicsOverview({ after }),
|
|
110
|
+
keywords: getTopicKeywords({ after }),
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
router.get('/api/efficiency', (req, res) => {
|
|
115
|
+
const after = rangeToAfter(req.query.range ?? '7d')
|
|
116
|
+
res.json({
|
|
117
|
+
roundsByTopic: getAvgRoundsByTopic({ after }),
|
|
118
|
+
durationByTopic: getDurationByTopic({ after }),
|
|
119
|
+
densityByTopic: getToolDensityByTopic({ after }),
|
|
120
|
+
heatmap: getTimeTopicHeatmap({ after }),
|
|
121
|
+
outlierSessions: getOutlierSessions({ after }),
|
|
122
|
+
projectDist: getProjectDist({ after }),
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// --- 主题 2:Skill & Agent & Plugin ---
|
|
127
|
+
router.get('/api/tools', (req, res) => {
|
|
128
|
+
const after = rangeToAfter(req.query.range ?? '7d')
|
|
129
|
+
const tools = getAllTools()
|
|
130
|
+
const usageStats = getToolUsageStats({ after })
|
|
131
|
+
const allTimeStats = getToolUsageStats({ after: 0 })
|
|
132
|
+
const statsMap = Object.fromEntries(usageStats.map(s => [s.toolName, s]))
|
|
133
|
+
const allTimeMap = Object.fromEntries(allTimeStats.map(s => [s.toolName, s]))
|
|
134
|
+
const claudeDir = getClaudeDir()
|
|
135
|
+
const result = tools
|
|
136
|
+
.filter(t => after === 0 || (t.installed_at ?? 0) >= after)
|
|
137
|
+
.map(t => {
|
|
138
|
+
// 推算本地路径
|
|
139
|
+
let localPath = null
|
|
140
|
+
if (t.type === 'skill' || t.type === 'agent') {
|
|
141
|
+
localPath = path.join(claudeDir, 'skills', t.name)
|
|
142
|
+
} else if (t.type === 'plugin') {
|
|
143
|
+
// id 格式: plugin:{marketplace}:{pluginName}
|
|
144
|
+
const parts = t.id.split(':')
|
|
145
|
+
if (parts.length >= 3) {
|
|
146
|
+
localPath = path.join(claudeDir, 'plugins', 'cache', parts[1], parts[2])
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
...t,
|
|
151
|
+
// camelCase 别名(DB 返回 snake_case,前端统一用 camelCase)
|
|
152
|
+
sourceType: t.source_type,
|
|
153
|
+
sourceUrl: t.source_url,
|
|
154
|
+
installedAt: t.installed_at,
|
|
155
|
+
updatedAt: t.updated_at,
|
|
156
|
+
securityScanResult: t.security_scan_result,
|
|
157
|
+
localPath,
|
|
158
|
+
// 使用统计(来自 tool_invocations 聚合)
|
|
159
|
+
useCount: statsMap[t.name]?.useCount ?? 0,
|
|
160
|
+
lastUsedAt: statsMap[t.name]?.lastUsedAt ?? null,
|
|
161
|
+
allTimeUseCount: allTimeMap[t.name]?.useCount ?? 0,
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
res.json(result)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
// --- MCP Server 列表 ---
|
|
168
|
+
router.get('/api/mcp-servers', (req, res) => {
|
|
169
|
+
const claudeDir = getClaudeDir()
|
|
170
|
+
const configPaths = [
|
|
171
|
+
path.join(claudeDir, 'settings.json'),
|
|
172
|
+
path.join(claudeDir, 'settings.local.json'),
|
|
173
|
+
path.join(os.homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
// 1. 从配置文件读已声明的 MCP
|
|
177
|
+
const declared = {}
|
|
178
|
+
for (const p of configPaths) {
|
|
179
|
+
try {
|
|
180
|
+
const cfg = JSON.parse(fs.readFileSync(p, 'utf8'))
|
|
181
|
+
for (const [name, info] of Object.entries(cfg.mcpServers ?? {})) {
|
|
182
|
+
declared[name] = { name, source: 'config', command: info.command ?? null,
|
|
183
|
+
url: info.url ?? null, status: 'configured' }
|
|
184
|
+
}
|
|
185
|
+
} catch {}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 2. 从 tool_invocations 推断历史使用过的 MCP(mcp__{server}__{tool} 格式)
|
|
189
|
+
const db = getDb()
|
|
190
|
+
const mcpRows = db.prepare(`
|
|
191
|
+
SELECT DISTINCT tool_name FROM tool_invocations WHERE tool_name GLOB 'mcp__*'
|
|
192
|
+
`).all()
|
|
193
|
+
const usedServers = {}
|
|
194
|
+
for (const { tool_name } of mcpRows) {
|
|
195
|
+
const parts = tool_name.split('__')
|
|
196
|
+
if (parts.length >= 3) {
|
|
197
|
+
const serverName = parts[1]
|
|
198
|
+
if (!usedServers[serverName]) usedServers[serverName] = []
|
|
199
|
+
usedServers[serverName].push(parts.slice(2).join('__'))
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 3. 从 auth cache 读 claude.ai 托管 MCP
|
|
204
|
+
const authCachePath = path.join(claudeDir, 'mcp-needs-auth-cache.json')
|
|
205
|
+
const authMcp = {}
|
|
206
|
+
try {
|
|
207
|
+
const cache = JSON.parse(fs.readFileSync(authCachePath, 'utf8'))
|
|
208
|
+
for (const name of Object.keys(cache)) {
|
|
209
|
+
authMcp[name] = { name, source: 'claude.ai', status: 'hosted' }
|
|
210
|
+
}
|
|
211
|
+
} catch {}
|
|
212
|
+
|
|
213
|
+
// 合并:配置 > 使用记录 > auth cache
|
|
214
|
+
const result = []
|
|
215
|
+
const seen = new Set()
|
|
216
|
+
|
|
217
|
+
for (const [name, info] of Object.entries(declared)) {
|
|
218
|
+
seen.add(name)
|
|
219
|
+
result.push({ ...info, tools: usedServers[name] ?? [], invocations: (usedServers[name] ?? []).length })
|
|
220
|
+
}
|
|
221
|
+
for (const [serverName, tools] of Object.entries(usedServers)) {
|
|
222
|
+
if (seen.has(serverName)) continue
|
|
223
|
+
seen.add(serverName)
|
|
224
|
+
result.push({ name: serverName, source: 'history', command: null, url: null,
|
|
225
|
+
status: 'used', tools, invocations: tools.length })
|
|
226
|
+
}
|
|
227
|
+
for (const [name, info] of Object.entries(authMcp)) {
|
|
228
|
+
if (seen.has(name)) continue
|
|
229
|
+
seen.add(name)
|
|
230
|
+
result.push({ ...info, tools: [], invocations: 0 })
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
res.json(result)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
// --- 配置(主题持久化)---
|
|
237
|
+
// GET /api/config — 注意:必须在 DELETE /api/tools/:name 之前注册,避免路由冲突
|
|
238
|
+
router.get('/api/config', (req, res) => {
|
|
239
|
+
const configPath = getConfigPath()
|
|
240
|
+
if (!fs.existsSync(configPath)) return res.json({})
|
|
241
|
+
try {
|
|
242
|
+
res.json(JSON.parse(fs.readFileSync(configPath, 'utf8')))
|
|
243
|
+
} catch {
|
|
244
|
+
res.json({})
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
router.post('/api/config', (req, res) => {
|
|
249
|
+
const configPath = getConfigPath()
|
|
250
|
+
const appDir = getAppDir()
|
|
251
|
+
if (!fs.existsSync(appDir)) fs.mkdirSync(appDir, { recursive: true })
|
|
252
|
+
let current = {}
|
|
253
|
+
if (fs.existsSync(configPath)) {
|
|
254
|
+
try { current = JSON.parse(fs.readFileSync(configPath, 'utf8')) } catch {}
|
|
255
|
+
}
|
|
256
|
+
const updated = { ...current, ...req.body }
|
|
257
|
+
fs.writeFileSync(configPath, JSON.stringify(updated, null, 2))
|
|
258
|
+
res.json({ ok: true })
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
// --- P1:海报称呼提取 ---
|
|
262
|
+
router.get('/api/poster/nickname', (req, res) => {
|
|
263
|
+
res.json({ nickname: extractNickname() })
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
// --- P2:海报文案规则生成 ---
|
|
267
|
+
router.get('/api/poster/generate-text', (req, res) => {
|
|
268
|
+
const after = rangeToAfter(req.query.range ?? '7d')
|
|
269
|
+
const nickname = extractNickname()
|
|
270
|
+
|
|
271
|
+
// 收集文案生成所需的基础数据
|
|
272
|
+
const overview = {
|
|
273
|
+
sessions: getSessionCount({ after }),
|
|
274
|
+
totalDurationSec: getTotalDurationSec({ after }),
|
|
275
|
+
avgDailyDurationSec: getAvgDailyDurationSec({ after }),
|
|
276
|
+
peakPeriod: getPeakPeriod({ after }),
|
|
277
|
+
silentDays: getSilentDays(),
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// 提取 habit 类型(复用 buildInsights 同款逻辑)
|
|
281
|
+
const dist = get24hDistribution({ after })
|
|
282
|
+
let habit = null
|
|
283
|
+
if (dist.length > 0) {
|
|
284
|
+
const total = dist.reduce((s, r) => s + r.count, 0)
|
|
285
|
+
const nightCount = dist.filter(r => parseInt(r.hour) >= 20).reduce((s, r) => s + r.count, 0)
|
|
286
|
+
const morningCount= dist.filter(r => parseInt(r.hour) >= 6 && parseInt(r.hour) < 10).reduce((s, r) => s + r.count, 0)
|
|
287
|
+
const workCount = dist.filter(r => parseInt(r.hour) >= 9 && parseInt(r.hour) < 18).reduce((s, r) => s + r.count, 0)
|
|
288
|
+
if (total > 0) {
|
|
289
|
+
if (nightCount / total > 0.5) habit = 'night'
|
|
290
|
+
else if (morningCount / total > 0.3) habit = 'morning'
|
|
291
|
+
else if (workCount / total > 0.5) habit = 'work'
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// 最常用 skill 名称
|
|
296
|
+
const usageStats = getToolUsageStats({ after })
|
|
297
|
+
const topSkill = usageStats[0]?.toolName ?? null
|
|
298
|
+
|
|
299
|
+
// 与上期对比趋势
|
|
300
|
+
const periodMs = Date.now() - after
|
|
301
|
+
const prevAfter = after > 0 ? after - periodMs : null
|
|
302
|
+
let trendChange = null
|
|
303
|
+
if (prevAfter !== null) {
|
|
304
|
+
const curr = getSessionCount({ after })
|
|
305
|
+
const prev = getSessionCount({ after: prevAfter })
|
|
306
|
+
if (prev > 0) trendChange = Math.round((curr - prev) / prev * 100)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const seed = parseInt(req.query.seed ?? '0', 10) || 0
|
|
310
|
+
res.json(generatePosterText({
|
|
311
|
+
...overview,
|
|
312
|
+
habit,
|
|
313
|
+
topSkill,
|
|
314
|
+
trendChange,
|
|
315
|
+
nickname,
|
|
316
|
+
seed,
|
|
317
|
+
}))
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
// --- P3:海报数据聚合(一次请求返回海报所需全部数据)---
|
|
321
|
+
router.get('/api/poster/data', (req, res) => {
|
|
322
|
+
const range = req.query.range ?? '7d'
|
|
323
|
+
const after = rangeToAfter(range)
|
|
324
|
+
|
|
325
|
+
// 基础指标
|
|
326
|
+
const sessions = getSessionCount({ after })
|
|
327
|
+
const totalDurationSec = getTotalDurationSec({ after })
|
|
328
|
+
const avgDailyDurationSec = getAvgDailyDurationSec({ after })
|
|
329
|
+
const peakPeriod = getPeakPeriod({ after })
|
|
330
|
+
const silentDays = getSilentDays()
|
|
331
|
+
|
|
332
|
+
// 图表数据
|
|
333
|
+
const heatmap = getHeatmapData({ after })
|
|
334
|
+
const distribution = get24hDistribution({ after })
|
|
335
|
+
|
|
336
|
+
// habit 类型
|
|
337
|
+
let habit = null
|
|
338
|
+
const total = distribution.reduce((s, r) => s + r.count, 0)
|
|
339
|
+
const nightCount = distribution.filter(r => parseInt(r.hour) >= 20).reduce((s, r) => s + r.count, 0)
|
|
340
|
+
const morningCount = distribution.filter(r => parseInt(r.hour) >= 6 && parseInt(r.hour) < 10).reduce((s, r) => s + r.count, 0)
|
|
341
|
+
const workCount = distribution.filter(r => parseInt(r.hour) >= 9 && parseInt(r.hour) < 18).reduce((s, r) => s + r.count, 0)
|
|
342
|
+
if (total > 0) {
|
|
343
|
+
if (nightCount / total > 0.5) habit = 'night'
|
|
344
|
+
else if (morningCount / total > 0.3) habit = 'morning'
|
|
345
|
+
else if (workCount / total > 0.5) habit = 'work'
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// top skill 名称
|
|
349
|
+
const usageStats = getToolUsageStats({ after })
|
|
350
|
+
const topSkillName = usageStats[0]?.toolName ?? null
|
|
351
|
+
|
|
352
|
+
// 使用趋势
|
|
353
|
+
const periodMs = Date.now() - after
|
|
354
|
+
const prevAfter = after > 0 ? after - periodMs : null
|
|
355
|
+
let trendChange = null
|
|
356
|
+
if (prevAfter !== null) {
|
|
357
|
+
const prev = getSessionCount({ after: prevAfter })
|
|
358
|
+
if (prev > 0) trendChange = Math.round((sessions - prev) / prev * 100)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// 称呼 & 生成文案
|
|
362
|
+
const nickname = extractNickname()
|
|
363
|
+
const seed = parseInt(req.query.seed ?? '0', 10) || 0
|
|
364
|
+
const { summary, tags, summaryCount } = generatePosterText({
|
|
365
|
+
sessions, totalDurationSec, avgDailyDurationSec,
|
|
366
|
+
peakPeriod, silentDays, habit,
|
|
367
|
+
topSkill: topSkillName, trendChange, nickname, seed,
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
res.json({
|
|
371
|
+
range,
|
|
372
|
+
nickname,
|
|
373
|
+
summary,
|
|
374
|
+
summaryCount,
|
|
375
|
+
tags,
|
|
376
|
+
metrics: {
|
|
377
|
+
sessions,
|
|
378
|
+
totalDurationSec,
|
|
379
|
+
avgDailyDurationSec,
|
|
380
|
+
peakPeriod,
|
|
381
|
+
silentDays,
|
|
382
|
+
topSkillName,
|
|
383
|
+
},
|
|
384
|
+
heatmap,
|
|
385
|
+
distribution,
|
|
386
|
+
})
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
// --- 批量清理吃灰工具(必须在 /:name 之前注册)---
|
|
390
|
+
router.delete('/api/tools/bulk-dust', async (req, res) => {
|
|
391
|
+
const after = rangeToAfter(req.query.range ?? '7d')
|
|
392
|
+
const dustNames = getDustToolNames({ after })
|
|
393
|
+
const deleted = []
|
|
394
|
+
for (const name of dustNames) {
|
|
395
|
+
// 尝试物理删除(skill 和 agent 都在 skills/ 目录)
|
|
396
|
+
for (const subdir of ['skills', 'plugins']) {
|
|
397
|
+
const target = path.join(getClaudeDir(), subdir, name)
|
|
398
|
+
if (fs.existsSync(target)) {
|
|
399
|
+
try { fs.rmSync(target, { recursive: true, force: true }) } catch {}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
deleteTool(name)
|
|
403
|
+
deleted.push(name)
|
|
404
|
+
}
|
|
405
|
+
res.json({ deleted: deleted.length, names: deleted })
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
// --- Plugin 子技能调用明细 ---
|
|
409
|
+
router.get('/api/tools/:name/subskills', (req, res) => {
|
|
410
|
+
const after = rangeToAfter(req.query.range ?? '7d')
|
|
411
|
+
res.json(getPluginSubskillStats({ name: req.params.name, after }))
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
// --- 单条删除(必须在 bulk-dust 之后注册)---
|
|
415
|
+
router.delete('/api/tools/:name', async (req, res) => {
|
|
416
|
+
const { name } = req.params
|
|
417
|
+
const { type } = req.query
|
|
418
|
+
|
|
419
|
+
let targetPath
|
|
420
|
+
try {
|
|
421
|
+
targetPath = validateToolPath(name, type)
|
|
422
|
+
} catch (err) {
|
|
423
|
+
return res.status(400).json({ error: err.message })
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (fs.existsSync(targetPath)) {
|
|
427
|
+
try {
|
|
428
|
+
fs.rmSync(targetPath, { recursive: true, force: true })
|
|
429
|
+
} catch (err) {
|
|
430
|
+
return res.status(500).json({ error: `删除文件失败: ${err.message}` })
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
deleteTool(name)
|
|
435
|
+
res.json({ ok: true, deleted: targetPath })
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
return router
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// 动态生成 Insights,有数据才输出对应条目
|
|
442
|
+
function buildInsights({ after }) {
|
|
443
|
+
const insights = []
|
|
444
|
+
|
|
445
|
+
// 最高产的一天
|
|
446
|
+
const heatmap = getHeatmapData({ after })
|
|
447
|
+
if (heatmap.length > 0) {
|
|
448
|
+
const best = heatmap.reduce((a, b) => b.count > a.count ? b : a)
|
|
449
|
+
if (best.count >= 2) {
|
|
450
|
+
insights.push({ type: 'best_day', day: best.day, count: best.count })
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// 当前静默期
|
|
455
|
+
const silentDays = getSilentDays()
|
|
456
|
+
if (silentDays >= 2) {
|
|
457
|
+
insights.push({ type: 'silent_days', days: silentDays })
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// 时间习惯:总是取占比最高的时段(无门槛保底)
|
|
461
|
+
const dist = get24hDistribution({ after })
|
|
462
|
+
if (dist.length > 0) {
|
|
463
|
+
const total = dist.reduce((s, r) => s + r.count, 0)
|
|
464
|
+
if (total > 0) {
|
|
465
|
+
const nightCount = dist.filter(r => parseInt(r.hour) >= 20).reduce((s, r) => s + r.count, 0)
|
|
466
|
+
const morningCount = dist.filter(r => parseInt(r.hour) >= 6 && parseInt(r.hour) < 10).reduce((s, r) => s + r.count, 0)
|
|
467
|
+
const workCount = dist.filter(r => parseInt(r.hour) >= 9 && parseInt(r.hour) < 18).reduce((s, r) => s + r.count, 0)
|
|
468
|
+
if (nightCount >= morningCount && nightCount >= workCount) {
|
|
469
|
+
insights.push({ type: 'habit', label: '夜猫子', pct: Math.round(nightCount / total * 100) })
|
|
470
|
+
} else if (morningCount >= workCount) {
|
|
471
|
+
insights.push({ type: 'habit', label: '早鸟', pct: Math.round(morningCount / total * 100) })
|
|
472
|
+
} else {
|
|
473
|
+
insights.push({ type: 'habit', label: '上班族', pct: Math.round(workCount / total * 100) })
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// 使用趋势(本期 vs 上期),all 范围无法比较,跳过
|
|
479
|
+
if (after > 0) {
|
|
480
|
+
const periodMs = Date.now() - after
|
|
481
|
+
const prevAfter = after - periodMs
|
|
482
|
+
const currCount = getSessionCount({ after })
|
|
483
|
+
const prevCount = getSessionCount({ after: prevAfter })
|
|
484
|
+
if (prevCount > 0) {
|
|
485
|
+
const change = Math.round((currCount - prevCount) / prevCount * 100)
|
|
486
|
+
if (Math.abs(change) >= 10) {
|
|
487
|
+
insights.push({ type: 'trend', change })
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// 兜底:不足 3 条时补充日均时长
|
|
493
|
+
if (insights.length < 3) {
|
|
494
|
+
const avgSec = getAvgDailyDurationSec({ after })
|
|
495
|
+
if (avgSec > 0) {
|
|
496
|
+
insights.push({ type: 'avg_daily', avgSec })
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return insights
|
|
501
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// src/classifiers/topic-rules.js
|
|
2
|
+
|
|
3
|
+
export const TOPIC_RULES = [
|
|
4
|
+
{
|
|
5
|
+
topic: '调试修复',
|
|
6
|
+
keywords: ['bug', 'error', 'fix', 'fixed', '报错', '修复', 'failed', 'fail', 'crash',
|
|
7
|
+
'undefined', 'cannot', 'wrong', '问题', '不对', '失败', '异常', 'exception',
|
|
8
|
+
'traceback', 'stacktrace', '调试', 'debug', 'broken', 'not working'],
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
topic: '新功能开发',
|
|
12
|
+
keywords: ['新增', 'implement', 'feature', '功能', ' add ', 'build', '做一个', '开发',
|
|
13
|
+
'实现', '支持', '创建', 'create', '添加', '增加', 'develop', 'new feature'],
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
topic: '架构设计',
|
|
17
|
+
keywords: ['设计', 'architecture', '方案', 'schema', '结构', '怎么设计', '如何设计',
|
|
18
|
+
'规划', 'design', '系统', '模块', '接口', 'interface', '数据模型'],
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
topic: '代码重构',
|
|
22
|
+
keywords: ['refactor', '重构', '优化', 'cleanup', '整理', '改造', '简化', 'simplify',
|
|
23
|
+
'reorganize', '清理', 'restructure', '改进'],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
topic: '学习探索',
|
|
27
|
+
keywords: ['学习', '了解', 'how ', 'what is', '原理', '为什么', '怎么', '是什么',
|
|
28
|
+
'解释', 'explain', '介绍', 'introduce', '概念', 'concept', '区别', 'difference'],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
topic: '配置运维',
|
|
32
|
+
keywords: ['安装', '配置', 'setup', 'install', 'deploy', '环境', '启动', '运行',
|
|
33
|
+
'nvm', 'npm', 'node', 'config', '部署', '服务器', 'server', 'docker', '权限'],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
topic: '数据分析',
|
|
37
|
+
keywords: ['数据', 'query', 'sql', '分析', '统计', '报表', 'select', 'database',
|
|
38
|
+
'db', '查询', '聚合', 'aggregate', '图表', 'chart', '指标', 'metric'],
|
|
39
|
+
},
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
/** 停用词:不作为关键词提取 */
|
|
43
|
+
const STOP_WORDS = new Set([
|
|
44
|
+
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
|
|
45
|
+
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
|
|
46
|
+
'should', 'may', 'might', 'can', 'to', 'of', 'in', 'for', 'on', 'with',
|
|
47
|
+
'at', 'by', 'from', 'up', 'about', 'into', 'through', 'during',
|
|
48
|
+
'and', 'or', 'but', 'if', 'then', 'that', 'this', 'it', 'its',
|
|
49
|
+
'i', 'you', 'we', 'he', 'she', 'they', 'my', 'your', 'our',
|
|
50
|
+
'的', '了', '是', '在', '我', '你', '他', '她', '它', '们',
|
|
51
|
+
'这', '那', '有', '和', '与', '或', '不', '也', '都', '就',
|
|
52
|
+
'一', '个', '来', '去', '说', '要', '会', '能', '把', '给',
|
|
53
|
+
'帮', '我', '请', '看', '下', '吗', '呢', '啊', '吧',
|
|
54
|
+
])
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 根据第一条用户消息分类话题
|
|
58
|
+
* @param {string} text
|
|
59
|
+
* @returns {string} 话题大类
|
|
60
|
+
*/
|
|
61
|
+
export function classifyTopic(text) {
|
|
62
|
+
if (!text || typeof text !== 'string') return '其他'
|
|
63
|
+
// 剥离 XML 标签(如 <local-command-caveat>...</local-command-caveat>)
|
|
64
|
+
const cleaned = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
|
65
|
+
const lower = cleaned.toLowerCase()
|
|
66
|
+
for (const { topic, keywords } of TOPIC_RULES) {
|
|
67
|
+
if (keywords.some(kw => lower.includes(kw.toLowerCase()))) {
|
|
68
|
+
return topic
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return '其他'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 从全量用户文本中提取高频关键词
|
|
76
|
+
* @param {string} text 所有用户消息拼接文本
|
|
77
|
+
* @param {number} limit 最多返回词数,默认 20
|
|
78
|
+
* @returns {string[]} 按频率降序的词列表
|
|
79
|
+
*/
|
|
80
|
+
export function extractKeywords(text, limit = 20) {
|
|
81
|
+
if (!text || typeof text !== 'string') return []
|
|
82
|
+
|
|
83
|
+
// 分词:取长度 ≥ 2 的英文单词(含 kebab-case)和中文词(连续汉字)
|
|
84
|
+
const tokens = [
|
|
85
|
+
...text.matchAll(/[a-zA-Z][a-zA-Z0-9_\-\.]{1,}/g),
|
|
86
|
+
...text.matchAll(/[\u4e00-\u9fa5]{2,}/g),
|
|
87
|
+
].map(m => m[0].toLowerCase())
|
|
88
|
+
|
|
89
|
+
// 过滤停用词,统计频率
|
|
90
|
+
const freq = {}
|
|
91
|
+
for (const token of tokens) {
|
|
92
|
+
if (STOP_WORDS.has(token)) continue
|
|
93
|
+
freq[token] = (freq[token] ?? 0) + 1
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return Object.entries(freq)
|
|
97
|
+
.sort((a, b) => b[1] - a[1])
|
|
98
|
+
.slice(0, limit)
|
|
99
|
+
.map(([word]) => word)
|
|
100
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// src/config.js
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import os from 'os'
|
|
4
|
+
import fs from 'fs'
|
|
5
|
+
|
|
6
|
+
export function getClaudeDir() {
|
|
7
|
+
return process.env.CLAUDE_DIR ?? path.join(os.homedir(), '.claude')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function getAppDir() {
|
|
11
|
+
return process.env.CC_INSIGHT_APP_DIR ?? path.join(os.homedir(), '.cc-insight')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getDbPath() {
|
|
15
|
+
return path.join(getAppDir(), 'data.db')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getConfigPath() {
|
|
19
|
+
return path.join(getAppDir(), 'config.json')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// 额外的 session 目录(如 macOS 桌面 App),自动检测,不存在则返回空数组
|
|
23
|
+
export function getExtraSessionDirs() {
|
|
24
|
+
const candidates = [
|
|
25
|
+
path.join(os.homedir(), 'Library', 'Application Support', 'Claude', 'claude-code-sessions'),
|
|
26
|
+
]
|
|
27
|
+
return candidates.filter(p => fs.existsSync(p))
|
|
28
|
+
}
|
package/src/db/db.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import Database from 'better-sqlite3'
|
|
2
|
+
import fs from 'fs'
|
|
3
|
+
import { getDbPath, getAppDir } from '../config.js'
|
|
4
|
+
import { CREATE_TABLES } from './schema.js'
|
|
5
|
+
|
|
6
|
+
let _db = null
|
|
7
|
+
|
|
8
|
+
export function getDb() {
|
|
9
|
+
if (_db) return _db
|
|
10
|
+
const appDir = getAppDir()
|
|
11
|
+
if (!fs.existsSync(appDir)) fs.mkdirSync(appDir, { recursive: true })
|
|
12
|
+
_db = new Database(getDbPath())
|
|
13
|
+
_db.pragma('journal_mode = WAL')
|
|
14
|
+
_db.exec(CREATE_TABLES)
|
|
15
|
+
// 迁移:兼容旧 DB,按需新增列
|
|
16
|
+
const existingCols = _db.prepare('PRAGMA table_info(sessions)').all().map(c => c.name)
|
|
17
|
+
if (!existingCols.includes('topic')) {
|
|
18
|
+
_db.exec('ALTER TABLE sessions ADD COLUMN topic TEXT')
|
|
19
|
+
}
|
|
20
|
+
if (!existingCols.includes('topic_keywords')) {
|
|
21
|
+
_db.exec('ALTER TABLE sessions ADD COLUMN topic_keywords TEXT')
|
|
22
|
+
}
|
|
23
|
+
if (!existingCols.includes('first_user_msg')) {
|
|
24
|
+
_db.exec('ALTER TABLE sessions ADD COLUMN first_user_msg TEXT')
|
|
25
|
+
}
|
|
26
|
+
return _db
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function closeDb() {
|
|
30
|
+
if (_db) { _db.close(); _db = null }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getMeta(key) {
|
|
34
|
+
return getDb().prepare('SELECT value FROM meta WHERE key = ?').get(key)?.value
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function setMeta(key, value) {
|
|
38
|
+
getDb().prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run(key, value)
|
|
39
|
+
}
|