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
@@ -19,7 +19,7 @@ export function getConfigDir(env = process.env, platform = process.platform) {
19
19
  return path.join(appData, "akm");
20
20
  const userProfile = env.USERPROFILE?.trim();
21
21
  if (!userProfile) {
22
- throw new ConfigError("Unable to determine config directory. Set APPDATA or USERPROFILE.");
22
+ throw new ConfigError("Unable to determine config directory. Set APPDATA or USERPROFILE.", "CONFIG_DIR_UNRESOLVABLE");
23
23
  }
24
24
  return path.join(userProfile, "AppData", "Roaming", "akm");
25
25
  }
@@ -28,7 +28,7 @@ export function getConfigDir(env = process.env, platform = process.platform) {
28
28
  return path.join(xdgConfigHome, "akm");
29
29
  const home = env.HOME?.trim();
30
30
  if (!home) {
31
- throw new ConfigError("Unable to determine config directory. Set XDG_CONFIG_HOME or HOME.");
31
+ throw new ConfigError("Unable to determine config directory. Set XDG_CONFIG_HOME or HOME.", "CONFIG_DIR_UNRESOLVABLE");
32
32
  }
33
33
  return path.join(home, ".config", "akm");
34
34
  }
@@ -49,7 +49,7 @@ export function getCacheDir() {
49
49
  return path.join(userProfile, "AppData", "Local", "akm");
50
50
  const appData = process.env.APPDATA?.trim();
51
51
  if (!appData) {
52
- throw new ConfigError("Unable to determine cache directory. Set LOCALAPPDATA, USERPROFILE, or APPDATA.");
52
+ throw new ConfigError("Unable to determine cache directory. Set LOCALAPPDATA, USERPROFILE, or APPDATA.", "CONFIG_DIR_UNRESOLVABLE");
53
53
  }
54
54
  // Heuristic fallback: APPDATA points to %APPDATA% (Roaming), so navigate
55
55
  // to the sibling "Local" directory. This is typically
@@ -96,7 +96,7 @@ export function getDefaultStashDir() {
96
96
  }
97
97
  const home = process.env.HOME?.trim();
98
98
  if (!home) {
99
- throw new ConfigError("Unable to determine default stash directory. Set HOME.");
99
+ throw new ConfigError("Unable to determine default stash directory. Set HOME.", "STASH_DIR_NOT_FOUND");
100
100
  }
101
101
  return path.join(home, "akm");
102
102
  }
