akm-cli 0.5.0 → 0.6.0-rc2

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 (118) hide show
  1. package/CHANGELOG.md +53 -5
  2. package/README.md +9 -9
  3. package/dist/cli.js +379 -1448
  4. package/dist/{completions.js → commands/completions.js} +1 -1
  5. package/dist/{config-cli.js → commands/config-cli.js} +109 -11
  6. package/dist/commands/curate.js +263 -0
  7. package/dist/{info.js → commands/info.js} +17 -11
  8. package/dist/{init.js → commands/init.js} +4 -4
  9. package/dist/{install-audit.js → commands/install-audit.js} +14 -2
  10. package/dist/{installed-kits.js → commands/installed-stashes.js} +122 -50
  11. package/dist/commands/migration-help.js +141 -0
  12. package/dist/{registry-search.js → commands/registry-search.js} +68 -9
  13. package/dist/commands/remember.js +178 -0
  14. package/dist/{stash-search.js → commands/search.js} +28 -69
  15. package/dist/{self-update.js → commands/self-update.js} +3 -3
  16. package/dist/{stash-show.js → commands/show.js} +106 -81
  17. package/dist/{stash-add.js → commands/source-add.js} +133 -67
  18. package/dist/{stash-clone.js → commands/source-clone.js} +15 -13
  19. package/dist/{stash-source-manage.js → commands/source-manage.js} +24 -24
  20. package/dist/{vault.js → commands/vault.js} +43 -0
  21. package/dist/{stash-ref.js → core/asset-ref.js} +4 -4
  22. package/dist/{asset-registry.js → core/asset-registry.js} +30 -6
  23. package/dist/{asset-spec.js → core/asset-spec.js} +13 -6
  24. package/dist/{common.js → core/common.js} +147 -50
  25. package/dist/{config.js → core/config.js} +288 -29
  26. package/dist/core/errors.js +90 -0
  27. package/dist/{frontmatter.js → core/frontmatter.js} +64 -8
  28. package/dist/{paths.js → core/paths.js} +4 -4
  29. package/dist/core/write-source.js +280 -0
  30. package/dist/{local-search.js → indexer/db-search.js} +49 -32
  31. package/dist/{db.js → indexer/db.js} +210 -81
  32. package/dist/{file-context.js → indexer/file-context.js} +3 -3
  33. package/dist/{indexer.js → indexer/indexer.js} +153 -30
  34. package/dist/{manifest.js → indexer/manifest.js} +10 -10
  35. package/dist/{matchers.js → indexer/matchers.js} +4 -7
  36. package/dist/{metadata.js → indexer/metadata.js} +9 -5
  37. package/dist/{search-source.js → indexer/search-source.js} +97 -55
  38. package/dist/{semantic-status.js → indexer/semantic-status.js} +2 -2
  39. package/dist/{walker.js → indexer/walker.js} +1 -1
  40. package/dist/{lockfile.js → integrations/lockfile.js} +29 -2
  41. package/dist/{llm.js → llm/client.js} +12 -48
  42. package/dist/llm/embedder.js +127 -0
  43. package/dist/llm/embedders/cache.js +47 -0
  44. package/dist/llm/embedders/local.js +152 -0
  45. package/dist/llm/embedders/remote.js +121 -0
  46. package/dist/llm/embedders/types.js +39 -0
  47. package/dist/llm/metadata-enhance.js +53 -0
  48. package/dist/output/cli-hints.js +301 -0
  49. package/dist/output/context.js +95 -0
  50. package/dist/{renderers.js → output/renderers.js} +57 -61
  51. package/dist/output/shapes.js +212 -0
  52. package/dist/output/text.js +520 -0
  53. package/dist/{registry-build-index.js → registry/build-index.js} +48 -32
  54. package/dist/{create-provider-registry.js → registry/create-provider-registry.js} +6 -2
  55. package/dist/registry/factory.js +33 -0
  56. package/dist/{origin-resolve.js → registry/origin-resolve.js} +1 -1
  57. package/dist/registry/providers/index.js +11 -0
  58. package/dist/{providers → registry/providers}/skills-sh.js +60 -4
  59. package/dist/{providers → registry/providers}/static-index.js +126 -56
  60. package/dist/registry/providers/types.js +25 -0
  61. package/dist/{registry-resolve.js → registry/resolve.js} +10 -6
  62. package/dist/{detect.js → setup/detect.js} +0 -27
  63. package/dist/{ripgrep-install.js → setup/ripgrep-install.js} +1 -1
  64. package/dist/{ripgrep-resolve.js → setup/ripgrep-resolve.js} +2 -2
  65. package/dist/{setup.js → setup/setup.js} +162 -129
  66. package/dist/setup/steps.js +45 -0
  67. package/dist/{kit-include.js → sources/include.js} +1 -1
  68. package/dist/sources/provider-factory.js +36 -0
  69. package/dist/sources/provider.js +21 -0
  70. package/dist/sources/providers/filesystem.js +35 -0
  71. package/dist/{stash-providers → sources/providers}/git.js +218 -28
  72. package/dist/{stash-providers → sources/providers}/index.js +4 -4
  73. package/dist/sources/providers/install-types.js +14 -0
  74. package/dist/sources/providers/npm.js +160 -0
  75. package/dist/sources/providers/provider-utils.js +173 -0
  76. package/dist/sources/providers/sync-from-ref.js +45 -0
  77. package/dist/sources/providers/tar-utils.js +154 -0
  78. package/dist/{stash-providers → sources/providers}/website.js +60 -20
  79. package/dist/{stash-resolve.js → sources/resolve.js} +13 -12
  80. package/dist/{wiki.js → wiki/wiki.js} +18 -17
  81. package/dist/{workflow-authoring.js → workflows/authoring.js} +48 -17
  82. package/dist/{workflow-cli.js → workflows/cli.js} +2 -1
  83. package/dist/{workflow-db.js → workflows/db.js} +1 -1
  84. package/dist/workflows/document-cache.js +20 -0
  85. package/dist/workflows/parser.js +379 -0
  86. package/dist/workflows/renderer.js +78 -0
  87. package/dist/{workflow-runs.js → workflows/runs.js} +84 -30
  88. package/dist/workflows/schema.js +11 -0
  89. package/dist/workflows/validator.js +48 -0
  90. package/docs/README.md +30 -0
  91. package/docs/migration/release-notes/0.0.13.md +4 -0
  92. package/docs/migration/release-notes/0.1.0.md +6 -0
  93. package/docs/migration/release-notes/0.2.0.md +6 -0
  94. package/docs/migration/release-notes/0.3.0.md +5 -0
  95. package/docs/migration/release-notes/0.5.0.md +6 -0
  96. package/docs/migration/release-notes/0.6.0.md +75 -0
  97. package/docs/migration/release-notes/README.md +21 -0
  98. package/package.json +3 -2
  99. package/dist/embedder.js +0 -351
  100. package/dist/errors.js +0 -34
  101. package/dist/migration-help.js +0 -110
  102. package/dist/registry-factory.js +0 -19
  103. package/dist/registry-install.js +0 -532
  104. package/dist/ripgrep.js +0 -2
  105. package/dist/stash-provider-factory.js +0 -35
  106. package/dist/stash-provider.js +0 -1
  107. package/dist/stash-providers/filesystem.js +0 -41
  108. package/dist/stash-providers/openviking.js +0 -348
  109. package/dist/stash-providers/provider-utils.js +0 -11
  110. package/dist/stash-types.js +0 -1
  111. package/dist/workflow-markdown.js +0 -251
  112. /package/dist/{markdown.js → core/markdown.js} +0 -0
  113. /package/dist/{warn.js → core/warn.js} +0 -0
  114. /package/dist/{search-fields.js → indexer/search-fields.js} +0 -0
  115. /package/dist/{usage-events.js → indexer/usage-events.js} +0 -0
  116. /package/dist/{github.js → integrations/github.js} +0 -0
  117. /package/dist/{registry-provider.js → registry/types.js} +0 -0
  118. /package/dist/{registry-types.js → sources/types.js} +0 -0
