agentikit 0.0.3 → 0.0.8

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 (80) hide show
  1. package/README.md +113 -77
  2. package/dist/index.d.ts +15 -3
  3. package/dist/index.js +8 -2
  4. package/dist/src/asset-spec.d.ts +14 -0
  5. package/dist/src/asset-spec.js +46 -0
  6. package/dist/src/cli.js +154 -52
  7. package/dist/src/common.d.ts +8 -0
  8. package/dist/src/common.js +46 -0
  9. package/dist/src/config.d.ts +31 -0
  10. package/dist/src/config.js +74 -0
  11. package/dist/src/embedder.d.ts +10 -0
  12. package/dist/src/embedder.js +87 -0
  13. package/dist/src/frontmatter.d.ts +30 -0
  14. package/dist/src/frontmatter.js +86 -0
  15. package/dist/src/indexer.d.ts +20 -2
  16. package/dist/src/indexer.js +212 -80
  17. package/dist/src/init.d.ts +19 -0
  18. package/dist/src/init.js +87 -0
  19. package/dist/src/llm.d.ts +15 -0
  20. package/dist/src/llm.js +91 -0
  21. package/dist/src/markdown.d.ts +18 -0
  22. package/dist/src/markdown.js +77 -0
  23. package/dist/src/metadata.d.ts +10 -2
  24. package/dist/src/metadata.js +146 -30
  25. package/dist/src/ripgrep-install.d.ts +12 -0
  26. package/dist/src/ripgrep-install.js +169 -0
  27. package/dist/src/ripgrep-resolve.d.ts +13 -0
  28. package/dist/src/ripgrep-resolve.js +68 -0
  29. package/dist/src/ripgrep.d.ts +3 -0
  30. package/dist/src/ripgrep.js +2 -0
  31. package/dist/src/similarity.d.ts +1 -2
  32. package/dist/src/similarity.js +35 -9
  33. package/dist/src/stash-ref.d.ts +7 -0
  34. package/dist/src/stash-ref.js +33 -0
  35. package/dist/src/stash-resolve.d.ts +2 -0
  36. package/dist/src/stash-resolve.js +45 -0
  37. package/dist/src/stash-search.d.ts +6 -0
  38. package/dist/src/stash-search.js +269 -0
  39. package/dist/src/stash-show.d.ts +5 -0
  40. package/dist/src/stash-show.js +107 -0
  41. package/dist/src/stash-types.d.ts +53 -0
  42. package/dist/src/stash-types.js +1 -0
  43. package/dist/src/stash.d.ts +8 -58
  44. package/dist/src/stash.js +4 -580
  45. package/dist/src/tool-runner.d.ts +35 -0
  46. package/dist/src/tool-runner.js +100 -0
  47. package/dist/src/walker.d.ts +19 -0
  48. package/dist/src/walker.js +47 -0
  49. package/package.json +8 -14
  50. package/src/asset-spec.ts +69 -0
  51. package/src/cli.ts +164 -48
  52. package/src/common.ts +58 -0
  53. package/src/config.ts +124 -0
  54. package/src/embedder.ts +117 -0
  55. package/src/frontmatter.ts +95 -0
  56. package/src/indexer.ts +244 -84
  57. package/src/init.ts +106 -0
  58. package/src/llm.ts +124 -0
  59. package/src/markdown.ts +106 -0
  60. package/src/metadata.ts +157 -29
  61. package/src/ripgrep-install.ts +200 -0
  62. package/src/ripgrep-resolve.ts +72 -0
  63. package/src/ripgrep.ts +3 -0
  64. package/src/similarity.ts +33 -9
  65. package/src/stash-ref.ts +41 -0
  66. package/src/stash-resolve.ts +47 -0
  67. package/src/stash-search.ts +343 -0
  68. package/src/stash-show.ts +104 -0
  69. package/src/stash-types.ts +46 -0
  70. package/src/stash.ts +16 -695
  71. package/src/tool-runner.ts +129 -0
  72. package/src/walker.ts +53 -0
  73. package/.claude-plugin/plugin.json +0 -21
  74. package/commands/open.md +0 -11
  75. package/commands/run.md +0 -11
  76. package/commands/search.md +0 -11
  77. package/dist/src/plugin.d.ts +0 -2
  78. package/dist/src/plugin.js +0 -55
  79. package/skills/stash/SKILL.md +0 -68
  80. package/src/plugin.ts +0 -56