@@ -0,0 +1,280 @@
1
+ /**
2
+ * write-source — the only place in the codebase that branches on `source.kind`.
3
+ *
4
+ * v1 architecture spec §2.6 / §2.7 / §10 step 5: writing to a source is *not*
5
+ * a SourceProvider interface concern. It's a small command-layer helper that
6
+ * does a plain filesystem write, plus a git-specific commit (and optional
7
+ * push) when the source is backed by a git working tree.
8
+ *
9
+ * If a third kind ever needs special write handling, it gets added here. For
10
+ * v1 there are exactly two cases. Adding more parallel scoring systems for
11
+ * different provider kinds is explicitly disallowed by CLAUDE.md.
12
+ *
13
+ * This module is the **single dispatch point** for `kind`-branching write
14
+ * logic. Callers (remember, import, source-add, etc.) MUST go through
15
+ * `writeAssetToSource` / `deleteAssetFromSource` rather than re-inlining the
16
+ * filesystem-write + git-commit dance.
17
+ */
18
+ import { spawnSync } from "node:child_process";
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import { getCachePaths, parseGitRepoUrl } from "../sources/providers/git";
22
+ import { makeAssetRef } from "./asset-ref";
23
+ import { resolveAssetPathFromName, TYPE_DIRS } from "./asset-spec";
24
+ import { isWithin, resolveStashDir } from "./common";
25
+ import { resolveConfiguredSources } from "./config";
26
+ import { ConfigError, UsageError } from "./errors";
27
+ /**
28
+ * Source kinds that the loader is allowed to mark `writable: true`. Anything
29
+ * else is rejected at config load (per locked decision 4) — see
30
+ * {@link assertWritableAllowedForKind}.
31
+ */
32
+ const REJECTED_WRITABLE_KINDS = new Set(["website", "npm"]);
33
+ // ── Public helpers ──────────────────────────────────────────────────────────
34
+ /**
35
+ * Resolve the effective `writable` flag for a source config entry, applying
36
+ * the v1 default policy from spec §5.4:
37
+ *
38
+ * - `filesystem` → `true` by default
39
+ * - everything else → `false` by default
40
+ *
41
+ * Users can opt out for `filesystem` via `writable: false`. They cannot opt
42
+ * **in** for `website` / `npm` — that combination is rejected at config load
43
+ * (see {@link assertWritableAllowedForKind}).
44
+ */
45
+ export function resolveWritable(entry) {
46
+ if (typeof entry.writable === "boolean")
47
+ return entry.writable;
48
+ return entry.type === "filesystem";
49
+ }
50
+ /**
51
+ * Reject `writable: true` on `website` / `npm` sources at config-load time.
52
+ * Per locked decision 4 (§6 of the v1 implementation plan): `sync()` would
53
+ * clobber writes on the next refresh, so allowing writes here is a footgun.
54
+ *
55
+ * Throws {@link ConfigError} when the combination is rejected.
56
+ */
57
+ export function assertWritableAllowedForKind(entry) {
58
+ if (entry.writable !== true)
59
+ return;
60
+ if (REJECTED_WRITABLE_KINDS.has(entry.type)) {
61
+ const label = entry.name ? ` "${entry.name}"` : "";
62
+ throw new ConfigError(`writable: true is only supported on filesystem and git sources (got "${entry.type}" on source${label}).`, "INVALID_CONFIG_FILE", "To author into a checked-out package, add the same path as a separate filesystem source.");
63
+ }
64
+ }
65
+ /**
66
+ * Write a textual asset (`content`) into `source` at the path implied by
67
+ * `ref`. Always:
68
+ *
69
+ * 1. Refuses if `config.writable` is not truthy (per §5.4).
70
+ * 2. Performs a plain filesystem write to `path.join(source.path, …)`.
71
+ *
72
+ * For sources of `kind === "git"`, additionally:
73
+ *
74
+ * 3. `git -C <path> add <file>`
75
+ * 4. `git -C <path> commit -m "Update <ref>"`
76
+ * 5. `git -C <path> push` when `config.options.pushOnCommit` is truthy.
77
+ *
78
+ * Any other `kind` reaching this helper is a configuration bug — the loader
79
+ * rejects unsupported writable kinds — so we throw {@link ConfigError}.
80
+ */
81
+ export async function writeAssetToSource(source, config, ref, content) {
82
+ ensureWritable(source, config);
83
+ const filePath = resolveAssetFilePath(source, ref);
84
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
85
+ const normalized = content.endsWith("\n") ? content : `${content}\n`;
86
+ fs.writeFileSync(filePath, normalized, "utf8");
87
+ await runKindSpecificCommit(source, config, filePath, `Update ${formatRefForMessage(ref)}`);
88
+ return { path: filePath, ref: makeAssetRef(ref.type, ref.name, ref.origin) };
89
+ }
90
+ /**
91
+ * Delete the asset at `ref` from `source`. Symmetric to
92
+ * {@link writeAssetToSource}: same writable check, same git-commit-and-push
93
+ * convenience for `kind === "git"`.
94
+ */
95
+ export async function deleteAssetFromSource(source, config, ref) {
96
+ ensureWritable(source, config);
97
+ const filePath = resolveAssetFilePath(source, ref);
98
+ if (!fs.existsSync(filePath)) {
99
+ throw new UsageError(`Asset "${formatRefForMessage(ref)}" not found in source "${source.name}" (expected at ${filePath}).`, "MISSING_REQUIRED_ARGUMENT");
100
+ }
101
+ fs.unlinkSync(filePath);
102
+ await runKindSpecificCommit(source, config, filePath, `Remove ${formatRefForMessage(ref)}`);
103
+ return { path: filePath, ref: makeAssetRef(ref.type, ref.name, ref.origin) };
104
+ }
105
+ /**
106
+ * Resolve the destination for a write per locked decision 3:
107
+ *
108
+ * 1. Explicit `--target <name>` (when supplied)
109
+ * 2. `config.defaultWriteTarget`
110
+ * 3. `config.stashDir` (the working stash created by `akm init`)
111
+ * 4. `ConfigError("no writable source configured; run `akm init`")`
112
+ *
113
+ * The legacy `first-writable-in-source-array-order` fallback is *not* used —
114
+ * see plan §6 decision 3 for the rationale.
115
+ */
116
+ export function resolveWriteTarget(akmConfig, explicitTarget) {
117
+ const configuredSources = resolveConfiguredSources(akmConfig);
118
+ // 1. Explicit --target wins.
119
+ if (explicitTarget) {
120
+ const match = configuredSources.find((s) => s.name === explicitTarget);
121
+ if (!match) {
122
+ throw new UsageError(`--target must reference a source name from your config. No source named "${explicitTarget}" is configured. Run \`akm list\` to see available sources.`, "INVALID_FLAG_VALUE");
123
+ }
124
+ // Up-front writable check so an explicit --target fails fast with a
125
+ // ConfigError (rather than the generic UsageError ensureWritable would
126
+ // raise after we've already started building paths). Resolve the
127
+ // effective writable flag (filesystem defaults to true; everything else
128
+ // defaults to false) so unset values are interpreted correctly.
129
+ const effectiveWritable = resolveWritable({ type: match.type, writable: match.writable });
130
+ if (!effectiveWritable) {
131
+ throw new ConfigError(`source ${explicitTarget} is not writable`, "INVALID_CONFIG_FILE", `Set \`writable: true\` on the "${explicitTarget}" source in your config, or pass --target to a different source.`);
132
+ }
133
+ return adaptConfiguredSource(match);
134
+ }
135
+ // 2. config.defaultWriteTarget.
136
+ if (akmConfig.defaultWriteTarget) {
137
+ const match = configuredSources.find((s) => s.name === akmConfig.defaultWriteTarget);
138
+ if (match)
139
+ return adaptConfiguredSource(match);
140
+ // Fall through if the named target no longer exists — surface a clear error.
141
+ throw new ConfigError(`defaultWriteTarget "${akmConfig.defaultWriteTarget}" does not match any configured source.`, "INVALID_CONFIG_FILE", "Update `defaultWriteTarget` in your config (run `akm config get defaultWriteTarget`) or run `akm list` to see configured sources.");
142
+ }
143
+ // 3. Working stash (config.stashDir / resolveStashDir()).
144
+ try {
145
+ const stashDir = resolveStashDir({ readOnly: true });
146
+ return {
147
+ source: { kind: "filesystem", name: "stash", path: stashDir },
148
+ config: { type: "filesystem", path: stashDir, name: "stash", writable: true },
149
+ };
150
+ }
151
+ catch {
152
+ // Fall through to the final ConfigError below.
153
+ }
154
+ // 4. Nothing usable.
155
+ throw new ConfigError("no writable source configured; run `akm init`", "STASH_DIR_NOT_FOUND", "Run `akm init` to create a working stash, or set `defaultWriteTarget` in your config.");
156
+ }
157
+ // ── Internals ───────────────────────────────────────────────────────────────
158
+ function ensureWritable(source, config) {
159
+ // Apply the same default-resolution rule as resolveWritable so callers can
160
+ // pass through a SourceConfigEntry with an absent `writable` field.
161
+ const writable = resolveWritable(config);
162
+ if (!writable) {
163
+ throw new UsageError(`Source "${source.name}" is not writable. Set \`writable: true\` on the source config entry to enable writes.`, "INVALID_FLAG_VALUE");
164
+ }
165
+ }
166
+ function resolveAssetFilePath(source, ref) {
167
+ const typeDir = TYPE_DIRS[ref.type];
168
+ if (!typeDir) {
169
+ throw new UsageError(`Unknown asset type "${ref.type}". Cannot resolve a write path.`, "INVALID_FLAG_VALUE");
170
+ }
171
+ const typeRoot = path.join(source.path, typeDir);
172
+ const assetPath = resolveAssetPathFromName(ref.type, typeRoot, ref.name);
173
+ if (!isWithin(assetPath, typeRoot)) {
174
+ throw new UsageError(`Resolved asset path escapes its source: "${ref.name}" in source "${source.name}".`, "PATH_ESCAPE_VIOLATION");
175
+ }
176
+ return assetPath;
177
+ }
178
+ async function runKindSpecificCommit(source, config, filePath, message) {
179
+ if (source.kind === "filesystem") {
180
+ return; // No commit step.
181
+ }
182
+ if (source.kind === "git") {
183
+ runGitCommit(source.path, filePath, message);
184
+ if (config.options?.pushOnCommit) {
185
+ runGitPush(source.path);
186
+ }
187
+ return;
188
+ }
189
+ // Reject any other kind reaching the helper. The config loader is the
190
+ // first line of defence (assertWritableAllowedForKind), but we throw here
191
+ // so external callers that bypass the loader still get a clear error.
192
+ throw new ConfigError(`write-source: unsupported kind "${source.kind}" for source "${source.name}". ` +
193
+ "Writes are only defined for `filesystem` and `git` sources.", "INVALID_CONFIG_FILE", 'Set `kind: "filesystem"` (or `kind: "git"`) on the source, or add a parallel filesystem entry.');
194
+ }
195
+ function runGitCommit(repoDir, filePath, message) {
196
+ // Stage the specific file rather than `add -A` so unrelated working-tree
197
+ // changes don't get folded into the asset commit.
198
+ const relPath = path.relative(repoDir, filePath) || filePath;
199
+ const addResult = spawnSync("git", ["-C", repoDir, "add", "--", relPath], { encoding: "utf8" });
200
+ if (addResult.status !== 0) {
201
+ throw new Error(`git add failed: ${addResult.stderr?.trim() || "unknown error"}`);
202
+ }
203
+ // Provide a fallback identity so fresh CI/test environments without
204
+ // user.name/user.email configured can always commit.
205
+ const commitResult = spawnSync("git", ["-C", repoDir, "-c", "user.name=akm", "-c", "user.email=akm@local", "commit", "-m", message], { encoding: "utf8" });
206
+ if (commitResult.status !== 0) {
207
+ // `nothing to commit` is a no-op success — the file may have matched the
208
+ // existing tree exactly. Surface other errors verbatim.
209
+ const stderr = commitResult.stderr ?? "";
210
+ if (/nothing to commit|no changes added/i.test(stderr) ||
211
+ /nothing to commit|no changes added/i.test(commitResult.stdout ?? "")) {
212
+ return;
213
+ }
214
+ throw new Error(`git commit failed: ${stderr.trim() || "unknown error"}`);
215
+ }
216
+ }
217
+ function runGitPush(repoDir) {
218
+ const pushResult = spawnSync("git", ["-C", repoDir, "push"], { encoding: "utf8", timeout: 120_000 });
219
+ if (pushResult.status !== 0) {
220
+ throw new Error(`git push failed: ${pushResult.stderr?.trim() || "unknown error"}`);
221
+ }
222
+ }
223
+ function formatRefForMessage(ref) {
224
+ return ref.origin ? `${ref.origin}//${ref.type}:${ref.name}` : `${ref.type}:${ref.name}`;
225
+ }
226
+ /**
227
+ * Derive a {@link WriteTargetSource} + persisted {@link SourceConfigEntry}
228
+ * from the runtime {@link ConfiguredSource} representation used elsewhere in
229
+ * the codebase. The mapping is:
230
+ *
231
+ * ConfiguredSource.type → WriteTargetSource.kind
232
+ * ConfiguredSource.name → WriteTargetSource.name
233
+ * ConfiguredSource.source.* → WriteTargetSource.path (via parseSourceSpec)
234
+ *
235
+ * Legacy aliases (`context-hub`, `github`) have already been normalised to
236
+ * `git` by the config loader, so this mapping is straightforward.
237
+ */
238
+ function adaptConfiguredSource(runtime) {
239
+ const writePath = pathFromConfiguredSource(runtime);
240
+ if (!writePath) {
241
+ throw new ConfigError(`Source "${runtime.name}" has no resolvable on-disk path; writes are unsupported for this entry.`, "INVALID_CONFIG_FILE");
242
+ }
243
+ // Map the runtime kind to the write helper's `kind` discriminator. Only
244
+ // filesystem and git produce writable sources at v1.
245
+ const kind = runtime.type === "filesystem" || runtime.type === "git" ? runtime.type : runtime.type;
246
+ const config = {
247
+ type: runtime.type,
248
+ name: runtime.name,
249
+ ...(writePath !== undefined ? { path: writePath } : {}),
250
+ ...(runtime.writable !== undefined ? { writable: runtime.writable } : {}),
251
+ ...(runtime.options ? { options: runtime.options } : {}),
252
+ };
253
+ return {
254
+ source: { kind, name: runtime.name, path: writePath },
255
+ config,
256
+ };
257
+ }
258
+ function pathFromConfiguredSource(runtime) {
259
+ // ConfiguredSource.source is the parsed SourceSpec (filesystem|git|website|npm).
260
+ // For writable kinds we only ever care about a local on-disk path: filesystem
261
+ // sources expose it directly; git sources resolve through the cache mirror
262
+ // (handled by the existing source provider). For v1 the helper trusts
263
+ // callers to materialise the cache path beforehand and does not re-clone.
264
+ const spec = runtime.source;
265
+ if (spec.type === "filesystem")
266
+ return spec.path;
267
+ // For git sources we fall back to the cached repo directory the provider
268
+ // already materialised. The lookup is intentionally lazy — we only import
269
+ // it when needed to keep the helper's import graph small.
270
+ if (spec.type === "git") {
271
+ try {
272
+ const repo = parseGitRepoUrl(spec.url);
273
+ return getCachePaths(repo.canonicalUrl).repoDir;
274
+ }
275
+ catch {
276
+ return undefined;
277
+ }
278
+ }
279
+ return undefined;
280
+ }
@@ -1,32 +1,35 @@
1
1
  /**
2
- * Local (filesystem + SQLite) stash search implementation.
2
+ * Database-backed (SQLite + FTS5/vector) source search implementation.
3
3
  *
4
- * Extracted from stash-search.ts to break the circular import:
5
- * stash-search.ts → stash-providers/filesystem.ts → local-search.ts (no cycle)
4
+ * Extracted from source-search.ts to break the circular import:
5
+ * source-search.ts → sources/providers/filesystem.ts → db-search.ts (no cycle)
6
6
  *
7
- * stash-search.ts imports this module for the `searchLocal` export.
8
- * stash-providers/filesystem.ts also imports `searchLocal` from here.
7
+ * source-search.ts imports this module for the `searchLocal` export.
8
+ * sources/providers/filesystem.ts also imports `searchLocal` from here.
9
+ *
10
+ * Renamed from `local-search.ts` to signal that this is the DB-layer search
11
+ * implementation, not a "local vs. remote" distinction.
9
12
  */
