agentikit 0.0.7 → 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 +13 -3
  3. package/dist/index.js +7 -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 -36
  30. package/dist/src/ripgrep.js +2 -262
  31. package/dist/src/similarity.d.ts +1 -2
  32. package/dist/src/similarity.js +11 -0
  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 -63
  44. package/dist/src/stash.js +4 -633
  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 -315
  64. package/src/similarity.ts +13 -1
  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 -760
  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 -73
  80. package/src/plugin.ts +0 -56
@@ -1,16 +1,12 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { resolveStashDir } from "./common";
4
+ import { ASSET_TYPES, TYPE_DIRS, deriveCanonicalAssetName } from "./asset-spec";
3
5
  import { loadStashFile, writeStashFile, generateMetadata, } from "./metadata";
4
6
  import { TfIdfAdapter } from "./similarity";
7
+ import { walkStash } from "./walker";
5
8
  // ── Constants ───────────────────────────────────────────────────────────────
6
- const INDEX_VERSION = 1;
7
- const SCRIPT_EXTENSIONS = new Set([".sh", ".ts", ".js", ".ps1", ".cmd", ".bat"]);
8
- const TYPE_DIRS = {
9
- tool: "tools",
10
- skill: "skills",
11
- command: "commands",
12
- agent: "agents",
13
- };
9
+ const INDEX_VERSION = 4;
14
10
  // ── Index Path ──────────────────────────────────────────────────────────────
15
11
  export function getIndexPath() {
16
12
  const cacheDir = process.env.XDG_CACHE_HOME
@@ -32,37 +28,98 @@ export function loadSearchIndex() {
32
28
  }
33
29
  }
34
30
  // ── Indexer ──────────────────────────────────────────────────────────────────
