agentikit 0.0.9 → 0.0.12

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.
Files changed (107) hide show
  1. package/README.md +129 -214
  2. package/dist/index.d.ts +8 -2
  3. package/dist/index.js +4 -1
  4. package/dist/src/asset-spec.d.ts +2 -0
  5. package/dist/src/asset-spec.js +22 -3
  6. package/dist/src/asset-type-handler.d.ts +27 -0
  7. package/dist/src/asset-type-handler.js +33 -0
  8. package/dist/src/cli.js +201 -75
  9. package/dist/src/common.d.ts +6 -1
  10. package/dist/src/common.js +18 -4
  11. package/dist/src/config-cli.d.ts +9 -0
  12. package/dist/src/config-cli.js +473 -0
  13. package/dist/src/config.d.ts +19 -6
  14. package/dist/src/config.js +139 -29
  15. package/dist/src/db.d.ts +46 -0
  16. package/dist/src/db.js +299 -0
  17. package/dist/src/embedder.js +12 -7
  18. package/dist/src/github.d.ts +4 -0
  19. package/dist/src/github.js +19 -0
  20. package/dist/src/handlers/agent-handler.d.ts +2 -0
  21. package/dist/src/handlers/agent-handler.js +26 -0
  22. package/dist/src/handlers/command-handler.d.ts +2 -0
  23. package/dist/src/handlers/command-handler.js +23 -0
  24. package/dist/src/handlers/index.d.ts +6 -0
  25. package/dist/src/handlers/index.js +23 -0
  26. package/dist/src/handlers/knowledge-handler.d.ts +2 -0
  27. package/dist/src/handlers/knowledge-handler.js +56 -0
  28. package/dist/src/handlers/markdown-helpers.d.ts +7 -0
  29. package/dist/src/handlers/markdown-helpers.js +15 -0
  30. package/dist/src/handlers/script-handler.d.ts +2 -0
  31. package/dist/src/handlers/script-handler.js +78 -0
  32. package/dist/src/handlers/skill-handler.d.ts +2 -0
  33. package/dist/src/handlers/skill-handler.js +30 -0
  34. package/dist/src/handlers/tool-handler.d.ts +2 -0
  35. package/dist/src/handlers/tool-handler.js +58 -0
  36. package/dist/src/indexer.d.ts +1 -23
  37. package/dist/src/indexer.js +162 -155
  38. package/dist/src/init.d.ts +2 -2
  39. package/dist/src/init.js +21 -9
  40. package/dist/src/llm.js +4 -3
  41. package/dist/src/metadata.d.ts +0 -1
  42. package/dist/src/metadata.js +6 -64
  43. package/dist/src/origin-resolve.d.ts +19 -0
  44. package/dist/src/origin-resolve.js +53 -0
  45. package/dist/src/registry-install.d.ts +2 -2
  46. package/dist/src/registry-install.js +142 -35
  47. package/dist/src/registry-resolve.js +90 -22
  48. package/dist/src/registry-search.d.ts +22 -0
  49. package/dist/src/registry-search.js +231 -97
  50. package/dist/src/registry-types.d.ts +9 -2
  51. package/dist/src/stash-add.js +4 -4
  52. package/dist/src/stash-clone.d.ts +22 -0
  53. package/dist/src/stash-clone.js +83 -0
  54. package/dist/src/stash-ref.d.ts +27 -3
  55. package/dist/src/stash-ref.js +63 -24
  56. package/dist/src/stash-registry.js +12 -12
  57. package/dist/src/stash-resolve.js +3 -0
  58. package/dist/src/stash-search.js +168 -164
  59. package/dist/src/stash-show.d.ts +1 -1
  60. package/dist/src/stash-show.js +28 -96
  61. package/dist/src/stash-source.d.ts +24 -0
  62. package/dist/src/stash-source.js +81 -0
  63. package/dist/src/stash-types.d.ts +14 -4
  64. package/dist/src/stash.d.ts +6 -0
  65. package/dist/src/stash.js +3 -0
  66. package/dist/src/tool-runner.d.ts +1 -1
  67. package/dist/src/tool-runner.js +18 -5
  68. package/package.json +7 -2
  69. package/src/asset-spec.ts +20 -4
  70. package/src/asset-type-handler.ts +77 -0
  71. package/src/cli.ts +213 -82
  72. package/src/common.ts +23 -5
  73. package/src/config-cli.ts +499 -0
  74. package/src/config.ts +160 -38
  75. package/src/db.ts +411 -0
  76. package/src/embedder.ts +22 -11
  77. package/src/github.ts +21 -0
  78. package/src/handlers/agent-handler.ts +32 -0
  79. package/src/handlers/command-handler.ts +29 -0
  80. package/src/handlers/index.ts +25 -0
  81. package/src/handlers/knowledge-handler.ts +62 -0
  82. package/src/handlers/markdown-helpers.ts +19 -0
  83. package/src/handlers/script-handler.ts +92 -0
  84. package/src/handlers/skill-handler.ts +37 -0
  85. package/src/handlers/tool-handler.ts +71 -0
  86. package/src/indexer.ts +208 -187
  87. package/src/init.ts +17 -9
  88. package/src/llm.ts +4 -3
  89. package/src/metadata.ts +5 -65
  90. package/src/origin-resolve.ts +67 -0
  91. package/src/registry-install.ts +158 -42
  92. package/src/registry-resolve.ts +92 -23
  93. package/src/registry-search.ts +288 -98
  94. package/src/registry-types.ts +10 -2
  95. package/src/stash-add.ts +14 -17
  96. package/src/stash-clone.ts +127 -0
  97. package/src/stash-ref.ts +84 -26
  98. package/src/stash-registry.ts +12 -12
  99. package/src/stash-resolve.ts +3 -0
  100. package/src/stash-search.ts +202 -184
  101. package/src/stash-show.ts +33 -90
  102. package/src/stash-source.ts +103 -0
  103. package/src/stash-types.ts +14 -4
  104. package/src/stash.ts +8 -0
  105. package/src/tool-runner.ts +18 -5
  106. package/dist/src/similarity.d.ts +0 -34
  107. package/src/similarity.ts +0 -271
