akm-cli 0.6.0-rc1 → 0.6.0

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 (108) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +9 -9
  3. package/dist/cli.js +199 -114
  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/{curate.js → commands/curate.js} +8 -3
  7. package/dist/{info.js → commands/info.js} +15 -9
  8. package/dist/{init.js → commands/init.js} +4 -4
  9. package/dist/{install-audit.js → commands/install-audit.js} +4 -7
  10. package/dist/{installed-stashes.js → commands/installed-stashes.js} +77 -31
  11. package/dist/{migration-help.js → commands/migration-help.js} +2 -2
  12. package/dist/{registry-search.js → commands/registry-search.js} +8 -6
  13. package/dist/{remember.js → commands/remember.js} +55 -49
  14. package/dist/{stash-search.js → commands/search.js} +28 -69
  15. package/dist/{self-update.js → commands/self-update.js} +69 -3
  16. package/dist/{stash-show.js → commands/show.js} +104 -84
  17. package/dist/{stash-add.js → commands/source-add.js} +42 -32
  18. package/dist/{stash-clone.js → commands/source-clone.js} +12 -10
  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} +1 -1
  23. package/dist/{asset-spec.js → core/asset-spec.js} +1 -1
  24. package/dist/{config.js → core/config.js} +133 -56
  25. package/dist/core/errors.js +90 -0
  26. package/dist/{frontmatter.js → core/frontmatter.js} +5 -3
  27. package/dist/core/write-source.js +280 -0
  28. package/dist/{db-search.js → indexer/db-search.js} +25 -19
  29. package/dist/{db.js → indexer/db.js} +79 -47
  30. package/dist/{file-context.js → indexer/file-context.js} +3 -3
  31. package/dist/{indexer.js → indexer/indexer.js} +132 -33
  32. package/dist/{manifest.js → indexer/manifest.js} +10 -10
  33. package/dist/{matchers.js → indexer/matchers.js} +3 -6
  34. package/dist/{metadata.js → indexer/metadata.js} +9 -5
  35. package/dist/{search-source.js → indexer/search-source.js} +52 -41
  36. package/dist/{semantic-status.js → indexer/semantic-status.js} +2 -2
  37. package/dist/{walker.js → indexer/walker.js} +1 -1
  38. package/dist/{lockfile.js → integrations/lockfile.js} +1 -1
  39. package/dist/{llm-client.js → llm/client.js} +1 -1
  40. package/dist/{embedders → llm/embedders}/local.js +2 -2
  41. package/dist/{embedders → llm/embedders}/remote.js +1 -1
  42. package/dist/{embedders → llm/embedders}/types.js +1 -1
  43. package/dist/{metadata-enhance.js → llm/metadata-enhance.js} +2 -2
  44. package/dist/{cli-hints.js → output/cli-hints.js} +3 -0
  45. package/dist/{output-context.js → output/context.js} +21 -3
  46. package/dist/{renderers.js → output/renderers.js} +9 -65
  47. package/dist/{output-shapes.js → output/shapes.js} +18 -4
  48. package/dist/{output-text.js → output/text.js} +2 -2
  49. package/dist/{registry-build-index.js → registry/build-index.js} +16 -7
  50. package/dist/{create-provider-registry.js → registry/create-provider-registry.js} +6 -2
  51. package/dist/registry/factory.js +33 -0
  52. package/dist/{origin-resolve.js → registry/origin-resolve.js} +1 -1
  53. package/dist/{providers → registry/providers}/index.js +1 -1
  54. package/dist/{providers → registry/providers}/skills-sh.js +59 -3
  55. package/dist/{providers → registry/providers}/static-index.js +80 -12
  56. package/dist/registry/providers/types.js +25 -0
  57. package/dist/{registry-resolve.js → registry/resolve.js} +3 -3
  58. package/dist/{detect.js → setup/detect.js} +0 -27
  59. package/dist/{ripgrep-install.js → setup/ripgrep-install.js} +1 -1
  60. package/dist/{ripgrep-resolve.js → setup/ripgrep-resolve.js} +2 -2
  61. package/dist/{setup.js → setup/setup.js} +16 -56
  62. package/dist/{stash-include.js → sources/include.js} +1 -1
  63. package/dist/sources/provider-factory.js +36 -0
  64. package/dist/sources/provider.js +21 -0
  65. package/dist/sources/providers/filesystem.js +35 -0
  66. package/dist/{stash-providers → sources/providers}/git.js +53 -64
  67. package/dist/{stash-providers → sources/providers}/index.js +3 -4
  68. package/dist/sources/providers/install-types.js +14 -0
  69. package/dist/{stash-providers → sources/providers}/npm.js +42 -41
  70. package/dist/{stash-providers → sources/providers}/provider-utils.js +3 -3
  71. package/dist/{stash-providers → sources/providers}/sync-from-ref.js +2 -2
  72. package/dist/{stash-providers → sources/providers}/tar-utils.js +11 -8
  73. package/dist/{stash-providers → sources/providers}/website.js +29 -65
  74. package/dist/{stash-resolve.js → sources/resolve.js} +8 -7
  75. package/dist/{wiki.js → wiki/wiki.js} +34 -18
  76. package/dist/{workflow-authoring.js → workflows/authoring.js} +37 -14
  77. package/dist/{workflow-cli.js → workflows/cli.js} +2 -1
  78. package/dist/{workflow-db.js → workflows/db.js} +1 -1
  79. package/dist/workflows/document-cache.js +20 -0
  80. package/dist/workflows/parser.js +379 -0
  81. package/dist/workflows/renderer.js +78 -0
  82. package/dist/{workflow-runs.js → workflows/runs.js} +72 -28
  83. package/dist/workflows/schema.js +11 -0
  84. package/dist/workflows/validator.js +48 -0
  85. package/docs/migration/release-notes/0.6.0.md +91 -23
  86. package/package.json +1 -1
  87. package/dist/errors.js +0 -45
  88. package/dist/llm.js +0 -16
  89. package/dist/registry-factory.js +0 -19
  90. package/dist/ripgrep.js +0 -2
  91. package/dist/stash-provider-factory.js +0 -35
  92. package/dist/stash-provider.js +0 -3
  93. package/dist/stash-providers/filesystem.js +0 -71
  94. package/dist/stash-providers/openviking.js +0 -348
  95. package/dist/stash-types.js +0 -1
  96. package/dist/workflow-markdown.js +0 -260
  97. /package/dist/{common.js → core/common.js} +0 -0
  98. /package/dist/{markdown.js → core/markdown.js} +0 -0
  99. /package/dist/{paths.js → core/paths.js} +0 -0
  100. /package/dist/{warn.js → core/warn.js} +0 -0
  101. /package/dist/{search-fields.js → indexer/search-fields.js} +0 -0
  102. /package/dist/{usage-events.js → indexer/usage-events.js} +0 -0
  103. /package/dist/{github.js → integrations/github.js} +0 -0
  104. /package/dist/{embedder.js → llm/embedder.js} +0 -0
  105. /package/dist/{embedders → llm/embedders}/cache.js +0 -0
  106. /package/dist/{registry-provider.js → registry/types.js} +0 -0
  107. /package/dist/{setup-steps.js → setup/steps.js} +0 -0
  108. /package/dist/{registry-types.js → sources/types.js} +0 -0
