akm-cli 0.5.0 → 0.6.0-rc1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -5
- package/dist/asset-registry.js +29 -5
- package/dist/asset-spec.js +12 -5
- package/dist/cli-hints.js +300 -0
- package/dist/cli.js +218 -1357
- package/dist/common.js +147 -50
- package/dist/config.js +224 -13
- package/dist/create-provider-registry.js +1 -1
- package/dist/curate.js +258 -0
- package/dist/{local-search.js → db-search.js} +30 -19
- package/dist/db.js +168 -62
- package/dist/embedder.js +49 -273
- package/dist/embedders/cache.js +47 -0
- package/dist/embedders/local.js +152 -0
- package/dist/embedders/remote.js +121 -0
- package/dist/embedders/types.js +39 -0
- package/dist/errors.js +14 -3
- package/dist/frontmatter.js +61 -7
- package/dist/indexer.js +38 -7
- package/dist/info.js +2 -2
- package/dist/install-audit.js +16 -1
- package/dist/{installed-kits.js → installed-stashes.js} +48 -22
- package/dist/llm-client.js +92 -0
- package/dist/llm.js +14 -126
- package/dist/lockfile.js +28 -1
- package/dist/matchers.js +1 -1
- package/dist/metadata-enhance.js +53 -0
- package/dist/migration-help.js +75 -44
- package/dist/output-context.js +77 -0
- package/dist/output-shapes.js +198 -0
- package/dist/output-text.js +520 -0
- package/dist/paths.js +4 -4
- package/dist/providers/index.js +11 -0
- package/dist/providers/skills-sh.js +1 -1
- package/dist/providers/static-index.js +47 -45
- package/dist/registry-build-index.js +36 -29
- package/dist/registry-factory.js +2 -2
- package/dist/registry-resolve.js +8 -4
- package/dist/registry-search.js +62 -5
- package/dist/remember.js +172 -0
- package/dist/renderers.js +52 -0
- package/dist/search-source.js +73 -42
- package/dist/setup-steps.js +45 -0
- package/dist/setup.js +149 -76
- package/dist/stash-add.js +94 -38
- package/dist/stash-clone.js +4 -4
- package/dist/stash-provider-factory.js +2 -2
- package/dist/stash-provider.js +3 -1
- package/dist/stash-providers/filesystem.js +31 -1
- package/dist/stash-providers/git.js +209 -8
- package/dist/stash-providers/index.js +1 -0
- package/dist/stash-providers/npm.js +159 -0
- package/dist/stash-providers/provider-utils.js +162 -0
- package/dist/stash-providers/sync-from-ref.js +45 -0
- package/dist/stash-providers/tar-utils.js +151 -0
- package/dist/stash-providers/website.js +80 -4
- package/dist/stash-resolve.js +5 -5
- package/dist/stash-search.js +4 -4
- package/dist/stash-show.js +3 -3
- package/dist/wiki.js +6 -6
- package/dist/workflow-authoring.js +12 -4
- package/dist/workflow-markdown.js +9 -0
- package/dist/workflow-runs.js +12 -2
- package/docs/README.md +30 -0
- package/docs/migration/release-notes/0.0.13.md +4 -0
- package/docs/migration/release-notes/0.1.0.md +6 -0
- package/docs/migration/release-notes/0.2.0.md +6 -0
- package/docs/migration/release-notes/0.3.0.md +5 -0
- package/docs/migration/release-notes/0.5.0.md +6 -0
- package/docs/migration/release-notes/0.6.0.md +29 -0
- package/docs/migration/release-notes/README.md +21 -0
- package/package.json +3 -2
- package/dist/registry-install.js +0 -532
- /package/dist/{kit-include.js → stash-include.js} +0 -0
package/dist/curate.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curate logic for `akm curate`.
|
|
3
|
+
*
|
|
4
|
+
* Given a query (and optional type filter / source / limit), pick a small,
|
|
5
|
+
* high-signal set of stash + registry hits and enrich each with the data
|
|
6
|
+
* needed to act (ref, run, parameters, follow-up command).
|
|
7
|
+
*
|
|
8
|
+
* The exported `akmCurate()` API is the single entry point. Internal
|
|
9
|
+
* helpers stay private. Tests can drive the public API or call the smaller
|
|
10
|
+
* pure helpers (`curateSearchResults`, `orderCuratedTypes`,
|
|
11
|
+
* `deriveCurateFallbackQueries`) by importing them directly.
|
|
12
|
+
*/
|
|
13
|
+
import { truncateDescription } from "./output-shapes";
|
|
14
|
+
import { akmSearch, parseSearchSource } from "./stash-search";
|
|
15
|
+
import { akmShowUnified } from "./stash-show";
|
|
16
|
+
const CURATE_FALLBACK_FILTER_WORDS = new Set([
|
|
17
|
+
"a",
|
|
18
|
+
"an",
|
|
19
|
+
"and",
|
|
20
|
+
"for",
|
|
21
|
+
"how",
|
|
22
|
+
"i",
|
|
23
|
+
"in",
|
|
24
|
+
"of",
|
|
25
|
+
"or",
|
|
26
|
+
"the",
|
|
27
|
+
"to",
|
|
28
|
+
"with",
|
|
29
|
+
]);
|
|
30
|
+
const CURATED_TYPE_FALLBACK_ORDER = ["skill", "command", "script", "knowledge", "agent", "memory"];
|
|
31
|
+
const CURATED_TYPE_FALLBACK_INDEX = new Map(CURATED_TYPE_FALLBACK_ORDER.map((type, index) => [type, index]));
|
|
32
|
+
const MIN_CURATE_FALLBACK_TOKEN_LENGTH = 3;
|
|
33
|
+
const MAX_CURATE_FALLBACK_KEYWORDS = 6;
|
|
34
|
+
export const CURATE_SEARCH_LIMIT_MULTIPLIER = 4;
|
|
35
|
+
export const MIN_CURATE_SEARCH_LIMIT = 12;
|
|
36
|
+
const DEFAULT_CURATE_LIMIT = 4;
|
|
37
|
+
/**
|
|
38
|
+
* Public curate entry point. Performs the search itself when
|
|
39
|
+
* `options.searchResponse` is not supplied.
|
|
40
|
+
*/
|
|
41
|
+
export async function akmCurate(options) {
|
|
42
|
+
const limit = options.limit && options.limit > 0 ? options.limit : DEFAULT_CURATE_LIMIT;
|
|
43
|
+
const source = options.source ?? parseSearchSource("stash");
|
|
44
|
+
const searchResponse = options.searchResponse ??
|
|
45
|
+
(await searchForCuration({
|
|
46
|
+
query: options.query,
|
|
47
|
+
type: options.type,
|
|
48
|
+
// Search deeper than the final curated count so we can pick one strong
|
|
49
|
+
// match per type and still have room for fallback retries.
|
|
50
|
+
limit: Math.max(limit * CURATE_SEARCH_LIMIT_MULTIPLIER, MIN_CURATE_SEARCH_LIMIT),
|
|
51
|
+
source,
|
|
52
|
+
}));
|
|
53
|
+
return curateSearchResults(options.query, searchResponse, limit, options.type);
|
|
54
|
+
}
|
|
55
|
+
export async function curateSearchResults(query, result, limit, selectedType) {
|
|
56
|
+
const stashHits = result.hits.filter((hit) => hit.type !== "registry");
|
|
57
|
+
const registryHits = result.registryHits ?? [];
|
|
58
|
+
let selectedStashHits;
|
|
59
|
+
if (selectedType && selectedType !== "any") {
|
|
60
|
+
selectedStashHits = stashHits.slice(0, limit);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
const bestByType = new Map();
|
|
64
|
+
for (const hit of stashHits) {
|
|
65
|
+
if (!bestByType.has(hit.type))
|
|
66
|
+
bestByType.set(hit.type, hit);
|
|
67
|
+
}
|
|
68
|
+
const orderedTypes = orderCuratedTypes(query, Array.from(bestByType.keys()));
|
|
69
|
+
selectedStashHits = orderedTypes
|
|
70
|
+
.map((type) => bestByType.get(type))
|
|
71
|
+
.filter((hit) => Boolean(hit));
|
|
72
|
+
}
|
|
73
|
+
const selectedRegistryHits = selectedStashHits.length >= limit ? [] : registryHits.slice(0, Math.min(2, limit - selectedStashHits.length));
|
|
74
|
+
const items = [
|
|
75
|
+
...(await Promise.all(selectedStashHits.slice(0, limit).map((hit) => enrichCuratedStashHit(query, hit)))),
|
|
76
|
+
...selectedRegistryHits.map((hit) => buildCuratedRegistryItem(query, hit)),
|
|
77
|
+
].slice(0, limit);
|
|
78
|
+
return {
|
|
79
|
+
query,
|
|
80
|
+
summary: buildCurateSummary(query, items),
|
|
81
|
+
items,
|
|
82
|
+
...(result.warnings?.length ? { warnings: result.warnings } : {}),
|
|
83
|
+
...(result.tip ? { tip: result.tip } : {}),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
export function orderCuratedTypes(query, types) {
|
|
87
|
+
const lower = query.toLowerCase();
|
|
88
|
+
const boosts = new Map();
|
|
89
|
+
const addBoost = (type, amount) => boosts.set(type, (boosts.get(type) ?? 0) + amount);
|
|
90
|
+
if (/(run|script|bash|shell|cli|execute|automation|deploy|build|test|lint)/.test(lower)) {
|
|
91
|
+
addBoost("script", 6);
|
|
92
|
+
addBoost("command", 4);
|
|
93
|
+
}
|
|
94
|
+
if (/(guide|docs?|readme|reference|how|explain|learn|why)/.test(lower)) {
|
|
95
|
+
addBoost("knowledge", 6);
|
|
96
|
+
addBoost("skill", 4);
|
|
97
|
+
}
|
|
98
|
+
if (/(agent|assistant|planner|review|analy[sz]e|architect|prompt)/.test(lower)) {
|
|
99
|
+
addBoost("agent", 6);
|
|
100
|
+
addBoost("skill", 3);
|
|
101
|
+
}
|
|
102
|
+
if (/(config|template|release|generate|command)/.test(lower)) {
|
|
103
|
+
addBoost("command", 5);
|
|
104
|
+
}
|
|
105
|
+
if (/(memory|context|recall|remember)/.test(lower)) {
|
|
106
|
+
addBoost("memory", 6);
|
|
107
|
+
}
|
|
108
|
+
return [...types].sort((a, b) => {
|
|
109
|
+
const boostDiff = (boosts.get(b) ?? 0) - (boosts.get(a) ?? 0);
|
|
110
|
+
if (boostDiff !== 0)
|
|
111
|
+
return boostDiff;
|
|
112
|
+
return ((CURATED_TYPE_FALLBACK_INDEX.get(a) ?? Number.MAX_SAFE_INTEGER) -
|
|
113
|
+
(CURATED_TYPE_FALLBACK_INDEX.get(b) ?? Number.MAX_SAFE_INTEGER));
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
async function enrichCuratedStashHit(query, hit) {
|
|
117
|
+
let shown;
|
|
118
|
+
try {
|
|
119
|
+
shown = await akmShowUnified({ ref: hit.ref });
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
shown = undefined;
|
|
123
|
+
}
|
|
124
|
+
const description = shown?.description ?? hit.description;
|
|
125
|
+
const preview = buildCuratedPreview(shown, hit);
|
|
126
|
+
return {
|
|
127
|
+
source: "stash",
|
|
128
|
+
type: shown?.type ?? hit.type,
|
|
129
|
+
name: shown?.name ?? hit.name,
|
|
130
|
+
ref: hit.ref,
|
|
131
|
+
...(description ? { description } : {}),
|
|
132
|
+
...(preview ? { preview } : {}),
|
|
133
|
+
...(shown?.parameters?.length ? { parameters: shown.parameters } : {}),
|
|
134
|
+
...(shown?.run ? { run: shown.run } : {}),
|
|
135
|
+
followUp: `akm show ${hit.ref}`,
|
|
136
|
+
reason: buildCuratedReason(query, shown?.type ?? hit.type),
|
|
137
|
+
...(hit.score !== undefined ? { score: hit.score } : {}),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function buildCuratedRegistryItem(query, hit) {
|
|
141
|
+
return {
|
|
142
|
+
source: "registry",
|
|
143
|
+
type: "registry",
|
|
144
|
+
name: hit.name,
|
|
145
|
+
id: hit.id,
|
|
146
|
+
...(hit.description ? { description: hit.description } : {}),
|
|
147
|
+
followUp: hit.action ?? `akm add ${hit.id}`,
|
|
148
|
+
reason: `Useful external source to explore for ${query}.`,
|
|
149
|
+
...(hit.score !== undefined ? { score: hit.score } : {}),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function firstNonEmpty(values) {
|
|
153
|
+
return values.find((value) => typeof value === "string" && value.trim().length > 0);
|
|
154
|
+
}
|
|
155
|
+
function buildCuratedPreview(shown, hit) {
|
|
156
|
+
if (shown?.run)
|
|
157
|
+
return truncateDescription(`run ${shown.run}`, 160);
|
|
158
|
+
const payload = firstNonEmpty([shown?.template, shown?.prompt, shown?.content, hit.description])
|
|
159
|
+
?.replace(/\s+/g, " ")
|
|
160
|
+
.trim();
|
|
161
|
+
return payload ? truncateDescription(payload, 160) : undefined;
|
|
162
|
+
}
|
|
163
|
+
function buildCuratedReason(query, type) {
|
|
164
|
+
switch (type) {
|
|
165
|
+
case "script":
|
|
166
|
+
return `Best runnable script match for "${query}".`;
|
|
167
|
+
case "command":
|
|
168
|
+
return `Best reusable command/template match for "${query}".`;
|
|
169
|
+
case "knowledge":
|
|
170
|
+
return `Best reference document match for "${query}".`;
|
|
171
|
+
case "skill":
|
|
172
|
+
return `Best instructions/workflow match for "${query}".`;
|
|
173
|
+
case "agent":
|
|
174
|
+
return `Best specialized agent prompt match for "${query}".`;
|
|
175
|
+
case "memory":
|
|
176
|
+
return `Best saved context match for "${query}".`;
|
|
177
|
+
default:
|
|
178
|
+
return `Best ${type} match for "${query}".`;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function buildCurateSummary(query, items) {
|
|
182
|
+
if (items.length === 0) {
|
|
183
|
+
return `No curated assets were selected for "${query}".`;
|
|
184
|
+
}
|
|
185
|
+
const labels = items.map((item) => `${item.type}:${item.name}`);
|
|
186
|
+
return `Selected ${items.length} high-signal result${items.length === 1 ? "" : "s"}: ${labels.join(", ")}.`;
|
|
187
|
+
}
|
|
188
|
+
function hasSearchResults(result) {
|
|
189
|
+
return result.hits.length > 0 || (result.registryHits?.length ?? 0) > 0;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Extract a small set of fallback keywords when a prompt-style curate query
|
|
193
|
+
* returns no hits as a whole phrase.
|
|
194
|
+
*
|
|
195
|
+
* We keep up to MAX_CURATE_FALLBACK_KEYWORDS distinct keywords and drop short
|
|
196
|
+
* or common filler words so follow-up searches stay inexpensive while focusing
|
|
197
|
+
* on higher-signal terms.
|
|
198
|
+
*/
|
|
199
|
+
export function deriveCurateFallbackQueries(query) {
|
|
200
|
+
return Array.from(new Set(query
|
|
201
|
+
.toLowerCase()
|
|
202
|
+
.split(/[^a-z0-9]+/)
|
|
203
|
+
.map((token) => token.trim())
|
|
204
|
+
// Keep longer tokens so fallback stays focused on higher-signal terms
|
|
205
|
+
// and avoids broad one- and two-letter matches that overwhelm curation.
|
|
206
|
+
.filter((token) => token.length >= MIN_CURATE_FALLBACK_TOKEN_LENGTH && !CURATE_FALLBACK_FILTER_WORDS.has(token)))).slice(0, MAX_CURATE_FALLBACK_KEYWORDS);
|
|
207
|
+
}
|
|
208
|
+
export function mergeCurateSearchResponses(base, extras) {
|
|
209
|
+
const hitsByRef = new Map();
|
|
210
|
+
for (const hit of base.hits.filter((entry) => entry.type !== "registry")) {
|
|
211
|
+
hitsByRef.set(hit.ref, hit);
|
|
212
|
+
}
|
|
213
|
+
for (const result of extras) {
|
|
214
|
+
for (const hit of result.hits.filter((entry) => entry.type !== "registry")) {
|
|
215
|
+
const existing = hitsByRef.get(hit.ref);
|
|
216
|
+
if (!existing || (hit.score ?? 0) > (existing.score ?? 0)) {
|
|
217
|
+
hitsByRef.set(hit.ref, hit);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const registryById = new Map();
|
|
222
|
+
for (const hit of base.registryHits ?? []) {
|
|
223
|
+
registryById.set(hit.id, hit);
|
|
224
|
+
}
|
|
225
|
+
for (const result of extras) {
|
|
226
|
+
for (const hit of result.registryHits ?? []) {
|
|
227
|
+
const existing = registryById.get(hit.id);
|
|
228
|
+
if (!existing || (hit.score ?? 0) > (existing.score ?? 0)) {
|
|
229
|
+
registryById.set(hit.id, hit);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const warnings = Array.from(new Set([...(base.warnings ?? []), ...extras.flatMap((result) => result.warnings ?? [])]));
|
|
234
|
+
const mergedHits = [...hitsByRef.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
|
235
|
+
const mergedRegistryHits = [...registryById.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
|
236
|
+
return {
|
|
237
|
+
...base,
|
|
238
|
+
hits: mergedHits,
|
|
239
|
+
...(mergedRegistryHits.length > 0 ? { registryHits: mergedRegistryHits } : {}),
|
|
240
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
241
|
+
...(mergedHits.length > 0 || mergedRegistryHits.length > 0 ? { tip: undefined } : {}),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
export async function searchForCuration(input) {
|
|
245
|
+
const initial = await akmSearch(input);
|
|
246
|
+
if (hasSearchResults(initial))
|
|
247
|
+
return initial;
|
|
248
|
+
const fallbackQueries = deriveCurateFallbackQueries(input.query);
|
|
249
|
+
if (fallbackQueries.length <= 1)
|
|
250
|
+
return initial;
|
|
251
|
+
const fallbackResults = await Promise.all(fallbackQueries.map((token) => akmSearch({
|
|
252
|
+
query: token,
|
|
253
|
+
type: input.type,
|
|
254
|
+
limit: input.limit,
|
|
255
|
+
source: input.source,
|
|
256
|
+
})));
|
|
257
|
+
return mergeCurateSearchResponses(initial, fallbackResults);
|
|
258
|
+
}
|
|
@@ -1,32 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Database-backed (SQLite + FTS5/vector) stash search implementation.
|
|
3
3
|
*
|
|
4
4
|
* Extracted from stash-search.ts to break the circular import:
|
|
5
|
-
* stash-search.ts → stash-providers/filesystem.ts →
|
|
5
|
+
* stash-search.ts → stash-providers/filesystem.ts → db-search.ts (no cycle)
|
|
6
6
|
*
|
|
7
7
|
* stash-search.ts imports this module for the `searchLocal` export.
|
|
8
8
|
* stash-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 {
|
|
15
|
+
import { defaultRendererRegistry } from "./asset-registry";
|
|
13
16
|
import { deriveCanonicalAssetNameFromStashRoot } from "./asset-spec";
|
|
14
17
|
import { closeDatabase, getAllEntries, getEntryById, getEntryCount, getMeta, getUtilityScoresByIds, openDatabase, searchFts, searchVec, } from "./db";
|
|
15
18
|
import { getRenderer } from "./file-context";
|
|
16
|
-
import { buildSearchText } from "./indexer";
|
|
17
19
|
import { generateMetadataFlat, loadStashFile, shouldIndexStashFile } from "./metadata";
|
|
18
20
|
import { getDbPath } from "./paths";
|
|
21
|
+
import { buildSearchText } from "./search-fields";
|
|
19
22
|
import { buildEditHint, findSourceForPath, isEditable } from "./search-source";
|
|
20
23
|
import { deriveSemanticProviderFingerprint, getEffectiveSemanticStatus, isSemanticRuntimeReady, readSemanticStatus, } from "./semantic-status";
|
|
21
24
|
import { makeAssetRef } from "./stash-ref";
|
|
22
25
|
import { walkStashFlat } from "./walker";
|
|
23
26
|
import { warn } from "./warn";
|
|
24
|
-
export async function rendererForType(type) {
|
|
25
|
-
const name =
|
|
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 =
|
|
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,6 +44,7 @@ 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;
|
|
47
|
+
const rendererRegistry = input.rendererRegistry ?? defaultRendererRegistry;
|
|
44
48
|
const allStashDirs = sources.map((s) => s.path);
|
|
45
49
|
const rawStatus = readSemanticStatus();
|
|
46
50
|
const semanticStatus = getEffectiveSemanticStatus(config, rawStatus);
|
|
@@ -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, allStashDirs, 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(allStashDirs.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, allStashDirs, config, sources, rendererRegistry = defaultRendererRegistry) {
|
|
114
118
|
// Empty query: return all entries
|
|
115
119
|
if (!query) {
|
|
116
120
|
const typeFilter = searchType === "any" ? undefined : searchType;
|
|
@@ -134,6 +138,7 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allStashDi
|
|
|
134
138
|
allStashDirs,
|
|
135
139
|
sources,
|
|
136
140
|
config,
|
|
141
|
+
rendererRegistry,
|
|
137
142
|
})));
|
|
138
143
|
return { hits };
|
|
139
144
|
}
|
|
@@ -371,6 +376,7 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allStashDi
|
|
|
371
376
|
sources,
|
|
372
377
|
config,
|
|
373
378
|
utilityBoosted,
|
|
379
|
+
rendererRegistry,
|
|
374
380
|
})));
|
|
375
381
|
return { embedMs, rankMs, hits };
|
|
376
382
|
}
|
|
@@ -401,20 +407,24 @@ async function tryVecScores(db, query, k, config) {
|
|
|
401
407
|
}
|
|
402
408
|
}
|
|
403
409
|
// ── Substring fallback (no index) ───────────────────────────────────────────
|
|
404
|
-
async function substringSearch(query, searchType, limit, stashDir, sources, config) {
|
|
410
|
+
async function substringSearch(query, searchType, limit, stashDir, sources, config, rendererRegistry = defaultRendererRegistry) {
|
|
405
411
|
const assets = await indexAssets(stashDir, searchType, sources);
|
|
406
412
|
const matched = assets.filter((asset) => !query || buildSearchText(asset.entry).includes(query));
|
|
407
413
|
if (!query) {
|
|
408
414
|
const sorted = matched.sort(compareAssets);
|
|
409
415
|
const unique = deduplicateAssetsByPath(sorted);
|
|
410
|
-
return Promise.all(unique
|
|
416
|
+
return Promise.all(unique
|
|
417
|
+
.slice(0, limit)
|
|
418
|
+
.map((asset) => assetToSearchHit(asset, stashDir, sources, config, undefined, rendererRegistry)));
|
|
411
419
|
}
|
|
412
420
|
// Score and sort by relevance
|
|
413
421
|
const scored = matched.map((asset) => ({ asset, score: scoreSubstringMatch(asset.entry, query) }));
|
|
414
422
|
scored.sort((a, b) => b.score - a.score || compareAssets(a.asset, b.asset));
|
|
415
423
|
// Deduplicate by path — keep highest-scored entry per file
|
|
416
424
|
const dedupedScored = deduplicateByPath(scored.map((s) => ({ ...s, filePath: s.asset.path })));
|
|
417
|
-
return Promise.all(dedupedScored
|
|
425
|
+
return Promise.all(dedupedScored
|
|
426
|
+
.slice(0, limit)
|
|
427
|
+
.map(({ asset, score }) => assetToSearchHit(asset, stashDir, sources, config, score, rendererRegistry)));
|
|
418
428
|
}
|
|
419
429
|
function scoreSubstringMatch(entry, query) {
|
|
420
430
|
const tokens = query.split(/\s+/).filter(Boolean);
|
|
@@ -444,6 +454,7 @@ function scoreSubstringMatch(entry, query) {
|
|
|
444
454
|
}
|
|
445
455
|
// ── Hit building ────────────────────────────────────────────────────────────
|
|
446
456
|
export async function buildDbHit(input) {
|
|
457
|
+
const rendererRegistry = input.rendererRegistry ?? defaultRendererRegistry;
|
|
447
458
|
const entryStashDir = findSourceForPath(input.path, input.sources)?.path ?? input.defaultStashDir;
|
|
448
459
|
const canonical = deriveCanonicalAssetNameFromStashRoot(input.entry.type, entryStashDir, input.path);
|
|
449
460
|
const refName = canonical && !canonical.startsWith("../") && !canonical.startsWith("..\\") ? canonical : input.entry.name;
|
|
@@ -471,12 +482,12 @@ export async function buildDbHit(input) {
|
|
|
471
482
|
description: input.entry.description,
|
|
472
483
|
tags: input.entry.tags,
|
|
473
484
|
size: deriveSize(input.entry.fileSize),
|
|
474
|
-
action: buildLocalAction(input.entry.type, ref),
|
|
485
|
+
action: buildLocalAction(input.entry.type, ref, rendererRegistry),
|
|
475
486
|
score,
|
|
476
487
|
whyMatched,
|
|
477
488
|
...(estimatedTokens !== undefined ? { estimatedTokens } : {}),
|
|
478
489
|
};
|
|
479
|
-
const renderer = await rendererForType(input.entry.type);
|
|
490
|
+
const renderer = await rendererForType(input.entry.type, rendererRegistry);
|
|
480
491
|
if (renderer?.enrichSearchHit) {
|
|
481
492
|
renderer.enrichSearchHit(hit, entryStashDir);
|
|
482
493
|
}
|
|
@@ -530,7 +541,7 @@ rankingMode, qualityBoost, confidenceBoost, utilityBoosted) {
|
|
|
530
541
|
reasons.push("usage history boost");
|
|
531
542
|
return reasons;
|
|
532
543
|
}
|
|
533
|
-
async function assetToSearchHit(asset, stashDir, sources, config, score) {
|
|
544
|
+
async function assetToSearchHit(asset, stashDir, sources, config, score, rendererRegistry = defaultRendererRegistry) {
|
|
534
545
|
const source = findSourceForPath(asset.path, sources);
|
|
535
546
|
const editable = isEditable(asset.path, config);
|
|
536
547
|
const ref = resolveSearchHitRef(asset.entry, asset.entry.name, source);
|
|
@@ -550,11 +561,11 @@ async function assetToSearchHit(asset, stashDir, sources, config, score) {
|
|
|
550
561
|
description: asset.entry.description,
|
|
551
562
|
tags: asset.entry.tags,
|
|
552
563
|
...(size ? { size } : {}),
|
|
553
|
-
action: buildLocalAction(asset.entry.type, ref),
|
|
564
|
+
action: buildLocalAction(asset.entry.type, ref, rendererRegistry),
|
|
554
565
|
...(score !== undefined ? { score } : {}),
|
|
555
566
|
...(estimatedTokens !== undefined ? { estimatedTokens } : {}),
|
|
556
567
|
};
|
|
557
|
-
const renderer = await rendererForType(asset.entry.type);
|
|
568
|
+
const renderer = await rendererForType(asset.entry.type, rendererRegistry);
|
|
558
569
|
if (renderer?.enrichSearchHit) {
|
|
559
570
|
renderer.enrichSearchHit(hit, stashDir);
|
|
560
571
|
}
|