10
13
  import fs from "node:fs";
11
14
  import path from "node:path";
12
- import { ACTION_BUILDERS, TYPE_TO_RENDERER } from "./asset-registry";
13
- import { deriveCanonicalAssetNameFromStashRoot } from "./asset-spec";
15
+ import { makeAssetRef } from "../core/asset-ref";
16
+ import { defaultRendererRegistry } from "../core/asset-registry";
17
+ import { deriveCanonicalAssetNameFromStashRoot } from "../core/asset-spec";
18
+ import { getDbPath } from "../core/paths";
19
+ import { warn } from "../core/warn";
14
20
  import { closeDatabase, getAllEntries, getEntryById, getEntryCount, getMeta, getUtilityScoresByIds, openDatabase, searchFts, searchVec, } from "./db";
15
21
  import { getRenderer } from "./file-context";
16
- import { buildSearchText } from "./indexer";
17
22
  import { generateMetadataFlat, loadStashFile, shouldIndexStashFile } from "./metadata";
18
- import { getDbPath } from "./paths";
23
+ import { buildSearchText } from "./search-fields";
19
24
  import { buildEditHint, findSourceForPath, isEditable } from "./search-source";
20
25
  import { deriveSemanticProviderFingerprint, getEffectiveSemanticStatus, isSemanticRuntimeReady, readSemanticStatus, } from "./semantic-status";
