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/poster.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// src/poster.js — 海报相关业务逻辑
|
|
2
|
+
|
|
3
|
+
import fs from 'fs'
|
|
4
|
+
import path from 'path'
|
|
5
|
+
import os from 'os'
|
|
6
|
+
import { getClaudeDir, getConfigPath } from './config.js'
|
|
7
|
+
|
|
8
|
+
// ── P1:称呼提取 ──────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 从本地 md 文件中提取用户称呼。
|
|
12
|
+
* 优先级:config.json > CLAUDE.md > memory/*.md > 空字符串
|
|
13
|
+
*/
|
|
14
|
+
export function extractNickname() {
|
|
15
|
+
// 1. 优先读持久化配置
|
|
16
|
+
const saved = readSavedNickname()
|
|
17
|
+
if (saved) return saved
|
|
18
|
+
|
|
19
|
+
// 2. 扫描 CLAUDE.md
|
|
20
|
+
const claudeMd = path.join(getClaudeDir(), 'CLAUDE.md')
|
|
21
|
+
const fromClaude = extractFromFile(claudeMd)
|
|
22
|
+
if (fromClaude) return fromClaude
|
|
23
|
+
|
|
24
|
+
// 3. 扫描 memory 目录下所有 .md 文件
|
|
25
|
+
const memoryDirs = findMemoryDirs()
|
|
26
|
+
for (const dir of memoryDirs) {
|
|
27
|
+
try {
|
|
28
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.md'))
|
|
29
|
+
for (const file of files) {
|
|
30
|
+
const found = extractFromFile(path.join(dir, file))
|
|
31
|
+
if (found) return found
|
|
32
|
+
}
|
|
33
|
+
} catch {}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return ''
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 从 config.json 读取已保存的 posterNickname */
|
|
40
|
+
function readSavedNickname() {
|
|
41
|
+
try {
|
|
42
|
+
const cfg = JSON.parse(fs.readFileSync(getConfigPath(), 'utf8'))
|
|
43
|
+
return cfg.posterNickname ?? ''
|
|
44
|
+
} catch {
|
|
45
|
+
return ''
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 在单个 md 文件中用正则提取称呼 */
|
|
50
|
+
function extractFromFile(filePath) {
|
|
51
|
+
let text
|
|
52
|
+
try {
|
|
53
|
+
text = fs.readFileSync(filePath, 'utf8')
|
|
54
|
+
} catch {
|
|
55
|
+
return ''
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 按优先级逐条匹配,取第一个命中
|
|
59
|
+
const patterns = [
|
|
60
|
+
// 中文:叫我 Halo、称呼我为 Halo、称呼我 Halo
|
|
61
|
+
/(?:叫我|称呼我为?)\s*[::]?\s*([A-Za-z\u4e00-\u9fa5]{1,10})/,
|
|
62
|
+
// 中文:称呼:Halo、昵称:Halo
|
|
63
|
+
/(?:称呼|昵称)\s*[::]\s*([A-Za-z\u4e00-\u9fa5]{1,10})/,
|
|
64
|
+
// 英文:call me Halo、nickname: Halo
|
|
65
|
+
/(?:call me|nickname)\s*[::\s]\s*([A-Za-z]{1,10})/i,
|
|
66
|
+
// 通用格式:posterNickname: Halo(手动写在 md 里)
|
|
67
|
+
/posterNickname\s*[::]\s*([A-Za-z\u4e00-\u9fa5]{1,10})/i,
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
for (const re of patterns) {
|
|
71
|
+
const m = text.match(re)
|
|
72
|
+
if (m?.[1]?.trim()) return m[1].trim()
|
|
73
|
+
}
|
|
74
|
+
return ''
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 查找 ~/.claude/projects/ 下所有 memory 子目录 */
|
|
78
|
+
function findMemoryDirs() {
|
|
79
|
+
const projectsDir = path.join(getClaudeDir(), 'projects')
|
|
80
|
+
const dirs = []
|
|
81
|
+
try {
|
|
82
|
+
for (const proj of fs.readdirSync(projectsDir)) {
|
|
83
|
+
const memDir = path.join(projectsDir, proj, 'memory')
|
|
84
|
+
if (fs.existsSync(memDir)) dirs.push(memDir)
|
|
85
|
+
}
|
|
86
|
+
} catch {}
|
|
87
|
+
return dirs
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── P2:文案规则生成 ──────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 根据使用数据生成海报文案。
|
|
94
|
+
* @param {object} data
|
|
95
|
+
* sessions - 对话次数
|
|
96
|
+
* totalDurationSec - 累计时长(秒)
|
|
97
|
+
* avgDailyDurationSec - 日均时长(秒)
|
|
98
|
+
* peakPeriod - 活跃时段,如 "22:00–23:59"
|
|
99
|
+
* silentDays - 静默天数
|
|
100
|
+
* habit - 'night'|'morning'|'work'|null
|
|
101
|
+
* topSkill - 最常用 skill 名称,如 "superpowers" / null
|
|
102
|
+
* trendChange - 与上期相比变化百分比,如 +30 / -20 / null
|
|
103
|
+
* nickname - 用户称呼,可空
|
|
104
|
+
* @returns {{ summary: string, tags: string[] }}
|
|
105
|
+
*/
|
|
106
|
+
export function generatePosterText({
|
|
107
|
+
sessions = 0,
|
|
108
|
+
totalDurationSec = 0,
|
|
109
|
+
avgDailyDurationSec = 0,
|
|
110
|
+
peakPeriod = null,
|
|
111
|
+
silentDays = 0,
|
|
112
|
+
habit = null,
|
|
113
|
+
topSkill = null,
|
|
114
|
+
trendChange = null,
|
|
115
|
+
nickname = '',
|
|
116
|
+
seed = 0, // 用于切换 summary 变体,0 = 默认
|
|
117
|
+
}) {
|
|
118
|
+
const totalHours = Math.round(totalDurationSec / 3600)
|
|
119
|
+
const avgMin = Math.round(avgDailyDurationSec / 60)
|
|
120
|
+
const name = nickname || ''
|
|
121
|
+
|
|
122
|
+
const summaries = buildAllSummaries({ sessions, totalHours, avgMin, habit, silentDays, trendChange, topSkill, name })
|
|
123
|
+
const summary = summaries[seed % summaries.length] ?? summaries[0]
|
|
124
|
+
const tags = buildTags({ habit, sessions, avgMin, topSkill, trendChange })
|
|
125
|
+
|
|
126
|
+
return { summary, tags, summaryCount: summaries.length }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── 一句话总结 ────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
/** 收集所有匹配当前数据的 summary 变体,返回数组(至少 1 条) */
|
|
132
|
+
function buildAllSummaries({ sessions, totalHours, avgMin, habit, silentDays, trendChange, topSkill, name }) {
|
|
133
|
+
const h = totalHours
|
|
134
|
+
const s = sessions
|
|
135
|
+
const n = () => name || 'You'
|
|
136
|
+
const poss = () => name ? `${name}'s` : 'Your'
|
|
137
|
+
const v = (third, second = null) => name ? third : (second ?? third)
|
|
138
|
+
|
|
139
|
+
const variants = []
|
|
140
|
+
|
|
141
|
+
// 极高强度
|
|
142
|
+
if (h >= 100 && habit === 'night') {
|
|
143
|
+
variants.push(`${h}h of late-night conversations — ${n()} and Claude don't sleep.`)
|
|
144
|
+
}
|
|
145
|
+
if (h >= 100) {
|
|
146
|
+
variants.push(`${h} hours in. ${poss()} workflow has been permanently upgraded.`)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 夜猫子
|
|
150
|
+
if (habit === 'night' && s >= 20) {
|
|
151
|
+
variants.push(`The best ideas happen after midnight. ${n()} ${v('has','have')} ${s} sessions to prove it.`)
|
|
152
|
+
variants.push(`${s} sessions — most of them after the rest of the world logged off.`)
|
|
153
|
+
}
|
|
154
|
+
if (habit === 'night') {
|
|
155
|
+
variants.push(`Night mode: on. ${poss()} best thinking happens after dark.`)
|
|
156
|
+
variants.push(`While everyone else sleeps, ${n()} ${v('ships','ship')}.`)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 早鸟
|
|
160
|
+
if (habit === 'morning' && s >= 20) {
|
|
161
|
+
variants.push(`${n()} ${v('starts','start')} every morning with coffee, code, and Claude.`)
|
|
162
|
+
variants.push(`${s} sessions, most of them before 10am. ${n()} ${v("doesn't","don't")} waste mornings.`)
|
|
163
|
+
}
|
|
164
|
+
if (habit === 'morning') {
|
|
165
|
+
variants.push(`Early bird gets the context window. ${n()} ${v('is','are')} up before the sun.`)
|
|
166
|
+
variants.push(`Morning person. ${poss()} AI workflow starts before the inbox does.`)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 上涨趋势
|
|
170
|
+
if (trendChange !== null && trendChange >= 50) {
|
|
171
|
+
variants.push(`Usage up ${trendChange}% this period. ${n()} just discovered what Claude can really do.`)
|
|
172
|
+
variants.push(`+${trendChange}% and climbing. ${poss()} AI usage is compounding.`)
|
|
173
|
+
}
|
|
174
|
+
if (trendChange !== null && trendChange >= 20) {
|
|
175
|
+
variants.push(`${poss()} Claude usage is trending up ${trendChange}%. Momentum is real.`)
|
|
176
|
+
variants.push(`${trendChange}% more sessions than last period. The habit is forming.`)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 高日均
|
|
180
|
+
if (avgMin >= 60) {
|
|
181
|
+
variants.push(`${avgMin} min/day, every day. For ${n()}, AI isn't a tool — it's a habit.`)
|
|
182
|
+
variants.push(`${avgMin} minutes a day with Claude. That's not a tool, that's a routine.`)
|
|
183
|
+
}
|
|
184
|
+
if (avgMin >= 30) {
|
|
185
|
+
variants.push(`${n()} ${v('spends','spend')} ${avgMin} min/day with Claude. That's ${Math.round(avgMin * 30 / 60)}h a month of compound intelligence.`)
|
|
186
|
+
variants.push(`${avgMin} min/day. ${poss()} AI time is an investment, not a cost.`)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 高频使用
|
|
190
|
+
if (s >= 100) {
|
|
191
|
+
variants.push(`${s} conversations and counting. ${n()} and Claude are practically colleagues.`)
|
|
192
|
+
variants.push(`${s} sessions. At this point, Claude ${v('knows','know')} ${n()} better than most.`)
|
|
193
|
+
}
|
|
194
|
+
if (s >= 50) {
|
|
195
|
+
variants.push(`${s} sessions in. ${n()} ${v('has','have')} found their AI workflow.`)
|
|
196
|
+
variants.push(`${s} conversations deep. ${n()} ${v("doesn't","don't")} remember how they worked before.`)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 静默后回来
|
|
200
|
+
if (silentDays >= 7 && s >= 5) {
|
|
201
|
+
variants.push(`Back after ${silentDays} days away. ${n()} missed this.`)
|
|
202
|
+
variants.push(`${silentDays} days offline, then ${s} sessions in one burst. The pull is real.`)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// top skill
|
|
206
|
+
if (topSkill) {
|
|
207
|
+
variants.push(`${poss()} most-used skill: ${topSkill}. The automation era is personal.`)
|
|
208
|
+
variants.push(`${topSkill} on repeat. ${n()} ${v('knows','know')} what works.`)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 兜底(总是加入)
|
|
212
|
+
if (s > 0) {
|
|
213
|
+
variants.push(`${s} sessions, ${h > 0 ? h + 'h' : 'counting'}. ${poss()} AI journey has begun.`)
|
|
214
|
+
}
|
|
215
|
+
variants.push(`${n()} ${v('is','are')} just getting started. The best sessions are ahead.`)
|
|
216
|
+
|
|
217
|
+
return variants
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── AI 人格标签 ───────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
function buildTags({ habit, sessions, avgMin, topSkill, trendChange }) {
|
|
223
|
+
const tags = []
|
|
224
|
+
|
|
225
|
+
// 标签1:时间习惯
|
|
226
|
+
if (habit === 'night') tags.push('🌙 Night Coder')
|
|
227
|
+
else if (habit === 'morning') tags.push('🌅 Early Bird Builder')
|
|
228
|
+
else tags.push('⏰ 9-to-5 Hacker')
|
|
229
|
+
|
|
230
|
+
// 标签2:使用强度
|
|
231
|
+
if (sessions >= 100 || avgMin >= 60) tags.push('🚀 Power User')
|
|
232
|
+
else if (sessions >= 30 || avgMin >= 30) tags.push('⚡ Daily Driver')
|
|
233
|
+
else tags.push('🌱 Growing Fast')
|
|
234
|
+
|
|
235
|
+
// 标签3:技能偏好 / 趋势
|
|
236
|
+
if (trendChange !== null && trendChange >= 30) {
|
|
237
|
+
tags.push('📈 On The Rise')
|
|
238
|
+
} else if (topSkill) {
|
|
239
|
+
const skillTag = skillToTag(topSkill)
|
|
240
|
+
if (skillTag) tags.push(skillTag)
|
|
241
|
+
else tags.push('🔧 Tool Builder')
|
|
242
|
+
} else {
|
|
243
|
+
tags.push('🤖 AI Native')
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return tags.slice(0, 3)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** 将 skill 名称映射为人格标签 */
|
|
250
|
+
function skillToTag(skillName) {
|
|
251
|
+
const name = skillName.toLowerCase()
|
|
252
|
+
if (name.includes('data') || name.includes('sql') || name.includes('analyst'))
|
|
253
|
+
return '📊 Data Whisperer'
|
|
254
|
+
if (name.includes('agent') || name.includes('browser') || name.includes('auto'))
|
|
255
|
+
return '⚡ Automation Hacker'
|
|
256
|
+
if (name.includes('frontend') || name.includes('design') || name.includes('ui'))
|
|
257
|
+
return '🎨 Frontend Crafter'
|
|
258
|
+
if (name.includes('superpowers') || name.includes('brainstorm') || name.includes('plan'))
|
|
259
|
+
return '🧠 Strategic Thinker'
|
|
260
|
+
if (name.includes('marketing') || name.includes('content') || name.includes('xhs'))
|
|
261
|
+
return '✍️ Content Creator'
|
|
262
|
+
if (name.includes('productivity') || name.includes('task') || name.includes('daily'))
|
|
263
|
+
return '🎯 Deep Focus'
|
|
264
|
+
return null
|
|
265
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// src/server.js
|
|
2
|
+
import express from 'express'
|
|
3
|
+
import { createServer } from 'http'
|
|
4
|
+
import { WebSocketServer } from 'ws'
|
|
5
|
+
import path from 'path'
|
|
6
|
+
import { fileURLToPath } from 'url'
|
|
7
|
+
import { createRouter } from './api.js'
|
|
8
|
+
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
10
|
+
|
|
11
|
+
export function createAppServer() {
|
|
12
|
+
const app = express()
|
|
13
|
+
const httpServer = createServer(app)
|
|
14
|
+
const wss = new WebSocketServer({ server: httpServer })
|
|
15
|
+
|
|
16
|
+
// JSON body 解析
|
|
17
|
+
app.use(express.json())
|
|
18
|
+
|
|
19
|
+
// 静态文件
|
|
20
|
+
app.use(express.static(path.join(__dirname, '..', 'public')))
|
|
21
|
+
|
|
22
|
+
// 当前进度状态
|
|
23
|
+
let _lastProgress = null
|
|
24
|
+
|
|
25
|
+
// 广播
|
|
26
|
+
function broadcast(msg) {
|
|
27
|
+
const payload = JSON.stringify(msg)
|
|
28
|
+
for (const client of wss.clients) {
|
|
29
|
+
if (client.readyState === 1) client.send(payload)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sendProgress(pct) {
|
|
34
|
+
_lastProgress = pct
|
|
35
|
+
broadcast({ type: 'progress', pct })
|
|
36
|
+
if (pct >= 100) _lastProgress = null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sendRefresh() {
|
|
40
|
+
_lastProgress = null
|
|
41
|
+
broadcast({ type: 'refresh' })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// API 路由(先于 SPA fallback 注册)
|
|
45
|
+
app.use(createRouter({ sendProgress, sendRefresh }))
|
|
46
|
+
|
|
47
|
+
// SPA fallback
|
|
48
|
+
app.get('*', (req, res) => {
|
|
49
|
+
res.sendFile(path.join(__dirname, '..', 'public', 'index.html'))
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
wss.on('connection', (ws) => {
|
|
53
|
+
if (_lastProgress !== null && _lastProgress < 100) {
|
|
54
|
+
ws.send(JSON.stringify({ type: 'progress', pct: _lastProgress }))
|
|
55
|
+
} else {
|
|
56
|
+
ws.send(JSON.stringify({ type: 'ready' }))
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
function listen(port = 3847) {
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
httpServer.listen(port, '127.0.0.1', () => resolve(port))
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { listen, sendProgress, sendRefresh }
|
|
67
|
+
}
|
package/src/watcher.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// src/watcher.js
|
|
2
|
+
import chokidar from 'chokidar'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import { getClaudeDir } from './config.js'
|
|
5
|
+
import { runIncrementalIndex } from './indexer.js'
|
|
6
|
+
|
|
7
|
+
export function startWatcher(onUpdate) {
|
|
8
|
+
const claudeDir = getClaudeDir()
|
|
9
|
+
const pattern = path.join(claudeDir, 'projects', '**', '*.jsonl')
|
|
10
|
+
|
|
11
|
+
const watcher = chokidar.watch(pattern, {
|
|
12
|
+
persistent: true,
|
|
13
|
+
ignoreInitial: true,
|
|
14
|
+
awaitWriteFinish: { stabilityThreshold: 1000, pollInterval: 200 },
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
watcher.on('add', async (filePath) => {
|
|
18
|
+
await runIncrementalIndex(filePath)
|
|
19
|
+
onUpdate?.()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
watcher.on('change', async (filePath) => {
|
|
23
|
+
await runIncrementalIndex(filePath)
|
|
24
|
+
onUpdate?.()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
return watcher
|
|
28
|
+
}
|