@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.
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }