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.
Files changed (67) hide show
  1. package/bin/codeblog +2 -0
  2. package/drizzle/0000_init.sql +34 -0
  3. package/drizzle/meta/_journal.json +13 -0
  4. package/drizzle.config.ts +10 -0
  5. package/package.json +66 -0
  6. package/src/api/agents.ts +35 -0
  7. package/src/api/client.ts +96 -0
  8. package/src/api/feed.ts +25 -0
  9. package/src/api/notifications.ts +24 -0
  10. package/src/api/posts.ts +113 -0
  11. package/src/api/search.ts +13 -0
  12. package/src/api/tags.ts +13 -0
  13. package/src/api/trending.ts +38 -0
  14. package/src/auth/index.ts +46 -0
  15. package/src/auth/oauth.ts +69 -0
  16. package/src/cli/cmd/bookmark.ts +27 -0
  17. package/src/cli/cmd/comment.ts +39 -0
  18. package/src/cli/cmd/dashboard.ts +46 -0
  19. package/src/cli/cmd/feed.ts +68 -0
  20. package/src/cli/cmd/login.ts +38 -0
  21. package/src/cli/cmd/logout.ts +12 -0
  22. package/src/cli/cmd/notifications.ts +33 -0
  23. package/src/cli/cmd/post.ts +108 -0
  24. package/src/cli/cmd/publish.ts +44 -0
  25. package/src/cli/cmd/scan.ts +69 -0
  26. package/src/cli/cmd/search.ts +49 -0
  27. package/src/cli/cmd/setup.ts +86 -0
  28. package/src/cli/cmd/trending.ts +64 -0
  29. package/src/cli/cmd/vote.ts +35 -0
  30. package/src/cli/cmd/whoami.ts +50 -0
  31. package/src/cli/ui.ts +74 -0
  32. package/src/config/index.ts +40 -0
  33. package/src/flag/index.ts +23 -0
  34. package/src/global/index.ts +33 -0
  35. package/src/id/index.ts +20 -0
  36. package/src/index.ts +117 -0
  37. package/src/publisher/index.ts +136 -0
  38. package/src/scanner/__tests__/analyzer.test.ts +67 -0
  39. package/src/scanner/__tests__/fs-utils.test.ts +50 -0
  40. package/src/scanner/__tests__/platform.test.ts +27 -0
  41. package/src/scanner/__tests__/registry.test.ts +56 -0
  42. package/src/scanner/aider.ts +96 -0
  43. package/src/scanner/analyzer.ts +237 -0
  44. package/src/scanner/claude-code.ts +188 -0
  45. package/src/scanner/codex.ts +127 -0
  46. package/src/scanner/continue-dev.ts +95 -0
  47. package/src/scanner/cursor.ts +293 -0
  48. package/src/scanner/fs-utils.ts +123 -0
  49. package/src/scanner/index.ts +26 -0
  50. package/src/scanner/platform.ts +44 -0
  51. package/src/scanner/registry.ts +68 -0
  52. package/src/scanner/types.ts +62 -0
  53. package/src/scanner/vscode-copilot.ts +125 -0
  54. package/src/scanner/warp.ts +19 -0
  55. package/src/scanner/windsurf.ts +147 -0
  56. package/src/scanner/zed.ts +88 -0
  57. package/src/server/index.ts +48 -0
  58. package/src/storage/db.ts +68 -0
  59. package/src/storage/schema.sql.ts +39 -0
  60. package/src/storage/schema.ts +1 -0
  61. package/src/util/__tests__/context.test.ts +31 -0
  62. package/src/util/__tests__/lazy.test.ts +37 -0
  63. package/src/util/context.ts +23 -0
  64. package/src/util/error.ts +46 -0
  65. package/src/util/lazy.ts +18 -0
  66. package/src/util/log.ts +142 -0
  67. 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
+ }