@@ -1,27 +1,28 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { isHttpUrl, resolveStashDir } from "./common";
3
+ import { isHttpUrl, resolveStashDir, toErrorMessage } from "../core/common";
4
+ import { getDbPath } from "../core/paths";
5
+ import { warn } from "../core/warn";
6
+ import { takeWorkflowDocument } from "../workflows/document-cache";
4
7
  import { closeDatabase, deleteEntriesByDir, deleteEntriesByStashDir, getEmbeddingCount, getEntriesByDir, getEntryCount, getMeta, isVecAvailable, openDatabase, rebuildFts, setMeta, upsertEmbedding, upsertEntry, upsertUtilityScore, warnIfVecMissing, } from "./db";
5
8
  import { generateMetadataFlat, loadStashFile, shouldIndexStashFile } from "./metadata";
6
- import { getDbPath } from "./paths";
7
9
  import { buildSearchText } from "./search-fields";
8
10
  import { classifySemanticFailure, clearSemanticStatus, deriveSemanticProviderFingerprint, writeSemanticStatus, } from "./semantic-status";
9
11
  import { ensureUsageEventsSchema, purgeOldUsageEvents } from "./usage-events";
10
12
  import { walkStashFlat } from "./walker";
11
- import { warn } from "./warn";
12
13
  // ── Indexer ──────────────────────────────────────────────────────────────────