35
- export function agentikitIndex(options) {
36
- const stashDir = options?.stashDir || resolveStashDirForIndex();
31
+ export async function agentikitIndex(options) {
32
+ const stashDir = options?.stashDir || resolveStashDir();
33
+ // Load config to get additional stash dirs and semantic search setting
34
+ const { loadConfig } = await import("./config.js");
35
+ const config = loadConfig(stashDir);
36
+ const allStashDirs = [stashDir];
37
+ for (const d of config.additionalStashDirs) {
38
+ try {
39
+ if (fs.statSync(d).isDirectory() && !allStashDirs.includes(path.resolve(d))) {
40
+ allStashDirs.push(path.resolve(d));
41
+ }
42
+ }
43
+ catch { /* skip nonexistent dirs */ }
44
+ }
45
+ const t0 = Date.now();
37
46
  const allEntries = [];
38
47
  let generatedCount = 0;
39
- for (const assetType of Object.keys(TYPE_DIRS)) {
40
- const typeRoot = path.join(stashDir, TYPE_DIRS[assetType]);
41
- if (!fs.existsSync(typeRoot) || !fs.statSync(typeRoot).isDirectory())
42
- continue;
43
- // Group files by their immediate parent directory
44
- const dirGroups = collectDirectoryGroups(typeRoot, assetType);
45
- for (const [dirPath, files] of dirGroups) {
46
- // Try loading existing .stash.json
47
- let stash = loadStashFile(dirPath);
48
- if (!stash) {
49
- // Generate metadata
50
- stash = generateMetadata(dirPath, assetType, files);
51
- if (stash.entries.length > 0) {
52
- writeStashFile(dirPath, stash);
53
- generatedCount += stash.entries.length;
54
- }
48
+ let scannedDirs = 0;
49
+ let skippedDirs = 0;
50
+ // Load previous index for incremental mode
51
+ const previousIndex = !options?.full ? loadSearchIndex() : null;
52
+ const isIncremental = previousIndex !== null && previousIndex.stashDir === stashDir;
53
+ const builtAtMs = isIncremental ? new Date(previousIndex.builtAt).getTime() : 0;
54
+ // Build lookup of previous entries by dirPath
55
+ const previousEntriesByDir = new Map();
56
+ if (isIncremental) {
57
+ for (const ie of previousIndex.entries) {
58
+ const list = previousEntriesByDir.get(ie.dirPath) || [];
59
+ list.push(ie);
60
+ previousEntriesByDir.set(ie.dirPath, list);
61
+ }
62
+ }
63
+ const seenPaths = new Set();
64
+ const tWalkStart = Date.now();
65
+ for (const currentStashDir of allStashDirs) {
66
+ for (const assetType of ASSET_TYPES) {
67
+ const typeRoot = path.join(currentStashDir, TYPE_DIRS[assetType]);
68
+ try {
69
+ if (!fs.statSync(typeRoot).isDirectory())
70
+ continue;
71
+ }
72
+ catch {
73
+ continue;
55
74
  }
56
- if (stash) {
57
- for (const entry of stash.entries) {
58
- const entryPath = entry.entry
59
- ? path.join(dirPath, entry.entry)
60
- : files[0] || dirPath;
61
- allEntries.push({ entry, path: entryPath, dirPath });
75
+ // Group files by their immediate parent directory
76
+ const dirGroups = walkStash(typeRoot, assetType);
77
+ for (const { dirPath, files } of dirGroups) {
78
+ // Deduplicate by dirPath across stash dirs
79
+ if (seenPaths.has(path.resolve(dirPath)))
80
+ continue;
81
+ seenPaths.add(path.resolve(dirPath));
82
+ // Incremental: skip directories that haven't changed
83
+ const prevEntries = previousEntriesByDir.get(dirPath);
84
+ if (isIncremental && prevEntries && !isDirStale(dirPath, files, prevEntries, builtAtMs)) {
85
+ allEntries.push(...prevEntries);
86
+ skippedDirs++;
87
+ continue;
88
+ }
89
+ scannedDirs++;
90
+ // Try loading existing .stash.json
91
+ let stash = loadStashFile(dirPath);
92
+ if (stash) {
93
+ const migration = migrateGeneratedSkillMetadata(stash, files, typeRoot);
94
+ if (migration.changed) {
95
+ stash = migration.stash;
96
+ writeStashFile(dirPath, stash);
97
+ }
98
+ }
99
+ if (!stash) {
100
+ // Generate metadata
101
+ stash = generateMetadata(dirPath, assetType, files, typeRoot);
102
+ // Enhance with LLM if configured
103
+ if (config.llm && stash.entries.length > 0) {
104
+ stash = await enhanceStashWithLlm(config.llm, stash, dirPath, files);
105
+ }
106
+ if (stash.entries.length > 0) {
107
+ writeStashFile(dirPath, stash);
108
+ generatedCount += stash.entries.length;
109
+ }
110
+ }
111
+ if (stash) {
112
+ for (const entry of stash.entries) {
113
+ const entryPath = entry.entry
114
+ ? path.join(dirPath, entry.entry)
115
+ : files[0] || dirPath;
116
+ allEntries.push({ entry, path: entryPath, dirPath });
117
+ }
62
118
  }
63
119
  }
64
120
  }
65
121
  }
122
+ const tWalkEnd = Date.now();
66
123
  // Build TF-IDF index
67
124
  const adapter = new TfIdfAdapter();
68
125
  const scoredEntries = allEntries.map((ie) => ({
@@ -72,6 +129,25 @@ export function agentikitIndex(options) {
72
129
  path: ie.path,
73
130
  }));
74
131
  adapter.buildIndex(scoredEntries);
132
+ const tTfidfEnd = Date.now();
133
+ // Generate embeddings if semantic search is enabled
134
+ let hasEmbeddings = false;
135
+ if (config.semanticSearch) {
136
+ try {
137
+ const { embed } = await import("./embedder.js");
138
+ for (const ie of allEntries) {
139
+ if (!ie.embedding) {
140
+ const text = buildSearchText(ie.entry);
141
+ ie.embedding = await embed(text, config.embedding);
142
+ }
143
+ }
144
+ hasEmbeddings = true;
145
+ }
146
+ catch {
147
+ // Embedding provider not available, continue without embeddings
148
+ }
149
+ }
150
+ const tEmbedEnd = Date.now();
75
151
  // Persist index
76
152
  const indexPath = getIndexPath();
77
153
  const indexDir = path.dirname(indexPath);
@@ -82,59 +158,119 @@ export function agentikitIndex(options) {
82
158
  version: INDEX_VERSION,
83
159
  builtAt: new Date().toISOString(),
84
160
  stashDir,
161
+ stashDirs: allStashDirs,
85
162
  entries: allEntries,
86
163
  tfidf: adapter.serialize(),
164
+ hasEmbeddings,
87
165
  };
88
166
  fs.writeFileSync(indexPath, JSON.stringify(index) + "\n", "utf8");
167
+ const tEnd = Date.now();
89
168
  return {
90
169
  stashDir,
91
170
  totalEntries: allEntries.length,
92
171
  generatedMetadata: generatedCount,
93
172
  indexPath,
173
+ mode: isIncremental ? "incremental" : "full",
174
+ directoriesScanned: scannedDirs,
175
+ directoriesSkipped: skippedDirs,
176
+ timing: {
177
+ totalMs: tEnd - t0,
178
+ walkMs: tWalkEnd - tWalkStart, // includes metadata generation (interleaved)
179
+ embedMs: tEmbedEnd - tTfidfEnd,
180
+ tfidfMs: tTfidfEnd - tWalkEnd,
181
+ },
94
182
  };
95
183
  }
96
184
  // ── Helpers ─────────────────────────────────────────────────────────────────
97
- function collectDirectoryGroups(typeRoot, assetType) {
98
- const groups = new Map();
99
- const walk = (dir) => {
100
- if (!fs.existsSync(dir))
101
- return;
102
- const entries = fs.readdirSync(dir, { withFileTypes: true });
103
- for (const entry of entries) {
104
- if (entry.name === ".stash.json")
105
- continue;
106
- const fullPath = path.join(dir, entry.name);
107
- if (entry.isDirectory()) {
108
- walk(fullPath);
109
- }
110
- else if (entry.isFile() && isRelevantFile(entry.name, assetType)) {
111
- const parentDir = path.dirname(fullPath);
112
- const existing = groups.get(parentDir);
113
- if (existing) {
114
- existing.push(fullPath);
115
- }
116
- else {
117
- groups.set(parentDir, [fullPath]);
118
- }
119
- }
185
+ function isDirStale(dirPath, currentFiles, previousEntries, builtAtMs) {
186
+ // Check if file set changed (additions or deletions)
187
+ const prevFileNames = new Set(previousEntries
188
+ .map((ie) => ie.entry.entry)
189
+ .filter((e) => !!e));
190
+ const currFileNames = new Set(currentFiles.map((f) => path.basename(f)));
191
+ if (prevFileNames.size !== currFileNames.size)
192
+ return true;
193
+ for (const name of currFileNames) {
194
+ if (!prevFileNames.has(name))
195
+ return true;
196
+ }
197
+ // Check modification times of current files
198
+ for (const file of currentFiles) {
199
+ try {
200
+ if (fs.statSync(file).mtimeMs > builtAtMs)
201
+ return true;
202
+ }
203
+ catch {
204
+ return true;
120
205
  }
206
+ }
207
+ // Check .stash.json modification time
208
+ const stashPath = path.join(dirPath, ".stash.json");
209
+ try {
210
+ if (fs.existsSync(stashPath) && fs.statSync(stashPath).mtimeMs > builtAtMs)
211
+ return true;
212
+ }
213
+ catch {
214
+ // ignore
215
+ }
216
+ return false;
217
+ }
218
+ function migrateGeneratedSkillMetadata(stash, files, typeRoot) {
219
+ const fileByBaseName = new Map(files.map((filePath) => [path.basename(filePath), filePath]));
220
+ let changed = false;
221
+ const entries = stash.entries.map((entry) => {
222
+ if (entry.type !== "skill" || entry.generated !== true)
223
+ return entry;
224
+ const hintedFilePath = entry.entry ? fileByBaseName.get(path.basename(entry.entry)) : undefined;
225
+ const skillFilePath = hintedFilePath ?? fileByBaseName.get("SKILL.md");
226
+ if (!skillFilePath)
227
+ return entry;
228
+ const canonicalName = deriveCanonicalAssetName("skill", typeRoot, skillFilePath);
229
+ if (!canonicalName || canonicalName === entry.name)
230
+ return entry;
231
+ changed = true;
232
+ return { ...entry, name: canonicalName };
233
+ });
234
+ if (!changed) {
235
+ return { stash, changed: false };
236
+ }
237
+ return {
238
+ stash: { entries },
239
+ changed: true,
121
240
  };
122
- walk(typeRoot);
123
- return groups;
124
241
  }
125
- function isRelevantFile(fileName, assetType) {
126
- const ext = path.extname(fileName).toLowerCase();
127
- switch (assetType) {
128
- case "tool":
129
- return SCRIPT_EXTENSIONS.has(ext);
130
- case "skill":
131
- return fileName === "SKILL.md";
132
- case "command":
133
- case "agent":
134
- return ext === ".md";
135
- default:
136
- return false;
242
+ async function enhanceStashWithLlm(llmConfig, stash, dirPath, files) {
243
+ const { enhanceMetadata } = await import("./llm.js");
244
+ const enhanced = [];
245
+ for (const entry of stash.entries) {
246
+ try {
247
+ // Find the file matching this entry for content context
248
+ const entryFile = entry.entry
249
+ ? files.find((f) => path.basename(f) === entry.entry) ?? files[0]
250
+ : files[0];
251
+ let fileContent;
252
+ if (entryFile) {
253
+ try {
254
+ fileContent = fs.readFileSync(entryFile, "utf8");
255
+ }
256
+ catch { /* ignore unreadable files */ }
257
+ }
258
+ const improvements = await enhanceMetadata(llmConfig, entry, fileContent);
259
+ const updated = { ...entry };
260
+ if (improvements.description)
261
+ updated.description = improvements.description;
262
+ if (improvements.intents?.length)
263
+ updated.intents = improvements.intents;
264
+ if (improvements.tags?.length)
265
+ updated.tags = improvements.tags;
266
+ enhanced.push(updated);
267
+ }
268
+ catch {
269
+ // LLM enhancement failed for this entry, keep original
270
+ enhanced.push(entry);
271
+ }
137
272
  }
273
+ return { entries: enhanced };
138
274
  }
139
275
  export function buildSearchText(entry) {
140
276
  const parts = [entry.name.replace(/[-_]/g, " ")];
@@ -144,6 +280,10 @@ export function buildSearchText(entry) {
144
280
  parts.push(entry.tags.join(" "));
145
281
  if (entry.examples)
146
282
  parts.push(entry.examples.join(" "));
283
+ if (entry.aliases)
284
+ parts.push(entry.aliases.join(" "));
285
+ if (entry.intents)
286
+ parts.push(entry.intents.join(" "));
147
287
  if (entry.intent) {
148
288
  if (entry.intent.when)
149
289
  parts.push(entry.intent.when);
@@ -152,16 +292,8 @@ export function buildSearchText(entry) {
152
292
  if (entry.intent.output)
153
293
  parts.push(entry.intent.output);
154
294
  }
155
- return parts.join(" ").toLowerCase();
156
- }
157
- function resolveStashDirForIndex() {
158
- const raw = process.env.AGENTIKIT_STASH_DIR?.trim();
159
- if (!raw) {
160
- throw new Error("AGENTIKIT_STASH_DIR is not set. Run 'agentikit init' first.");
295
+ if (entry.toc) {
296
+ parts.push(entry.toc.map((h) => h.text).join(" "));
161
297
  }
162
- const stashDir = path.resolve(raw);
163
- if (!fs.existsSync(stashDir) || !fs.statSync(stashDir).isDirectory()) {
164
- throw new Error(`AGENTIKIT_STASH_DIR does not exist or is not a directory: "${stashDir}"`);
165
- }
166
- return stashDir;
298
+ return parts.join(" ").toLowerCase();
167
299
  }
@@ -0,0 +1,19 @@
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
+ export interface InitResponse {
8
+ stashDir: string;
9
+ created: boolean;
10
+ envSet: boolean;
11
+ profileUpdated?: string;
12
+ configPath: string;
13
+ ripgrep?: {
14
+ rgPath: string;
15
+ installed: boolean;
16
+ version: string;
17
+ };
18
+ }
19
+ export declare function agentikitInit(): InitResponse;
@@ -0,0 +1,87 @@
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
+ import { spawnSync } from "node:child_process";
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { IS_WINDOWS, TYPE_DIRS } from "./common";
11
+ import { ensureRg } from "./ripgrep-install";
12
+ import { getConfigPath, saveConfig, DEFAULT_CONFIG } from "./config";
13
+ export function agentikitInit() {
14
+ let stashDir;
15
+ if (IS_WINDOWS) {
16
+ const userProfile = process.env.USERPROFILE?.trim();
17
+ if (!userProfile) {
18
+ throw new Error("Unable to determine Documents folder. Ensure USERPROFILE is set.");
19
+ }
20
+ const docs = path.join(userProfile, "Documents");
21
+ stashDir = path.join(docs, "agentikit");
22
+ }
23
+ else {
24
+ const home = process.env.HOME?.trim();
25
+ if (!home) {
26
+ throw new Error("Unable to determine home directory. Set HOME.");
27
+ }
28
+ stashDir = path.join(home, "agentikit");
29
+ }
30
+ let created = false;
31
+ if (!fs.existsSync(stashDir)) {
32
+ fs.mkdirSync(stashDir, { recursive: true });
33
+ created = true;
34
+ }
35
+ for (const sub of Object.values(TYPE_DIRS)) {
36
+ const subDir = path.join(stashDir, sub);
37
+ if (!fs.existsSync(subDir)) {
38
+ fs.mkdirSync(subDir, { recursive: true });
39
+ }
40
+ }
41
+ let envSet = false;
42
+ let profileUpdated;
43
+ if (IS_WINDOWS) {
44
+ const result = spawnSync("setx", ["AGENTIKIT_STASH_DIR", stashDir], {
45
+ encoding: "utf8",
46
+ timeout: 10_000,
47
+ });
48
+ envSet = result.status === 0;
49
+ }
50
+ else {
51
+ const shell = process.env.SHELL || "";
52
+ const homeDir = process.env.HOME; // already validated non-empty above
53
+ let profile;
54
+ if (shell.endsWith("/zsh")) {
55
+ profile = path.join(homeDir, ".zshrc");
56
+ }
57
+ else if (shell.endsWith("/bash")) {
58
+ profile = path.join(homeDir, ".bashrc");
59
+ }
60
+ else {
61
+ profile = path.join(homeDir, ".profile");
62
+ }
63
+ const exportLine = `export AGENTIKIT_STASH_DIR="${stashDir}"`;
64
+ const existing = fs.existsSync(profile) ? fs.readFileSync(profile, "utf8") : "";
65
+ if (!existing.includes("AGENTIKIT_STASH_DIR")) {
66
+ fs.appendFileSync(profile, `\n# Agentikit stash directory\n${exportLine}\n`);
67
+ envSet = true;
68
+ profileUpdated = profile;
69
+ }
70
+ }
71
+ // Create default config.json if it doesn't exist
72
+ const configPath = getConfigPath(stashDir);
73
+ if (!fs.existsSync(configPath)) {
74
+ saveConfig(DEFAULT_CONFIG, stashDir);
75
+ }
76
+ process.env.AGENTIKIT_STASH_DIR = stashDir;
77
+ // Ensure ripgrep is available (install to stash/bin if needed)
78
+ let ripgrep;
79
+ try {
80
+ const rgResult = ensureRg(stashDir);
81
+ ripgrep = rgResult;
82
+ }
83
+ catch {
84
+ // Non-fatal: ripgrep is optional, search works without it
85
+ }
86
+ return { stashDir, created, envSet, profileUpdated, configPath, ripgrep };
87
+ }
@@ -0,0 +1,15 @@
1
+ import type { LlmConnectionConfig } from "./config";
2
+ import type { StashEntry } from "./metadata";
3
+ /**
4
+ * Use an LLM to enhance a stash entry's metadata: improve description,
5
+ * generate intents, and suggest tags.
6
+ */
7
+ export declare function enhanceMetadata(config: LlmConnectionConfig, entry: StashEntry, fileContent?: string): Promise<{
8
+ description?: string;
9
+ intents?: string[];
10
+ tags?: string[];
11
+ }>;
12
+ /**
13
+ * Check if the LLM endpoint is reachable.
14
+ */
15
+ export declare function isLlmAvailable(config: LlmConnectionConfig): Promise<boolean>;
@@ -0,0 +1,91 @@
1
+ async function chatCompletion(config, messages) {
2
+ const headers = { "Content-Type": "application/json" };
3
+ if (config.apiKey) {
4
+ headers["Authorization"] = `Bearer ${config.apiKey}`;
5
+ }
6
+ const response = await fetch(config.endpoint, {
7
+ method: "POST",
8
+ headers,
9
+ body: JSON.stringify({
10
+ model: config.model,
11
+ messages,
12
+ temperature: 0.3,
13
+ max_tokens: 512,
14
+ }),
15
+ });
16
+ if (!response.ok) {
17
+ const body = await response.text().catch(() => "");
18
+ throw new Error(`LLM request failed (${response.status}): ${body}`);
19
+ }
20
+ const json = (await response.json());
21
+ return json.choices?.[0]?.message?.content?.trim() ?? "";
22
+ }
23
+ // ── Metadata Enhancement ────────────────────────────────────────────────────
24
+ 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.`;
25
+ /**
26
+ * Use an LLM to enhance a stash entry's metadata: improve description,
27
+ * generate intents, and suggest tags.
28
+ */
29
+ export async function enhanceMetadata(config, entry, fileContent) {
30
+ const contextParts = [
31
+ `Name: ${entry.name}`,
32
+ `Type: ${entry.type}`,
33
+ ];
34
+ if (entry.description)
35
+ contextParts.push(`Current description: ${entry.description}`);
36
+ if (entry.tags?.length)
37
+ contextParts.push(`Current tags: ${entry.tags.join(", ")}`);
38
+ if (fileContent) {
39
+ // Limit content to first 2000 chars to stay within token limits
40
+ const truncated = fileContent.length > 2000
41
+ ? fileContent.slice(0, 2000) + "\n... (truncated)"
42
+ : fileContent;
43
+ contextParts.push(`File content:\n${truncated}`);
44
+ }
45
+ const userPrompt = `${contextParts.join("\n")}
46
+
47
+ Generate improved metadata for this ${entry.type}. Return JSON with these fields:
48
+ - "description": a clear, concise one-sentence description of what this does
49
+ - "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")
50
+ - "tags": an array of 3-8 relevant keyword tags
51
+
52
+ Return ONLY the JSON object, no explanation.`;
53
+ const raw = await chatCompletion(config, [
54
+ { role: "system", content: SYSTEM_PROMPT },
55
+ { role: "user", content: userPrompt },
56
+ ]);
57
+ try {
58
+ // Strip markdown code fences if present
59
+ const cleaned = raw.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, "");
60
+ const parsed = JSON.parse(cleaned);
61
+ const result = {};
62
+ if (typeof parsed.description === "string" && parsed.description) {
63
+ result.description = parsed.description;
64
+ }
65
+ if (Array.isArray(parsed.intents)) {
66
+ result.intents = parsed.intents.filter((s) => typeof s === "string" && s.trim().length > 0).slice(0, 8);
67
+ }
68
+ if (Array.isArray(parsed.tags)) {
69
+ result.tags = parsed.tags.filter((s) => typeof s === "string" && s.trim().length > 0).slice(0, 10);
70
+ }
71
+ return result;
72
+ }
73
+ catch {
74
+ // LLM returned unparseable output, return empty
75
+ return {};
76
+ }
77
+ }
78
+ /**
79
+ * Check if the LLM endpoint is reachable.
80
+ */
81
+ export async function isLlmAvailable(config) {
82
+ try {
83
+ const result = await chatCompletion(config, [
84
+ { role: "user", content: "Respond with just the word: ok" },
85
+ ]);
86
+ return result.length > 0;
87
+ }
88
+ catch {
89
+ return false;
90
+ }
91
+ }
@@ -0,0 +1,18 @@
1
+ export interface TocHeading {
2
+ level: number;
3
+ text: string;
4
+ line: number;
5
+ }
6
+ export interface KnowledgeToc {
7
+ headings: TocHeading[];
8
+ totalLines: number;
9
+ }
10
+ export declare function parseMarkdownToc(content: string): KnowledgeToc;
11
+ export declare function extractSection(content: string, heading: string): {
12
+ content: string;
13
+ startLine: number;
14
+ endLine: number;
15
+ } | null;
16
+ export declare function extractLineRange(content: string, start: number, end: number): string;
17
+ export declare function extractFrontmatterOnly(content: string): string | null;
18
+ export declare function formatToc(toc: KnowledgeToc): string;
@@ -0,0 +1,77 @@
1
+ import { parseFrontmatter } from "./frontmatter";
2
+ // ── Parsing ─────────────────────────────────────────────────────────────────
3
+ export function parseMarkdownToc(content) {
4
+ const lines = content.split(/\r?\n/);
5
+ const headings = [];
6
+ const parsed = parseFrontmatter(content);
7
+ const start = parsed.frontmatter ? parsed.bodyStartLine - 1 : 0;
8
+ for (let i = start; i < lines.length; i++) {
9
+ const match = lines[i].match(/^(#{1,6})\s+(.+)$/);
10
+ if (match) {
11
+ headings.push({
12
+ level: match[1].length,
13
+ text: match[2].replace(/\s+#+\s*$/, "").trim(),
14
+ line: i + 1,
15
+ });
16
+ }
17
+ }
18
+ return { headings, totalLines: lines.length };
19
+ }
20
+ // ── Extraction ──────────────────────────────────────────────────────────────
21
+ export function extractSection(content, heading) {
22
+ const lines = content.split(/\r?\n/);
23
+ const target = heading.toLowerCase();
24
+ let startIdx = -1;
25
+ let startLevel = 0;
26
+ for (let i = 0; i < lines.length; i++) {
27
+ const match = lines[i].match(/^(#{1,6})\s+(.+)$/);
28
+ if (!match)
29
+ continue;
30
+ const text = match[2].replace(/\s+#+\s*$/, "").trim();
31
+ if (text.toLowerCase() === target && startIdx === -1) {
32
+ startIdx = i;
33
+ startLevel = match[1].length;
34
+ }
35
+ else if (startIdx !== -1 && match[1].length <= startLevel) {
36
+ return {
37
+ content: lines.slice(startIdx, i).join("\n"),
38
+ startLine: startIdx + 1,
39
+ endLine: i,
40
+ };
41
+ }
42
+ }
43
+ if (startIdx === -1)
44
+ return null;
45
+ return {
46
+ content: lines.slice(startIdx).join("\n"),
47
+ startLine: startIdx + 1,
48
+ endLine: lines.length,
49
+ };
50
+ }
51
+ export function extractLineRange(content, start, end) {
52
+ const lines = content.split(/\r?\n/);
53
+ if (end < start)
54
+ return "";
55
+ const s = Math.max(1, Math.min(start, lines.length));
56
+ const e = Math.min(end, lines.length);
57
+ return lines.slice(s - 1, e).join("\n");
58
+ }
59
+ export function extractFrontmatterOnly(content) {
60
+ const parsed = parseFrontmatter(content);
61
+ return parsed.frontmatter;
62
+ }
63
+ // ── Formatting ──────────────────────────────────────────────────────────────
64
+ export function formatToc(toc) {
65
+ if (toc.headings.length === 0) {
66
+ return `(no headings found — ${toc.totalLines} lines total)`;
67
+ }
68
+ const lineWidth = String(toc.totalLines).length;
69
+ const parts = toc.headings.map((h) => {
70
+ const lineNum = `L${String(h.line).padStart(lineWidth)}`;
71
+ const indent = " ".repeat(h.level - 1);
72
+ const prefix = "#".repeat(h.level);
73
+ return `${lineNum} ${indent}${prefix} ${h.text}`;
74
+ });
75
+ parts.push(`\n${toc.totalLines} lines total`);
76
+ return parts.join("\n");
77
+ }