akm-cli 0.0.21 → 0.0.23

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 (46) hide show
  1. package/README.md +8 -5
  2. package/dist/asset-spec.js +91 -10
  3. package/dist/cli.js +172 -57
  4. package/dist/common.js +15 -2
  5. package/dist/config-cli.js +55 -6
  6. package/dist/config.js +118 -22
  7. package/dist/create-provider-registry.js +18 -0
  8. package/dist/db.js +156 -53
  9. package/dist/embedder.js +36 -18
  10. package/dist/errors.js +6 -0
  11. package/dist/file-context.js +18 -19
  12. package/dist/frontmatter.js +19 -3
  13. package/dist/indexer.js +126 -89
  14. package/dist/{stash-registry.js → installed-kits.js} +16 -24
  15. package/dist/kit-include.js +108 -0
  16. package/dist/local-search.js +429 -0
  17. package/dist/lockfile.js +47 -5
  18. package/dist/matchers.js +6 -0
  19. package/dist/metadata.js +20 -10
  20. package/dist/paths.js +4 -0
  21. package/dist/providers/skills-sh.js +3 -2
  22. package/dist/providers/static-index.js +4 -9
  23. package/dist/registry-build-index.js +356 -0
  24. package/dist/registry-factory.js +19 -0
  25. package/dist/registry-install.js +114 -109
  26. package/dist/registry-resolve.js +44 -9
  27. package/dist/registry-search.js +14 -9
  28. package/dist/renderers.js +23 -7
  29. package/dist/ripgrep-install.js +9 -4
  30. package/dist/self-update.js +31 -4
  31. package/dist/stash-add.js +75 -6
  32. package/dist/stash-clone.js +1 -1
  33. package/dist/stash-provider-factory.js +37 -0
  34. package/dist/stash-provider.js +1 -0
  35. package/dist/stash-providers/filesystem.js +42 -0
  36. package/dist/stash-providers/index.js +9 -0
  37. package/dist/stash-providers/openviking.js +337 -0
  38. package/dist/stash-resolve.js +4 -4
  39. package/dist/stash-search.js +70 -401
  40. package/dist/stash-show.js +24 -5
  41. package/dist/stash-source-manage.js +82 -0
  42. package/dist/stash-source.js +19 -11
  43. package/dist/walker.js +15 -10
  44. package/dist/warn.js +7 -0
  45. package/package.json +1 -1
  46. package/dist/provider-registry.js +0 -8
