@codehourra/llm-iwiki 0.1.0 → 0.1.1

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.
@@ -1,235 +0,0 @@
1
- import type { ParsedExperiencesYaml } from './types'
2
- import type { LlmIwikiDatabase } from './db'
3
- import { slugifyProjectName } from './projects'
4
-
5
- export type ExperienceScope = 'changed-summaries' | 'all-recent'
6
-
7
- export interface PrepareExperiencesResult {
8
- markdown: string
9
- summaryCount: number
10
- }
11
-
12
- interface SummaryRow {
13
- session_id: string
14
- title: string
15
- value: string
16
- summary_markdown: string
17
- }
18
-
19
- const EXPERIENCES_FORMAT = `## 输出格式
20
-
21
- 基于下面这些会话摘要,提炼出可复用的项目经验,生成 \`experiences.yaml\` 写入
22
- \`.llm-iwiki/tasks/experiences.yaml\`,再运行 \`llm-iwiki experiences propose\`。
23
-
24
- \`\`\`yaml
25
- project_id: <见下方 project_id>
26
- experiences:
27
- - title: <经验标题>
28
- slug: <可选,稳定短标识>
29
- summary: |
30
- 一句话说明这条经验。
31
- body_markdown: |
32
- ## 背景
33
- ## 方案
34
- ## 结论
35
- source_sessions:
36
- - <相关 session_id>
37
- confidence: low | medium | high
38
- \`\`\`
39
-
40
- 要求:
41
- - 一条经验可以聚合多个会话,按主题归纳,不要逐会话复述。
42
- - \`source_sessions\` 填写该经验来自哪些 session_id。`
43
-
44
- function fetchSummaries(db: LlmIwikiDatabase, projectId: string, scope: ExperienceScope): SummaryRow[] {
45
- const valueClause = scope === 'changed-summaries' ? "AND value IN ('medium', 'high')" : ''
46
- return db
47
- .query<SummaryRow, [string]>(`
48
- SELECT session_id, title, value, summary_markdown
49
- FROM session_summaries
50
- WHERE project_id = ? ${valueClause}
51
- ORDER BY updated_at DESC
52
- `)
53
- .all(projectId)
54
- }
55
-
56
- export function prepareExperiencesTask(
57
- db: LlmIwikiDatabase,
58
- projectId: string,
59
- scope: ExperienceScope,
60
- ): PrepareExperiencesResult {
61
- const summaries = fetchSummaries(db, projectId, scope)
62
-
63
- const blocks = summaries.map((summary) =>
64
- [`### ${summary.session_id} — ${summary.title}`, `- value: ${summary.value}`, '', summary.summary_markdown].join('\n'),
65
- )
66
-
67
- const markdown = [
68
- `# Experiences Task`,
69
- '',
70
- `project_id: ${projectId}`,
71
- `scope: ${scope}`,
72
- `summaries: ${blocks.length}`,
73
- '',
74
- EXPERIENCES_FORMAT,
75
- '',
76
- '---',
77
- '',
78
- blocks.join('\n\n---\n\n'),
79
- '',
80
- ].join('\n')
81
-
82
- return { markdown, summaryCount: blocks.length }
83
- }
84
-
85
- export interface ProposeExperiencesResult {
86
- written: number
87
- }
88
-
89
- function hash(value: string): string {
90
- return Bun.hash(value).toString(16)
91
- }
92
-
93
- export function proposeExperiences(db: LlmIwikiDatabase, parsed: ParsedExperiencesYaml): ProposeExperiencesResult {
94
- const now = new Date().toISOString()
95
- let written = 0
96
-
97
- const propose = db.transaction(() => {
98
- for (const experience of parsed.experiences) {
99
- const slug = experience.slug && experience.slug.trim() !== '' ? experience.slug : slugifyProjectName(experience.title)
100
- const id = `cand_${hash(`${parsed.projectId}\u0000${slug}`)}`
101
-
102
- db.query(`
103
- INSERT INTO experience_candidates (
104
- id, project_id, proposed_title, proposed_slug, proposed_body_markdown, source_sessions_json, confidence, status, created_at
105
- ) VALUES ($id, $projectId, $title, $slug, $body, $sources, $confidence, 'proposed', $now)
106
- ON CONFLICT(id) DO UPDATE SET
107
- proposed_title = excluded.proposed_title,
108
- proposed_body_markdown = excluded.proposed_body_markdown,
109
- source_sessions_json = excluded.source_sessions_json,
110
- confidence = excluded.confidence
111
- `).run({
112
- $id: id,
113
- $projectId: parsed.projectId,
114
- $title: experience.title,
115
- $slug: slug,
116
- $body: experience.bodyMarkdown,
117
- $sources: JSON.stringify(experience.sourceSessions),
118
- $confidence: experience.confidence,
119
- $now: now,
120
- })
121
- written += 1
122
- }
123
- })
124
- propose()
125
-
126
- return { written }
127
- }
128
-
129
- export interface CandidateRow {
130
- id: string
131
- project_id: string
132
- proposed_title: string
133
- proposed_slug: string
134
- confidence: string | null
135
- status: string
136
- source_sessions_json: string
137
- created_at: string
138
- }
139
-
140
- export function listCandidates(db: LlmIwikiDatabase, projectId: string | null): CandidateRow[] {
141
- if (projectId) {
142
- return db
143
- .query<CandidateRow, [string]>(
144
- 'SELECT id, project_id, proposed_title, proposed_slug, confidence, status, source_sessions_json, created_at FROM experience_candidates WHERE project_id = ? ORDER BY created_at DESC',
145
- )
146
- .all(projectId)
147
- }
148
- return db
149
- .query<CandidateRow, []>(
150
- 'SELECT id, project_id, proposed_title, proposed_slug, confidence, status, source_sessions_json, created_at FROM experience_candidates ORDER BY created_at DESC',
151
- )
152
- .all()
153
- }
154
-
155
- export interface AcceptExperienceResult {
156
- experienceId: string
157
- slug: string
158
- linkedSessions: number
159
- }
160
-
161
- export function acceptExperience(db: LlmIwikiDatabase, candidateId: string): AcceptExperienceResult {
162
- const candidate = db
163
- .query<
164
- {
165
- project_id: string
166
- proposed_title: string
167
- proposed_slug: string
168
- proposed_body_markdown: string
169
- confidence: string | null
170
- source_sessions_json: string
171
- },
172
- [string]
173
- >(
174
- 'SELECT project_id, proposed_title, proposed_slug, proposed_body_markdown, confidence, source_sessions_json FROM experience_candidates WHERE id = ?',
175
- )
176
- .get(candidateId)
177
-
178
- if (!candidate) throw new Error(`Experience candidate not found: ${candidateId}`)
179
-
180
- let sourceSessions: string[] = []
181
- try {
182
- const parsed = JSON.parse(candidate.source_sessions_json) as unknown
183
- if (Array.isArray(parsed)) sourceSessions = parsed.filter((value): value is string => typeof value === 'string')
184
- } catch {
185
- sourceSessions = []
186
- }
187
-
188
- const now = new Date().toISOString()
189
- const experienceId = `exp_${hash(`${candidate.project_id}\u0000${candidate.proposed_slug}`)}`
190
-
191
- const accept = db.transaction(() => {
192
- db.query(`
193
- INSERT INTO experiences (
194
- id, project_id, title, slug, body_markdown, confidence, status, created_at, updated_at
195
- ) VALUES ($id, $projectId, $title, $slug, $body, $confidence, 'accepted', $now, $now)
196
- ON CONFLICT(project_id, slug) DO UPDATE SET
197
- title = excluded.title,
198
- body_markdown = excluded.body_markdown,
199
- confidence = excluded.confidence,
200
- status = 'accepted',
201
- updated_at = excluded.updated_at
202
- `).run({
203
- $id: experienceId,
204
- $projectId: candidate.project_id,
205
- $title: candidate.proposed_title,
206
- $slug: candidate.proposed_slug,
207
- $body: candidate.proposed_body_markdown,
208
- $confidence: candidate.confidence,
209
- $now: now,
210
- })
211
-
212
- const resolved = db
213
- .query<{ id: string }, [string, string]>('SELECT id FROM experiences WHERE project_id = ? AND slug = ?')
214
- .get(candidate.project_id, candidate.proposed_slug)
215
- const finalId = resolved?.id ?? experienceId
216
-
217
- for (const sessionId of sourceSessions) {
218
- db.query(`
219
- INSERT INTO session_experience_links (session_id, experience_id, relation)
220
- VALUES (?, ?, 'source')
221
- ON CONFLICT(session_id, experience_id) DO NOTHING
222
- `).run(sessionId, finalId)
223
- }
224
-
225
- db.query("UPDATE experience_candidates SET status = 'accepted' WHERE id = ?").run(candidateId)
226
- })
227
- accept()
228
-
229
- return { experienceId, slug: candidate.proposed_slug, linkedSessions: sourceSessions.length }
230
- }
231
-
232
- export function rejectExperience(db: LlmIwikiDatabase, candidateId: string): void {
233
- const changes = db.query("UPDATE experience_candidates SET status = 'rejected' WHERE id = ?").run(candidateId)
234
- if (changes.changes === 0) throw new Error(`Experience candidate not found: ${candidateId}`)
235
- }
package/src/index.ts DELETED
@@ -1,10 +0,0 @@
1
- #!/usr/bin/env bun
2
- import { runCli } from './cli'
3
-
4
- const exitCode = await runCli(process.argv.slice(2), {
5
- cwd: process.cwd(),
6
- stdout: console.log,
7
- stderr: console.error,
8
- })
9
-
10
- process.exit(exitCode)
package/src/obsidian.ts DELETED
@@ -1,345 +0,0 @@
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 DELETED
@@ -1,23 +0,0 @@
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
- }