codeblog-app 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/bin/codeblog +2 -0
- package/drizzle/0000_init.sql +34 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +10 -0
- package/package.json +66 -0
- package/src/api/agents.ts +35 -0
- package/src/api/client.ts +96 -0
- package/src/api/feed.ts +25 -0
- package/src/api/notifications.ts +24 -0
- package/src/api/posts.ts +113 -0
- package/src/api/search.ts +13 -0
- package/src/api/tags.ts +13 -0
- package/src/api/trending.ts +38 -0
- package/src/auth/index.ts +46 -0
- package/src/auth/oauth.ts +69 -0
- package/src/cli/cmd/bookmark.ts +27 -0
- package/src/cli/cmd/comment.ts +39 -0
- package/src/cli/cmd/dashboard.ts +46 -0
- package/src/cli/cmd/feed.ts +68 -0
- package/src/cli/cmd/login.ts +38 -0
- package/src/cli/cmd/logout.ts +12 -0
- package/src/cli/cmd/notifications.ts +33 -0
- package/src/cli/cmd/post.ts +108 -0
- package/src/cli/cmd/publish.ts +44 -0
- package/src/cli/cmd/scan.ts +69 -0
- package/src/cli/cmd/search.ts +49 -0
- package/src/cli/cmd/setup.ts +86 -0
- package/src/cli/cmd/trending.ts +64 -0
- package/src/cli/cmd/vote.ts +35 -0
- package/src/cli/cmd/whoami.ts +50 -0
- package/src/cli/ui.ts +74 -0
- package/src/config/index.ts +40 -0
- package/src/flag/index.ts +23 -0
- package/src/global/index.ts +33 -0
- package/src/id/index.ts +20 -0
- package/src/index.ts +117 -0
- package/src/publisher/index.ts +136 -0
- package/src/scanner/__tests__/analyzer.test.ts +67 -0
- package/src/scanner/__tests__/fs-utils.test.ts +50 -0
- package/src/scanner/__tests__/platform.test.ts +27 -0
- package/src/scanner/__tests__/registry.test.ts +56 -0
- package/src/scanner/aider.ts +96 -0
- package/src/scanner/analyzer.ts +237 -0
- package/src/scanner/claude-code.ts +188 -0
- package/src/scanner/codex.ts +127 -0
- package/src/scanner/continue-dev.ts +95 -0
- package/src/scanner/cursor.ts +293 -0
- package/src/scanner/fs-utils.ts +123 -0
- package/src/scanner/index.ts +26 -0
- package/src/scanner/platform.ts +44 -0
- package/src/scanner/registry.ts +68 -0
- package/src/scanner/types.ts +62 -0
- package/src/scanner/vscode-copilot.ts +125 -0
- package/src/scanner/warp.ts +19 -0
- package/src/scanner/windsurf.ts +147 -0
- package/src/scanner/zed.ts +88 -0
- package/src/server/index.ts +48 -0
- package/src/storage/db.ts +68 -0
- package/src/storage/schema.sql.ts +39 -0
- package/src/storage/schema.ts +1 -0
- package/src/util/__tests__/context.test.ts +31 -0
- package/src/util/__tests__/lazy.test.ts +37 -0
- package/src/util/context.ts +23 -0
- package/src/util/error.ts +46 -0
- package/src/util/lazy.ts +18 -0
- package/src/util/log.ts +142 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach } from "bun:test"
|
|
2
|
+
import { registerScanner, getScanners, scanAll, listScannerStatus } from "../registry"
|
|
3
|
+
import type { Scanner, Session, ParsedSession } from "../types"
|
|
4
|
+
|
|
5
|
+
const mockScanner: Scanner = {
|
|
6
|
+
name: "Test Scanner",
|
|
7
|
+
source: "test" as any,
|
|
8
|
+
description: "A test scanner",
|
|
9
|
+
detect() {
|
|
10
|
+
return ["/tmp"]
|
|
11
|
+
},
|
|
12
|
+
scan(limit = 10): Session[] {
|
|
13
|
+
return [
|
|
14
|
+
{
|
|
15
|
+
id: "test-session-1",
|
|
16
|
+
title: "Test Session",
|
|
17
|
+
source: "test" as any,
|
|
18
|
+
project: "test-project",
|
|
19
|
+
filePath: "/tmp/test.json",
|
|
20
|
+
modifiedAt: new Date(),
|
|
21
|
+
humanMessages: 5,
|
|
22
|
+
aiMessages: 5,
|
|
23
|
+
},
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
parse(filePath: string): ParsedSession | null {
|
|
27
|
+
return {
|
|
28
|
+
id: "test-session-1",
|
|
29
|
+
source: "test" as any,
|
|
30
|
+
project: "test-project",
|
|
31
|
+
projectPath: "/tmp/test-project",
|
|
32
|
+
turns: [
|
|
33
|
+
{ role: "human", content: "Hello", timestamp: new Date() },
|
|
34
|
+
{ role: "assistant", content: "Hi there!", timestamp: new Date() },
|
|
35
|
+
],
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("registry", () => {
|
|
41
|
+
test("registerScanner adds scanner", () => {
|
|
42
|
+
registerScanner(mockScanner)
|
|
43
|
+
const scanners = getScanners()
|
|
44
|
+
expect(scanners.some((s) => s.name === "Test Scanner")).toBe(true)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test("listScannerStatus returns status for all scanners", () => {
|
|
48
|
+
const statuses = listScannerStatus()
|
|
49
|
+
expect(statuses.length).toBeGreaterThan(0)
|
|
50
|
+
for (const status of statuses) {
|
|
51
|
+
expect(status.name).toBeDefined()
|
|
52
|
+
expect(status.source).toBeDefined()
|
|
53
|
+
expect(typeof status.available).toBe("boolean")
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import * as path from "path"
|
|
2
|
+
import * as fs from "fs"
|
|
3
|
+
import type { Scanner, Session, ParsedSession, ConversationTurn } from "./types"
|
|
4
|
+
import { getHome } from "./platform"
|
|
5
|
+
import { listFiles, safeReadFile, safeStats } from "./fs-utils"
|
|
6
|
+
|
|
7
|
+
export const aiderScanner: Scanner = {
|
|
8
|
+
name: "Aider",
|
|
9
|
+
sourceType: "aider",
|
|
10
|
+
description: "Aider AI pair programming sessions",
|
|
11
|
+
|
|
12
|
+
getSessionDirs(): string[] {
|
|
13
|
+
const home = getHome()
|
|
14
|
+
const candidates = [path.join(home, ".aider", "history"), path.join(home, ".aider")]
|
|
15
|
+
return candidates.filter((d) => {
|
|
16
|
+
try { return fs.existsSync(d) } catch { return false }
|
|
17
|
+
})
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
scan(limit: number): Session[] {
|
|
21
|
+
const sessions: Session[] = []
|
|
22
|
+
for (const dir of this.getSessionDirs()) {
|
|
23
|
+
for (const filePath of listFiles(dir, [".md"], true)) {
|
|
24
|
+
if (!path.basename(filePath).includes("aider")) continue
|
|
25
|
+
const stats = safeStats(filePath)
|
|
26
|
+
if (!stats || stats.size < 100) continue
|
|
27
|
+
const content = safeReadFile(filePath)
|
|
28
|
+
if (!content) continue
|
|
29
|
+
const { humanCount, aiCount, preview } = parseAiderMarkdown(content)
|
|
30
|
+
if (humanCount === 0) continue
|
|
31
|
+
sessions.push({
|
|
32
|
+
id: path.basename(filePath, ".md"), source: "aider",
|
|
33
|
+
project: path.basename(path.dirname(filePath)),
|
|
34
|
+
title: preview.slice(0, 80) || "Aider session",
|
|
35
|
+
messageCount: humanCount + aiCount, humanMessages: humanCount, aiMessages: aiCount,
|
|
36
|
+
preview, filePath, modifiedAt: stats.mtime, sizeBytes: stats.size,
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
sessions.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime())
|
|
41
|
+
return sessions.slice(0, limit)
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
parse(filePath: string, maxTurns?: number): ParsedSession | null {
|
|
45
|
+
const content = safeReadFile(filePath)
|
|
46
|
+
if (!content) return null
|
|
47
|
+
const stats = safeStats(filePath)
|
|
48
|
+
const turns = parseAiderTurns(content, maxTurns)
|
|
49
|
+
if (turns.length === 0) return null
|
|
50
|
+
const humanMsgs = turns.filter((t) => t.role === "human")
|
|
51
|
+
const aiMsgs = turns.filter((t) => t.role === "assistant")
|
|
52
|
+
return {
|
|
53
|
+
id: path.basename(filePath, ".md"), source: "aider",
|
|
54
|
+
project: path.basename(path.dirname(filePath)),
|
|
55
|
+
title: humanMsgs[0]?.content.slice(0, 80) || "Aider session",
|
|
56
|
+
messageCount: turns.length, humanMessages: humanMsgs.length, aiMessages: aiMsgs.length,
|
|
57
|
+
preview: humanMsgs[0]?.content.slice(0, 200) || "",
|
|
58
|
+
filePath, modifiedAt: stats?.mtime || new Date(), sizeBytes: stats?.size || 0, turns,
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseAiderMarkdown(content: string) {
|
|
64
|
+
const userBlocks = content.split(/^####\s+/m).filter(Boolean)
|
|
65
|
+
let humanCount = 0
|
|
66
|
+
let aiCount = 0
|
|
67
|
+
let preview = ""
|
|
68
|
+
for (const block of userBlocks) {
|
|
69
|
+
const firstLine = block.split("\n")[0]?.trim()
|
|
70
|
+
if (firstLine) {
|
|
71
|
+
humanCount++
|
|
72
|
+
if (!preview) preview = firstLine.slice(0, 200)
|
|
73
|
+
const rest = block.split("\n").slice(1).join("\n").trim()
|
|
74
|
+
if (rest) aiCount++
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { humanCount, aiCount, preview }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseAiderTurns(content: string, maxTurns?: number): ConversationTurn[] {
|
|
81
|
+
const turns: ConversationTurn[] = []
|
|
82
|
+
for (const block of content.split(/^####\s+/m).filter(Boolean)) {
|
|
83
|
+
if (maxTurns && turns.length >= maxTurns) break
|
|
84
|
+
const lines = block.split("\n")
|
|
85
|
+
const userMsg = lines[0]?.trim()
|
|
86
|
+
if (userMsg) {
|
|
87
|
+
turns.push({ role: "human", content: userMsg })
|
|
88
|
+
const aiResponse = lines.slice(1).join("\n").trim()
|
|
89
|
+
if (aiResponse) {
|
|
90
|
+
if (maxTurns && turns.length >= maxTurns) break
|
|
91
|
+
turns.push({ role: "assistant", content: aiResponse })
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return turns
|
|
96
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import type { ParsedSession, SessionAnalysis, ConversationTurn } from "./types"
|
|
2
|
+
|
|
3
|
+
export function analyzeSession(session: ParsedSession): SessionAnalysis {
|
|
4
|
+
const allContent = session.turns.map((t) => t.content).join("\n")
|
|
5
|
+
const humanContent = session.turns
|
|
6
|
+
.filter((t) => t.role === "human")
|
|
7
|
+
.map((t) => t.content)
|
|
8
|
+
.join("\n")
|
|
9
|
+
const aiContent = session.turns
|
|
10
|
+
.filter((t) => t.role === "assistant")
|
|
11
|
+
.map((t) => t.content)
|
|
12
|
+
.join("\n")
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
summary: generateSummary(session),
|
|
16
|
+
topics: extractTopics(allContent),
|
|
17
|
+
languages: detectLanguages(allContent),
|
|
18
|
+
keyInsights: extractInsights(session.turns),
|
|
19
|
+
codeSnippets: extractCodeSnippets(allContent),
|
|
20
|
+
problems: extractProblems(humanContent),
|
|
21
|
+
solutions: extractSolutions(aiContent),
|
|
22
|
+
suggestedTitle: suggestTitle(session),
|
|
23
|
+
suggestedTags: suggestTags(allContent),
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function generateSummary(session: ParsedSession): string {
|
|
28
|
+
const humanMsgs = session.turns.filter((t) => t.role === "human")
|
|
29
|
+
const problems = extractProblems(humanMsgs.map((t) => t.content).join("\n"))
|
|
30
|
+
const langs = detectLanguages(session.turns.map((t) => t.content).join("\n"))
|
|
31
|
+
const parts: string[] = []
|
|
32
|
+
|
|
33
|
+
if (problems.length > 0) {
|
|
34
|
+
parts.push(`Ran into ${problems.length > 1 ? "a few issues" : "an issue"} while working on ${session.project}`)
|
|
35
|
+
} else {
|
|
36
|
+
parts.push(`Worked on ${session.project}`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (langs.length > 0) parts.push(`using ${langs.slice(0, 3).join(", ")}`)
|
|
40
|
+
|
|
41
|
+
const firstTask = humanMsgs.find((m) => m.content.trim().length > 20)
|
|
42
|
+
if (firstTask) {
|
|
43
|
+
const task = firstTask.content.split("\n")[0].trim().slice(0, 120)
|
|
44
|
+
parts.push(`— started with: "${task}"`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return parts.join(" ") + "."
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function extractTopics(content: string): string[] {
|
|
51
|
+
const topics: Set<string> = new Set()
|
|
52
|
+
const patterns: [RegExp, string][] = [
|
|
53
|
+
[/\b(react|vue|angular|svelte|nextjs|next\.js|nuxt)\b/i, "frontend"],
|
|
54
|
+
[/\b(express|fastify|koa|nest\.?js|django|flask|rails)\b/i, "backend"],
|
|
55
|
+
[/\b(typescript|javascript|python|rust|go|java|c\+\+|ruby|swift|kotlin)\b/i, "programming-language"],
|
|
56
|
+
[/\b(docker|kubernetes|k8s|ci\/cd|deploy|devops)\b/i, "devops"],
|
|
57
|
+
[/\b(sql|postgres|mysql|mongodb|redis|database|prisma|drizzle)\b/i, "database"],
|
|
58
|
+
[/\b(test|jest|vitest|pytest|testing|spec|unit test)\b/i, "testing"],
|
|
59
|
+
[/\b(api|rest|graphql|grpc|websocket)\b/i, "api"],
|
|
60
|
+
[/\b(auth|jwt|oauth|session|login|password)\b/i, "authentication"],
|
|
61
|
+
[/\b(css|tailwind|styled|sass|scss|styling)\b/i, "styling"],
|
|
62
|
+
[/\b(git|merge|rebase|branch|commit)\b/i, "git"],
|
|
63
|
+
[/\b(performance|optimize|cache|lazy|memo)\b/i, "performance"],
|
|
64
|
+
[/\b(debug|error|bug|fix|issue|crash)\b/i, "debugging"],
|
|
65
|
+
[/\b(refactor|clean|architecture|pattern|design)\b/i, "architecture"],
|
|
66
|
+
[/\b(security|vulnerability|xss|csrf|injection)\b/i, "security"],
|
|
67
|
+
[/\b(ai|ml|llm|gpt|claude|model|prompt)\b/i, "ai-ml"],
|
|
68
|
+
]
|
|
69
|
+
for (const [pattern, topic] of patterns) {
|
|
70
|
+
if (pattern.test(content)) topics.add(topic)
|
|
71
|
+
}
|
|
72
|
+
return Array.from(topics)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function detectLanguages(content: string): string[] {
|
|
76
|
+
const langs: Set<string> = new Set()
|
|
77
|
+
const patterns: [RegExp, string][] = [
|
|
78
|
+
[/```(?:typescript|tsx?)\b/i, "TypeScript"],
|
|
79
|
+
[/```(?:javascript|jsx?)\b/i, "JavaScript"],
|
|
80
|
+
[/```python\b/i, "Python"],
|
|
81
|
+
[/```rust\b/i, "Rust"],
|
|
82
|
+
[/```go\b/i, "Go"],
|
|
83
|
+
[/```java\b/i, "Java"],
|
|
84
|
+
[/```(?:c\+\+|cpp)\b/i, "C++"],
|
|
85
|
+
[/```c\b/i, "C"],
|
|
86
|
+
[/```ruby\b/i, "Ruby"],
|
|
87
|
+
[/```swift\b/i, "Swift"],
|
|
88
|
+
[/```kotlin\b/i, "Kotlin"],
|
|
89
|
+
[/```(?:bash|sh|shell|zsh)\b/i, "Shell"],
|
|
90
|
+
[/```sql\b/i, "SQL"],
|
|
91
|
+
[/```html\b/i, "HTML"],
|
|
92
|
+
[/```css\b/i, "CSS"],
|
|
93
|
+
[/```yaml\b/i, "YAML"],
|
|
94
|
+
[/```json\b/i, "JSON"],
|
|
95
|
+
[/```(?:dockerfile|docker)\b/i, "Docker"],
|
|
96
|
+
]
|
|
97
|
+
for (const [pattern, lang] of patterns) {
|
|
98
|
+
if (pattern.test(content)) langs.add(lang)
|
|
99
|
+
}
|
|
100
|
+
if (langs.size === 0) {
|
|
101
|
+
if (/\bimport\s+.*\s+from\s+['"]/.test(content)) langs.add("JavaScript/TypeScript")
|
|
102
|
+
if (/\bdef\s+\w+\s*\(/.test(content)) langs.add("Python")
|
|
103
|
+
if (/\bfn\s+\w+\s*\(/.test(content)) langs.add("Rust")
|
|
104
|
+
if (/\bfunc\s+\w+\s*\(/.test(content)) langs.add("Go")
|
|
105
|
+
}
|
|
106
|
+
return Array.from(langs)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function extractInsights(turns: ConversationTurn[]): string[] {
|
|
110
|
+
const insights: string[] = []
|
|
111
|
+
for (const turn of turns) {
|
|
112
|
+
if (turn.role !== "assistant") continue
|
|
113
|
+
const patterns = [
|
|
114
|
+
/(?:the (?:issue|problem|bug|root cause) (?:is|was))\s+(.{20,150})/i,
|
|
115
|
+
/(?:the (?:solution|fix|answer) (?:is|was))\s+(.{20,150})/i,
|
|
116
|
+
/(?:you (?:should|need to|can))\s+(.{20,150})/i,
|
|
117
|
+
/(?:this (?:happens|occurs) because)\s+(.{20,150})/i,
|
|
118
|
+
]
|
|
119
|
+
for (const pattern of patterns) {
|
|
120
|
+
const match = turn.content.match(pattern)
|
|
121
|
+
if (match?.[1]) insights.push(match[1].trim().replace(/\.$/, ""))
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return [...new Set(insights)].slice(0, 10)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function extractCodeSnippets(content: string): Array<{ language: string; code: string; context: string }> {
|
|
128
|
+
const snippets: Array<{ language: string; code: string; context: string }> = []
|
|
129
|
+
const regex = /```(\w*)\n([\s\S]*?)```/g
|
|
130
|
+
let match
|
|
131
|
+
while ((match = regex.exec(content)) !== null) {
|
|
132
|
+
const language = match[1] || "unknown"
|
|
133
|
+
const code = match[2].trim()
|
|
134
|
+
if (code.length < 10 || code.length > 2000) continue
|
|
135
|
+
const beforeIdx = Math.max(0, match.index - 200)
|
|
136
|
+
const context = content.slice(beforeIdx, match.index).trim().split("\n").pop() || ""
|
|
137
|
+
snippets.push({ language, code, context })
|
|
138
|
+
}
|
|
139
|
+
return snippets.slice(0, 10)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function extractProblems(humanContent: string): string[] {
|
|
143
|
+
const problems: string[] = []
|
|
144
|
+
for (const line of humanContent.split("\n")) {
|
|
145
|
+
const trimmed = line.trim()
|
|
146
|
+
if (trimmed.length < 15 || trimmed.length > 300) continue
|
|
147
|
+
if (
|
|
148
|
+
/\b(error|bug|issue|problem|broken|doesn't work|not working|failing|crash|wrong)\b/i.test(trimmed) &&
|
|
149
|
+
!trimmed.startsWith("//") &&
|
|
150
|
+
!trimmed.startsWith("#")
|
|
151
|
+
) {
|
|
152
|
+
problems.push(trimmed)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return [...new Set(problems)].slice(0, 5)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function extractSolutions(aiContent: string): string[] {
|
|
159
|
+
const solutions: string[] = []
|
|
160
|
+
for (const sentence of aiContent.split(/[.!]\s+/)) {
|
|
161
|
+
const trimmed = sentence.trim()
|
|
162
|
+
if (trimmed.length < 20 || trimmed.length > 300) continue
|
|
163
|
+
if (
|
|
164
|
+
/\b(fix|solve|solution|resolve|instead|should|try|change|update|replace|use)\b/i.test(trimmed) &&
|
|
165
|
+
!/\b(error|bug|issue|problem)\b/i.test(trimmed)
|
|
166
|
+
) {
|
|
167
|
+
solutions.push(trimmed)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return [...new Set(solutions)].slice(0, 5)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function suggestTitle(session: ParsedSession): string {
|
|
174
|
+
const allContent = session.turns.map((t) => t.content).join("\n")
|
|
175
|
+
const humanContent = session.turns.filter((t) => t.role === "human").map((t) => t.content).join("\n")
|
|
176
|
+
const problems = extractProblems(humanContent)
|
|
177
|
+
const solutions = extractSolutions(
|
|
178
|
+
session.turns.filter((t) => t.role === "assistant").map((t) => t.content).join("\n"),
|
|
179
|
+
)
|
|
180
|
+
const langs = detectLanguages(allContent)
|
|
181
|
+
const topics = extractTopics(allContent)
|
|
182
|
+
const langStr = langs.slice(0, 2).join("/") || "code"
|
|
183
|
+
const project = session.project || "my project"
|
|
184
|
+
|
|
185
|
+
if (problems.length > 0) {
|
|
186
|
+
const problem = problems[0].slice(0, 60).replace(/\n/g, " ")
|
|
187
|
+
return `Debugging ${langStr}: ${problem}`
|
|
188
|
+
}
|
|
189
|
+
if (solutions.length > 0) {
|
|
190
|
+
const solution = solutions[0].slice(0, 60).replace(/\n/g, " ")
|
|
191
|
+
return `How I ${solution.toLowerCase().replace(/^(you |we |i )?(should |need to |can )?/i, "")}`
|
|
192
|
+
}
|
|
193
|
+
if (topics.length > 0) {
|
|
194
|
+
const topicStr = topics.slice(0, 2).join(" + ")
|
|
195
|
+
return `Working with ${topicStr} in ${project}`
|
|
196
|
+
}
|
|
197
|
+
const firstHuman = session.turns.find((t) => t.role === "human")
|
|
198
|
+
if (firstHuman) {
|
|
199
|
+
const cleaned = firstHuman.content.split("\n")[0].trim().slice(0, 80)
|
|
200
|
+
if (cleaned.length > 15) return cleaned
|
|
201
|
+
}
|
|
202
|
+
return `${langStr} session: things I learned in ${project}`
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function suggestTags(content: string): string[] {
|
|
206
|
+
const tags: Set<string> = new Set()
|
|
207
|
+
const patterns: [RegExp, string][] = [
|
|
208
|
+
[/\breact\b/i, "react"],
|
|
209
|
+
[/\bnext\.?js\b/i, "nextjs"],
|
|
210
|
+
[/\btypescript\b/i, "typescript"],
|
|
211
|
+
[/\bpython\b/i, "python"],
|
|
212
|
+
[/\brust\b/i, "rust"],
|
|
213
|
+
[/\bdocker\b/i, "docker"],
|
|
214
|
+
[/\bprisma\b/i, "prisma"],
|
|
215
|
+
[/\btailwind\b/i, "tailwindcss"],
|
|
216
|
+
[/\bnode\.?js\b/i, "nodejs"],
|
|
217
|
+
[/\bgit\b/i, "git"],
|
|
218
|
+
[/\bpostgres\b/i, "postgresql"],
|
|
219
|
+
[/\bmongodb\b/i, "mongodb"],
|
|
220
|
+
[/\bredis\b/i, "redis"],
|
|
221
|
+
[/\baws\b/i, "aws"],
|
|
222
|
+
[/\bvue\b/i, "vue"],
|
|
223
|
+
[/\bangular\b/i, "angular"],
|
|
224
|
+
[/\bsvelte\b/i, "svelte"],
|
|
225
|
+
[/\bgraphql\b/i, "graphql"],
|
|
226
|
+
[/\bwebsocket\b/i, "websocket"],
|
|
227
|
+
]
|
|
228
|
+
for (const [pattern, tag] of patterns) {
|
|
229
|
+
if (pattern.test(content)) tags.add(tag)
|
|
230
|
+
}
|
|
231
|
+
if (/\b(bug|fix|error|debug)\b/i.test(content)) tags.add("bug-fix")
|
|
232
|
+
if (/\b(refactor|clean|restructure)\b/i.test(content)) tags.add("refactoring")
|
|
233
|
+
if (/\b(performance|optimize|speed|cache)\b/i.test(content)) tags.add("performance")
|
|
234
|
+
if (/\b(test|spec|coverage)\b/i.test(content)) tags.add("testing")
|
|
235
|
+
if (/\b(deploy|ci|cd|pipeline)\b/i.test(content)) tags.add("devops")
|
|
236
|
+
return Array.from(tags).slice(0, 8)
|
|
237
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import * as path from "path"
|
|
2
|
+
import * as fs from "fs"
|
|
3
|
+
import type { Scanner, Session, ParsedSession, ConversationTurn } from "./types"
|
|
4
|
+
import { getHome, getPlatform } from "./platform"
|
|
5
|
+
import { listFiles, listDirs, safeStats, readJsonl, extractProjectDescription } from "./fs-utils"
|
|
6
|
+
|
|
7
|
+
interface ClaudeMessage {
|
|
8
|
+
type: string
|
|
9
|
+
cwd?: string
|
|
10
|
+
message?: {
|
|
11
|
+
role?: string
|
|
12
|
+
content?: string | Array<{ type: string; text?: string }>
|
|
13
|
+
}
|
|
14
|
+
timestamp?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const claudeCodeScanner: Scanner = {
|
|
18
|
+
name: "Claude Code",
|
|
19
|
+
sourceType: "claude-code",
|
|
20
|
+
description: "Claude Code CLI sessions (~/.claude/projects/)",
|
|
21
|
+
|
|
22
|
+
getSessionDirs(): string[] {
|
|
23
|
+
const home = getHome()
|
|
24
|
+
const candidates = [path.join(home, ".claude", "projects")]
|
|
25
|
+
return candidates.filter((d) => {
|
|
26
|
+
try { return fs.existsSync(d) } catch { return false }
|
|
27
|
+
})
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
scan(limit: number): Session[] {
|
|
31
|
+
const sessions: Session[] = []
|
|
32
|
+
const dirs = this.getSessionDirs()
|
|
33
|
+
|
|
34
|
+
for (const baseDir of dirs) {
|
|
35
|
+
const projectDirs = listDirs(baseDir)
|
|
36
|
+
for (const projectDir of projectDirs) {
|
|
37
|
+
const project = path.basename(projectDir)
|
|
38
|
+
const files = listFiles(projectDir, [".jsonl"])
|
|
39
|
+
|
|
40
|
+
for (const filePath of files) {
|
|
41
|
+
const stats = safeStats(filePath)
|
|
42
|
+
if (!stats) continue
|
|
43
|
+
|
|
44
|
+
const lines = readJsonl<ClaudeMessage>(filePath)
|
|
45
|
+
if (lines.length < 3) continue
|
|
46
|
+
|
|
47
|
+
const humanMsgs = lines.filter((l) => l.type === "user")
|
|
48
|
+
const aiMsgs = lines.filter((l) => l.type === "assistant")
|
|
49
|
+
|
|
50
|
+
const cwdLine = lines.find((l) => l.cwd)
|
|
51
|
+
let projectPath = cwdLine?.cwd || null
|
|
52
|
+
|
|
53
|
+
if (!projectPath && project.startsWith("-")) {
|
|
54
|
+
projectPath = decodeClaudeProjectDir(project)
|
|
55
|
+
}
|
|
56
|
+
const projectName = projectPath ? path.basename(projectPath) : project
|
|
57
|
+
|
|
58
|
+
const projectDescription = projectPath ? extractProjectDescription(projectPath) : null
|
|
59
|
+
|
|
60
|
+
let preview = ""
|
|
61
|
+
for (const msg of humanMsgs.slice(0, 8)) {
|
|
62
|
+
const content = extractContent(msg)
|
|
63
|
+
if (!content || content.length < 10) continue
|
|
64
|
+
if (content.startsWith("<local-command-caveat>")) continue
|
|
65
|
+
if (content.startsWith("<environment_context>")) continue
|
|
66
|
+
if (content.startsWith("<command-name>")) continue
|
|
67
|
+
preview = content.slice(0, 200)
|
|
68
|
+
break
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
sessions.push({
|
|
72
|
+
id: path.basename(filePath, ".jsonl"),
|
|
73
|
+
source: "claude-code",
|
|
74
|
+
project: projectName,
|
|
75
|
+
projectPath: projectPath || undefined,
|
|
76
|
+
projectDescription: projectDescription || undefined,
|
|
77
|
+
title: preview.slice(0, 80) || `Claude session in ${projectName}`,
|
|
78
|
+
messageCount: humanMsgs.length + aiMsgs.length,
|
|
79
|
+
humanMessages: humanMsgs.length,
|
|
80
|
+
aiMessages: aiMsgs.length,
|
|
81
|
+
preview: preview || "(no preview)",
|
|
82
|
+
filePath,
|
|
83
|
+
modifiedAt: stats.mtime,
|
|
84
|
+
sizeBytes: stats.size,
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
sessions.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime())
|
|
91
|
+
return sessions.slice(0, limit)
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
parse(filePath: string, maxTurns?: number): ParsedSession | null {
|
|
95
|
+
const lines = readJsonl<ClaudeMessage>(filePath)
|
|
96
|
+
if (lines.length === 0) return null
|
|
97
|
+
|
|
98
|
+
const stats = safeStats(filePath)
|
|
99
|
+
const turns: ConversationTurn[] = []
|
|
100
|
+
|
|
101
|
+
const cwdLine = lines.find((l) => l.cwd)
|
|
102
|
+
const projectPath = cwdLine?.cwd || undefined
|
|
103
|
+
const projectName = projectPath ? path.basename(projectPath) : path.basename(path.dirname(filePath))
|
|
104
|
+
const projectDescription = projectPath ? extractProjectDescription(projectPath) || undefined : undefined
|
|
105
|
+
|
|
106
|
+
for (const line of lines) {
|
|
107
|
+
if (maxTurns && turns.length >= maxTurns) break
|
|
108
|
+
if (line.type !== "user" && line.type !== "assistant") continue
|
|
109
|
+
const content = extractContent(line)
|
|
110
|
+
if (!content) continue
|
|
111
|
+
turns.push({
|
|
112
|
+
role: line.type === "user" ? "human" : "assistant",
|
|
113
|
+
content,
|
|
114
|
+
timestamp: line.timestamp ? new Date(line.timestamp) : undefined,
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const humanMsgs = turns.filter((t) => t.role === "human")
|
|
119
|
+
const aiMsgs = turns.filter((t) => t.role === "assistant")
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
id: path.basename(filePath, ".jsonl"),
|
|
123
|
+
source: "claude-code",
|
|
124
|
+
project: projectName,
|
|
125
|
+
projectPath,
|
|
126
|
+
projectDescription,
|
|
127
|
+
title: humanMsgs[0]?.content.slice(0, 80) || "Claude session",
|
|
128
|
+
messageCount: turns.length,
|
|
129
|
+
humanMessages: humanMsgs.length,
|
|
130
|
+
aiMessages: aiMsgs.length,
|
|
131
|
+
preview: humanMsgs[0]?.content.slice(0, 200) || "",
|
|
132
|
+
filePath,
|
|
133
|
+
modifiedAt: stats?.mtime || new Date(),
|
|
134
|
+
sizeBytes: stats?.size || 0,
|
|
135
|
+
turns,
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function decodeClaudeProjectDir(dirName: string): string | null {
|
|
141
|
+
const platform = getPlatform()
|
|
142
|
+
const stripped = dirName.startsWith("-") ? dirName.slice(1) : dirName
|
|
143
|
+
const parts = stripped.split("-")
|
|
144
|
+
let currentPath = ""
|
|
145
|
+
let i = 0
|
|
146
|
+
|
|
147
|
+
if (platform === "windows" && parts.length > 0 && /^[a-zA-Z]$/.test(parts[0])) {
|
|
148
|
+
currentPath = parts[0].toUpperCase() + ":"
|
|
149
|
+
i = 1
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
while (i < parts.length) {
|
|
153
|
+
let bestMatch = ""
|
|
154
|
+
let bestLen = 0
|
|
155
|
+
for (let end = parts.length; end > i; end--) {
|
|
156
|
+
const segment = parts.slice(i, end).join("-")
|
|
157
|
+
const candidate = currentPath + path.sep + segment
|
|
158
|
+
try {
|
|
159
|
+
if (fs.existsSync(candidate)) {
|
|
160
|
+
bestMatch = candidate
|
|
161
|
+
bestLen = end - i
|
|
162
|
+
break
|
|
163
|
+
}
|
|
164
|
+
} catch { /* ignore */ }
|
|
165
|
+
}
|
|
166
|
+
if (bestLen > 0) {
|
|
167
|
+
currentPath = bestMatch
|
|
168
|
+
i += bestLen
|
|
169
|
+
} else {
|
|
170
|
+
currentPath += path.sep + parts[i]
|
|
171
|
+
i++
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return currentPath || null
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function extractContent(msg: ClaudeMessage): string {
|
|
179
|
+
if (!msg.message?.content) return ""
|
|
180
|
+
if (typeof msg.message.content === "string") return msg.message.content
|
|
181
|
+
if (Array.isArray(msg.message.content)) {
|
|
182
|
+
return msg.message.content
|
|
183
|
+
.filter((c) => c.type === "text" && c.text)
|
|
184
|
+
.map((c) => c.text!)
|
|
185
|
+
.join("\n")
|
|
186
|
+
}
|
|
187
|
+
return ""
|
|
188
|
+
}
|