@codehourra/llm-iwiki 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/LICENSE +21 -0
- package/README.md +67 -0
- package/package.json +53 -0
- package/src/ai-yaml.ts +82 -0
- package/src/cli.ts +485 -0
- package/src/collectors/claude-code.ts +120 -0
- package/src/collectors/codebuddy.ts +164 -0
- package/src/collectors/codex.ts +130 -0
- package/src/collectors/cursor.ts +178 -0
- package/src/collectors/gemini.ts +141 -0
- package/src/collectors/index.ts +20 -0
- package/src/collectors/types.ts +22 -0
- package/src/collectors/util.ts +58 -0
- package/src/compaction.ts +63 -0
- package/src/config.ts +57 -0
- package/src/db.ts +152 -0
- package/src/experiences.ts +235 -0
- package/src/index.ts +10 -0
- package/src/obsidian.ts +345 -0
- package/src/paths.ts +23 -0
- package/src/projects.ts +192 -0
- package/src/sessions.ts +119 -0
- package/src/skills.ts +168 -0
- package/src/summarize.ts +122 -0
- package/src/sync.ts +207 -0
- package/src/types.ts +26 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import type { Collector, RawMessage, RawSession } from './types'
|
|
5
|
+
import { clampTitle, deriveTitle, isEphemeralPath, normalizeContentParts } from './util'
|
|
6
|
+
|
|
7
|
+
const APP_SUPPORT = 'Library/Application Support'
|
|
8
|
+
const EXTENSION_DIRS = ['CodeBuddyExtension', 'CodeBuddy CN']
|
|
9
|
+
|
|
10
|
+
interface ConversationMeta {
|
|
11
|
+
id: string
|
|
12
|
+
name?: string
|
|
13
|
+
createdAt?: string
|
|
14
|
+
lastMessageAt?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function findHistoryDirs(root: string, acc: string[], depth = 0): void {
|
|
18
|
+
if (depth > 6) return
|
|
19
|
+
let entries
|
|
20
|
+
try {
|
|
21
|
+
entries = readdirSync(root, { withFileTypes: true })
|
|
22
|
+
} catch {
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
if (!entry.isDirectory()) continue
|
|
27
|
+
const full = join(root, entry.name)
|
|
28
|
+
if (entry.name === 'history') {
|
|
29
|
+
acc.push(full)
|
|
30
|
+
} else if (entry.name !== 'messages') {
|
|
31
|
+
findHistoryDirs(full, acc, depth + 1)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readJson<T>(filePath: string): T | null {
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(readFileSync(filePath, 'utf8')) as T
|
|
39
|
+
} catch {
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function extractMessageText(messageField: unknown): string {
|
|
45
|
+
if (typeof messageField !== 'string') return normalizeContentParts(messageField)
|
|
46
|
+
const trimmed = messageField.trim()
|
|
47
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(trimmed) as Record<string, unknown>
|
|
50
|
+
if (parsed && typeof parsed === 'object' && 'content' in parsed) {
|
|
51
|
+
return normalizeContentParts((parsed as { content: unknown }).content)
|
|
52
|
+
}
|
|
53
|
+
return normalizeContentParts(parsed)
|
|
54
|
+
} catch {
|
|
55
|
+
return messageField
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return messageField
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function extractWorkspaceFolder(text: string): string | null {
|
|
62
|
+
const match = text.match(/Workspace Folder:\s*(.+)/)
|
|
63
|
+
if (!match) return null
|
|
64
|
+
const folder = match[1]!.trim()
|
|
65
|
+
return folder === '' ? null : folder
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readConversation(
|
|
69
|
+
convDir: string,
|
|
70
|
+
conversationId: string,
|
|
71
|
+
meta: ConversationMeta,
|
|
72
|
+
): RawSession | null {
|
|
73
|
+
const convIndex = readJson<{ messages?: Array<{ id: string; role?: string }> }>(join(convDir, 'index.json'))
|
|
74
|
+
if (!convIndex?.messages) return null
|
|
75
|
+
|
|
76
|
+
const messages: RawMessage[] = []
|
|
77
|
+
let rawProjectPath: string | null = null
|
|
78
|
+
|
|
79
|
+
for (const entry of convIndex.messages) {
|
|
80
|
+
const raw = readJson<{ role?: string; message?: unknown }>(join(convDir, 'messages', `${entry.id}.json`))
|
|
81
|
+
if (!raw) continue
|
|
82
|
+
const role = entry.role ?? raw.role ?? 'user'
|
|
83
|
+
if (role !== 'user' && role !== 'assistant') continue
|
|
84
|
+
|
|
85
|
+
const content = extractMessageText(raw.message)
|
|
86
|
+
if (content.trim() === '') continue
|
|
87
|
+
|
|
88
|
+
if (!rawProjectPath && role === 'user') {
|
|
89
|
+
rawProjectPath = extractWorkspaceFolder(content)
|
|
90
|
+
}
|
|
91
|
+
messages.push({ role, content, timestamp: null })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (messages.length === 0) return null
|
|
95
|
+
if (isEphemeralPath(rawProjectPath)) return null
|
|
96
|
+
|
|
97
|
+
const title = meta.name && meta.name.trim() !== '' ? clampTitle(meta.name.trim()) : deriveTitle(messages)
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
sourceSessionId: conversationId,
|
|
101
|
+
rawPath: convDir,
|
|
102
|
+
rawProjectPath,
|
|
103
|
+
title,
|
|
104
|
+
createdAt: meta.createdAt ?? null,
|
|
105
|
+
updatedAt: meta.lastMessageAt ?? null,
|
|
106
|
+
messages,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function collectFromHistory(historyDir: string, sessions: RawSession[]): void {
|
|
111
|
+
for (const workspaceEntry of readdirSync(historyDir, { withFileTypes: true })) {
|
|
112
|
+
if (!workspaceEntry.isDirectory()) continue
|
|
113
|
+
const workspaceDir = join(historyDir, workspaceEntry.name)
|
|
114
|
+
const workspaceIndex = readJson<{ conversations?: ConversationMeta[] }>(join(workspaceDir, 'index.json'))
|
|
115
|
+
if (!workspaceIndex?.conversations?.length) continue
|
|
116
|
+
|
|
117
|
+
for (const conversation of workspaceIndex.conversations) {
|
|
118
|
+
if (!conversation.id) continue
|
|
119
|
+
const convDir = join(workspaceDir, conversation.id)
|
|
120
|
+
if (!existsSync(convDir)) continue
|
|
121
|
+
try {
|
|
122
|
+
const session = readConversation(convDir, conversation.id, conversation)
|
|
123
|
+
if (session) sessions.push(session)
|
|
124
|
+
} catch {
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function extensionRoots(homeDir: string): string[] {
|
|
132
|
+
return EXTENSION_DIRS.map((dir) => join(homeDir, APP_SUPPORT, dir)).filter((dir) => existsSync(dir))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export const codebuddyCollector: Collector = {
|
|
136
|
+
id: 'codebuddy',
|
|
137
|
+
name: 'CodeBuddy',
|
|
138
|
+
|
|
139
|
+
detect(homeDir: string): boolean {
|
|
140
|
+
return extensionRoots(homeDir).length > 0
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
collect(homeDir: string): RawSession[] {
|
|
144
|
+
const sessions: RawSession[] = []
|
|
145
|
+
const seen = new Set<string>()
|
|
146
|
+
for (const root of extensionRoots(homeDir)) {
|
|
147
|
+
const historyDirs: string[] = []
|
|
148
|
+
findHistoryDirs(join(root, 'Data'), historyDirs)
|
|
149
|
+
for (const historyDir of historyDirs) {
|
|
150
|
+
try {
|
|
151
|
+
if (!statSync(historyDir).isDirectory()) continue
|
|
152
|
+
} catch {
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
collectFromHistory(historyDir, sessions)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return sessions.filter((session) => {
|
|
159
|
+
if (seen.has(session.sourceSessionId)) return false
|
|
160
|
+
seen.add(session.sourceSessionId)
|
|
161
|
+
return true
|
|
162
|
+
})
|
|
163
|
+
},
|
|
164
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
|
2
|
+
import { basename, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import type { Collector, RawMessage, RawSession } from './types'
|
|
5
|
+
import { deriveTitle, isEphemeralPath } from './util'
|
|
6
|
+
|
|
7
|
+
const SESSION_ROOTS = ['.codex/sessions', '.codex-internal/sessions']
|
|
8
|
+
|
|
9
|
+
function cleanCodexText(text: string): string {
|
|
10
|
+
return text
|
|
11
|
+
.replace(/\[REQ:[0-9a-f-]+\]/gi, '')
|
|
12
|
+
.replace(/\[AMP_DONE:[0-9a-f-]+\]/gi, '')
|
|
13
|
+
.trim()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function walkRolloutFiles(dir: string, acc: string[]): void {
|
|
17
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
18
|
+
const full = join(dir, entry.name)
|
|
19
|
+
if (entry.isDirectory()) {
|
|
20
|
+
walkRolloutFiles(full, acc)
|
|
21
|
+
} else if (entry.isFile() && entry.name.startsWith('rollout-') && entry.name.endsWith('.jsonl')) {
|
|
22
|
+
acc.push(full)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseRolloutFile(filePath: string): RawSession | null {
|
|
28
|
+
const raw = readFileSync(filePath, 'utf8')
|
|
29
|
+
const messages: RawMessage[] = []
|
|
30
|
+
let rawProjectPath: string | null = null
|
|
31
|
+
let sourceSessionId: string | null = null
|
|
32
|
+
let createdAt: string | null = null
|
|
33
|
+
|
|
34
|
+
for (const line of raw.split('\n')) {
|
|
35
|
+
const trimmed = line.trim()
|
|
36
|
+
if (trimmed === '') continue
|
|
37
|
+
|
|
38
|
+
let entry: Record<string, unknown>
|
|
39
|
+
try {
|
|
40
|
+
entry = JSON.parse(trimmed) as Record<string, unknown>
|
|
41
|
+
} catch {
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const payload = entry.payload
|
|
46
|
+
if (!payload || typeof payload !== 'object') continue
|
|
47
|
+
const payloadRecord = payload as Record<string, unknown>
|
|
48
|
+
const timestamp = typeof entry.timestamp === 'string' ? entry.timestamp : null
|
|
49
|
+
|
|
50
|
+
if (entry.type === 'session_meta') {
|
|
51
|
+
if (typeof payloadRecord.id === 'string') sourceSessionId = payloadRecord.id
|
|
52
|
+
if (typeof payloadRecord.cwd === 'string') rawProjectPath = payloadRecord.cwd
|
|
53
|
+
if (typeof payloadRecord.timestamp === 'string') createdAt = payloadRecord.timestamp
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (entry.type !== 'event_msg') continue
|
|
58
|
+
const eventType = payloadRecord.type
|
|
59
|
+
if (eventType !== 'user_message' && eventType !== 'agent_message') continue
|
|
60
|
+
if (typeof payloadRecord.message !== 'string') continue
|
|
61
|
+
|
|
62
|
+
const content = cleanCodexText(payloadRecord.message)
|
|
63
|
+
if (content === '') continue
|
|
64
|
+
messages.push({
|
|
65
|
+
role: eventType === 'user_message' ? 'user' : 'assistant',
|
|
66
|
+
content,
|
|
67
|
+
timestamp,
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (messages.length === 0) return null
|
|
72
|
+
if (isEphemeralPath(rawProjectPath)) return null
|
|
73
|
+
|
|
74
|
+
sourceSessionId ??= basename(filePath).replace(/\.jsonl$/, '')
|
|
75
|
+
const timestamps = messages.map((message) => message.timestamp).filter((value): value is string => value != null)
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
sourceSessionId,
|
|
79
|
+
rawPath: filePath,
|
|
80
|
+
rawProjectPath,
|
|
81
|
+
title: deriveTitle(messages),
|
|
82
|
+
createdAt: createdAt ?? timestamps[0] ?? null,
|
|
83
|
+
updatedAt: timestamps[timestamps.length - 1] ?? null,
|
|
84
|
+
messages,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function listRolloutFiles(homeDir: string): string[] {
|
|
89
|
+
const files: string[] = []
|
|
90
|
+
const seenRoots = new Set<string>()
|
|
91
|
+
for (const root of SESSION_ROOTS) {
|
|
92
|
+
const sessionsDir = join(homeDir, root)
|
|
93
|
+
if (!existsSync(sessionsDir)) continue
|
|
94
|
+
|
|
95
|
+
let canonicalRoot: string
|
|
96
|
+
try {
|
|
97
|
+
canonicalRoot = realpathSync(sessionsDir)
|
|
98
|
+
} catch {
|
|
99
|
+
canonicalRoot = sessionsDir
|
|
100
|
+
}
|
|
101
|
+
if (seenRoots.has(canonicalRoot)) continue
|
|
102
|
+
seenRoots.add(canonicalRoot)
|
|
103
|
+
|
|
104
|
+
walkRolloutFiles(sessionsDir, files)
|
|
105
|
+
}
|
|
106
|
+
return files
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const codexCollector: Collector = {
|
|
110
|
+
id: 'codex',
|
|
111
|
+
name: 'Codex',
|
|
112
|
+
|
|
113
|
+
detect(homeDir: string): boolean {
|
|
114
|
+
return SESSION_ROOTS.some((root) => existsSync(join(homeDir, root)))
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
collect(homeDir: string): RawSession[] {
|
|
118
|
+
const sessions: RawSession[] = []
|
|
119
|
+
for (const filePath of listRolloutFiles(homeDir)) {
|
|
120
|
+
try {
|
|
121
|
+
if (!statSync(filePath).isFile()) continue
|
|
122
|
+
const session = parseRolloutFile(filePath)
|
|
123
|
+
if (session) sessions.push(session)
|
|
124
|
+
} catch {
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return sessions
|
|
129
|
+
},
|
|
130
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite'
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import type { Collector, RawMessage, RawSession } from './types'
|
|
6
|
+
import { clampTitle, deriveTitle, isEphemeralPath } from './util'
|
|
7
|
+
|
|
8
|
+
const CURSOR_USER_DIR = 'Library/Application Support/Cursor/User'
|
|
9
|
+
|
|
10
|
+
interface ComposerMeta {
|
|
11
|
+
cwd: string | null
|
|
12
|
+
name: string | null
|
|
13
|
+
createdAt: string | null
|
|
14
|
+
updatedAt: string | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function epochToIso(value: unknown): string | null {
|
|
18
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return null
|
|
19
|
+
const date = new Date(value)
|
|
20
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function folderUriToPath(uri: string): string | null {
|
|
24
|
+
if (!uri.startsWith('file://')) return null
|
|
25
|
+
try {
|
|
26
|
+
return decodeURIComponent(uri.replace(/^file:\/\//, '')).replace(/\/+$/, '')
|
|
27
|
+
} catch {
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function openReadonly(path: string): Database | null {
|
|
33
|
+
try {
|
|
34
|
+
return new Database(path, { readonly: true })
|
|
35
|
+
} catch {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function collectWorkspaceComposers(userDir: string): Map<string, ComposerMeta> {
|
|
41
|
+
const composers = new Map<string, ComposerMeta>()
|
|
42
|
+
const workspaceStorage = join(userDir, 'workspaceStorage')
|
|
43
|
+
if (!existsSync(workspaceStorage)) return composers
|
|
44
|
+
|
|
45
|
+
for (const entry of readdirSync(workspaceStorage, { withFileTypes: true })) {
|
|
46
|
+
if (!entry.isDirectory()) continue
|
|
47
|
+
const wsDir = join(workspaceStorage, entry.name)
|
|
48
|
+
|
|
49
|
+
let cwd: string | null = null
|
|
50
|
+
const workspaceJson = join(wsDir, 'workspace.json')
|
|
51
|
+
if (existsSync(workspaceJson)) {
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(readFileSync(workspaceJson, 'utf8')) as { folder?: string }
|
|
54
|
+
if (typeof parsed.folder === 'string') cwd = folderUriToPath(parsed.folder)
|
|
55
|
+
} catch {
|
|
56
|
+
// ignore
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const dbPath = join(wsDir, 'state.vscdb')
|
|
61
|
+
if (!existsSync(dbPath)) continue
|
|
62
|
+
const db = openReadonly(dbPath)
|
|
63
|
+
if (!db) continue
|
|
64
|
+
try {
|
|
65
|
+
const row = db.query<{ value: string }, [string]>('SELECT value FROM ItemTable WHERE key = ?').get('composer.composerData')
|
|
66
|
+
if (!row) continue
|
|
67
|
+
const data = JSON.parse(row.value) as { allComposers?: Array<Record<string, unknown>> }
|
|
68
|
+
for (const composer of data.allComposers ?? []) {
|
|
69
|
+
const composerId = composer.composerId
|
|
70
|
+
if (typeof composerId !== 'string') continue
|
|
71
|
+
if (composers.has(composerId)) continue
|
|
72
|
+
composers.set(composerId, {
|
|
73
|
+
cwd,
|
|
74
|
+
name: typeof composer.name === 'string' ? composer.name : null,
|
|
75
|
+
createdAt: epochToIso(composer.createdAt),
|
|
76
|
+
updatedAt: epochToIso(composer.lastUpdatedAt),
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// ignore malformed workspace db
|
|
81
|
+
} finally {
|
|
82
|
+
db.close()
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return composers
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function buildSession(
|
|
90
|
+
globalDb: Database,
|
|
91
|
+
dbPath: string,
|
|
92
|
+
composerId: string,
|
|
93
|
+
meta: ComposerMeta,
|
|
94
|
+
): RawSession | null {
|
|
95
|
+
const composerRow = globalDb
|
|
96
|
+
.query<{ value: string }, [string]>('SELECT value FROM cursorDiskKV WHERE key = ?')
|
|
97
|
+
.get(`composerData:${composerId}`)
|
|
98
|
+
if (!composerRow) return null
|
|
99
|
+
|
|
100
|
+
let composer: { name?: string; createdAt?: number; fullConversationHeadersOnly?: Array<{ bubbleId: string; type: number }> }
|
|
101
|
+
try {
|
|
102
|
+
composer = JSON.parse(composerRow.value)
|
|
103
|
+
} catch {
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const headers = composer.fullConversationHeadersOnly ?? []
|
|
108
|
+
if (headers.length === 0) return null
|
|
109
|
+
|
|
110
|
+
const bubbleStmt = globalDb.query<{ value: string }, [string]>('SELECT value FROM cursorDiskKV WHERE key = ?')
|
|
111
|
+
const messages: RawMessage[] = []
|
|
112
|
+
for (const header of headers) {
|
|
113
|
+
if (header.type !== 1 && header.type !== 2) continue
|
|
114
|
+
const bubbleRow = bubbleStmt.get(`bubbleId:${composerId}:${header.bubbleId}`)
|
|
115
|
+
if (!bubbleRow) continue
|
|
116
|
+
let bubble: { text?: string }
|
|
117
|
+
try {
|
|
118
|
+
bubble = JSON.parse(bubbleRow.value)
|
|
119
|
+
} catch {
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
const text = typeof bubble.text === 'string' ? bubble.text.trim() : ''
|
|
123
|
+
if (text === '') continue
|
|
124
|
+
messages.push({ role: header.type === 1 ? 'user' : 'assistant', content: text, timestamp: null })
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (messages.length === 0) return null
|
|
128
|
+
if (isEphemeralPath(meta.cwd)) return null
|
|
129
|
+
|
|
130
|
+
const name = meta.name ?? composer.name ?? null
|
|
131
|
+
const title = name && name.trim() !== '' ? clampTitle(name.trim()) : deriveTitle(messages)
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
sourceSessionId: composerId,
|
|
135
|
+
rawPath: `${dbPath}#${composerId}`,
|
|
136
|
+
rawProjectPath: meta.cwd,
|
|
137
|
+
title,
|
|
138
|
+
createdAt: meta.createdAt ?? epochToIso(composer.createdAt),
|
|
139
|
+
updatedAt: meta.updatedAt,
|
|
140
|
+
messages,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const cursorCollector: Collector = {
|
|
145
|
+
id: 'cursor',
|
|
146
|
+
name: 'Cursor',
|
|
147
|
+
|
|
148
|
+
detect(homeDir: string): boolean {
|
|
149
|
+
return existsSync(join(homeDir, CURSOR_USER_DIR, 'globalStorage', 'state.vscdb'))
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
collect(homeDir: string): RawSession[] {
|
|
153
|
+
const userDir = join(homeDir, CURSOR_USER_DIR)
|
|
154
|
+
const globalDbPath = join(userDir, 'globalStorage', 'state.vscdb')
|
|
155
|
+
if (!existsSync(globalDbPath)) return []
|
|
156
|
+
|
|
157
|
+
const composers = collectWorkspaceComposers(userDir)
|
|
158
|
+
if (composers.size === 0) return []
|
|
159
|
+
|
|
160
|
+
const globalDb = openReadonly(globalDbPath)
|
|
161
|
+
if (!globalDb) return []
|
|
162
|
+
|
|
163
|
+
const sessions: RawSession[] = []
|
|
164
|
+
try {
|
|
165
|
+
for (const [composerId, meta] of composers) {
|
|
166
|
+
try {
|
|
167
|
+
const session = buildSession(globalDb, globalDbPath, composerId, meta)
|
|
168
|
+
if (session) sessions.push(session)
|
|
169
|
+
} catch {
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
} finally {
|
|
174
|
+
globalDb.close()
|
|
175
|
+
}
|
|
176
|
+
return sessions
|
|
177
|
+
},
|
|
178
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
|
2
|
+
import { basename, dirname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import type { Collector, RawMessage, RawSession } from './types'
|
|
5
|
+
import { deriveTitle, isEphemeralPath } from './util'
|
|
6
|
+
|
|
7
|
+
const INTERNAL_ROOTS = ['.gemini-internal', '.gemini']
|
|
8
|
+
|
|
9
|
+
function loadProjectNameMap(geminiRoot: string): Map<string, string> {
|
|
10
|
+
const nameToPath = new Map<string, string>()
|
|
11
|
+
const projectsFile = join(geminiRoot, 'projects.json')
|
|
12
|
+
if (!existsSync(projectsFile)) return nameToPath
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(readFileSync(projectsFile, 'utf8')) as { projects?: Record<string, string> }
|
|
15
|
+
for (const [path, name] of Object.entries(parsed.projects ?? {})) {
|
|
16
|
+
if (typeof name === 'string' && !nameToPath.has(name)) nameToPath.set(name, path)
|
|
17
|
+
}
|
|
18
|
+
} catch {
|
|
19
|
+
// ignore malformed projects.json
|
|
20
|
+
}
|
|
21
|
+
return nameToPath
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function looksLikeAbsolutePath(value: string): boolean {
|
|
25
|
+
const trimmed = value.trim()
|
|
26
|
+
return trimmed.startsWith('/') && !trimmed.includes('\n') && trimmed.length < 256
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseChatFile(filePath: string, projectDir: string, nameToPath: Map<string, string>): RawSession | null {
|
|
30
|
+
let parsed: {
|
|
31
|
+
sessionId?: string
|
|
32
|
+
startTime?: string
|
|
33
|
+
lastUpdated?: string
|
|
34
|
+
messages?: Array<{ type?: string; content?: unknown; timestamp?: string }>
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
parsed = JSON.parse(readFileSync(filePath, 'utf8'))
|
|
38
|
+
} catch {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const messages: RawMessage[] = []
|
|
43
|
+
for (const message of parsed.messages ?? []) {
|
|
44
|
+
if (message.type !== 'user' && message.type !== 'gemini') continue
|
|
45
|
+
const content = typeof message.content === 'string' ? message.content : ''
|
|
46
|
+
if (content.trim() === '') continue
|
|
47
|
+
messages.push({
|
|
48
|
+
role: message.type === 'user' ? 'user' : 'assistant',
|
|
49
|
+
content,
|
|
50
|
+
timestamp: typeof message.timestamp === 'string' ? message.timestamp : null,
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (messages.length === 0) return null
|
|
55
|
+
|
|
56
|
+
let rawProjectPath = nameToPath.get(projectDir) ?? null
|
|
57
|
+
if (!rawProjectPath) {
|
|
58
|
+
const firstUser = messages.find((message) => message.role === 'user')
|
|
59
|
+
if (firstUser && looksLikeAbsolutePath(firstUser.content)) {
|
|
60
|
+
rawProjectPath = firstUser.content.trim().replace(/\/+$/, '')
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (isEphemeralPath(rawProjectPath)) return null
|
|
64
|
+
|
|
65
|
+
const timestamps = messages.map((message) => message.timestamp).filter((value): value is string => value != null)
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
sourceSessionId: parsed.sessionId ?? basename(filePath).replace(/\.json$/, ''),
|
|
69
|
+
rawPath: filePath,
|
|
70
|
+
rawProjectPath,
|
|
71
|
+
title: deriveTitle(messages),
|
|
72
|
+
createdAt: parsed.startTime ?? timestamps[0] ?? null,
|
|
73
|
+
updatedAt: parsed.lastUpdated ?? timestamps[timestamps.length - 1] ?? null,
|
|
74
|
+
messages,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function resolveGeminiRoot(homeDir: string): string | null {
|
|
79
|
+
const seen = new Set<string>()
|
|
80
|
+
for (const root of INTERNAL_ROOTS) {
|
|
81
|
+
const dir = join(homeDir, root)
|
|
82
|
+
if (!existsSync(dir)) continue
|
|
83
|
+
let canonical: string
|
|
84
|
+
try {
|
|
85
|
+
canonical = realpathSync(dir)
|
|
86
|
+
} catch {
|
|
87
|
+
canonical = dir
|
|
88
|
+
}
|
|
89
|
+
if (seen.has(canonical)) continue
|
|
90
|
+
seen.add(canonical)
|
|
91
|
+
return dir
|
|
92
|
+
}
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function listChatFiles(geminiRoot: string): string[] {
|
|
97
|
+
const tmpDir = join(geminiRoot, 'tmp')
|
|
98
|
+
if (!existsSync(tmpDir)) return []
|
|
99
|
+
|
|
100
|
+
const files: string[] = []
|
|
101
|
+
for (const projectEntry of readdirSync(tmpDir, { withFileTypes: true })) {
|
|
102
|
+
if (!projectEntry.isDirectory()) continue
|
|
103
|
+
const chatsDir = join(tmpDir, projectEntry.name, 'chats')
|
|
104
|
+
if (!existsSync(chatsDir)) continue
|
|
105
|
+
for (const fileEntry of readdirSync(chatsDir, { withFileTypes: true })) {
|
|
106
|
+
if (fileEntry.isFile() && fileEntry.name.startsWith('session-') && fileEntry.name.endsWith('.json')) {
|
|
107
|
+
files.push(join(chatsDir, fileEntry.name))
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return files
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const geminiCollector: Collector = {
|
|
115
|
+
id: 'gemini',
|
|
116
|
+
name: 'Gemini',
|
|
117
|
+
|
|
118
|
+
detect(homeDir: string): boolean {
|
|
119
|
+
const root = resolveGeminiRoot(homeDir)
|
|
120
|
+
return root != null && existsSync(join(root, 'tmp'))
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
collect(homeDir: string): RawSession[] {
|
|
124
|
+
const geminiRoot = resolveGeminiRoot(homeDir)
|
|
125
|
+
if (!geminiRoot) return []
|
|
126
|
+
const nameToPath = loadProjectNameMap(geminiRoot)
|
|
127
|
+
|
|
128
|
+
const sessions: RawSession[] = []
|
|
129
|
+
for (const filePath of listChatFiles(geminiRoot)) {
|
|
130
|
+
try {
|
|
131
|
+
if (!statSync(filePath).isFile()) continue
|
|
132
|
+
const projectDir = basename(dirname(dirname(filePath)))
|
|
133
|
+
const session = parseChatFile(filePath, projectDir, nameToPath)
|
|
134
|
+
if (session) sessions.push(session)
|
|
135
|
+
} catch {
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return sessions
|
|
140
|
+
},
|
|
141
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { claudeCodeCollector } from './claude-code'
|
|
2
|
+
import { codebuddyCollector } from './codebuddy'
|
|
3
|
+
import { codexCollector } from './codex'
|
|
4
|
+
import { cursorCollector } from './cursor'
|
|
5
|
+
import { geminiCollector } from './gemini'
|
|
6
|
+
import type { Collector } from './types'
|
|
7
|
+
|
|
8
|
+
export const COLLECTORS: Collector[] = [
|
|
9
|
+
claudeCodeCollector,
|
|
10
|
+
codexCollector,
|
|
11
|
+
cursorCollector,
|
|
12
|
+
geminiCollector,
|
|
13
|
+
codebuddyCollector,
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
export function getCollector(id: string): Collector | null {
|
|
17
|
+
return COLLECTORS.find((collector) => collector.id === id) ?? null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type { Collector, RawMessage, RawSession } from './types'
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface RawMessage {
|
|
2
|
+
role: string
|
|
3
|
+
content: string
|
|
4
|
+
timestamp: string | null
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface RawSession {
|
|
8
|
+
sourceSessionId: string
|
|
9
|
+
rawPath: string
|
|
10
|
+
rawProjectPath: string | null
|
|
11
|
+
title: string | null
|
|
12
|
+
createdAt: string | null
|
|
13
|
+
updatedAt: string | null
|
|
14
|
+
messages: RawMessage[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface Collector {
|
|
18
|
+
id: string
|
|
19
|
+
name: string
|
|
20
|
+
detect(homeDir: string): boolean
|
|
21
|
+
collect(homeDir: string): RawSession[]
|
|
22
|
+
}
|