@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/src/skills.ts ADDED
@@ -0,0 +1,168 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ export type SkillTarget = 'codex' | 'claude-code' | 'cursor'
5
+
6
+ export const SKILL_TARGETS = ['codex', 'claude-code', 'cursor'] as const satisfies readonly SkillTarget[]
7
+
8
+ export interface InitSkillsOptions {
9
+ cwd: string
10
+ target: SkillTarget | null
11
+ force: boolean
12
+ dryRun: boolean
13
+ }
14
+
15
+ export interface InitSkillsResult {
16
+ written: string[]
17
+ skipped: string[]
18
+ }
19
+
20
+ interface SkillTemplate {
21
+ directory: string
22
+ content: string
23
+ }
24
+
25
+ const TARGET_NAMES: Record<SkillTarget, string> = {
26
+ codex: 'Codex',
27
+ 'claude-code': 'Claude Code',
28
+ cursor: 'Cursor',
29
+ }
30
+
31
+ const SKILL_TEMPLATES: SkillTemplate[] = [
32
+ {
33
+ directory: 'aiwiki-after-session',
34
+ content: `---
35
+ name: aiwiki-after-session
36
+ description: Capture session knowledge into llm-iwiki summaries, experiences, and Obsidian export after project work.
37
+ ---
38
+
39
+ # AIWiki After Session
40
+
41
+ Use this skill after a meaningful coding or design session in the current project.
42
+
43
+ ## Steps
44
+
45
+ 1. Sync the current project:
46
+ \`\`\`bash
47
+ llm-iwiki sync --project .
48
+ \`\`\`
49
+ 2. Prepare changed summaries:
50
+ \`\`\`bash
51
+ llm-iwiki summarize prepare changed --project . --out .llm-iwiki/tasks/summaries-task.md
52
+ \`\`\`
53
+ 3. Generate \`.llm-iwiki/tasks/summaries.yaml\` from the prepared task.
54
+ 4. Apply the summaries:
55
+ \`\`\`bash
56
+ llm-iwiki summarize apply --project . --file .llm-iwiki/tasks/summaries.yaml
57
+ \`\`\`
58
+ 5. Prepare experience proposals from changed summaries:
59
+ \`\`\`bash
60
+ llm-iwiki experiences prepare --project . --from changed-summaries --out .llm-iwiki/tasks/experiences-task.md
61
+ \`\`\`
62
+ 6. Generate \`.llm-iwiki/tasks/experiences.yaml\` from the prepared task.
63
+ 7. Propose the experiences:
64
+ \`\`\`bash
65
+ llm-iwiki experiences propose --project . --file .llm-iwiki/tasks/experiences.yaml
66
+ \`\`\`
67
+ 8. Export project knowledge to Obsidian:
68
+ \`\`\`bash
69
+ llm-iwiki obsidian export --project .
70
+ \`\`\`
71
+ `,
72
+ },
73
+ {
74
+ directory: 'aiwiki-before-debug',
75
+ content: `---
76
+ name: aiwiki-before-debug
77
+ description: Search project memory before debugging so related history is visible before changing code.
78
+ ---
79
+
80
+ # AIWiki Before Debug
81
+
82
+ Use this skill before investigating a bug, failure, error message, or confusing behavior.
83
+
84
+ ## Steps
85
+
86
+ 1. Search the full project knowledge index:
87
+ \`\`\`bash
88
+ llm-iwiki search "<error or topic>" --project . --index all
89
+ \`\`\`
90
+ 2. Read any related summaries, experiences, or prior decisions before editing code.
91
+ 3. Report whether related history was found, and name the most relevant item when it was found.
92
+ `,
93
+ },
94
+ {
95
+ directory: 'aiwiki-project-retrospective',
96
+ content: `---
97
+ name: aiwiki-project-retrospective
98
+ description: Review recent project knowledge and extract retrospective themes from llm-iwiki.
99
+ ---
100
+
101
+ # AIWiki Project Retrospective
102
+
103
+ Use this skill when preparing a project retrospective or looking for recent repeated lessons.
104
+
105
+ ## Steps
106
+
107
+ 1. Prepare a retrospective from recent project history:
108
+ \`\`\`bash
109
+ llm-iwiki experiences prepare --project . --from all-recent --since 30d --out .llm-iwiki/tasks/retrospective-task.md
110
+ \`\`\`
111
+ 2. Review the prepared task for repeated themes, unresolved questions, and process improvements.
112
+ 3. Capture any accepted learnings through the normal llm-iwiki experience proposal flow.
113
+ `,
114
+ },
115
+ ]
116
+
117
+ function appendTargetGuidance(content: string, target: SkillTarget | null): string {
118
+ if (!target) return content
119
+
120
+ return `${content}
121
+ ## Tool Target
122
+
123
+ This skill is initialized for ${TARGET_NAMES[target]}.
124
+ `
125
+ }
126
+
127
+ function isFileExistsError(error: unknown): boolean {
128
+ return error instanceof Error && 'code' in error && error.code === 'EEXIST'
129
+ }
130
+
131
+ export function initSkills(options: InitSkillsOptions): InitSkillsResult {
132
+ const written: string[] = []
133
+ const skipped: string[] = []
134
+
135
+ for (const template of SKILL_TEMPLATES) {
136
+ const filePath = join(options.cwd, '.agents', 'skills', template.directory, 'SKILL.md')
137
+
138
+ if (existsSync(filePath) && !options.force) {
139
+ skipped.push(filePath)
140
+ continue
141
+ }
142
+
143
+ written.push(filePath)
144
+
145
+ if (options.dryRun) {
146
+ continue
147
+ }
148
+
149
+ mkdirSync(dirname(filePath), { recursive: true })
150
+ const content = appendTargetGuidance(template.content, options.target)
151
+ if (options.force) {
152
+ writeFileSync(filePath, content)
153
+ continue
154
+ }
155
+
156
+ try {
157
+ writeFileSync(filePath, content, { flag: 'wx' })
158
+ } catch (error) {
159
+ if (!isFileExistsError(error)) {
160
+ throw error
161
+ }
162
+ written.pop()
163
+ skipped.push(filePath)
164
+ }
165
+ }
166
+
167
+ return { written, skipped }
168
+ }
@@ -0,0 +1,122 @@
1
+ import type { ParsedSummariesYaml } from './types'
2
+ import type { LlmIwikiDatabase } from './db'
3
+ import { compactTranscript } from './compaction'
4
+ import { getSessionMessages, listSessionsToSummarize } from './sessions'
5
+
6
+ export interface PrepareSummariesResult {
7
+ markdown: string
8
+ sessionCount: number
9
+ }
10
+
11
+ const SUMMARIES_FORMAT = `## 输出格式
12
+
13
+ 请阅读下面每个会话的压缩记录,为有价值的会话生成一份 \`summaries.yaml\`,写入
14
+ \`.llm-iwiki/tasks/summaries.yaml\`,再运行 \`llm-iwiki summarize apply\`。
15
+
16
+ \`\`\`yaml
17
+ project_id: <见下方 project_id>
18
+ summaries:
19
+ - session_id: <会话的 session_id,原样照抄>
20
+ title: <一句话标题>
21
+ value: none | low | medium | high
22
+ summary_markdown: |
23
+ 用 Markdown 概括这次会话解决了什么问题、关键决策、结论。
24
+ \`\`\`
25
+
26
+ 要求:
27
+ - \`value\` 表示这次会话的沉淀价值,\`medium\` / \`high\` 会进入经验提取。
28
+ - \`session_id\` 必须与下面给出的完全一致。
29
+ - 没有价值的会话可以省略,不必每个都写。`
30
+
31
+ export function prepareSummariesTask(
32
+ db: LlmIwikiDatabase,
33
+ projectId: string,
34
+ scope: 'changed' | 'all',
35
+ ): PrepareSummariesResult {
36
+ const sessions = listSessionsToSummarize(db, projectId, scope)
37
+
38
+ const blocks: string[] = []
39
+ for (const session of sessions) {
40
+ const messages = getSessionMessages(db, session.id)
41
+ if (messages.length === 0) continue
42
+ const transcript = compactTranscript(messages)
43
+ blocks.push(
44
+ [
45
+ `### ${session.id}`,
46
+ `- source: ${session.sourceId}`,
47
+ `- title: ${session.title ?? '(无标题)'}`,
48
+ `- messages: ${session.messageCount}`,
49
+ '',
50
+ transcript,
51
+ ].join('\n'),
52
+ )
53
+ }
54
+
55
+ const markdown = [
56
+ `# Summaries Task`,
57
+ '',
58
+ `project_id: ${projectId}`,
59
+ `scope: ${scope}`,
60
+ `sessions: ${blocks.length}`,
61
+ '',
62
+ SUMMARIES_FORMAT,
63
+ '',
64
+ '---',
65
+ '',
66
+ blocks.join('\n\n---\n\n'),
67
+ '',
68
+ ].join('\n')
69
+
70
+ return { markdown, sessionCount: blocks.length }
71
+ }
72
+
73
+ export interface ApplySummariesResult {
74
+ written: number
75
+ skipped: string[]
76
+ }
77
+
78
+ function hash(value: string): string {
79
+ return Bun.hash(value).toString(16)
80
+ }
81
+
82
+ export function applySummaries(db: LlmIwikiDatabase, parsed: ParsedSummariesYaml): ApplySummariesResult {
83
+ const now = new Date().toISOString()
84
+ let written = 0
85
+ const skipped: string[] = []
86
+
87
+ const apply = db.transaction(() => {
88
+ for (const summary of parsed.summaries) {
89
+ const session = db
90
+ .query<{ project_id: string | null }, [string]>('SELECT project_id FROM sessions WHERE id = ?')
91
+ .get(summary.sessionId)
92
+ if (!session) {
93
+ skipped.push(summary.sessionId)
94
+ continue
95
+ }
96
+
97
+ db.query(`
98
+ INSERT INTO session_summaries (id, session_id, project_id, title, value, summary_markdown, metadata_json, created_at, updated_at)
99
+ VALUES ($id, $sessionId, $projectId, $title, $value, $summaryMarkdown, $metadata, $now, $now)
100
+ ON CONFLICT(id) DO UPDATE SET
101
+ title = excluded.title,
102
+ value = excluded.value,
103
+ summary_markdown = excluded.summary_markdown,
104
+ metadata_json = excluded.metadata_json,
105
+ updated_at = excluded.updated_at
106
+ `).run({
107
+ $id: `sum_${hash(summary.sessionId)}`,
108
+ $sessionId: summary.sessionId,
109
+ $projectId: session.project_id ?? parsed.projectId,
110
+ $title: summary.title,
111
+ $value: summary.value,
112
+ $summaryMarkdown: summary.summaryMarkdown,
113
+ $metadata: JSON.stringify(summary.metadata),
114
+ $now: now,
115
+ })
116
+ written += 1
117
+ }
118
+ })
119
+ apply()
120
+
121
+ return { written, skipped }
122
+ }
package/src/sync.ts ADDED
@@ -0,0 +1,207 @@
1
+ import type { LlmIwikiDatabase } from './db'
2
+ import { resolveProjectByPath } from './projects'
3
+ import { COLLECTORS, type Collector, type RawSession } from './collectors'
4
+
5
+ export interface SourceSyncReport {
6
+ source: string
7
+ new: number
8
+ changed: number
9
+ unchanged: number
10
+ sourceMissing: number
11
+ total: number
12
+ }
13
+
14
+ export interface SyncReport {
15
+ bySource: SourceSyncReport[]
16
+ }
17
+
18
+ export interface SyncOptions {
19
+ homeDir: string
20
+ projectFilter?: string | null
21
+ }
22
+
23
+ function hash(value: string): string {
24
+ return Bun.hash(value).toString(16)
25
+ }
26
+
27
+ function sessionPrimaryId(sourceId: string, session: RawSession): string {
28
+ return `ses_${hash(`${sourceId}\u0000${session.sourceSessionId}\u0000${session.rawPath}`)}`
29
+ }
30
+
31
+ function messageContentHash(role: string, content: string): string {
32
+ return hash(`${role}\u0000${content}`)
33
+ }
34
+
35
+ function sessionContentHash(messageHashes: string[]): string {
36
+ return hash(`${messageHashes.length}\u0000${messageHashes.join('\u0000')}`)
37
+ }
38
+
39
+ function upsertSource(db: LlmIwikiDatabase, collector: Collector, now: string): void {
40
+ db.query(`
41
+ INSERT INTO sources (id, name, enabled, scan_paths, config_json, last_sync_at)
42
+ VALUES ($id, $name, 1, NULL, NULL, $now)
43
+ ON CONFLICT(id) DO UPDATE SET
44
+ name = excluded.name,
45
+ last_sync_at = excluded.last_sync_at
46
+ `).run({ $id: collector.id, $name: collector.name, $now: now })
47
+ }
48
+
49
+ function resolveProjectId(db: LlmIwikiDatabase, rawProjectPath: string | null): string | null {
50
+ if (!rawProjectPath) return null
51
+ try {
52
+ return resolveProjectByPath(db, rawProjectPath).id
53
+ } catch {
54
+ return null
55
+ }
56
+ }
57
+
58
+ function writeSession(
59
+ db: LlmIwikiDatabase,
60
+ collector: Collector,
61
+ session: RawSession,
62
+ now: string,
63
+ report: SourceSyncReport,
64
+ ): string {
65
+ const id = sessionPrimaryId(collector.id, session)
66
+ const messageHashes = session.messages.map((message) => messageContentHash(message.role, message.content))
67
+ const contentHash = sessionContentHash(messageHashes)
68
+ const projectId = resolveProjectId(db, session.rawProjectPath)
69
+
70
+ const existing = db
71
+ .query<{ content_hash: string; first_seen_at: string }, [string]>(
72
+ 'SELECT content_hash, first_seen_at FROM sessions WHERE id = ?',
73
+ )
74
+ .get(id)
75
+
76
+ if (existing && existing.content_hash === contentHash) {
77
+ db.query('UPDATE sessions SET project_id = $projectId, status = $status, last_seen_at = $now WHERE id = $id').run({
78
+ $projectId: projectId,
79
+ $status: 'unchanged',
80
+ $now: now,
81
+ $id: id,
82
+ })
83
+ report.unchanged += 1
84
+ return id
85
+ }
86
+
87
+ const status = existing ? 'changed' : 'new'
88
+ const firstSeenAt = existing?.first_seen_at ?? now
89
+
90
+ db.query(`
91
+ INSERT INTO sessions (
92
+ id, source_id, source_session_id, project_id, checkout_id, raw_project_path, raw_path,
93
+ title, message_count, content_hash, status, created_at, updated_at, first_seen_at, last_seen_at
94
+ ) VALUES (
95
+ $id, $sourceId, $sourceSessionId, $projectId, NULL, $rawProjectPath, $rawPath,
96
+ $title, $messageCount, $contentHash, $status, $createdAt, $updatedAt, $firstSeenAt, $now
97
+ )
98
+ ON CONFLICT(id) DO UPDATE SET
99
+ project_id = excluded.project_id,
100
+ raw_project_path = excluded.raw_project_path,
101
+ title = excluded.title,
102
+ message_count = excluded.message_count,
103
+ content_hash = excluded.content_hash,
104
+ status = excluded.status,
105
+ created_at = excluded.created_at,
106
+ updated_at = excluded.updated_at,
107
+ last_seen_at = excluded.last_seen_at
108
+ `).run({
109
+ $id: id,
110
+ $sourceId: collector.id,
111
+ $sourceSessionId: session.sourceSessionId,
112
+ $projectId: projectId,
113
+ $rawProjectPath: session.rawProjectPath,
114
+ $rawPath: session.rawPath,
115
+ $title: session.title,
116
+ $messageCount: session.messages.length,
117
+ $contentHash: contentHash,
118
+ $status: status,
119
+ $createdAt: session.createdAt,
120
+ $updatedAt: session.updatedAt,
121
+ $firstSeenAt: firstSeenAt,
122
+ $now: now,
123
+ })
124
+
125
+ db.query('DELETE FROM messages WHERE session_id = ?').run(id)
126
+ session.messages.forEach((message, index) => {
127
+ db.query(`
128
+ INSERT INTO messages (id, session_id, role, content, timestamp, seq_order, content_hash)
129
+ VALUES ($id, $sessionId, $role, $content, $timestamp, $seqOrder, $contentHash)
130
+ `).run({
131
+ $id: `msg_${id}_${index}`,
132
+ $sessionId: id,
133
+ $role: message.role,
134
+ $content: message.content,
135
+ $timestamp: message.timestamp,
136
+ $seqOrder: index,
137
+ $contentHash: messageHashes[index]!,
138
+ })
139
+ })
140
+
141
+ if (status === 'new') report.new += 1
142
+ else report.changed += 1
143
+ return id
144
+ }
145
+
146
+ function markMissingSessions(
147
+ db: LlmIwikiDatabase,
148
+ sourceId: string,
149
+ seenIds: Set<string>,
150
+ now: string,
151
+ report: SourceSyncReport,
152
+ ): void {
153
+ const rows = db
154
+ .query<{ id: string }, [string]>(
155
+ "SELECT id FROM sessions WHERE source_id = ? AND status != 'source_missing'",
156
+ )
157
+ .all(sourceId)
158
+
159
+ for (const row of rows) {
160
+ if (seenIds.has(row.id)) continue
161
+ db.query('UPDATE sessions SET status = $status, last_seen_at = $now WHERE id = $id').run({
162
+ $status: 'source_missing',
163
+ $now: now,
164
+ $id: row.id,
165
+ })
166
+ report.sourceMissing += 1
167
+ }
168
+ }
169
+
170
+ export function runSync(db: LlmIwikiDatabase, options: SyncOptions): SyncReport {
171
+ const now = new Date().toISOString()
172
+ const bySource: SourceSyncReport[] = []
173
+
174
+ for (const collector of COLLECTORS) {
175
+ if (!collector.detect(options.homeDir)) continue
176
+
177
+ upsertSource(db, collector, now)
178
+ const report: SourceSyncReport = {
179
+ source: collector.id,
180
+ new: 0,
181
+ changed: 0,
182
+ unchanged: 0,
183
+ sourceMissing: 0,
184
+ total: 0,
185
+ }
186
+
187
+ const sessions = collector.collect(options.homeDir)
188
+ const seenIds = new Set<string>()
189
+
190
+ const writeAll = db.transaction(() => {
191
+ for (const session of sessions) {
192
+ if (options.projectFilter && session.rawProjectPath !== options.projectFilter) continue
193
+ const id = writeSession(db, collector, session, now, report)
194
+ seenIds.add(id)
195
+ report.total += 1
196
+ }
197
+ if (!options.projectFilter) {
198
+ markMissingSessions(db, collector.id, seenIds, now, report)
199
+ }
200
+ })
201
+ writeAll()
202
+
203
+ bySource.push(report)
204
+ }
205
+
206
+ return { bySource }
207
+ }
package/src/types.ts ADDED
@@ -0,0 +1,26 @@
1
+ export type SummaryValue = 'none' | 'low' | 'medium' | 'high'
2
+ export type Confidence = 'low' | 'medium' | 'high'
3
+
4
+ export interface ParsedSummariesYaml {
5
+ projectId: string
6
+ summaries: Array<{
7
+ sessionId: string
8
+ title: string
9
+ value: SummaryValue
10
+ summaryMarkdown: string
11
+ metadata: Record<string, unknown>
12
+ }>
13
+ }
14
+
15
+ export interface ParsedExperiencesYaml {
16
+ projectId: string
17
+ experiences: Array<{
18
+ title: string
19
+ slug: string | null
20
+ summary: string
21
+ bodyMarkdown: string
22
+ sourceSessions: string[]
23
+ confidence: Confidence | null
24
+ metadata: Record<string, unknown>
25
+ }>
26
+ }