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.
- package/CHANGELOG.md +33 -0
- package/README.md +9 -9
- package/dist/cli.js +199 -114
- package/dist/{completions.js → commands/completions.js} +1 -1
- package/dist/{config-cli.js → commands/config-cli.js} +109 -11
- package/dist/{curate.js → commands/curate.js} +8 -3
- package/dist/{info.js → commands/info.js} +15 -9
- package/dist/{init.js → commands/init.js} +4 -4
- package/dist/{install-audit.js → commands/install-audit.js} +4 -7
- package/dist/{installed-stashes.js → commands/installed-stashes.js} +77 -31
- package/dist/{migration-help.js → commands/migration-help.js} +2 -2
- package/dist/{registry-search.js → commands/registry-search.js} +8 -6
- package/dist/{remember.js → commands/remember.js} +55 -49
- package/dist/{stash-search.js → commands/search.js} +28 -69
- package/dist/{self-update.js → commands/self-update.js} +69 -3
- package/dist/{stash-show.js → commands/show.js} +104 -84
- package/dist/{stash-add.js → commands/source-add.js} +42 -32
- package/dist/{stash-clone.js → commands/source-clone.js} +12 -10
- package/dist/{stash-source-manage.js → commands/source-manage.js} +24 -24
- package/dist/{vault.js → commands/vault.js} +43 -0
- package/dist/{stash-ref.js → core/asset-ref.js} +4 -4
- package/dist/{asset-registry.js → core/asset-registry.js} +1 -1
- package/dist/{asset-spec.js → core/asset-spec.js} +1 -1
- package/dist/{config.js → core/config.js} +133 -56
- package/dist/core/errors.js +90 -0
- package/dist/{frontmatter.js → core/frontmatter.js} +5 -3
- package/dist/core/write-source.js +280 -0
- package/dist/{db-search.js → indexer/db-search.js} +25 -19
- package/dist/{db.js → indexer/db.js} +79 -47
- package/dist/{file-context.js → indexer/file-context.js} +3 -3
- package/dist/{indexer.js → indexer/indexer.js} +132 -33
- package/dist/{manifest.js → indexer/manifest.js} +10 -10
- package/dist/{matchers.js → indexer/matchers.js} +3 -6
- package/dist/{metadata.js → indexer/metadata.js} +9 -5
- package/dist/{search-source.js → indexer/search-source.js} +52 -41
- package/dist/{semantic-status.js → indexer/semantic-status.js} +2 -2
- package/dist/{walker.js → indexer/walker.js} +1 -1
- package/dist/{lockfile.js → integrations/lockfile.js} +1 -1
- package/dist/{llm-client.js → llm/client.js} +1 -1
- package/dist/{embedders → llm/embedders}/local.js +2 -2
- package/dist/{embedders → llm/embedders}/remote.js +1 -1
- package/dist/{embedders → llm/embedders}/types.js +1 -1
- package/dist/{metadata-enhance.js → llm/metadata-enhance.js} +2 -2
- package/dist/{cli-hints.js → output/cli-hints.js} +3 -0
- package/dist/{output-context.js → output/context.js} +21 -3
- package/dist/{renderers.js → output/renderers.js} +9 -65
- package/dist/{output-shapes.js → output/shapes.js} +18 -4
- package/dist/{output-text.js → output/text.js} +2 -2
- package/dist/{registry-build-index.js → registry/build-index.js} +16 -7
- package/dist/{create-provider-registry.js → registry/create-provider-registry.js} +6 -2
- package/dist/registry/factory.js +33 -0
- package/dist/{origin-resolve.js → registry/origin-resolve.js} +1 -1
- package/dist/{providers → registry/providers}/index.js +1 -1
- package/dist/{providers → registry/providers}/skills-sh.js +59 -3
- package/dist/{providers → registry/providers}/static-index.js +80 -12
- package/dist/registry/providers/types.js +25 -0
- package/dist/{registry-resolve.js → registry/resolve.js} +3 -3
- package/dist/{detect.js → setup/detect.js} +0 -27
- package/dist/{ripgrep-install.js → setup/ripgrep-install.js} +1 -1
- package/dist/{ripgrep-resolve.js → setup/ripgrep-resolve.js} +2 -2
- package/dist/{setup.js → setup/setup.js} +16 -56
- package/dist/{stash-include.js → sources/include.js} +1 -1
- package/dist/sources/provider-factory.js +36 -0
- package/dist/sources/provider.js +21 -0
- package/dist/sources/providers/filesystem.js +35 -0
- package/dist/{stash-providers → sources/providers}/git.js +53 -64
- package/dist/{stash-providers → sources/providers}/index.js +3 -4
- package/dist/sources/providers/install-types.js +14 -0
- package/dist/{stash-providers → sources/providers}/npm.js +42 -41
- package/dist/{stash-providers → sources/providers}/provider-utils.js +3 -3
- package/dist/{stash-providers → sources/providers}/sync-from-ref.js +2 -2
- package/dist/{stash-providers → sources/providers}/tar-utils.js +11 -8
- package/dist/{stash-providers → sources/providers}/website.js +29 -65
- package/dist/{stash-resolve.js → sources/resolve.js} +8 -7
- package/dist/{wiki.js → wiki/wiki.js} +34 -18
- package/dist/{workflow-authoring.js → workflows/authoring.js} +37 -14
- package/dist/{workflow-cli.js → workflows/cli.js} +2 -1
- package/dist/{workflow-db.js → workflows/db.js} +1 -1
- package/dist/workflows/document-cache.js +20 -0
- package/dist/workflows/parser.js +379 -0
- package/dist/workflows/renderer.js +78 -0
- package/dist/{workflow-runs.js → workflows/runs.js} +72 -28
- package/dist/workflows/schema.js +11 -0
- package/dist/workflows/validator.js +48 -0
- package/docs/migration/release-notes/0.6.0.md +91 -23
- package/package.json +1 -1
- package/dist/errors.js +0 -45
- package/dist/llm.js +0 -16
- package/dist/registry-factory.js +0 -19
- package/dist/ripgrep.js +0 -2
- package/dist/stash-provider-factory.js +0 -35
- package/dist/stash-provider.js +0 -3
- package/dist/stash-providers/filesystem.js +0 -71
- package/dist/stash-providers/openviking.js +0 -348
- package/dist/stash-types.js +0 -1
- package/dist/workflow-markdown.js +0 -260
- /package/dist/{common.js → core/common.js} +0 -0
- /package/dist/{markdown.js → core/markdown.js} +0 -0
- /package/dist/{paths.js → core/paths.js} +0 -0
- /package/dist/{warn.js → core/warn.js} +0 -0
- /package/dist/{search-fields.js → indexer/search-fields.js} +0 -0
- /package/dist/{usage-events.js → indexer/usage-events.js} +0 -0
- /package/dist/{github.js → integrations/github.js} +0 -0
- /package/dist/{embedder.js → llm/embedder.js} +0 -0
- /package/dist/{embedders → llm/embedders}/cache.js +0 -0
- /package/dist/{registry-provider.js → registry/types.js} +0 -0
- /package/dist/{setup-steps.js → setup/steps.js} +0 -0
- /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)
|
|
2
|
+
* Database-backed (SQLite + FTS5/vector) source search implementation.
|
|
3
3
|
*
|
|
4
|
-
* Extracted from
|
|
5
|
-
*
|
|
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
|
-
*
|
|
8
|
-
*
|
|
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 {
|
|
16
|
-
import {
|
|
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
|
|
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,
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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("
|
|
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 {
|
|
6
|
-
import { getDbPath } from "
|
|
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 =
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
|
300
|
-
// entries that actually changed.
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
return
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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:
|
|
473
|
-
//
|
|
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 "
|
|
10
|
-
import { parseFrontmatter } from "
|
|
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("
|
|
84
|
+
const { registerBuiltinRenderers } = await import("../output/renderers.js");
|
|
85
85
|
registerBuiltinMatchers();
|
|
86
86
|
registerBuiltinRenderers();
|
|
87
87
|
})();
|