akm-cli 0.0.20 → 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.
Files changed (45) hide show
  1. package/README.md +8 -5
  2. package/dist/asset-spec.js +96 -9
  3. package/dist/cli.js +195 -55
  4. package/dist/common.js +15 -2
  5. package/dist/config-cli.js +65 -6
  6. package/dist/config.js +206 -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 +22 -16
  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 +52 -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 +33 -3
  39. package/dist/stash-search.js +70 -402
  40. package/dist/stash-show.js +24 -5
  41. package/dist/stash-source.js +19 -11
  42. package/dist/walker.js +15 -10
  43. package/dist/warn.js +7 -0
  44. package/package.json +1 -1
  45. package/dist/provider-registry.js +0 -8
package/dist/stash-add.js CHANGED
@@ -1,15 +1,77 @@
1
1
  import fs from "node:fs";
2
+ import path from "node:path";
2
3
  import { resolveStashDir } from "./common";
3
- import { loadConfig } from "./config";
4
+ import { loadConfig, saveConfig } from "./config";
4
5
  import { UsageError } from "./errors";
5
6
  import { agentikitIndex } from "./indexer";
6
7
  import { upsertLockEntry } from "./lockfile";
7
- import { installRegistryRef, upsertInstalledRegistryEntry } from "./registry-install";
8
+ import { detectStashRoot, installRegistryRef, upsertInstalledRegistryEntry } from "./registry-install";
9
+ import { parseRegistryRef } from "./registry-resolve";
8
10
  export async function agentikitAdd(input) {
9
11
  const ref = input.ref.trim();
10
12
  if (!ref)
11
13
  throw new UsageError("Install ref or local directory is required.");
12
14
  const stashDir = resolveStashDir();
15
+ // Detect local directory refs and route them to stashes[] instead of installed[]
16
+ try {
17
+ const parsed = parseRegistryRef(ref);
18
+ if (parsed.source === "local") {
19
+ return addLocalStashSource(ref, parsed.sourcePath, stashDir);
20
+ }
21
+ }
22
+ catch {
23
+ // Not a local ref — fall through to registry install
24
+ }
25
+ return addRegistryKit(ref, stashDir);
26
+ }
27
+ /**
28
+ * Add a local directory as a filesystem stash source.
29
+ * Creates a stashes[] entry instead of an installed[] entry.
30
+ */
31
+ async function addLocalStashSource(ref, sourcePath, stashDir) {
32
+ const stashRoot = detectStashRoot(sourcePath);
33
+ const resolvedPath = path.resolve(stashRoot);
34
+ const config = loadConfig();
35
+ // Check for duplicates in stashes[]
36
+ const stashes = [...(config.stashes ?? [])];
37
+ const existing = stashes.find((s) => s.type === "filesystem" && s.path && path.resolve(s.path) === resolvedPath);
38
+ if (!existing) {
39
+ const entry = {
40
+ type: "filesystem",
41
+ path: resolvedPath,
42
+ name: toReadableId(resolvedPath),
43
+ };
44
+ stashes.push(entry);
45
+ saveConfig({ ...config, stashes });
46
+ }
47
+ const index = await agentikitIndex({ stashDir });
48
+ const updatedConfig = loadConfig();
49
+ return {
50
+ schemaVersion: 1,
51
+ stashDir,
52
+ ref,
53
+ stashSource: {
54
+ type: "filesystem",
55
+ path: resolvedPath,
56
+ name: toReadableId(resolvedPath),
57
+ stashRoot: resolvedPath,
58
+ },
59
+ config: {
60
+ searchPaths: updatedConfig.searchPaths,
61
+ installedKitCount: updatedConfig.installed?.length ?? 0,
62
+ },
63
+ index: {
64
+ mode: index.mode,
65
+ totalEntries: index.totalEntries,
66
+ directoriesScanned: index.directoriesScanned,
67
+ directoriesSkipped: index.directoriesSkipped,
68
+ },
69
+ };
70
+ }
71
+ /**
72
+ * Install a kit from a registry (npm, github, git).
73
+ */
74
+ async function addRegistryKit(ref, stashDir) {
13
75
  const installed = await installRegistryRef(ref);
14
76
  const replaced = (loadConfig().installed ?? []).find((entry) => entry.id === installed.id);
15
77
  const config = upsertInstalledRegistryEntry({
@@ -23,16 +85,16 @@ export async function agentikitAdd(input) {
23
85
  cacheDir: installed.cacheDir,
24
86
  installedAt: installed.installedAt,
25
87
  });
26
- upsertLockEntry({
88
+ await upsertLockEntry({
27
89
  id: installed.id,
28
90
  source: installed.source,
29
91
  ref: installed.ref,
30
92
  resolvedVersion: installed.resolvedVersion,
31
93
  resolvedRevision: installed.resolvedRevision,
32
- integrity: installed.integrity ?? (installed.source === "local" ? "local" : undefined),
94
+ integrity: installed.integrity,
33
95
  });
34
- // Clean up old cache directory on re-install (skip for local sources — no cache to clean)
35
- if (replaced && replaced.source !== "local" && replaced.cacheDir !== installed.cacheDir) {
96
+ // Clean up old cache directory on re-install
97
+ if (replaced && replaced.cacheDir !== installed.cacheDir) {
36
98
  try {
37
99
  fs.rmSync(replaced.cacheDir, { recursive: true, force: true });
38
100
  }
@@ -69,3 +131,10 @@ export async function agentikitAdd(input) {
69
131
  },
70
132
  };
71
133
  }
134
+ function toReadableId(resolvedPath) {
135
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
136
+ if (home && resolvedPath.startsWith(home + path.sep)) {
137
+ return `~${resolvedPath.slice(home.length)}`;
138
+ }
139
+ return resolvedPath;
140
+ }
@@ -47,7 +47,7 @@ export async function agentikitClone(options) {
47
47
  let lastError;
48
48
  for (const source of searchSources) {
49
49
  try {
50
- sourcePath = resolveAssetPath(source.path, parsed.type, parsed.name);
50
+ sourcePath = await resolveAssetPath(source.path, parsed.type, parsed.name);
51
51
  break;
52
52
  }
53
53
  catch (err) {
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Stash provider factory map.
3
+ *
4
+ * Maps stash source type identifiers (e.g. "filesystem", "openviking") to
5
+ * factory functions that create StashProvider instances from a StashConfigEntry.
6
+ *
7
+ * "Stash providers" are runtime data sources for the search and show commands —
8
+ * distinct from the kit-discovery registries (registry-factory.ts) and the
9
+ * installed-kit operations (installed-kits.ts).
10
+ */
11
+ import { createProviderRegistry } from "./create-provider-registry";
12
+ // ── Factory map ─────────────────────────────────────────────────────────────
13
+ const registry = createProviderRegistry();
14
+ export function registerStashProvider(type, factory) {
15
+ registry.register(type, factory);
16
+ }
17
+ export function resolveStashProviderFactory(type) {
18
+ return registry.resolve(type);
19
+ }
20
+ /**
21
+ * Resolve all non-filesystem stash providers from config.
22
+ * Sources come from `stashes` (new) or `remoteStashSources` (legacy).
23
+ * Filesystem entries are excluded — they are handled by resolveStashSources().
24
+ */
25
+ export function resolveStashProviders(config) {
26
+ const providers = [];
27
+ // New config: stashes[]
28
+ if (config.stashes) {
29
+ for (const entry of config.stashes) {
30
+ if (entry.enabled === false)
31
+ continue;
32
+ if (entry.type === "filesystem")
33
+ continue;
34
+ const factory = registry.resolve(entry.type);
35
+ if (factory) {
36
+ providers.push(factory(entry));
37
+ }
38
+ }
39
+ }
40
+ // Legacy config: remoteStashSources[] → map to stash providers
41
+ if (!config.stashes && config.remoteStashSources) {
42
+ for (const entry of config.remoteStashSources) {
43
+ if (entry.enabled === false)
44
+ continue;
45
+ const factory = registry.resolve(entry.type ?? "openviking");
46
+ if (factory) {
47
+ providers.push(factory(entry));
48
+ }
49
+ }
50
+ }
51
+ return providers;
52
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,42 @@
1
+ import { resolveStashDir } from "../common";
2
+ import { loadConfig } from "../config";
3
+ import { searchLocal } from "../local-search";
4
+ import { registerStashProvider } from "../stash-provider-factory";
5
+ import { showLocal } from "../stash-show";
6
+ import { resolveStashSources } from "../stash-source";
7
+ class FilesystemStashProvider {
8
+ type = "filesystem";
9
+ name;
10
+ stashDir;
11
+ config;
12
+ constructor(entry) {
13
+ this.config = loadConfig();
14
+ this.stashDir = entry.path ?? resolveStashDir();
15
+ this.name = entry.name ?? this.stashDir;
16
+ }
17
+ async search(options) {
18
+ const sources = resolveStashSources(this.stashDir, this.config);
19
+ const result = await searchLocal({
20
+ query: options.query.toLowerCase(),
21
+ searchType: options.type ?? "any",
22
+ limit: options.limit,
23
+ stashDir: this.stashDir,
24
+ sources,
25
+ config: this.config,
26
+ });
27
+ return {
28
+ hits: result.hits,
29
+ warnings: result.warnings,
30
+ embedMs: result.embedMs,
31
+ rankMs: result.rankMs,
32
+ };
33
+ }
34
+ async show(ref, view) {
35
+ return showLocal({ ref, view });
36
+ }
37
+ canShow(ref) {
38
+ return !ref.trim().startsWith("viking://");
39
+ }
40
+ }
41
+ // ── Self-register ───────────────────────────────────────────────────────────
42
+ registerStashProvider("filesystem", (config) => new FilesystemStashProvider(config));
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Centralized stash provider registration.
3
+ *
4
+ * Import this module (side-effect import) to register all built-in stash
5
+ * providers with the provider registry. This replaces the individual
6
+ * side-effect imports that were duplicated in stash-search.ts and stash-show.ts.
7
+ */
8
+ import "./filesystem";
9
+ import "./openviking";
@@ -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 };
@@ -1,13 +1,25 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { isRelevantAssetFile, resolveAssetPathFromName, TYPE_DIRS } from "./asset-spec";
3
+ import { deriveCanonicalAssetNameFromStashRoot, isRelevantAssetFile, resolveAssetPathFromName, TYPE_DIRS, } from "./asset-spec";
4
4
  import { hasErrnoCode, isWithin } from "./common";
5
5
  import { NotFoundError, UsageError } from "./errors";
6
+ import { runMatchers } from "./file-context";
7
+ import { walkStashFlat } from "./walker";
6
8
  /**
7
9
  * Resolve an asset path from a stash directory, type, and name.
8
10
  */
9
- export function resolveAssetPath(stashDir, type, name) {
10
- return resolveInTypeDir(stashDir, TYPE_DIRS[type], type, name);
11
+ export async function resolveAssetPath(stashDir, type, name) {
12
+ try {
13
+ return resolveInTypeDir(stashDir, TYPE_DIRS[type], type, name);
14
+ }
15
+ catch (error) {
16
+ if (!(error instanceof NotFoundError))
17
+ throw error;
18
+ const fallback = await resolveByCanonicalName(stashDir, type, name);
19
+ if (fallback)
20
+ return fallback;
21
+ throw error;
22
+ }
11
23
  }
12
24
  /**
13
25
  * Try to resolve an asset path within a specific type directory.
@@ -53,3 +65,21 @@ function readTypeRootStat(root, type, name) {
53
65
  throw error;
54
66
  }
55
67
  }
68
+ async function resolveByCanonicalName(stashDir, type, name) {
69
+ const normalizedName = name.replace(/\\/g, "/");
70
+ for (const ctx of walkStashFlat(stashDir)) {
71
+ const match = await runMatchers(ctx);
72
+ if (!match || match.type !== type)
73
+ continue;
74
+ const canonicalName = deriveCanonicalAssetNameFromStashRoot(type, stashDir, ctx.absPath);
75
+ if (canonicalName !== normalizedName)
76
+ continue;
77
+ const realTarget = fs.realpathSync(ctx.absPath);
78
+ const resolvedRoot = fs.realpathSync(stashDir);
79
+ if (!isWithin(realTarget, resolvedRoot)) {
80
+ throw new UsageError("Ref resolves outside the stash root.");
81
+ }
82
+ return realTarget;
83
+ }
84
+ return undefined;
85
+ }