akm-cli 0.5.0 → 0.6.0-rc1

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 (74) hide show
  1. package/CHANGELOG.md +32 -5
  2. package/dist/asset-registry.js +29 -5
  3. package/dist/asset-spec.js +12 -5
  4. package/dist/cli-hints.js +300 -0
  5. package/dist/cli.js +218 -1357
  6. package/dist/common.js +147 -50
  7. package/dist/config.js +224 -13
  8. package/dist/create-provider-registry.js +1 -1
  9. package/dist/curate.js +258 -0
  10. package/dist/{local-search.js → db-search.js} +30 -19
  11. package/dist/db.js +168 -62
  12. package/dist/embedder.js +49 -273
  13. package/dist/embedders/cache.js +47 -0
  14. package/dist/embedders/local.js +152 -0
  15. package/dist/embedders/remote.js +121 -0
  16. package/dist/embedders/types.js +39 -0
  17. package/dist/errors.js +14 -3
  18. package/dist/frontmatter.js +61 -7
  19. package/dist/indexer.js +38 -7
  20. package/dist/info.js +2 -2
  21. package/dist/install-audit.js +16 -1
  22. package/dist/{installed-kits.js → installed-stashes.js} +48 -22
  23. package/dist/llm-client.js +92 -0
  24. package/dist/llm.js +14 -126
  25. package/dist/lockfile.js +28 -1
  26. package/dist/matchers.js +1 -1
  27. package/dist/metadata-enhance.js +53 -0
  28. package/dist/migration-help.js +75 -44
  29. package/dist/output-context.js +77 -0
  30. package/dist/output-shapes.js +198 -0
  31. package/dist/output-text.js +520 -0
  32. package/dist/paths.js +4 -4
  33. package/dist/providers/index.js +11 -0
  34. package/dist/providers/skills-sh.js +1 -1
  35. package/dist/providers/static-index.js +47 -45
  36. package/dist/registry-build-index.js +36 -29
  37. package/dist/registry-factory.js +2 -2
  38. package/dist/registry-resolve.js +8 -4
  39. package/dist/registry-search.js +62 -5
  40. package/dist/remember.js +172 -0
  41. package/dist/renderers.js +52 -0
  42. package/dist/search-source.js +73 -42
  43. package/dist/setup-steps.js +45 -0
  44. package/dist/setup.js +149 -76
  45. package/dist/stash-add.js +94 -38
  46. package/dist/stash-clone.js +4 -4
  47. package/dist/stash-provider-factory.js +2 -2
  48. package/dist/stash-provider.js +3 -1
  49. package/dist/stash-providers/filesystem.js +31 -1
  50. package/dist/stash-providers/git.js +209 -8
  51. package/dist/stash-providers/index.js +1 -0
  52. package/dist/stash-providers/npm.js +159 -0
  53. package/dist/stash-providers/provider-utils.js +162 -0
  54. package/dist/stash-providers/sync-from-ref.js +45 -0
  55. package/dist/stash-providers/tar-utils.js +151 -0
  56. package/dist/stash-providers/website.js +80 -4
  57. package/dist/stash-resolve.js +5 -5
  58. package/dist/stash-search.js +4 -4
  59. package/dist/stash-show.js +3 -3
  60. package/dist/wiki.js +6 -6
  61. package/dist/workflow-authoring.js +12 -4
  62. package/dist/workflow-markdown.js +9 -0
  63. package/dist/workflow-runs.js +12 -2
  64. package/docs/README.md +30 -0
  65. package/docs/migration/release-notes/0.0.13.md +4 -0
  66. package/docs/migration/release-notes/0.1.0.md +6 -0
  67. package/docs/migration/release-notes/0.2.0.md +6 -0
  68. package/docs/migration/release-notes/0.3.0.md +5 -0
  69. package/docs/migration/release-notes/0.5.0.md +6 -0
  70. package/docs/migration/release-notes/0.6.0.md +29 -0
  71. package/docs/migration/release-notes/README.md +21 -0
  72. package/package.json +3 -2
  73. package/dist/registry-install.js +0 -532
  74. /package/dist/{kit-include.js → stash-include.js} +0 -0