@@ -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,29 +1,29 @@
1
1
  /**
2
- * Database-backed (SQLite + FTS5/vector) 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 → db-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
9
  *
10
10
  * Renamed from `local-search.ts` to signal that this is the DB-layer search
11
11
  * implementation, not a "local vs. remote" distinction.
12
12
  */
13
13
  import fs from "node:fs";
14
14
  import path from "node:path";
15
- import { defaultRendererRegistry } from "./asset-registry";
16
- 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";
17
20
  import { closeDatabase, getAllEntries, getEntryById, getEntryCount, getMeta, getUtilityScoresByIds, openDatabase, searchFts, searchVec, } from "./db";
18
21
  import { getRenderer } from "./file-context";
19
22
  import { generateMetadataFlat, loadStashFile, shouldIndexStashFile } from "./metadata";
20
- import { getDbPath } from "./paths";
21
23
  import { buildSearchText } from "./search-fields";
22
24
  import { buildEditHint, findSourceForPath, isEditable } from "./search-source";
23
25
  import { deriveSemanticProviderFingerprint, getEffectiveSemanticStatus, isSemanticRuntimeReady, readSemanticStatus, } from "./semantic-status";
24
- import { makeAssetRef } from "./stash-ref";
25
26
  import { walkStashFlat } from "./walker";
