akm-cli 0.1.3 → 0.2.1

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/dist/setup.js CHANGED
@@ -6,8 +6,11 @@
6
6
  * Collects all choices and writes config once at the end.
7
7
  */
8
8
  import * as p from "@clack/prompts";
9
+ import { isHttpUrl } from "./common";
9
10
  import { DEFAULT_CONFIG, getConfigPath, loadConfig, saveConfig } from "./config";
11
+ import { closeDatabase, isVecAvailable, openDatabase } from "./db";
10
12
  import { detectAgentPlatforms, detectOllama, detectOpenViking } from "./detect";
13
+ import { checkEmbeddingAvailability, DEFAULT_LOCAL_MODEL, isTransformersAvailable } from "./embedder";
11
14
  import { akmIndex } from "./indexer";
12
15
  import { akmInit } from "./init";
13
16
  import { getDefaultStashDir } from "./paths";
@@ -20,6 +23,11 @@ const RECOMMENDED_GITHUB_REPOS = [
20
23
  hint: "community knowledge",
21
24
  },
22
25
  ];
26
+ // Approximate first-download sizes used in the setup note.
27
+ // LOCAL_MODEL_APPROX_SIZE_MB tracks the default local model (DEFAULT_LOCAL_MODEL).
28
+ const LOCAL_MODEL_APPROX_SIZE_MB = 130;
29
+ // SQLITE_VEC_APPROX_SIZE_MB reflects the optional sqlite-vec install footprint.
30
+ const SQLITE_VEC_APPROX_SIZE_MB = 5;
23
31
  // ── Helpers ─────────────────────────────────────────────────────────────────
