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.
- package/README.md +8 -5
- package/dist/asset-spec.js +91 -10
- package/dist/cli.js +172 -57
- package/dist/common.js +15 -2
- package/dist/config-cli.js +55 -6
- package/dist/config.js +118 -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 +37 -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-manage.js +82 -0
- 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,337 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fetchWithRetry } from "../common";
|
|
4
|
+
import { ConfigError, NotFoundError } from "../errors";
|
|
5
|
+
import { getRegistryIndexCacheDir } from "../paths";
|
|
6
|
+
import { registerStashProvider } from "../stash-provider-factory";
|
|
7
|
+
/** Strip terminal control characters from untrusted strings. */
|
|
8
|
+
function sanitizeString(value, maxLength = 255) {
|
|
9
|
+
if (typeof value !== "string")
|
|
10
|
+
return "";
|
|
11
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — strip control chars from untrusted remote data
|
|
12
|
+
return value.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, maxLength);
|
|
13
|
+
}
|
|
14
|
+
/** Per-query cache TTL in milliseconds (5 minutes). */
|
|
15
|
+
const QUERY_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
16
|
+
/** Maximum age before query cache is considered stale but still usable (1 hour). */
|
|
17
|
+
const QUERY_CACHE_STALE_MS = 60 * 60 * 1000;
|
|
18
|
+
/**
|
|
19
|
+
* Single source of truth for OpenViking type → agentikit asset type mapping.
|
|
20
|
+
* Used by both search hit mapping and show response mapping.
|
|
21
|
+
*/
|
|
22
|
+
const OV_TYPE_MAP = {
|
|
23
|
+
skill: "skill",
|
|
24
|
+
skills: "skill",
|
|
25
|
+
memory: "memory",
|
|
26
|
+
memories: "memory",
|
|
27
|
+
resource: "knowledge",
|
|
28
|
+
resources: "knowledge",
|
|
29
|
+
knowledge: "knowledge",
|
|
30
|
+
agent: "agent",
|
|
31
|
+
agents: "agent",
|
|
32
|
+
command: "command",
|
|
33
|
+
commands: "command",
|
|
34
|
+
script: "script",
|
|
35
|
+
scripts: "script",
|
|
36
|
+
};
|
|
37
|
+
class OpenVikingStashProvider {
|
|
38
|
+
type = "openviking";
|
|
39
|
+
name;
|
|
40
|
+
config;
|
|
41
|
+
constructor(config) {
|
|
42
|
+
this.config = config;
|
|
43
|
+
this.name = config.name ?? "openviking";
|
|
44
|
+
// Validate baseUrl scheme to prevent SSRF via file:// or other non-HTTP schemes
|
|
45
|
+
const rawUrl = config.url ?? "";
|
|
46
|
+
if (rawUrl) {
|
|
47
|
+
try {
|
|
48
|
+
const parsed = new URL(rawUrl);
|
|
49
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
50
|
+
throw new ConfigError(`OpenViking baseUrl must use http:// or https://, got "${parsed.protocol}" in "${rawUrl}"`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
if (err instanceof ConfigError)
|
|
55
|
+
throw err;
|
|
56
|
+
throw new ConfigError(`OpenViking baseUrl is not a valid URL: "${rawUrl}"`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async search(options) {
|
|
61
|
+
try {
|
|
62
|
+
const entries = await this.fetchResults(options.query, options.limit);
|
|
63
|
+
const limited = entries.slice(0, options.limit);
|
|
64
|
+
const hits = this.mapToStashHits(limited);
|
|
65
|
+
return { hits };
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
69
|
+
return { hits: [], warnings: [`Stash ${this.name}: ${message}`] };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async show(ref, _view) {
|
|
73
|
+
const uri = ref.trim();
|
|
74
|
+
const baseUrl = this.baseUrl;
|
|
75
|
+
const headers = this.authHeaders;
|
|
76
|
+
const [statResult, contentResult] = await Promise.all([
|
|
77
|
+
fetchOVJson(`${baseUrl}/api/v1/fs/stat?uri=${encodeURIComponent(uri)}`, headers),
|
|
78
|
+
fetchOVJson(`${baseUrl}/api/v1/content/read?uri=${encodeURIComponent(uri)}&offset=0&limit=-1`, headers),
|
|
79
|
+
]);
|
|
80
|
+
if (statResult == null && contentResult == null) {
|
|
81
|
+
throw new NotFoundError(`Could not fetch remote asset "${uri}". The OpenViking server at ${baseUrl} may be unreachable or the resource does not exist.`);
|
|
82
|
+
}
|
|
83
|
+
if (contentResult == null) {
|
|
84
|
+
throw new NotFoundError(`Content not found for remote asset "${uri}". The server returned metadata but no content.`);
|
|
85
|
+
}
|
|
86
|
+
const stat = (typeof statResult === "object" && statResult !== null ? statResult : {});
|
|
87
|
+
const uriPath = uri.replace(/^viking:\/\//, "");
|
|
88
|
+
// Sanitize untrusted fields to strip terminal control characters
|
|
89
|
+
const name = sanitizeString(stat.name) || uriPath.split("/").pop() || "unknown";
|
|
90
|
+
const ovType = sanitizeString(stat.type) || inferTypeFromUri(uri);
|
|
91
|
+
const assetType = OV_TYPE_MAP[ovType] ?? "knowledge";
|
|
92
|
+
const content = typeof contentResult === "string" ? contentResult : "";
|
|
93
|
+
const description = sanitizeString(stat.abstract, 1000) || undefined;
|
|
94
|
+
return {
|
|
95
|
+
type: assetType,
|
|
96
|
+
name,
|
|
97
|
+
path: uri,
|
|
98
|
+
action: `Remote content from OpenViking — ${uri}`,
|
|
99
|
+
content,
|
|
100
|
+
description,
|
|
101
|
+
editable: false,
|
|
102
|
+
origin: "remote",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
canShow(ref) {
|
|
106
|
+
return ref.trim().startsWith("viking://");
|
|
107
|
+
}
|
|
108
|
+
get baseUrl() {
|
|
109
|
+
return (this.config.url ?? "").replace(/\/+$/, "");
|
|
110
|
+
}
|
|
111
|
+
get authHeaders() {
|
|
112
|
+
const headers = {};
|
|
113
|
+
const apiKey = this.config.options?.apiKey ?? undefined;
|
|
114
|
+
if (apiKey)
|
|
115
|
+
headers.Authorization = `Bearer ${apiKey}`;
|
|
116
|
+
return headers;
|
|
117
|
+
}
|
|
118
|
+
async fetchResults(query, limit) {
|
|
119
|
+
const cachePath = this.queryCachePath(query, limit);
|
|
120
|
+
const cached = this.readQueryCache(cachePath);
|
|
121
|
+
if (cached && !isExpired(cached.mtime, QUERY_CACHE_TTL_MS)) {
|
|
122
|
+
return cached.entries;
|
|
123
|
+
}
|
|
124
|
+
const baseUrl = this.baseUrl;
|
|
125
|
+
const searchType = this.config.options?.searchType ?? "semantic";
|
|
126
|
+
try {
|
|
127
|
+
let url;
|
|
128
|
+
let body;
|
|
129
|
+
if (searchType === "text") {
|
|
130
|
+
url = `${baseUrl}/api/v1/search/grep`;
|
|
131
|
+
body = JSON.stringify({ uri: "viking://", pattern: query, case_insensitive: true });
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
url = `${baseUrl}/api/v1/search/find`;
|
|
135
|
+
body = JSON.stringify({ query, limit });
|
|
136
|
+
}
|
|
137
|
+
const headers = { "Content-Type": "application/json", ...this.authHeaders };
|
|
138
|
+
const response = await fetchWithRetry(url, { method: "POST", headers, body }, { timeout: 10_000, retries: 1 });
|
|
139
|
+
if (!response.ok) {
|
|
140
|
+
throw new Error(`HTTP ${response.status}`);
|
|
141
|
+
}
|
|
142
|
+
const data = (await response.json());
|
|
143
|
+
if (data.status !== "ok") {
|
|
144
|
+
throw new Error(data.error ?? "OpenViking returned error status");
|
|
145
|
+
}
|
|
146
|
+
const entries = parseOVSearchResponse(data.result);
|
|
147
|
+
this.writeQueryCache(cachePath, entries);
|
|
148
|
+
return entries;
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
if (cached && !isExpired(cached.mtime, QUERY_CACHE_STALE_MS)) {
|
|
152
|
+
return cached.entries;
|
|
153
|
+
}
|
|
154
|
+
throw err;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
mapToStashHits(entries) {
|
|
158
|
+
if (entries.length === 0)
|
|
159
|
+
return [];
|
|
160
|
+
const maxScore = entries.reduce((max, e) => Math.max(max, e.score), 0.01);
|
|
161
|
+
return entries.map((entry) => {
|
|
162
|
+
const name = sanitizeString(entry.name);
|
|
163
|
+
const abstract = sanitizeString(entry.abstract, 1000);
|
|
164
|
+
const type = sanitizeString(entry.type);
|
|
165
|
+
const uri = sanitizeString(entry.uri, 2048);
|
|
166
|
+
const assetType = OV_TYPE_MAP[type] ?? "knowledge";
|
|
167
|
+
const ref = uriToVikingRef(uri);
|
|
168
|
+
return {
|
|
169
|
+
type: assetType,
|
|
170
|
+
name,
|
|
171
|
+
path: ref,
|
|
172
|
+
ref,
|
|
173
|
+
origin: this.type,
|
|
174
|
+
editable: false,
|
|
175
|
+
description: abstract || undefined,
|
|
176
|
+
action: `akm show ${ref}`,
|
|
177
|
+
score: Math.round((entry.score / maxScore) * 1000) / 1000,
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
queryCachePath(query, limit) {
|
|
182
|
+
const cacheDir = getRegistryIndexCacheDir();
|
|
183
|
+
const hasher = new Bun.CryptoHasher("md5");
|
|
184
|
+
hasher.update(this.config.url ?? "");
|
|
185
|
+
hasher.update("\0");
|
|
186
|
+
hasher.update(query.trim().toLowerCase());
|
|
187
|
+
hasher.update("\0");
|
|
188
|
+
hasher.update(String(limit));
|
|
189
|
+
hasher.update("\0");
|
|
190
|
+
const searchType = this.config.options?.searchType ?? "semantic";
|
|
191
|
+
hasher.update(searchType);
|
|
192
|
+
hasher.update("\0");
|
|
193
|
+
const apiKey = this.config.options?.apiKey ?? "";
|
|
194
|
+
hasher.update(apiKey);
|
|
195
|
+
const hash = hasher.digest("hex");
|
|
196
|
+
return path.join(cacheDir, `openviking-search-${hash}.json`);
|
|
197
|
+
}
|
|
198
|
+
readQueryCache(cachePath) {
|
|
199
|
+
try {
|
|
200
|
+
const stat = fs.statSync(cachePath);
|
|
201
|
+
const raw = JSON.parse(fs.readFileSync(cachePath, "utf8"));
|
|
202
|
+
if (!Array.isArray(raw))
|
|
203
|
+
return null;
|
|
204
|
+
const entries = raw.filter(isValidOVEntry);
|
|
205
|
+
return { entries, mtime: stat.mtimeMs };
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
writeQueryCache(cachePath, entries) {
|
|
212
|
+
try {
|
|
213
|
+
const dir = path.dirname(cachePath);
|
|
214
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
215
|
+
const tmpPath = `${cachePath}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
|
|
216
|
+
// 0o600: owner read/write only — cache may contain search terms tied to API keys
|
|
217
|
+
fs.writeFileSync(tmpPath, JSON.stringify(entries), { encoding: "utf8", mode: 0o600 });
|
|
218
|
+
fs.renameSync(tmpPath, cachePath);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// Best-effort caching
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// ── Self-register ───────────────────────────────────────────────────────────
|
|
226
|
+
registerStashProvider("openviking", (config) => new OpenVikingStashProvider(config));
|
|
227
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
228
|
+
function uriToVikingRef(uri) {
|
|
229
|
+
if (uri.startsWith("viking://"))
|
|
230
|
+
return uri;
|
|
231
|
+
return `viking://${uri.replace(/^\/+/, "")}`;
|
|
232
|
+
}
|
|
233
|
+
function parseOVSearchResponse(result) {
|
|
234
|
+
if (Array.isArray(result))
|
|
235
|
+
return result.filter(isValidOVEntry);
|
|
236
|
+
if (typeof result !== "object" || result === null)
|
|
237
|
+
return [];
|
|
238
|
+
const grouped = result;
|
|
239
|
+
if (Array.isArray(grouped.matches)) {
|
|
240
|
+
return deduplicateGrepMatches(grouped.matches);
|
|
241
|
+
}
|
|
242
|
+
const entries = [];
|
|
243
|
+
for (const [category, items] of Object.entries(grouped)) {
|
|
244
|
+
if (category === "total")
|
|
245
|
+
continue;
|
|
246
|
+
if (!Array.isArray(items))
|
|
247
|
+
continue;
|
|
248
|
+
for (const item of items) {
|
|
249
|
+
if (!isValidOVSearchItem(item))
|
|
250
|
+
continue;
|
|
251
|
+
entries.push({
|
|
252
|
+
uri: item.uri,
|
|
253
|
+
name: extractNameFromUri(item.uri),
|
|
254
|
+
score: item.score,
|
|
255
|
+
type: item.context_type ?? category,
|
|
256
|
+
abstract: item.abstract ?? undefined,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return entries;
|
|
261
|
+
}
|
|
262
|
+
function isValidGrepMatch(item) {
|
|
263
|
+
if (typeof item !== "object" || item === null)
|
|
264
|
+
return false;
|
|
265
|
+
const obj = item;
|
|
266
|
+
return typeof obj.uri === "string" && typeof obj.content === "string";
|
|
267
|
+
}
|
|
268
|
+
function deduplicateGrepMatches(matches) {
|
|
269
|
+
const byUri = new Map();
|
|
270
|
+
for (const m of matches) {
|
|
271
|
+
if (!isValidGrepMatch(m))
|
|
272
|
+
continue;
|
|
273
|
+
const existing = byUri.get(m.uri);
|
|
274
|
+
if (existing) {
|
|
275
|
+
existing.count++;
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
const pathSegment = m.uri.replace(/^viking:\/\//, "").split("/")[0] ?? "";
|
|
279
|
+
byUri.set(m.uri, { content: m.content, count: 1, type: pathSegment });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const maxCount = Math.max(...[...byUri.values()].map((v) => v.count), 1);
|
|
283
|
+
const entries = [];
|
|
284
|
+
for (const [uri, { content, count, type }] of byUri) {
|
|
285
|
+
entries.push({
|
|
286
|
+
uri,
|
|
287
|
+
name: extractNameFromUri(uri),
|
|
288
|
+
score: count / maxCount,
|
|
289
|
+
type,
|
|
290
|
+
abstract: content.slice(0, 200),
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
entries.sort((a, b) => b.score - a.score);
|
|
294
|
+
return entries;
|
|
295
|
+
}
|
|
296
|
+
function isValidOVEntry(entry) {
|
|
297
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry))
|
|
298
|
+
return false;
|
|
299
|
+
const obj = entry;
|
|
300
|
+
return typeof obj.uri === "string" && typeof obj.name === "string" && typeof obj.score === "number";
|
|
301
|
+
}
|
|
302
|
+
function isValidOVSearchItem(item) {
|
|
303
|
+
if (typeof item !== "object" || item === null || Array.isArray(item))
|
|
304
|
+
return false;
|
|
305
|
+
const obj = item;
|
|
306
|
+
return typeof obj.uri === "string" && typeof obj.score === "number";
|
|
307
|
+
}
|
|
308
|
+
function extractNameFromUri(uri) {
|
|
309
|
+
const uriPath = uri.replace(/^viking:\/\//, "");
|
|
310
|
+
const segments = uriPath.split("/").filter(Boolean);
|
|
311
|
+
const last = segments[segments.length - 1] ?? "unknown";
|
|
312
|
+
return last.replace(/\.[^.]+$/, "");
|
|
313
|
+
}
|
|
314
|
+
function isExpired(mtimeMs, ttlMs) {
|
|
315
|
+
return Date.now() - mtimeMs > ttlMs;
|
|
316
|
+
}
|
|
317
|
+
async function fetchOVJson(url, headers) {
|
|
318
|
+
try {
|
|
319
|
+
const response = await fetchWithRetry(url, { headers }, { timeout: 10_000, retries: 1 });
|
|
320
|
+
if (!response.ok)
|
|
321
|
+
return null;
|
|
322
|
+
const data = (await response.json());
|
|
323
|
+
if (data.status !== "ok")
|
|
324
|
+
return null;
|
|
325
|
+
return data.result ?? null;
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
function inferTypeFromUri(uri) {
|
|
332
|
+
const uriPath = uri.replace(/^viking:\/\//, "");
|
|
333
|
+
const firstSegment = uriPath.split("/")[0] ?? "";
|
|
334
|
+
return OV_TYPE_MAP[firstSegment] ?? "knowledge";
|
|
335
|
+
}
|
|
336
|
+
// ── Exports for testing ─────────────────────────────────────────────────────
|
|
337
|
+
export { OpenVikingStashProvider, uriToVikingRef, parseOVSearchResponse };
|
package/dist/stash-resolve.js
CHANGED
|
@@ -8,14 +8,14 @@ import { walkStashFlat } from "./walker";
|
|
|
8
8
|
/**
|
|
9
9
|
* Resolve an asset path from a stash directory, type, and name.
|
|
10
10
|
*/
|
|
11
|
-
export function resolveAssetPath(stashDir, type, name) {
|
|
11
|
+
export async function resolveAssetPath(stashDir, type, name) {
|
|
12
12
|
try {
|
|
13
13
|
return resolveInTypeDir(stashDir, TYPE_DIRS[type], type, name);
|
|
14
14
|
}
|
|
15
15
|
catch (error) {
|
|
16
16
|
if (!(error instanceof NotFoundError))
|
|
17
17
|
throw error;
|
|
18
|
-
const fallback = resolveByCanonicalName(stashDir, type, name);
|
|
18
|
+
const fallback = await resolveByCanonicalName(stashDir, type, name);
|
|
19
19
|
if (fallback)
|
|
20
20
|
return fallback;
|
|
21
21
|
throw error;
|
|
@@ -65,10 +65,10 @@ function readTypeRootStat(root, type, name) {
|
|
|
65
65
|
throw error;
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
|
-
function resolveByCanonicalName(stashDir, type, name) {
|
|
68
|
+
async function resolveByCanonicalName(stashDir, type, name) {
|
|
69
69
|
const normalizedName = name.replace(/\\/g, "/");
|
|
70
70
|
for (const ctx of walkStashFlat(stashDir)) {
|
|
71
|
-
const match = runMatchers(ctx);
|
|
71
|
+
const match = await runMatchers(ctx);
|
|
72
72
|
if (!match || match.type !== type)
|
|
73
73
|
continue;
|
|
74
74
|
const canonicalName = deriveCanonicalAssetNameFromStashRoot(type, stashDir, ctx.absPath);
|