@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,141 +0,0 @@
1
- import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
2
- import { basename, dirname, join } from 'node:path'
3
-
4
- import type { Collector, RawMessage, RawSession } from './types'
5
- import { deriveTitle, isEphemeralPath } from './util'
6
-
7
- const INTERNAL_ROOTS = ['.gemini-internal', '.gemini']
8
-
9
- function loadProjectNameMap(geminiRoot: string): Map<string, string> {
10
- const nameToPath = new Map<string, string>()
11
- const projectsFile = join(geminiRoot, 'projects.json')
12
- if (!existsSync(projectsFile)) return nameToPath
13
- try {
14
- const parsed = JSON.parse(readFileSync(projectsFile, 'utf8')) as { projects?: Record<string, string> }
15
- for (const [path, name] of Object.entries(parsed.projects ?? {})) {
16
- if (typeof name === 'string' && !nameToPath.has(name)) nameToPath.set(name, path)
17
- }
18
- } catch {
19
- // ignore malformed projects.json
20
- }
21
- return nameToPath
22
- }
23
-
24
- function looksLikeAbsolutePath(value: string): boolean {
25
- const trimmed = value.trim()
26
- return trimmed.startsWith('/') && !trimmed.includes('\n') && trimmed.length < 256
27
- }
28
-
29
- function parseChatFile(filePath: string, projectDir: string, nameToPath: Map<string, string>): RawSession | null {
30
- let parsed: {
31
- sessionId?: string
32
- startTime?: string
33
- lastUpdated?: string
34
- messages?: Array<{ type?: string; content?: unknown; timestamp?: string }>
35
- }
36
- try {
37
- parsed = JSON.parse(readFileSync(filePath, 'utf8'))
38
- } catch {
39
- return null
40
- }
41
-
42
- const messages: RawMessage[] = []
43
- for (const message of parsed.messages ?? []) {
44
- if (message.type !== 'user' && message.type !== 'gemini') continue
45
- const content = typeof message.content === 'string' ? message.content : ''
46
- if (content.trim() === '') continue
47
- messages.push({
48
- role: message.type === 'user' ? 'user' : 'assistant',
49
- content,
50
- timestamp: typeof message.timestamp === 'string' ? message.timestamp : null,
51
- })
52
- }
53
-
54
- if (messages.length === 0) return null
55
-
56
- let rawProjectPath = nameToPath.get(projectDir) ?? null
57
- if (!rawProjectPath) {
58
- const firstUser = messages.find((message) => message.role === 'user')
59
- if (firstUser && looksLikeAbsolutePath(firstUser.content)) {
60
- rawProjectPath = firstUser.content.trim().replace(/\/+$/, '')
61
- }
62
- }
63
- if (isEphemeralPath(rawProjectPath)) return null
64
-
65
- const timestamps = messages.map((message) => message.timestamp).filter((value): value is string => value != null)
66
-
67
- return {
68
- sourceSessionId: parsed.sessionId ?? basename(filePath).replace(/\.json$/, ''),
69
- rawPath: filePath,
70
- rawProjectPath,
71
- title: deriveTitle(messages),
72
- createdAt: parsed.startTime ?? timestamps[0] ?? null,
73
- updatedAt: parsed.lastUpdated ?? timestamps[timestamps.length - 1] ?? null,
74
- messages,
75
- }
76
- }
77
-
78
- function resolveGeminiRoot(homeDir: string): string | null {
79
- const seen = new Set<string>()
80
- for (const root of INTERNAL_ROOTS) {
81
- const dir = join(homeDir, root)
82
- if (!existsSync(dir)) continue
83
- let canonical: string
84
- try {
85
- canonical = realpathSync(dir)
86
- } catch {
87
- canonical = dir
88
- }
89
- if (seen.has(canonical)) continue
90
- seen.add(canonical)
91
- return dir
92
- }
93
- return null
94
- }
95
-
96
- function listChatFiles(geminiRoot: string): string[] {
97
- const tmpDir = join(geminiRoot, 'tmp')
98
- if (!existsSync(tmpDir)) return []
99
-
100
- const files: string[] = []
101
- for (const projectEntry of readdirSync(tmpDir, { withFileTypes: true })) {
102
- if (!projectEntry.isDirectory()) continue
103
- const chatsDir = join(tmpDir, projectEntry.name, 'chats')
104
- if (!existsSync(chatsDir)) continue
105
- for (const fileEntry of readdirSync(chatsDir, { withFileTypes: true })) {
106
- if (fileEntry.isFile() && fileEntry.name.startsWith('session-') && fileEntry.name.endsWith('.json')) {
107
- files.push(join(chatsDir, fileEntry.name))
108
- }
109
- }
110
- }
111
- return files
112
- }
113
-
114
- export const geminiCollector: Collector = {
115
- id: 'gemini',
116
- name: 'Gemini',
117
-
118
- detect(homeDir: string): boolean {
119
- const root = resolveGeminiRoot(homeDir)
120
- return root != null && existsSync(join(root, 'tmp'))
121
- },
122
-
123
- collect(homeDir: string): RawSession[] {
124
- const geminiRoot = resolveGeminiRoot(homeDir)
125
- if (!geminiRoot) return []
126
- const nameToPath = loadProjectNameMap(geminiRoot)
127
-
128
- const sessions: RawSession[] = []
129
- for (const filePath of listChatFiles(geminiRoot)) {
130
- try {
131
- if (!statSync(filePath).isFile()) continue
132
- const projectDir = basename(dirname(dirname(filePath)))
133
- const session = parseChatFile(filePath, projectDir, nameToPath)
134
- if (session) sessions.push(session)
135
- } catch {
136
- continue
137
- }
138
- }
139
- return sessions
140
- },
141
- }
@@ -1,20 +0,0 @@
1
- import { claudeCodeCollector } from './claude-code'
2
- import { codebuddyCollector } from './codebuddy'
3
- import { codexCollector } from './codex'
4
- import { cursorCollector } from './cursor'
5
- import { geminiCollector } from './gemini'
6
- import type { Collector } from './types'
7
-
8
- export const COLLECTORS: Collector[] = [
9
- claudeCodeCollector,
10
- codexCollector,
11
- cursorCollector,
12
- geminiCollector,
13
- codebuddyCollector,
14
- ]
15
-
16
- export function getCollector(id: string): Collector | null {
17
- return COLLECTORS.find((collector) => collector.id === id) ?? null
18
- }
19
-
20
- export type { Collector, RawMessage, RawSession } from './types'
@@ -1,22 +0,0 @@
1
- export interface RawMessage {
2
- role: string
3
- content: string
4
- timestamp: string | null
5
- }
6
-
7
- export interface RawSession {
8
- sourceSessionId: string
9
- rawPath: string
10
- rawProjectPath: string | null
11
- title: string | null
12
- createdAt: string | null
13
- updatedAt: string | null
14
- messages: RawMessage[]
15
- }
16
-
17
- export interface Collector {
18
- id: string
19
- name: string
20
- detect(homeDir: string): boolean
21
- collect(homeDir: string): RawSession[]
22
- }
@@ -1,58 +0,0 @@
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
- }
package/src/compaction.ts DELETED
@@ -1,63 +0,0 @@
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 DELETED
@@ -1,57 +0,0 @@
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 DELETED
@@ -1,152 +0,0 @@
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
- }