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,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"
|