21
- import { makeAssetRef } from "./stash-ref";
22
26
  import { walkStashFlat } from "./walker";
23
- import { warn } from "./warn";
24
- export async function rendererForType(type) {
25
- const name = TYPE_TO_RENDERER[type];
27
+ export async function rendererForType(type, registry = defaultRendererRegistry) {
28
+ const name = registry.rendererNameFor(type);
26
29
  return name ? getRenderer(name) : undefined;
27
30
  }
28
- export function buildLocalAction(type, ref) {
29
- const builder = ACTION_BUILDERS[type];
31
+ export function buildLocalAction(type, ref, registry = defaultRendererRegistry) {
32
+ const builder = registry.actionBuilderFor(type);
30
33
  return builder ? builder(ref) : `akm show ${ref}`;
31
34
  }
32
35
  function resolveSearchHitRef(entry, refName, source) {
@@ -41,7 +44,8 @@ function resolveSearchHitOrigin(source) {
41
44
  // ── Main search entrypoint ───────────────────────────────────────────────────
42
45
  export async function searchLocal(input) {
43
46
  const { query, searchType, limit, stashDir, sources, config } = input;
44
- const allStashDirs = sources.map((s) => s.path);
47
+ const rendererRegistry = input.rendererRegistry ?? defaultRendererRegistry;
48
+ const allSourceDirs = sources.map((s) => s.path);
45
49
  const rawStatus = readSemanticStatus();
46
50
  const semanticStatus = getEffectiveSemanticStatus(config, rawStatus);
47
51
  const warnings = [];
@@ -81,7 +85,7 @@ export async function searchLocal(input) {
81
85
  }
82
86
  }
83
87
  if (entryCount > 0 && stashDirMatch) {
84
- const { hits, embedMs, rankMs } = await searchDatabase(db, query, searchType, limit, stashDir, allStashDirs, config, sources);
88
+ const { hits, embedMs, rankMs } = await searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry);
85
89
  return {
86
90
  hits,
87
91
  tip: hits.length === 0
@@ -101,7 +105,7 @@ export async function searchLocal(input) {
101
105
  catch (error) {
102
106
  warn("Search index unavailable, falling back to substring search:", error instanceof Error ? error.message : String(error));
103
107
  }
104
- const hitArrays = await Promise.all(allStashDirs.map((dir) => substringSearch(query, searchType, limit, dir, sources, config)));
108
+ const hitArrays = await Promise.all(allSourceDirs.map((dir) => substringSearch(query, searchType, limit, dir, sources, config, rendererRegistry)));
105
109
  const hits = hitArrays.flat().slice(0, limit);
106
110
  return {
107
111
  hits,
@@ -110,7 +114,7 @@ export async function searchLocal(input) {
110
114
  };
111
115
  }
112
116
  // ── Database search ─────────────────────────────────────────────────────────
113
- async function searchDatabase(db, query, searchType, limit, stashDir, allStashDirs, config, sources) {
117
+ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry = defaultRendererRegistry) {
114
118
  // Empty query: return all entries
115
119
  if (!query) {
116
120
  const typeFilter = searchType === "any" ? undefined : searchType;
@@ -131,9 +135,10 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allStashDi
131
135
  query,
132
136
  rankingMode: "fts",
133
137
  defaultStashDir: stashDir,
134
- allStashDirs,
138
+ allSourceDirs,
135
139
  sources,
136
140
  config,
141
+ rendererRegistry,
137
142
  })));
138
143
  return { hits };
139
144
  }
@@ -350,13 +355,19 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allStashDi
350
355
  item.utilityBoosted = true;
351
356
  }
352
357
  }
358
+ // ── minScore floor ──────────────────────────────────────────────────────
359
+ // Drop semantic-only hits (cosine-only, no FTS match) whose score falls
360
+ // below the configured floor. FTS hits and hybrid hits are always kept.
361
+ // Default floor: 0.2. Set search.minScore = 0 in config to disable.
362
+ const minScore = config.search?.minScore ?? 0.2;
363
+ const preFilter = minScore > 0 ? scored.filter((item) => item.rankingMode !== "semantic" || item.score >= minScore) : scored;
353
364
  // Deterministic tiebreaker on equal scores
354
- scored.sort((a, b) => b.score - a.score || a.entry.name.localeCompare(b.entry.name));
365
+ preFilter.sort((a, b) => b.score - a.score || a.entry.name.localeCompare(b.entry.name));
355
366
  // Deduplicate by file path — keep only the highest-scored entry per file.
356
367
  // Multiple .stash.json entries can map to the same file (e.g. entries without
357
368
  // a filename field all collapse to files[0]). Showing the same path/ref
358
369
  // multiple times clutters results.
359
- const deduped = deduplicateByPath(scored);
370
+ const deduped = deduplicateByPath(preFilter);
360
371
  const rankMs = Date.now() - tRank0;
361
372
  const selected = deduped.slice(0, limit);
362
373
  const hits = await Promise.all(selected.map(({ entry, filePath, score, rankingMode, utilityBoosted }) => buildDbHit({
@@ -367,10 +378,11 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allStashDi
367
378
  query,
368
379
  rankingMode,
369
380
  defaultStashDir: stashDir,
370
- allStashDirs,
381
+ allSourceDirs,
371
382
  sources,
372
383
  config,
373
384
  utilityBoosted,
385
+ rendererRegistry,
374
386
  })));
375
387
  return { embedMs, rankMs, hits };
376
388
  }
