akm-cli 0.0.21 → 0.0.22
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 +8 -5
- package/dist/asset-spec.js +91 -10
- package/dist/cli.js +195 -55
- package/dist/common.js +15 -2
- package/dist/config-cli.js +65 -6
- package/dist/config.js +206 -22
- package/dist/create-provider-registry.js +18 -0
- package/dist/db.js +156 -53
- package/dist/embedder.js +36 -18
- package/dist/errors.js +6 -0
- package/dist/file-context.js +18 -19
- package/dist/frontmatter.js +19 -3
- package/dist/indexer.js +126 -89
- package/dist/{stash-registry.js → installed-kits.js} +16 -24
- package/dist/kit-include.js +108 -0
- package/dist/local-search.js +429 -0
- package/dist/lockfile.js +47 -5
- package/dist/matchers.js +6 -0
- package/dist/metadata.js +20 -10
- package/dist/paths.js +4 -0
- package/dist/providers/skills-sh.js +3 -2
- package/dist/providers/static-index.js +4 -9
- package/dist/registry-build-index.js +356 -0
- package/dist/registry-factory.js +19 -0
- package/dist/registry-install.js +114 -109
- package/dist/registry-resolve.js +44 -9
- package/dist/registry-search.js +14 -9
- package/dist/renderers.js +23 -7
- package/dist/ripgrep-install.js +9 -4
- package/dist/self-update.js +31 -4
- package/dist/stash-add.js +75 -6
- package/dist/stash-clone.js +1 -1
- package/dist/stash-provider-factory.js +52 -0
- package/dist/stash-provider.js +1 -0
- package/dist/stash-providers/filesystem.js +42 -0
- package/dist/stash-providers/index.js +9 -0
- package/dist/stash-providers/openviking.js +337 -0
- package/dist/stash-resolve.js +4 -4
- package/dist/stash-search.js +70 -401
- package/dist/stash-show.js +24 -5
- package/dist/stash-source.js +19 -11
- package/dist/walker.js +15 -10
- package/dist/warn.js +7 -0
- package/package.json +1 -1
- 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
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
292
|
-
|
|
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 "../
|
|
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
|
-
|
|
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 "../
|
|
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
|
-
}
|