@@ -0,0 +1,429 @@
1
+ /**
2
+ * Local (filesystem + SQLite) stash search implementation.
3
+ *
4
+ * Extracted from stash-search.ts to break the circular import:
5
+ * stash-search.ts → stash-providers/filesystem.ts → local-search.ts (no cycle)
6
+ *
7
+ * stash-search.ts imports this module for the `searchLocal` export.
8
+ * stash-providers/filesystem.ts also imports `searchLocal` from here.
9
+ */
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { _setAssetTypeHooks, deriveCanonicalAssetNameFromStashRoot } from "./asset-spec";
13
+ import { closeDatabase, getAllEntries, getEntryById, getEntryCount, getMeta, openDatabase, searchFts, searchVec, } from "./db";
14
+ import { getRenderer } from "./file-context";
15
+ import { buildSearchText } from "./indexer";
16
+ import { generateMetadataFlat, loadStashFile } from "./metadata";
17
+ import { getDbPath } from "./paths";
18
+ import { makeAssetRef } from "./stash-ref";
19
+ import { buildEditHint, findSourceForPath, isEditable } from "./stash-source";
20
+ import { walkStashFlat } from "./walker";
21
+ import { warn } from "./warn";
22
+ // ── Type renderer/action maps (re-exported so stash-search.ts can register) ──
23
+ /** Map asset types to their primary renderer names. */
24
+ export const TYPE_TO_RENDERER = {
25
+ script: "script-source",
26
+ skill: "skill-md",
27
+ command: "command-md",
28
+ agent: "agent-md",
29
+ knowledge: "knowledge-md",
30
+ memory: "memory-md",
31
+ };
32
+ export const ACTION_BUILDERS = {
33
+ script: (ref) => `akm show ${ref} -> execute the run command`,
34
+ skill: (ref) => `akm show ${ref} -> follow the instructions`,
35
+ command: (ref) => `akm show ${ref} -> fill placeholders and dispatch`,
36
+ agent: (ref) => `akm show ${ref} -> dispatch with full prompt`,
37
+ knowledge: (ref) => `akm show ${ref} -> read reference material`,
38
+ memory: (ref) => `akm show ${ref} -> recall context`,
39
+ };
40
+ // Wire asset-spec's deferred hooks so that registerAssetType() automatically
41
+ // populates TYPE_TO_RENDERER and ACTION_BUILDERS when the optional spec fields
42
+ // rendererName / actionBuilder are provided.
43
+ _setAssetTypeHooks((type, rendererName) => {
44
+ TYPE_TO_RENDERER[type] = rendererName;
45
+ }, (type, builder) => {
46
+ ACTION_BUILDERS[type] = builder;
47
+ });
48
+ export async function rendererForType(type) {
49
+ const name = TYPE_TO_RENDERER[type];
50
+ return name ? getRenderer(name) : undefined;
51
+ }
52
+ export function buildLocalAction(type, ref) {
53
+ const builder = ACTION_BUILDERS[type];
54
+ return builder ? builder(ref) : `akm show ${ref}`;
55
+ }
56
+ // ── Main search entrypoint ───────────────────────────────────────────────────
57
+ export async function searchLocal(input) {
58
+ const { query, searchType, limit, stashDir, sources, config } = input;
59
+ const allStashDirs = sources.map((s) => s.path);
60
+ // Try to open the database
61
+ const dbPath = getDbPath();
62
+ try {
63
+ if (fs.existsSync(dbPath)) {
64
+ const embeddingDim = config.embedding?.dimension;
65
+ const db = openDatabase(dbPath, embeddingDim ? { embeddingDim } : undefined);
66
+ try {
67
+ const entryCount = getEntryCount(db);
68
+ const storedStashDir = getMeta(db, "stashDir");
69
+ if (entryCount > 0 && storedStashDir === stashDir) {
70
+ const { hits, embedMs, rankMs } = await searchDatabase(db, query, searchType, limit, stashDir, allStashDirs, config, sources);
71
+ return {
72
+ hits,
73
+ tip: hits.length === 0
74
+ ? "No matching stash assets were found. Try running 'akm index' to rebuild."
75
+ : undefined,
76
+ embedMs,
77
+ rankMs,
78
+ };
79
+ }
80
+ }
81
+ finally {
82
+ closeDatabase(db);
83
+ }
84
+ }
85
+ }
86
+ catch (error) {
87
+ warn("Search index unavailable, falling back to substring search:", error instanceof Error ? error.message : String(error));
88
+ }
89
+ const hitArrays = await Promise.all(allStashDirs.map((dir) => substringSearch(query, searchType, limit, dir, sources, config)));
90
+ const hits = hitArrays.flat().slice(0, limit);
91
+ return {
92
+ hits,
93
+ tip: hits.length === 0 ? "No matching stash assets were found. Try running 'akm index' to rebuild." : undefined,
94
+ };
95
+ }
96
+ // ── Database search ─────────────────────────────────────────────────────────
97
+ async function searchDatabase(db, query, searchType, limit, stashDir, allStashDirs, config, sources) {
98
+ // Empty query: return all entries
99
+ if (!query) {
100
+ const typeFilter = searchType === "any" ? undefined : searchType;
101
+ const allEntries = getAllEntries(db, typeFilter);
102
+ const selected = allEntries.slice(0, limit);
103
+ const hits = await Promise.all(selected.map((ie) => buildDbHit({
104
+ entry: ie.entry,
105
+ path: ie.filePath,
106
+ score: 1,
107
+ query,
108
+ rankingMode: "fts",
109
+ defaultStashDir: stashDir,
110
+ allStashDirs,
111
+ sources,
112
+ config,
113
+ })));
114
+ return { hits };
115
+ }
116
+ // Score using FTS5 (BM25) and optionally sqlite-vec
117
+ const tEmbed0 = Date.now();
118
+ const embeddingScores = await tryVecScores(db, query, limit * 3, config);
119
+ const embedMs = Date.now() - tEmbed0;
120
+ const tRank0 = Date.now();
121
+ const typeFilter = searchType === "any" ? undefined : searchType;
122
+ const ftsResults = searchFts(db, query, limit * 3, typeFilter);
123
+ // Reciprocal Rank Fusion (RRF) constant
124
+ const RRF_K = 60;
125
+ // Build FTS rank map: rank 1 = best BM25, rank 2 = second best, etc.
126
+ const ftsRankMap = new Map();
127
+ for (let i = 0; i < ftsResults.length; i++) {
128
+ const r = ftsResults[i];
129
+ ftsRankMap.set(r.id, { rank: i + 1, result: r });
130
+ }
131
+ // Build embedding rank map: sort by cosine similarity descending
132
+ const embedRankMap = new Map();
133
+ if (embeddingScores) {
134
+ const sortedEmbeddings = [...embeddingScores.entries()].sort((a, b) => b[1] - a[1]);
135
+ for (let i = 0; i < sortedEmbeddings.length; i++) {
136
+ embedRankMap.set(sortedEmbeddings[i][0], i + 1);
137
+ }
138
+ }
139
+ // Merge results using RRF
140
+ const scored = [];
141
+ const seenIds = new Set();
142
+ // Process FTS results
143
+ for (const [id, { rank, result }] of ftsRankMap) {
144
+ seenIds.add(id);
145
+ const ftsRrf = 1 / (RRF_K + rank);
146
+ const embedRank = embedRankMap.get(id);
147
+ const embedRrf = embedRank !== undefined ? 1 / (RRF_K + embedRank) : 0;
148
+ const rrfScore = ftsRrf + embedRrf;
149
+ const rankingMode = embedRrf > 0 ? "semantic" : "fts";
150
+ scored.push({ id, entry: result.entry, filePath: result.filePath, score: rrfScore, rankingMode });
151
+ }
152
+ // Add vec-only results not already in FTS results
153
+ if (embeddingScores) {
154
+ for (const [id] of embeddingScores) {
155
+ if (seenIds.has(id))
156
+ continue;
157
+ const embedRank = embedRankMap.get(id);
158
+ if (embedRank === undefined)
159
+ continue;
160
+ const found = getEntryById(db, id);
161
+ if (found) {
162
+ if (typeFilter && found.entry.type !== typeFilter)
163
+ continue;
164
+ const rrfScore = 1 / (RRF_K + embedRank);
165
+ scored.push({
166
+ id,
167
+ entry: found.entry,
168
+ filePath: found.filePath,
169
+ score: rrfScore,
170
+ rankingMode: "semantic",
171
+ });
172
+ }
173
+ }
174
+ }
175
+ // Apply boosts as multiplicative factors
176
+ const queryTokens = query.toLowerCase().split(/\s+/).filter(Boolean);
177
+ for (const item of scored) {
178
+ const entry = item.entry;
179
+ let boostSum = 0;
180
+ // Tag boost
181
+ if (entry.tags) {
182
+ for (const tag of entry.tags) {
183
+ if (queryTokens.some((t) => tag.toLowerCase() === t)) {
184
+ boostSum += 0.15;
185
+ }
186
+ }
187
+ }
188
+ // Search hint boost
189
+ if (entry.searchHints) {
190
+ for (const hint of entry.searchHints) {
191
+ const hintLower = hint.toLowerCase();
192
+ for (const token of queryTokens) {
193
+ if (hintLower.includes(token)) {
194
+ boostSum += 0.12;
195
+ break;
196
+ }
197
+ }
198
+ }
199
+ }
200
+ // Name boost
201
+ const nameLower = entry.name.toLowerCase().replace(/[-_]/g, " ");
202
+ if (queryTokens.some((t) => nameLower.includes(t))) {
203
+ boostSum += 0.1;
204
+ }
205
+ item.score = item.score * (1 + boostSum);
206
+ }
207
+ scored.sort((a, b) => b.score - a.score);
208
+ const rankMs = Date.now() - tRank0;
209
+ const selected = scored.slice(0, limit);
210
+ const hits = await Promise.all(selected.map(({ entry, filePath, score, rankingMode }) => buildDbHit({
211
+ entry,
212
+ path: filePath,
213
+ score: Math.round(score * 100) / 100,
214
+ query,
215
+ rankingMode,
216
+ defaultStashDir: stashDir,
217
+ allStashDirs,
218
+ sources,
219
+ config,
220
+ })));
221
+ return { embedMs, rankMs, hits };
222
+ }
223
+ // ── Vector scorer ───────────────────────────────────────────────────────────
224
+ async function tryVecScores(db, query, k, config) {
225
+ if (!config.semanticSearch)
226
+ return null;
227
+ const hasEmbeddings = getMeta(db, "hasEmbeddings");
228
+ if (hasEmbeddings !== "1")
229
+ return null;
230
+ try {
231
+ const { embed } = await import("./embedder.js");
232
+ const queryEmbedding = await embed(query, config.embedding);
233
+ const vecResults = searchVec(db, queryEmbedding, k);
234
+ const scores = new Map();
235
+ for (const { id, distance } of vecResults) {
236
+ // Convert L2 distance to cosine similarity (vectors are normalized)
237
+ const cosineSim = 1 - (distance * distance) / 2;
238
+ scores.set(id, Math.max(0, cosineSim));
239
+ }
240
+ return scores;
241
+ }
242
+ catch (error) {
243
+ warn("Vector search failed, skipping:", error instanceof Error ? error.message : String(error));
244
+ return null;
245
+ }
246
+ }
247
+ // ── Substring fallback (no index) ───────────────────────────────────────────
248
+ async function substringSearch(query, searchType, limit, stashDir, sources, config) {
249
+ const assets = await indexAssets(stashDir, searchType);
250
+ const matched = assets.filter((asset) => !query || buildSearchText(asset.entry).includes(query));
251
+ if (!query) {
252
+ return Promise.all(matched
253
+ .sort(compareAssets)
254
+ .slice(0, limit)
255
+ .map((asset) => assetToSearchHit(asset, query, stashDir, sources, config)));
256
+ }
257
+ // Score and sort by relevance
258
+ const scored = matched.map((asset) => ({ asset, score: scoreSubstringMatch(asset.entry, query) }));
259
+ scored.sort((a, b) => b.score - a.score || compareAssets(a.asset, b.asset));
260
+ return Promise.all(scored.slice(0, limit).map(({ asset, score }) => assetToSearchHit(asset, query, stashDir, sources, config, score)));
261
+ }
262
+ function scoreSubstringMatch(entry, query) {
263
+ const tokens = query.split(/\s+/).filter(Boolean);
264
+ if (tokens.length === 0)
265
+ return 0.5;
266
+ let score = 0.3;
267
+ const nameLower = entry.name.toLowerCase().replace(/[-_]/g, " ");
268
+ const descLower = (entry.description ?? "").toLowerCase();
269
+ const tagsLower = (entry.tags ?? []).join(" ").toLowerCase();
270
+ if (nameLower === query) {
271
+ score += 0.5;
272
+ }
273
+ else if (nameLower.includes(query)) {
274
+ score += 0.35;
275
+ }
276
+ else if (tokens.some((t) => nameLower.includes(t))) {
277
+ score += 0.2;
278
+ }
279
+ if (tokens.some((t) => tagsLower.includes(t))) {
280
+ score += 0.1;
281
+ }
282
+ if (tokens.some((t) => descLower.includes(t))) {
283
+ score += 0.05;
284
+ }
285
+ return Math.round(Math.min(1, score) * 100) / 100;
286
+ }
287
+ // ── Hit building ────────────────────────────────────────────────────────────
288
+ export async function buildDbHit(input) {
289
+ const entryStashDir = findSourceForPath(input.path, input.sources)?.path ?? input.defaultStashDir;
290
+ const canonical = deriveCanonicalAssetNameFromStashRoot(input.entry.type, entryStashDir, input.path);
291
+ const refName = canonical && !canonical.startsWith("../") && !canonical.startsWith("..\\") ? canonical : input.entry.name;
292
+ const qualityBoost = input.entry.quality === "generated" ? 0 : 0.05;
293
+ const confidenceBoost = typeof input.entry.confidence === "number" ? Math.min(0.05, Math.max(0, input.entry.confidence) * 0.05) : 0;
294
+ const score = Math.round(input.score * (1 + qualityBoost + confidenceBoost) * 100) / 100;
295
+ const whyMatched = buildWhyMatched(input.entry, input.query, input.rankingMode, qualityBoost, confidenceBoost);
296
+ const source = findSourceForPath(input.path, input.sources);
297
+ const editable = isEditable(input.path, input.config);
298
+ const hit = {
299
+ type: input.entry.type,
300
+ name: input.entry.name,
301
+ path: input.path,
302
+ ref: makeAssetRef(input.entry.type, refName, source?.registryId),
303
+ origin: source?.registryId ?? null,
304
+ editable,
305
+ ...(!editable ? { editHint: buildEditHint(input.path, input.entry.type, refName, source?.registryId) } : {}),
306
+ description: input.entry.description,
307
+ tags: input.entry.tags,
308
+ size: deriveSize(input.entry.fileSize),
309
+ action: buildLocalAction(input.entry.type, makeAssetRef(input.entry.type, refName, source?.registryId)),
310
+ score,
311
+ whyMatched,
312
+ };
313
+ const renderer = await rendererForType(input.entry.type);
314
+ if (renderer?.enrichSearchHit) {
315
+ renderer.enrichSearchHit(hit, entryStashDir);
316
+ }
317
+ return hit;
318
+ }
319
+ export function buildWhyMatched(entry, query, rankingMode, qualityBoost, confidenceBoost) {
320
+ const reasons = [rankingMode === "semantic" ? "semantic similarity" : "fts bm25 relevance"];
321
+ const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
322
+ const name = entry.name.toLowerCase();
323
+ const tags = entry.tags?.join(" ").toLowerCase() ?? "";
324
+ const searchHints = entry.searchHints?.join(" ").toLowerCase() ?? "";
325
+ const aliases = entry.aliases?.join(" ").toLowerCase() ?? "";
326
+ if (tokens.some((t) => name.includes(t)))
327
+ reasons.push("matched name tokens");
328
+ if (tokens.some((t) => tags.includes(t)))
329
+ reasons.push("matched tags");
330
+ if (tokens.some((t) => searchHints.includes(t)))
331
+ reasons.push("matched searchHints");
332
+ if (tokens.some((t) => aliases.includes(t)))
333
+ reasons.push("matched aliases");
334
+ if (qualityBoost > 0)
335
+ reasons.push("curated metadata boost");
336
+ if (confidenceBoost > 0)
337
+ reasons.push("metadata confidence boost");
338
+ return reasons;
339
+ }
340
+ async function assetToSearchHit(asset, _query, stashDir, sources, config, score) {
341
+ const source = findSourceForPath(asset.path, sources);
342
+ const editable = isEditable(asset.path, config);
343
+ const ref = makeAssetRef(asset.entry.type, asset.entry.name, source?.registryId);
344
+ const fileSize = readFileSize(asset.path);
345
+ const size = deriveSize(fileSize);
346
+ const hit = {
347
+ type: asset.entry.type,
348
+ name: asset.entry.name,
349
+ path: asset.path,
350
+ ref,
351
+ origin: source?.registryId ?? null,
352
+ editable,
353
+ ...(!editable
354
+ ? { editHint: buildEditHint(asset.path, asset.entry.type, asset.entry.name, source?.registryId) }
355
+ : {}),
356
+ description: asset.entry.description,
357
+ tags: asset.entry.tags,
358
+ ...(size ? { size } : {}),
359
+ action: buildLocalAction(asset.entry.type, ref),
360
+ ...(score !== undefined ? { score } : {}),
361
+ };
362
+ const renderer = await rendererForType(asset.entry.type);
363
+ if (renderer?.enrichSearchHit) {
364
+ renderer.enrichSearchHit(hit, stashDir);
365
+ }
366
+ return hit;
367
+ }
368
+ // ── Utilities ────────────────────────────────────────────────────────────────
369
+ export function deriveSize(bytes) {
370
+ if (bytes === undefined)
371
+ return undefined;
372
+ if (bytes < 1024)
373
+ return "small";
374
+ if (bytes < 10240)
375
+ return "medium";
376
+ return "large";
377
+ }
378
+ function readFileSize(filePath) {
379
+ try {
380
+ return fs.statSync(filePath).size;
381
+ }
382
+ catch {
383
+ return undefined;
384
+ }
385
+ }
386
+ async function indexAssets(stashDir, type) {
387
+ const assets = [];
388
+ const filterType = type === "any" ? undefined : type;
389
+ const fileContexts = walkStashFlat(stashDir);
390
+ const dirGroups = new Map();
391
+ for (const ctx of fileContexts) {
392
+ const group = dirGroups.get(ctx.parentDirAbs);
393
+ if (group)
394
+ group.push(ctx.absPath);
395
+ else
396
+ dirGroups.set(ctx.parentDirAbs, [ctx.absPath]);
397
+ }
398
+ for (const [dirPath, files] of dirGroups) {
399
+ let stash = loadStashFile(dirPath);
400
+ if (stash) {
401
+ const coveredFiles = new Set(stash.entries.map((entry) => entry.filename).filter((entry) => !!entry));
402
+ const uncoveredFiles = files.filter((file) => !coveredFiles.has(path.basename(file)));
403
+ if (uncoveredFiles.length > 0) {
404
+ const generated = await generateMetadataFlat(stashDir, uncoveredFiles);
405
+ if (generated.entries.length > 0) {
406
+ stash = { entries: [...stash.entries, ...generated.entries] };
407
+ }
408
+ }
409
+ }
410
+ else {
411
+ const generated = await generateMetadataFlat(stashDir, files);
412
+ if (generated.entries.length === 0)
413
+ continue;
414
+ stash = generated;
415
+ }
416
+ for (const entry of stash.entries) {
417
+ if (filterType && entry.type !== filterType)
418
+ continue;
419
+ const entryPath = entry.filename ? path.join(dirPath, entry.filename) : files[0] || dirPath;
420
+ assets.push({ entry, path: entryPath });
421
+ }
422
+ }
423
+ return assets;
424
+ }
425
+ function compareAssets(a, b) {
426
+ if (a.entry.type !== b.entry.type)
427
+ return a.entry.type.localeCompare(b.entry.type);
428
+ return a.entry.name.localeCompare(b.entry.name);
429
+ }
package/dist/lockfile.js CHANGED
@@ -5,6 +5,41 @@ import { getConfigDir } from "./config";
5
5
  function getLockfilePath() {
6
6
  return path.join(getConfigDir(), "stash.lock");
7
7
  }
