@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
package/src/obsidian.ts
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { stringify as stringifyYaml } from 'yaml'
|
|
5
|
+
|
|
6
|
+
import type { LlmIwikiDatabase } from './db'
|
|
7
|
+
import type { ProjectRecord } from './projects'
|
|
8
|
+
|
|
9
|
+
const MANAGED_START = '<!-- aiwiki:managed:start -->'
|
|
10
|
+
const MANAGED_END = '<!-- aiwiki:managed:end -->'
|
|
11
|
+
|
|
12
|
+
export type NoteWriteStatus = 'created' | 'updated' | 'conflict' | 'forced'
|
|
13
|
+
|
|
14
|
+
export interface NoteSpec {
|
|
15
|
+
noteType: string
|
|
16
|
+
entityId: string
|
|
17
|
+
relPath: string
|
|
18
|
+
frontmatter: Record<string, unknown>
|
|
19
|
+
title: string
|
|
20
|
+
managedBody: string
|
|
21
|
+
userSectionHeading?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ExportOptions {
|
|
25
|
+
force: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ExportReport {
|
|
29
|
+
created: number
|
|
30
|
+
updated: number
|
|
31
|
+
forced: number
|
|
32
|
+
conflicts: string[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function hash(value: string): string {
|
|
36
|
+
return Bun.hash(value).toString(16)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sanitizeFileName(name: string): string {
|
|
40
|
+
const cleaned = name
|
|
41
|
+
.replace(/[\\/:*?"<>|]/g, ' ')
|
|
42
|
+
.replace(/\s+/g, ' ')
|
|
43
|
+
.trim()
|
|
44
|
+
.slice(0, 80)
|
|
45
|
+
return cleaned === '' ? 'untitled' : cleaned
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function buildNote(spec: NoteSpec): string {
|
|
49
|
+
const frontmatter = stringifyYaml(spec.frontmatter).trimEnd()
|
|
50
|
+
const userHeading = spec.userSectionHeading ?? '## 我的补充'
|
|
51
|
+
return [
|
|
52
|
+
'---',
|
|
53
|
+
frontmatter,
|
|
54
|
+
'---',
|
|
55
|
+
'',
|
|
56
|
+
`# ${spec.title}`,
|
|
57
|
+
'',
|
|
58
|
+
MANAGED_START,
|
|
59
|
+
spec.managedBody.trim(),
|
|
60
|
+
MANAGED_END,
|
|
61
|
+
'',
|
|
62
|
+
userHeading,
|
|
63
|
+
'',
|
|
64
|
+
'',
|
|
65
|
+
].join('\n')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface ManagedSplit {
|
|
69
|
+
before: string
|
|
70
|
+
managed: string
|
|
71
|
+
after: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function splitManaged(content: string): ManagedSplit | null {
|
|
75
|
+
const startIndex = content.indexOf(MANAGED_START)
|
|
76
|
+
const endIndex = content.indexOf(MANAGED_END)
|
|
77
|
+
if (startIndex === -1 || endIndex === -1 || endIndex < startIndex) return null
|
|
78
|
+
return {
|
|
79
|
+
before: content.slice(0, startIndex + MANAGED_START.length),
|
|
80
|
+
managed: content.slice(startIndex + MANAGED_START.length, endIndex).trim(),
|
|
81
|
+
after: content.slice(endIndex),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function replaceManaged(split: ManagedSplit, managedBody: string): string {
|
|
86
|
+
return `${split.before}\n${managedBody.trim()}\n${split.after}`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface NoteRecord {
|
|
90
|
+
managed_hash: string | null
|
|
91
|
+
file_path: string
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function getNoteRecord(db: LlmIwikiDatabase, noteType: string, entityId: string): NoteRecord | null {
|
|
95
|
+
return db
|
|
96
|
+
.query<NoteRecord, [string, string]>(
|
|
97
|
+
'SELECT managed_hash, file_path FROM obsidian_notes WHERE note_type = ? AND entity_id = ?',
|
|
98
|
+
)
|
|
99
|
+
.get(noteType, entityId)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function upsertNoteRecord(
|
|
103
|
+
db: LlmIwikiDatabase,
|
|
104
|
+
spec: NoteSpec,
|
|
105
|
+
filePath: string,
|
|
106
|
+
managedHash: string,
|
|
107
|
+
conflictStatus: string,
|
|
108
|
+
now: string,
|
|
109
|
+
): void {
|
|
110
|
+
db.query(`
|
|
111
|
+
INSERT INTO obsidian_notes (id, note_type, entity_id, file_path, managed_hash, frontmatter_hash, last_exported_at, conflict_status)
|
|
112
|
+
VALUES ($id, $noteType, $entityId, $filePath, $managedHash, NULL, $now, $conflictStatus)
|
|
113
|
+
ON CONFLICT(note_type, entity_id) DO UPDATE SET
|
|
114
|
+
file_path = excluded.file_path,
|
|
115
|
+
managed_hash = excluded.managed_hash,
|
|
116
|
+
last_exported_at = excluded.last_exported_at,
|
|
117
|
+
conflict_status = excluded.conflict_status
|
|
118
|
+
`).run({
|
|
119
|
+
$id: `note_${hash(`${spec.noteType}\u0000${spec.entityId}`)}`,
|
|
120
|
+
$noteType: spec.noteType,
|
|
121
|
+
$entityId: spec.entityId,
|
|
122
|
+
$filePath: filePath,
|
|
123
|
+
$managedHash: managedHash,
|
|
124
|
+
$conflictStatus: conflictStatus,
|
|
125
|
+
$now: now,
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function writeNote(
|
|
130
|
+
db: LlmIwikiDatabase,
|
|
131
|
+
vault: string,
|
|
132
|
+
spec: NoteSpec,
|
|
133
|
+
options: ExportOptions,
|
|
134
|
+
now: string,
|
|
135
|
+
): NoteWriteStatus {
|
|
136
|
+
const filePath = join(vault, spec.relPath)
|
|
137
|
+
const managedHash = hash(spec.managedBody.trim())
|
|
138
|
+
|
|
139
|
+
if (!existsSync(filePath)) {
|
|
140
|
+
mkdirSync(dirname(filePath), { recursive: true })
|
|
141
|
+
writeFileSync(filePath, buildNote(spec))
|
|
142
|
+
upsertNoteRecord(db, spec, filePath, managedHash, 'clean', now)
|
|
143
|
+
return 'created'
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const existing = readFileSync(filePath, 'utf8')
|
|
147
|
+
const split = splitManaged(existing)
|
|
148
|
+
if (!split) {
|
|
149
|
+
upsertNoteRecord(db, spec, filePath, managedHash, 'no_managed_block', now)
|
|
150
|
+
return 'conflict'
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const currentManagedHash = hash(split.managed)
|
|
154
|
+
const record = getNoteRecord(db, spec.noteType, spec.entityId)
|
|
155
|
+
const userEditedManaged = !record || record.managed_hash !== currentManagedHash
|
|
156
|
+
|
|
157
|
+
if (userEditedManaged && !options.force) {
|
|
158
|
+
upsertNoteRecord(db, spec, filePath, record?.managed_hash ?? currentManagedHash, 'managed_conflict', now)
|
|
159
|
+
return 'conflict'
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
writeFileSync(filePath, replaceManaged(split, spec.managedBody))
|
|
163
|
+
upsertNoteRecord(db, spec, filePath, managedHash, 'clean', now)
|
|
164
|
+
return userEditedManaged ? 'forced' : 'updated'
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function projectDirName(project: ProjectRecord): string {
|
|
168
|
+
return sanitizeFileName(project.displayName ?? project.canonicalName ?? project.slug)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
interface SummaryRow {
|
|
172
|
+
id: string
|
|
173
|
+
session_id: string
|
|
174
|
+
title: string
|
|
175
|
+
value: string
|
|
176
|
+
summary_markdown: string
|
|
177
|
+
updated_at: string | null
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
interface ExperienceRow {
|
|
181
|
+
id: string
|
|
182
|
+
title: string
|
|
183
|
+
slug: string
|
|
184
|
+
body_markdown: string
|
|
185
|
+
confidence: string | null
|
|
186
|
+
status: string
|
|
187
|
+
updated_at: string
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function exportProject(
|
|
191
|
+
db: LlmIwikiDatabase,
|
|
192
|
+
vault: string,
|
|
193
|
+
project: ProjectRecord,
|
|
194
|
+
options: ExportOptions,
|
|
195
|
+
): ExportReport {
|
|
196
|
+
const now = new Date().toISOString()
|
|
197
|
+
const report: ExportReport = { created: 0, updated: 0, forced: 0, conflicts: [] }
|
|
198
|
+
const dirName = projectDirName(project)
|
|
199
|
+
const baseRel = join('LLM-iWiki', 'Projects', dirName)
|
|
200
|
+
const displayName = project.displayName ?? project.canonicalName
|
|
201
|
+
|
|
202
|
+
const track = (status: NoteWriteStatus, relPath: string): void => {
|
|
203
|
+
if (status === 'created') report.created += 1
|
|
204
|
+
else if (status === 'updated') report.updated += 1
|
|
205
|
+
else if (status === 'forced') report.forced += 1
|
|
206
|
+
else report.conflicts.push(relPath)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const summaries = db
|
|
210
|
+
.query<SummaryRow, [string]>(`
|
|
211
|
+
SELECT id, session_id, title, value, summary_markdown, updated_at
|
|
212
|
+
FROM session_summaries WHERE project_id = ? ORDER BY updated_at DESC
|
|
213
|
+
`)
|
|
214
|
+
.all(project.id)
|
|
215
|
+
|
|
216
|
+
for (const summary of summaries) {
|
|
217
|
+
const relPath = join(baseRel, 'Sessions', `${sanitizeFileName(summary.title)}.md`)
|
|
218
|
+
const spec: NoteSpec = {
|
|
219
|
+
noteType: 'session-summary',
|
|
220
|
+
entityId: summary.id,
|
|
221
|
+
relPath,
|
|
222
|
+
title: summary.title,
|
|
223
|
+
managedBody: summary.summary_markdown,
|
|
224
|
+
frontmatter: {
|
|
225
|
+
type: 'session-summary',
|
|
226
|
+
aiwiki_id: summary.id,
|
|
227
|
+
aiwiki_project_id: project.id,
|
|
228
|
+
project: displayName,
|
|
229
|
+
session_id: summary.session_id,
|
|
230
|
+
value: summary.value,
|
|
231
|
+
updated_at: summary.updated_at ?? now,
|
|
232
|
+
},
|
|
233
|
+
}
|
|
234
|
+
track(writeNote(db, vault, spec, options, now), relPath)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const experiences = db
|
|
238
|
+
.query<ExperienceRow, [string]>(`
|
|
239
|
+
SELECT id, title, slug, body_markdown, confidence, status, updated_at
|
|
240
|
+
FROM experiences WHERE project_id = ? AND status = 'accepted' ORDER BY updated_at DESC
|
|
241
|
+
`)
|
|
242
|
+
.all(project.id)
|
|
243
|
+
|
|
244
|
+
for (const experience of experiences) {
|
|
245
|
+
const relPath = join(baseRel, 'Experiences', `${sanitizeFileName(experience.slug || experience.title)}.md`)
|
|
246
|
+
const sourceSessions = db
|
|
247
|
+
.query<{ session_id: string }, [string]>(
|
|
248
|
+
'SELECT session_id FROM session_experience_links WHERE experience_id = ?',
|
|
249
|
+
)
|
|
250
|
+
.all(experience.id)
|
|
251
|
+
.map((row) => row.session_id)
|
|
252
|
+
const spec: NoteSpec = {
|
|
253
|
+
noteType: 'experience',
|
|
254
|
+
entityId: experience.id,
|
|
255
|
+
relPath,
|
|
256
|
+
title: experience.title,
|
|
257
|
+
managedBody: experience.body_markdown,
|
|
258
|
+
frontmatter: {
|
|
259
|
+
type: 'experience',
|
|
260
|
+
aiwiki_id: experience.id,
|
|
261
|
+
aiwiki_project_id: project.id,
|
|
262
|
+
project: displayName,
|
|
263
|
+
slug: experience.slug,
|
|
264
|
+
confidence: experience.confidence,
|
|
265
|
+
status: experience.status,
|
|
266
|
+
source_sessions: sourceSessions,
|
|
267
|
+
updated_at: experience.updated_at,
|
|
268
|
+
},
|
|
269
|
+
}
|
|
270
|
+
track(writeNote(db, vault, spec, options, now), relPath)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const indexRel = join(baseRel, 'Project Summary.md')
|
|
274
|
+
const indexBody = [
|
|
275
|
+
`- canonical: \`${project.canonicalName}\``,
|
|
276
|
+
project.canonicalRepoUrl ? `- repo: ${project.canonicalRepoUrl}` : null,
|
|
277
|
+
`- session summaries: ${summaries.length}`,
|
|
278
|
+
`- experiences: ${experiences.length}`,
|
|
279
|
+
]
|
|
280
|
+
.filter((line): line is string => line != null)
|
|
281
|
+
.join('\n')
|
|
282
|
+
const indexSpec: NoteSpec = {
|
|
283
|
+
noteType: 'project-summary',
|
|
284
|
+
entityId: project.id,
|
|
285
|
+
relPath: indexRel,
|
|
286
|
+
title: displayName,
|
|
287
|
+
managedBody: indexBody,
|
|
288
|
+
frontmatter: {
|
|
289
|
+
type: 'project-summary',
|
|
290
|
+
aiwiki_project_id: project.id,
|
|
291
|
+
project: displayName,
|
|
292
|
+
canonical_repo_url: project.canonicalRepoUrl,
|
|
293
|
+
updated_at: now,
|
|
294
|
+
},
|
|
295
|
+
}
|
|
296
|
+
track(writeNote(db, vault, indexSpec, options, now), indexRel)
|
|
297
|
+
|
|
298
|
+
return report
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export type CheckStatus = 'clean' | 'drift' | 'missing' | 'no_managed_block'
|
|
302
|
+
|
|
303
|
+
export interface CheckEntry {
|
|
304
|
+
noteType: string
|
|
305
|
+
entityId: string
|
|
306
|
+
filePath: string
|
|
307
|
+
status: CheckStatus
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export interface CheckReport {
|
|
311
|
+
total: number
|
|
312
|
+
clean: number
|
|
313
|
+
entries: CheckEntry[]
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function checkVault(db: LlmIwikiDatabase): CheckReport {
|
|
317
|
+
const rows = db
|
|
318
|
+
.query<{ note_type: string; entity_id: string; file_path: string; managed_hash: string | null }, []>(
|
|
319
|
+
'SELECT note_type, entity_id, file_path, managed_hash FROM obsidian_notes ORDER BY file_path ASC',
|
|
320
|
+
)
|
|
321
|
+
.all()
|
|
322
|
+
|
|
323
|
+
const entries: CheckEntry[] = []
|
|
324
|
+
let clean = 0
|
|
325
|
+
|
|
326
|
+
for (const row of rows) {
|
|
327
|
+
let status: CheckStatus
|
|
328
|
+
if (!existsSync(row.file_path)) {
|
|
329
|
+
status = 'missing'
|
|
330
|
+
} else {
|
|
331
|
+
const split = splitManaged(readFileSync(row.file_path, 'utf8'))
|
|
332
|
+
if (!split) {
|
|
333
|
+
status = 'no_managed_block'
|
|
334
|
+
} else if (row.managed_hash && hash(split.managed) === row.managed_hash) {
|
|
335
|
+
status = 'clean'
|
|
336
|
+
} else {
|
|
337
|
+
status = 'drift'
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (status === 'clean') clean += 1
|
|
341
|
+
else entries.push({ noteType: row.note_type, entityId: row.entity_id, filePath: row.file_path, status })
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return { total: rows.length, clean, entries }
|
|
345
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export interface AppPaths {
|
|
5
|
+
homeDir: string
|
|
6
|
+
configDir: string
|
|
7
|
+
configFile: string
|
|
8
|
+
databaseFile: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getAppPaths(homeDir = homedir()): AppPaths {
|
|
12
|
+
const configDir = join(homeDir, '.llm-iwiki')
|
|
13
|
+
return {
|
|
14
|
+
homeDir,
|
|
15
|
+
configDir,
|
|
16
|
+
configFile: join(configDir, 'config.toml'),
|
|
17
|
+
databaseFile: join(configDir, 'llm-iwiki.db'),
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getProjectTaskDir(cwd: string): string {
|
|
22
|
+
return resolve(cwd, '.llm-iwiki', 'tasks')
|
|
23
|
+
}
|
package/src/projects.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { basename, resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import type { LlmIwikiDatabase } from './db'
|
|
6
|
+
|
|
7
|
+
export interface ProjectRecord {
|
|
8
|
+
id: string
|
|
9
|
+
canonicalName: string
|
|
10
|
+
displayName: string | null
|
|
11
|
+
slug: string
|
|
12
|
+
canonicalRepoUrl: string | null
|
|
13
|
+
identitySource: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function canonicalizeRemoteUrl(remoteUrl: string): string {
|
|
17
|
+
return remoteUrl
|
|
18
|
+
.trim()
|
|
19
|
+
.replace(/\/+$/, '')
|
|
20
|
+
.replace(/^ssh:\/\/git@([^/]+)\//, '$1/')
|
|
21
|
+
.replace(/^git@([^:]+):/, '$1/')
|
|
22
|
+
.replace(/^https?:\/\//, '')
|
|
23
|
+
.replace(/\.git$/, '')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function slugifyProjectName(name: string): string {
|
|
27
|
+
const ascii = name
|
|
28
|
+
.normalize('NFKD')
|
|
29
|
+
.replace(/[^\w\s./-]/g, '')
|
|
30
|
+
.trim()
|
|
31
|
+
.toLowerCase()
|
|
32
|
+
.replace(/[\s._/]+/g, '-')
|
|
33
|
+
.replace(/-+/g, '-')
|
|
34
|
+
.replace(/^-|-$/g, '')
|
|
35
|
+
|
|
36
|
+
return ascii || 'project'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function git(cwd: string, args: string[]): string | null {
|
|
40
|
+
const result = spawnSync('git', args, { cwd, encoding: 'utf8' })
|
|
41
|
+
if (result.status !== 0) return null
|
|
42
|
+
return result.stdout.trim() || null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface ProjectIdentity {
|
|
46
|
+
id: string
|
|
47
|
+
canonicalName: string
|
|
48
|
+
slug: string
|
|
49
|
+
canonicalRepoUrl: string | null
|
|
50
|
+
identitySource: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function computeProjectIdentity(localPath: string): ProjectIdentity {
|
|
54
|
+
const gitRoot = git(localPath, ['rev-parse', '--show-toplevel'])
|
|
55
|
+
const remote = git(localPath, ['config', '--get', 'remote.origin.url'])
|
|
56
|
+
const canonicalRepoUrl = remote ? canonicalizeRemoteUrl(remote) : null
|
|
57
|
+
const canonicalName = canonicalRepoUrl ?? basename(gitRoot ?? localPath)
|
|
58
|
+
const slug = slugifyProjectName(canonicalName)
|
|
59
|
+
const id = `proj_${Bun.hash(canonicalRepoUrl ?? gitRoot ?? localPath).toString(16)}`
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
id,
|
|
63
|
+
canonicalName,
|
|
64
|
+
slug,
|
|
65
|
+
canonicalRepoUrl,
|
|
66
|
+
identitySource: canonicalRepoUrl ? 'git_remote' : 'path',
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function upsertProjectIdentity(db: LlmIwikiDatabase, identity: ProjectIdentity): ProjectRecord {
|
|
71
|
+
const now = new Date().toISOString()
|
|
72
|
+
db.query(`
|
|
73
|
+
INSERT INTO projects (id, canonical_name, display_name, slug, canonical_repo_url, provider, identity_source, created_at, updated_at)
|
|
74
|
+
VALUES ($id, $canonicalName, NULL, $slug, $canonicalRepoUrl, NULL, $identitySource, $now, $now)
|
|
75
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
76
|
+
canonical_name = excluded.canonical_name,
|
|
77
|
+
slug = excluded.slug,
|
|
78
|
+
canonical_repo_url = excluded.canonical_repo_url,
|
|
79
|
+
identity_source = excluded.identity_source,
|
|
80
|
+
updated_at = excluded.updated_at
|
|
81
|
+
`).run({
|
|
82
|
+
$id: identity.id,
|
|
83
|
+
$canonicalName: identity.canonicalName,
|
|
84
|
+
$slug: identity.slug,
|
|
85
|
+
$canonicalRepoUrl: identity.canonicalRepoUrl,
|
|
86
|
+
$identitySource: identity.identitySource,
|
|
87
|
+
$now: now,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
return getProject(db, identity.id)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function resolveProject(db: LlmIwikiDatabase, checkoutPath: string): ProjectRecord {
|
|
94
|
+
const localPath = resolve(checkoutPath)
|
|
95
|
+
if (!existsSync(localPath)) throw new Error(`Path does not exist: ${localPath}`)
|
|
96
|
+
return upsertProjectIdentity(db, computeProjectIdentity(localPath))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function resolveProjectByPath(db: LlmIwikiDatabase, rawPath: string): ProjectRecord {
|
|
100
|
+
const localPath = resolve(rawPath)
|
|
101
|
+
return upsertProjectIdentity(db, computeProjectIdentity(localPath))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function renameProject(db: LlmIwikiDatabase, projectId: string, displayName: string): ProjectRecord {
|
|
105
|
+
const now = new Date().toISOString()
|
|
106
|
+
const changes = db
|
|
107
|
+
.query('UPDATE projects SET display_name = $displayName, updated_at = $now WHERE id = $projectId')
|
|
108
|
+
.run({
|
|
109
|
+
$displayName: displayName,
|
|
110
|
+
$projectId: projectId,
|
|
111
|
+
$now: now,
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
if (changes.changes === 0) throw new Error(`Project not found: ${projectId}`)
|
|
115
|
+
|
|
116
|
+
return getProject(db, projectId)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface ProjectSummaryRow extends ProjectRecord {
|
|
120
|
+
sessionCount: number
|
|
121
|
+
lastSeenAt: string | null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function listProjects(db: LlmIwikiDatabase): ProjectSummaryRow[] {
|
|
125
|
+
const rows = db
|
|
126
|
+
.query<
|
|
127
|
+
{
|
|
128
|
+
id: string
|
|
129
|
+
canonical_name: string
|
|
130
|
+
display_name: string | null
|
|
131
|
+
slug: string
|
|
132
|
+
canonical_repo_url: string | null
|
|
133
|
+
identity_source: string
|
|
134
|
+
session_count: number
|
|
135
|
+
last_seen_at: string | null
|
|
136
|
+
},
|
|
137
|
+
[]
|
|
138
|
+
>(`
|
|
139
|
+
SELECT
|
|
140
|
+
p.id,
|
|
141
|
+
p.canonical_name,
|
|
142
|
+
p.display_name,
|
|
143
|
+
p.slug,
|
|
144
|
+
p.canonical_repo_url,
|
|
145
|
+
p.identity_source,
|
|
146
|
+
COUNT(s.id) AS session_count,
|
|
147
|
+
MAX(s.last_seen_at) AS last_seen_at
|
|
148
|
+
FROM projects p
|
|
149
|
+
LEFT JOIN sessions s ON s.project_id = p.id
|
|
150
|
+
GROUP BY p.id
|
|
151
|
+
ORDER BY session_count DESC, p.canonical_name ASC
|
|
152
|
+
`)
|
|
153
|
+
.all()
|
|
154
|
+
|
|
155
|
+
return rows.map((row) => ({
|
|
156
|
+
id: row.id,
|
|
157
|
+
canonicalName: row.canonical_name,
|
|
158
|
+
displayName: row.display_name,
|
|
159
|
+
slug: row.slug,
|
|
160
|
+
canonicalRepoUrl: row.canonical_repo_url,
|
|
161
|
+
identitySource: row.identity_source,
|
|
162
|
+
sessionCount: row.session_count,
|
|
163
|
+
lastSeenAt: row.last_seen_at,
|
|
164
|
+
}))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function getProject(db: LlmIwikiDatabase, projectId: string): ProjectRecord {
|
|
168
|
+
const row = db
|
|
169
|
+
.query<
|
|
170
|
+
{
|
|
171
|
+
id: string
|
|
172
|
+
canonical_name: string
|
|
173
|
+
display_name: string | null
|
|
174
|
+
slug: string
|
|
175
|
+
canonical_repo_url: string | null
|
|
176
|
+
identity_source: string
|
|
177
|
+
},
|
|
178
|
+
[string]
|
|
179
|
+
>('SELECT * FROM projects WHERE id = ?')
|
|
180
|
+
.get(projectId)
|
|
181
|
+
|
|
182
|
+
if (!row) throw new Error(`Project not found: ${projectId}`)
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
id: row.id,
|
|
186
|
+
canonicalName: row.canonical_name,
|
|
187
|
+
displayName: row.display_name,
|
|
188
|
+
slug: row.slug,
|
|
189
|
+
canonicalRepoUrl: row.canonical_repo_url,
|
|
190
|
+
identitySource: row.identity_source,
|
|
191
|
+
}
|
|
192
|
+
}
|
package/src/sessions.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { LlmIwikiDatabase } from './db'
|
|
2
|
+
|
|
3
|
+
export interface SessionRow {
|
|
4
|
+
id: string
|
|
5
|
+
sourceId: string
|
|
6
|
+
sourceSessionId: string
|
|
7
|
+
title: string | null
|
|
8
|
+
messageCount: number
|
|
9
|
+
status: string
|
|
10
|
+
rawProjectPath: string | null
|
|
11
|
+
updatedAt: string | null
|
|
12
|
+
lastSeenAt: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SourceBreakdown {
|
|
16
|
+
source: string
|
|
17
|
+
sessionCount: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ProjectInspection {
|
|
21
|
+
sources: SourceBreakdown[]
|
|
22
|
+
sessions: SessionRow[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function mapRow(row: {
|
|
26
|
+
id: string
|
|
27
|
+
source_id: string
|
|
28
|
+
source_session_id: string
|
|
29
|
+
title: string | null
|
|
30
|
+
message_count: number
|
|
31
|
+
status: string
|
|
32
|
+
raw_project_path: string | null
|
|
33
|
+
updated_at: string | null
|
|
34
|
+
last_seen_at: string
|
|
35
|
+
}): SessionRow {
|
|
36
|
+
return {
|
|
37
|
+
id: row.id,
|
|
38
|
+
sourceId: row.source_id,
|
|
39
|
+
sourceSessionId: row.source_session_id,
|
|
40
|
+
title: row.title,
|
|
41
|
+
messageCount: row.message_count,
|
|
42
|
+
status: row.status,
|
|
43
|
+
rawProjectPath: row.raw_project_path,
|
|
44
|
+
updatedAt: row.updated_at,
|
|
45
|
+
lastSeenAt: row.last_seen_at,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function listSessionsByProject(db: LlmIwikiDatabase, projectId: string, limit = 100): SessionRow[] {
|
|
50
|
+
return db
|
|
51
|
+
.query<Parameters<typeof mapRow>[0], [string, number]>(`
|
|
52
|
+
SELECT id, source_id, source_session_id, title, message_count, status, raw_project_path, updated_at, last_seen_at
|
|
53
|
+
FROM sessions
|
|
54
|
+
WHERE project_id = ?
|
|
55
|
+
ORDER BY COALESCE(updated_at, last_seen_at) DESC
|
|
56
|
+
LIMIT ?
|
|
57
|
+
`)
|
|
58
|
+
.all(projectId, limit)
|
|
59
|
+
.map(mapRow)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface StoredMessage {
|
|
63
|
+
role: string
|
|
64
|
+
content: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function getSessionMessages(db: LlmIwikiDatabase, sessionId: string): StoredMessage[] {
|
|
68
|
+
return db
|
|
69
|
+
.query<{ role: string; content: string }, [string]>(
|
|
70
|
+
'SELECT role, content FROM messages WHERE session_id = ? ORDER BY seq_order ASC',
|
|
71
|
+
)
|
|
72
|
+
.all(sessionId)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function getSession(db: LlmIwikiDatabase, sessionId: string): SessionRow | null {
|
|
76
|
+
const row = db
|
|
77
|
+
.query<Parameters<typeof mapRow>[0], [string]>(`
|
|
78
|
+
SELECT id, source_id, source_session_id, title, message_count, status, raw_project_path, updated_at, last_seen_at
|
|
79
|
+
FROM sessions
|
|
80
|
+
WHERE id = ?
|
|
81
|
+
`)
|
|
82
|
+
.get(sessionId)
|
|
83
|
+
return row ? mapRow(row) : null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function listSessionsToSummarize(
|
|
87
|
+
db: LlmIwikiDatabase,
|
|
88
|
+
projectId: string,
|
|
89
|
+
scope: 'changed' | 'all',
|
|
90
|
+
): SessionRow[] {
|
|
91
|
+
const statusClause = scope === 'changed' ? "AND status IN ('new', 'changed')" : ''
|
|
92
|
+
return db
|
|
93
|
+
.query<Parameters<typeof mapRow>[0], [string]>(`
|
|
94
|
+
SELECT id, source_id, source_session_id, title, message_count, status, raw_project_path, updated_at, last_seen_at
|
|
95
|
+
FROM sessions
|
|
96
|
+
WHERE project_id = ? ${statusClause}
|
|
97
|
+
ORDER BY COALESCE(updated_at, last_seen_at) DESC
|
|
98
|
+
`)
|
|
99
|
+
.all(projectId)
|
|
100
|
+
.map(mapRow)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function inspectProject(db: LlmIwikiDatabase, projectId: string): ProjectInspection {
|
|
104
|
+
const sources = db
|
|
105
|
+
.query<{ source_id: string; session_count: number }, [string]>(`
|
|
106
|
+
SELECT source_id, COUNT(*) AS session_count
|
|
107
|
+
FROM sessions
|
|
108
|
+
WHERE project_id = ?
|
|
109
|
+
GROUP BY source_id
|
|
110
|
+
ORDER BY session_count DESC
|
|
111
|
+
`)
|
|
112
|
+
.all(projectId)
|
|
113
|
+
.map((row) => ({ source: row.source_id, sessionCount: row.session_count }))
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
sources,
|
|
117
|
+
sessions: listSessionsByProject(db, projectId),
|
|
118
|
+
}
|
|
119
|
+
}
|