13
14
  export async function akmIndex(options) {
14
15
  const stashDir = options?.stashDir || resolveStashDir();
15
16
  const onProgress = options?.onProgress ?? (() => { });
16
17
  // Load config and resolve all stash sources
17
- const { loadConfig } = await import("./config.js");
18
+ const { loadConfig } = await import("../core/config.js");
18
19
  const config = loadConfig();
19
20
  // Ensure git stash caches are extracted before resolving stash dirs,
20
21
  // so their content directories exist on disk for the walker to discover.
21
- const { ensureStashCaches, resolveStashSources } = await import("./search-source.js");
22
- await ensureStashCaches(config);
23
- const allStashSources = resolveStashSources(stashDir, config);
24
- const allStashDirs = allStashSources.map((s) => s.path);
22
+ const { ensureSourceCaches, resolveSourceEntries } = await import("./search-source.js");
23
+ await ensureSourceCaches(config);
24
+ const allSourceEntries = resolveSourceEntries(stashDir, config);
25
+ const allSourceDirs = allSourceEntries.map((s) => s.path);
25
26
  const t0 = Date.now();
26
27
  // Open database — pass embedding dimension from config if available
27
28
  const dbPath = getDbPath();
@@ -37,7 +38,7 @@ export async function akmIndex(options) {
37
38
  phase: "summary",
38
39
  message: buildIndexSummaryMessage({
39
40
  mode: isIncremental ? "incremental" : "full",
40
- stashSources: allStashDirs.length,
41
+ sourcesCount: allSourceDirs.length,
41
42
  semanticSearchMode: config.semanticSearchMode,
42
43
  embeddingProvider: getEmbeddingProvider(config.embedding),
43
44
  llmEnabled: !!config.llm,
@@ -67,7 +68,7 @@ export async function akmIndex(options) {
67
68
  catch {
68
69
  warn("index_meta stashDirs value is corrupt JSON — treating as empty");
69
70
  }
70
- const currentSet = new Set(allStashDirs);
71
+ const currentSet = new Set(allSourceDirs);
71
72
  for (const dir of prevStashDirs) {
72
73
  if (!currentSet.has(dir)) {
73
74
  deleteEntriesByStashDir(db, dir);
@@ -80,7 +81,7 @@ export async function akmIndex(options) {
80
81
  // doFullDelete=true merges the wipe into the same transaction as the
81
82
  // inserts so readers never see an empty database mid-rebuild.
82
83
  const doFullDelete = options?.full || !isIncremental;
83
- const { scannedDirs, skippedDirs, generatedCount, dirsNeedingLlm, warnings } = await indexEntries(db, allStashSources, isIncremental, builtAtMs, doFullDelete);
84
+ const { scannedDirs, skippedDirs, generatedCount, dirsNeedingLlm, warnings } = await indexEntries(db, allSourceEntries, isIncremental, builtAtMs, doFullDelete);
84
85
  onProgress({
85
86
  phase: "scan",
86
87
  message: `Scanned ${scannedDirs} ${scannedDirs === 1 ? "directory" : "directories"} and skipped ${skippedDirs}.`,
@@ -95,9 +96,15 @@ export async function akmIndex(options) {
95
96
  : "LLM enhancement disabled.",
96
97
  });
97
98
  const tLlmEnd = Date.now();
98
- // Rebuild FTS after all inserts
99
- rebuildFts(db);
100
- onProgress({ phase: "fts", message: "Rebuilt full-text search index." });
99
+ // Rebuild FTS after all inserts. Use incremental mode when this whole
100
+ // index run is incremental — only entries touched by `upsertEntry`
101
+ // since the last rebuild are re-indexed, instead of re-scanning every
102
+ // row on every `akm index` invocation.
103
+ rebuildFts(db, { incremental: isIncremental });
104
+ onProgress({
105
+ phase: "fts",
106
+ message: isIncremental ? "Rebuilt full-text search index (dirty rows only)." : "Rebuilt full-text search index.",
107
+ });
101
108
  const tFtsEnd = Date.now();
102
109
  // Re-link detached usage_events to their new entry_ids via entry_ref.
103
110
  // entry_ref is "type:name" (e.g., "skill:code-review"), entry_key is "stashDir:type:name".
@@ -124,7 +131,7 @@ export async function akmIndex(options) {
124
131
  // are read-only caches, and regenerating their indexes would mutate
125
132
  // cache content.
126
133
  try {
127
- const { regenerateAllWikiIndexes } = await import("./wiki.js");
134
+ const { regenerateAllWikiIndexes } = await import("../wiki/wiki.js");
128
135
  regenerateAllWikiIndexes(stashDir);
129
136
  }
130
137
  catch {
@@ -136,7 +143,7 @@ export async function akmIndex(options) {
136
143
  // Update metadata
137
144
  setMeta(db, "builtAt", new Date().toISOString());
138
145
  setMeta(db, "stashDir", stashDir);
139
- setMeta(db, "stashDirs", JSON.stringify(allStashDirs));
146
+ setMeta(db, "stashDirs", JSON.stringify(allSourceDirs));
140
147
  setMeta(db, "hasEmbeddings", embeddingResult.success ? "1" : "0");
141
148
  const totalEntries = getEntryCount(db);
142
149
  // Warn on every index run if using JS fallback with many entries
@@ -182,7 +189,7 @@ export async function akmIndex(options) {
182
189
  }
183
190
  }
184
191
  // ── Extracted helpers for indexing ────────────────────────────────────────────
185
- async function indexEntries(db, allStashSources, isIncremental, builtAtMs, doFullDelete = false) {
192
+ async function indexEntries(db, allSourceEntries, isIncremental, builtAtMs, doFullDelete = false) {
186
193
  let scannedDirs = 0;
187
194
  let skippedDirs = 0;
188
195
  let generatedCount = 0;
@@ -190,12 +197,12 @@ async function indexEntries(db, allStashSources, isIncremental, builtAtMs, doFul
190
197
  const seenPaths = new Set();
191
198
  const dirsNeedingLlm = [];
192
199
  const dirRecords = [];
193
- for (const stashSource of allStashSources) {
194
- const currentStashDir = stashSource.path;
200
+ for (const sourceAdded of allSourceEntries) {
201
+ const currentStashDir = sourceAdded.path;
195
202
  const fileContexts = walkStashFlat(currentStashDir);
196
203
  // Wiki-root stashes: all .md files are indexed as wiki pages under wikiName
197
- if (stashSource.wikiName) {
198
- const wikiName = stashSource.wikiName;
204
+ if (sourceAdded.wikiName) {
205
+ const wikiName = sourceAdded.wikiName;
199
206
  const wikiDirGroups = new Map();
200
207
  for (const ctx of fileContexts) {
201
208
  if (ctx.ext !== ".md")
@@ -347,7 +354,13 @@ async function indexEntries(db, allStashSources, isIncremental, builtAtMs, doFul
347
354
  const entryKey = `${currentStashDir}:${entry.type}:${entry.name}`;
348
355
  const searchText = buildSearchText(entry);
349
356
  const entryWithSize = attachFileSize(entry, entryPath);
350
- upsertEntry(db, entryKey, dirPath, entryPath, currentStashDir, entryWithSize, searchText);
357
+ const entryId = upsertEntry(db, entryKey, dirPath, entryPath, currentStashDir, entryWithSize, searchText);
358
+ if (entry.type === "workflow") {
359
+ const doc = takeWorkflowDocument(entry);
360
+ if (doc) {
361
+ upsertWorkflowDocument(db, entryId, doc, fs.readFileSync(entryPath));
362
+ }
363
+ }
351
364
  }
352
365
  // Collect dirs needing LLM enhancement during the first walk
353
366
  if (stash.entries.some((e) => e.quality === "generated")) {
@@ -362,13 +375,17 @@ async function indexEntries(db, allStashSources, isIncremental, builtAtMs, doFul
362
375
  async function enhanceDirsWithLlm(db, config, dirsNeedingLlm) {
363
376
  if (!config.llm || dirsNeedingLlm.length === 0)
364
377
  return;
378
+ // Aggregate per-entry failures so a misconfigured LLM endpoint surfaces
379
+ // as a single visible warning instead of silently degrading every entry
380
+ // and leaving the user wondering why nothing got enhanced.
381
+ const summary = { attempted: 0, succeeded: 0, failureSamples: [] };
365
382
  for (const { dirPath, files, currentStashDir, stash: originalStash } of dirsNeedingLlm) {
366
383
  // Only enhance generated entries; user-provided overrides should not be overwritten
367
384
  const generatedEntries = originalStash.entries.filter((e) => e.quality === "generated");
368
385
  if (generatedEntries.length === 0)
369
386
  continue;
370
387
  const generatedStash = { entries: generatedEntries };
371
- const enhanced = await enhanceStashWithLlm(config.llm, generatedStash, files);
388
+ const enhanced = await enhanceStashWithLlm(config.llm, generatedStash, files, summary);
372
389
  // Re-upsert the enhanced entries in a single transaction so a crash
373
390
  // cannot leave half the entries updated and the rest stale.
374
391
  db.transaction(() => {
@@ -380,6 +397,16 @@ async function enhanceDirsWithLlm(db, config, dirsNeedingLlm) {
380
397
  }
381
398
  })();
382
399
  }
400
+ if (summary.attempted > 0 && summary.succeeded === 0) {
401
+ const sample = summary.failureSamples.length ? ` Example: ${summary.failureSamples[0]}` : "";
402
+ warn(`LLM enhancement failed for all ${summary.attempted} attempted entries — index built without LLM enrichment.` +
403
+ ` Check llm.endpoint and llm.model in your config.${sample}`);
404
+ }
405
+ else if (summary.attempted > 0 && summary.succeeded < summary.attempted) {
406
+ const failed = summary.attempted - summary.succeeded;
407
+ const sample = summary.failureSamples.length ? ` Examples: ${summary.failureSamples.join("; ")}` : "";
408
+ warn(`LLM enhancement failed for ${failed}/${summary.attempted} entries — they were left un-enhanced.${sample}`);
409
+ }
383
410
  }
384
411
  async function generateEmbeddingsForDb(db, config, onProgress) {
385
412
  if (config.semanticSearchMode === "off") {
@@ -408,7 +435,7 @@ async function generateEmbeddingsForDb(db, config, onProgress) {
408
435
  setMeta(db, "hasEmbeddings", "0");
409
436
  }
410
437
  try {
411
- const { embedBatch } = await import("./embedder.js");
438
+ const { embedBatch } = await import("../llm/embedder.js");
412
439
  const allEntries = getAllEntriesForEmbedding(db);
413
440
  if (allEntries.length === 0) {
414
441
  onProgress({ phase: "embeddings", message: "Embeddings already up to date." });
@@ -466,10 +493,32 @@ function attachFileSize(entry, entryPath) {
466
493
  return entry;
467
494
  }
468
495
  }
496
+ function upsertWorkflowDocument(db, entryId, doc, content) {
497
+ const sourceHash = computeSourceHash(content);
498
+ db.prepare(`INSERT INTO workflow_documents (entry_id, schema_version, document_json, source_path, source_hash, updated_at)
499
+ VALUES (?, ?, ?, ?, ?, ?)
500
+ ON CONFLICT(entry_id) DO UPDATE SET
501
+ schema_version = excluded.schema_version,
502
+ document_json = excluded.document_json,
503
+ source_path = excluded.source_path,
504
+ source_hash = excluded.source_hash,
505
+ updated_at = excluded.updated_at`).run(entryId, doc.schemaVersion, JSON.stringify(doc), doc.source.path, sourceHash, new Date().toISOString());
506
+ }
507
+ function computeSourceHash(content) {
508
+ // Cheap, stable identity for the source markdown — used by future
509
+ // incremental fast-paths that skip re-validation when content is unchanged.
510
+ // Not security-sensitive; FNV-1a over the bytes is sufficient.
511
+ let hash = 0x811c9dc5;
512
+ for (let i = 0; i < content.length; i++) {
513
+ hash ^= content[i];
514
+ hash = Math.imul(hash, 0x01000193);
515
+ }
516
+ return (hash >>> 0).toString(16);
517
+ }
469
518
  function buildIndexSummaryMessage(options) {
470
- const stashSourceLabel = options.stashSources === 1 ? "stash source" : "stash sources";
519
+ const stashSourceLabel = options.sourcesCount === 1 ? "stash source" : "stash sources";
471
520
  const semanticDetail = getSemanticSearchLabel(options.semanticSearchMode, options.embeddingProvider, options.vecAvailable);
472
- return `Starting ${options.mode} index (${options.stashSources} ${stashSourceLabel}, semantic search: ${semanticDetail}, LLM: ${options.llmEnabled ? "enabled" : "disabled"}).`;
521
+ return `Starting ${options.mode} index (${options.sourcesCount} ${stashSourceLabel}, semantic search: ${semanticDetail}, LLM: ${options.llmEnabled ? "enabled" : "disabled"}).`;
473
522
  }
474
523
  function getEmbeddingProvider(embedding) {
475
524
  return isHttpUrl(embedding?.endpoint) ? "remote" : "local";
@@ -569,10 +618,11 @@ function isDirStale(dirPath, currentFiles, previousEntries, builtAtMs) {
569
618
  }
570
619
  return false;
571
620
  }
572
- async function enhanceStashWithLlm(llmConfig, stash, files) {
573
- const { enhanceMetadata } = await import("./llm.js");
621
+ async function enhanceStashWithLlm(llmConfig, stash, files, summary) {
622
+ const { enhanceMetadata } = await import("../llm/metadata-enhance");
574
623
  const enhanced = [];
575
624
  for (const entry of stash.entries) {
625
+ summary.attempted++;
576
626
  try {
577
627
  const entryFile = entry.filename
578
628
  ? (files.find((f) => path.basename(f) === entry.filename) ?? files[0])
@@ -595,9 +645,16 @@ async function enhanceStashWithLlm(llmConfig, stash, files) {
595
645
  if (improvements.tags?.length)
596
646
  updated.tags = improvements.tags;
597
647
  enhanced.push(updated);
648
+ summary.succeeded++;
598
649
  }
599
- catch {
650
+ catch (err) {
600
651
  enhanced.push(entry);
652
+ const msg = toErrorMessage(err);
653
+ // failureSamples is bounded to 3 items, so a linear scan is cheaper
654
+ // than maintaining a parallel Set for membership checks (#177 review).
655
+ if (summary.failureSamples.length < 3 && !summary.failureSamples.includes(msg)) {
656
+ summary.failureSamples.push(msg);
657
+ }
601
658
  }
602
659
  }
603
660
  return { entries: enhanced };
@@ -639,7 +696,73 @@ export function matchEntryToFile(entryName, fileMap, files) {
639
696
  // Fallback to first file, or null if no files are available
640
697
  return files[0] || null;
641
698
  }
642
- export { buildSearchFields, buildSearchText } from "./search-fields";
699
+ /**
700
+ * Look up a single asset by ref. Spec §6.2 — `akm show` queries this and
701
+ * reads the file from disk. The index is the source of truth for which
702
+ * file corresponds to which ref; the indexer walks `provider.path()` for
703
+ * every configured source, so this query covers all source kinds.
704
+ *
705
+ * Match rules:
706
+ * - `ref.origin === undefined` → first match across all sources (primary
707
+ * source first, then in declared order — same priority as the indexer's
708
+ * write order).
709
+ * - `ref.origin === "local"` → primary source only (entry_key prefix is
710
+ * the primary stash dir).
711
+ * - `ref.origin === <name>` → restrict to the matching source name. We
712
+ * resolve the source's directory and match on `entry_key` prefix.
713
+ *
714
+ * Returns `null` when no row matches — callers translate that into a
715
+ * `NotFoundError` with their own messaging.
716
+ */
717
+ export async function lookup(ref) {
718
+ const { loadConfig } = await import("../core/config.js");
719
+ const { resolveSourceEntries } = await import("./search-source.js");
720
+ const config = loadConfig();
721
+ const sources = resolveSourceEntries(undefined, config);
722
+ if (sources.length === 0)
723
+ return null;
724
+ const dbPath = getDbPath();
725
+ const db = openDatabase(dbPath);
726
+ try {
727
+ // entry_key shape: `${stashDir}:${type}:${name}`. Suffix-match on
728
+ // `:type:name` so we can scope by source dir as a prefix when origin is
729
+ // supplied. Use parameterised queries throughout — names may include
730
+ // user-supplied glob characters.
731
+ const escapeLike = (value) => value.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
732
+ const suffix = `:${ref.type}:${ref.name}`;
733
+ const escapedSuffix = escapeLike(suffix);
734
+ const candidateDirs = (() => {
735
+ if (!ref.origin)
736
+ return sources.map((s) => s.path);
737
+ if (ref.origin === "local")
738
+ return [sources[0].path];
739
+ const named = sources.find((s) => s.registryId === ref.origin);
740
+ return named ? [named.path] : [];
741
+ })();
742
+ if (candidateDirs.length === 0)
743
+ return null;
744
+ for (const dir of candidateDirs) {
745
+ const escapedDir = escapeLike(dir);
746
+ const row = db
747
+ .prepare("SELECT entry_key AS entryKey, file_path AS filePath, stash_dir AS stashDir, entry_type AS type FROM entries " +
748
+ "WHERE entry_key LIKE ? ESCAPE '\\' AND entry_type = ? LIMIT 1")
749
+ .get(`${escapedDir}${escapedSuffix}`, ref.type);
750
+ if (row) {
751
+ return {
752
+ entryKey: row.entryKey,
753
+ filePath: row.filePath,
754
+ stashDir: row.stashDir,
755
+ type: row.type,
756
+ name: ref.name,
757
+ };
758
+ }
759
+ }
760
+ return null;
761
+ }
762
+ finally {
763
+ closeDatabase(db);
764
+ }
765
+ }
643
766
  // ── Utility score recomputation ──────────────────────────────────────────────
644
767
  /** Retention window for usage events: events older than this are purged. */
645
768
  const USAGE_EVENT_RETENTION_DAYS = 90;
@@ -8,16 +8,16 @@
8
8
  */
9
9
  import fs from "node:fs";
10
10
  import path from "node:path";
11
- import { deriveCanonicalAssetNameFromStashRoot } from "./asset-spec";
12
- import { resolveStashDir } from "./common";
13
- import { loadConfig } from "./config";
11
+ import { makeAssetRef } from "../core/asset-ref";
12
+ import { deriveCanonicalAssetNameFromStashRoot } from "../core/asset-spec";
13
+ import { resolveStashDir } from "../core/common";
14
+ import { loadConfig } from "../core/config";
15
+ import { getDbPath } from "../core/paths";
16
+ import { warn } from "../core/warn";
14
17
  import { closeDatabase, getAllEntries, getEntryCount, getMeta, openDatabase } from "./db";
15
18
  import { generateMetadataFlat, loadStashFile } from "./metadata";
16
- import { getDbPath } from "./paths";
17
- import { resolveStashSources } from "./search-source";
18
- import { makeAssetRef } from "./stash-ref";
19
+ import { resolveSourceEntries } from "./search-source";
19
20
  import { walkStashFlat } from "./walker";
20
- import { warn } from "./warn";
21
21
  const MAX_DESCRIPTION_LENGTH = 80;
22
22
  /**
23
23
  * Truncate a description string to a maximum length, appending "..." if truncated.
@@ -99,9 +99,9 @@ function getManifestFromDb(stashDir, config, sources, type) {
99
99
  * Get the manifest by walking the stash directory (fallback when no index).
100
100
  */
101
101
  async function getManifestFromWalker(sources, type) {
102
- const allStashDirs = sources.map((s) => s.path);
102
+ const allSourceDirs = sources.map((s) => s.path);
103
103
  const entries = [];
104
- for (const currentStashDir of allStashDirs) {
104
+ for (const currentStashDir of allSourceDirs) {
105
105
  const fileContexts = walkStashFlat(currentStashDir);
106
106
  // Group by parent directory
107
107
  const dirGroups = new Map();
@@ -154,7 +154,7 @@ export async function akmManifest(options) {
154
154
  const stashDir = options?.stashDir ?? resolveStashDir();
155
155
  const type = options?.type;
156
156
  const config = loadConfig();
157
- const sources = resolveStashSources(stashDir, config);
157
+ const sources = resolveSourceEntries(stashDir, config);
158
158
  // Fast path: try database
159
159
  const dbEntries = getManifestFromDb(stashDir, config, sources, type);
160
160
  if (dbEntries !== null) {
@@ -18,7 +18,8 @@
18
18
  * - `wikiMatcher` (20) -- classifies any `.md` under `wikis/<name>/…` as
19
19
  * `wiki`. Registered last so the later-wins tiebreaker beats agent at 20.
20
20
  */
21
- import { SCRIPT_EXTENSIONS } from "./asset-spec";
21
+ import { SCRIPT_EXTENSIONS } from "../core/asset-spec";
22
+ import { looksLikeWorkflow } from "../workflows/parser";
22
23
  import { registerMatcher } from "./file-context";
23
24
  // ── extensionMatcher (specificity: 3) ────────────────────────────────────────
24
25
  /**
@@ -51,7 +52,7 @@ export function extensionMatcher(ctx) {
51
52
  * directory segment from the stash root matches a known type name.
52
53
  *
53
54
  * The first matching type-like ancestor wins. This preserves intuitive
54
- * behavior for nested kit layouts such as `agent-stash/agents/blog/foo.md`
55
+ * behavior for nested stash layouts such as `agent-stash/agents/blog/foo.md`
55
56
  * while still honoring earlier type roots like `commands/agents/foo.md`.
56
57
  */
57
58
  export function directoryMatcher(ctx) {
@@ -140,11 +141,7 @@ export function smartMdMatcher(ctx) {
140
141
  if (ctx.ext !== ".md")
141
142
  return null;
142
143
  const body = ctx.content();
143
- const hasWorkflowSignals = /^#\s+Workflow:\s+/m.test(body) &&
144
- /^##\s+Step:\s+/m.test(body) &&
145
- /^Step ID:\s+/m.test(body) &&
146
- /^###\s+Instructions\s*$/m.test(body);
147
- if (hasWorkflowSignals) {
144
+ if (looksLikeWorkflow(body)) {
148
145
  return { type: "workflow", specificity: 19, renderer: "workflow-md" };
149
146
  }
150
147
  const fm = ctx.frontmatter();
@@ -1,10 +1,10 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { deriveCanonicalAssetName, deriveCanonicalAssetNameFromStashRoot, isRelevantAssetFile } from "./asset-spec";
4
- import { isAssetType } from "./common";
3
+ import { deriveCanonicalAssetName, deriveCanonicalAssetNameFromStashRoot, isRelevantAssetFile, } from "../core/asset-spec";
4
+ import { isAssetType } from "../core/common";
5
+ import { parseFrontmatter, toStringOrUndefined } from "../core/frontmatter";
6
+ import { warn } from "../core/warn";
5
7
  import { buildFileContext, buildRenderContext, getRenderer, runMatchers } from "./file-context";
6
- import { parseFrontmatter, toStringOrUndefined } from "./frontmatter";
7
- import { warn } from "./warn";
8
8
  // ── Load / Write ────────────────────────────────────────────────────────────
9
9
  const STASH_FILENAME = ".stash.json";
10
10
  export function stashFilePath(dirPath) {
@@ -568,7 +568,11 @@ export async function generateMetadataFlat(stashRoot, files) {
568
568
  }
569
569
  function buildMetadataSkipWarning(filePath, assetType, error) {
570
570
  const detail = error instanceof Error ? error.message : String(error);
571
- const warning = `Skipped malformed ${assetType} asset at ${filePath}: ${detail}`;
571
+ // Workflow errors are already multi-line `path:line message` blocks; print
572
+ // them as-is so the author sees a flat list without a redundant prefix.
573
+ const warning = assetType === "workflow"
574
+ ? `Skipped workflow ${filePath}:\n${detail}`
575
+ : `Skipped malformed ${assetType} asset at ${filePath}: ${detail}`;
572
576
  warn(warning);
573
577
  return warning;
574
578
  }
@@ -1,21 +1,35 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { resolveStashDir } from "./common";
4
- import { loadConfig } from "./config";
5
- import { ensureGitMirror, getCachePaths, parseGitRepoUrl } from "./stash-providers/git";
6
- import { ensureWebsiteMirror, getCachePaths as getWebsiteCachePaths } from "./stash-providers/website";
7
- import { warn } from "./warn";
3
+ import { resolveStashDir } from "../core/common";
4
+ import { loadConfig } from "../core/config";
5
+ import { resolveSourceProviderFactory } from "../sources/provider-factory";
6
+ // Eager side-effect imports so all built-in source providers self-register
7
+ // before resolveEntryContentDir() runs.
8
+ import "../sources/providers/index";
9
+ import { warn } from "../core/warn";
10
+ import { ensureGitMirror, getCachePaths, parseGitRepoUrl } from "../sources/providers/git";
11
+ import { ensureWebsiteMirror } from "../sources/providers/website";
12
+ // Legacy "context-hub" / "github" type aliases are normalized to "git" at
13
+ // config-load time (see src/config.ts), so this set only contains the canonical
14
+ // type.
15
+ const GIT_STASH_TYPES = new Set(["git"]);
8
16
  // ── Resolution ──────────────────────────────────────────────────────────────
9
17
  /**
10
- * Build the ordered list of stash sources:
11
- * 1. Primary stash dir (user's own, destination for clone)
12
- * 2. Additional stashes (filesystem and remote providers)
13
- * 3. Installed kit paths (cache-managed, from registry)
18
+ * Build the ordered list of stash sources, walking every configured stash
19
+ * once. Iteration order:
14
20
  *
15
- * The first entry is always the primary stash. Additional entries come
16
- * from `stashes` config and `installed` kit entries.
21
+ * 1. The primary stash directory (the entry marked `primary: true`, or the
22
+ * legacy top-level `stashDir`). Always emitted, even when the directory
23
+ * does not yet exist on disk, so callers can use it as the clone target.
24
+ * 2. Each entry in `config.sources ?? config.stashes[]` (in declared order), excluding the
25
+ * one already emitted as the primary.
26
+ * 3. Each entry in `config.installed[]` (registry-managed stashes).
27
+ *
28
+ * Replaces the previous four-pass loop that walked `stashes[]` separately
29
+ * for each provider kind. Disabled entries (`enabled: false`) and entries
30
+ * whose disk path doesn't exist are filtered after deduplication.
17
31
  */
18
- export function resolveStashSources(overrideStashDir, existingConfig) {
32
+ export function resolveSourceEntries(overrideStashDir, existingConfig) {
19
33
  const stashDir = overrideStashDir ?? resolveStashDir();
20
34
  const config = existingConfig ?? loadConfig();
21
35
  const sources = [{ path: stashDir }];
@@ -36,53 +50,86 @@ export function resolveStashSources(overrideStashDir, existingConfig) {
36
50
  });
37
51
  }
38
52
  };
39
- // Filesystem entries from stashes[]
40
- for (const entry of config.stashes ?? []) {
41
- if (entry.type === "filesystem" && entry.path && entry.enabled !== false) {
42
- addSource(entry.path, entry.name, entry.wikiName);
43
- }
53
+ // (1) + (2) Single pass over declared stashes — primary first if present,
54
+ // then the rest in declared order. The primary's directory is already
55
+ // injected as `sources[0]` above, so we only need to dedupe the source set.
56
+ const stashes = config.sources ?? config.stashes ?? [];
57
+ const primaryIdx = stashes.findIndex((entry) => entry.primary === true);
58
+ const ordered = [];
59
+ if (primaryIdx >= 0) {
60
+ ordered.push(stashes[primaryIdx]);
61
+ stashes.forEach((entry, i) => {
62
+ if (i !== primaryIdx)
63
+ ordered.push(entry);
64
+ });
44
65
  }
45
- // Git stash entries: resolve cache directory so the indexer can walk it.
46
- // "git" provider type (and its legacy aliases "context-hub", "github") are handled.
47
- for (const entry of config.stashes ?? []) {
48
- if (GIT_STASH_TYPES.has(entry.type) && entry.url && entry.enabled !== false) {
49
- try {
50
- const repo = parseGitRepoUrl(entry.url);
51
- const cachePaths = getCachePaths(repo.canonicalUrl);
52
- // The content/ subdirectory inside the extracted repo is the actual
53
- // stash root containing DOC.md / SKILL.md files that the walker indexes.
54
- const contentDir = path.join(cachePaths.repoDir, "content");
55
- addSource(contentDir, entry.name, entry.wikiName);
56
- }
57
- catch (err) {
58
- warn(`Warning: failed to resolve git stash cache for "${entry.url}": ${err instanceof Error ? err.message : String(err)}`);
59
- }
60
- }
66
+ else {
67
+ ordered.push(...stashes);
61
68
  }
62
- // Website stash entries: resolve cache directory so the indexer can walk
63
- // the scraped markdown snapshots.
64
- for (const entry of config.stashes ?? []) {
65
- if (entry.type === "website" && entry.url && entry.enabled !== false) {
66
- try {
67
- const cachePaths = getWebsiteCachePaths(entry.url);
68
- addSource(cachePaths.stashDir, entry.name ?? entry.url, entry.wikiName);
69
- }
70
- catch (err) {
71
- warn(`Warning: failed to resolve website stash cache for "${entry.url}": ${err instanceof Error ? err.message : String(err)}`);
72
- }
73
- }
69
+ for (const entry of ordered) {
70
+ if (entry.enabled === false)
71
+ continue;
72
+ const dir = resolveEntryContentDir(entry);
73
+ if (dir == null)
74
+ continue;
75
+ addSource(dir, entry.name, entry.wikiName);
74
76
  }
75
- // Installed kits (registry and local)
77
+ // (3) Installed stashes (registry-managed). Always last.
76
78
  for (const entry of config.installed ?? []) {
77
79
  addSource(entry.stashRoot, entry.id, entry.wikiName);
78
80
  }
79
81
  return sources;
80
82
  }
83
+ /**
84
+ * Resolve the content directory the indexer should walk for a given config
85
+ * entry. Returns `undefined` if the entry has no walkable content
86
+ * so the caller can skip it.
87
+ *
88
+ * Single source of truth: each provider owns its own path. We instantiate the
89
+ * registered {@link import("../sources/provider").SourceProvider} for the entry
90
+ * and call `provider.path()`. This replaces the old per-kind switch ladder
91
+ * (filesystem path / git cache / website cache) that lived here in 0.6.0 —
92
+ * see spec §10 step 4 and §7 "Removed from 0.6.0".
93
+ *
94
+ * The git case still does one extra step: the provider returns the cloned
95
+ * repo dir, but the indexer walks the `content/` subdirectory inside it.
96
+ * That convention is part of the akm content layout, not a provider concern,
97
+ * so it stays here.
98
+ */
99
+ function resolveEntryContentDir(entry) {
100
+ const factory = resolveSourceProviderFactory(entry.type);
101
+ if (!factory)
102
+ return undefined;
103
+ let provider;
104
+ try {
105
+ provider = factory(entry);
106
+ }
107
+ catch (err) {
108
+ warn(`Warning: failed to construct ${entry.type} source provider for "${entry.name ?? entry.url ?? entry.path}": ${err instanceof Error ? err.message : String(err)}`);
109
+ return undefined;
110
+ }
111
+ let dir;
112
+ try {
113
+ dir = provider.path();
114
+ }
115
+ catch (err) {
116
+ warn(`Warning: failed to resolve ${entry.type} source path for "${entry.name ?? entry.url ?? entry.path}": ${err instanceof Error ? err.message : String(err)}`);
117
+ return undefined;
118
+ }
119
+ // Git providers expose the cloned repo root as their path. The akm content
120
+ // layout puts indexable files under `<repo>/content/`, so the walker needs
121
+ // that subdirectory. This is a content-layout convention, not a provider
122
+ // capability — keep it here.
123
+ if (GIT_STASH_TYPES.has(entry.type)) {
124
+ return path.join(dir, "content");
125
+ }
126
+ return dir;
127
+ }
81
128
  /**
82
129
  * Convenience: returns just the directory paths, preserving priority order.
83
130
  */
84
131
  export function resolveAllStashDirs(overrideStashDir) {
85
- return resolveStashSources(overrideStashDir).map((s) => s.path);
132
+ return resolveSourceEntries(overrideStashDir).map((s) => s.path);
86
133
  }
87
134
  /**
88
135
  * Find which source a file path belongs to.
@@ -168,15 +215,14 @@ function isValidDirectory(dir) {
168
215
  return false;
169
216
  }
170
217
  }
171
- // ── Git stash cache integration ──────────────────────────────────────────────
172
- const GIT_STASH_TYPES = new Set(["context-hub", "github", "git"]);
218
+ // ── Stash cache integration ─────────────────────────────────────────────────
173
219
  /**
174
220
  * Ensure all cache-backed stash providers are refreshed so their cache
175
221
  * directories exist on disk. Must be called (async) before
176
- * `resolveStashSources()` so the content directories pass the
222
+ * `resolveSourceEntries()` so the content directories pass the
177
223
  * `isValidDirectory()` check.
178
224
  */
179
- export async function ensureStashCaches(config) {
225
+ export async function ensureSourceCaches(config) {
180
226
  const cfg = config ?? loadConfig();
181
227
  for (const entry of cfg.stashes ?? []) {
182
228
  if (!GIT_STASH_TYPES.has(entry.type) || !entry.url || entry.enabled === false)
@@ -201,7 +247,3 @@ export async function ensureStashCaches(config) {
201
247
  }
202
248
  }
203
249
  }
204
- /** @deprecated Use ensureStashCaches instead. */
205
- export const ensureGitCaches = ensureStashCaches;
206
- /** @deprecated Use ensureStashCaches instead. */
207
- export const ensureContextHubCaches = ensureStashCaches;
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { DEFAULT_LOCAL_MODEL } from "./embedder";
4
- import { getCacheDir, getSemanticStatusPath } from "./paths";
3
+ import { getCacheDir, getSemanticStatusPath } from "../core/paths";
4
+ import { DEFAULT_LOCAL_MODEL } from "../llm/embedder";
5
5
  export function deriveSemanticProviderFingerprint(embedding) {
6
6
  if (embedding?.endpoint) {
7
7
  return `remote:${embedding.endpoint}|${embedding.model}|${embedding.dimension ?? "default"}`;
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import fs from "node:fs";
9
9
  import path from "node:path";
10
- import { isRelevantAssetFile } from "./asset-spec";
10
+ import { isRelevantAssetFile } from "../core/asset-spec";
11
11
  import { buildFileContext } from "./file-context";
12
12
  const SKIP_DIRS = new Set([".git", "node_modules", "bin", ".cache"]);
13
13
  /**