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.
@@ -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
+ }
@@ -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
+ }