8
+ // ── Lock sentinel ────────────────────────────────────────────────────────────
9
+ const LOCK_MAX_RETRIES = 3;
10
+ const LOCK_RETRY_DELAY_MS = 100;
11
+ function getLockSentinelPath() {
12
+ return `${getLockfilePath()}.lck`;
13
+ }
14
+ async function acquireLockSentinel() {
15
+ const sentinelPath = getLockSentinelPath();
16
+ // Ensure the directory exists before attempting to create the sentinel
17
+ fs.mkdirSync(path.dirname(sentinelPath), { recursive: true });
18
+ for (let attempt = 0; attempt < LOCK_MAX_RETRIES; attempt++) {
19
+ try {
20
+ fs.writeFileSync(sentinelPath, String(process.pid), { flag: "wx" });
21
+ return true; // Sentinel created — we own the lock
22
+ }
23
+ catch (err) {
24
+ if (err.code !== "EEXIST")
25
+ throw err;
26
+ // Another process holds the lock — wait briefly before retrying
27
+ if (attempt < LOCK_MAX_RETRIES - 1) {
28
+ await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_DELAY_MS));
29
+ }
30
+ }
31
+ }
32
+ // Best-effort: proceed without the lock rather than failing the install
33
+ return false;
34
+ }
35
+ function releaseLockSentinel() {
36
+ try {
37
+ fs.unlinkSync(getLockSentinelPath());
38
+ }
39
+ catch {
40
+ /* ignore — sentinel may already be gone */
41
+ }
42
+ }
8
43
  // ── Read / Write ────────────────────────────────────────────────────────────
