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,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
|
+
}
|