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.
- package/README.md +129 -214
- package/dist/index.d.ts +8 -2
- package/dist/index.js +4 -1
- package/dist/src/asset-spec.d.ts +2 -0
- package/dist/src/asset-spec.js +22 -3
- package/dist/src/asset-type-handler.d.ts +27 -0
- package/dist/src/asset-type-handler.js +33 -0
- package/dist/src/cli.js +201 -75
- package/dist/src/common.d.ts +6 -1
- package/dist/src/common.js +18 -4
- package/dist/src/config-cli.d.ts +9 -0
- package/dist/src/config-cli.js +473 -0
- package/dist/src/config.d.ts +19 -6
- package/dist/src/config.js +139 -29
- package/dist/src/db.d.ts +46 -0
- package/dist/src/db.js +299 -0
- package/dist/src/embedder.js +12 -7
- package/dist/src/github.d.ts +4 -0
- package/dist/src/github.js +19 -0
- package/dist/src/handlers/agent-handler.d.ts +2 -0
- package/dist/src/handlers/agent-handler.js +26 -0
- package/dist/src/handlers/command-handler.d.ts +2 -0
- package/dist/src/handlers/command-handler.js +23 -0
- package/dist/src/handlers/index.d.ts +6 -0
- package/dist/src/handlers/index.js +23 -0
- package/dist/src/handlers/knowledge-handler.d.ts +2 -0
- package/dist/src/handlers/knowledge-handler.js +56 -0
- package/dist/src/handlers/markdown-helpers.d.ts +7 -0
- package/dist/src/handlers/markdown-helpers.js +15 -0
- package/dist/src/handlers/script-handler.d.ts +2 -0
- package/dist/src/handlers/script-handler.js +78 -0
- package/dist/src/handlers/skill-handler.d.ts +2 -0
- package/dist/src/handlers/skill-handler.js +30 -0
- package/dist/src/handlers/tool-handler.d.ts +2 -0
- package/dist/src/handlers/tool-handler.js +58 -0
- package/dist/src/indexer.d.ts +1 -23
- package/dist/src/indexer.js +162 -155
- package/dist/src/init.d.ts +2 -2
- package/dist/src/init.js +21 -9
- package/dist/src/llm.js +4 -3
- package/dist/src/metadata.d.ts +0 -1
- package/dist/src/metadata.js +6 -64
- package/dist/src/origin-resolve.d.ts +19 -0
- package/dist/src/origin-resolve.js +53 -0
- package/dist/src/registry-install.d.ts +2 -2
- package/dist/src/registry-install.js +142 -35
- package/dist/src/registry-resolve.js +90 -22
- package/dist/src/registry-search.d.ts +22 -0
- package/dist/src/registry-search.js +231 -97
- package/dist/src/registry-types.d.ts +9 -2
- package/dist/src/stash-add.js +4 -4
- package/dist/src/stash-clone.d.ts +22 -0
- package/dist/src/stash-clone.js +83 -0
- package/dist/src/stash-ref.d.ts +27 -3
- package/dist/src/stash-ref.js +63 -24
- package/dist/src/stash-registry.js +12 -12
- package/dist/src/stash-resolve.js +3 -0
- package/dist/src/stash-search.js +168 -164
- package/dist/src/stash-show.d.ts +1 -1
- package/dist/src/stash-show.js +28 -96
- package/dist/src/stash-source.d.ts +24 -0
- package/dist/src/stash-source.js +81 -0
- package/dist/src/stash-types.d.ts +14 -4
- package/dist/src/stash.d.ts +6 -0
- package/dist/src/stash.js +3 -0
- package/dist/src/tool-runner.d.ts +1 -1
- package/dist/src/tool-runner.js +18 -5
- package/package.json +7 -2
- package/src/asset-spec.ts +20 -4
- package/src/asset-type-handler.ts +77 -0
- package/src/cli.ts +213 -82
- package/src/common.ts +23 -5
- package/src/config-cli.ts +499 -0
- package/src/config.ts +160 -38
- package/src/db.ts +411 -0
- package/src/embedder.ts +22 -11
- package/src/github.ts +21 -0
- package/src/handlers/agent-handler.ts +32 -0
- package/src/handlers/command-handler.ts +29 -0
- package/src/handlers/index.ts +25 -0
- package/src/handlers/knowledge-handler.ts +62 -0
- package/src/handlers/markdown-helpers.ts +19 -0
- package/src/handlers/script-handler.ts +92 -0
- package/src/handlers/skill-handler.ts +37 -0
- package/src/handlers/tool-handler.ts +71 -0
- package/src/indexer.ts +208 -187
- package/src/init.ts +17 -9
- package/src/llm.ts +4 -3
- package/src/metadata.ts +5 -65
- package/src/origin-resolve.ts +67 -0
- package/src/registry-install.ts +158 -42
- package/src/registry-resolve.ts +92 -23
- package/src/registry-search.ts +288 -98
- package/src/registry-types.ts +10 -2
- package/src/stash-add.ts +14 -17
- package/src/stash-clone.ts +127 -0
- package/src/stash-ref.ts +84 -26
- package/src/stash-registry.ts +12 -12
- package/src/stash-resolve.ts +3 -0
- package/src/stash-search.ts +202 -184
- package/src/stash-show.ts +33 -90
- package/src/stash-source.ts +103 -0
- package/src/stash-types.ts +14 -4
- package/src/stash.ts +8 -0
- package/src/tool-runner.ts +18 -5
- package/dist/src/similarity.d.ts +0 -34
- 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
|
-
/**
|
|
30
|
-
|
|
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
|
-
|
|
58
|
+
mountedStashDirs: [],
|
|
48
59
|
}
|
|
49
60
|
|
|
50
61
|
// ── Paths ───────────────────────────────────────────────────────────────────
|
|
51
62
|
|
|
52
|
-
export function
|
|
53
|
-
|
|
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(
|
|
59
|
-
const
|
|
60
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
102
|
-
config.
|
|
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 =
|
|
169
|
+
const embedding = parseEmbeddingConfig(raw.embedding)
|
|
108
170
|
if (embedding) config.embedding = embedding
|
|
109
171
|
|
|
110
|
-
const 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
|
|
120
|
-
|
|
121
|
-
|
|
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:
|
|
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
|
+
}
|