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
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// src/db/queries.js
|
|
2
|
+
import { getDb } from './db.js'
|
|
3
|
+
|
|
4
|
+
export function upsertSession({ id, source, startTime, endTime, durationSec,
|
|
5
|
+
projectPath, messageCount, toolUseCount, jsonlFile,
|
|
6
|
+
topic = null, topicKeywords = null, firstUserMsg = null }) {
|
|
7
|
+
getDb().prepare(`
|
|
8
|
+
INSERT OR REPLACE INTO sessions
|
|
9
|
+
(id, source, start_time, end_time, duration_sec, project_path, message_count, tool_use_count, jsonl_file,
|
|
10
|
+
topic, topic_keywords, first_user_msg)
|
|
11
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
12
|
+
`).run(id, source, startTime, endTime, durationSec, projectPath, messageCount, toolUseCount, jsonlFile,
|
|
13
|
+
topic, topicKeywords ? JSON.stringify(topicKeywords) : null, firstUserMsg ?? null)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function upsertTool({ id, name, type, subtype, description, sourceType, sourceUrl,
|
|
17
|
+
installedAt, updatedAt, securityScanResult }) {
|
|
18
|
+
getDb().prepare(`
|
|
19
|
+
INSERT OR REPLACE INTO tools
|
|
20
|
+
(id, name, type, subtype, description, source_type, source_url, installed_at, updated_at, security_scan_result)
|
|
21
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
22
|
+
`).run(id, name, type, subtype ?? null, description, sourceType, sourceUrl, installedAt, updatedAt, securityScanResult)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function insertInvocations(invocations) {
|
|
26
|
+
const stmt = getDb().prepare(
|
|
27
|
+
'INSERT OR IGNORE INTO tool_invocations (session_id, tool_name, invoked_at) VALUES (?, ?, ?)'
|
|
28
|
+
)
|
|
29
|
+
const insert = getDb().transaction((rows) => {
|
|
30
|
+
for (const r of rows) stmt.run(r.sessionId, r.toolName, r.invokedAt)
|
|
31
|
+
})
|
|
32
|
+
insert(invocations)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getSessionCount({ after }) {
|
|
36
|
+
return getDb().prepare(
|
|
37
|
+
'SELECT COUNT(*) as n FROM sessions WHERE start_time >= ?'
|
|
38
|
+
).get(after).n
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 每个 session 最多计 4 小时(14400s),避免长时间挂起的对话文件虚增时长
|
|
42
|
+
const SESSION_DUR_CAP = 14400
|
|
43
|
+
|
|
44
|
+
export function getTotalDurationSec({ after }) {
|
|
45
|
+
return getDb().prepare(
|
|
46
|
+
'SELECT COALESCE(SUM(MIN(duration_sec, ?)), 0) as total FROM sessions WHERE start_time >= ?'
|
|
47
|
+
).get(SESSION_DUR_CAP, after).total
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getAvgDailyDurationSec({ after }) {
|
|
51
|
+
const db = getDb()
|
|
52
|
+
const total = getTotalDurationSec({ after })
|
|
53
|
+
const days = db.prepare(
|
|
54
|
+
"SELECT COUNT(DISTINCT date(start_time / 1000, 'unixepoch', '+8 hours')) as n FROM sessions WHERE start_time >= ?"
|
|
55
|
+
).get(after).n
|
|
56
|
+
return days > 0 ? Math.round(total / days) : 0
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getPeakPeriod({ after }) {
|
|
60
|
+
const rows = getDb().prepare(`
|
|
61
|
+
SELECT strftime('%H', start_time / 1000, 'unixepoch', '+8 hours') as hour, COUNT(*) as n
|
|
62
|
+
FROM sessions WHERE start_time >= ?
|
|
63
|
+
GROUP BY hour ORDER BY n DESC LIMIT 1
|
|
64
|
+
`).get(after)
|
|
65
|
+
if (!rows) return null
|
|
66
|
+
const h = parseInt(rows.hour)
|
|
67
|
+
return `${String(h).padStart(2,'0')}:00–${String(h+2 > 23 ? 23 : h+2).padStart(2,'0')}:59`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 2020-01-01 毫秒时间戳,用于过滤明显异常的历史时间戳
|
|
71
|
+
const MIN_VALID_TS = 1577836800000
|
|
72
|
+
|
|
73
|
+
export function getSilentDays() {
|
|
74
|
+
const db = getDb()
|
|
75
|
+
// 连续静默期 = 距今最近一次使用间隔多少天,与时间范围无关,始终查全量
|
|
76
|
+
const activeDays = new Set(
|
|
77
|
+
db.prepare("SELECT DISTINCT date(start_time / 1000, 'unixepoch', '+8 hours') as d FROM sessions WHERE start_time >= ?")
|
|
78
|
+
.all(MIN_VALID_TS).map(r => r.d)
|
|
79
|
+
)
|
|
80
|
+
const today = new Date(Date.now() + 8 * 3600_000)
|
|
81
|
+
today.setUTCHours(0, 0, 0, 0)
|
|
82
|
+
let silent = 0
|
|
83
|
+
for (let d = new Date(today); silent < 365; d.setUTCDate(d.getUTCDate() - 1)) {
|
|
84
|
+
if (activeDays.has(d.toISOString().slice(0, 10))) break
|
|
85
|
+
silent++
|
|
86
|
+
}
|
|
87
|
+
return silent
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function getHeatmapData({ after }) {
|
|
91
|
+
return getDb().prepare(`
|
|
92
|
+
SELECT date(start_time / 1000, 'unixepoch', '+8 hours') as day, COUNT(*) as count
|
|
93
|
+
FROM sessions WHERE start_time >= ?
|
|
94
|
+
GROUP BY day ORDER BY day
|
|
95
|
+
`).all(after)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function get24hDistribution({ after }) {
|
|
99
|
+
return getDb().prepare(`
|
|
100
|
+
SELECT strftime('%H', start_time / 1000, 'unixepoch', '+8 hours') as hour, COUNT(*) as count
|
|
101
|
+
FROM sessions WHERE start_time >= ?
|
|
102
|
+
GROUP BY hour ORDER BY hour
|
|
103
|
+
`).all(after)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function getInvocationsByTool({ after }) {
|
|
107
|
+
return getDb().prepare(`
|
|
108
|
+
SELECT tool_name as toolName, COUNT(*) as count
|
|
109
|
+
FROM tool_invocations WHERE invoked_at >= ?
|
|
110
|
+
GROUP BY tool_name ORDER BY count DESC
|
|
111
|
+
`).all(after)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function getAllTools() {
|
|
115
|
+
return getDb().prepare('SELECT * FROM tools ORDER BY installed_at DESC').all()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function getToolUsageStats({ after }) {
|
|
119
|
+
// plugin 的子 skill 调用格式为 "pluginName:subSkill",按冒号前缀归组
|
|
120
|
+
// 这样 superpowers:brainstorming 等会归到 "superpowers"
|
|
121
|
+
return getDb().prepare(`
|
|
122
|
+
SELECT
|
|
123
|
+
CASE
|
|
124
|
+
WHEN tool_name GLOB '*:*'
|
|
125
|
+
THEN substr(tool_name, 1, instr(tool_name, ':') - 1)
|
|
126
|
+
ELSE tool_name
|
|
127
|
+
END as toolName,
|
|
128
|
+
COUNT(*) as useCount,
|
|
129
|
+
MAX(invoked_at) as lastUsedAt
|
|
130
|
+
FROM tool_invocations WHERE invoked_at >= ?
|
|
131
|
+
GROUP BY toolName
|
|
132
|
+
`).all(after)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function getDustToolNames({ after }) {
|
|
136
|
+
const used = new Set(
|
|
137
|
+
getDb().prepare(`
|
|
138
|
+
SELECT DISTINCT tool_name FROM tool_invocations WHERE invoked_at >= ?
|
|
139
|
+
`).all(after).map(r => r.tool_name)
|
|
140
|
+
)
|
|
141
|
+
return getDb().prepare('SELECT name FROM tools').all()
|
|
142
|
+
.map(r => r.name)
|
|
143
|
+
.filter(name => !used.has(name))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function deleteTool(name) {
|
|
147
|
+
getDb().prepare('DELETE FROM tools WHERE name = ?').run(name)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 全量同步:删除不在 foundIds 集合中的工具(用于重建索引时清理旧记录)
|
|
151
|
+
export function syncToolIds(foundIds) {
|
|
152
|
+
const all = getDb().prepare('SELECT id FROM tools').all().map(r => r.id)
|
|
153
|
+
const toDelete = all.filter(id => !foundIds.has(id))
|
|
154
|
+
const del = getDb().prepare('DELETE FROM tools WHERE id = ?')
|
|
155
|
+
const run = getDb().transaction(ids => { for (const id of ids) del.run(id) })
|
|
156
|
+
run(toDelete)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// plugin 子技能调用明细(tool_name 格式:pluginName:subSkill)
|
|
160
|
+
export function getPluginSubskillStats({ name, after }) {
|
|
161
|
+
return getDb().prepare(`
|
|
162
|
+
SELECT tool_name as toolName, COUNT(*) as count
|
|
163
|
+
FROM tool_invocations
|
|
164
|
+
WHERE tool_name GLOB ? AND invoked_at >= ?
|
|
165
|
+
GROUP BY tool_name ORDER BY count DESC
|
|
166
|
+
`).all(name + ':*', after)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function getToolDistribution({ after }) {
|
|
170
|
+
// 只取内置工具:首字母大写、不含 : 或 __ (GLOB 里 _ 不是通配符)
|
|
171
|
+
return getDb().prepare(`
|
|
172
|
+
SELECT tool_name as toolName, COUNT(*) as count
|
|
173
|
+
FROM tool_invocations
|
|
174
|
+
WHERE invoked_at >= ?
|
|
175
|
+
AND tool_name GLOB '[A-Z]*'
|
|
176
|
+
AND tool_name NOT GLOB '*:*'
|
|
177
|
+
AND tool_name NOT GLOB '*__*'
|
|
178
|
+
GROUP BY tool_name ORDER BY count DESC
|
|
179
|
+
`).all(after)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function getIndexedFiles() {
|
|
183
|
+
return new Set(
|
|
184
|
+
getDb().prepare('SELECT jsonl_file FROM sessions WHERE jsonl_file IS NOT NULL').all()
|
|
185
|
+
.map(r => r.jsonl_file)
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function getFilesNeedingTopics() {
|
|
190
|
+
return new Set(
|
|
191
|
+
getDb().prepare('SELECT jsonl_file FROM sessions WHERE topic IS NULL AND jsonl_file IS NOT NULL').all()
|
|
192
|
+
.map(r => r.jsonl_file)
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function getTopicsOverview({ after }) {
|
|
197
|
+
return getDb().prepare(`
|
|
198
|
+
SELECT topic,
|
|
199
|
+
COUNT(*) as count,
|
|
200
|
+
ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER (), 1) as pct
|
|
201
|
+
FROM sessions
|
|
202
|
+
WHERE start_time >= ? AND topic IS NOT NULL
|
|
203
|
+
GROUP BY topic
|
|
204
|
+
ORDER BY count DESC
|
|
205
|
+
`).all(after)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── Insights / Efficiency 查询 ──
|
|
209
|
+
|
|
210
|
+
export function getAvgRoundsByTopic({ after }) {
|
|
211
|
+
return getDb().prepare(`
|
|
212
|
+
SELECT topic, ROUND(AVG(message_count), 1) as avgRounds
|
|
213
|
+
FROM sessions
|
|
214
|
+
WHERE start_time >= ? AND topic IS NOT NULL
|
|
215
|
+
GROUP BY topic
|
|
216
|
+
ORDER BY avgRounds DESC
|
|
217
|
+
`).all(after)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function getDurationByTopic({ after }) {
|
|
221
|
+
return getDb().prepare(`
|
|
222
|
+
SELECT topic,
|
|
223
|
+
SUM(MIN(duration_sec, 14400)) as totalSec,
|
|
224
|
+
ROUND(SUM(MIN(duration_sec, 14400)) * 100.0
|
|
225
|
+
/ SUM(SUM(MIN(duration_sec, 14400))) OVER (), 1) as pct
|
|
226
|
+
FROM sessions
|
|
227
|
+
WHERE start_time >= ? AND topic IS NOT NULL
|
|
228
|
+
GROUP BY topic
|
|
229
|
+
ORDER BY totalSec DESC
|
|
230
|
+
`).all(after)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function getToolDensityByTopic({ after }) {
|
|
234
|
+
return getDb().prepare(`
|
|
235
|
+
SELECT topic,
|
|
236
|
+
ROUND(AVG(CAST(tool_use_count AS REAL) / NULLIF(message_count / 2.0, 0)), 2) as density,
|
|
237
|
+
ROUND(AVG(message_count / 2.0), 0) as avgTurns,
|
|
238
|
+
ROUND(AVG(tool_use_count), 0) as avgTools
|
|
239
|
+
FROM sessions
|
|
240
|
+
WHERE start_time >= ? AND topic IS NOT NULL AND message_count > 0
|
|
241
|
+
GROUP BY topic
|
|
242
|
+
ORDER BY density DESC
|
|
243
|
+
`).all(after)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function getTimeTopicHeatmap({ after }) {
|
|
247
|
+
return getDb().prepare(`
|
|
248
|
+
SELECT strftime('%H', start_time / 1000, 'unixepoch', '+8 hours') as hour,
|
|
249
|
+
topic,
|
|
250
|
+
COUNT(*) as count
|
|
251
|
+
FROM sessions
|
|
252
|
+
WHERE start_time >= ? AND topic IS NOT NULL
|
|
253
|
+
GROUP BY hour, topic
|
|
254
|
+
ORDER BY hour
|
|
255
|
+
`).all(after)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function getOutlierSessions({ after }) {
|
|
259
|
+
return getDb().prepare(`
|
|
260
|
+
WITH avg_mc AS (
|
|
261
|
+
SELECT AVG(message_count) as v FROM sessions WHERE start_time >= ?
|
|
262
|
+
)
|
|
263
|
+
SELECT s.topic,
|
|
264
|
+
s.message_count as messageCount,
|
|
265
|
+
s.first_user_msg as firstUserMsg,
|
|
266
|
+
s.start_time as startTime
|
|
267
|
+
FROM sessions s, avg_mc
|
|
268
|
+
WHERE s.start_time >= ?
|
|
269
|
+
AND avg_mc.v > 0
|
|
270
|
+
AND s.message_count > avg_mc.v * 2
|
|
271
|
+
AND s.topic IS NOT NULL
|
|
272
|
+
AND s.first_user_msg IS NOT NULL
|
|
273
|
+
AND s.first_user_msg != ''
|
|
274
|
+
ORDER BY s.message_count DESC
|
|
275
|
+
LIMIT 50
|
|
276
|
+
`).all(after, after)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function getProjectDist({ after }) {
|
|
280
|
+
return getDb().prepare(`
|
|
281
|
+
SELECT COALESCE(project_path, '未知') as project,
|
|
282
|
+
COUNT(*) as count,
|
|
283
|
+
ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER (), 1) as pct
|
|
284
|
+
FROM sessions
|
|
285
|
+
WHERE start_time >= ?
|
|
286
|
+
GROUP BY project
|
|
287
|
+
ORDER BY count DESC
|
|
288
|
+
LIMIT 8
|
|
289
|
+
`).all(after)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function getTopicKeywords({ after }) {
|
|
293
|
+
const rows = getDb().prepare(`
|
|
294
|
+
SELECT topic, topic_keywords
|
|
295
|
+
FROM sessions
|
|
296
|
+
WHERE start_time >= ? AND topic_keywords IS NOT NULL
|
|
297
|
+
`).all(after)
|
|
298
|
+
|
|
299
|
+
const freq = {}
|
|
300
|
+
const topicMap = {}
|
|
301
|
+
for (const r of rows) {
|
|
302
|
+
let words
|
|
303
|
+
try { words = JSON.parse(r.topic_keywords) } catch { continue }
|
|
304
|
+
for (const w of words) {
|
|
305
|
+
freq[w] = (freq[w] ?? 0) + 1
|
|
306
|
+
if (!topicMap[w]) topicMap[w] = r.topic
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return Object.entries(freq)
|
|
311
|
+
.sort((a, b) => b[1] - a[1])
|
|
312
|
+
.slice(0, 20)
|
|
313
|
+
.map(([word, count]) => ({ word, count, topic: topicMap[word] ?? '其他' }))
|
|
314
|
+
}
|
package/src/db/schema.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export const CREATE_TABLES = `
|
|
2
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
3
|
+
id TEXT PRIMARY KEY,
|
|
4
|
+
source TEXT NOT NULL DEFAULT 'claude-code',
|
|
5
|
+
start_time INTEGER NOT NULL,
|
|
6
|
+
end_time INTEGER,
|
|
7
|
+
duration_sec INTEGER,
|
|
8
|
+
project_path TEXT,
|
|
9
|
+
message_count INTEGER DEFAULT 0,
|
|
10
|
+
tool_use_count INTEGER DEFAULT 0,
|
|
11
|
+
jsonl_file TEXT,
|
|
12
|
+
topic TEXT,
|
|
13
|
+
topic_keywords TEXT,
|
|
14
|
+
first_user_msg TEXT
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
CREATE TABLE IF NOT EXISTS tools (
|
|
18
|
+
id TEXT PRIMARY KEY,
|
|
19
|
+
name TEXT NOT NULL,
|
|
20
|
+
type TEXT NOT NULL,
|
|
21
|
+
subtype TEXT,
|
|
22
|
+
description TEXT,
|
|
23
|
+
source_type TEXT DEFAULT 'downloaded',
|
|
24
|
+
source_url TEXT,
|
|
25
|
+
installed_at INTEGER,
|
|
26
|
+
updated_at INTEGER,
|
|
27
|
+
security_scan_result TEXT DEFAULT 'unscanned'
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
CREATE TABLE IF NOT EXISTS tool_invocations (
|
|
31
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
32
|
+
session_id TEXT NOT NULL,
|
|
33
|
+
tool_name TEXT NOT NULL,
|
|
34
|
+
invoked_at INTEGER NOT NULL,
|
|
35
|
+
FOREIGN KEY (session_id) REFERENCES sessions(id)
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
39
|
+
key TEXT PRIMARY KEY,
|
|
40
|
+
value TEXT
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_start ON sessions(start_time);
|
|
44
|
+
CREATE INDEX IF NOT EXISTS idx_invocations_tool ON tool_invocations(tool_name);
|
|
45
|
+
CREATE INDEX IF NOT EXISTS idx_invocations_session ON tool_invocations(session_id);
|
|
46
|
+
`
|
package/src/indexer.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// src/indexer.js
|
|
2
|
+
import fs from 'fs'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import { getClaudeDir, getExtraSessionDirs } from './config.js'
|
|
5
|
+
import { parseJsonlFile } from './parsers/jsonl.js'
|
|
6
|
+
import { parseSkillMd } from './parsers/skill-md.js'
|
|
7
|
+
import { scanSkillSecurity } from './parsers/security.js'
|
|
8
|
+
import { upsertSession, upsertTool, insertInvocations, getIndexedFiles, getFilesNeedingTopics, syncToolIds } from './db/queries.js'
|
|
9
|
+
import { classifyTopic, extractKeywords } from './classifiers/topic-rules.js'
|
|
10
|
+
import { getMeta, setMeta } from './db/db.js'
|
|
11
|
+
|
|
12
|
+
function findAllJsonlFiles(claudeDir) {
|
|
13
|
+
const results = []
|
|
14
|
+
function walk(dir) {
|
|
15
|
+
if (!fs.existsSync(dir)) return
|
|
16
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
17
|
+
const full = path.join(dir, entry.name)
|
|
18
|
+
if (entry.isDirectory()) walk(full)
|
|
19
|
+
else if (entry.name.endsWith('.jsonl')) results.push(full)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// CLI 数据目录
|
|
23
|
+
walk(path.join(claudeDir, 'projects'))
|
|
24
|
+
// 桌面 App 额外目录(macOS),自动检测
|
|
25
|
+
for (const extraDir of getExtraSessionDirs()) {
|
|
26
|
+
walk(extraDir)
|
|
27
|
+
}
|
|
28
|
+
return results
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function findAllTools(claudeDir) {
|
|
32
|
+
const tools = []
|
|
33
|
+
// Skills
|
|
34
|
+
const skillsDir = path.join(claudeDir, 'skills')
|
|
35
|
+
if (fs.existsSync(skillsDir)) {
|
|
36
|
+
for (const name of fs.readdirSync(skillsDir)) {
|
|
37
|
+
try {
|
|
38
|
+
const skillPath = path.join(skillsDir, name)
|
|
39
|
+
const stat = fs.statSync(skillPath)
|
|
40
|
+
if (!stat.isDirectory()) continue
|
|
41
|
+
const skillMd = path.join(skillPath, 'SKILL.md')
|
|
42
|
+
const meta = parseSkillMd(skillMd, name) ?? { name, description: '', type: 'skill', source: null }
|
|
43
|
+
const security = scanSkillSecurity(skillMd)
|
|
44
|
+
const toolType = ['skill', 'agent'].includes(meta.type) ? meta.type : 'skill'
|
|
45
|
+
const sourceType = meta.source ? 'downloaded' : 'self'
|
|
46
|
+
const sourceUrl = meta.source ?? null
|
|
47
|
+
tools.push({ id: `${toolType}:${name}`, name: meta.name, type: toolType,
|
|
48
|
+
subtype: null, description: meta.description, sourceType, sourceUrl,
|
|
49
|
+
installedAt: stat.birthtimeMs, updatedAt: stat.mtimeMs,
|
|
50
|
+
securityScanResult: security })
|
|
51
|
+
} catch {}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Plugins
|
|
55
|
+
const pluginsDir = path.join(claudeDir, 'plugins', 'cache')
|
|
56
|
+
if (fs.existsSync(pluginsDir)) {
|
|
57
|
+
for (const marketplace of fs.readdirSync(pluginsDir)) {
|
|
58
|
+
try {
|
|
59
|
+
const mDir = path.join(pluginsDir, marketplace)
|
|
60
|
+
if (!fs.statSync(mDir).isDirectory()) continue
|
|
61
|
+
const children = fs.readdirSync(mDir).filter(n => {
|
|
62
|
+
try { return fs.statSync(path.join(mDir, n)).isDirectory() } catch { return false }
|
|
63
|
+
})
|
|
64
|
+
if (children.length > 1) {
|
|
65
|
+
// 多子 plugin(如 knowledge-work-plugins)→ 以 marketplace 为整体注册
|
|
66
|
+
const stat = fs.statSync(mDir)
|
|
67
|
+
tools.push({ id: `plugin:${marketplace}:${marketplace}`, name: marketplace,
|
|
68
|
+
type: 'plugin', subtype: null, description: '', sourceType: 'downloaded',
|
|
69
|
+
sourceUrl: null, installedAt: stat.birthtimeMs, updatedAt: stat.mtimeMs,
|
|
70
|
+
securityScanResult: 'unscanned' })
|
|
71
|
+
} else {
|
|
72
|
+
// 单 plugin(如 claude-plugins-official/superpowers)→ 以 pluginName 注册
|
|
73
|
+
for (const pluginName of children) {
|
|
74
|
+
try {
|
|
75
|
+
const stat = fs.statSync(path.join(mDir, pluginName))
|
|
76
|
+
tools.push({ id: `plugin:${marketplace}:${pluginName}`, name: pluginName,
|
|
77
|
+
type: 'plugin', subtype: null, description: '', sourceType: 'downloaded',
|
|
78
|
+
sourceUrl: null, installedAt: stat.birthtimeMs, updatedAt: stat.mtimeMs,
|
|
79
|
+
securityScanResult: 'unscanned' })
|
|
80
|
+
} catch {}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch {}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return tools
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 返回所有正在扫描的目录列表(用于空视图展示检测路径)
|
|
90
|
+
export function getScanPaths() {
|
|
91
|
+
const claudeDir = getClaudeDir()
|
|
92
|
+
const dirs = [path.join(claudeDir, 'projects'), ...getExtraSessionDirs()]
|
|
93
|
+
return dirs
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function indexJsonlFile(filePath) {
|
|
97
|
+
const result = parseJsonlFile(filePath)
|
|
98
|
+
if (!result) return
|
|
99
|
+
|
|
100
|
+
// 优先用第一条用户消息分类,匹配不到时用全量文本兜底
|
|
101
|
+
let topic = classifyTopic(result.firstUserMessage)
|
|
102
|
+
if (topic === '其他') topic = classifyTopic(result.allUserText)
|
|
103
|
+
const topicKeywords = extractKeywords(result.allUserText)
|
|
104
|
+
|
|
105
|
+
upsertSession({
|
|
106
|
+
...result,
|
|
107
|
+
id: result.sessionId,
|
|
108
|
+
source: 'claude-code',
|
|
109
|
+
jsonlFile: filePath,
|
|
110
|
+
topic,
|
|
111
|
+
topicKeywords,
|
|
112
|
+
firstUserMsg: result.firstUserMessage ?? null,
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
if (result.invocations.length > 0) {
|
|
116
|
+
insertInvocations(result.invocations.map(inv => ({
|
|
117
|
+
sessionId: result.sessionId, ...inv
|
|
118
|
+
})))
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 只同步工具(skills/plugins),不重新索引 JSONL,速度快,每次启动都调用
|
|
123
|
+
export function syncToolsOnly() {
|
|
124
|
+
const claudeDir = getClaudeDir()
|
|
125
|
+
const tools = findAllTools(claudeDir)
|
|
126
|
+
for (const tool of tools) upsertTool(tool)
|
|
127
|
+
syncToolIds(new Set(tools.map(t => t.id)))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function runFullIndex(onProgress) {
|
|
131
|
+
const claudeDir = getClaudeDir()
|
|
132
|
+
const files = findAllJsonlFiles(claudeDir)
|
|
133
|
+
const tools = findAllTools(claudeDir)
|
|
134
|
+
|
|
135
|
+
// Index tools first (fast),并同步删除已不存在的工具记录
|
|
136
|
+
for (const tool of tools) upsertTool(tool)
|
|
137
|
+
syncToolIds(new Set(tools.map(t => t.id)))
|
|
138
|
+
|
|
139
|
+
// Index JSONL files with progress
|
|
140
|
+
// 同时处理:新文件 + 已索引但 topic 为 NULL 的文件(补分类)
|
|
141
|
+
const indexed = getIndexedFiles()
|
|
142
|
+
const needsTopics = getFilesNeedingTopics()
|
|
143
|
+
const toIndex = files.filter(f => !indexed.has(f) || needsTopics.has(f))
|
|
144
|
+
const total = toIndex.length
|
|
145
|
+
|
|
146
|
+
for (let i = 0; i < toIndex.length; i++) {
|
|
147
|
+
await indexJsonlFile(toIndex[i])
|
|
148
|
+
const pct = Math.round(((i + 1) / Math.max(total, 1)) * 100)
|
|
149
|
+
onProgress?.(pct)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (total === 0) onProgress?.(100)
|
|
153
|
+
setMeta('last_full_index', Date.now().toString())
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function runIncrementalIndex(filePath) {
|
|
157
|
+
await indexJsonlFile(filePath)
|
|
158
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
|
|
3
|
+
export function parseJsonlFile(filePath) {
|
|
4
|
+
const raw = fs.readFileSync(filePath, 'utf8')
|
|
5
|
+
const lines = raw.split('\n').filter(l => l.trim())
|
|
6
|
+
|
|
7
|
+
const records = []
|
|
8
|
+
for (const line of lines) {
|
|
9
|
+
try { records.push(JSON.parse(line)) } catch { /* skip */ }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const userMsgs = records.filter(r => r.type === 'user' && r.sessionId && r.timestamp)
|
|
13
|
+
if (userMsgs.length === 0) return null
|
|
14
|
+
|
|
15
|
+
const sessionId = userMsgs[0].sessionId
|
|
16
|
+
const timestamps = userMsgs.map(r => new Date(r.timestamp).getTime()).filter(Boolean).sort((a, b) => a - b)
|
|
17
|
+
const startTime = timestamps[0]
|
|
18
|
+
const endTime = timestamps[timestamps.length - 1]
|
|
19
|
+
const durationSec = Math.round((endTime - startTime) / 1000)
|
|
20
|
+
const projectPath = userMsgs[0].cwd ?? null
|
|
21
|
+
const messageCount = records.filter(r => r.type === 'user' || r.type === 'assistant').length
|
|
22
|
+
|
|
23
|
+
const invocations = []
|
|
24
|
+
for (const r of records) {
|
|
25
|
+
if (r.type !== 'assistant') continue
|
|
26
|
+
const content = r.message?.content ?? []
|
|
27
|
+
for (const block of content) {
|
|
28
|
+
if (block.type === 'tool_use' && block.name) {
|
|
29
|
+
// Skill 调用:用 input.skill 作为 toolName,保留真实 skill 名称
|
|
30
|
+
const toolName = block.name === 'Skill' && block.input?.skill
|
|
31
|
+
? block.input.skill
|
|
32
|
+
: block.name
|
|
33
|
+
invocations.push({
|
|
34
|
+
toolName,
|
|
35
|
+
invokedAt: new Date(r.timestamp).getTime() || startTime,
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 提取用户消息文本(排除 tool_result 类型内容)
|
|
42
|
+
function extractText(msg) {
|
|
43
|
+
const content = msg.message?.content ?? msg.content ?? ''
|
|
44
|
+
if (typeof content === 'string') return content
|
|
45
|
+
if (Array.isArray(content)) {
|
|
46
|
+
return content
|
|
47
|
+
.filter(b => b.type === 'text')
|
|
48
|
+
.map(b => b.text ?? '')
|
|
49
|
+
.join(' ')
|
|
50
|
+
}
|
|
51
|
+
return ''
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const userTextList = userMsgs.map(r => extractText(r)).filter(Boolean)
|
|
55
|
+
const firstUserMessage = userTextList[0] ?? ''
|
|
56
|
+
const allUserText = userTextList.join(' ')
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
sessionId,
|
|
60
|
+
startTime,
|
|
61
|
+
endTime,
|
|
62
|
+
durationSec,
|
|
63
|
+
projectPath,
|
|
64
|
+
messageCount,
|
|
65
|
+
toolUseCount: invocations.length,
|
|
66
|
+
invocations,
|
|
67
|
+
firstUserMessage,
|
|
68
|
+
allUserText,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
|
|
3
|
+
const RED_FLAGS = [
|
|
4
|
+
/curl\s+https?:\/\/(?!raw\.githubusercontent\.com|api\.github\.com)/,
|
|
5
|
+
/wget\s+https?:\/\//,
|
|
6
|
+
/rm\s+-rf/,
|
|
7
|
+
/base64\s+--decode/,
|
|
8
|
+
/base64\s+-d/,
|
|
9
|
+
/eval\s*\(/,
|
|
10
|
+
/exec\s*\(/,
|
|
11
|
+
/\bsudo\b/,
|
|
12
|
+
/\~\/\.ssh/,
|
|
13
|
+
/\~\/\.aws/,
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
export function scanSkillSecurity(filePath) {
|
|
17
|
+
if (!fs.existsSync(filePath)) return 'unscanned'
|
|
18
|
+
const raw = fs.readFileSync(filePath, 'utf8')
|
|
19
|
+
for (const pattern of RED_FLAGS) {
|
|
20
|
+
if (pattern.test(raw)) return 'warning'
|
|
21
|
+
}
|
|
22
|
+
return 'safe'
|
|
23
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
|
|
3
|
+
export function parseSkillMd(filePath, fallbackName = '') {
|
|
4
|
+
if (!fs.existsSync(filePath)) return null
|
|
5
|
+
const raw = fs.readFileSync(filePath, 'utf8')
|
|
6
|
+
|
|
7
|
+
let name = fallbackName
|
|
8
|
+
let description = ''
|
|
9
|
+
let type = 'skill'
|
|
10
|
+
let source = null
|
|
11
|
+
|
|
12
|
+
const fmMatch = raw.match(/^---\n([\s\S]*?)\n---/)
|
|
13
|
+
if (fmMatch) {
|
|
14
|
+
const fm = fmMatch[1]
|
|
15
|
+
const nameMatch = fm.match(/^name:\s*(.+)$/m)
|
|
16
|
+
const typeMatch = fm.match(/^type:\s*(.+)$/m)
|
|
17
|
+
const sourceMatch = fm.match(/^source:\s*(.+)$/m)
|
|
18
|
+
if (nameMatch) name = nameMatch[1].trim()
|
|
19
|
+
if (typeMatch) type = typeMatch[1].trim()
|
|
20
|
+
if (sourceMatch) source = sourceMatch[1].trim()
|
|
21
|
+
|
|
22
|
+
// 支持 YAML block scalar(description: > 或 description: |)
|
|
23
|
+
const descInline = fm.match(/^description:\s*([^>|].+)$/m)
|
|
24
|
+
const descBlock = fm.match(/^description:\s*[>|]\n((?:[ \t]+.+\n?)+)/m)
|
|
25
|
+
if (descInline) {
|
|
26
|
+
description = descInline[1].trim()
|
|
27
|
+
} else if (descBlock) {
|
|
28
|
+
description = descBlock[1]
|
|
29
|
+
.split('\n')
|
|
30
|
+
.map(l => l.trim())
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.join(' ')
|
|
33
|
+
.trim()
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { name, description, type, source }
|
|
38
|
+
}
|