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,44 @@
1
+ import * as fs from "fs"
2
+ import * as os from "os"
3
+ import * as path from "path"
4
+
5
+ export type Platform = "macos" | "windows" | "linux"
6
+
7
+ export function getPlatform(): Platform {
8
+ switch (os.platform()) {
9
+ case "win32":
10
+ return "windows"
11
+ case "darwin":
12
+ return "macos"
13
+ default:
14
+ return "linux"
15
+ }
16
+ }
17
+
18
+ export function getHome(): string {
19
+ return os.homedir()
20
+ }
21
+
22
+ export function getAppDataDir(): string {
23
+ const platform = getPlatform()
24
+ if (platform === "windows") return process.env.APPDATA || path.join(getHome(), "AppData", "Roaming")
25
+ if (platform === "macos") return path.join(getHome(), "Library", "Application Support")
26
+ return process.env.XDG_CONFIG_HOME || path.join(getHome(), ".config")
27
+ }
28
+
29
+ export function getLocalAppDataDir(): string {
30
+ const platform = getPlatform()
31
+ if (platform === "windows") return process.env.LOCALAPPDATA || path.join(getHome(), "AppData", "Local")
32
+ if (platform === "macos") return path.join(getHome(), "Library", "Application Support")
33
+ return process.env.XDG_DATA_HOME || path.join(getHome(), ".local", "share")
34
+ }
35
+
36
+ export function resolvePaths(candidates: string[]): string[] {
37
+ return candidates.filter((p) => {
38
+ try {
39
+ return fs.existsSync(p)
40
+ } catch {
41
+ return false
42
+ }
43
+ })
44
+ }
@@ -0,0 +1,68 @@
1
+ import type { Scanner, Session, ParsedSession } from "./types"
2
+
3
+ const scanners: Scanner[] = []
4
+
5
+ export function registerScanner(scanner: Scanner): void {
6
+ scanners.push(scanner)
7
+ }
8
+
9
+ export function getScanners(): Scanner[] {
10
+ return [...scanners]
11
+ }
12
+
13
+ export function getScannerBySource(source: string): Scanner | undefined {
14
+ return scanners.find((s) => s.sourceType === source)
15
+ }
16
+
17
+ function safeScannerCall<T>(scannerName: string, method: string, fn: () => T, fallback: T): T {
18
+ try {
19
+ return fn()
20
+ } catch (err) {
21
+ console.error(`[codeblog] Scanner "${scannerName}" ${method} failed:`, err instanceof Error ? err.message : err)
22
+ return fallback
23
+ }
24
+ }
25
+
26
+ export function scanAll(limit = 20, source?: string): Session[] {
27
+ const all: Session[] = []
28
+ const targets = source ? scanners.filter((s) => s.sourceType === source) : scanners
29
+
30
+ for (const scanner of targets) {
31
+ const sessions = safeScannerCall(scanner.name, "scan", () => scanner.scan(limit), [])
32
+ all.push(...sessions)
33
+ }
34
+
35
+ all.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime())
36
+ return all.slice(0, limit)
37
+ }
38
+
39
+ export function parseSession(filePath: string, source: string, maxTurns?: number): ParsedSession | null {
40
+ const scanner = getScannerBySource(source)
41
+ if (!scanner) return null
42
+ return safeScannerCall(scanner.name, "parse", () => scanner.parse(filePath, maxTurns), null)
43
+ }
44
+
45
+ export function listScannerStatus(): Array<{
46
+ name: string
47
+ source: string
48
+ description: string
49
+ available: boolean
50
+ dirs: string[]
51
+ error?: string
52
+ }> {
53
+ return scanners.map((s) => {
54
+ try {
55
+ const dirs = s.getSessionDirs()
56
+ return { name: s.name, source: s.sourceType, description: s.description, available: dirs.length > 0, dirs }
57
+ } catch (err) {
58
+ return {
59
+ name: s.name,
60
+ source: s.sourceType,
61
+ description: s.description,
62
+ available: false,
63
+ dirs: [],
64
+ error: err instanceof Error ? err.message : String(err),
65
+ }
66
+ }
67
+ })
68
+ }
@@ -0,0 +1,62 @@
1
+ export interface Session {
2
+ id: string
3
+ source: SourceType
4
+ project: string
5
+ projectPath?: string
6
+ projectDescription?: string
7
+ title: string
8
+ messageCount: number
9
+ humanMessages: number
10
+ aiMessages: number
11
+ preview: string
12
+ filePath: string
13
+ modifiedAt: Date
14
+ sizeBytes: number
15
+ }
16
+
17
+ export interface ConversationTurn {
18
+ role: "human" | "assistant" | "system" | "tool"
19
+ content: string
20
+ timestamp?: Date
21
+ }
22
+
23
+ export interface ParsedSession extends Session {
24
+ turns: ConversationTurn[]
25
+ }
26
+
27
+ export interface SessionAnalysis {
28
+ summary: string
29
+ topics: string[]
30
+ languages: string[]
31
+ keyInsights: string[]
32
+ codeSnippets: Array<{
33
+ language: string
34
+ code: string
35
+ context: string
36
+ }>
37
+ problems: string[]
38
+ solutions: string[]
39
+ suggestedTitle: string
40
+ suggestedTags: string[]
41
+ }
42
+
43
+ export type SourceType =
44
+ | "claude-code"
45
+ | "cursor"
46
+ | "windsurf"
47
+ | "codex"
48
+ | "warp"
49
+ | "vscode-copilot"
50
+ | "aider"
51
+ | "continue"
52
+ | "zed"
53
+ | "unknown"
54
+
55
+ export interface Scanner {
56
+ name: string
57
+ sourceType: SourceType
58
+ description: string
59
+ getSessionDirs(): string[]
60
+ scan(limit: number): Session[]
61
+ parse(filePath: string, maxTurns?: number): ParsedSession | null
62
+ }
@@ -0,0 +1,125 @@
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, safeReadJson, safeStats } from "./fs-utils"
6
+
7
+ export const vscodeCopilotScanner: Scanner = {
8
+ name: "VS Code Copilot Chat",
9
+ sourceType: "vscode-copilot",
10
+ description: "GitHub Copilot Chat sessions in VS Code",
11
+
12
+ getSessionDirs(): string[] {
13
+ const home = getHome()
14
+ const platform = getPlatform()
15
+ const candidates: string[] = []
16
+ const variants = ["Code", "Code - Insiders", "VSCodium"]
17
+ for (const variant of variants) {
18
+ if (platform === "macos") {
19
+ candidates.push(path.join(home, "Library", "Application Support", variant, "User", "workspaceStorage"))
20
+ candidates.push(path.join(home, "Library", "Application Support", variant, "User", "globalStorage", "github.copilot-chat"))
21
+ } else if (platform === "windows") {
22
+ const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming")
23
+ candidates.push(path.join(appData, variant, "User", "workspaceStorage"))
24
+ candidates.push(path.join(appData, variant, "User", "globalStorage", "github.copilot-chat"))
25
+ } else {
26
+ candidates.push(path.join(home, ".config", variant, "User", "workspaceStorage"))
27
+ candidates.push(path.join(home, ".config", variant, "User", "globalStorage", "github.copilot-chat"))
28
+ }
29
+ }
30
+ return candidates.filter((d) => {
31
+ try { return fs.existsSync(d) } catch { return false }
32
+ })
33
+ },
34
+
35
+ scan(limit: number): Session[] {
36
+ const sessions: Session[] = []
37
+ for (const baseDir of this.getSessionDirs()) {
38
+ if (baseDir.includes("globalStorage")) {
39
+ for (const filePath of listFiles(baseDir, [".json"])) {
40
+ const session = tryParseConversationFile(filePath)
41
+ if (session) sessions.push(session)
42
+ }
43
+ continue
44
+ }
45
+ for (const hashDir of listDirs(baseDir)) {
46
+ for (const filePath of listFiles(path.join(hashDir, "github.copilot-chat"), [".json"])) {
47
+ const session = tryParseConversationFile(filePath)
48
+ if (session) sessions.push(session)
49
+ }
50
+ }
51
+ }
52
+ sessions.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime())
53
+ return sessions.slice(0, limit)
54
+ },
55
+
56
+ parse(filePath: string, maxTurns?: number): ParsedSession | null {
57
+ const data = safeReadJson<Record<string, unknown>>(filePath)
58
+ if (!data) return null
59
+ const stats = safeStats(filePath)
60
+ const turns = extractCopilotTurns(data, maxTurns)
61
+ if (turns.length === 0) return null
62
+ const humanMsgs = turns.filter((t) => t.role === "human")
63
+ const aiMsgs = turns.filter((t) => t.role === "assistant")
64
+ return {
65
+ id: path.basename(filePath, ".json"), source: "vscode-copilot",
66
+ project: path.basename(path.dirname(filePath)),
67
+ title: humanMsgs[0]?.content.slice(0, 80) || "Copilot Chat session",
68
+ messageCount: turns.length, humanMessages: humanMsgs.length, aiMessages: aiMsgs.length,
69
+ preview: humanMsgs[0]?.content.slice(0, 200) || "",
70
+ filePath, modifiedAt: stats?.mtime || new Date(), sizeBytes: stats?.size || 0, turns,
71
+ }
72
+ },
73
+ }
74
+
75
+ function tryParseConversationFile(filePath: string): Session | null {
76
+ const stats = safeStats(filePath)
77
+ if (!stats || stats.size < 100) return null
78
+ const data = safeReadJson<Record<string, unknown>>(filePath)
79
+ if (!data) return null
80
+ const turns = extractCopilotTurns(data)
81
+ if (turns.length < 2) return null
82
+ const humanMsgs = turns.filter((t) => t.role === "human")
83
+ const preview = humanMsgs[0]?.content.slice(0, 200) || "(copilot session)"
84
+ return {
85
+ id: path.basename(filePath, ".json"), source: "vscode-copilot",
86
+ project: path.basename(path.dirname(filePath)),
87
+ title: preview.slice(0, 80), messageCount: turns.length,
88
+ humanMessages: humanMsgs.length, aiMessages: turns.length - humanMsgs.length,
89
+ preview, filePath, modifiedAt: stats.mtime, sizeBytes: stats.size,
90
+ }
91
+ }
92
+
93
+ function extractCopilotTurns(data: Record<string, unknown>, maxTurns?: number): ConversationTurn[] {
94
+ const turns: ConversationTurn[] = []
95
+ if (Array.isArray(data.conversations)) {
96
+ for (const conv of data.conversations) {
97
+ if (!conv || typeof conv !== "object") continue
98
+ const c = conv as Record<string, unknown>
99
+ if (!Array.isArray(c.turns)) continue
100
+ for (const turn of c.turns) {
101
+ if (maxTurns && turns.length >= maxTurns) break
102
+ const t = turn as Record<string, unknown>
103
+ if (t.request && typeof t.request === "string") turns.push({ role: "human", content: t.request })
104
+ if (t.response && typeof t.response === "string") turns.push({ role: "assistant", content: t.response })
105
+ if (t.message && typeof t.message === "string") {
106
+ turns.push({ role: t.role === "user" ? "human" : "assistant", content: t.message })
107
+ }
108
+ }
109
+ }
110
+ return turns
111
+ }
112
+ for (const arr of [data.messages, data.history, data.entries]) {
113
+ if (!Array.isArray(arr)) continue
114
+ for (const msg of arr) {
115
+ if (maxTurns && turns.length >= maxTurns) break
116
+ if (!msg || typeof msg !== "object") continue
117
+ const m = msg as Record<string, unknown>
118
+ const content = (m.content || m.text || m.message) as string | undefined
119
+ if (typeof content !== "string") continue
120
+ turns.push({ role: m.role === "user" ? "human" : "assistant", content })
121
+ }
122
+ if (turns.length > 0) return turns
123
+ }
124
+ return turns
125
+ }
@@ -0,0 +1,19 @@
1
+ import type { Scanner, Session, ParsedSession } from "./types"
2
+
3
+ export const warpScanner: Scanner = {
4
+ name: "Warp Terminal",
5
+ sourceType: "warp",
6
+ description: "Warp Terminal (AI chat is cloud-only, no local history)",
7
+
8
+ getSessionDirs(): string[] {
9
+ return []
10
+ },
11
+
12
+ scan(_limit: number): Session[] {
13
+ return []
14
+ },
15
+
16
+ parse(_filePath: string, _maxTurns?: number): ParsedSession | null {
17
+ return null
18
+ },
19
+ }
@@ -0,0 +1,147 @@
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 { listDirs, safeReadJson, safeStats, extractProjectDescription } from "./fs-utils"
7
+
8
+ interface VscdbChatIndex {
9
+ version: number
10
+ entries: Record<string, VscdbChatEntry>
11
+ }
12
+
13
+ interface VscdbChatEntry {
14
+ messages?: Array<{ role?: string; content?: string; text?: string }>
15
+ [key: string]: unknown
16
+ }
17
+
18
+ export const windsurfScanner: Scanner = {
19
+ name: "Windsurf",
20
+ sourceType: "windsurf",
21
+ description: "Windsurf (Codeium) Cascade chat sessions (SQLite)",
22
+
23
+ getSessionDirs(): string[] {
24
+ const home = getHome()
25
+ const platform = getPlatform()
26
+ const candidates: string[] = []
27
+ if (platform === "macos") {
28
+ candidates.push(path.join(home, "Library", "Application Support", "Windsurf", "User", "workspaceStorage"))
29
+ } else if (platform === "windows") {
30
+ const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming")
31
+ candidates.push(path.join(appData, "Windsurf", "User", "workspaceStorage"))
32
+ } else {
33
+ candidates.push(path.join(home, ".config", "Windsurf", "User", "workspaceStorage"))
34
+ }
35
+ return candidates.filter((d) => {
36
+ try { return fs.existsSync(d) } catch { return false }
37
+ })
38
+ },
39
+
40
+ scan(limit: number): Session[] {
41
+ const sessions: Session[] = []
42
+ for (const baseDir of this.getSessionDirs()) {
43
+ for (const wsDir of listDirs(baseDir)) {
44
+ const dbPath = path.join(wsDir, "state.vscdb")
45
+ if (!fs.existsSync(dbPath)) continue
46
+ const wsJson = safeReadJson<{ folder?: string }>(path.join(wsDir, "workspace.json"))
47
+ let projectPath: string | undefined
48
+ if (wsJson?.folder) {
49
+ try { projectPath = decodeURIComponent(new URL(wsJson.folder).pathname) } catch { /* */ }
50
+ }
51
+ const project = projectPath ? path.basename(projectPath) : path.basename(wsDir)
52
+ const projectDescription = projectPath ? extractProjectDescription(projectPath) || undefined : undefined
53
+ const chatData = readVscdbChatSessions(dbPath)
54
+ if (!chatData || Object.keys(chatData.entries).length === 0) continue
55
+ for (const [sessionId, entry] of Object.entries(chatData.entries)) {
56
+ const messages = extractVscdbMessages(entry)
57
+ if (messages.length < 2) continue
58
+ const humanMsgs = messages.filter((m) => m.role === "human")
59
+ const preview = humanMsgs[0]?.content.slice(0, 200) || "(windsurf session)"
60
+ const dbStats = safeStats(dbPath)
61
+ sessions.push({
62
+ id: sessionId, source: "windsurf", project, projectPath, projectDescription,
63
+ title: preview.slice(0, 80), messageCount: messages.length,
64
+ humanMessages: humanMsgs.length, aiMessages: messages.length - humanMsgs.length,
65
+ preview, filePath: `${dbPath}|${sessionId}`,
66
+ modifiedAt: dbStats?.mtime || new Date(), sizeBytes: dbStats?.size || 0,
67
+ })
68
+ }
69
+ }
70
+ }
71
+ sessions.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime())
72
+ return sessions.slice(0, limit)
73
+ },
74
+
75
+ parse(filePath: string, maxTurns?: number): ParsedSession | null {
76
+ const sepIdx = filePath.lastIndexOf("|")
77
+ const dbPath = sepIdx > 0 ? filePath.slice(0, sepIdx) : filePath
78
+ const targetSessionId = sepIdx > 0 ? filePath.slice(sepIdx + 1) : null
79
+ const chatData = readVscdbChatSessions(dbPath)
80
+ if (!chatData) return null
81
+ const stats = safeStats(dbPath)
82
+ const entries = Object.entries(chatData.entries)
83
+ if (entries.length === 0) return null
84
+ let targetEntry: VscdbChatEntry | null = null
85
+ let targetId = path.basename(path.dirname(dbPath))
86
+ if (targetSessionId && chatData.entries[targetSessionId]) {
87
+ targetEntry = chatData.entries[targetSessionId]
88
+ targetId = targetSessionId
89
+ } else {
90
+ for (const [id, entry] of entries) {
91
+ const msgs = extractVscdbMessages(entry)
92
+ if (msgs.length >= 2) { targetEntry = entry; targetId = id; break }
93
+ }
94
+ }
95
+ if (!targetEntry) return null
96
+ const allTurns = extractVscdbMessages(targetEntry)
97
+ const turns = maxTurns ? allTurns.slice(0, maxTurns) : allTurns
98
+ if (turns.length === 0) return null
99
+ const humanMsgs = turns.filter((t) => t.role === "human")
100
+ const aiMsgs = turns.filter((t) => t.role === "assistant")
101
+ return {
102
+ id: targetId, source: "windsurf", project: path.basename(path.dirname(filePath)),
103
+ title: humanMsgs[0]?.content.slice(0, 80) || "Windsurf session",
104
+ messageCount: turns.length, humanMessages: humanMsgs.length, aiMessages: aiMsgs.length,
105
+ preview: humanMsgs[0]?.content.slice(0, 200) || "",
106
+ filePath: dbPath, modifiedAt: stats?.mtime || new Date(), sizeBytes: stats?.size || 0, turns,
107
+ }
108
+ },
109
+ }
110
+
111
+ function readVscdbChatSessions(dbPath: string): VscdbChatIndex | null {
112
+ try {
113
+ const db = new BunDatabase(dbPath, { readonly: true })
114
+ let row: { value: string | Buffer } | undefined
115
+ try {
116
+ row = db.prepare("SELECT value FROM ItemTable WHERE key = 'chat.ChatSessionStore.index'").get() as any
117
+ } finally { db.close() }
118
+ if (!row?.value) return null
119
+ const valueStr = typeof row.value === "string" ? row.value : (row.value as Buffer).toString("utf-8")
120
+ return JSON.parse(valueStr) as VscdbChatIndex
121
+ } catch { return null }
122
+ }
123
+
124
+ function extractVscdbMessages(entry: VscdbChatEntry): ConversationTurn[] {
125
+ const turns: ConversationTurn[] = []
126
+ if (Array.isArray(entry.messages)) {
127
+ for (const msg of entry.messages) {
128
+ if (!msg || typeof msg !== "object") continue
129
+ const content = msg.content || msg.text
130
+ if (typeof content !== "string" || !content.trim()) continue
131
+ turns.push({ role: msg.role === "user" || msg.role === "human" ? "human" : "assistant", content })
132
+ }
133
+ return turns
134
+ }
135
+ for (const [, value] of Object.entries(entry)) {
136
+ if (!Array.isArray(value)) continue
137
+ for (const item of value) {
138
+ if (!item || typeof item !== "object") continue
139
+ const m = item as Record<string, unknown>
140
+ const content = (m.content || m.text) as string | undefined
141
+ if (typeof content !== "string" || !content.trim()) continue
142
+ turns.push({ role: m.role === "user" || m.role === "human" ? "human" : "assistant", content })
143
+ }
144
+ if (turns.length > 0) return turns
145
+ }
146
+ return turns
147
+ }
@@ -0,0 +1,88 @@
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 zedScanner: Scanner = {
8
+ name: "Zed",
9
+ sourceType: "zed",
10
+ description: "Zed editor AI assistant conversations",
11
+
12
+ getSessionDirs(): string[] {
13
+ const home = getHome()
14
+ const platform = getPlatform()
15
+ const candidates: string[] = []
16
+ if (platform === "macos") {
17
+ candidates.push(path.join(home, "Library", "Application Support", "Zed", "conversations"))
18
+ }
19
+ if (platform === "windows") {
20
+ const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming")
21
+ candidates.push(path.join(appData, "Zed", "conversations"))
22
+ }
23
+ candidates.push(path.join(home, ".config", "zed", "conversations"))
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", ".zed"], true)) {
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 = extractZedTurns(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) || "(zed session)"
41
+ sessions.push({
42
+ id: path.basename(filePath).replace(/\.\w+$/, ""), source: "zed",
43
+ project: (data.project 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 = extractZedTurns(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).replace(/\.\w+$/, ""), source: "zed",
64
+ project: (data.project as string) || path.basename(path.dirname(filePath)),
65
+ title: (data.title as string) || humanMsgs[0]?.content.slice(0, 80) || "Zed 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 extractZedTurns(data: Record<string, unknown>, maxTurns?: number): ConversationTurn[] {
74
+ const turns: ConversationTurn[] = []
75
+ for (const arr of [data.messages, data.conversation, data.entries]) {
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
+ const content = (m.content || m.body || m.text) as string | undefined
82
+ if (typeof content !== "string") continue
83
+ turns.push({ role: m.role === "user" || m.role === "human" ? "human" : "assistant", content })
84
+ }
85
+ if (turns.length > 0) return turns
86
+ }
87
+ return turns
88
+ }
@@ -0,0 +1,48 @@
1
+ import { Hono } from "hono"
2
+ import { Log } from "../util/log"
3
+
4
+ const log = Log.create({ service: "server" })
5
+
6
+ export namespace Server {
7
+ let instance: ReturnType<typeof Bun.serve> | null = null
8
+
9
+ export function start(app: Hono, port: number): ReturnType<typeof Bun.serve> {
10
+ if (instance) {
11
+ log.warn("server already running, stopping previous instance")
12
+ instance.stop()
13
+ }
14
+ instance = Bun.serve({ port, fetch: app.fetch })
15
+ log.info("server started", { port })
16
+ return instance
17
+ }
18
+
19
+ export function stop() {
20
+ if (instance) {
21
+ instance.stop()
22
+ instance = null
23
+ log.info("server stopped")
24
+ }
25
+ }
26
+
27
+ export function running(): boolean {
28
+ return instance !== null
29
+ }
30
+
31
+ export function createCallbackServer(onCallback: (params: URLSearchParams) => Promise<string>): {
32
+ app: Hono
33
+ port: number
34
+ } {
35
+ const port = 19823
36
+ const app = new Hono()
37
+
38
+ app.get("/callback", async (c) => {
39
+ const params = new URL(c.req.url).searchParams
40
+ const html = await onCallback(params)
41
+ return c.html(html)
42
+ })
43
+
44
+ app.get("/health", (c) => c.json({ ok: true }))
45
+
46
+ return { app, port }
47
+ }
48
+ }
@@ -0,0 +1,68 @@
1
+ import { Database as BunDatabase } from "bun:sqlite"
2
+ import { drizzle } from "drizzle-orm/bun-sqlite"
3
+ import { Context } from "../util/context"
4
+ import { lazy } from "../util/lazy"
5
+ import { Global } from "../global"
6
+ import { Log } from "../util/log"
7
+ import path from "path"
8
+ import * as schema from "./schema"
9
+
10
+ const log = Log.create({ service: "db" })
11
+
12
+ export namespace Database {
13
+ type Schema = typeof schema
14
+
15
+ export const Client = lazy(() => {
16
+ const dbpath = path.join(Global.Path.data, "codeblog.db")
17
+ log.info("opening database", { path: dbpath })
18
+
19
+ const sqlite = new BunDatabase(dbpath, { create: true })
20
+
21
+ sqlite.run("PRAGMA journal_mode = WAL")
22
+ sqlite.run("PRAGMA synchronous = NORMAL")
23
+ sqlite.run("PRAGMA busy_timeout = 5000")
24
+ sqlite.run("PRAGMA cache_size = -64000")
25
+ sqlite.run("PRAGMA foreign_keys = ON")
26
+
27
+ // Auto-create tables
28
+ sqlite.run(`CREATE TABLE IF NOT EXISTS published_sessions (
29
+ id TEXT PRIMARY KEY,
30
+ session_id TEXT NOT NULL,
31
+ source TEXT NOT NULL,
32
+ post_id TEXT,
33
+ file_path TEXT NOT NULL,
34
+ time_created INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
35
+ time_updated INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
36
+ )`)
37
+
38
+ sqlite.run(`CREATE TABLE IF NOT EXISTS cached_posts (
39
+ id TEXT PRIMARY KEY,
40
+ title TEXT NOT NULL,
41
+ content TEXT NOT NULL,
42
+ author_name TEXT,
43
+ votes INTEGER DEFAULT 0,
44
+ comments_count INTEGER DEFAULT 0,
45
+ tags TEXT,
46
+ time_created INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
47
+ time_updated INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
48
+ )`)
49
+
50
+ sqlite.run(`CREATE TABLE IF NOT EXISTS notifications_cache (
51
+ id TEXT PRIMARY KEY,
52
+ type TEXT NOT NULL,
53
+ message TEXT NOT NULL,
54
+ read INTEGER DEFAULT 0,
55
+ post_id TEXT,
56
+ time_created INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
57
+ time_updated INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
58
+ )`)
59
+
60
+ return drizzle({ client: sqlite, schema })
61
+ })
62
+
63
+ const ctx = Context.create<{ tx: any; effects: (() => void | Promise<void>)[] }>("database")
64
+
65
+ export function use<T>(callback: (db: ReturnType<typeof Client>) => T): T {
66
+ return callback(Client())
67
+ }
68
+ }