@@ -383,7 +395,7 @@ async function tryVecScores(db, query, k, config) {
383
395
  if (hasEmbeddings !== "1")
384
396
  return null;
385
397
  try {
386
- const { embed } = await import("./embedder.js");
398
+ const { embed } = await import("../llm/embedder.js");
387
399
  const queryEmbedding = await embed(query, config.embedding);
388
400
  const vecResults = searchVec(db, queryEmbedding, k);
389
401
  const scores = new Map();
@@ -401,20 +413,24 @@ async function tryVecScores(db, query, k, config) {
401
413
  }
402
414
  }
403
415
  // ── Substring fallback (no index) ───────────────────────────────────────────
404
- async function substringSearch(query, searchType, limit, stashDir, sources, config) {
416
+ async function substringSearch(query, searchType, limit, stashDir, sources, config, rendererRegistry = defaultRendererRegistry) {
405
417
  const assets = await indexAssets(stashDir, searchType, sources);
406
418
  const matched = assets.filter((asset) => !query || buildSearchText(asset.entry).includes(query));
407
419
  if (!query) {
408
420
  const sorted = matched.sort(compareAssets);
409
421
  const unique = deduplicateAssetsByPath(sorted);
410
- return Promise.all(unique.slice(0, limit).map((asset) => assetToSearchHit(asset, stashDir, sources, config)));
422
+ return Promise.all(unique
423
+ .slice(0, limit)
424
+ .map((asset) => assetToSearchHit(asset, stashDir, sources, config, undefined, rendererRegistry)));
411
425
  }
412
426
  // Score and sort by relevance
413
427
  const scored = matched.map((asset) => ({ asset, score: scoreSubstringMatch(asset.entry, query) }));
414
428
  scored.sort((a, b) => b.score - a.score || compareAssets(a.asset, b.asset));
415
429
  // Deduplicate by path — keep highest-scored entry per file
416
430
  const dedupedScored = deduplicateByPath(scored.map((s) => ({ ...s, filePath: s.asset.path })));
417
- return Promise.all(dedupedScored.slice(0, limit).map(({ asset, score }) => assetToSearchHit(asset, stashDir, sources, config, score)));
431
+ return Promise.all(dedupedScored
432
+ .slice(0, limit)
433
+ .map(({ asset, score }) => assetToSearchHit(asset, stashDir, sources, config, score, rendererRegistry)));
418
434
  }
419
435
  function scoreSubstringMatch(entry, query) {
420
436
  const tokens = query.split(/\s+/).filter(Boolean);
@@ -444,6 +460,7 @@ function scoreSubstringMatch(entry, query) {
444
460
  }
445
461
  // ── Hit building ────────────────────────────────────────────────────────────
446
462
  export async function buildDbHit(input) {
463
+ const rendererRegistry = input.rendererRegistry ?? defaultRendererRegistry;
447
464
  const entryStashDir = findSourceForPath(input.path, input.sources)?.path ?? input.defaultStashDir;
448
465
  const canonical = deriveCanonicalAssetNameFromStashRoot(input.entry.type, entryStashDir, input.path);
449
466
  const refName = canonical && !canonical.startsWith("../") && !canonical.startsWith("..\\") ? canonical : input.entry.name;
@@ -471,12 +488,12 @@ export async function buildDbHit(input) {
471
488
  description: input.entry.description,
472
489
  tags: input.entry.tags,
473
490
  size: deriveSize(input.entry.fileSize),
474
- action: buildLocalAction(input.entry.type, ref),
491
+ action: buildLocalAction(input.entry.type, ref, rendererRegistry),
475
492
  score,
476
493
  whyMatched,
477
494
  ...(estimatedTokens !== undefined ? { estimatedTokens } : {}),
478
495
  };
479
- const renderer = await rendererForType(input.entry.type);
496
+ const renderer = await rendererForType(input.entry.type, rendererRegistry);
480
497
  if (renderer?.enrichSearchHit) {
481
498
  renderer.enrichSearchHit(hit, entryStashDir);
482
499
  }
@@ -530,7 +547,7 @@ rankingMode, qualityBoost, confidenceBoost, utilityBoosted) {
530
547
  reasons.push("usage history boost");
531
548
  return reasons;
532
549
  }
533
- async function assetToSearchHit(asset, stashDir, sources, config, score) {
550
+ async function assetToSearchHit(asset, stashDir, sources, config, score, rendererRegistry = defaultRendererRegistry) {
534
551
  const source = findSourceForPath(asset.path, sources);
535
552
  const editable = isEditable(asset.path, config);
536
553
  const ref = resolveSearchHitRef(asset.entry, asset.entry.name, source);
@@ -550,11 +567,11 @@ async function assetToSearchHit(asset, stashDir, sources, config, score) {
550
567
  description: asset.entry.description,
551
568
  tags: asset.entry.tags,
552
569
  ...(size ? { size } : {}),
553
- action: buildLocalAction(asset.entry.type, ref),
570
+ action: buildLocalAction(asset.entry.type, ref, rendererRegistry),
554
571
  ...(score !== undefined ? { score } : {}),
555
572
  ...(estimatedTokens !== undefined ? { estimatedTokens } : {}),
556
573
  };
557
- const renderer = await rendererForType(asset.entry.type);
574
+ const renderer = await rendererForType(asset.entry.type, rendererRegistry);
558
575
  if (renderer?.enrichSearchHit) {
559
576
  renderer.enrichSearchHit(hit, stashDir);
560
577
  }