24
32
  function bail() {
25
33
  p.cancel("Setup cancelled. No changes were saved.");
@@ -70,6 +78,112 @@ async function promptOrBack(fn) {
70
78
  return null;
71
79
  return result;
72
80
  }
81
+ function isRemoteEmbeddingConfig(embedding) {
82
+ return isHttpUrl(embedding?.endpoint);
83
+ }
84
+ /**
85
+ * @internal Exported for testing only.
86
+ */
87
+ export function describeSemanticSearchAssets(embedding) {
88
+ if (isRemoteEmbeddingConfig(embedding)) {
89
+ return [
90
+ `• Embedding endpoint: ${embedding?.provider ?? "custom"} / ${embedding?.model} (no local model download)`,
91
+ `• sqlite-vec acceleration: optional native extension (~${SQLITE_VEC_APPROX_SIZE_MB} MB when installed separately)`,
92
+ ];
93
+ }
94
+ return [
95
+ `• Local embedding model: ${embedding?.localModel ?? DEFAULT_LOCAL_MODEL} (~${LOCAL_MODEL_APPROX_SIZE_MB} MB download on first use)`,
96
+ `• sqlite-vec acceleration: optional native extension (~${SQLITE_VEC_APPROX_SIZE_MB} MB when installed separately)`,
97
+ ];
98
+ }
99
+ export async function stepSemanticSearch(current, embedding) {
100
+ const enabled = await prompt(() => p.confirm({
101
+ message: "Enable semantic search?",
102
+ initialValue: current.semanticSearch,
103
+ }));
104
+ if (!enabled) {
105
+ return { enabled: false, prepareAssets: false };
106
+ }
107
+ p.note(describeSemanticSearchAssets(embedding).join("\n"), "Semantic Search Assets");
108
+ const prepareAssets = await prompt(() => p.confirm({
109
+ message: isRemoteEmbeddingConfig(embedding)
110
+ ? "Check the embedding endpoint and verify semantic search now?"
111
+ : "Download and verify semantic-search assets now?",
112
+ initialValue: true,
113
+ }));
114
+ return { enabled: true, prepareAssets };
115
+ }
116
+ async function prepareSemanticSearchAssets(config) {
117
+ const remote = isRemoteEmbeddingConfig(config.embedding);
118
+ // For local embeddings, ensure the required package is installed first.
119
+ if (!remote) {
120
+ if (!(await isTransformersAvailable())) {
121
+ const spin = p.spinner();
122
+ spin.start("Installing @huggingface/transformers...");
123
+ try {
124
+ const proc = Bun.spawn(["bun", "add", "@huggingface/transformers"], {
125
+ stdout: "pipe",
126
+ stderr: "pipe",
127
+ });
128
+ await proc.exited;
129
+ if (proc.exitCode !== 0) {
130
+ const stderr = await new Response(proc.stderr).text();
131
+ throw new Error(stderr || `exit code ${proc.exitCode}`);
132
+ }
133
+ spin.stop("@huggingface/transformers installed.");
134
+ }
135
+ catch (err) {
136
+ const msg = err instanceof Error ? err.message : String(err);
137
+ spin.stop("Could not install @huggingface/transformers.");
138
+ p.log.warn(`Automatic install failed: ${msg}\n` +
139
+ "Install it manually with: bun add @huggingface/transformers\n" +
140
+ "Then re-run `akm setup` or `akm index --full --verbose`.");
141
+ return false;
142
+ }
143
+ }
144
+ }
145
+ const spin = p.spinner();
146
+ spin.start(remote
147
+ ? "Checking remote embedding endpoint..."
148
+ : `Downloading local embedding model (${config.embedding?.localModel ?? DEFAULT_LOCAL_MODEL})...`);
149
+ const result = await checkEmbeddingAvailability(config.embedding);
150
+ if (!result.available) {
151
+ spin.stop("Semantic-search assets could not be prepared.");
152
+ if (result.reason === "remote-unreachable") {
153
+ p.log.warn("The remote embedding endpoint is not reachable. Check your endpoint and credentials, then retry `akm index --full --verbose`.");
154
+ }
155
+ else if (result.reason === "missing-package") {
156
+ p.log.warn("@huggingface/transformers is not installed. Install it with: bun add @huggingface/transformers\n" +
157
+ "Then re-run `akm setup` or `akm index --full --verbose`.");
158
+ }
159
+ else {
160
+ p.log.warn(`The local embedding model could not be downloaded: ${result.message}\n` +
161
+ "Retry `akm index --full --verbose` after confirming local model downloads are permitted.");
162
+ }
163
+ return false;
164
+ }
165
+ spin.stop(remote ? "Remote embedding endpoint is ready." : "Local embedding model downloaded and ready.");
166
+ let db;
167
+ try {
168
+ db = openDatabase();
169
+ if (isVecAvailable(db)) {
170
+ p.log.info("sqlite-vec is available for fast vector search.");
171
+ }
172
+ else {
173
+ p.log.info("sqlite-vec is not available. Semantic search will use the JS fallback until the optional extension is installed.");
174
+ }
175
+ }
176
+ catch (error) {
177
+ const message = error instanceof Error ? error.message : String(error);
178
+ p.log.warn(`Could not open the local database or check for sqlite-vec. Semantic search will use the JS fallback. (${message})\n` +
179
+ "Check file permissions and available disk space in the cache directory, or run `akm index --full --verbose` to diagnose.");
180
+ }
181
+ finally {
182
+ if (db)
183
+ closeDatabase(db);
184
+ }
185
+ return true;
186
+ }
73
187
  // ── Steps ───────────────────────────────────────────────────────────────────
74
188
  async function stepStashDir(current) {
75
189
  const defaultDir = current.stashDir ?? getDefaultStashDir();
@@ -431,14 +545,17 @@ export async function runSetupWizard() {
431
545
  // Step 2: Ollama / Embedding / LLM
432
546
  p.log.step("Step 2: Embedding & LLM");
433
547
  const { embedding, llm } = await stepOllama(current);
434
- // Step 3: Registries
435
- p.log.step("Step 3: Registries");
548
+ // Step 3: Semantic search assets
549
+ p.log.step("Step 3: Semantic Search");
550
+ const semanticSearch = await stepSemanticSearch(current, embedding);
551
+ // Step 4: Registries
552
+ p.log.step("Step 4: Registries");
436
553
  const registries = await stepRegistries(current);
437
- // Step 4: Stash sources
438
- p.log.step("Step 4: Stash Sources");
554
+ // Step 5: Stash sources
555
+ p.log.step("Step 5: Stash Sources");
439
556
  const stashes = await stepStashSources(current);
440
- // Step 5: Agent platform detection
441
- p.log.step("Step 5: Agent Platform Detection");
557
+ // Step 6: Agent platform detection
558
+ p.log.step("Step 6: Agent Platform Detection");
442
559
  const platformStashes = await stepAgentPlatforms(current);
443
560
  // Merge platform stashes into main stashes list
444
561
  const allStashes = [...stashes];
@@ -456,7 +573,7 @@ export async function runSetupWizard() {
456
573
  registries,
457
574
  stashes: allStashes.length > 0 ? allStashes : undefined,
458
575
  // Preserve existing fields
459
- semanticSearch: current.semanticSearch,
576
+ semanticSearch: semanticSearch.enabled,
460
577
  installed: current.installed,
461
578
  output: current.output,
462
579
  };
@@ -466,6 +583,7 @@ export async function runSetupWizard() {
466
583
  `Stash directory: ${stashDir}`,
467
584
  `Embedding: ${embedding ? `${embedding.provider ?? "remote"} / ${embedding.model}` : "built-in local"}`,
468
585
  `LLM: ${llm ? `${llm.provider ?? "remote"} / ${llm.model}` : "disabled"}`,
586
+ `Semantic search: ${semanticSearch.enabled ? "enabled" : "disabled"}`,
469
587
  `Registries: ${effectiveRegistries.filter((r) => r.enabled !== false).length} enabled`,
470
588
  `Stash sources: ${allStashes.length}`,
471
589
  ].join("\n"), "Configuration Summary");
@@ -479,12 +597,38 @@ export async function runSetupWizard() {
479
597
  saveConfig(newConfig);
480
598
  // Initialize stash directory
481
599
  await akmInit({ dir: stashDir });
600
+ if (semanticSearch.enabled) {
601
+ if (semanticSearch.prepareAssets) {
602
+ const ready = await prepareSemanticSearchAssets(newConfig);
603
+ if (!ready) {
604
+ // Asset preparation failed: disable semantic search and persist the update.
605
+ newConfig.semanticSearch = false;
606
+ saveConfig(newConfig);
607
+ p.log.warn("Semantic search has been disabled in the saved configuration. Re-run `akm setup` or `akm index --full --verbose` once the issue is resolved.");
608
+ }
609
+ }
610
+ else {
611
+ p.log.info("Semantic search will be enabled, but asset preparation was skipped. Run `akm index --full --verbose` later to verify it.");
612
+ }
613
+ }
482
614
  // Build search index
615
+ p.log.info("Building search index...");
483
616
  const spin = p.spinner();
484
617
  spin.start("Building search index...");
485
618
  try {
486
619
  const indexResult = await akmIndex({ stashDir });
487
620
  spin.stop(`Indexed ${indexResult.totalEntries} assets.`);
621
+ if (newConfig.semanticSearch) {
622
+ if (indexResult.verification.ok) {
623
+ p.log.success(indexResult.verification.message);
624
+ }
625
+ else {
626
+ p.log.warn(indexResult.verification.message);
627
+ if (indexResult.verification.guidance) {
628
+ p.log.info(indexResult.verification.guidance);
629
+ }
630
+ }
631
+ }
488
632
  }
489
633
  catch (err) {
490
634
  spin.stop("Indexing failed — you can run `akm index` manually later.");
@@ -98,7 +98,9 @@ export async function akmClone(options) {
98
98
  }
99
99
  else {
100
100
  const resolvedSource = path.resolve(sourcePath);
101
- const resolvedDest = path.resolve(path.join(destRoot, typeDir, destName));
101
+ const sourceExt = path.extname(sourcePath);
102
+ const effectiveDestName = !path.extname(destName) && sourceExt ? destName + sourceExt : destName;
103
+ const resolvedDest = path.resolve(path.join(destRoot, typeDir, effectiveDestName));
102
104
  if (resolvedSource === resolvedDest) {
103
105
  throw new Error(`Source and destination are the same path. Use --name to provide a new name for the clone.`);
104
106
  }
@@ -26,8 +26,6 @@ export function resolveStashProviders(config) {
26
26
  for (const entry of config.stashes ?? []) {
27
27
  if (entry.enabled === false)
28
28
  continue;
29
- if (entry.type === "filesystem")
30
- continue;
31
29
  const factory = registry.resolve(entry.type);
32
30
  if (factory) {
33
31
  providers.push(factory(entry));
@@ -8,21 +8,20 @@ class FilesystemStashProvider {
8
8
  type = "filesystem";
9
9
  name;
10
10
  stashDir;
11
- config;
12
11
  constructor(entry) {
13
- this.config = loadConfig();
14
12
  this.stashDir = entry.path ?? resolveStashDir();
15
13
  this.name = entry.name ?? this.stashDir;
16
14
  }
17
15
  async search(options) {
18
- const sources = resolveStashSources(this.stashDir, this.config);
16
+ const config = loadConfig();
17
+ const sources = resolveStashSources(this.stashDir, config);
19
18
  const result = await searchLocal({
20
19
  query: options.query.toLowerCase(),
21
20
  searchType: options.type ?? "any",
22
21
  limit: options.limit,
23
22
  stashDir: this.stashDir,
24
23
  sources,
25
- config: this.config,
24
+ config,
26
25
  });
27
26
  return {
28
27
  hits: result.hits,
@@ -35,7 +34,7 @@ class FilesystemStashProvider {
35
34
  return showLocal({ ref, view });
36
35
  }
37
36
  canShow(ref) {
38
- return !ref.trim().startsWith("viking://");
37
+ return !ref.includes("://");
39
38
  }
40
39
  }
41
40
  // ── Self-register ───────────────────────────────────────────────────────────
@@ -0,0 +1,140 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fetchWithRetry } from "../common";
5
+ import { ConfigError } from "../errors";
6
+ import { getRegistryIndexCacheDir } from "../paths";
7
+ import { extractTarGzSecure } from "../registry-install";
8
+ import { registerStashProvider } from "../stash-provider-factory";
9
+ import { isExpired, sanitizeString } from "./provider-utils";
10
+ /** Cache TTL before refreshing the mirrored repo (12 hours). */
11
+ const CACHE_TTL_MS = 12 * 60 * 60 * 1000;
12
+ /** Maximum stale age allowed when refresh fails (7 days). */
13
+ const CACHE_STALE_MS = 7 * 24 * 60 * 60 * 1000;
14
+ class GitStashProvider {
15
+ type = "git";
16
+ name;
17
+ constructor(config) {
18
+ this.name = config.name ?? "git";
19
+ }
20
+ /** Content is indexed through the standard FTS5 pipeline. */
21
+ async search(_options) {
22
+ return { hits: [] };
23
+ }
24
+ /** Content is local files, shown via showLocal. */
25
+ async show(_ref, _view) {
26
+ throw new Error("Git provider content is shown via local index");
27
+ }
28
+ /** Content is local; no remote show needed. */
29
+ canShow(_ref) {
30
+ return false;
31
+ }
32
+ }
33
+ // ── Self-register ───────────────────────────────────────────────────────────
34
+ registerStashProvider("git", (config) => new GitStashProvider(config));
35
+ registerStashProvider("context-hub", (config) => new GitStashProvider(config));
36
+ registerStashProvider("github", (config) => new GitStashProvider(config));
37
+ // ── Cache management ────────────────────────────────────────────────────────
38
+ function getCachePaths(repoUrl) {
39
+ const key = createHash("sha256").update(repoUrl).digest("hex").slice(0, 16);
40
+ const rootDir = path.join(getRegistryIndexCacheDir(), `context-hub-${key}`);
41
+ return {
42
+ rootDir,
43
+ archivePath: path.join(rootDir, "repo.tar.gz"),
44
+ repoDir: path.join(rootDir, "repo"),
45
+ indexPath: path.join(rootDir, "index.json"),
46
+ };
47
+ }
48
+ async function ensureGitMirror(repo, cachePaths, options) {
49
+ const requireRepoDir = options?.requireRepoDir === true;
50
+ // Check if cache is fresh
51
+ let mtime = 0;
52
+ try {
53
+ mtime = fs.statSync(cachePaths.indexPath).mtimeMs;
54
+ }
55
+ catch {
56
+ /* no cached index */
57
+ }
58
+ if (mtime && !isExpired(mtime, CACHE_TTL_MS) && (!requireRepoDir || hasExtractedRepo(cachePaths.repoDir))) {
59
+ return;
60
+ }
61
+ try {
62
+ fs.mkdirSync(cachePaths.rootDir, { recursive: true });
63
+ await downloadArchive(buildTarballUrl(repo), cachePaths.archivePath);
64
+ extractTarGzSecure(cachePaths.archivePath, cachePaths.repoDir);
65
+ // Touch index file to track freshness
66
+ fs.writeFileSync(cachePaths.indexPath, "[]", { encoding: "utf8", mode: 0o600 });
67
+ }
68
+ catch (err) {
69
+ if (mtime && !isExpired(mtime, CACHE_STALE_MS) && (!requireRepoDir || hasExtractedRepo(cachePaths.repoDir))) {
70
+ return;
71
+ }
72
+ throw err;
73
+ }
74
+ }
75
+ function hasExtractedRepo(repoDir) {
76
+ try {
77
+ return fs.statSync(repoDir).isDirectory() && fs.statSync(path.join(repoDir, "content")).isDirectory();
78
+ }
79
+ catch {
80
+ return false;
81
+ }
82
+ }
83
+ async function downloadArchive(url, destination) {
84
+ const response = await fetchWithRetry(url, undefined, { timeout: 120_000, retries: 1 });
85
+ if (!response.ok) {
86
+ throw new Error(`Failed to download archive (${response.status}) from ${url}`);
87
+ }
88
+ const BunRuntime = globalThis.Bun;
89
+ if (BunRuntime?.write) {
90
+ await BunRuntime.write(destination, response);
91
+ return;
92
+ }
93
+ const arrayBuffer = await response.arrayBuffer();
94
+ fs.writeFileSync(destination, Buffer.from(arrayBuffer));
95
+ }
96
+ function buildTarballUrl(repo) {
97
+ return `https://github.com/${repo.owner}/${repo.repo}/archive/refs/heads/${repo.ref}.tar.gz`;
98
+ }
99
+ function parseGitRepoUrl(rawUrl) {
100
+ if (!rawUrl) {
101
+ throw new ConfigError("Git provider requires a GitHub repository URL");
102
+ }
103
+ let parsed;
104
+ try {
105
+ parsed = new URL(rawUrl);
106
+ }
107
+ catch {
108
+ throw new ConfigError(`Git provider URL is not valid: "${rawUrl}"`);
109
+ }
110
+ if (parsed.protocol !== "https:") {
111
+ throw new ConfigError(`Git provider URL must use https://, got "${parsed.protocol}"`);
112
+ }
113
+ if (parsed.hostname !== "github.com") {
114
+ throw new ConfigError(`Git provider only supports github.com URLs, got "${parsed.hostname}"`);
115
+ }
116
+ const segments = parsed.pathname.split("/").filter(Boolean);
117
+ if (segments.length < 2) {
118
+ throw new ConfigError(`Git provider URL must point to a GitHub repository, got "${rawUrl}"`);
119
+ }
120
+ const owner = sanitizeString(segments[0]);
121
+ const repo = sanitizeString(segments[1].replace(/\.git$/i, ""));
122
+ let ref = "main";
123
+ if (segments[2] === "tree" && segments.length >= 4) {
124
+ ref = sanitizeString(segments.slice(3).join("/"), 255) || "main";
125
+ }
126
+ if (!owner || !repo || !/^[A-Za-z0-9_.-]+$/.test(owner) || !/^[A-Za-z0-9_.-]+$/.test(repo)) {
127
+ throw new ConfigError(`Unsupported repository URL: "${rawUrl}"`);
128
+ }
129
+ if (!ref || ref.includes("..") || !/^[A-Za-z0-9._/-]+$/.test(ref)) {
130
+ throw new ConfigError(`Unsupported branch/ref in URL: "${rawUrl}"`);
131
+ }
132
+ return {
133
+ owner,
134
+ repo,
135
+ ref,
136
+ canonicalUrl: `https://github.com/${owner}/${repo}/tree/${ref}`,
137
+ };
138
+ }
139
+ // ── Exports ─────────────────────────────────────────────────────────────────
140
+ export { ensureGitMirror, GitStashProvider, getCachePaths, parseGitRepoUrl };
@@ -6,5 +6,5 @@
6
6
  * side-effect imports that were duplicated in stash-search.ts and stash-show.ts.
7
7
  */
8
8
  import "./filesystem";
9
- import "./context-hub";
9
+ import "./git";
10
10
  import "./openviking";
@@ -1,16 +1,11 @@
1
+ import { createHash } from "node:crypto";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import { fetchWithRetry } from "../common";
4
5
  import { ConfigError, NotFoundError } from "../errors";
5
6
  import { getRegistryIndexCacheDir } from "../paths";
6
7
  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
- }
8
+ import { isExpired, sanitizeString } from "./provider-utils";
14
9
  /** Per-query cache TTL in milliseconds (5 minutes). */
15
10
  const QUERY_CACHE_TTL_MS = 5 * 60 * 1000;
16
11
  /** Maximum age before query cache is considered stale but still usable (1 hour). */
@@ -70,7 +65,9 @@ class OpenVikingStashProvider {
70
65
  }
71
66
  }
72
67
  async show(ref, _view) {
73
- const uri = ref.trim();
68
+ const trimmed = ref.trim();
69
+ // Accept both viking:// URIs (legacy/internal) and type:name refs
70
+ const uri = trimmed.startsWith("viking://") ? trimmed : refToVikingUri(trimmed);
74
71
  const baseUrl = this.baseUrl;
75
72
  const headers = this.authHeaders;
76
73
  const [statResult, contentResult] = await Promise.all([
@@ -78,10 +75,10 @@ class OpenVikingStashProvider {
78
75
  fetchOVJson(`${baseUrl}/api/v1/content/read?uri=${encodeURIComponent(uri)}&offset=0&limit=-1`, headers),
79
76
  ]);
80
77
  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.`);
78
+ throw new NotFoundError(`Could not fetch remote asset "${trimmed}". The OpenViking server at ${baseUrl} may be unreachable or the resource does not exist.`);
82
79
  }
83
80
  if (contentResult == null) {
84
- throw new NotFoundError(`Content not found for remote asset "${uri}". The server returned metadata but no content.`);
81
+ throw new NotFoundError(`Content not found for remote asset "${trimmed}". The server returned metadata but no content.`);
85
82
  }
86
83
  const stat = (typeof statResult === "object" && statResult !== null ? statResult : {});
87
84
  const uriPath = uri.replace(/^viking:\/\//, "");
@@ -91,19 +88,20 @@ class OpenVikingStashProvider {
91
88
  const assetType = OV_TYPE_MAP[ovType] ?? "knowledge";
92
89
  const content = typeof contentResult === "string" ? contentResult : "";
93
90
  const description = sanitizeString(stat.abstract, 1000) || undefined;
91
+ const assetRef = `${assetType}:${name}`;
94
92
  return {
95
93
  type: assetType,
96
94
  name,
97
- path: uri,
98
- action: `Remote content from OpenViking — ${uri}`,
95
+ path: assetRef,
96
+ action: `Remote content from OpenViking — ${assetRef}`,
99
97
  content,
100
98
  description,
101
99
  editable: false,
102
100
  origin: "remote",
103
101
  };
104
102
  }
105
- canShow(ref) {
106
- return ref.trim().startsWith("viking://");
103
+ canShow(_ref) {
104
+ return !!(this.config.url ?? "").trim();
107
105
  }
108
106
  get baseUrl() {
109
107
  return (this.config.url ?? "").replace(/\/+$/, "");
@@ -162,9 +160,8 @@ class OpenVikingStashProvider {
162
160
  const name = sanitizeString(entry.name);
163
161
  const abstract = sanitizeString(entry.abstract, 1000);
164
162
  const type = sanitizeString(entry.type);
165
- const uri = sanitizeString(entry.uri, 2048);
166
163
  const assetType = OV_TYPE_MAP[type] ?? "knowledge";
167
- const ref = uriToVikingRef(uri);
164
+ const ref = `${assetType}:${name}`;
168
165
  return {
169
166
  type: assetType,
170
167
  name,
@@ -180,7 +177,7 @@ class OpenVikingStashProvider {
180
177
  }
181
178
  queryCachePath(query, limit) {
182
179
  const cacheDir = getRegistryIndexCacheDir();
183
- const hasher = new Bun.CryptoHasher("md5");
180
+ const hasher = createHash("md5");
184
181
  hasher.update(this.config.url ?? "");
185
182
  hasher.update("\0");
186
183
  hasher.update(query.trim().toLowerCase());
@@ -225,11 +222,28 @@ class OpenVikingStashProvider {
225
222
  // ── Self-register ───────────────────────────────────────────────────────────
226
223
  registerStashProvider("openviking", (config) => new OpenVikingStashProvider(config));
227
224
  // ── Helpers ─────────────────────────────────────────────────────────────────
228
- function uriToVikingRef(uri) {
229
- if (uri.startsWith("viking://"))
230
- return uri;
231
- return `viking://${uri.replace(/^\/+/, "")}`;
225
+ /**
226
+ * Convert a type:name ref to a viking:// URI for the OpenViking API.
227
+ * Maps the akm asset type back to the OV plural form (e.g. "skill" -> "skills").
228
+ */
229
+ function refToVikingUri(ref) {
230
+ const colon = ref.indexOf(":");
231
+ if (colon <= 0)
232
+ return `viking://${ref}`;
233
+ const name = ref.slice(colon + 1);
234
+ const type = ref.slice(0, colon);
235
+ const ovDir = AKM_TO_OV_DIR[type] ?? type;
236
+ return `viking://${ovDir}/${name}`;
232
237
  }
238
+ /** Reverse map: akm asset type → OpenViking directory name (plural). */
239
+ const AKM_TO_OV_DIR = {
240
+ skill: "skills",
241
+ memory: "memories",
242
+ knowledge: "resources",
243
+ agent: "agents",
244
+ command: "commands",
245
+ script: "scripts",
246
+ };
233
247
  function parseOVSearchResponse(result) {
234
248
  if (Array.isArray(result))
235
249
  return result.filter(isValidOVEntry);
@@ -311,9 +325,6 @@ function extractNameFromUri(uri) {
311
325
  const last = segments[segments.length - 1] ?? "unknown";
312
326
  return last.replace(/\.[^.]+$/, "");
313
327
  }
314
- function isExpired(mtimeMs, ttlMs) {
315
- return Date.now() - mtimeMs > ttlMs;
316
- }
317
328
  async function fetchOVJson(url, headers) {
318
329
  try {
319
330
  const response = await fetchWithRetry(url, { headers }, { timeout: 10_000, retries: 1 });
@@ -334,4 +345,4 @@ function inferTypeFromUri(uri) {
334
345
  return OV_TYPE_MAP[firstSegment] ?? "knowledge";
335
346
  }
336
347
  // ── Exports for testing ─────────────────────────────────────────────────────
337
- export { OpenVikingStashProvider, uriToVikingRef, parseOVSearchResponse };
348
+ export { OpenVikingStashProvider, parseOVSearchResponse, refToVikingUri };
@@ -0,0 +1,11 @@
1
+ /** Strip terminal control characters from untrusted strings. */
2
+ export function sanitizeString(value, maxLength = 255) {
3
+ if (typeof value !== "string")
4
+ return "";
5
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — strip control chars from untrusted remote data
6
+ return value.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, maxLength);
7
+ }
8
+ /** Check whether a cached timestamp has exceeded its TTL. */
9
+ export function isExpired(mtimeMs, ttlMs) {
10
+ return Date.now() - mtimeMs > ttlMs;
11
+ }