akm-cli 0.7.4 → 0.8.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 → .github/CHANGELOG.md} +34 -1
- package/.github/LICENSE +374 -0
- package/dist/cli/parse-args.js +43 -0
- package/dist/cli.js +1007 -593
- package/dist/commands/agent-dispatch.js +102 -0
- package/dist/commands/agent-support.js +62 -0
- package/dist/commands/config-cli.js +68 -84
- package/dist/commands/consolidate.js +823 -0
- package/dist/commands/curate.js +1 -0
- package/dist/commands/distill-promotion-policy.js +658 -0
- package/dist/commands/distill.js +250 -48
- package/dist/commands/eval-cases.js +40 -0
- package/dist/commands/events.js +12 -24
- package/dist/commands/graph.js +222 -0
- package/dist/commands/health.js +376 -0
- package/dist/commands/help/help-accept.md +9 -0
- package/dist/commands/help/help-improve.md +53 -0
- package/dist/commands/help/help-proposals.md +15 -0
- package/dist/commands/help/help-propose.md +17 -0
- package/dist/commands/help/help-reject.md +8 -0
- package/dist/commands/history.js +3 -30
- package/dist/commands/improve.js +1170 -0
- package/dist/commands/info.js +2 -2
- package/dist/commands/init.js +2 -2
- package/dist/commands/install-audit.js +5 -1
- package/dist/commands/installed-stashes.js +118 -138
- package/dist/commands/knowledge.js +133 -0
- package/dist/commands/lint/agent-linter.js +46 -0
- package/dist/commands/lint/base-linter.js +251 -0
- package/dist/commands/lint/command-linter.js +46 -0
- package/dist/commands/lint/default-linter.js +13 -0
- package/dist/commands/lint/index.js +107 -0
- package/dist/commands/lint/knowledge-linter.js +13 -0
- package/dist/commands/lint/memory-linter.js +58 -0
- package/dist/commands/lint/registry.js +33 -0
- package/dist/commands/lint/skill-linter.js +42 -0
- package/dist/commands/lint/task-linter.js +47 -0
- package/dist/commands/lint/types.js +1 -0
- package/dist/commands/lint/workflow-linter.js +53 -0
- package/dist/commands/lint.js +1 -0
- package/dist/commands/migration-help.js +2 -2
- package/dist/commands/proposal.js +8 -7
- package/dist/commands/propose.js +113 -43
- package/dist/commands/reflect.js +175 -41
- package/dist/commands/registry-search.js +2 -2
- package/dist/commands/remember.js +55 -1
- package/dist/commands/schema-repair.js +130 -0
- package/dist/commands/search.js +21 -5
- package/dist/commands/show.js +131 -52
- package/dist/commands/source-add.js +10 -10
- package/dist/commands/source-manage.js +11 -19
- package/dist/commands/tasks.js +385 -0
- package/dist/commands/url-checker.js +39 -0
- package/dist/commands/vault.js +7 -33
- package/dist/core/action-contributors.js +25 -0
- package/dist/core/asset-registry.js +5 -17
- package/dist/core/asset-spec.js +11 -1
- package/dist/core/common.js +94 -0
- package/dist/core/concurrent.js +22 -0
- package/dist/core/config.js +229 -122
- package/dist/core/events.js +87 -123
- package/dist/core/frontmatter.js +3 -1
- package/dist/core/markdown.js +17 -0
- package/dist/core/memory-improve.js +678 -0
- package/dist/core/parse.js +155 -0
- package/dist/core/paths.js +101 -3
- package/dist/core/proposal-validators.js +61 -0
- package/dist/core/proposals.js +49 -38
- package/dist/core/state-db.js +775 -0
- package/dist/core/time.js +51 -0
- package/dist/core/warn.js +59 -1
- package/dist/indexer/db-search.js +86 -472
- package/dist/indexer/db.js +392 -6
- package/dist/indexer/ensure-index.js +133 -0
- package/dist/indexer/graph-boost.js +247 -94
- package/dist/indexer/graph-db.js +201 -0
- package/dist/indexer/graph-dedup.js +99 -0
- package/dist/indexer/graph-extraction.js +417 -74
- package/dist/indexer/index-context.js +10 -0
- package/dist/indexer/indexer.js +466 -298
- package/dist/indexer/llm-cache.js +47 -0
- package/dist/indexer/match-contributors.js +141 -0
- package/dist/indexer/matchers.js +24 -190
- package/dist/indexer/memory-inference.js +63 -29
- package/dist/indexer/metadata-contributors.js +26 -0
- package/dist/indexer/metadata.js +188 -175
- package/dist/indexer/path-resolver.js +89 -0
- package/dist/indexer/ranking-contributors.js +204 -0
- package/dist/indexer/ranking.js +74 -0
- package/dist/indexer/search-hit-enrichers.js +22 -0
- package/dist/indexer/search-source.js +24 -9
- package/dist/indexer/semantic-status.js +2 -16
- package/dist/indexer/walker.js +25 -0
- package/dist/integrations/agent/config.js +175 -3
- package/dist/integrations/agent/index.js +3 -1
- package/dist/integrations/agent/pipeline.js +39 -0
- package/dist/integrations/agent/profiles.js +67 -5
- package/dist/integrations/agent/prompts.js +114 -29
- package/dist/integrations/agent/runners.js +31 -0
- package/dist/integrations/agent/sdk-runner.js +120 -0
- package/dist/integrations/agent/spawn.js +136 -28
- package/dist/integrations/lockfile.js +10 -18
- package/dist/integrations/session-logs/index.js +65 -0
- package/dist/integrations/session-logs/providers/claude-code.js +56 -0
- package/dist/integrations/session-logs/providers/opencode.js +52 -0
- package/dist/integrations/session-logs/types.js +1 -0
- package/dist/llm/call-ai.js +74 -0
- package/dist/llm/client.js +63 -86
- package/dist/llm/feature-gate.js +27 -16
- package/dist/llm/graph-extract.js +297 -64
- package/dist/llm/memory-infer.js +52 -71
- package/dist/llm/metadata-enhance.js +39 -22
- package/dist/llm/prompts/graph-extract-user-prompt.md +12 -0
- package/dist/output/cli-hints-full.md +277 -0
- package/dist/output/cli-hints-short.md +65 -0
- package/dist/output/cli-hints.js +2 -309
- package/dist/output/renderers.js +196 -124
- package/dist/output/shapes.js +41 -3
- package/dist/output/text.js +257 -21
- package/dist/registry/providers/skills-sh.js +61 -49
- package/dist/registry/providers/static-index.js +44 -48
- package/dist/setup/setup.js +510 -11
- package/dist/sources/provider-factory.js +2 -1
- package/dist/sources/providers/git.js +44 -2
- package/dist/sources/website-ingest.js +4 -0
- package/dist/tasks/backends/cron.js +200 -0
- package/dist/tasks/backends/exec-utils.js +25 -0
- package/dist/tasks/backends/index.js +32 -0
- package/dist/tasks/backends/launchd-template.xml +19 -0
- package/dist/tasks/backends/launchd.js +184 -0
- package/dist/tasks/backends/schtasks-template.xml +29 -0
- package/dist/tasks/backends/schtasks.js +212 -0
- package/dist/tasks/parser.js +198 -0
- package/dist/tasks/resolveAkmBin.js +84 -0
- package/dist/tasks/runner.js +432 -0
- package/dist/tasks/schedule.js +208 -0
- package/dist/tasks/schema.js +13 -0
- package/dist/tasks/validator.js +59 -0
- package/dist/wiki/index-template.md +12 -0
- package/dist/wiki/ingest-workflow-template.md +54 -0
- package/dist/wiki/log-template.md +8 -0
- package/dist/wiki/schema-template.md +61 -0
- package/dist/wiki/wiki-templates.js +12 -0
- package/dist/wiki/wiki.js +10 -61
- package/dist/workflows/authoring.js +5 -25
- package/dist/workflows/db.js +9 -0
- package/dist/workflows/renderer.js +8 -3
- package/dist/workflows/runs.js +73 -88
- package/dist/workflows/scope-key.js +76 -0
- package/dist/workflows/validator.js +1 -1
- package/dist/workflows/workflow-template.md +24 -0
- package/docs/README.md +3 -0
- package/docs/migration/release-notes/0.7.0.md +1 -1
- package/docs/migration/release-notes/0.7.4.md +1 -1
- package/docs/migration/release-notes/0.7.5.md +20 -0
- package/docs/migration/release-notes/0.8.0.md +43 -0
- package/package.json +4 -3
- package/dist/templates/wiki-templates.js +0 -100
|
@@ -11,27 +11,21 @@
|
|
|
11
11
|
* implementation, not a "local vs. remote" distinction.
|
|
12
12
|
*/
|
|
13
13
|
import fs from "node:fs";
|
|
14
|
-
import
|
|
14
|
+
import { buildActionFromContributors, defaultActionContributors } from "../core/action-contributors";
|
|
15
15
|
import { makeAssetRef } from "../core/asset-ref";
|
|
16
16
|
import { defaultRendererRegistry } from "../core/asset-registry";
|
|
17
|
-
import { deriveCanonicalAssetNameFromStashRoot } from "../core/asset-spec";
|
|
18
17
|
import { getDbPath } from "../core/paths";
|
|
19
18
|
import { warn } from "../core/warn";
|
|
20
|
-
import { closeDatabase, getAllEntries, getEntryById, getEntryCount, getMeta,
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
19
|
+
import { closeDatabase, getAllEntries, getEntryById, getEntryCount, getMeta, openExistingDatabase, sanitizeFtsQuery, searchFts, searchVec, } from "./db";
|
|
20
|
+
import { ensureIndex } from "./ensure-index";
|
|
21
|
+
import { collectGraphRelatedHit, loadGraphBoostContext } from "./graph-boost";
|
|
22
|
+
import { isProposedQuality } from "./metadata";
|
|
23
|
+
import { applyRankingRules, combineSearchScores, normalizeFtsScores } from "./ranking";
|
|
24
|
+
import { enrichSearchHit } from "./search-hit-enrichers";
|
|
25
25
|
import { buildEditHint, findSourceForPath, isEditable } from "./search-source";
|
|
26
26
|
import { deriveSemanticProviderFingerprint, getEffectiveSemanticStatus, isSemanticRuntimeReady, readSemanticStatus, } from "./semantic-status";
|
|
27
|
-
import { walkStashFlat } from "./walker";
|
|
28
|
-
export async function rendererForType(type, registry = defaultRendererRegistry) {
|
|
29
|
-
const name = registry.rendererNameFor(type);
|
|
30
|
-
return name ? getRenderer(name) : undefined;
|
|
31
|
-
}
|
|
32
27
|
export function buildLocalAction(type, ref, registry = defaultRendererRegistry) {
|
|
33
|
-
|
|
34
|
-
return builder ? builder(ref) : `akm show ${ref}`;
|
|
28
|
+
return buildActionFromContributors({ type, ref }, defaultActionContributors(registry)) ?? `akm show ${ref}`;
|
|
35
29
|
}
|
|
36
30
|
function resolveSearchHitRef(entry, refName, source) {
|
|
37
31
|
if (source?.wikiName) {
|
|
@@ -47,13 +41,13 @@ export async function searchLocal(input) {
|
|
|
47
41
|
const { query, searchType, limit, stashDir, sources, config } = input;
|
|
48
42
|
const filters = input.filters;
|
|
49
43
|
const includeProposed = input.includeProposed === true;
|
|
44
|
+
const beliefFilter = input.beliefFilter ?? "all";
|
|
50
45
|
const rendererRegistry = input.rendererRegistry ?? defaultRendererRegistry;
|
|
51
46
|
const allSourceDirs = sources.map((s) => s.path);
|
|
52
47
|
const rawStatus = readSemanticStatus();
|
|
53
48
|
const semanticStatus = getEffectiveSemanticStatus(config, rawStatus);
|
|
54
49
|
const warnings = [];
|
|
55
50
|
if (config.semanticSearchMode === "auto" && semanticStatus === "pending") {
|
|
56
|
-
// Distinguish between fingerprint mismatch (config changed) and never-set-up.
|
|
57
51
|
const currentFingerprint = deriveSemanticProviderFingerprint(config.embedding);
|
|
58
52
|
if (rawStatus && rawStatus.providerFingerprint !== currentFingerprint) {
|
|
59
53
|
warnings.push("Embedding config changed. Run 'akm index --full' to rebuild the semantic index with the new provider.");
|
|
@@ -65,58 +59,46 @@ export async function searchLocal(input) {
|
|
|
65
59
|
if (config.semanticSearchMode === "auto" && semanticStatus === "blocked") {
|
|
66
60
|
warnings.push("Semantic search is currently blocked. Using keyword search until the semantic backend is healthy again.");
|
|
67
61
|
}
|
|
68
|
-
//
|
|
62
|
+
// Auto-index when stale so the DB is always current before querying.
|
|
63
|
+
await ensureIndex(stashDir);
|
|
69
64
|
const dbPath = getDbPath();
|
|
65
|
+
if (!fs.existsSync(dbPath)) {
|
|
66
|
+
return {
|
|
67
|
+
hits: [],
|
|
68
|
+
tip: "No search index available. Run 'akm index' to build one.",
|
|
69
|
+
warnings: warnings.length > 0 ? warnings : undefined,
|
|
70
|
+
mode: "keyword",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const db = openExistingDatabase(dbPath);
|
|
70
74
|
try {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
let stashDirMatch = storedStashDir === stashDir;
|
|
80
|
-
if (!stashDirMatch) {
|
|
81
|
-
try {
|
|
82
|
-
const storedDirs = JSON.parse(getMeta(db, "stashDirs") ?? "[]");
|
|
83
|
-
stashDirMatch = storedDirs.includes(stashDir);
|
|
84
|
-
}
|
|
85
|
-
catch {
|
|
86
|
-
/* ignore malformed stashDirs */
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
if (entryCount > 0 && stashDirMatch) {
|
|
90
|
-
const { hits, embedMs, rankMs } = await searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry, filters, includeProposed);
|
|
91
|
-
return {
|
|
92
|
-
hits,
|
|
93
|
-
tip: hits.length === 0
|
|
94
|
-
? "No matching stash assets were found. Try running 'akm index' to rebuild."
|
|
95
|
-
: undefined,
|
|
96
|
-
warnings: warnings.length > 0 ? warnings : undefined,
|
|
97
|
-
embedMs,
|
|
98
|
-
rankMs,
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
finally {
|
|
103
|
-
closeDatabase(db);
|
|
104
|
-
}
|
|
75
|
+
const entryCount = getEntryCount(db);
|
|
76
|
+
if (entryCount === 0) {
|
|
77
|
+
return {
|
|
78
|
+
hits: [],
|
|
79
|
+
tip: "Index is empty. Run 'akm index' to populate it.",
|
|
80
|
+
warnings: warnings.length > 0 ? warnings : undefined,
|
|
81
|
+
mode: "keyword",
|
|
82
|
+
};
|
|
105
83
|
}
|
|
84
|
+
const { hits, embedMs, rankMs } = await searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry, filters, includeProposed, beliefFilter);
|
|
85
|
+
return {
|
|
86
|
+
hits,
|
|
87
|
+
tip: hits.length === 0
|
|
88
|
+
? "No matching stash assets were found. Try a different query or run 'akm index' to rebuild."
|
|
89
|
+
: undefined,
|
|
90
|
+
warnings: warnings.length > 0 ? warnings : undefined,
|
|
91
|
+
embedMs,
|
|
92
|
+
rankMs,
|
|
93
|
+
mode: embedMs !== undefined && embedMs > 0 ? "semantic" : "keyword",
|
|
94
|
+
};
|
|
106
95
|
}
|
|
107
|
-
|
|
108
|
-
|
|
96
|
+
finally {
|
|
97
|
+
closeDatabase(db);
|
|
109
98
|
}
|
|
110
|
-
const hitArrays = await Promise.all(allSourceDirs.map((dir) => substringSearch(query, searchType, limit, dir, sources, config, rendererRegistry, filters, includeProposed)));
|
|
111
|
-
const hits = hitArrays.flat().slice(0, limit);
|
|
112
|
-
return {
|
|
113
|
-
hits,
|
|
114
|
-
tip: hits.length === 0 ? "No matching stash assets were found. Try running 'akm index' to rebuild." : undefined,
|
|
115
|
-
warnings: warnings.length > 0 ? warnings : undefined,
|
|
116
|
-
};
|
|
117
99
|
}
|
|
118
100
|
// ── Database search ─────────────────────────────────────────────────────────
|
|
119
|
-
async function searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry = defaultRendererRegistry, filters, includeProposed = false) {
|
|
101
|
+
async function searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry = defaultRendererRegistry, filters, includeProposed = false, beliefFilter = "all") {
|
|
120
102
|
const hasSearchableTokens = query.length > 0 && sanitizeFtsQuery(query).length > 0;
|
|
121
103
|
// Empty queries — including ones that sanitize down to no searchable FTS
|
|
122
104
|
// tokens such as "." — should enumerate matching entries instead of
|
|
@@ -143,7 +125,8 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
|
|
|
143
125
|
const qualityFiltered = includeProposed
|
|
144
126
|
? scopeFiltered
|
|
145
127
|
: scopeFiltered.filter((ie) => !isProposedQuality(ie.entry.quality));
|
|
146
|
-
const
|
|
128
|
+
const beliefFiltered = qualityFiltered.filter((ie) => matchBeliefFilter(ie.entry.type, ie.entry.beliefState, beliefFilter));
|
|
129
|
+
const selected = beliefFiltered.slice(0, limit);
|
|
147
130
|
const hits = await Promise.all(selected.map((ie) => buildDbHit({
|
|
148
131
|
entry: ie.entry,
|
|
149
132
|
path: ie.filePath,
|
|
@@ -170,22 +153,7 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
|
|
|
170
153
|
// ── Score normalization ──────────────────────────────────────────────
|
|
171
154
|
// Normalized BM25 + cosine similarity with weighted addition
|
|
172
155
|
// (FTS 0.7, vector 0.3) for well-differentiated combined scores.
|
|
173
|
-
|
|
174
|
-
const ftsScoreMap = new Map();
|
|
175
|
-
if (ftsResults.length > 0) {
|
|
176
|
-
// BM25 scores are negative; most negative = best match
|
|
177
|
-
const bestBm25 = ftsResults[0].bm25Score; // most negative (best)
|
|
178
|
-
const worstBm25 = ftsResults[ftsResults.length - 1].bm25Score; // least negative (worst)
|
|
179
|
-
const range = bestBm25 - worstBm25; // negative range
|
|
180
|
-
for (const r of ftsResults) {
|
|
181
|
-
// Normalize: best match = 1.0, worst match approaches 0
|
|
182
|
-
// When range is 0 (all same score), all get 1.0
|
|
183
|
-
const normalized = range !== 0 ? (r.bm25Score - worstBm25) / range : 1.0;
|
|
184
|
-
// Scale to 0.3-1.0 range so even the worst FTS hit has a meaningful base score
|
|
185
|
-
const ftsScore = 0.3 + normalized * 0.7;
|
|
186
|
-
ftsScoreMap.set(r.id, { score: ftsScore, result: r });
|
|
187
|
-
}
|
|
188
|
-
}
|
|
156
|
+
const ftsScoreMap = normalizeFtsScores(ftsResults);
|
|
189
157
|
// Build embedding score map (cosine similarities already 0-1)
|
|
190
158
|
const embedScoreMap = new Map();
|
|
191
159
|
if (embeddingScores) {
|
|
@@ -194,46 +162,12 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
|
|
|
194
162
|
}
|
|
195
163
|
}
|
|
196
164
|
// ── Combine FTS + vector scores ──────────────────────────────────────
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
for (const [id, { score: ftsScore, result }] of ftsScoreMap) {
|
|
204
|
-
seenIds.add(id);
|
|
205
|
-
const embedScore = embedScoreMap.get(id);
|
|
206
|
-
let combinedScore;
|
|
207
|
-
let rankingMode;
|
|
208
|
-
if (embedScore !== undefined) {
|
|
209
|
-
combinedScore = ftsScore * FTS_WEIGHT + embedScore * VEC_WEIGHT;
|
|
210
|
-
rankingMode = "hybrid";
|
|
211
|
-
}
|
|
212
|
-
else {
|
|
213
|
-
combinedScore = ftsScore;
|
|
214
|
-
rankingMode = "fts";
|
|
215
|
-
}
|
|
216
|
-
scored.push({ id, entry: result.entry, filePath: result.filePath, score: combinedScore, rankingMode });
|
|
217
|
-
}
|
|
218
|
-
// Add vec-only results not already in FTS results
|
|
219
|
-
if (embeddingScores) {
|
|
220
|
-
for (const [id, cosine] of embeddingScores) {
|
|
221
|
-
if (seenIds.has(id))
|
|
222
|
-
continue;
|
|
223
|
-
const found = getEntryById(db, id);
|
|
224
|
-
if (found) {
|
|
225
|
-
if (typeFilter && found.entry.type !== typeFilter)
|
|
226
|
-
continue;
|
|
227
|
-
scored.push({
|
|
228
|
-
id,
|
|
229
|
-
entry: found.entry,
|
|
230
|
-
filePath: found.filePath,
|
|
231
|
-
score: cosine * VEC_WEIGHT, // Only vector score, no FTS
|
|
232
|
-
rankingMode: "semantic",
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
}
|
|
165
|
+
const scored = combineSearchScores({
|
|
166
|
+
ftsScoreMap,
|
|
167
|
+
embedScoreMap,
|
|
168
|
+
getEntryById: (id) => getEntryById(db, id) ?? undefined,
|
|
169
|
+
typeFilter,
|
|
170
|
+
});
|
|
237
171
|
// ── Scoring Phase ──────────────────────────────────────────────────────
|
|
238
172
|
// Apply boosts as multiplicative factors (all boosts in a single phase
|
|
239
173
|
// so that sort order and displayed scores are always consistent).
|
|
@@ -242,8 +176,6 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
|
|
|
242
176
|
// user's intent. An exact name match is the strongest signal. Actionable
|
|
243
177
|
// asset types (skills, commands, agents) are more useful than passive
|
|
244
178
|
// reference docs. Curated metadata is more reliable than auto-generated.
|
|
245
|
-
const queryTokens = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
246
|
-
const queryLower = query.toLowerCase().trim();
|
|
247
179
|
// Graph boost context (#207). Built once per query and reused across
|
|
248
180
|
// every scored entry so the disk read + JSON parse only happens once
|
|
249
181
|
// per search invocation. `null` when no graph file is present, when
|
|
@@ -256,170 +188,11 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
|
|
|
256
188
|
// Search across all source dirs; the graph file lives next to the
|
|
257
189
|
// primary source root. Cache misses are silent — the helper handles
|
|
258
190
|
// missing files internally and returns `null` instead of throwing.
|
|
259
|
-
|
|
260
|
-
if (!primaryDir)
|
|
191
|
+
if (allSourceDirs.length === 0)
|
|
261
192
|
return null;
|
|
262
|
-
return loadGraphBoostContext(
|
|
193
|
+
return loadGraphBoostContext(allSourceDirs, query, config, db);
|
|
263
194
|
})();
|
|
264
|
-
|
|
265
|
-
const entry = item.entry;
|
|
266
|
-
let boostSum = 0;
|
|
267
|
-
// ── 1. Exact / near-exact name match (strongest signal) ──
|
|
268
|
-
// If the query IS the asset name (or very close), this is almost certainly
|
|
269
|
-
// what the user wants. This is the single most important ranking signal.
|
|
270
|
-
const nameLower = entry.name.toLowerCase();
|
|
271
|
-
const rawNameBase = nameLower.split("/").pop() ?? nameLower; // last segment for path-based names
|
|
272
|
-
const nameBase = entry.type === "memory" && rawNameBase.endsWith(".derived")
|
|
273
|
-
? rawNameBase.slice(0, -".derived".length)
|
|
274
|
-
: rawNameBase;
|
|
275
|
-
if (nameBase === queryLower || nameLower === queryLower) {
|
|
276
|
-
// Exact match: massive boost
|
|
277
|
-
boostSum += 2.0;
|
|
278
|
-
}
|
|
279
|
-
else if (nameBase.includes(queryLower) || queryLower.includes(nameBase)) {
|
|
280
|
-
// Near-exact: query is substring of name or vice versa
|
|
281
|
-
boostSum += 1.0;
|
|
282
|
-
}
|
|
283
|
-
else {
|
|
284
|
-
// Token overlap: how many query tokens appear in the base name?
|
|
285
|
-
const nameTokens = nameBase.split(/[-_\s]+/).filter(Boolean);
|
|
286
|
-
const matchCount = queryTokens.filter((qt) => nameTokens.some((nt) => nt === qt || nt.includes(qt))).length;
|
|
287
|
-
if (matchCount > 0) {
|
|
288
|
-
// Proportional to how many query tokens match (0.3 per token, max 0.9)
|
|
289
|
-
boostSum += Math.min(0.9, matchCount * 0.3);
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
// ── 2. Type relevance boost ──
|
|
293
|
-
// Actionable assets (skills, commands, agents) are generally more useful
|
|
294
|
-
// than passive reference material when the user is searching for something
|
|
295
|
-
// to use. Knowledge docs are reference — valuable but secondary.
|
|
296
|
-
const TYPE_BOOST = {
|
|
297
|
-
skill: 0.4,
|
|
298
|
-
command: 0.35,
|
|
299
|
-
workflow: 0.35,
|
|
300
|
-
agent: 0.3,
|
|
301
|
-
script: 0.2,
|
|
302
|
-
memory: 0.1,
|
|
303
|
-
knowledge: 0,
|
|
304
|
-
};
|
|
305
|
-
boostSum += TYPE_BOOST[entry.type] ?? 0;
|
|
306
|
-
// ── 2.5. Derived-vs-raw memory preference ──
|
|
307
|
-
// Raw memories are user notes and may be incomplete or unvetted. Compressed
|
|
308
|
-
// `.derived` memories are the higher-signal retrieval target, but the
|
|
309
|
-
// preference should stay modest so stronger relevance signals still dominate.
|
|
310
|
-
if (entry.type === "memory") {
|
|
311
|
-
if (entry.name.toLowerCase().endsWith(".derived")) {
|
|
312
|
-
boostSum += 0.18;
|
|
313
|
-
}
|
|
314
|
-
else {
|
|
315
|
-
boostSum -= 0.08;
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
// ── 3. Tag exact match ──
|
|
319
|
-
// Exact tag equality is a strong signal — the author explicitly tagged
|
|
320
|
-
// this asset with the user's search term.
|
|
321
|
-
if (entry.tags) {
|
|
322
|
-
let tagBoost = 0;
|
|
323
|
-
for (const tag of entry.tags) {
|
|
324
|
-
if (queryTokens.some((t) => tag.toLowerCase() === t)) {
|
|
325
|
-
tagBoost += 0.15;
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
boostSum += Math.min(0.3, tagBoost);
|
|
329
|
-
}
|
|
330
|
-
// ── 4. Search hint match ──
|
|
331
|
-
// Hints are author-curated retrieval cues (e.g. "use when deploying to k8s").
|
|
332
|
-
if (entry.searchHints) {
|
|
333
|
-
let hintBoost = 0;
|
|
334
|
-
for (const hint of entry.searchHints) {
|
|
335
|
-
const hintLower = hint.toLowerCase();
|
|
336
|
-
for (const token of queryTokens) {
|
|
337
|
-
if (hintLower.includes(token)) {
|
|
338
|
-
hintBoost += 0.12;
|
|
339
|
-
break;
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
boostSum += Math.min(0.24, hintBoost);
|
|
344
|
-
}
|
|
345
|
-
// ── 5. Alias match ──
|
|
346
|
-
// Aliases are alternate names the author defined for discovery.
|
|
347
|
-
if (entry.aliases) {
|
|
348
|
-
for (const alias of entry.aliases) {
|
|
349
|
-
const aliasLower = alias.toLowerCase();
|
|
350
|
-
if (aliasLower === queryLower) {
|
|
351
|
-
boostSum += 1.5; // Nearly as strong as exact name match
|
|
352
|
-
break;
|
|
353
|
-
}
|
|
354
|
-
if (queryTokens.some((t) => aliasLower.includes(t))) {
|
|
355
|
-
boostSum += 0.3;
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
// ── 6. Description relevance ──
|
|
360
|
-
// All query tokens appearing in description suggests strong relevance.
|
|
361
|
-
if (entry.description) {
|
|
362
|
-
const descLower = entry.description.toLowerCase();
|
|
363
|
-
const descMatchCount = queryTokens.filter((t) => descLower.includes(t)).length;
|
|
364
|
-
if (descMatchCount === queryTokens.length && queryTokens.length > 1) {
|
|
365
|
-
// All query tokens found in description — high relevance
|
|
366
|
-
boostSum += 0.25;
|
|
367
|
-
}
|
|
368
|
-
else if (descMatchCount > 0) {
|
|
369
|
-
boostSum += 0.1;
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
// ── 7. Metadata quality signals ──
|
|
373
|
-
// Curated metadata is the only boost-bearing quality marker. `generated`
|
|
374
|
-
// and `proposed` (and unknown values) get no boost. `proposed` is also
|
|
375
|
-
// filtered out by default downstream (v1 spec §4.2).
|
|
376
|
-
const qualityBoost = entry.quality === "curated" ? 0.05 : 0;
|
|
377
|
-
boostSum += qualityBoost;
|
|
378
|
-
const confidenceBoost = typeof entry.confidence === "number" ? Math.min(0.05, Math.max(0, entry.confidence) * 0.05) : 0;
|
|
379
|
-
boostSum += confidenceBoost;
|
|
380
|
-
// ── 8. Graph signal (opt-in, #207) ──
|
|
381
|
-
// When the graph-extraction pass has produced a `graph.json`,
|
|
382
|
-
// contribute an additive boost based on how many of this entry's
|
|
383
|
-
// extracted entities match the query (or are one hop away from a
|
|
384
|
-
// match). Computed inside the same loop so all boosts are in one
|
|
385
|
-
// place and the per-call cost is one map lookup when the graph is
|
|
386
|
-
// absent. There is no parallel scoring track — `boostSum` is the
|
|
387
|
-
// single accumulator and the existing `MAX_BOOST_SUM` cap below
|
|
388
|
-
// applies to graph contributions exactly as it does to every other
|
|
389
|
-
// boost.
|
|
390
|
-
if (graphContext) {
|
|
391
|
-
boostSum += computeGraphBoost(graphContext, item.filePath);
|
|
392
|
-
}
|
|
393
|
-
const cappedBoost = Math.min(boostSum, MAX_BOOST_SUM);
|
|
394
|
-
item.score = item.score * (1 + cappedBoost);
|
|
395
|
-
}
|
|
396
|
-
// Utility-based re-ranking (MemRL pattern).
|
|
397
|
-
// After the FTS+boost scoring pass, apply a multiplicative
|
|
398
|
-
// utility factor based on aggregated usage telemetry.
|
|
399
|
-
// Batch-load all utility scores in one query to avoid N+1.
|
|
400
|
-
const UTILITY_WEIGHT = 0.5;
|
|
401
|
-
const UTILITY_MAX_BOOST = 1.5; // Cap at 1.5x multiplier
|
|
402
|
-
const RECENCY_DECAY_DAYS = 30;
|
|
403
|
-
const utilScoresMap = getUtilityScoresByIds(db, scored.map((s) => s.id));
|
|
404
|
-
for (const item of scored) {
|
|
405
|
-
const utilScore = utilScoresMap.get(item.id);
|
|
406
|
-
if (utilScore && utilScore.utility > 0) {
|
|
407
|
-
// Compute recency factor: exponential decay based on days since last use
|
|
408
|
-
let recencyFactor = 1;
|
|
409
|
-
if (utilScore.lastUsedAt) {
|
|
410
|
-
const lastUsedMs = new Date(utilScore.lastUsedAt).getTime();
|
|
411
|
-
const daysSinceLastUse = Number.isNaN(lastUsedMs)
|
|
412
|
-
? Infinity
|
|
413
|
-
: Math.max(0, (Date.now() - lastUsedMs) / (1000 * 60 * 60 * 24));
|
|
414
|
-
recencyFactor = Math.exp(-daysSinceLastUse / RECENCY_DECAY_DAYS);
|
|
415
|
-
}
|
|
416
|
-
// Compute raw utility boost and cap it
|
|
417
|
-
const rawBoost = 1 + utilScore.utility * recencyFactor * UTILITY_WEIGHT;
|
|
418
|
-
const cappedBoost = Math.min(rawBoost, UTILITY_MAX_BOOST);
|
|
419
|
-
item.score = item.score * cappedBoost;
|
|
420
|
-
item.utilityBoosted = true;
|
|
421
|
-
}
|
|
422
|
-
}
|
|
195
|
+
applyRankingRules({ db, query, items: scored, graphContext });
|
|
423
196
|
// ── minScore floor ──────────────────────────────────────────────────────
|
|
424
197
|
// Drop semantic-only hits (cosine-only, no FTS match) whose score falls
|
|
425
198
|
// below the configured floor. FTS hits and hybrid hits are always kept.
|
|
@@ -443,8 +216,9 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
|
|
|
443
216
|
const qualityFiltered = includeProposed
|
|
444
217
|
? scopeFiltered
|
|
445
218
|
: scopeFiltered.filter((item) => !isProposedQuality(item.entry.quality));
|
|
219
|
+
const beliefFiltered = qualityFiltered.filter((item) => matchBeliefFilter(item.entry.type, item.entry.beliefState, beliefFilter));
|
|
446
220
|
const rankMs = Date.now() - tRank0;
|
|
447
|
-
const selected =
|
|
221
|
+
const selected = beliefFiltered.slice(0, limit);
|
|
448
222
|
const hits = await Promise.all(selected.map(({ entry, filePath, score, rankingMode, utilityBoosted }) => {
|
|
449
223
|
// CLAUDE.md locks SearchHit.score in [0,1]. The boost loop above can
|
|
450
224
|
// exceed 1.0 (this was a pre-existing breach that #207's graph boost
|
|
@@ -463,11 +237,21 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
|
|
|
463
237
|
sources,
|
|
464
238
|
config,
|
|
465
239
|
utilityBoosted,
|
|
240
|
+
graphContext,
|
|
466
241
|
rendererRegistry,
|
|
467
242
|
});
|
|
468
243
|
}));
|
|
469
244
|
return { embedMs, rankMs, hits };
|
|
470
245
|
}
|
|
246
|
+
function matchBeliefFilter(type, beliefState, filter) {
|
|
247
|
+
if (filter === "all")
|
|
248
|
+
return true;
|
|
249
|
+
if (type !== "memory")
|
|
250
|
+
return true;
|
|
251
|
+
if (filter === "current")
|
|
252
|
+
return beliefState === undefined || beliefState === "active";
|
|
253
|
+
return beliefState === "contradicted" || beliefState === "superseded" || beliefState === "archived";
|
|
254
|
+
}
|
|
471
255
|
// ── Vector scorer ───────────────────────────────────────────────────────────
|
|
472
256
|
async function tryVecScores(db, query, k, config) {
|
|
473
257
|
const semanticStatus = getEffectiveSemanticStatus(config, readSemanticStatus());
|
|
@@ -494,62 +278,10 @@ async function tryVecScores(db, query, k, config) {
|
|
|
494
278
|
return null;
|
|
495
279
|
}
|
|
496
280
|
}
|
|
497
|
-
// ── Substring fallback (no index) ───────────────────────────────────────────
|
|
498
|
-
async function substringSearch(query, searchType, limit, stashDir, sources, config, rendererRegistry = defaultRendererRegistry, filters, includeProposed = false) {
|
|
499
|
-
const assets = await indexAssets(stashDir, searchType, sources);
|
|
500
|
-
const scopeMatched = filters ? assets.filter((asset) => entryMatchesScope(asset.entry.scope, filters)) : assets;
|
|
501
|
-
const qualityMatched = includeProposed
|
|
502
|
-
? scopeMatched
|
|
503
|
-
: scopeMatched.filter((asset) => !isProposedQuality(asset.entry.quality));
|
|
504
|
-
const matched = qualityMatched.filter((asset) => !query || buildSearchText(asset.entry).includes(query));
|
|
505
|
-
if (!query) {
|
|
506
|
-
const sorted = matched.sort(compareAssets);
|
|
507
|
-
const unique = deduplicateAssetsByPath(sorted);
|
|
508
|
-
return Promise.all(unique
|
|
509
|
-
.slice(0, limit)
|
|
510
|
-
.map((asset) => assetToSearchHit(asset, stashDir, sources, config, undefined, rendererRegistry)));
|
|
511
|
-
}
|
|
512
|
-
// Score and sort by relevance
|
|
513
|
-
const scored = matched.map((asset) => ({ asset, score: scoreSubstringMatch(asset.entry, query) }));
|
|
514
|
-
scored.sort((a, b) => b.score - a.score || compareAssets(a.asset, b.asset));
|
|
515
|
-
// Deduplicate by path — keep highest-scored entry per file
|
|
516
|
-
const dedupedScored = deduplicateByPath(scored.map((s) => ({ ...s, filePath: s.asset.path })));
|
|
517
|
-
return Promise.all(dedupedScored
|
|
518
|
-
.slice(0, limit)
|
|
519
|
-
.map(({ asset, score }) => assetToSearchHit(asset, stashDir, sources, config, score, rendererRegistry)));
|
|
520
|
-
}
|
|
521
|
-
function scoreSubstringMatch(entry, query) {
|
|
522
|
-
const tokens = query.split(/\s+/).filter(Boolean);
|
|
523
|
-
if (tokens.length === 0)
|
|
524
|
-
return 0.5;
|
|
525
|
-
let score = 0.3;
|
|
526
|
-
const nameLower = entry.name.toLowerCase().replace(/[-_]/g, " ");
|
|
527
|
-
const descLower = (entry.description ?? "").toLowerCase();
|
|
528
|
-
const tagsLower = (entry.tags ?? []).join(" ").toLowerCase();
|
|
529
|
-
if (nameLower === query) {
|
|
530
|
-
score += 0.5;
|
|
531
|
-
}
|
|
532
|
-
else if (nameLower.includes(query)) {
|
|
533
|
-
score += 0.35;
|
|
534
|
-
}
|
|
535
|
-
else if (tokens.some((t) => nameLower.includes(t))) {
|
|
536
|
-
score += 0.2;
|
|
537
|
-
}
|
|
538
|
-
if (tokens.some((t) => tagsLower.includes(t))) {
|
|
539
|
-
score += 0.1;
|
|
540
|
-
}
|
|
541
|
-
if (tokens.some((t) => descLower.includes(t))) {
|
|
542
|
-
score += 0.05;
|
|
543
|
-
}
|
|
544
|
-
// Issue #8: round to 4 decimal places instead of 2
|
|
545
|
-
return Math.round(Math.min(1, score) * 10000) / 10000;
|
|
546
|
-
}
|
|
547
281
|
// ── Hit building ────────────────────────────────────────────────────────────
|
|
548
282
|
export async function buildDbHit(input) {
|
|
549
283
|
const rendererRegistry = input.rendererRegistry ?? defaultRendererRegistry;
|
|
550
284
|
const entryStashDir = findSourceForPath(input.path, input.sources)?.path ?? input.defaultStashDir;
|
|
551
|
-
const canonical = deriveCanonicalAssetNameFromStashRoot(input.entry.type, entryStashDir, input.path);
|
|
552
|
-
const refName = canonical && !canonical.startsWith("../") && !canonical.startsWith("..\\") ? canonical : input.entry.name;
|
|
553
285
|
// Quality and confidence boosts are now applied in the main scoring
|
|
554
286
|
// phase (searchDatabase). buildDbHit receives the already-final score and
|
|
555
287
|
// passes it through without further multiplication. We still compute the
|
|
@@ -561,8 +293,9 @@ export async function buildDbHit(input) {
|
|
|
561
293
|
// Round to 4 decimal places, no boost multiplication
|
|
562
294
|
const score = Math.round(input.score * 10000) / 10000;
|
|
563
295
|
const whyMatched = buildWhyMatched(input.entry, input.query, input.rankingMode, qualityBoost, confidenceBoost, input.utilityBoosted);
|
|
296
|
+
const graphHit = input.graphContext ? collectGraphRelatedHit(input.graphContext, input.path) : null;
|
|
564
297
|
const source = findSourceForPath(input.path, input.sources);
|
|
565
|
-
const ref = resolveSearchHitRef(input.entry,
|
|
298
|
+
const ref = resolveSearchHitRef(input.entry, input.entry.name, source);
|
|
566
299
|
const editable = isEditable(input.path, input.config);
|
|
567
300
|
const estimatedTokens = typeof input.entry.fileSize === "number" ? Math.round(input.entry.fileSize / 4) : undefined;
|
|
568
301
|
const hit = {
|
|
@@ -572,7 +305,9 @@ export async function buildDbHit(input) {
|
|
|
572
305
|
ref,
|
|
573
306
|
origin: resolveSearchHitOrigin(source),
|
|
574
307
|
editable,
|
|
575
|
-
...(!editable
|
|
308
|
+
...(!editable
|
|
309
|
+
? { editHint: buildEditHint(input.path, input.entry.type, input.entry.name, source?.registryId) }
|
|
310
|
+
: {}),
|
|
576
311
|
description: input.entry.description,
|
|
577
312
|
tags: input.entry.tags,
|
|
578
313
|
size: deriveSize(input.entry.fileSize),
|
|
@@ -583,11 +318,15 @@ export async function buildDbHit(input) {
|
|
|
583
318
|
// Surface optional quality (v1 spec §4.2). Omitted when entry has
|
|
584
319
|
// no `quality` field so payloads stay compact for the common case.
|
|
585
320
|
...(input.entry.quality ? { quality: input.entry.quality } : {}),
|
|
321
|
+
...(input.entry.beliefState ? { beliefState: input.entry.beliefState } : {}),
|
|
322
|
+
...(input.entry.currentBeliefRefs ? { currentBeliefRefs: input.entry.currentBeliefRefs } : {}),
|
|
323
|
+
...(graphHit ? { graph: { entities: graphHit.entities, relations: graphHit.relations } } : {}),
|
|
586
324
|
};
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
325
|
+
await enrichSearchHit(hit, {
|
|
326
|
+
type: input.entry.type,
|
|
327
|
+
stashDir: entryStashDir,
|
|
328
|
+
rendererRegistry,
|
|
329
|
+
});
|
|
591
330
|
return hit;
|
|
592
331
|
}
|
|
593
332
|
export function buildWhyMatched(entry, query,
|
|
@@ -634,41 +373,16 @@ rankingMode, qualityBoost, confidenceBoost, utilityBoosted) {
|
|
|
634
373
|
reasons.push("curated metadata boost");
|
|
635
374
|
if (confidenceBoost > 0)
|
|
636
375
|
reasons.push("metadata confidence boost");
|
|
376
|
+
if (entry.beliefState === "active")
|
|
377
|
+
reasons.push("active belief state");
|
|
378
|
+
if (entry.beliefState === "contradicted")
|
|
379
|
+
reasons.push("contradicted belief state");
|
|
380
|
+
if (entry.beliefState === "superseded")
|
|
381
|
+
reasons.push("superseded belief state");
|
|
637
382
|
if (utilityBoosted)
|
|
638
383
|
reasons.push("usage history boost");
|
|
639
384
|
return reasons;
|
|
640
385
|
}
|
|
641
|
-
async function assetToSearchHit(asset, stashDir, sources, config, score, rendererRegistry = defaultRendererRegistry) {
|
|
642
|
-
const source = findSourceForPath(asset.path, sources);
|
|
643
|
-
const editable = isEditable(asset.path, config);
|
|
644
|
-
const ref = resolveSearchHitRef(asset.entry, asset.entry.name, source);
|
|
645
|
-
const fileSize = readFileSize(asset.path);
|
|
646
|
-
const size = deriveSize(fileSize);
|
|
647
|
-
const estimatedTokens = typeof fileSize === "number" ? Math.round(fileSize / 4) : undefined;
|
|
648
|
-
const hit = {
|
|
649
|
-
type: asset.entry.type,
|
|
650
|
-
name: asset.entry.name,
|
|
651
|
-
path: asset.path,
|
|
652
|
-
ref,
|
|
653
|
-
origin: resolveSearchHitOrigin(source),
|
|
654
|
-
editable,
|
|
655
|
-
...(!editable
|
|
656
|
-
? { editHint: buildEditHint(asset.path, asset.entry.type, asset.entry.name, source?.registryId) }
|
|
657
|
-
: {}),
|
|
658
|
-
description: asset.entry.description,
|
|
659
|
-
tags: asset.entry.tags,
|
|
660
|
-
...(size ? { size } : {}),
|
|
661
|
-
action: buildLocalAction(asset.entry.type, ref, rendererRegistry),
|
|
662
|
-
...(score !== undefined ? { score } : {}),
|
|
663
|
-
...(estimatedTokens !== undefined ? { estimatedTokens } : {}),
|
|
664
|
-
...(asset.entry.quality ? { quality: asset.entry.quality } : {}),
|
|
665
|
-
};
|
|
666
|
-
const renderer = await rendererForType(asset.entry.type, rendererRegistry);
|
|
667
|
-
if (renderer?.enrichSearchHit) {
|
|
668
|
-
renderer.enrichSearchHit(hit, stashDir);
|
|
669
|
-
}
|
|
670
|
-
return hit;
|
|
671
|
-
}
|
|
672
386
|
// ── Utilities ────────────────────────────────────────────────────────────────
|
|
673
387
|
export function deriveSize(bytes) {
|
|
674
388
|
if (bytes === undefined)
|
|
@@ -679,93 +393,13 @@ export function deriveSize(bytes) {
|
|
|
679
393
|
return "medium";
|
|
680
394
|
return "large";
|
|
681
395
|
}
|
|
682
|
-
function readFileSize(filePath) {
|
|
683
|
-
try {
|
|
684
|
-
return fs.statSync(filePath).size;
|
|
685
|
-
}
|
|
686
|
-
catch {
|
|
687
|
-
return undefined;
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
async function indexAssets(stashDir, type, sources) {
|
|
691
|
-
const resolvedStashDir = realpathOrResolve(stashDir);
|
|
692
|
-
const source = sources?.find((entry) => realpathOrResolve(entry.path) === resolvedStashDir);
|
|
693
|
-
if (source?.wikiName) {
|
|
694
|
-
return indexWikiRootAssets(stashDir, source.wikiName, type);
|
|
695
|
-
}
|
|
696
|
-
const assets = [];
|
|
697
|
-
const filterType = type === "any" ? undefined : type;
|
|
698
|
-
const fileContexts = walkStashFlat(stashDir);
|
|
699
|
-
const dirGroups = new Map();
|
|
700
|
-
for (const ctx of fileContexts) {
|
|
701
|
-
const group = dirGroups.get(ctx.parentDirAbs);
|
|
702
|
-
if (group)
|
|
703
|
-
group.push(ctx.absPath);
|
|
704
|
-
else
|
|
705
|
-
dirGroups.set(ctx.parentDirAbs, [ctx.absPath]);
|
|
706
|
-
}
|
|
707
|
-
for (const [dirPath, files] of dirGroups) {
|
|
708
|
-
const generated = await generateMetadataFlat(stashDir, files);
|
|
709
|
-
const legacyOverrides = loadStashFile(dirPath, { requireFilename: true });
|
|
710
|
-
const mergedEntries = legacyOverrides
|
|
711
|
-
? generated.entries.map((entry) => mergeLegacyEntry(entry, legacyOverrides.entries))
|
|
712
|
-
: generated.entries;
|
|
713
|
-
const stash = mergedEntries.length > 0 ? { entries: mergedEntries } : legacyOverrides;
|
|
714
|
-
if (!stash || stash.entries.length === 0)
|
|
715
|
-
continue;
|
|
716
|
-
for (const entry of stash.entries) {
|
|
717
|
-
if (filterType && entry.type !== filterType)
|
|
718
|
-
continue;
|
|
719
|
-
if (!entry.filename)
|
|
720
|
-
continue;
|
|
721
|
-
const entryPath = path.join(dirPath, entry.filename);
|
|
722
|
-
if (!shouldIndexStashFile(stashDir, entryPath))
|
|
723
|
-
continue;
|
|
724
|
-
assets.push({ entry, path: entryPath });
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
return assets;
|
|
728
|
-
}
|
|
729
|
-
function mergeLegacyEntry(entry, legacyEntries) {
|
|
730
|
-
const legacy = legacyEntries.find((candidate) => candidate.filename === entry.filename);
|
|
731
|
-
return legacy ? { ...entry, ...legacy, filename: entry.filename } : entry;
|
|
732
|
-
}
|
|
733
|
-
async function indexWikiRootAssets(wikiRoot, wikiName, type) {
|
|
734
|
-
if (type !== "any" && type !== "wiki")
|
|
735
|
-
return [];
|
|
736
|
-
const assets = [];
|
|
737
|
-
for (const ctx of walkStashFlat(wikiRoot)) {
|
|
738
|
-
if (ctx.ext !== ".md")
|
|
739
|
-
continue;
|
|
740
|
-
if (!shouldIndexStashFile(wikiRoot, ctx.absPath, { treatStashRootAsWikiRoot: true }))
|
|
741
|
-
continue;
|
|
742
|
-
const relNoExt = ctx.relPath.replace(/\.md$/, "");
|
|
743
|
-
assets.push({
|
|
744
|
-
entry: {
|
|
745
|
-
name: `${wikiName}/${relNoExt}`,
|
|
746
|
-
type: "wiki",
|
|
747
|
-
filename: ctx.fileName,
|
|
748
|
-
description: ctx.frontmatter()?.description,
|
|
749
|
-
source: "frontmatter",
|
|
750
|
-
},
|
|
751
|
-
path: ctx.absPath,
|
|
752
|
-
});
|
|
753
|
-
}
|
|
754
|
-
return assets;
|
|
755
|
-
}
|
|
756
|
-
function compareAssets(a, b) {
|
|
757
|
-
if (a.entry.type !== b.entry.type)
|
|
758
|
-
return a.entry.type.localeCompare(b.entry.type);
|
|
759
|
-
return a.entry.name.localeCompare(b.entry.name);
|
|
760
|
-
}
|
|
761
396
|
/**
|
|
762
397
|
* Deduplicate scored results by file path, keeping only the highest-scored
|
|
763
398
|
* entry per unique path. Sorts by score descending internally to ensure the
|
|
764
399
|
* precondition is always met regardless of caller.
|
|
765
400
|
*/
|
|
766
401
|
function deduplicateByPath(items) {
|
|
767
|
-
|
|
768
|
-
const sorted = [...items].sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
|
402
|
+
const sorted = [...items].sort((a, b) => (b.score ?? 0) - (a.score ?? 0) || a.filePath.localeCompare(b.filePath));
|
|
769
403
|
const seen = new Set();
|
|
770
404
|
return sorted.filter((item) => {
|
|
771
405
|
if (seen.has(item.filePath))
|
|
@@ -774,18 +408,6 @@ function deduplicateByPath(items) {
|
|
|
774
408
|
return true;
|
|
775
409
|
});
|
|
776
410
|
}
|
|
777
|
-
/**
|
|
778
|
-
* Deduplicate IndexedAsset[] by path, keeping the first (highest-priority) entry.
|
|
779
|
-
*/
|
|
780
|
-
function deduplicateAssetsByPath(assets) {
|
|
781
|
-
const seen = new Set();
|
|
782
|
-
return assets.filter((asset) => {
|
|
783
|
-
if (seen.has(asset.path))
|
|
784
|
-
return false;
|
|
785
|
-
seen.add(asset.path);
|
|
786
|
-
return true;
|
|
787
|
-
});
|
|
788
|
-
}
|
|
789
411
|
/**
|
|
790
412
|
* Exact-match scope filter check. Legacy entries without a `scope` object only
|
|
791
413
|
* match when no filter is supplied — which is what the caller guards on
|
|
@@ -801,11 +423,3 @@ function entryMatchesScope(scope, filters) {
|
|
|
801
423
|
}
|
|
802
424
|
return true;
|
|
803
425
|
}
|
|
804
|
-
function realpathOrResolve(targetPath) {
|
|
805
|
-
try {
|
|
806
|
-
return fs.realpathSync(targetPath);
|
|
807
|
-
}
|
|
808
|
-
catch {
|
|
809
|
-
return path.resolve(targetPath);
|
|
810
|
-
}
|
|
811
|
-
}
|