26
- import { warn } from "./warn";
27
27
  export async function rendererForType(type, registry = defaultRendererRegistry) {
28
28
  const name = registry.rendererNameFor(type);
29
29
  return name ? getRenderer(name) : undefined;
@@ -45,7 +45,7 @@ function resolveSearchHitOrigin(source) {
45
45
  export async function searchLocal(input) {
46
46
  const { query, searchType, limit, stashDir, sources, config } = input;
47
47
  const rendererRegistry = input.rendererRegistry ?? defaultRendererRegistry;
48
- const allStashDirs = sources.map((s) => s.path);
48
+ const allSourceDirs = sources.map((s) => s.path);
49
49
  const rawStatus = readSemanticStatus();
50
50
  const semanticStatus = getEffectiveSemanticStatus(config, rawStatus);
51
51
  const warnings = [];
@@ -85,7 +85,7 @@ export async function searchLocal(input) {
85
85
  }
86
86
  }
87
87
  if (entryCount > 0 && stashDirMatch) {
88
- const { hits, embedMs, rankMs } = await searchDatabase(db, query, searchType, limit, stashDir, allStashDirs, config, sources, rendererRegistry);
88
+ const { hits, embedMs, rankMs } = await searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry);
89
89
  return {
90
90
  hits,
91
91
  tip: hits.length === 0
@@ -105,7 +105,7 @@ export async function searchLocal(input) {
105
105
  catch (error) {
106
106
  warn("Search index unavailable, falling back to substring search:", error instanceof Error ? error.message : String(error));
107
107
  }
108
- const hitArrays = await Promise.all(allStashDirs.map((dir) => substringSearch(query, searchType, limit, dir, sources, config, rendererRegistry)));
108
+ const hitArrays = await Promise.all(allSourceDirs.map((dir) => substringSearch(query, searchType, limit, dir, sources, config, rendererRegistry)));
109
109
  const hits = hitArrays.flat().slice(0, limit);
110
110
  return {
111
111
  hits,
@@ -114,7 +114,7 @@ export async function searchLocal(input) {
114
114
  };
115
115
  }
116
116
  // ── Database search ─────────────────────────────────────────────────────────
117
- async function searchDatabase(db, query, searchType, limit, stashDir, allStashDirs, config, sources, rendererRegistry = defaultRendererRegistry) {
117
+ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry = defaultRendererRegistry) {
118
118
  // Empty query: return all entries
119
119
  if (!query) {
120
120
  const typeFilter = searchType === "any" ? undefined : searchType;
@@ -135,7 +135,7 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allStashDi
135
135
  query,
136
136
  rankingMode: "fts",
137
137
  defaultStashDir: stashDir,
138
- allStashDirs,
138
+ allSourceDirs,
139
139
  sources,
140
140
  config,
141
141
  rendererRegistry,
@@ -355,13 +355,19 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allStashDi
355
355
  item.utilityBoosted = true;
356
356
  }
357
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;
358
364
  // Deterministic tiebreaker on equal scores
359
- 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));
360
366
  // Deduplicate by file path — keep only the highest-scored entry per file.
361
367
  // Multiple .stash.json entries can map to the same file (e.g. entries without
362
368
  // a filename field all collapse to files[0]). Showing the same path/ref
363
369
  // multiple times clutters results.
364
- const deduped = deduplicateByPath(scored);
370
+ const deduped = deduplicateByPath(preFilter);
365
371
  const rankMs = Date.now() - tRank0;
366
372
  const selected = deduped.slice(0, limit);
367
373
  const hits = await Promise.all(selected.map(({ entry, filePath, score, rankingMode, utilityBoosted }) => buildDbHit({
@@ -372,7 +378,7 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allStashDi
372
378
  query,
373
379
  rankingMode,
374
380
  defaultStashDir: stashDir,
375
- allStashDirs,
381
+ allSourceDirs,
376
382
  sources,
377
383
  config,
378
384
  utilityBoosted,
@@ -389,7 +395,7 @@ async function tryVecScores(db, query, k, config) {
389
395
  if (hasEmbeddings !== "1")
390
396
  return null;
391
397
  try {
392
- const { embed } = await import("./embedder.js");
398
+ const { embed } = await import("../llm/embedder.js");
393
399
  const queryEmbedding = await embed(query, config.embedding);
394
400
  const vecResults = searchVec(db, queryEmbedding, k);
395
401
  const scores = new Map();
@@ -2,13 +2,14 @@ import { Database } from "bun:sqlite";
2
2
  import fs from "node:fs";
3
3
  import { createRequire } from "node:module";
4
4
  import path from "node:path";
5
- import { cosineSimilarity } from "./embedders/types";
6
- import { getDbPath } from "./paths";
5
+ import { parseAssetRef } from "../core/asset-ref";
6
+ import { getDbPath } from "../core/paths";
7
+ import { warn } from "../core/warn";
8
+ import { cosineSimilarity } from "../llm/embedders/types";
7
9
  import { buildSearchFields } from "./search-fields";
8
10
  import { ensureUsageEventsSchema } from "./usage-events";
9
- import { warn } from "./warn";
10
11
  // ── Constants ───────────────────────────────────────────────────────────────
11
- export const DB_VERSION = 8;
12
+ export const DB_VERSION = 9;
12
13
  export const EMBEDDING_DIM = 384;
13
14
  // ── Database lifecycle ──────────────────────────────────────────────────────
14
15
  export function openDatabase(dbPath, options) {
@@ -19,6 +20,7 @@ export function openDatabase(dbPath, options) {
19
20
  }
20
21
  const db = new Database(resolvedPath);
21
22
  db.exec("PRAGMA journal_mode = WAL");
23
+ db.exec("PRAGMA busy_timeout = 5000");
22
24
  db.exec("PRAGMA foreign_keys = ON");
23
25
  // Try to load sqlite-vec extension
24
26
  loadVecExtension(db);
@@ -102,6 +104,22 @@ function ensureSchema(db, embeddingDim) {
102
104
 
103
105
  CREATE INDEX IF NOT EXISTS idx_entries_dir ON entries(dir_path);
104
106
  CREATE INDEX IF NOT EXISTS idx_entries_type ON entries(entry_type);
107
+ `);
108
+ // Validated WorkflowDocument JSON, one row per indexed workflow entry.
109
+ // Pure index data — fully rebuilt on each `akm index`. ON DELETE CASCADE
110
+ // means clearing entries (full rebuild or per-dir delete) drops these too.
111
+ db.exec(`
112
+ CREATE TABLE IF NOT EXISTS workflow_documents (
113
+ entry_id INTEGER PRIMARY KEY REFERENCES entries(id) ON DELETE CASCADE,
114
+ schema_version INTEGER NOT NULL,
115
+ document_json TEXT NOT NULL,
116
+ source_path TEXT NOT NULL,
117
+ source_hash TEXT NOT NULL,
118
+ updated_at TEXT NOT NULL
119
+ );
120
+
121
+ CREATE INDEX IF NOT EXISTS idx_workflow_documents_source_path
122
+ ON workflow_documents(source_path);
105
123
  `);
106
124
  // Set version immediately after table creation so a crash before the end of
107
125
  // ensureSchema() does not leave the database in a versionless state on next open.
@@ -145,6 +163,15 @@ function ensureSchema(db, embeddingDim) {
145
163
  updated_at TEXT NOT NULL DEFAULT (datetime('now')),
146
164
  FOREIGN KEY (entry_id) REFERENCES entries(id) ON DELETE CASCADE
147
165
  );
166
+ `);
167
+ // FTS-dirty queue. Created here (not lazily on first upsert) so the
168
+ // per-entry write path doesn't issue a CREATE TABLE IF NOT EXISTS on
169
+ // every call — that DDL would fire thousands of times during a full
170
+ // index. See `markFtsDirty` and `rebuildFts({ incremental: true })`.
171
+ db.exec(`
172
+ CREATE TABLE IF NOT EXISTS entries_fts_dirty (
173
+ entry_id INTEGER PRIMARY KEY
174
+ );
148
175
  `);
149
176
  // sqlite-vec table
150
177
  if (isVecAvailable(db)) {
@@ -280,45 +307,45 @@ export function setMeta(db, key, value) {
280
307
  * reflect the changes.
281
308
  */
282
309
  export function upsertEntry(db, entryKey, dirPath, filePath, stashDir, entry, searchText) {
283
- const stmt = db.prepare(`
284
- INSERT INTO entries (entry_key, dir_path, file_path, stash_dir, entry_json, search_text, entry_type)
285
- VALUES (?, ?, ?, ?, ?, ?, ?)
286
- ON CONFLICT(entry_key) DO UPDATE SET
287
- dir_path = excluded.dir_path,
288
- file_path = excluded.file_path,
289
- stash_dir = excluded.stash_dir,
290
- entry_json = excluded.entry_json,
291
- search_text = excluded.search_text,
292
- entry_type = excluded.entry_type
293
- `);
294
- stmt.run(entryKey, dirPath, filePath, stashDir, JSON.stringify(entry), searchText, entry.type);
295
- // Fetch the row id explicitly since last_insert_rowid() is unreliable for ON CONFLICT DO UPDATE
296
- const row = db.prepare("SELECT id FROM entries WHERE entry_key = ?").get(entryKey);
297
- if (!row)
310
+ // Hot path during indexing — cache the two prepared statements per
311
+ // database connection so we don't pay the SQL parse/compile cost on
312
+ // every call. The dirty-mark INSERT and the upsert-with-RETURNING
313
+ // share the same WeakMap so they live and die with the connection.
314
+ const stmts = getUpsertStmts(db);
315
+ const result = stmts.upsert.get(entryKey, dirPath, filePath, stashDir, JSON.stringify(entry), searchText, entry.type);
316
+ if (!result)
298
317
  throw new Error("upsertEntry: entry_key not found after upsert");
299
- // Mark this entry as FTS-dirty so an incremental rebuild only revisits the
300
- // entries that actually changed. Without this, every `akm index` run had to
301
- // re-scan and re-insert every FTS row, even if only one entry was touched.
302
- markFtsDirty(db, row.id);
303
- return row.id;
304
- }
305
- /**
306
- * Mark an entry as needing FTS re-indexing on the next `rebuildFts` call.
307
- *
308
- * The list lives in a small `entries_fts_dirty` table — a per-entry-id queue
309
- * of work items. `rebuildFts({ incremental: true })` drains this list rather
310
- * than scanning the entire `entries` table.
311
- */
312
- function markFtsDirty(db, entryId) {
313
- ensureFtsDirtyTable(db);
314
- db.prepare("INSERT OR IGNORE INTO entries_fts_dirty (entry_id) VALUES (?)").run(entryId);
315
- }
316
- function ensureFtsDirtyTable(db) {
317
- db.exec(`
318
- CREATE TABLE IF NOT EXISTS entries_fts_dirty (
319
- entry_id INTEGER PRIMARY KEY
320
- );
321
- `);
318
+ // Mark this entry as FTS-dirty so `rebuildFts({ incremental: true })`
319
+ // only revisits entries that actually changed. INSERT OR IGNORE is
320
+ // idempotent across multiple upserts of the same row.
321
+ stmts.markDirty.run(result.id);
322
+ return result.id;
323
+ }
324
+ const upsertStmtsByDb = new WeakMap();
325
+ function getUpsertStmts(db) {
326
+ const existing = upsertStmtsByDb.get(db);
327
+ if (existing)
328
+ return existing;
329
+ const stmts = {
330
+ // RETURNING id handles ON CONFLICT DO UPDATE correctly — no second
331
+ // SELECT round-trip needed (last_insert_rowid() is unreliable for
332
+ // ON CONFLICT). Use `.get()` so a single row comes back.
333
+ upsert: db.prepare(`
334
+ INSERT INTO entries (entry_key, dir_path, file_path, stash_dir, entry_json, search_text, entry_type)
335
+ VALUES (?, ?, ?, ?, ?, ?, ?)
336
+ ON CONFLICT(entry_key) DO UPDATE SET
337
+ dir_path = excluded.dir_path,
338
+ file_path = excluded.file_path,
339
+ stash_dir = excluded.stash_dir,
340
+ entry_json = excluded.entry_json,
341
+ search_text = excluded.search_text,
342
+ entry_type = excluded.entry_type
343
+ RETURNING id
344
+ `),
345
+ markDirty: db.prepare("INSERT OR IGNORE INTO entries_fts_dirty (entry_id) VALUES (?)"),
346
+ };
347
+ upsertStmtsByDb.set(db, stmts);
348
+ return stmts;
322
349
  }
323
350
  export function deleteEntriesByDir(db, dirPath) {
324
351
  db.transaction(() => {
@@ -419,7 +446,6 @@ export function rebuildFts(db, options) {
419
446
  db.transaction(() => {
420
447
  let rows;
421
448
  if (incremental) {
422
- ensureFtsDirtyTable(db);
423
449
  // Read the dirty queue and join against entries to get the JSON.
424
450
  // Then drop the matching rows from entries_fts so the INSERT below
425
451
  // doesn't double-up. The dirty list is drained at the end.
@@ -469,10 +495,8 @@ export function rebuildFts(db, options) {
469
495
  db.exec("DELETE FROM entries_fts_dirty");
470
496
  }
471
497
  else {
472
- // Full path: only drop the dirty table if it exists. Use
473
- // `CREATE IF NOT EXISTS` then DELETE so we don't error on databases
474
- // that haven't run any upserts yet (e.g. fresh schema).
475
- ensureFtsDirtyTable(db);
498
+ // Full path: drain the dirty queue too. The table is created by
499
+ // ensureSchema(), so it always exists at this point.
476
500
  db.exec("DELETE FROM entries_fts_dirty");
477
501
  }
478
502
  })();
@@ -694,6 +718,14 @@ export function getAllEntries(db, entryType) {
694
718
  }
695
719
  return entries;
696
720
  }
721
+ export function findEntryIdByRef(db, ref) {
722
+ const parsed = parseAssetRef(ref);
723
+ const suffix = `${parsed.type}:${parsed.name}`;
724
+ const row = db
725
+ .prepare("SELECT id FROM entries WHERE entry_type = ? AND substr(entry_key, length(entry_key) - length(?) + 1) = ? LIMIT 1")
726
+ .get(parsed.type, suffix, suffix);
727
+ return row?.id;
728
+ }
697
729
  export function getEntryCount(db) {
698
730
  const row = db.prepare("SELECT COUNT(*) AS cnt FROM entries").get();
699
731
  return row.cnt;
@@ -6,8 +6,8 @@
6
6
  */
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
- import { toPosix } from "./common";
10
- import { parseFrontmatter } from "./frontmatter";
9
+ import { toPosix } from "../core/common";
10
+ import { parseFrontmatter } from "../core/frontmatter";
11
11
  /**
12
12
  * Build a FileContext from a stash root and an absolute file path.
13
13
  *
@@ -81,7 +81,7 @@ async function ensureBuiltinsRegistered() {
81
81
  if (!builtinsPromise) {
82
82
  builtinsPromise = (async () => {
83
83
  const { registerBuiltinMatchers } = await import("./matchers.js");
84
- const { registerBuiltinRenderers } = await import("./renderers.js");
84
+ const { registerBuiltinRenderers } = await import("../output/renderers.js");
85
85
  registerBuiltinMatchers();
86
86
  registerBuiltinRenderers();
87
87
  })();