9
44
  export function readLockfile() {
10
45
  const lockfilePath = getLockfilePath();
@@ -22,7 +57,7 @@ export function writeLockfile(entries) {
22
57
  const lockfilePath = getLockfilePath();
23
58
  const dir = path.dirname(lockfilePath);
24
59
  fs.mkdirSync(dir, { recursive: true });
25
- const tmpPath = `${lockfilePath}.tmp.${process.pid}`;
60
+ const tmpPath = `${lockfilePath}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 8)}`;
26
61
  try {
27
62
  fs.writeFileSync(tmpPath, `${JSON.stringify(entries, null, 2)}\n`, "utf8");
28
63
  fs.renameSync(tmpPath, lockfilePath);
@@ -37,10 +72,17 @@ export function writeLockfile(entries) {
37
72
  throw err;
38
73
  }
39
74
  }
40
- export function upsertLockEntry(entry) {
41
- const entries = readLockfile();
42
- const withoutExisting = entries.filter((e) => e.id !== entry.id);
43
- writeLockfile([...withoutExisting, entry]);
75
+ export async function upsertLockEntry(entry) {
76
+ const acquired = await acquireLockSentinel();
77
+ try {
78
+ const entries = readLockfile();
79
+ const withoutExisting = entries.filter((e) => e.id !== entry.id);
80
+ writeLockfile([...withoutExisting, entry]);
81
+ }
82
+ finally {
83
+ if (acquired)
84
+ releaseLockSentinel();
85
+ }
44
86
  }
45
87
  export function removeLockEntry(id) {
46
88
  const entries = readLockfile();
package/dist/matchers.js CHANGED
@@ -66,6 +66,9 @@ export function directoryMatcher(ctx) {
66
66
  if (topDir === "knowledge" && ext === ".md") {
67
67
  return { type: "knowledge", specificity: 10, renderer: "knowledge-md" };
68
68
  }
69
+ if (topDir === "memories" && ext === ".md") {
70
+ return { type: "memory", specificity: 10, renderer: "memory-md" };
71
+ }
69
72
  return null;
70
73
  }
71
74
  // ── parentDirHintMatcher (specificity: 15) ──────────────────────────────────
@@ -92,6 +95,9 @@ export function parentDirHintMatcher(ctx) {
92
95
  if (parentDir === "knowledge" && ext === ".md") {
93
96
  return { type: "knowledge", specificity: 15, renderer: "knowledge-md" };
94
97
  }
98
+ if (parentDir === "memories" && ext === ".md") {
99
+ return { type: "memory", specificity: 15, renderer: "memory-md" };
100
+ }
95
101
  return null;
96
102
  }
97
103
  // ── smartMdMatcher (specificity: 20 / 18 / 8 / 5) ──────────────────────────
package/dist/metadata.js CHANGED
@@ -39,7 +39,7 @@ export function loadStashFile(dirPath) {
39
39
  }
40
40
  export function writeStashFile(dirPath, stash) {
41
41
  const filePath = stashFilePath(dirPath);
42
- const tmpPath = `${filePath}.tmp.${process.pid}`;
42
+ const tmpPath = `${filePath}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
43
43
  try {
44
44
  fs.writeFileSync(tmpPath, `${JSON.stringify(stash, null, 2)}\n`, "utf8");
45
45
  fs.renameSync(tmpPath, filePath);
@@ -54,6 +54,14 @@ export function writeStashFile(dirPath, stash) {
54
54
  throw err;
55
55
  }
56
56
  }
57
+ /**
58
+ * Validate and normalize a raw object into a `StashEntry`.
59
+ *
60
+ * **Ordering dependency:** Uses `isAssetType()` to check `entry.type`, which
61
+ * only recognizes custom types registered via `registerAssetType()`. If this
62
+ * function is called before custom types are registered, those entries will be
63
+ * rejected as invalid.
64
+ */
57
65
  export function validateStashEntry(entry) {
58
66
  if (typeof entry !== "object" || entry === null)
59
67
  return null;
@@ -115,6 +123,8 @@ export function validateStashEntry(entry) {
115
123
  const usage = normalizeNonEmptyStringList(e.usage);
116
124
  if (usage)
117
125
  result.usage = usage;
126
+ // SECURITY NOTE: run, setup, and cwd are advisory metadata fields for AI agent consumers.
127
+ // They are NOT executed by akm directly. Consumers should validate and sanitize before execution.
118
128
  if (typeof e.run === "string" && e.run.trim())
119
129
  result.run = e.run.trim();
120
130
  if (typeof e.setup === "string" && e.setup.trim())
@@ -139,7 +149,7 @@ function normalizeNonEmptyStringList(value) {
139
149
  return filtered.length > 0 ? filtered : undefined;
140
150
  }
141
151
  // ── Metadata Generation ─────────────────────────────────────────────────────
142
- export function generateMetadata(dirPath, assetType, files, typeRoot = dirPath) {
152
+ export async function generateMetadata(dirPath, assetType, files, typeRoot = dirPath) {
143
153
  const entries = [];
144
154
  const pkgMeta = extractPackageMetadata(dirPath);
145
155
  for (const file of files) {
@@ -178,9 +188,9 @@ export function generateMetadata(dirPath, assetType, files, typeRoot = dirPath)
178
188
  }
179
189
  // Priority 3: Type-specific metadata extraction (e.g. TOC for knowledge, comments for scripts)
180
190
  const fileCtx = buildFileContext(typeRoot, file);
181
- const match = runMatchers(fileCtx);
191
+ const match = await runMatchers(fileCtx);
182
192
  if (match) {
183
- const renderer = getRenderer(match.renderer);
193
+ const renderer = await getRenderer(match.renderer);
184
194
  if (renderer?.extractMetadata) {
185
195
  const renderCtx = buildRenderContext(fileCtx, match, [typeRoot]);
186
196
  renderer.extractMetadata(entry, renderCtx);
@@ -211,12 +221,12 @@ export function generateMetadata(dirPath, assetType, files, typeRoot = dirPath)
211
221
  * file via `runMatchers()` and uses the matched type for canonical naming.
212
222
  * Files that no matcher claims are silently skipped.
213
223
  */
214
- export function generateMetadataFlat(stashRoot, files) {
224
+ export async function generateMetadataFlat(stashRoot, files) {
215
225
  const entries = [];
216
226
  const pkgMetaCache = new Map();
217
227
  for (const file of files) {
218
228
  const ctx = buildFileContext(stashRoot, file);
219
- const match = runMatchers(ctx);
229
+ const match = await runMatchers(ctx);
220
230
  if (!match)
221
231
  continue;
222
232
  const assetType = match.type;
@@ -260,7 +270,7 @@ export function generateMetadataFlat(stashRoot, files) {
260
270
  }
261
271
  }
262
272
  // Renderer metadata extraction
263
- const renderer = getRenderer(match.renderer);
273
+ const renderer = await getRenderer(match.renderer);
264
274
  if (renderer?.extractMetadata) {
265
275
  const renderCtx = buildRenderContext(ctx, match, [stashRoot]);
266
276
  renderer.extractMetadata(entry, renderCtx);
@@ -288,9 +298,9 @@ function normalizeTerms(values) {
288
298
  if (!cleaned)
289
299
  continue;
290
300
  normalized.add(cleaned);
291
- if (cleaned.endsWith("s") && cleaned.length > 3) {
292
- normalized.add(cleaned.slice(0, -1));
293
- }
301
+ // De-pluralization heuristic removed: the FTS5 porter stemmer (configured
302
+ // with `tokenize='porter unicode61'`) handles stemming correctly, including
303
+ // edge cases like "kubernetes" and "status" that the naive s-strip mangled.
294
304
  }
295
305
  return Array.from(normalized);
296
306
  }
package/dist/paths.js CHANGED
@@ -51,6 +51,10 @@ export function getCacheDir() {
51
51
  if (!appData) {
52
52
  throw new ConfigError("Unable to determine cache directory. Set LOCALAPPDATA, USERPROFILE, or APPDATA.");
53
53
  }
54
+ // Heuristic fallback: APPDATA points to %APPDATA% (Roaming), so navigate
55
+ // to the sibling "Local" directory. This is typically
56
+ // C:\Users\<name>\AppData\Roaming → C:\Users\<name>\AppData\Local\akm.
57
+ // Preferred: set LOCALAPPDATA to avoid this navigation.
54
58
  return path.join(appData, "..", "Local", "akm");
55
59
  }
56
60
  const xdgCacheHome = process.env.XDG_CACHE_HOME?.trim();
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fetchWithRetry } from "../common";
4
4
  import { getRegistryIndexCacheDir } from "../paths";
5
- import { registerProvider } from "../provider-registry";
5
+ import { registerProvider } from "../registry-factory";
6
6
  // ── Constants ───────────────────────────────────────────────────────────────
7
7
  /** Per-query cache TTL in milliseconds (15 minutes). */
8
8
  const QUERY_CACHE_TTL_MS = 15 * 60 * 1000;
@@ -132,7 +132,8 @@ class SkillsShProvider {
132
132
  const dir = path.dirname(cachePath);
133
133
  fs.mkdirSync(dir, { recursive: true });
134
134
  const tmpPath = `${cachePath}.tmp.${process.pid}`;
135
- fs.writeFileSync(tmpPath, JSON.stringify(entries), "utf8");
135
+ // 0o600: owner read/write only — cache may contain search terms tied to API keys
136
+ fs.writeFileSync(tmpPath, JSON.stringify(entries), { encoding: "utf8", mode: 0o600 });
136
137
  fs.renameSync(tmpPath, cachePath);
137
138
  }
138
139
  catch {
@@ -1,8 +1,9 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { fetchWithRetry } from "../common";
3
+ import { fetchWithRetry, toErrorMessage } from "../common";
4
+ import { asString } from "../github";
4
5
  import { getRegistryIndexCacheDir } from "../paths";
5
- import { registerProvider } from "../provider-registry";
6
+ import { registerProvider } from "../registry-factory";
6
7
  // ── Constants ───────────────────────────────────────────────────────────────
7
8
  /** Cache TTL in milliseconds (1 hour). */
8
9
  const CACHE_TTL_MS = 60 * 60 * 1000;
@@ -100,7 +101,7 @@ export function writeCachedIndex(cachePath, index) {
100
101
  try {
101
102
  const dir = path.dirname(cachePath);
102
103
  fs.mkdirSync(dir, { recursive: true });
103
- const tmpPath = `${cachePath}.tmp.${process.pid}`;
104
+ const tmpPath = `${cachePath}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
104
105
  fs.writeFileSync(tmpPath, JSON.stringify(index), "utf8");
105
106
  fs.renameSync(tmpPath, cachePath);
106
107
  }
@@ -316,9 +317,6 @@ function scoreAsset(asset, tokens) {
316
317
  return tokens.length > 0 ? score / tokens.length : 0;
317
318
  }
318
319
  // ── Utilities ───────────────────────────────────────────────────────────────
319
- function asString(value) {
320
- return typeof value === "string" && value ? value : undefined;
321
- }
322
320
  function asSource(value) {
323
321
  if (value === "npm" || value === "github" || value === "git" || value === "local")
324
322
  return value;
@@ -342,6 +340,3 @@ function buildInstallRef(source, ref) {
342
340
  return `github:${ref}`;
343
341
  }
344
342
  }
345
- function toErrorMessage(error) {
346
- return error instanceof Error ? error.message : String(error);
347
- }