package/src/indexer.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs"
2
2
  import path from "node:path"
3
- import type { AgentikitAssetType } from "./stash"
3
+ import { type AgentikitAssetType, resolveStashDir } from "./common"
4
+ import { ASSET_TYPES, TYPE_DIRS, deriveCanonicalAssetName } from "./asset-spec"
4
5
  import {
5
6
  type StashFile,
6
7
  type StashEntry,
@@ -8,7 +9,10 @@ import {
8
9
  writeStashFile,
9
10
  generateMetadata,
10
11
  } from "./metadata"
11
- import { TfIdfAdapter, type ScoredEntry } from "./similarity"
12
+ import { TfIdfAdapter, type ScoredEntry, type SerializedTfIdf } from "./similarity"
13
+ import { walkStash } from "./walker"
14
+ import type { EmbeddingVector } from "./embedder"
15
+ import type { LlmConnectionConfig } from "./config"
12
16
 
13
17
  // ── Types ───────────────────────────────────────────────────────────────────
14
18
 
@@ -16,15 +20,20 @@ export interface IndexedEntry {
16
20
  entry: StashEntry
17
21
  path: string
18
22
  dirPath: string
23
+ embedding?: EmbeddingVector
19
24
  }
20
25
 
21
26
  export interface SearchIndex {
22
27
  version: number
23
28
  builtAt: string
24
29
  stashDir: string
30
+ /** All stash directories that were indexed (primary + additional) */
31
+ stashDirs?: string[]
25
32
  entries: IndexedEntry[]
26
33
  /** Serialized TF-IDF state (term frequencies, idf values) */
27
- tfidf?: unknown
34
+ tfidf?: SerializedTfIdf
35
+ /** Whether embeddings are included in entries */
36
+ hasEmbeddings?: boolean
28
37
  }
29
38
 
30
39
  export interface IndexResponse {
@@ -32,19 +41,16 @@ export interface IndexResponse {
32
41
  totalEntries: number
33
42
  generatedMetadata: number
34
43
  indexPath: string
44
+ mode: "full" | "incremental"
45
+ directoriesScanned: number
46
+ directoriesSkipped: number
47
+ /** Timing counters in milliseconds */
48
+ timing?: { totalMs: number; walkMs: number; embedMs: number; tfidfMs: number }
35
49
  }
36
50
 
37
51
  // ── Constants ───────────────────────────────────────────────────────────────
38
52
 
39
- const INDEX_VERSION = 1
40
- const SCRIPT_EXTENSIONS = new Set([".sh", ".ts", ".js", ".ps1", ".cmd", ".bat"])
41
-
42
- const TYPE_DIRS: Record<AgentikitAssetType, string> = {
43
- tool: "tools",
44
- skill: "skills",
45
- command: "commands",
46
- agent: "agents",
47
- }
53
+ const INDEX_VERSION = 4
48
54
 
49
55
  // ── Index Path ──────────────────────────────────────────────────────────────
50
56
 
@@ -68,42 +74,109 @@ export function loadSearchIndex(): SearchIndex | null {
68
74
 
69
75
  // ── Indexer ──────────────────────────────────────────────────────────────────
70
76
 
71
- export function agentikitIndex(options?: { stashDir?: string }): IndexResponse {
72
- const stashDir = options?.stashDir || resolveStashDirForIndex()
77
+ export async function agentikitIndex(options?: { stashDir?: string; full?: boolean }): Promise<IndexResponse> {
78
+ const stashDir = options?.stashDir || resolveStashDir()
79
+
80
+ // Load config to get additional stash dirs and semantic search setting
81
+ const { loadConfig } = await import("./config.js")
82
+ const config = loadConfig(stashDir)
83
+
84
+ const allStashDirs = [stashDir]
85
+ for (const d of config.additionalStashDirs) {
86
+ try {
87
+ if (fs.statSync(d).isDirectory() && !allStashDirs.includes(path.resolve(d))) {
88
+ allStashDirs.push(path.resolve(d))
89
+ }
90
+ } catch { /* skip nonexistent dirs */ }
91
+ }
92
+
93
+ const t0 = Date.now()
73
94
  const allEntries: IndexedEntry[] = []
74
95
  let generatedCount = 0
96
+ let scannedDirs = 0
97
+ let skippedDirs = 0
75
98
 
76
- for (const assetType of Object.keys(TYPE_DIRS) as AgentikitAssetType[]) {
77
- const typeRoot = path.join(stashDir, TYPE_DIRS[assetType])
78
- if (!fs.existsSync(typeRoot) || !fs.statSync(typeRoot).isDirectory()) continue
99
+ // Load previous index for incremental mode
100
+ const previousIndex = !options?.full ? loadSearchIndex() : null
101
+ const isIncremental = previousIndex !== null && previousIndex.stashDir === stashDir
102
+ const builtAtMs = isIncremental ? new Date(previousIndex.builtAt).getTime() : 0
79
103
 
80
- // Group files by their immediate parent directory
81
- const dirGroups = collectDirectoryGroups(typeRoot, assetType)
104
+ // Build lookup of previous entries by dirPath
105
+ const previousEntriesByDir = new Map<string, IndexedEntry[]>()
106
+ if (isIncremental) {
107
+ for (const ie of previousIndex.entries) {
108
+ const list = previousEntriesByDir.get(ie.dirPath) || []
109
+ list.push(ie)
110
+ previousEntriesByDir.set(ie.dirPath, list)
111
+ }
112
+ }
113
+
114
+ const seenPaths = new Set<string>()
115
+ const tWalkStart = Date.now()
116
+
117
+ for (const currentStashDir of allStashDirs) {
118
+ for (const assetType of ASSET_TYPES as AgentikitAssetType[]) {
119
+ const typeRoot = path.join(currentStashDir, TYPE_DIRS[assetType])
120
+ try {
121
+ if (!fs.statSync(typeRoot).isDirectory()) continue
122
+ } catch { continue }
123
+
124
+ // Group files by their immediate parent directory
125
+ const dirGroups = walkStash(typeRoot, assetType)
82
126
 
83
- for (const [dirPath, files] of dirGroups) {
84
- // Try loading existing .stash.json
85
- let stash = loadStashFile(dirPath)
127
+ for (const { dirPath, files } of dirGroups) {
128
+ // Deduplicate by dirPath across stash dirs
129
+ if (seenPaths.has(path.resolve(dirPath))) continue
130
+ seenPaths.add(path.resolve(dirPath))
86
131
 
87
- if (!stash) {
88
- // Generate metadata
89
- stash = generateMetadata(dirPath, assetType, files)
90
- if (stash.entries.length > 0) {
91
- writeStashFile(dirPath, stash)
92
- generatedCount += stash.entries.length
132
+ // Incremental: skip directories that haven't changed
133
+ const prevEntries = previousEntriesByDir.get(dirPath)
134
+ if (isIncremental && prevEntries && !isDirStale(dirPath, files, prevEntries, builtAtMs)) {
135
+ allEntries.push(...prevEntries)
136
+ skippedDirs++
137
+ continue
93
138
  }
94
- }
95
139
 
96
- if (stash) {
97
- for (const entry of stash.entries) {
98
- const entryPath = entry.entry
99
- ? path.join(dirPath, entry.entry)
100
- : files[0] || dirPath
101
- allEntries.push({ entry, path: entryPath, dirPath })
140
+ scannedDirs++
141
+
142
+ // Try loading existing .stash.json
143
+ let stash = loadStashFile(dirPath)
144
+
145
+ if (stash) {
146
+ const migration = migrateGeneratedSkillMetadata(stash, files, typeRoot)
147
+ if (migration.changed) {
148
+ stash = migration.stash
149
+ writeStashFile(dirPath, stash)
150
+ }
151
+ }
152
+
153
+ if (!stash) {
154
+ // Generate metadata
155
+ stash = generateMetadata(dirPath, assetType, files, typeRoot)
156
+ // Enhance with LLM if configured
157
+ if (config.llm && stash.entries.length > 0) {
158
+ stash = await enhanceStashWithLlm(config.llm, stash, dirPath, files)
159
+ }
160
+ if (stash.entries.length > 0) {
161
+ writeStashFile(dirPath, stash)
162
+ generatedCount += stash.entries.length
163
+ }
164
+ }
165
+
166
+ if (stash) {
167
+ for (const entry of stash.entries) {
168
+ const entryPath = entry.entry
169
+ ? path.join(dirPath, entry.entry)
170
+ : files[0] || dirPath
171
+ allEntries.push({ entry, path: entryPath, dirPath })
172
+ }
102
173
  }
103
174
  }
104
175
  }
105
176
  }
106
177
 
178
+ const tWalkEnd = Date.now()
179
+
107
180
  // Build TF-IDF index
108
181
  const adapter = new TfIdfAdapter()
109
182
  const scoredEntries: ScoredEntry[] = allEntries.map((ie) => ({
@@ -113,6 +186,26 @@ export function agentikitIndex(options?: { stashDir?: string }): IndexResponse {
113
186
  path: ie.path,
114
187
  }))
115
188
  adapter.buildIndex(scoredEntries)
189
+ const tTfidfEnd = Date.now()
190
+
191
+ // Generate embeddings if semantic search is enabled
192
+ let hasEmbeddings = false
193
+ if (config.semanticSearch) {
194
+ try {
195
+ const { embed } = await import("./embedder.js")
196
+ for (const ie of allEntries) {
197
+ if (!ie.embedding) {
198
+ const text = buildSearchText(ie.entry)
199
+ ie.embedding = await embed(text, config.embedding)
200
+ }
201
+ }
202
+ hasEmbeddings = true
203
+ } catch {
204
+ // Embedding provider not available, continue without embeddings
205
+ }
206
+ }
207
+
208
+ const tEmbedEnd = Date.now()
116
209
 
117
210
  // Persist index
118
211
  const indexPath = getIndexPath()
@@ -125,64 +218,138 @@ export function agentikitIndex(options?: { stashDir?: string }): IndexResponse {
125
218
  version: INDEX_VERSION,
126
219
  builtAt: new Date().toISOString(),
127
220
  stashDir,
221
+ stashDirs: allStashDirs,
128
222
  entries: allEntries,
129
223
  tfidf: adapter.serialize(),
224
+ hasEmbeddings,
130
225
  }
131
226
  fs.writeFileSync(indexPath, JSON.stringify(index) + "\n", "utf8")
132
227
 
228
+ const tEnd = Date.now()
229
+
133
230
  return {
134
231
  stashDir,
135
232
  totalEntries: allEntries.length,
136
233
  generatedMetadata: generatedCount,
137
234
  indexPath,
235
+ mode: isIncremental ? "incremental" : "full",
236
+ directoriesScanned: scannedDirs,
237
+ directoriesSkipped: skippedDirs,
238
+ timing: {
239
+ totalMs: tEnd - t0,
240
+ walkMs: tWalkEnd - tWalkStart, // includes metadata generation (interleaved)
241
+ embedMs: tEmbedEnd - tTfidfEnd,
242
+ tfidfMs: tTfidfEnd - tWalkEnd,
243
+ },
138
244
  }
139
245
  }
140
246
 
141
247
  // ── Helpers ─────────────────────────────────────────────────────────────────
142
248
 
143
- function collectDirectoryGroups(
144
- typeRoot: string,
145
- assetType: AgentikitAssetType,
146
- ): Map<string, string[]> {
147
- const groups = new Map<string, string[]>()
148
-
149
- const walk = (dir: string): void => {
150
- if (!fs.existsSync(dir)) return
151
- const entries = fs.readdirSync(dir, { withFileTypes: true })
152
- for (const entry of entries) {
153
- if (entry.name === ".stash.json") continue
154
- const fullPath = path.join(dir, entry.name)
155
- if (entry.isDirectory()) {
156
- walk(fullPath)
157
- } else if (entry.isFile() && isRelevantFile(entry.name, assetType)) {
158
- const parentDir = path.dirname(fullPath)
159
- const existing = groups.get(parentDir)
160
- if (existing) {
161
- existing.push(fullPath)
162
- } else {
163
- groups.set(parentDir, [fullPath])
164
- }
165
- }
249
+ function isDirStale(
250
+ dirPath: string,
251
+ currentFiles: string[],
252
+ previousEntries: IndexedEntry[],
253
+ builtAtMs: number,
254
+ ): boolean {
255
+ // Check if file set changed (additions or deletions)
256
+ const prevFileNames = new Set(
257
+ previousEntries
258
+ .map((ie) => ie.entry.entry)
259
+ .filter((e): e is string => !!e),
260
+ )
261
+ const currFileNames = new Set(currentFiles.map((f) => path.basename(f)))
262
+ if (prevFileNames.size !== currFileNames.size) return true
263
+ for (const name of currFileNames) {
264
+ if (!prevFileNames.has(name)) return true
265
+ }
266
+
267
+ // Check modification times of current files
268
+ for (const file of currentFiles) {
269
+ try {
270
+ if (fs.statSync(file).mtimeMs > builtAtMs) return true
271
+ } catch {
272
+ return true
166
273
  }
167
274
  }
168
275
 
169
- walk(typeRoot)
170
- return groups
276
+ // Check .stash.json modification time
277
+ const stashPath = path.join(dirPath, ".stash.json")
278
+ try {
279
+ if (fs.existsSync(stashPath) && fs.statSync(stashPath).mtimeMs > builtAtMs) return true
280
+ } catch {
281
+ // ignore
282
+ }
283
+
284
+ return false
285
+ }
286
+
287
+ function migrateGeneratedSkillMetadata(
288
+ stash: StashFile,
289
+ files: string[],
290
+ typeRoot: string,
291
+ ): { stash: StashFile; changed: boolean } {
292
+ const fileByBaseName = new Map(files.map((filePath) => [path.basename(filePath), filePath]))
293
+ let changed = false
294
+
295
+ const entries = stash.entries.map((entry) => {
296
+ if (entry.type !== "skill" || entry.generated !== true) return entry
297
+
298
+ const hintedFilePath = entry.entry ? fileByBaseName.get(path.basename(entry.entry)) : undefined
299
+ const skillFilePath = hintedFilePath ?? fileByBaseName.get("SKILL.md")
300
+ if (!skillFilePath) return entry
301
+
302
+ const canonicalName = deriveCanonicalAssetName("skill", typeRoot, skillFilePath)
303
+ if (!canonicalName || canonicalName === entry.name) return entry
304
+
305
+ changed = true
306
+ return { ...entry, name: canonicalName }
307
+ })
308
+
309
+ if (!changed) {
310
+ return { stash, changed: false }
311
+ }
312
+
313
+ return {
314
+ stash: { entries },
315
+ changed: true,
316
+ }
171
317
  }
172
318
 
173
- function isRelevantFile(fileName: string, assetType: AgentikitAssetType): boolean {
174
- const ext = path.extname(fileName).toLowerCase()
175
- switch (assetType) {
176
- case "tool":
177
- return SCRIPT_EXTENSIONS.has(ext)
178
- case "skill":
179
- return fileName === "SKILL.md"
180
- case "command":
181
- case "agent":
182
- return ext === ".md"
183
- default:
184
- return false
319
+ async function enhanceStashWithLlm(
320
+ llmConfig: LlmConnectionConfig,
321
+ stash: StashFile,
322
+ dirPath: string,
323
+ files: string[],
324
+ ): Promise<StashFile> {
325
+ const { enhanceMetadata } = await import("./llm.js")
326
+
327
+ const enhanced: StashEntry[] = []
328
+ for (const entry of stash.entries) {
329
+ try {
330
+ // Find the file matching this entry for content context
331
+ const entryFile = entry.entry
332
+ ? files.find((f) => path.basename(f) === entry.entry) ?? files[0]
333
+ : files[0]
334
+ let fileContent: string | undefined
335
+ if (entryFile) {
336
+ try {
337
+ fileContent = fs.readFileSync(entryFile, "utf8")
338
+ } catch { /* ignore unreadable files */ }
339
+ }
340
+
341
+ const improvements = await enhanceMetadata(llmConfig, entry, fileContent)
342
+ const updated = { ...entry }
343
+ if (improvements.description) updated.description = improvements.description
344
+ if (improvements.intents?.length) updated.intents = improvements.intents
345
+ if (improvements.tags?.length) updated.tags = improvements.tags
346
+ enhanced.push(updated)
347
+ } catch {
348
+ // LLM enhancement failed for this entry, keep original
349
+ enhanced.push(entry)
350
+ }
185
351
  }
352
+ return { entries: enhanced }
186
353
  }
187
354
 
188
355
  export function buildSearchText(entry: StashEntry): string {
@@ -190,22 +357,15 @@ export function buildSearchText(entry: StashEntry): string {
190
357
  if (entry.description) parts.push(entry.description)
191
358
  if (entry.tags) parts.push(entry.tags.join(" "))
192
359
  if (entry.examples) parts.push(entry.examples.join(" "))
360
+ if (entry.aliases) parts.push(entry.aliases.join(" "))
361
+ if (entry.intents) parts.push(entry.intents.join(" "))
193
362
  if (entry.intent) {
194
363
  if (entry.intent.when) parts.push(entry.intent.when)
195
364
  if (entry.intent.input) parts.push(entry.intent.input)
196
365
  if (entry.intent.output) parts.push(entry.intent.output)
197
366
  }
198
- return parts.join(" ").toLowerCase()
199
- }
200
-
201
- function resolveStashDirForIndex(): string {
202
- const raw = process.env.AGENTIKIT_STASH_DIR?.trim()
203
- if (!raw) {
204
- throw new Error("AGENTIKIT_STASH_DIR is not set. Run 'agentikit init' first.")
205
- }
206
- const stashDir = path.resolve(raw)
207
- if (!fs.existsSync(stashDir) || !fs.statSync(stashDir).isDirectory()) {
208
- throw new Error(`AGENTIKIT_STASH_DIR does not exist or is not a directory: "${stashDir}"`)
367
+ if (entry.toc) {
368
+ parts.push(entry.toc.map((h) => h.text).join(" "))
209
369
  }
210
- return stashDir
370
+ return parts.join(" ").toLowerCase()
211
371
  }
package/src/init.ts ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Agentikit initialization logic.
3
+ *
4
+ * Creates the stash directory structure, sets the AGENTIKIT_STASH_DIR
5
+ * environment variable, and ensures ripgrep is available.
6
+ */
7
+
8
+ import { spawnSync } from "node:child_process"
9
+ import fs from "node:fs"
10
+ import path from "node:path"
11
+ import { IS_WINDOWS, TYPE_DIRS } from "./common"
12
+ import { ensureRg } from "./ripgrep-install"
13
+ import { getConfigPath, saveConfig, DEFAULT_CONFIG } from "./config"
14
+
15
+ export interface InitResponse {
16
+ stashDir: string
17
+ created: boolean
18
+ envSet: boolean
19
+ profileUpdated?: string
20
+ configPath: string
21
+ ripgrep?: {
22
+ rgPath: string
23
+ installed: boolean
24
+ version: string
25
+ }
26
+ }
27
+
28
+ export function agentikitInit(): InitResponse {
29
+ let stashDir: string
30
+ if (IS_WINDOWS) {
31
+ const userProfile = process.env.USERPROFILE?.trim()
32
+ if (!userProfile) {
33
+ throw new Error("Unable to determine Documents folder. Ensure USERPROFILE is set.")
34
+ }
35
+ const docs = path.join(userProfile, "Documents")
36
+ stashDir = path.join(docs, "agentikit")
37
+ } else {
38
+ const home = process.env.HOME?.trim()
39
+ if (!home) {
40
+ throw new Error("Unable to determine home directory. Set HOME.")
41
+ }
42
+ stashDir = path.join(home, "agentikit")
43
+ }
44
+
45
+ let created = false
46
+ if (!fs.existsSync(stashDir)) {
47
+ fs.mkdirSync(stashDir, { recursive: true })
48
+ created = true
49
+ }
50
+
51
+ for (const sub of Object.values(TYPE_DIRS)) {
52
+ const subDir = path.join(stashDir, sub)
53
+ if (!fs.existsSync(subDir)) {
54
+ fs.mkdirSync(subDir, { recursive: true })
55
+ }
56
+ }
57
+
58
+ let envSet = false
59
+ let profileUpdated: string | undefined
60
+
61
+ if (IS_WINDOWS) {
62
+ const result = spawnSync("setx", ["AGENTIKIT_STASH_DIR", stashDir], {
63
+ encoding: "utf8",
64
+ timeout: 10_000,
65
+ })
66
+ envSet = result.status === 0
67
+ } else {
68
+ const shell = process.env.SHELL || ""
69
+ const homeDir = process.env.HOME! // already validated non-empty above
70
+ let profile: string
71
+ if (shell.endsWith("/zsh")) {
72
+ profile = path.join(homeDir, ".zshrc")
73
+ } else if (shell.endsWith("/bash")) {
74
+ profile = path.join(homeDir, ".bashrc")
75
+ } else {
76
+ profile = path.join(homeDir, ".profile")
77
+ }
78
+
79
+ const exportLine = `export AGENTIKIT_STASH_DIR="${stashDir}"`
80
+ const existing = fs.existsSync(profile) ? fs.readFileSync(profile, "utf8") : ""
81
+ if (!existing.includes("AGENTIKIT_STASH_DIR")) {
82
+ fs.appendFileSync(profile, `\n# Agentikit stash directory\n${exportLine}\n`)
83
+ envSet = true
84
+ profileUpdated = profile
85
+ }
86
+ }
87
+
88
+ // Create default config.json if it doesn't exist
89
+ const configPath = getConfigPath(stashDir)
90
+ if (!fs.existsSync(configPath)) {
91
+ saveConfig(DEFAULT_CONFIG, stashDir)
92
+ }
93
+
94
+ process.env.AGENTIKIT_STASH_DIR = stashDir
95
+
96
+ // Ensure ripgrep is available (install to stash/bin if needed)
97
+ let ripgrep: InitResponse["ripgrep"]
98
+ try {
99
+ const rgResult = ensureRg(stashDir)
100
+ ripgrep = rgResult
101
+ } catch {
102
+ // Non-fatal: ripgrep is optional, search works without it
103
+ }
104
+
105
+ return { stashDir, created, envSet, profileUpdated, configPath, ripgrep }
106
+ }
package/src/llm.ts ADDED
@@ -0,0 +1,124 @@
1
+ import type { LlmConnectionConfig } from "./config"
2
+ import type { StashEntry } from "./metadata"
3
+
4
+ // ── OpenAI-compatible chat completions ──────────────────────────────────────
5
+
6
+ interface ChatMessage {
7
+ role: "system" | "user" | "assistant"
8
+ content: string
9
+ }
10
+
11
+ interface ChatCompletionResponse {
12
+ choices: Array<{ message: { content: string } }>
13
+ }
14
+
15
+ async function chatCompletion(
16
+ config: LlmConnectionConfig,
17
+ messages: ChatMessage[],
18
+ ): Promise<string> {
19
+ const headers: Record<string, string> = { "Content-Type": "application/json" }
20
+ if (config.apiKey) {
21
+ headers["Authorization"] = `Bearer ${config.apiKey}`
22
+ }
23
+
24
+ const response = await fetch(config.endpoint, {
25
+ method: "POST",
26
+ headers,
27
+ body: JSON.stringify({
28
+ model: config.model,
29
+ messages,
30
+ temperature: 0.3,
31
+ max_tokens: 512,
32
+ }),
33
+ })
34
+
35
+ if (!response.ok) {
36
+ const body = await response.text().catch(() => "")
37
+ throw new Error(`LLM request failed (${response.status}): ${body}`)
38
+ }
39
+
40
+ const json = (await response.json()) as ChatCompletionResponse
41
+ return json.choices?.[0]?.message?.content?.trim() ?? ""
42
+ }
43
+
44
+ // ── Metadata Enhancement ────────────────────────────────────────────────────
45
+
46
+ const SYSTEM_PROMPT = `You are a metadata generator for a developer tool registry. Given a tool/skill/command/agent entry, generate improved metadata. Respond with ONLY valid JSON, no markdown fencing.`
47
+
48
+ /**
49
+ * Use an LLM to enhance a stash entry's metadata: improve description,
50
+ * generate intents, and suggest tags.
51
+ */
52
+ export async function enhanceMetadata(
53
+ config: LlmConnectionConfig,
54
+ entry: StashEntry,
55
+ fileContent?: string,
56
+ ): Promise<{ description?: string; intents?: string[]; tags?: string[] }> {
57
+ const contextParts = [
58
+ `Name: ${entry.name}`,
59
+ `Type: ${entry.type}`,
60
+ ]
61
+ if (entry.description) contextParts.push(`Current description: ${entry.description}`)
62
+ if (entry.tags?.length) contextParts.push(`Current tags: ${entry.tags.join(", ")}`)
63
+ if (fileContent) {
64
+ // Limit content to first 2000 chars to stay within token limits
65
+ const truncated = fileContent.length > 2000
66
+ ? fileContent.slice(0, 2000) + "\n... (truncated)"
67
+ : fileContent
68
+ contextParts.push(`File content:\n${truncated}`)
69
+ }
70
+
71
+ const userPrompt = `${contextParts.join("\n")}
72
+
73
+ Generate improved metadata for this ${entry.type}. Return JSON with these fields:
74
+ - "description": a clear, concise one-sentence description of what this does
75
+ - "intents": an array of 3-6 natural language task phrases an agent might use to find this (e.g. "deploy a docker container", "run database migrations")
76
+ - "tags": an array of 3-8 relevant keyword tags
77
+
78
+ Return ONLY the JSON object, no explanation.`
79
+
80
+ const raw = await chatCompletion(config, [
81
+ { role: "system", content: SYSTEM_PROMPT },
82
+ { role: "user", content: userPrompt },
83
+ ])
84
+
85
+ try {
86
+ // Strip markdown code fences if present
87
+ const cleaned = raw.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, "")
88
+ const parsed = JSON.parse(cleaned) as Record<string, unknown>
89
+ const result: { description?: string; intents?: string[]; tags?: string[] } = {}
90
+
91
+ if (typeof parsed.description === "string" && parsed.description) {
92
+ result.description = parsed.description
93
+ }
94
+ if (Array.isArray(parsed.intents)) {
95
+ result.intents = parsed.intents.filter(
96
+ (s): s is string => typeof s === "string" && s.trim().length > 0,
97
+ ).slice(0, 8)
98
+ }
99
+ if (Array.isArray(parsed.tags)) {
100
+ result.tags = parsed.tags.filter(
101
+ (s): s is string => typeof s === "string" && s.trim().length > 0,
102
+ ).slice(0, 10)
103
+ }
104
+
105
+ return result
106
+ } catch {
107
+ // LLM returned unparseable output, return empty
108
+ return {}
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Check if the LLM endpoint is reachable.
114
+ */
115
+ export async function isLlmAvailable(config: LlmConnectionConfig): Promise<boolean> {
116
+ try {
117
+ const result = await chatCompletion(config, [
118
+ { role: "user", content: "Respond with just the word: ok" },
119
+ ])
120
+ return result.length > 0
121
+ } catch {
122
+ return false
123
+ }
124
+ }