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,127 @@
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, safeStats, readJsonl, extractProjectDescription } from "./fs-utils"
6
+
7
+ interface CodexLine {
8
+ timestamp?: string
9
+ type?: string
10
+ payload?: {
11
+ type?: string
12
+ role?: string
13
+ content?: Array<{ type?: string; text?: string }>
14
+ cwd?: string
15
+ }
16
+ }
17
+
18
+ export const codexScanner: Scanner = {
19
+ name: "Codex (OpenAI CLI)",
20
+ sourceType: "codex",
21
+ description: "OpenAI Codex CLI sessions (~/.codex/)",
22
+
23
+ getSessionDirs(): string[] {
24
+ const home = getHome()
25
+ const candidates = [path.join(home, ".codex", "sessions"), path.join(home, ".codex", "archived_sessions")]
26
+ return candidates.filter((d) => {
27
+ try { return fs.existsSync(d) } catch { return false }
28
+ })
29
+ },
30
+
31
+ scan(limit: number): Session[] {
32
+ const sessions: Session[] = []
33
+ for (const dir of this.getSessionDirs()) {
34
+ const files = listFiles(dir, [".jsonl"], true)
35
+ for (const filePath of files) {
36
+ const stats = safeStats(filePath)
37
+ if (!stats) continue
38
+ const lines = readJsonl<CodexLine>(filePath)
39
+ if (lines.length < 3) continue
40
+ const messageTurns = extractCodexTurns(lines)
41
+ const humanMsgs = messageTurns.filter((t) => t.role === "human")
42
+ const aiMsgs = messageTurns.filter((t) => t.role === "assistant")
43
+ const startLine = lines.find((l) => l.payload?.cwd)
44
+ const projectPath = startLine?.payload?.cwd || null
45
+ const project = projectPath ? path.basename(projectPath) : path.basename(dir)
46
+ const projectDescription = projectPath ? extractProjectDescription(projectPath) : null
47
+ const preview = humanMsgs[0]?.content.slice(0, 200) || "(codex session)"
48
+
49
+ sessions.push({
50
+ id: path.basename(filePath, ".jsonl"),
51
+ source: "codex",
52
+ project,
53
+ projectPath: projectPath || undefined,
54
+ projectDescription: projectDescription || undefined,
55
+ title: preview.slice(0, 80) || "Codex session",
56
+ messageCount: messageTurns.length,
57
+ humanMessages: humanMsgs.length,
58
+ aiMessages: aiMsgs.length,
59
+ preview,
60
+ filePath,
61
+ modifiedAt: stats.mtime,
62
+ sizeBytes: stats.size,
63
+ })
64
+ }
65
+ }
66
+ sessions.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime())
67
+ return sessions.slice(0, limit)
68
+ },
69
+
70
+ parse(filePath: string, maxTurns?: number): ParsedSession | null {
71
+ const lines = readJsonl<CodexLine>(filePath)
72
+ if (lines.length === 0) return null
73
+ const stats = safeStats(filePath)
74
+ const allTurns = extractCodexTurns(lines)
75
+ const turns = maxTurns ? allTurns.slice(0, maxTurns) : allTurns
76
+ if (turns.length === 0) return null
77
+ const humanMsgs = turns.filter((t) => t.role === "human")
78
+ const aiMsgs = turns.filter((t) => t.role === "assistant")
79
+ const startLine = lines.find((l) => l.payload?.cwd)
80
+ const projectPath = startLine?.payload?.cwd || undefined
81
+ const project = projectPath ? path.basename(projectPath) : path.basename(path.dirname(filePath))
82
+ const projectDescription = projectPath ? extractProjectDescription(projectPath) || undefined : undefined
83
+
84
+ return {
85
+ id: path.basename(filePath, ".jsonl"),
86
+ source: "codex",
87
+ project,
88
+ projectPath,
89
+ projectDescription,
90
+ title: humanMsgs[0]?.content.slice(0, 80) || "Codex session",
91
+ messageCount: turns.length,
92
+ humanMessages: humanMsgs.length,
93
+ aiMessages: aiMsgs.length,
94
+ preview: humanMsgs[0]?.content.slice(0, 200) || "",
95
+ filePath,
96
+ modifiedAt: stats?.mtime || new Date(),
97
+ sizeBytes: stats?.size || 0,
98
+ turns,
99
+ }
100
+ },
101
+ }
102
+
103
+ function extractCodexTurns(lines: CodexLine[]): ConversationTurn[] {
104
+ const turns: ConversationTurn[] = []
105
+ for (const line of lines) {
106
+ if (!line.payload || line.payload.type !== "message") continue
107
+ const textParts = (line.payload.content || []).filter((c) => c.text).map((c) => c.text || "").filter(Boolean)
108
+ const content = textParts.join("\n").trim()
109
+ if (!content) continue
110
+ if (line.payload.role === "developer" || line.payload.role === "system") continue
111
+ if (
112
+ line.payload.role === "user" &&
113
+ (content.startsWith("# AGENTS.md") ||
114
+ content.startsWith("<environment_context>") ||
115
+ content.startsWith("<permissions") ||
116
+ content.startsWith("<app-context>") ||
117
+ content.startsWith("<collaboration_mode>"))
118
+ )
119
+ continue
120
+ turns.push({
121
+ role: line.payload.role === "user" ? "human" : "assistant",
122
+ content,
123
+ timestamp: line.timestamp ? new Date(line.timestamp) : undefined,
124
+ })
125
+ }
126
+ return turns
127
+ }
@@ -0,0 +1,95 @@
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, safeReadJson, safeStats } from "./fs-utils"
6
+
7
+ export const continueDevScanner: Scanner = {
8
+ name: "Continue.dev",
9
+ sourceType: "continue",
10
+ description: "Continue.dev AI coding assistant sessions",
11
+
12
+ getSessionDirs(): string[] {
13
+ const home = getHome()
14
+ const platform = getPlatform()
15
+ const candidates: string[] = [path.join(home, ".continue", "sessions")]
16
+ if (platform === "macos") {
17
+ candidates.push(path.join(home, "Library", "Application Support", "Continue", "sessions"))
18
+ } else if (platform === "windows") {
19
+ const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming")
20
+ candidates.push(path.join(appData, "Continue", "sessions"))
21
+ } else {
22
+ candidates.push(path.join(home, ".config", "continue", "sessions"))
23
+ }
24
+ return candidates.filter((d) => {
25
+ try { return fs.existsSync(d) } catch { return false }
26
+ })
27
+ },
28
+
29
+ scan(limit: number): Session[] {
30
+ const sessions: Session[] = []
31
+ for (const dir of this.getSessionDirs()) {
32
+ for (const filePath of listFiles(dir, [".json"])) {
33
+ const stats = safeStats(filePath)
34
+ if (!stats || stats.size < 100) continue
35
+ const data = safeReadJson<Record<string, unknown>>(filePath)
36
+ if (!data) continue
37
+ const turns = extractContinueTurns(data)
38
+ if (turns.length < 2) continue
39
+ const humanMsgs = turns.filter((t) => t.role === "human")
40
+ const preview = humanMsgs[0]?.content.slice(0, 200) || "(continue session)"
41
+ sessions.push({
42
+ id: path.basename(filePath, ".json"), source: "continue",
43
+ project: (data.workspacePath as string) || path.basename(path.dirname(filePath)),
44
+ title: (data.title as string) || preview.slice(0, 80),
45
+ messageCount: turns.length, humanMessages: humanMsgs.length, aiMessages: turns.length - humanMsgs.length,
46
+ preview, filePath, modifiedAt: stats.mtime, sizeBytes: stats.size,
47
+ })
48
+ }
49
+ }
50
+ sessions.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime())
51
+ return sessions.slice(0, limit)
52
+ },
53
+
54
+ parse(filePath: string, maxTurns?: number): ParsedSession | null {
55
+ const data = safeReadJson<Record<string, unknown>>(filePath)
56
+ if (!data) return null
57
+ const stats = safeStats(filePath)
58
+ const turns = extractContinueTurns(data, maxTurns)
59
+ if (turns.length === 0) return null
60
+ const humanMsgs = turns.filter((t) => t.role === "human")
61
+ const aiMsgs = turns.filter((t) => t.role === "assistant")
62
+ return {
63
+ id: path.basename(filePath, ".json"), source: "continue",
64
+ project: (data.workspacePath as string) || path.basename(path.dirname(filePath)),
65
+ title: (data.title as string) || humanMsgs[0]?.content.slice(0, 80) || "Continue session",
66
+ messageCount: turns.length, humanMessages: humanMsgs.length, aiMessages: aiMsgs.length,
67
+ preview: humanMsgs[0]?.content.slice(0, 200) || "",
68
+ filePath, modifiedAt: stats?.mtime || new Date(), sizeBytes: stats?.size || 0, turns,
69
+ }
70
+ },
71
+ }
72
+
73
+ function extractContinueTurns(data: Record<string, unknown>, maxTurns?: number): ConversationTurn[] {
74
+ const turns: ConversationTurn[] = []
75
+ for (const arr of [data.history, data.messages, data.steps]) {
76
+ if (!Array.isArray(arr)) continue
77
+ for (const msg of arr) {
78
+ if (maxTurns && turns.length >= maxTurns) break
79
+ if (!msg || typeof msg !== "object") continue
80
+ const m = msg as Record<string, unknown>
81
+ if (m.name === "UserInput" && typeof m.description === "string") {
82
+ turns.push({ role: "human", content: m.description }); continue
83
+ }
84
+ if (m.name === "DefaultModelEditCodeStep" || m.name === "ChatModelResponse") {
85
+ if (typeof m.description === "string") turns.push({ role: "assistant", content: m.description })
86
+ continue
87
+ }
88
+ const content = (m.content || m.text || m.message) as string | undefined
89
+ if (typeof content !== "string") continue
90
+ turns.push({ role: m.role === "user" ? "human" : "assistant", content })
91
+ }
92
+ if (turns.length > 0) return turns
93
+ }
94
+ return turns
95
+ }
@@ -0,0 +1,293 @@
1
+ import * as path from "path"
2
+ import * as fs from "fs"
3
+ import { Database as BunDatabase } from "bun:sqlite"
4
+ import type { Scanner, Session, ParsedSession, ConversationTurn } from "./types"
5
+ import { getHome, getPlatform } from "./platform"
6
+ import { listFiles, listDirs, safeReadFile, safeReadJson, safeStats, extractProjectDescription } from "./fs-utils"
7
+
8
+ const VSCDB_SEP = "|"
9
+
10
+ function makeVscdbPath(dbPath: string, composerId: string): string {
11
+ return `vscdb:${dbPath}${VSCDB_SEP}${composerId}`
12
+ }
13
+
14
+ function parseVscdbVirtualPath(virtualPath: string): { dbPath: string; composerId: string } | null {
15
+ const prefix = "vscdb:"
16
+ if (!virtualPath.startsWith(prefix)) return null
17
+ const rest = virtualPath.slice(prefix.length)
18
+ const sepIdx = rest.lastIndexOf(VSCDB_SEP)
19
+ if (sepIdx <= 0) return null
20
+ return { dbPath: rest.slice(0, sepIdx), composerId: rest.slice(sepIdx + 1) }
21
+ }
22
+
23
+ function withDb<T>(dbPath: string, fn: (db: BunDatabase) => T, fallback: T): T {
24
+ try {
25
+ const db = new BunDatabase(dbPath, { readonly: true })
26
+ try { return fn(db) } finally { db.close() }
27
+ } catch (err) {
28
+ console.error(`[codeblog] Cursor DB error:`, err instanceof Error ? err.message : err)
29
+ return fallback
30
+ }
31
+ }
32
+
33
+ function safeQueryDb<T>(db: BunDatabase, sql: string, params: unknown[] = []): T[] {
34
+ try {
35
+ return db.prepare(sql).all(...params) as T[]
36
+ } catch (err) {
37
+ console.error(`[codeblog] Cursor query error:`, err instanceof Error ? err.message : err)
38
+ return []
39
+ }
40
+ }
41
+
42
+ function getGlobalStoragePath(): string | null {
43
+ const home = getHome()
44
+ const platform = getPlatform()
45
+ let p: string
46
+ if (platform === "macos") {
47
+ p = path.join(home, "Library", "Application Support", "Cursor", "User", "globalStorage", "state.vscdb")
48
+ } else if (platform === "windows") {
49
+ const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming")
50
+ p = path.join(appData, "Cursor", "User", "globalStorage", "state.vscdb")
51
+ } else {
52
+ p = path.join(home, ".config", "Cursor", "User", "globalStorage", "state.vscdb")
53
+ }
54
+ try { return fs.existsSync(p) ? p : null } catch { return null }
55
+ }
56
+
57
+ interface CursorChatSession {
58
+ version?: number
59
+ requests: Array<{ message: string; response?: string | unknown[] }>
60
+ sessionId?: string
61
+ }
62
+
63
+ interface CursorComposerData {
64
+ composerId?: string
65
+ name?: string
66
+ createdAt?: number
67
+ lastUpdatedAt?: number
68
+ fullConversationHeadersOnly?: Array<{ bubbleId: string; type: number }>
69
+ }
70
+
71
+ export const cursorScanner: Scanner = {
72
+ name: "Cursor",
73
+ sourceType: "cursor",
74
+ description: "Cursor AI IDE sessions (agent transcripts + chat sessions + composer)",
75
+
76
+ getSessionDirs(): string[] {
77
+ const home = getHome()
78
+ const platform = getPlatform()
79
+ const candidates: string[] = [path.join(home, ".cursor", "projects")]
80
+
81
+ if (platform === "macos") {
82
+ candidates.push(path.join(home, "Library", "Application Support", "Cursor", "User", "workspaceStorage"))
83
+ } else if (platform === "windows") {
84
+ const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming")
85
+ candidates.push(path.join(appData, "Cursor", "User", "workspaceStorage"))
86
+ } else {
87
+ candidates.push(path.join(home, ".config", "Cursor", "User", "workspaceStorage"))
88
+ }
89
+
90
+ const globalDb = getGlobalStoragePath()
91
+ if (globalDb) candidates.push(path.dirname(globalDb))
92
+
93
+ return candidates.filter((d) => {
94
+ try { return fs.existsSync(d) } catch { return false }
95
+ })
96
+ },
97
+
98
+ scan(limit: number): Session[] {
99
+ const sessions: Session[] = []
100
+ const dirs = this.getSessionDirs()
101
+ const seenIds = new Set<string>()
102
+
103
+ for (const baseDir of dirs) {
104
+ if (baseDir.endsWith("globalStorage")) continue
105
+ const projectDirs = listDirs(baseDir)
106
+ for (const projectDir of projectDirs) {
107
+ const dirName = path.basename(projectDir)
108
+ let projectPath: string | undefined
109
+ const workspaceJson = safeReadJson<{ folder?: string }>(path.join(projectDir, "workspace.json"))
110
+ if (workspaceJson?.folder) {
111
+ try { projectPath = decodeURIComponent(new URL(workspaceJson.folder).pathname) } catch { /* */ }
112
+ }
113
+ const project = projectPath ? path.basename(projectPath) : dirName
114
+ const projectDescription = projectPath ? extractProjectDescription(projectPath) || undefined : undefined
115
+
116
+ // FORMAT 1: agent-transcripts
117
+ for (const filePath of listFiles(path.join(projectDir, "agent-transcripts"), [".txt"])) {
118
+ const stats = safeStats(filePath)
119
+ if (!stats) continue
120
+ const content = safeReadFile(filePath)
121
+ if (!content || content.length < 100) continue
122
+ const userQueries = content.match(/<user_query>\n?([\s\S]*?)\n?<\/user_query>/g) || []
123
+ if (userQueries.length === 0) continue
124
+ const firstQuery = content.match(/<user_query>\n?([\s\S]*?)\n?<\/user_query>/)
125
+ const preview = firstQuery ? firstQuery[1].trim().slice(0, 200) : content.slice(0, 200)
126
+ const id = path.basename(filePath, ".txt")
127
+ seenIds.add(id)
128
+ sessions.push({
129
+ id, source: "cursor", project, projectPath, projectDescription,
130
+ title: preview.slice(0, 80) || `Cursor session in ${project}`,
131
+ messageCount: userQueries.length * 2, humanMessages: userQueries.length, aiMessages: userQueries.length,
132
+ preview, filePath, modifiedAt: stats.mtime, sizeBytes: stats.size,
133
+ })
134
+ }
135
+
136
+ // FORMAT 2: chatSessions
137
+ for (const filePath of listFiles(path.join(projectDir, "chatSessions"), [".json"])) {
138
+ const stats = safeStats(filePath)
139
+ if (!stats || stats.size < 100) continue
140
+ const data = safeReadJson<CursorChatSession>(filePath)
141
+ if (!data || !Array.isArray(data.requests) || data.requests.length === 0) continue
142
+ const humanCount = data.requests.length
143
+ const firstMsg = data.requests[0]?.message || ""
144
+ const preview = (typeof firstMsg === "string" ? firstMsg : "").slice(0, 200)
145
+ const id = data.sessionId || path.basename(filePath, ".json")
146
+ seenIds.add(id)
147
+ sessions.push({
148
+ id, source: "cursor", project, projectPath, projectDescription,
149
+ title: preview.slice(0, 80) || `Cursor chat in ${project}`,
150
+ messageCount: humanCount * 2, humanMessages: humanCount, aiMessages: humanCount,
151
+ preview: preview || "(cursor chat session)", filePath, modifiedAt: stats.mtime, sizeBytes: stats.size,
152
+ })
153
+ }
154
+ }
155
+ }
156
+
157
+ // FORMAT 3: globalStorage state.vscdb
158
+ const globalDb = getGlobalStoragePath()
159
+ if (globalDb) {
160
+ withDb(globalDb, (db) => {
161
+ const rows = safeQueryDb<{ key: string; value: string }>(
162
+ db, "SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'",
163
+ )
164
+ for (const row of rows) {
165
+ try {
166
+ const data = JSON.parse(row.value) as CursorComposerData
167
+ const composerId = data.composerId || row.key.replace("composerData:", "")
168
+ if (seenIds.has(composerId)) continue
169
+ const bubbleHeaders = data.fullConversationHeadersOnly || []
170
+ if (bubbleHeaders.length === 0) continue
171
+ const humanCount = bubbleHeaders.filter((b) => b.type === 1).length
172
+ const aiCount = bubbleHeaders.filter((b) => b.type === 2).length
173
+ let preview = data.name || ""
174
+ if (!preview) {
175
+ const firstUserBubble = bubbleHeaders.find((b) => b.type === 1)
176
+ if (firstUserBubble) {
177
+ const bubbleRow = safeQueryDb<{ value: string }>(
178
+ db, "SELECT value FROM cursorDiskKV WHERE key = ?",
179
+ [`bubbleId:${composerId}:${firstUserBubble.bubbleId}`],
180
+ )
181
+ if (bubbleRow.length > 0) {
182
+ try {
183
+ const bubble = JSON.parse(bubbleRow[0].value)
184
+ preview = (bubble.text || bubble.message || "").slice(0, 200)
185
+ } catch { /* */ }
186
+ }
187
+ }
188
+ }
189
+ const createdAt = data.createdAt ? new Date(data.createdAt) : new Date()
190
+ const updatedAt = data.lastUpdatedAt ? new Date(data.lastUpdatedAt) : createdAt
191
+ sessions.push({
192
+ id: composerId, source: "cursor", project: "Cursor Composer",
193
+ title: (data.name || preview || "Cursor composer session").slice(0, 80),
194
+ messageCount: humanCount + aiCount, humanMessages: humanCount, aiMessages: aiCount,
195
+ preview: preview || "(composer session)",
196
+ filePath: makeVscdbPath(globalDb, composerId), modifiedAt: updatedAt, sizeBytes: row.value.length,
197
+ })
198
+ } catch { /* skip */ }
199
+ }
200
+ }, undefined)
201
+ }
202
+
203
+ sessions.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime())
204
+ return sessions.slice(0, limit)
205
+ },
206
+
207
+ parse(filePath: string, maxTurns?: number): ParsedSession | null {
208
+ if (filePath.startsWith("vscdb:")) return parseVscdbSession(filePath, maxTurns)
209
+ const stats = safeStats(filePath)
210
+ const turns: ConversationTurn[] = []
211
+
212
+ if (filePath.endsWith(".txt")) {
213
+ const content = safeReadFile(filePath)
214
+ if (!content) return null
215
+ const blocks = content.split(/^user:\s*$/m)
216
+ for (const block of blocks) {
217
+ if (!block.trim()) continue
218
+ if (maxTurns && turns.length >= maxTurns) break
219
+ const queryMatch = block.match(/<user_query>\n?([\s\S]*?)\n?<\/user_query>/)
220
+ if (queryMatch) turns.push({ role: "human", content: queryMatch[1].trim() })
221
+ const afterQuery = block.split(/<\/user_query>/)[1]
222
+ if (afterQuery) {
223
+ const aiContent = afterQuery.replace(/^\s*\n\s*A:\s*\n?/, "").trim()
224
+ if (aiContent && (!maxTurns || turns.length < maxTurns)) turns.push({ role: "assistant", content: aiContent })
225
+ }
226
+ }
227
+ } else {
228
+ const data = safeReadJson<CursorChatSession>(filePath)
229
+ if (!data || !Array.isArray(data.requests)) return null
230
+ for (const req of data.requests) {
231
+ if (maxTurns && turns.length >= maxTurns) break
232
+ if (req.message) turns.push({ role: "human", content: typeof req.message === "string" ? req.message : JSON.stringify(req.message) })
233
+ if (maxTurns && turns.length >= maxTurns) break
234
+ if (req.response) {
235
+ let respText = ""
236
+ if (typeof req.response === "string") respText = req.response
237
+ else if (Array.isArray(req.response)) {
238
+ respText = req.response.map((r: unknown) => (typeof r === "string" ? r : (r as Record<string, unknown>)?.text || "")).join("")
239
+ }
240
+ if (respText.trim()) turns.push({ role: "assistant", content: respText.trim() })
241
+ }
242
+ }
243
+ }
244
+
245
+ if (turns.length === 0) return null
246
+ const humanMsgs = turns.filter((t) => t.role === "human")
247
+ const aiMsgs = turns.filter((t) => t.role === "assistant")
248
+ return {
249
+ id: path.basename(filePath).replace(/\.\w+$/, ""), source: "cursor",
250
+ project: path.basename(path.dirname(filePath)),
251
+ title: humanMsgs[0]?.content.slice(0, 80) || "Cursor session",
252
+ messageCount: turns.length, humanMessages: humanMsgs.length, aiMessages: aiMsgs.length,
253
+ preview: humanMsgs[0]?.content.slice(0, 200) || "",
254
+ filePath, modifiedAt: stats?.mtime || new Date(), sizeBytes: stats?.size || 0, turns,
255
+ }
256
+ },
257
+ }
258
+
259
+ function parseVscdbSession(virtualPath: string, maxTurns?: number): ParsedSession | null {
260
+ const parsed = parseVscdbVirtualPath(virtualPath)
261
+ if (!parsed) return null
262
+ return withDb(parsed.dbPath, (db) => {
263
+ const metaRows = safeQueryDb<{ value: string }>(db, "SELECT value FROM cursorDiskKV WHERE key = ?", [`composerData:${parsed.composerId}`])
264
+ if (metaRows.length === 0) return null
265
+ let composerData: CursorComposerData
266
+ try { composerData = JSON.parse(metaRows[0].value) } catch { return null }
267
+ const bubbleHeaders = composerData.fullConversationHeadersOnly || []
268
+ if (bubbleHeaders.length === 0) return null
269
+ const turns: ConversationTurn[] = []
270
+ for (const header of bubbleHeaders) {
271
+ if (maxTurns && turns.length >= maxTurns) break
272
+ const bubbleRows = safeQueryDb<{ value: string }>(db, "SELECT value FROM cursorDiskKV WHERE key = ?", [`bubbleId:${parsed.composerId}:${header.bubbleId}`])
273
+ if (bubbleRows.length === 0) continue
274
+ try {
275
+ const bubble = JSON.parse(bubbleRows[0].value)
276
+ const text = bubble.text || bubble.message || bubble.rawText || ""
277
+ if (!text && header.type === 2) { turns.push({ role: "assistant", content: "(AI response)" }); continue }
278
+ turns.push({ role: header.type === 1 ? "human" : "assistant", content: text || "(empty)" })
279
+ } catch { /* skip */ }
280
+ }
281
+ if (turns.length === 0) return null
282
+ const humanMsgs = turns.filter((t) => t.role === "human")
283
+ const aiMsgs = turns.filter((t) => t.role === "assistant")
284
+ return {
285
+ id: parsed.composerId, source: "cursor" as const, project: "Cursor Composer",
286
+ title: composerData.name || humanMsgs[0]?.content.slice(0, 80) || "Cursor session",
287
+ messageCount: turns.length, humanMessages: humanMsgs.length, aiMessages: aiMsgs.length,
288
+ preview: humanMsgs[0]?.content.slice(0, 200) || "",
289
+ filePath: virtualPath, modifiedAt: composerData.lastUpdatedAt ? new Date(composerData.lastUpdatedAt) : new Date(),
290
+ sizeBytes: 0, turns,
291
+ } as ParsedSession
292
+ }, null)
293
+ }
@@ -0,0 +1,123 @@
1
+ import * as fs from "fs"
2
+ import * as path from "path"
3
+
4
+ export function safeReadFile(filePath: string): string | null {
5
+ try {
6
+ return fs.readFileSync(filePath, "utf-8")
7
+ } catch {
8
+ return null
9
+ }
10
+ }
11
+
12
+ export function safeReadJson<T = unknown>(filePath: string): T | null {
13
+ const content = safeReadFile(filePath)
14
+ if (!content) return null
15
+ try {
16
+ return JSON.parse(content) as T
17
+ } catch {
18
+ return null
19
+ }
20
+ }
21
+
22
+ export function safeStats(filePath: string): fs.Stats | null {
23
+ try {
24
+ return fs.statSync(filePath)
25
+ } catch {
26
+ return null
27
+ }
28
+ }
29
+
30
+ export function listFiles(dir: string, extensions?: string[], recursive = false): string[] {
31
+ if (!fs.existsSync(dir)) return []
32
+ const results: string[] = []
33
+ try {
34
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
35
+ for (const entry of entries) {
36
+ const fullPath = path.join(dir, entry.name)
37
+ if (entry.isFile()) {
38
+ if (!extensions || extensions.some((ext) => entry.name.endsWith(ext))) results.push(fullPath)
39
+ } else if (entry.isDirectory() && recursive) {
40
+ results.push(...listFiles(fullPath, extensions, true))
41
+ }
42
+ }
43
+ } catch {
44
+ // Permission denied or other errors
45
+ }
46
+ return results
47
+ }
48
+
49
+ export function listDirs(dir: string): string[] {
50
+ if (!fs.existsSync(dir)) return []
51
+ try {
52
+ return fs
53
+ .readdirSync(dir, { withFileTypes: true })
54
+ .filter((e) => e.isDirectory())
55
+ .map((e) => path.join(dir, e.name))
56
+ } catch {
57
+ return []
58
+ }
59
+ }
60
+
61
+ export function exists(p: string): boolean {
62
+ try {
63
+ return fs.existsSync(p)
64
+ } catch {
65
+ return false
66
+ }
67
+ }
68
+
69
+ export function extractProjectDescription(projectPath: string): string | null {
70
+ if (!projectPath || !fs.existsSync(projectPath)) return null
71
+
72
+ const pkgPath = path.join(projectPath, "package.json")
73
+ const pkg = safeReadJson<{ name?: string; description?: string }>(pkgPath)
74
+ if (pkg?.description) return pkg.description.slice(0, 200)
75
+
76
+ for (const readmeName of ["README.md", "readme.md", "Readme.md", "README.rst"]) {
77
+ const readmePath = path.join(projectPath, readmeName)
78
+ const content = safeReadFile(readmePath)
79
+ if (!content) continue
80
+ const lines = content.split("\n")
81
+ let desc = ""
82
+ for (const line of lines) {
83
+ const trimmed = line.trim()
84
+ if (!trimmed) {
85
+ if (desc) break
86
+ continue
87
+ }
88
+ if (trimmed.startsWith("#") || trimmed.startsWith("=") || trimmed.startsWith("-")) {
89
+ if (desc) break
90
+ continue
91
+ }
92
+ if (trimmed.startsWith("![") || trimmed.startsWith("<img")) continue
93
+ desc += (desc ? " " : "") + trimmed
94
+ if (desc.length > 200) break
95
+ }
96
+ if (desc.length > 10) return desc.slice(0, 300)
97
+ }
98
+
99
+ const cargoPath = path.join(projectPath, "Cargo.toml")
100
+ const cargo = safeReadFile(cargoPath)
101
+ if (cargo) {
102
+ const match = cargo.match(/description\s*=\s*"([^"]+)"/)
103
+ if (match) return match[1].slice(0, 200)
104
+ }
105
+
106
+ return null
107
+ }
108
+
109
+ export function readJsonl<T = unknown>(filePath: string): T[] {
110
+ const content = safeReadFile(filePath)
111
+ if (!content) return []
112
+ return content
113
+ .split("\n")
114
+ .filter(Boolean)
115
+ .map((line) => {
116
+ try {
117
+ return JSON.parse(line) as T
118
+ } catch {
119
+ return null
120
+ }
121
+ })
122
+ .filter((x): x is T => x !== null)
123
+ }
@@ -0,0 +1,26 @@
1
+ import { registerScanner } from "./registry"
2
+ import { claudeCodeScanner } from "./claude-code"
3
+ import { cursorScanner } from "./cursor"
4
+ import { windsurfScanner } from "./windsurf"
5
+ import { codexScanner } from "./codex"
6
+ import { warpScanner } from "./warp"
7
+ import { vscodeCopilotScanner } from "./vscode-copilot"
8
+ import { aiderScanner } from "./aider"
9
+ import { continueDevScanner } from "./continue-dev"
10
+ import { zedScanner } from "./zed"
11
+
12
+ export function registerAllScanners(): void {
13
+ registerScanner(claudeCodeScanner)
14
+ registerScanner(cursorScanner)
15
+ registerScanner(windsurfScanner)
16
+ registerScanner(codexScanner)
17
+ registerScanner(warpScanner)
18
+ registerScanner(vscodeCopilotScanner)
19
+ registerScanner(aiderScanner)
20
+ registerScanner(continueDevScanner)
21
+ registerScanner(zedScanner)
22
+ }
23
+
24
+ export { scanAll, parseSession, listScannerStatus, getScanners } from "./registry"
25
+ export { analyzeSession } from "./analyzer"
26
+ export type { Session, ParsedSession, SessionAnalysis, Scanner, SourceType, ConversationTurn } from "./types"