package/src/config.ts CHANGED
@@ -1,24 +1,33 @@
1
1
  import fs from "node:fs"
2
2
  import path from "node:path"
3
- import { resolveStashDir } from "./common"
4
3
  import type { RegistryInstalledEntry, RegistrySource } from "./registry-types"
5
4
 
6
5
  // ── Types ───────────────────────────────────────────────────────────────────
7
6
 
8
7
  export interface EmbeddingConnectionConfig {
8
+ /** Provider name for display/CLI switching (e.g. "openai", "ollama") */
9
+ provider?: string
9
10
  /** OpenAI-compatible embeddings endpoint (e.g. "http://localhost:11434/v1/embeddings") */
10
11
  endpoint: string
11
12
  /** Model name to use for embeddings (e.g. "nomic-embed-text") */
12
13
  model: string
14
+ /** Optional output dimension for providers that support it */
15
+ dimension?: number
13
16
  /** Optional API key for authenticated endpoints */
14
17
  apiKey?: string
15
18
  }
16
19
 
17
20
  export interface LlmConnectionConfig {
21
+ /** Provider name for display/CLI switching (e.g. "openai", "ollama") */
22
+ provider?: string
18
23
  /** OpenAI-compatible chat completions endpoint (e.g. "http://localhost:11434/v1/chat/completions") */
19
24
  endpoint: string
20
25
  /** Model name to use (e.g. "llama3.2") */
21
26
  model: string
27
+ /** Optional sampling temperature */
28
+ temperature?: number
29
+ /** Optional response token limit */
30
+ maxTokens?: number
22
31
  /** Optional API key for authenticated endpoints */
23
32
  apiKey?: string
24
33
  }
