@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,58 @@
1
+ import { tmpdir } from 'node:os'
2
+
3
+ import type { RawMessage } from './types'
4
+
5
+ const TITLE_MAX_LENGTH = 120
6
+
7
+ const EPHEMERAL_PREFIXES = ['/tmp/', '/private/tmp/', '/var/folders/', '/private/var/folders/']
8
+
9
+ export function isEphemeralPath(path: string | null): boolean {
10
+ if (!path) return false
11
+ const temp = tmpdir()
12
+ if (path === temp) return true
13
+ const normalized = path.endsWith('/') ? path : `${path}/`
14
+ const prefixes = [...EPHEMERAL_PREFIXES, `${temp}/`]
15
+ return prefixes.some((prefix) => normalized.startsWith(prefix))
16
+ }
17
+
18
+ export function clampTitle(value: string): string {
19
+ return value.length > TITLE_MAX_LENGTH ? `${value.slice(0, TITLE_MAX_LENGTH)}…` : value
20
+ }
21
+
22
+ function firstMeaningfulLine(content: string): string | null {
23
+ for (const line of content.split('\n')) {
24
+ const trimmed = line.trim()
25
+ if (trimmed === '' || /^[-=*#>`~]+$/.test(trimmed)) continue
26
+ return trimmed
27
+ }
28
+ return null
29
+ }
30
+
31
+ export function deriveTitle(messages: RawMessage[]): string | null {
32
+ for (const message of messages) {
33
+ if (message.role !== 'user') continue
34
+ const line = firstMeaningfulLine(message.content)
35
+ if (line) return clampTitle(line)
36
+ }
37
+ return null
38
+ }
39
+
40
+ export function normalizeContentParts(content: unknown): string {
41
+ if (typeof content === 'string') return content
42
+ if (Array.isArray(content)) {
43
+ return content
44
+ .map((part) => {
45
+ if (typeof part === 'string') return part
46
+ if (part && typeof part === 'object') {
47
+ const record = part as Record<string, unknown>
48
+ if (typeof record.text === 'string') return record.text
49
+ return JSON.stringify(record)
50
+ }
51
+ return String(part)
52
+ })
53
+ .join('\n')
54
+ .trim()
55
+ }
56
+ if (content == null) return ''
57
+ return JSON.stringify(content)
58
+ }
@@ -0,0 +1,63 @@
1
+ import type { RawMessage } from './collectors'
2
+
3
+ export interface CompactionOptions {
4
+ maxPerMessage: number
5
+ maxMessages: number
6
+ }
7
+
8
+ const DEFAULTS: CompactionOptions = {
9
+ maxPerMessage: 1200,
10
+ maxMessages: 60,
11
+ }
12
+
13
+ function squashWhitespace(text: string): string {
14
+ return text.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
15
+ }
16
+
17
+ function truncate(text: string, max: number): string {
18
+ const cleaned = squashWhitespace(text)
19
+ if (cleaned.length <= max) return cleaned
20
+ return `${cleaned.slice(0, max)}\n…[truncated ${cleaned.length - max} chars]`
21
+ }
22
+
23
+ export interface CompactMessageInput {
24
+ role: string
25
+ content: string
26
+ }
27
+
28
+ /**
29
+ * Deterministic, dependency-free compaction. Keeps the head and tail of long
30
+ * conversations and truncates oversized individual messages so the result is a
31
+ * readable transcript that fits a token budget without calling any model.
32
+ */
33
+ export function compactTranscript(
34
+ messages: CompactMessageInput[],
35
+ options: Partial<CompactionOptions> = {},
36
+ ): string {
37
+ const opts = { ...DEFAULTS, ...options }
38
+ const meaningful = messages.filter((message) => message.content.trim() !== '')
39
+
40
+ let kept = meaningful
41
+ let omittedNote = ''
42
+ if (meaningful.length > opts.maxMessages) {
43
+ const head = Math.ceil(opts.maxMessages * 0.6)
44
+ const tail = opts.maxMessages - head
45
+ kept = [...meaningful.slice(0, head), ...meaningful.slice(meaningful.length - tail)]
46
+ omittedNote = `\n…[omitted ${meaningful.length - opts.maxMessages} middle messages]\n`
47
+ }
48
+
49
+ const lines: string[] = []
50
+ kept.forEach((message, index) => {
51
+ if (omittedNote && index === Math.ceil(opts.maxMessages * 0.6)) {
52
+ lines.push(omittedNote)
53
+ }
54
+ const label = message.role === 'user' ? 'User' : message.role === 'assistant' ? 'Assistant' : message.role
55
+ lines.push(`**${label}:** ${truncate(message.content, opts.maxPerMessage)}`)
56
+ })
57
+
58
+ return lines.join('\n\n')
59
+ }
60
+
61
+ export function compactRawMessages(messages: RawMessage[], options?: Partial<CompactionOptions>): string {
62
+ return compactTranscript(messages, options)
63
+ }
package/src/config.ts ADDED
@@ -0,0 +1,57 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2
+
3
+ export interface LlmIwikiConfig {
4
+ obsidianVault: string | null
5
+ }
6
+
7
+ const KEY_ALIASES: Record<string, string> = {
8
+ 'obsidian.vault': 'obsidian_vault',
9
+ obsidian_vault: 'obsidian_vault',
10
+ }
11
+
12
+ function parseToml(source: string): Record<string, string> {
13
+ const config: Record<string, string> = {}
14
+ for (const line of source.split('\n')) {
15
+ const trimmed = line.trim()
16
+ if (trimmed === '' || trimmed.startsWith('#') || trimmed.startsWith('[')) continue
17
+ const match = trimmed.match(/^([\w.]+)\s*=\s*(.*)$/)
18
+ if (!match) continue
19
+ const key = match[1]!
20
+ let value = match[2]!.trim()
21
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
22
+ value = value.slice(1, -1)
23
+ }
24
+ config[key] = value
25
+ }
26
+ return config
27
+ }
28
+
29
+ export function readConfig(configFile: string): LlmIwikiConfig {
30
+ if (!existsSync(configFile)) return { obsidianVault: null }
31
+ const raw = parseToml(readFileSync(configFile, 'utf8'))
32
+ const vault = raw.obsidian_vault
33
+ return { obsidianVault: vault && vault.trim() !== '' ? vault : null }
34
+ }
35
+
36
+ export function setConfigValue(configFile: string, key: string, value: string): string {
37
+ const normalizedKey = KEY_ALIASES[key]
38
+ if (!normalizedKey) {
39
+ throw new Error(`Unknown config key: ${key}. Supported: obsidian.vault`)
40
+ }
41
+
42
+ const existing = existsSync(configFile) ? readFileSync(configFile, 'utf8') : ''
43
+ const lines = existing.split('\n')
44
+ const line = `${normalizedKey} = "${value}"`
45
+ let replaced = false
46
+ const next = lines.map((entry) => {
47
+ if (entry.trim().startsWith(`${normalizedKey} `) || entry.trim().startsWith(`${normalizedKey}=`)) {
48
+ replaced = true
49
+ return line
50
+ }
51
+ return entry
52
+ })
53
+ if (!replaced) next.push(line)
54
+
55
+ writeFileSync(configFile, `${next.filter((entry, index) => !(entry === '' && index === next.length - 1)).join('\n')}\n`)
56
+ return normalizedKey
57
+ }
package/src/db.ts ADDED
@@ -0,0 +1,152 @@
1
+ import { Database } from 'bun:sqlite'
2
+ import { mkdirSync } from 'node:fs'
3
+ import { dirname } from 'node:path'
4
+
5
+ export type LlmIwikiDatabase = Database
6
+
7
+ export function openDatabase(databaseFile: string): LlmIwikiDatabase {
8
+ mkdirSync(dirname(databaseFile), { recursive: true })
9
+ return new Database(databaseFile)
10
+ }
11
+
12
+ export function runMigrations(db: LlmIwikiDatabase): void {
13
+ db.exec(`
14
+ PRAGMA journal_mode = WAL;
15
+
16
+ CREATE TABLE IF NOT EXISTS projects (
17
+ id TEXT PRIMARY KEY,
18
+ canonical_name TEXT NOT NULL,
19
+ display_name TEXT,
20
+ slug TEXT NOT NULL,
21
+ canonical_repo_url TEXT,
22
+ provider TEXT,
23
+ identity_source TEXT NOT NULL,
24
+ created_at TEXT NOT NULL,
25
+ updated_at TEXT NOT NULL
26
+ );
27
+
28
+ CREATE TABLE IF NOT EXISTS project_checkouts (
29
+ id TEXT PRIMARY KEY,
30
+ project_id TEXT NOT NULL,
31
+ local_path TEXT NOT NULL,
32
+ git_root TEXT,
33
+ remote_url TEXT,
34
+ canonical_remote_url TEXT,
35
+ current_branch TEXT,
36
+ first_seen_at TEXT NOT NULL,
37
+ last_seen_at TEXT NOT NULL
38
+ );
39
+
40
+ CREATE TABLE IF NOT EXISTS project_aliases (
41
+ id TEXT PRIMARY KEY,
42
+ project_id TEXT NOT NULL,
43
+ alias_type TEXT NOT NULL,
44
+ alias_value TEXT NOT NULL
45
+ );
46
+
47
+ CREATE TABLE IF NOT EXISTS sources (
48
+ id TEXT PRIMARY KEY,
49
+ name TEXT NOT NULL,
50
+ enabled INTEGER NOT NULL,
51
+ scan_paths TEXT,
52
+ config_json TEXT,
53
+ last_sync_at TEXT
54
+ );
55
+
56
+ CREATE TABLE IF NOT EXISTS sessions (
57
+ id TEXT PRIMARY KEY,
58
+ source_id TEXT NOT NULL,
59
+ source_session_id TEXT NOT NULL,
60
+ project_id TEXT,
61
+ checkout_id TEXT,
62
+ raw_project_path TEXT,
63
+ raw_path TEXT,
64
+ title TEXT,
65
+ message_count INTEGER NOT NULL,
66
+ content_hash TEXT NOT NULL,
67
+ status TEXT NOT NULL,
68
+ created_at TEXT,
69
+ updated_at TEXT,
70
+ first_seen_at TEXT NOT NULL,
71
+ last_seen_at TEXT NOT NULL,
72
+ UNIQUE(source_id, source_session_id, raw_path)
73
+ );
74
+
75
+ CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions (project_id);
76
+
77
+ CREATE TABLE IF NOT EXISTS messages (
78
+ id TEXT PRIMARY KEY,
79
+ session_id TEXT NOT NULL,
80
+ role TEXT NOT NULL,
81
+ content TEXT NOT NULL,
82
+ timestamp TEXT,
83
+ tokens_in INTEGER DEFAULT 0,
84
+ tokens_out INTEGER DEFAULT 0,
85
+ seq_order INTEGER NOT NULL,
86
+ content_hash TEXT NOT NULL
87
+ );
88
+
89
+ CREATE INDEX IF NOT EXISTS idx_messages_session ON messages (session_id);
90
+
91
+ CREATE TABLE IF NOT EXISTS session_summaries (
92
+ id TEXT PRIMARY KEY,
93
+ session_id TEXT NOT NULL,
94
+ project_id TEXT NOT NULL,
95
+ title TEXT NOT NULL,
96
+ value TEXT NOT NULL,
97
+ summary_markdown TEXT NOT NULL,
98
+ metadata_json TEXT,
99
+ created_at TEXT NOT NULL,
100
+ updated_at TEXT NOT NULL
101
+ );
102
+
103
+ CREATE TABLE IF NOT EXISTS experience_candidates (
104
+ id TEXT PRIMARY KEY,
105
+ project_id TEXT NOT NULL,
106
+ proposed_title TEXT NOT NULL,
107
+ proposed_slug TEXT NOT NULL,
108
+ proposed_body_markdown TEXT NOT NULL,
109
+ source_sessions_json TEXT NOT NULL,
110
+ confidence TEXT,
111
+ status TEXT NOT NULL,
112
+ created_at TEXT NOT NULL
113
+ );
114
+
115
+ CREATE TABLE IF NOT EXISTS experiences (
116
+ id TEXT PRIMARY KEY,
117
+ project_id TEXT,
118
+ title TEXT NOT NULL,
119
+ slug TEXT NOT NULL,
120
+ problem_type TEXT,
121
+ solution_type TEXT,
122
+ tech_stack_json TEXT,
123
+ summary TEXT,
124
+ body_markdown TEXT NOT NULL,
125
+ confidence TEXT,
126
+ status TEXT NOT NULL,
127
+ created_at TEXT NOT NULL,
128
+ updated_at TEXT NOT NULL,
129
+ UNIQUE(project_id, slug)
130
+ );
131
+
132
+ CREATE TABLE IF NOT EXISTS session_experience_links (
133
+ session_id TEXT NOT NULL,
134
+ experience_id TEXT NOT NULL,
135
+ relation TEXT NOT NULL,
136
+ PRIMARY KEY (session_id, experience_id)
137
+ );
138
+
139
+ CREATE TABLE IF NOT EXISTS obsidian_notes (
140
+ id TEXT PRIMARY KEY,
141
+ note_type TEXT NOT NULL,
142
+ entity_id TEXT NOT NULL,
143
+ file_path TEXT NOT NULL,
144
+ managed_hash TEXT,
145
+ frontmatter_hash TEXT,
146
+ last_exported_at TEXT,
147
+ last_seen_mtime TEXT,
148
+ conflict_status TEXT NOT NULL,
149
+ UNIQUE(note_type, entity_id)
150
+ );
151
+ `)
152
+ }
@@ -0,0 +1,235 @@
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 ADDED
@@ -0,0 +1,10 @@
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)