@@ -0,0 +1,121 @@
1
+ /**
2
+ * OpenAI-compatible remote embedder.
3
+ *
4
+ * Calls the configured `/embeddings` endpoint and L2-normalizes the returned
5
+ * vectors so the scoring pipeline's L2-to-cosine conversion is correct.
6
+ */
7
+ import { fetchWithTimeout, isHttpUrl } from "../common";
8
+ const REMOTE_BATCH_SIZE = 100;
9
+ export class RemoteEmbedder {
10
+ config;
11
+ constructor(config) {
12
+ this.config = config;
13
+ }
14
+ async embed(text) {
15
+ const headers = this.buildHeaders();
16
+ const body = {
17
+ input: text,
18
+ model: this.config.model,
19
+ };
20
+ if (this.config.dimension) {
21
+ body.dimensions = this.config.dimension;
22
+ }
23
+ const response = await fetchWithTimeout(normalizeEmbeddingEndpoint(this.config.endpoint), {
24
+ method: "POST",
25
+ headers,
26
+ body: JSON.stringify(body),
27
+ });
28
+ if (!response.ok) {
29
+ const errBody = await response.text().catch(() => "");
30
+ throw new Error(`Embedding request failed (${response.status}): ${errBody}`);
31
+ }
32
+ const json = (await response.json());
33
+ if (!json.data?.[0]?.embedding) {
34
+ throw new Error(`Unexpected embedding response format: missing data[0].embedding.${embeddingEndpointPathHint(this.config.endpoint)}`);
35
+ }
36
+ return l2Normalize(json.data[0].embedding);
37
+ }
38
+ async embedBatch(texts) {
39
+ if (texts.length === 0)
40
+ return [];
41
+ const results = [];
42
+ const headers = this.buildHeaders();
43
+ for (let i = 0; i < texts.length; i += REMOTE_BATCH_SIZE) {
44
+ const batch = texts.slice(i, i + REMOTE_BATCH_SIZE);
45
+ const body = {
46
+ input: batch,
47
+ model: this.config.model,
48
+ };
49
+ if (this.config.dimension) {
50
+ body.dimensions = this.config.dimension;
51
+ }
52
+ const response = await fetchWithTimeout(normalizeEmbeddingEndpoint(this.config.endpoint), {
53
+ method: "POST",
54
+ headers,
55
+ body: JSON.stringify(body),
56
+ });
57
+ if (!response.ok) {
58
+ const respBody = await response.text().catch(() => "");
59
+ throw new Error(`Embedding batch request failed (${response.status}): ${respBody}`);
60
+ }
61
+ const json = (await response.json());
62
+ if (!json.data || json.data.length !== batch.length) {
63
+ throw new Error(`Unexpected embedding batch response: expected ${batch.length} embeddings, got ${json.data?.length ?? 0}.${embeddingEndpointPathHint(this.config.endpoint)}`);
64
+ }
65
+ // Sort by index to guarantee correct order (OpenAI API doesn't guarantee order)
66
+ const sorted = [...json.data].sort((a, b) => a.index - b.index);
67
+ for (const [idx, d] of sorted.entries()) {
68
+ if (!Array.isArray(d.embedding)) {
69
+ throw new Error(`Unexpected embedding at batch index ${idx}: missing or invalid`);
70
+ }
71
+ results.push(l2Normalize(d.embedding));
72
+ }
73
+ }
74
+ return results;
75
+ }
76
+ buildHeaders() {
77
+ const headers = { "Content-Type": "application/json" };
78
+ if (this.config.apiKey) {
79
+ headers.Authorization = `Bearer ${this.config.apiKey}`;
80
+ }
81
+ return headers;
82
+ }
83
+ }
84
+ /**
85
+ * L2-normalize a vector to unit length.
86
+ * Required for remote embeddings because the scoring pipeline's L2-to-cosine
87
+ * conversion formula (1 - distance^2/2) is only correct for unit vectors.
88
+ * The local embedder already normalizes via `normalize: true`.
89
+ */
90
+ function l2Normalize(vec) {
91
+ const norm = Math.sqrt(vec.reduce((sum, v) => sum + v * v, 0));
92
+ if (norm === 0)
93
+ return vec;
94
+ return vec.map((v) => v / norm);
95
+ }
96
+ export function normalizeEmbeddingEndpoint(endpoint) {
97
+ let parsed;
98
+ try {
99
+ parsed = new URL(endpoint);
100
+ }
101
+ catch {
102
+ return endpoint;
103
+ }
104
+ const normalizedPath = parsed.pathname.replace(/\/+$/, "");
105
+ if (normalizedPath.endsWith("/embeddings")) {
106
+ return parsed.toString();
107
+ }
108
+ parsed.pathname = normalizedPath ? `${normalizedPath}/embeddings` : "/embeddings";
109
+ return parsed.toString();
110
+ }
111
+ function embeddingEndpointPathHint(endpoint) {
112
+ const normalizedEndpoint = normalizeEmbeddingEndpoint(endpoint);
113
+ if (normalizedEndpoint !== endpoint) {
114
+ return ` Check that your endpoint includes the full embeddings path (for example "${normalizedEndpoint}", not just "${endpoint}").`;
115
+ }
116
+ return "";
117
+ }
118
+ /** Check whether an EmbeddingConnectionConfig has a valid remote endpoint. */
119
+ export function hasRemoteEndpoint(config) {
120
+ return isHttpUrl(config.endpoint);
121
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Shared embedder types.
3
+ *
4
+ * Pulled out of `embedder.ts` so concrete implementations (`local.ts`,
5
+ * `remote.ts`) and the cache layer can depend on a small, stable types
6
+ * module without dragging in the facade or a sibling implementation.
7
+ */
8
+ /**
9
+ * Cosine similarity between two embedding vectors.
10
+ *
11
+ * Lives next to {@link EmbeddingVector} so importers (notably `db.ts`)
12
+ * can pull just the math without dragging in the embedder facade and its
13
+ * transitive `@huggingface/transformers` import chain.
14
+ *
15
+ * Returns 0 when the vectors have different dimensions — silently
16
+ * computing on a truncated view would produce meaningless scores.
17
+ */
18
+ export function cosineSimilarity(a, b) {
19
+ if (a.length !== b.length) {
20
+ warn("cosineSimilarity: vector dimension mismatch (%d vs %d) — re-index recommended", a.length, b.length);
21
+ return 0;
22
+ }
23
+ const len = a.length;
24
+ if (len === 0)
25
+ return 0;
26
+ let dot = 0;
27
+ let magA = 0;
28
+ let magB = 0;
29
+ for (let i = 0; i < len; i++) {
30
+ dot += a[i] * b[i];
31
+ magA += a[i] * a[i];
32
+ magB += b[i] * b[i];
33
+ }
34
+ const denom = Math.sqrt(magA) * Math.sqrt(magB);
35
+ return denom === 0 ? 0 : dot / denom;
36
+ }
37
+ // Imported lazily to keep this types module dependency-free where possible;
38
+ // `warn` is a thin printf wrapper so the cost is negligible.
39
+ import { warn } from "../warn";
package/dist/errors.js CHANGED
@@ -4,30 +4,41 @@
4
4
  * - ConfigError -> exit 78 (configuration / environment problems)
5
5
  * - UsageError -> exit 2 (bad CLI arguments or invalid input)
6
6
  * - NotFoundError -> exit 1 (requested resource missing)
7
+ *
8
+ * Each error carries a machine-readable `code` field. Codes are stable
9
+ * identifiers safe to consume from scripts and JSON output. Existing throw
10
+ * sites without an explicit code receive a default code per error class so
11
+ * older call sites continue to compile and behave unchanged.
7
12
  */
8
13
  /** Raised when configuration or environment is invalid or missing. */
9
14
  export class ConfigError extends Error {
10
- constructor(msg) {
15
+ code;
16
+ constructor(msg, code = "INVALID_CONFIG_FILE") {
11
17
  super(msg);
12
18
  this.name = "ConfigError";
19
+ this.code = code;
13
20
  // Fixes `instanceof` checks under ES5 transpilation targets.
14
21
  Object.setPrototypeOf(this, new.target.prototype);
15
22
  }
16
23
  }
17
24
  /** Raised when the user supplies invalid arguments or input. */
18
25
  export class UsageError extends Error {
19
- constructor(msg) {
26
+ code;
27
+ constructor(msg, code = "INVALID_FLAG_VALUE") {
20
28
  super(msg);
21
29
  this.name = "UsageError";
30
+ this.code = code;
22
31
  // Fixes `instanceof` checks under ES5 transpilation targets.
23
32
  Object.setPrototypeOf(this, new.target.prototype);
24
33
  }
25
34
  }
26
35
  /** Raised when a requested resource (asset, entry, file) is not found. */
27
36
  export class NotFoundError extends Error {
28
- constructor(msg) {
37
+ code;
38
+ constructor(msg, code = "ASSET_NOT_FOUND") {
29
39
  super(msg);
30
40
  this.name = "NotFoundError";
41
+ this.code = code;
31
42
  // Fixes `instanceof` checks under ES5 transpilation targets.
32
43
  Object.setPrototypeOf(this, new.target.prototype);
33
44
  }
@@ -11,10 +11,8 @@
11
11
  *
12
12
  * **Limitations**: This is a hand-rolled YAML-subset parser with intentional
13
13
  * constraints for simplicity and safety:
14
- * - **No list support**: YAML block sequences (`- item`) and flow arrays
15
- * (`[a, b, c]`) are silently ignored. List-valued frontmatter keys will
16
- * produce an empty string or be skipped. Callers must NOT rely on list-
17
- * valued frontmatter.
14
+ * - **List support**: YAML block sequences (`- item`) and flow arrays
15
+ * (`[a, b, c]`) are both supported for top-level list-valued keys.
18
16
  * - **No nested objects beyond one level**: Only a single level of indented
19
17
  * key-value pairs is supported.
20
18
  * - **Scalar values only**: string, boolean, and number scalars are supported.
@@ -26,28 +24,75 @@ export function parseFrontmatter(raw) {
26
24
  }
27
25
  const data = {};
28
26
  let currentKey = null;
27
+ /** "scalar" | "list" | "object" | "pending" — "pending" means empty value, mode determined by next line */
28
+ let mode = "scalar";
29
29
  let nested = null;
30
+ let currentList = null;
31
+ const flushPending = () => {
32
+ // Called when we start a new top-level key and the previous key was still "pending".
33
+ // An empty-value key followed by another top-level key means it was an empty scalar.
34
+ if (mode === "pending" && currentKey !== null) {
35
+ data[currentKey] = "";
36
+ }
37
+ };
30
38
  for (const line of parsedBlock.frontmatter.split(/\r?\n/)) {
39
+ // Block-sequence item: "- value" or " - value" (optional 2-space indent)
40
+ // Only match when the current key is in list or pending mode.
41
+ const seqItem = line.match(/^(?: {2})?- (.*)$/);
42
+ if (seqItem && currentKey !== null && (mode === "list" || mode === "pending")) {
43
+ if (mode === "pending") {
44
+ // First block-sequence item after an empty-value key — switch to list mode
45
+ currentList = [];
46
+ data[currentKey] = currentList;
47
+ mode = "list";
48
+ }
49
+ currentList.push(parseYamlScalar(seqItem[1].trim()));
50
+ continue;
51
+ }
52
+ // Indented nested key-value (object under a key with empty value)
31
53
  const indented = line.match(/^ {2}(\w[\w-]*):\s*(.+)$/);
32
- if (indented && currentKey && nested) {
54
+ if (indented && currentKey !== null && (mode === "object" || mode === "pending")) {
55
+ if (mode === "pending") {
56
+ // First indented k-v after an empty-value key — switch to object mode
57
+ nested = {};
58
+ data[currentKey] = nested;
59
+ mode = "object";
60
+ }
33
61
  nested[indented[1]] = parseYamlScalar(indented[2].trim());
34
62
  continue;
35
63
  }
64
+ // Top-level key (possibly with inline value)
36
65
  const top = line.match(/^(\w[\w-]*):\s*(.*)$/);
37
66
  if (!top) {
38
67
  continue;
39
68
  }
69
+ // Starting a new top-level key — flush any pending empty-value key
70
+ flushPending();
40
71
  currentKey = top[1];
41
72
  const value = top[2].trim();
42
73
  if (value === "") {
43
- nested = {};
44
- data[currentKey] = nested;
74
+ // Defer mode decision until we see the next line
75
+ mode = "pending";
76
+ nested = null;
77
+ currentList = null;
78
+ // Don't store anything yet — flushPending will set "" if no continuation
79
+ }
80
+ else if (value.startsWith("[") && value.endsWith("]")) {
81
+ // Inline flow array: tags: [ops, networking]
82
+ mode = "list";
83
+ nested = null;
84
+ currentList = parseFlowArray(value);
85
+ data[currentKey] = currentList;
45
86
  }
46
87
  else {
88
+ mode = "scalar";
47
89
  nested = null;
90
+ currentList = null;
48
91
  data[currentKey] = parseYamlScalar(value);
49
92
  }
50
93
  }
94
+ // Flush the last key if it was still pending (empty value, no continuation)
95
+ flushPending();
51
96
  return {
52
97
  data,
53
98
  content: parsedBlock.content,
@@ -55,6 +100,15 @@ export function parseFrontmatter(raw) {
55
100
  bodyStartLine: parsedBlock.bodyStartLine,
56
101
  };
57
102
  }
103
+ /**
104
+ * Parse a YAML flow array string like `[a, b, c]` into an array of scalars.
105
+ */
106
+ function parseFlowArray(value) {
107
+ const inner = value.slice(1, -1).trim();
108
+ if (!inner)
109
+ return [];
110
+ return inner.split(",").map((item) => parseYamlScalar(item.trim()));
111
+ }
58
112
  export function parseFrontmatterBlock(raw) {
59
113
  // Handle both LF and CRLF line endings throughout.
60
114
  // The closing --- may be preceded by \r\n; capture and strip trailing \r
package/dist/indexer.js CHANGED
@@ -95,9 +95,15 @@ export async function akmIndex(options) {
95
95
  : "LLM enhancement disabled.",
96
96
  });
97
97
  const tLlmEnd = Date.now();
98
- // Rebuild FTS after all inserts
99
- rebuildFts(db);
100
- onProgress({ phase: "fts", message: "Rebuilt full-text search index." });
98
+ // Rebuild FTS after all inserts. Use incremental mode when this whole
99
+ // index run is incremental — only entries touched by `upsertEntry`
100
+ // since the last rebuild are re-indexed, instead of re-scanning every
101
+ // row on every `akm index` invocation.
102
+ rebuildFts(db, { incremental: isIncremental });
103
+ onProgress({
104
+ phase: "fts",
105
+ message: isIncremental ? "Rebuilt full-text search index (dirty rows only)." : "Rebuilt full-text search index.",
106
+ });
101
107
  const tFtsEnd = Date.now();
102
108
  // Re-link detached usage_events to their new entry_ids via entry_ref.
103
109
  // entry_ref is "type:name" (e.g., "skill:code-review"), entry_key is "stashDir:type:name".
@@ -362,13 +368,17 @@ async function indexEntries(db, allStashSources, isIncremental, builtAtMs, doFul
362
368
  async function enhanceDirsWithLlm(db, config, dirsNeedingLlm) {
363
369
  if (!config.llm || dirsNeedingLlm.length === 0)
364
370
  return;
371
+ // Aggregate per-entry failures so a misconfigured LLM endpoint surfaces
372
+ // as a single visible warning instead of silently degrading every entry
373
+ // and leaving the user wondering why nothing got enhanced.
374
+ const summary = { attempted: 0, succeeded: 0, failureSamples: [] };
365
375
  for (const { dirPath, files, currentStashDir, stash: originalStash } of dirsNeedingLlm) {
366
376
  // Only enhance generated entries; user-provided overrides should not be overwritten
367
377
  const generatedEntries = originalStash.entries.filter((e) => e.quality === "generated");
368
378
  if (generatedEntries.length === 0)
369
379
  continue;
370
380
  const generatedStash = { entries: generatedEntries };
371
- const enhanced = await enhanceStashWithLlm(config.llm, generatedStash, files);
381
+ const enhanced = await enhanceStashWithLlm(config.llm, generatedStash, files, summary);
372
382
  // Re-upsert the enhanced entries in a single transaction so a crash
373
383
  // cannot leave half the entries updated and the rest stale.
374
384
  db.transaction(() => {
@@ -380,6 +390,16 @@ async function enhanceDirsWithLlm(db, config, dirsNeedingLlm) {
380
390
  }
381
391
  })();
382
392
  }
393
+ if (summary.attempted > 0 && summary.succeeded === 0) {
394
+ const sample = summary.failureSamples.length ? ` Example: ${summary.failureSamples[0]}` : "";
395
+ warn(`LLM enhancement failed for all ${summary.attempted} attempted entries — index built without LLM enrichment.` +
396
+ ` Check llm.endpoint and llm.model in your config.${sample}`);
397
+ }
398
+ else if (summary.attempted > 0 && summary.succeeded < summary.attempted) {
399
+ const failed = summary.attempted - summary.succeeded;
400
+ const sample = summary.failureSamples.length ? ` Examples: ${summary.failureSamples.join("; ")}` : "";
401
+ warn(`LLM enhancement failed for ${failed}/${summary.attempted} entries — they were left un-enhanced.${sample}`);
402
+ }
383
403
  }
384
404
  async function generateEmbeddingsForDb(db, config, onProgress) {
385
405
  if (config.semanticSearchMode === "off") {
@@ -569,10 +589,12 @@ function isDirStale(dirPath, currentFiles, previousEntries, builtAtMs) {
569
589
  }
570
590
  return false;
571
591
  }
572
- async function enhanceStashWithLlm(llmConfig, stash, files) {
592
+ async function enhanceStashWithLlm(llmConfig, stash, files, summary) {
573
593
  const { enhanceMetadata } = await import("./llm.js");
574
594
  const enhanced = [];
595
+ const seenSamples = new Set();
575
596
  for (const entry of stash.entries) {
597
+ summary.attempted++;
576
598
  try {
577
599
  const entryFile = entry.filename
578
600
  ? (files.find((f) => path.basename(f) === entry.filename) ?? files[0])
@@ -595,9 +617,15 @@ async function enhanceStashWithLlm(llmConfig, stash, files) {
595
617
  if (improvements.tags?.length)
596
618
  updated.tags = improvements.tags;
597
619
  enhanced.push(updated);
620
+ summary.succeeded++;
598
621
  }
599
- catch {
622
+ catch (err) {
600
623
  enhanced.push(entry);
624
+ const msg = err instanceof Error ? err.message : String(err);
625
+ if (summary.failureSamples.length < 3 && !seenSamples.has(msg)) {
626
+ summary.failureSamples.push(msg);
627
+ seenSamples.add(msg);
628
+ }
601
629
  }
602
630
  }
603
631
  return { entries: enhanced };
@@ -639,7 +667,10 @@ export function matchEntryToFile(entryName, fileMap, files) {
639
667
  // Fallback to first file, or null if no files are available
640
668
  return files[0] || null;
641
669
  }
642
- export { buildSearchFields, buildSearchText } from "./search-fields";
670
+ // `buildSearchFields` and `buildSearchText` were previously re-exported from
671
+ // here for backwards compatibility. Importers should now pull them directly
672
+ // from `./search-fields` to avoid loading the indexer's full dependency
673
+ // graph (LLM client, embedder facade) when only the text builder is needed.
643
674
  // ── Utility score recomputation ──────────────────────────────────────────────
644
675
  /** Retention window for usage events: events older than this are purged. */
645
676
  const USAGE_EVENT_RETENTION_DAYS = 90;
package/dist/info.js CHANGED
@@ -13,8 +13,8 @@ import { pkgVersion } from "./version";
13
13
  */
14
14
  export function assembleInfo(options) {
15
15
  const config = loadConfig();
16
- // Asset types
17
- const assetTypes = getAssetTypes();
16
+ // Asset types (copy into a mutable array — `getAssetTypes()` returns readonly)
17
+ const assetTypes = [...getAssetTypes()];
18
18
  const semanticRuntime = readSemanticStatus();
19
19
  const semanticStatus = getEffectiveSemanticStatus(config, semanticRuntime);
20
20
  // Search modes
@@ -346,16 +346,31 @@ function splitAllowedFindings(findings, ref, allowedFindings) {
346
346
  return { findings: active, waivedFindings: waived };
347
347
  }
348
348
  function matchesAllowedFinding(finding, ref, allowedFindings) {
349
+ // Normalize paths so a waiver written against `scripts/setup.sh` matches
350
+ // a finding emitted as `./scripts/setup.sh` or `scripts//setup.sh`. On
351
+ // Windows we also fold case, mirroring `isWithin`'s comparison rules.
352
+ const findingPathNormalized = normalizeWaiverPath(finding.file);
349
353
  return allowedFindings.some((allowed) => {
350
354
  if (allowed.id !== finding.id)
351
355
  return false;
352
356
  if (allowed.ref && allowed.ref !== ref)
353
357
  return false;
354
- if (allowed.path && allowed.path !== finding.file)
358
+ if (allowed.path && normalizeWaiverPath(allowed.path) !== findingPathNormalized)
355
359
  return false;
356
360
  return true;
357
361
  });
358
362
  }
363
+ function normalizeWaiverPath(value) {
364
+ if (!value)
365
+ return value;
366
+ // Strip a leading `./`, collapse duplicate slashes via path.normalize, and
367
+ // POSIX-ify so Windows path separators don't trigger spurious mismatches.
368
+ const normalized = path
369
+ .normalize(value)
370
+ .replace(/\\/g, "/")
371
+ .replace(/^\.\/+/, "");
372
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
373
+ }
359
374
  function addUrlLabels(labels, rawUrl) {
360
375
  if (!rawUrl)
361
376
  return;
@@ -10,9 +10,11 @@ import { resolveStashDir } from "./common";
10
10
  import { loadConfig } from "./config";
11
11
  import { NotFoundError, UsageError } from "./errors";
12
12
  import { akmIndex } from "./indexer";
13
+ import { auditInstallCandidate, deriveRegistryLabels, enforceRegistryInstallPolicy, formatInstallAuditFailure, } from "./install-audit";
13
14
  import { removeLockEntry, upsertLockEntry } from "./lockfile";
14
- import { installRegistryRef, removeInstalledRegistryEntry, upsertInstalledRegistryEntry } from "./registry-install";
15
15
  import { parseRegistryRef } from "./registry-resolve";
16
+ import { removeInstalledRegistryEntry, upsertInstalledRegistryEntry } from "./stash-add";
17
+ import { syncFromRef } from "./stash-providers/sync-from-ref";
16
18
  import { removeStash } from "./stash-source-manage";
17
19
  export async function akmListSources(input) {
18
20
  const stashDir = input?.stashDir ?? resolveStashDir();
@@ -64,7 +66,7 @@ export async function akmListSources(input) {
64
66
  export async function akmRemove(input) {
65
67
  const target = input.target.trim();
66
68
  if (!target)
67
- throw new UsageError("Target is required. Provide the source id, ref, path, URL, or name (e.g. `akm remove npm:@scope/kit` or `akm remove ~/my-stash`).");
69
+ throw new UsageError("Target is required. Provide the source id, ref, path, URL, or name (e.g. `akm remove npm:@scope/stash` or `akm remove ~/my-stash`).");
68
70
  const stashDir = input.stashDir ?? resolveStashDir();
69
71
  const config = loadConfig();
70
72
  const installed = config.installed ?? [];
@@ -138,26 +140,58 @@ export async function akmUpdate(input) {
138
140
  const force = input?.force === true;
139
141
  const installedEntries = loadConfig().installed ?? [];
140
142
  const selectedEntries = selectTargets(installedEntries, target, all);
143
+ const auditConfig = loadConfig();
141
144
  const processed = [];
142
145
  for (const entry of selectedEntries) {
143
146
  if (force && shouldCleanupCache(entry)) {
144
147
  cleanupDirectoryBestEffort(entry.cacheDir);
145
148
  }
146
- const installed = await installRegistryRef(entry.ref);
147
- upsertInstalledRegistryEntry(toInstalledEntry(installed));
149
+ const synced = await syncFromRef(entry.ref, { force });
150
+ // Mirror the post-sync audit hook from akmAdd so `akm update` can't
151
+ // silently land malicious content during refresh.
152
+ const registryLabels = deriveRegistryLabels({
153
+ source: synced.source,
154
+ ref: synced.ref,
155
+ artifactUrl: synced.artifactUrl,
156
+ });
157
+ enforceRegistryInstallPolicy(registryLabels, auditConfig, entry.ref);
158
+ const audit = auditInstallCandidate({
159
+ rootDir: synced.extractedDir,
160
+ source: synced.source,
161
+ ref: synced.ref,
162
+ registryLabels,
163
+ config: auditConfig,
164
+ });
165
+ if (audit.blocked) {
166
+ throw new Error(formatInstallAuditFailure(synced.ref, audit));
167
+ }
168
+ const installedEntry = {
169
+ id: synced.id,
170
+ source: synced.source,
171
+ ref: synced.ref,
172
+ artifactUrl: synced.artifactUrl,
173
+ resolvedVersion: synced.resolvedVersion,
174
+ resolvedRevision: synced.resolvedRevision,
175
+ stashRoot: synced.contentDir,
176
+ cacheDir: synced.cacheDir,
177
+ installedAt: synced.syncedAt,
178
+ writable: synced.writable ?? entry.writable,
179
+ ...(entry.wikiName ? { wikiName: entry.wikiName } : {}),
180
+ };
181
+ upsertInstalledRegistryEntry(installedEntry);
148
182
  await upsertLockEntry({
149
- id: installed.id,
150
- source: installed.source,
151
- ref: installed.ref,
152
- resolvedVersion: installed.resolvedVersion,
153
- resolvedRevision: installed.resolvedRevision,
154
- integrity: installed.integrity ?? (installed.source === "local" ? "local" : undefined),
183
+ id: synced.id,
184
+ source: synced.source,
185
+ ref: synced.ref,
186
+ resolvedVersion: synced.resolvedVersion,
187
+ resolvedRevision: synced.resolvedRevision,
188
+ integrity: synced.integrity ?? (synced.source === "local" ? "local" : undefined),
155
189
  });
156
- if (entry.cacheDir !== installed.cacheDir && shouldCleanupCache(entry)) {
190
+ if (entry.cacheDir !== synced.cacheDir && shouldCleanupCache(entry)) {
157
191
  cleanupDirectoryBestEffort(entry.cacheDir);
158
192
  }
159
- const versionChanged = (entry.resolvedVersion ?? "") !== (installed.resolvedVersion ?? "");
160
- const revisionChanged = (entry.resolvedRevision ?? "") !== (installed.resolvedRevision ?? "");
193
+ const versionChanged = (entry.resolvedVersion ?? "") !== (synced.resolvedVersion ?? "");
194
+ const revisionChanged = (entry.resolvedRevision ?? "") !== (synced.resolvedRevision ?? "");
161
195
  processed.push({
162
196
  id: entry.id,
163
197
  source: entry.source,
@@ -167,7 +201,7 @@ export async function akmUpdate(input) {
167
201
  resolvedRevision: entry.resolvedRevision,
168
202
  cacheDir: entry.cacheDir,
169
203
  },
170
- installed: toInstallStatus(installed),
204
+ installed: { ...installedEntry, extractedDir: synced.extractedDir, audit },
171
205
  changed: {
172
206
  version: versionChanged,
173
207
  revision: revisionChanged,
@@ -250,14 +284,6 @@ function tryResolveInstalledTarget(installed, target) {
250
284
  }
251
285
  return undefined;
252
286
  }
253
- function toInstalledEntry(status) {
254
- // KitInstallStatus extends InstalledKitEntry; omit transient install-only fields.
255
- const { extractedDir: _extractedDir, audit: _audit, ...base } = status;
256
- return base;
257
- }
258
- function toInstallStatus(status) {
259
- return { ...status };
260
- }
261
287
  function cleanupDirectoryBestEffort(target) {
262
288
  try {
263
289
  fs.rmSync(target, { recursive: true, force: true });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Low-level OpenAI-compatible chat completions client and capability probing.
3
+ *
4
+ * Split out of `llm.ts` to keep the transport-layer concerns (HTTP request,
5
+ * response parsing, JSON-fence stripping, capability probe, availability
6
+ * check) separate from higher-level metadata-enhancement workflows.
7
+ *
8
+ * `llm.ts` re-exports everything from this module for backward compatibility.
9
+ */
10
+ import { fetchWithTimeout } from "./common";
11
+ export async function chatCompletion(config, messages, options) {
12
+ const headers = { "Content-Type": "application/json" };
13
+ if (config.apiKey) {
14
+ headers.Authorization = `Bearer ${config.apiKey}`;
15
+ }
16
+ const response = await fetchWithTimeout(config.endpoint, {
17
+ method: "POST",
18
+ headers,
19
+ body: JSON.stringify({
20
+ model: config.model,
21
+ messages,
22
+ temperature: options?.temperature ?? config.temperature ?? 0.3,
23
+ max_tokens: options?.maxTokens ?? config.maxTokens ?? 512,
24
+ }),
25
+ });
26
+ if (!response.ok) {
27
+ const body = await response.text().catch(() => "");
28
+ throw new Error(`LLM request failed (${response.status}): ${body}`);
29
+ }
30
+ const json = (await response.json());
31
+ return json.choices?.[0]?.message?.content?.trim() ?? "";
32
+ }
33
+ /** Strip leading/trailing markdown code fences from an LLM response. */
34
+ export function stripJsonFences(raw) {
35
+ return raw
36
+ .trim()
37
+ .replace(/^```(?:json)?\s*\n?/i, "")
38
+ .replace(/\n?```\s*$/i, "")
39
+ .trim();
40
+ }
41
+ /** Parse a possibly-fenced JSON response. Returns undefined if invalid. */
42
+ export function parseJsonResponse(raw) {
43
+ try {
44
+ return JSON.parse(stripJsonFences(raw));
45
+ }
46
+ catch {
47
+ return undefined;
48
+ }
49
+ }
50
+ // ── Availability check ──────────────────────────────────────────────────────
51
+ /**
52
+ * Check if the LLM endpoint is reachable.
53
+ */
54
+ export async function isLlmAvailable(config) {
55
+ try {
56
+ const result = await chatCompletion(config, [{ role: "user", content: "Respond with just the word: ok" }]);
57
+ return result.length > 0;
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ }
63
+ // ── Capability probe ────────────────────────────────────────────────────────
64
+ /**
65
+ * Ask the model to emit a strict JSON object so we know whether the knowledge
66
+ * wiki ingest/lint flows can rely on structured output. Failure is non-fatal —
67
+ * the caller can fall back to assist-only mode.
68
+ */
69
+ export async function probeLlmCapabilities(config) {
70
+ try {
71
+ const raw = await chatCompletion(config, [
72
+ {
73
+ role: "system",
74
+ content: "You return only valid JSON. No prose, no markdown fences.",
75
+ },
76
+ {
77
+ role: "user",
78
+ content: 'Return exactly this JSON object and nothing else: {"ok": true, "ingest": true, "lint": true}',
79
+ },
80
+ ], { maxTokens: 64, temperature: 0 });
81
+ if (!raw)
82
+ return { reachable: false, structuredOutput: false, error: "empty response" };
83
+ const parsed = parseJsonResponse(raw);
84
+ return {
85
+ reachable: true,
86
+ structuredOutput: Boolean(parsed && parsed.ok === true),
87
+ };
88
+ }
89
+ catch (err) {
90
+ return { reachable: false, structuredOutput: false, error: err instanceof Error ? err.message : String(err) };
91
+ }
92
+ }