@@ -26,14 +35,16 @@ export interface LlmConnectionConfig {
26
35
  export interface AgentikitConfig {
27
36
  /** Whether semantic search is enabled. Default: true */
28
37
  semanticSearch: boolean
29
- /** Additional stash directories to search alongside the primary one */
30
- additionalStashDirs: string[]
38
+ /** User-mounted read-only stash directories */
39
+ mountedStashDirs: string[]
31
40
  /** OpenAI-compatible embedding endpoint config. If not set, uses local @xenova/transformers */
32
41
  embedding?: EmbeddingConnectionConfig
33
42
  /** OpenAI-compatible LLM endpoint config for metadata generation. If not set, uses heuristic generation */
34
43
  llm?: LlmConnectionConfig
35
44
  /** Installed registry sources and local cache metadata */
36
45
  registry?: RegistryConfig
46
+ /** Registry index URLs for kit discovery. Default: official agentikit-registry on GitHub */
47
+ registryUrls?: string[]
37
48
  }
38
49
 
39
50
  export interface RegistryConfig {
@@ -44,48 +55,99 @@ export interface RegistryConfig {
44
55
 
45
56
  export const DEFAULT_CONFIG: AgentikitConfig = {
46
57
  semanticSearch: true,
47
- additionalStashDirs: [],
58
+ mountedStashDirs: [],
48
59
  }
49
60
 
50
61
  // ── Paths ───────────────────────────────────────────────────────────────────
51
62
 
52
- export function getConfigPath(stashDir: string): string {
53
- return path.join(stashDir, "config.json")
63
+ export function getConfigDir(
64
+ env: NodeJS.ProcessEnv = process.env,
65
+ platform = process.platform,
66
+ ): string {
67
+ if (platform === "win32") {
68
+ const appData = env.APPDATA?.trim()
69
+ if (appData) return path.join(appData, "agentikit")
70
+
71
+ const userProfile = env.USERPROFILE?.trim()
72
+ if (!userProfile) {
73
+ throw new Error("Unable to determine config directory. Set APPDATA or USERPROFILE.")
74
+ }
75
+ return path.join(userProfile, "AppData", "Roaming", "agentikit")
76
+ }
77
+
78
+ const xdgConfigHome = env.XDG_CONFIG_HOME?.trim()
79
+ if (xdgConfigHome) return path.join(xdgConfigHome, "agentikit")
80
+
81
+ const home = env.HOME?.trim()
82
+ if (!home) {
83
+ throw new Error("Unable to determine config directory. Set XDG_CONFIG_HOME or HOME.")
84
+ }
85
+ return path.join(home, ".config", "agentikit")
86
+ }
87
+
88
+ export function getConfigPath(): string {
89
+ return path.join(getConfigDir(), "config.json")
54
90
  }
55
91
 
56
92
  // ── Load / Save / Update ────────────────────────────────────────────────────
57
93
 
58
- export function loadConfig(stashDir?: string): AgentikitConfig {
59
- const dir = stashDir ?? resolveStashDir()
60
- const configPath = getConfigPath(dir)
94
+ export function loadConfig(): AgentikitConfig {
95
+ const configPath = getConfigPath()
96
+ const raw = readConfigObject(configPath)
97
+ const config = raw ? pickKnownKeys(raw) : { ...DEFAULT_CONFIG }
98
+
99
+ // Inject API keys from environment variables.
100
+ // API keys should be provided via AKM_EMBED_API_KEY and AKM_LLM_API_KEY
101
+ // rather than stored in the config file.
102
+ if (config.embedding && !config.embedding.apiKey) {
103
+ const envKey = process.env.AKM_EMBED_API_KEY?.trim()
104
+ if (envKey) config.embedding.apiKey = envKey
105
+ }
106
+ if (config.llm && !config.llm.apiKey) {
107
+ const envKey = process.env.AKM_LLM_API_KEY?.trim()
108
+ if (envKey) config.llm.apiKey = envKey
109
+ }
110
+
111
+ return config
112
+ }
61
113
 
62
- let raw: Record<string, unknown>
114
+ export function saveConfig(config: AgentikitConfig): void {
115
+ const configPath = getConfigPath()
116
+ const dir = path.dirname(configPath)
117
+ fs.mkdirSync(dir, { recursive: true })
118
+ const sanitized = sanitizeConfigForWrite(config)
119
+ const tmpPath = configPath + `.tmp.${process.pid}`
63
120
  try {
64
- raw = JSON.parse(fs.readFileSync(configPath, "utf8"))
65
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
66
- return { ...DEFAULT_CONFIG }
67
- }
68
- } catch {
69
- return { ...DEFAULT_CONFIG }
121
+ fs.writeFileSync(tmpPath, JSON.stringify(sanitized, null, 2) + "\n", "utf8")
122
+ fs.renameSync(tmpPath, configPath)
123
+ } catch (err) {
124
+ try { fs.unlinkSync(tmpPath) } catch { /* ignore cleanup failure */ }
125
+ throw err
70
126
  }
71
-
72
- return pickKnownKeys(raw)
73
127
  }
74
128
 
75
- export function saveConfig(config: AgentikitConfig, stashDir?: string): void {
76
- const dir = stashDir ?? resolveStashDir()
77
- const configPath = getConfigPath(dir)
78
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8")
129
+ /**
130
+ * Strip apiKey fields before writing config to disk.
131
+ * API keys should be provided via environment variables
132
+ * AKM_EMBED_API_KEY and AKM_LLM_API_KEY.
133
+ */
134
+ function sanitizeConfigForWrite(config: AgentikitConfig): AgentikitConfig {
135
+ const sanitized = { ...config }
136
+ if (sanitized.embedding) {
137
+ const { apiKey, ...rest } = sanitized.embedding
138
+ sanitized.embedding = rest as EmbeddingConnectionConfig
139
+ }
140
+ if (sanitized.llm) {
141
+ const { apiKey, ...rest } = sanitized.llm
142
+ sanitized.llm = rest as LlmConnectionConfig
143
+ }
144
+ return sanitized
79
145
  }
80
146
 
81
- export function updateConfig(
82
- partial: Partial<AgentikitConfig>,
83
- stashDir?: string,
84
- ): AgentikitConfig {
85
- const dir = stashDir ?? resolveStashDir()
86
- const current = loadConfig(dir)
147
+ export function updateConfig(partial: Partial<AgentikitConfig>): AgentikitConfig {
148
+ const current = loadConfig()
87
149
  const merged: AgentikitConfig = { ...current, ...partial }
88
- saveConfig(merged, dir)
150
+ saveConfig(merged)
89
151
  return merged
90
152
  }
91
153
 
@@ -98,35 +160,95 @@ function pickKnownKeys(raw: Record<string, unknown>): AgentikitConfig {
98
160
  config.semanticSearch = raw.semanticSearch
99
161
  }
100
162
 
101
- if (Array.isArray(raw.additionalStashDirs)) {
102
- config.additionalStashDirs = raw.additionalStashDirs.filter(
163
+ if (Array.isArray(raw.mountedStashDirs)) {
164
+ config.mountedStashDirs = raw.mountedStashDirs.filter(
103
165
  (d): d is string => typeof d === "string",
104
166
  )
105
167
  }
106
168
 
107
- const embedding = parseConnectionConfig(raw.embedding)
169
+ const embedding = parseEmbeddingConfig(raw.embedding)
108
170
  if (embedding) config.embedding = embedding
109
171
 
110
- const llm = parseConnectionConfig(raw.llm)
172
+ const llm = parseLlmConfig(raw.llm)
111
173
  if (llm) config.llm = llm
112
174
 
113
175
  const registry = parseRegistryConfig(raw.registry)
114
176
  if (registry) config.registry = registry
115
177
 
178
+ if (Array.isArray(raw.registryUrls)) {
179
+ config.registryUrls = raw.registryUrls.filter(
180
+ (u): u is string => typeof u === "string" && u.startsWith("http"),
181
+ )
182
+ }
183
+
116
184
  return config
117
185
  }
118
186
 
119
- function parseConnectionConfig(
120
- value: unknown,
121
- ): EmbeddingConnectionConfig | LlmConnectionConfig | undefined {
187
+ function readConfigObject(configPath: string): Record<string, unknown> | undefined {
188
+ try {
189
+ const raw = JSON.parse(fs.readFileSync(configPath, "utf8"))
190
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined
191
+ return raw
192
+ } catch {
193
+ return undefined
194
+ }
195
+ }
196
+
197
+ function parseEmbeddingConfig(value: unknown): EmbeddingConnectionConfig | undefined {
198
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined
199
+ const obj = value as Record<string, unknown>
200
+ if (typeof obj.endpoint !== "string" || !obj.endpoint) return undefined
201
+ if (typeof obj.model !== "string" || !obj.model) return undefined
202
+ const result: EmbeddingConnectionConfig = {
203
+ endpoint: obj.endpoint,
204
+ model: obj.model,
205
+ }
206
+ if (typeof obj.provider === "string" && obj.provider) {
207
+ result.provider = obj.provider
208
+ }
209
+ if ("dimension" in obj) {
210
+ if (
211
+ typeof obj.dimension !== "number" ||
212
+ !Number.isFinite(obj.dimension) ||
213
+ !Number.isInteger(obj.dimension) ||
214
+ obj.dimension <= 0
215
+ ) {
216
+ return undefined
217
+ }
218
+ result.dimension = obj.dimension
219
+ }
220
+ if (typeof obj.apiKey === "string" && obj.apiKey) {
221
+ result.apiKey = obj.apiKey
222
+ }
223
+ return result
224
+ }
225
+
226
+ function parseLlmConfig(value: unknown): LlmConnectionConfig | undefined {
122
227
  if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined
123
228
  const obj = value as Record<string, unknown>
124
229
  if (typeof obj.endpoint !== "string" || !obj.endpoint) return undefined
125
230
  if (typeof obj.model !== "string" || !obj.model) return undefined
126
- const result: { endpoint: string; model: string; apiKey?: string } = {
231
+ const result: LlmConnectionConfig = {
127
232
  endpoint: obj.endpoint,
128
233
  model: obj.model,
129
234
  }
235
+ if (typeof obj.provider === "string" && obj.provider) {
236
+ result.provider = obj.provider
237
+ }
238
+ if (typeof obj.temperature === "number" && Number.isFinite(obj.temperature)) {
239
+ result.temperature = obj.temperature
240
+ }
241
+ if ("maxTokens" in obj) {
242
+ if (
243
+ typeof obj.maxTokens !== "number" ||
244
+ !Number.isFinite(obj.maxTokens) ||
245
+ !Number.isInteger(obj.maxTokens) ||
246
+ obj.maxTokens <= 0
247
+ ) {
248
+ return undefined
249
+ }
250
+ result.maxTokens = obj.maxTokens
251
+ }
130
252
  if (typeof obj.apiKey === "string" && obj.apiKey) {
131
253
  result.apiKey = obj.apiKey
132
254
  }
@@ -179,5 +301,5 @@ function asNonEmptyString(value: unknown): string | undefined {
179
301
  }
180
302
 
181
303
  function asRegistrySource(value: unknown): RegistrySource | undefined {
182
- return value === "npm" || value === "github" ? value : undefined
304
+ return value === "npm" || value === "github" || value === "git" ? value : undefined
183
305
  }
package/src/db.ts ADDED
@@ -0,0 +1,411 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+ import { Database } from "bun:sqlite"
4
+ import type { StashEntry } from "./metadata"
5
+ import type { EmbeddingVector } from "./embedder"
6
+
7
+ // ── Types ───────────────────────────────────────────────────────────────────
8
+
9
+ export interface DbIndexedEntry {
10
+ id: number
11
+ entryKey: string
12
+ dirPath: string
13
+ filePath: string
14
+ stashDir: string
15
+ entry: StashEntry
16
+ searchText: string
17
+ }
18
+
19
+ export interface DbSearchResult {
20
+ id: number
21
+ filePath: string
22
+ entry: StashEntry
23
+ searchText: string
24
+ bm25Score: number
25
+ }
26
+
27
+ export interface DbVecResult {
28
+ id: number
29
+ distance: number
30
+ }
31
+
32
+ // ── Constants ───────────────────────────────────────────────────────────────
33
+
34
+ export const DB_VERSION = 5
35
+ export const EMBEDDING_DIM = 384
36
+
37
+ // ── Path ────────────────────────────────────────────────────────────────────
38
+
39
+ export function getDbPath(): string {
40
+ const cacheDir =
41
+ process.env.XDG_CACHE_HOME ||
42
+ path.join(process.env.HOME || process.env.USERPROFILE || "", ".cache")
43
+ return path.join(cacheDir, "agentikit", "index.db")
44
+ }
45
+
46
+ // ── Database lifecycle ──────────────────────────────────────────────────────
47
+
48
+ export function openDatabase(dbPath?: string, options?: { embeddingDim?: number }): Database {
49
+ const resolvedPath = dbPath ?? getDbPath()
50
+ const dir = path.dirname(resolvedPath)
51
+ if (!fs.existsSync(dir)) {
52
+ fs.mkdirSync(dir, { recursive: true })
53
+ }
54
+
55
+ const db = new Database(resolvedPath)
56
+ db.exec("PRAGMA journal_mode = WAL")
57
+ db.exec("PRAGMA foreign_keys = ON")
58
+
59
+ // Try to load sqlite-vec extension
60
+ loadVecExtension(db)
61
+
62
+ ensureSchema(db, options?.embeddingDim ?? EMBEDDING_DIM)
63
+ return db
64
+ }
65
+
66
+ export function closeDatabase(db: Database): void {
67
+ db.close()
68
+ }
69
+
70
+ // ── sqlite-vec extension ────────────────────────────────────────────────────
71
+
72
+ let vecAvailable = false
73
+
74
+ function loadVecExtension(db: Database): void {
75
+ try {
76
+ const sqliteVec = require("sqlite-vec")
77
+ sqliteVec.load(db)
78
+ vecAvailable = true
79
+ } catch {
80
+ console.warn("sqlite-vec extension not available, embeddings will be skipped")
81
+ vecAvailable = false
82
+ }
83
+ }
84
+
85
+ export function isVecAvailable(): boolean {
86
+ return vecAvailable
87
+ }
88
+
89
+ // ── Schema ──────────────────────────────────────────────────────────────────
90
+
91
+ function ensureSchema(db: Database, embeddingDim: number): void {
92
+ // Create meta table first so we can check version
93
+ db.exec(`
94
+ CREATE TABLE IF NOT EXISTS index_meta (
95
+ key TEXT PRIMARY KEY,
96
+ value TEXT NOT NULL
97
+ );
98
+ `)
99
+
100
+ // Check stored version — if it differs from DB_VERSION, drop and recreate all tables
101
+ const storedVersion = getMeta(db, "version")
102
+ if (storedVersion && storedVersion !== String(DB_VERSION)) {
103
+ db.exec("DROP TABLE IF EXISTS entries_vec")
104
+ db.exec("DROP TABLE IF EXISTS entries_fts")
105
+ db.exec("DROP INDEX IF EXISTS idx_entries_dir")
106
+ db.exec("DROP INDEX IF EXISTS idx_entries_type")
107
+ db.exec("DROP TABLE IF EXISTS entries")
108
+ db.exec("DELETE FROM index_meta")
109
+ }
110
+
111
+ db.exec(`
112
+ CREATE TABLE IF NOT EXISTS entries (
113
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
114
+ entry_key TEXT NOT NULL UNIQUE,
115
+ dir_path TEXT NOT NULL,
116
+ file_path TEXT NOT NULL,
117
+ stash_dir TEXT NOT NULL,
118
+ entry_json TEXT NOT NULL,
119
+ search_text TEXT NOT NULL,
120
+ entry_type TEXT NOT NULL
121
+ );
122
+
123
+ CREATE INDEX IF NOT EXISTS idx_entries_dir ON entries(dir_path);
124
+ CREATE INDEX IF NOT EXISTS idx_entries_type ON entries(entry_type);
125
+ `)
126
+
127
+ // FTS5 table — standalone with explicit entry_id for joining
128
+ const ftsExists = db
129
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='entries_fts'")
130
+ .get()
131
+ if (!ftsExists) {
132
+ db.exec(`
133
+ CREATE VIRTUAL TABLE entries_fts USING fts5(
134
+ entry_id UNINDEXED,
135
+ search_text,
136
+ tokenize='porter unicode61'
137
+ );
138
+ `)
139
+ }
140
+
141
+ // sqlite-vec table
142
+ if (vecAvailable) {
143
+ // Check if stored embedding dimension differs from configured one
144
+ const storedDim = getMeta(db, "embeddingDim")
145
+ if (storedDim && storedDim !== String(embeddingDim)) {
146
+ try { db.exec("DROP TABLE IF EXISTS entries_vec") } catch { /* ignore */ }
147
+ }
148
+
149
+ const vecExists = db
150
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='entries_vec'")
151
+ .get()
152
+ if (!vecExists) {
153
+ db.exec(`
154
+ CREATE VIRTUAL TABLE entries_vec USING vec0(
155
+ id INTEGER PRIMARY KEY,
156
+ embedding FLOAT[${embeddingDim}]
157
+ );
158
+ `)
159
+ }
160
+ setMeta(db, "embeddingDim", String(embeddingDim))
161
+ }
162
+
163
+ // Set version if not present
164
+ const version = getMeta(db, "version")
165
+ if (!version) {
166
+ setMeta(db, "version", String(DB_VERSION))
167
+ }
168
+ }
169
+
170
+ // ── Meta helpers ────────────────────────────────────────────────────────────
171
+
172
+ export function getMeta(db: Database, key: string): string | undefined {
173
+ const row = db.prepare("SELECT value FROM index_meta WHERE key = ?").get(key) as
174
+ | { value: string }
175
+ | undefined
176
+ return row?.value
177
+ }
178
+
179
+ export function setMeta(db: Database, key: string, value: string): void {
180
+ db.prepare("INSERT OR REPLACE INTO index_meta (key, value) VALUES (?, ?)").run(key, value)
181
+ }
182
+
183
+ // ── Entry operations ────────────────────────────────────────────────────────
184
+
185
+ export function upsertEntry(
186
+ db: Database,
187
+ entryKey: string,
188
+ dirPath: string,
189
+ filePath: string,
190
+ stashDir: string,
191
+ entry: StashEntry,
192
+ searchText: string,
193
+ ): number {
194
+ const stmt = db.prepare(`
195
+ INSERT INTO entries (entry_key, dir_path, file_path, stash_dir, entry_json, search_text, entry_type)
196
+ VALUES (?, ?, ?, ?, ?, ?, ?)
197
+ ON CONFLICT(entry_key) DO UPDATE SET
198
+ dir_path = excluded.dir_path,
199
+ file_path = excluded.file_path,
200
+ stash_dir = excluded.stash_dir,
201
+ entry_json = excluded.entry_json,
202
+ search_text = excluded.search_text,
203
+ entry_type = excluded.entry_type
204
+ `)
205
+ stmt.run(entryKey, dirPath, filePath, stashDir, JSON.stringify(entry), searchText, entry.type)
206
+ // Fetch the row id explicitly since last_insert_rowid() is unreliable for ON CONFLICT DO UPDATE
207
+ const row = db.prepare("SELECT id FROM entries WHERE entry_key = ?").get(entryKey) as { id: number }
208
+ return row.id
209
+ }
210
+
211
+ export function deleteEntriesByDir(db: Database, dirPath: string): void {
212
+ if (vecAvailable) {
213
+ const ids = db
214
+ .prepare("SELECT id FROM entries WHERE dir_path = ?")
215
+ .all(dirPath) as Array<{ id: number }>
216
+ for (const { id } of ids) {
217
+ try {
218
+ db.prepare("DELETE FROM entries_vec WHERE id = ?").run(id)
219
+ } catch { /* ignore if vec table missing */ }
220
+ }
221
+ }
222
+ db.prepare("DELETE FROM entries WHERE dir_path = ?").run(dirPath)
223
+ }
224
+
225
+ export function rebuildFts(db: Database): void {
226
+ db.exec("DELETE FROM entries_fts")
227
+ db.exec("INSERT INTO entries_fts (entry_id, search_text) SELECT CAST(id AS TEXT), search_text FROM entries")
228
+ }
229
+
230
+ // ── Vector operations ───────────────────────────────────────────────────────
231
+
232
+ export function upsertEmbedding(
233
+ db: Database,
234
+ entryId: number,
235
+ embedding: EmbeddingVector,
236
+ ): void {
237
+ if (!vecAvailable) return
238
+ const buf = float32Buffer(embedding)
239
+ try {
240
+ db.prepare("DELETE FROM entries_vec WHERE id = ?").run(entryId)
241
+ } catch { /* ignore */ }
242
+ db.prepare("INSERT INTO entries_vec (id, embedding) VALUES (?, ?)").run(entryId, buf)
243
+ }
244
+
245
+ export function searchVec(
246
+ db: Database,
247
+ queryEmbedding: EmbeddingVector,
248
+ k: number,
249
+ ): DbVecResult[] {
250
+ if (!vecAvailable) return []
251
+ const buf = float32Buffer(queryEmbedding)
252
+ try {
253
+ return db
254
+ .prepare("SELECT id, distance FROM entries_vec WHERE embedding MATCH ? AND k = ?")
255
+ .all(buf, k) as DbVecResult[]
256
+ } catch {
257
+ return []
258
+ }
259
+ }
260
+
261
+ function float32Buffer(vec: number[]): Buffer {
262
+ const f32 = new Float32Array(vec)
263
+ return Buffer.from(f32.buffer)
264
+ }
265
+
266
+ // ── FTS5 search ─────────────────────────────────────────────────────────────
267
+
268
+ export function searchFts(
269
+ db: Database,
270
+ query: string,
271
+ limit: number,
272
+ entryType?: string,
273
+ ): DbSearchResult[] {
274
+ const ftsQuery = sanitizeFtsQuery(query)
275
+ if (!ftsQuery) return []
276
+
277
+ let sql: string
278
+ let params: unknown[]
279
+
280
+ if (entryType && entryType !== "any") {
281
+ sql = `
282
+ SELECT e.id, e.file_path AS filePath, e.entry_json, e.search_text AS searchText,
283
+ bm25(entries_fts) AS bm25Score
284
+ FROM entries_fts f
285
+ JOIN entries e ON e.id = CAST(f.entry_id AS INTEGER)
286
+ WHERE entries_fts MATCH ?
287
+ AND e.entry_type = ?
288
+ ORDER BY bm25Score
289
+ LIMIT ?
290
+ `
291
+ params = [ftsQuery, entryType, limit]
292
+ } else {
293
+ sql = `
294
+ SELECT e.id, e.file_path AS filePath, e.entry_json, e.search_text AS searchText,
295
+ bm25(entries_fts) AS bm25Score
296
+ FROM entries_fts f
297
+ JOIN entries e ON e.id = CAST(f.entry_id AS INTEGER)
298
+ WHERE entries_fts MATCH ?
299
+ ORDER BY bm25Score
300
+ LIMIT ?
301
+ `
302
+ params = [ftsQuery, limit]
303
+ }
304
+
305
+ try {
306
+ const rows = db.prepare(sql).all(...(params as import("bun:sqlite").SQLQueryBindings[])) as Array<{
307
+ id: number
308
+ filePath: string
309
+ entry_json: string
310
+ searchText: string
311
+ bm25Score: number
312
+ }>
313
+
314
+ return rows.map((row) => ({
315
+ id: row.id,
316
+ filePath: row.filePath,
317
+ entry: JSON.parse(row.entry_json) as StashEntry,
318
+ searchText: row.searchText,
319
+ bm25Score: row.bm25Score,
320
+ }))
321
+ } catch {
322
+ return []
323
+ }
324
+ }
325
+
326
+ function sanitizeFtsQuery(query: string): string {
327
+ const tokens = query
328
+ .replace(/[^a-zA-Z0-9\s]/g, " ")
329
+ .split(/\s+/)
330
+ .filter((t) => t.length > 1)
331
+ if (tokens.length === 0) return ""
332
+ // Use unquoted tokens so the porter stemmer can normalize word forms
333
+ return tokens.join(" OR ")
334
+ }
335
+
336
+ // ── All entries ─────────────────────────────────────────────────────────────
337
+
338
+ export function getAllEntries(
339
+ db: Database,
340
+ entryType?: string,
341
+ ): DbIndexedEntry[] {
342
+ let sql: string
343
+ let params: unknown[]
344
+
345
+ if (entryType && entryType !== "any") {
346
+ sql = "SELECT id, entry_key, dir_path, file_path, stash_dir, entry_json, search_text FROM entries WHERE entry_type = ?"
347
+ params = [entryType]
348
+ } else {
349
+ sql = "SELECT id, entry_key, dir_path, file_path, stash_dir, entry_json, search_text FROM entries"
350
+ params = []
351
+ }
352
+
353
+ const rows = db.prepare(sql).all(...(params as import("bun:sqlite").SQLQueryBindings[])) as Array<{
354
+ id: number
355
+ entry_key: string
356
+ dir_path: string
357
+ file_path: string
358
+ stash_dir: string
359
+ entry_json: string
360
+ search_text: string
361
+ }>
362
+
363
+ return rows.map((row) => ({
364
+ id: row.id,
365
+ entryKey: row.entry_key,
366
+ dirPath: row.dir_path,
367
+ filePath: row.file_path,
368
+ stashDir: row.stash_dir,
369
+ entry: JSON.parse(row.entry_json) as StashEntry,
370
+ searchText: row.search_text,
371
+ }))
372
+ }
373
+
374
+ export function getEntryCount(db: Database): number {
375
+ const row = db.prepare("SELECT COUNT(*) AS cnt FROM entries").get() as { cnt: number }
376
+ return row.cnt
377
+ }
378
+
379
+ export function getEntryById(db: Database, id: number): { filePath: string; entry: StashEntry } | undefined {
380
+ const row = db
381
+ .prepare("SELECT file_path, entry_json FROM entries WHERE id = ?")
382
+ .get(id) as { file_path: string; entry_json: string } | undefined
383
+ if (!row) return undefined
384
+ return { filePath: row.file_path, entry: JSON.parse(row.entry_json) as StashEntry }
385
+ }
386
+
387
+ export function getEntriesByDir(db: Database, dirPath: string): DbIndexedEntry[] {
388
+ const rows = db
389
+ .prepare(
390
+ "SELECT id, entry_key, dir_path, file_path, stash_dir, entry_json, search_text FROM entries WHERE dir_path = ?",
391
+ )
392
+ .all(dirPath) as Array<{
393
+ id: number
394
+ entry_key: string
395
+ dir_path: string
396
+ file_path: string
397
+ stash_dir: string
398
+ entry_json: string
399
+ search_text: string
400
+ }>
401
+
402
+ return rows.map((row) => ({
403
+ id: row.id,
404
+ entryKey: row.entry_key,
405
+ dirPath: row.dir_path,
406
+ filePath: row.file_path,
407
+ stashDir: row.stash_dir,
408
+ entry: JSON.parse(row.entry_json) as StashEntry,
409
+ searchText: row.search_text,
410
+ }))
411
+ }