akm-cli 0.7.4 → 0.8.0-rc.10
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 +224 -1
- package/README.md +22 -6
- package/SECURITY.md +93 -0
- package/dist/cli/config-migrate.js +144 -0
- package/dist/cli/config-validate.js +39 -0
- package/dist/cli/confirm.js +73 -0
- package/dist/cli/parse-args.js +133 -0
- package/dist/cli/shared.js +129 -0
- package/dist/cli.js +2631 -1440
- package/dist/commands/add-cli.js +279 -0
- package/dist/commands/agent-dispatch.js +110 -0
- package/dist/commands/agent-support.js +68 -0
- package/dist/commands/completions.js +3 -0
- package/dist/commands/config-cli.js +130 -534
- package/dist/commands/consolidate.js +2122 -0
- package/dist/commands/curate.js +45 -3
- package/dist/commands/db-cli.js +23 -0
- package/dist/commands/distill-promotion-policy.js +660 -0
- package/dist/commands/distill.js +1081 -73
- package/dist/commands/env.js +213 -0
- package/dist/commands/eval-cases.js +43 -0
- package/dist/commands/events.js +15 -24
- package/dist/commands/extract-cli.js +127 -0
- package/dist/commands/extract-prompt.js +204 -0
- package/dist/commands/extract.js +477 -0
- package/dist/commands/feedback-cli.js +331 -0
- package/dist/commands/graph.js +477 -0
- package/dist/commands/health.js +1302 -0
- package/dist/commands/help/help-accept.md +12 -0
- package/dist/commands/help/help-improve.md +69 -0
- package/dist/commands/help/help-proposals.md +18 -0
- package/dist/commands/help/help-propose.md +17 -0
- package/dist/commands/help/help-reject.md +11 -0
- package/dist/commands/history.js +54 -46
- package/dist/commands/improve-auto-accept.js +97 -0
- package/dist/commands/improve-cli.js +217 -0
- package/dist/commands/improve-profiles.js +166 -0
- package/dist/commands/improve-result-file.js +167 -0
- package/dist/commands/improve.js +2373 -0
- package/dist/commands/info.js +5 -2
- package/dist/commands/init.js +50 -2
- package/dist/commands/installed-stashes.js +102 -139
- package/dist/commands/knowledge.js +136 -0
- package/dist/commands/lint/agent-linter.js +49 -0
- package/dist/commands/lint/base-linter.js +479 -0
- package/dist/commands/lint/command-linter.js +49 -0
- package/dist/commands/lint/default-linter.js +16 -0
- package/dist/commands/lint/env-key-rules.js +154 -0
- package/dist/commands/lint/index.js +196 -0
- package/dist/commands/lint/knowledge-linter.js +16 -0
- package/dist/commands/lint/markdown-insertion.js +343 -0
- package/dist/commands/lint/memory-linter.js +61 -0
- package/dist/commands/lint/registry.js +36 -0
- package/dist/commands/lint/skill-linter.js +45 -0
- package/dist/commands/lint/task-linter.js +50 -0
- package/dist/commands/lint/types.js +4 -0
- package/dist/commands/lint/workflow-linter.js +56 -0
- package/dist/commands/lint.js +4 -0
- package/dist/commands/migration-help.js +3 -0
- package/dist/commands/proposal.js +67 -12
- package/dist/commands/propose.js +120 -45
- package/dist/commands/reflect.js +1104 -60
- package/dist/commands/registry-cli.js +150 -0
- package/dist/commands/registry-search.js +5 -2
- package/dist/commands/remember-cli.js +257 -0
- package/dist/commands/remember.js +70 -7
- package/dist/commands/schema-repair.js +203 -0
- package/dist/commands/search.js +115 -14
- package/dist/commands/secret.js +173 -0
- package/dist/commands/self-update.js +3 -0
- package/dist/commands/show.js +158 -60
- package/dist/commands/source-add.js +17 -45
- package/dist/commands/source-clone.js +3 -0
- package/dist/commands/source-manage.js +14 -19
- package/dist/commands/tasks.js +437 -0
- package/dist/commands/url-checker.js +42 -0
- package/dist/core/action-contributors.js +28 -0
- package/dist/core/asset-ref.js +17 -2
- package/dist/core/asset-registry.js +12 -17
- package/dist/core/asset-serialize.js +88 -0
- package/dist/core/asset-spec.js +67 -1
- package/dist/core/common.js +182 -0
- package/dist/core/concurrent.js +25 -0
- package/dist/core/config-io.js +347 -0
- package/dist/core/config-migration.js +622 -0
- package/dist/core/config-schema.js +534 -0
- package/dist/core/config-sources.js +108 -0
- package/dist/core/config-types.js +4 -0
- package/dist/core/config-walker.js +337 -0
- package/dist/core/config.js +364 -968
- package/dist/core/errors.js +42 -20
- package/dist/core/events.js +105 -135
- package/dist/core/file-lock.js +104 -0
- package/dist/core/frontmatter.js +75 -8
- package/dist/core/lesson-lint.js +3 -0
- package/dist/core/markdown.js +20 -0
- package/dist/core/memory-belief.js +62 -0
- package/dist/core/memory-contradiction-detect.js +274 -0
- package/dist/core/memory-improve.js +806 -0
- package/dist/core/parse.js +158 -0
- package/dist/core/paths.js +280 -14
- package/dist/core/proposal-quality-validators.js +380 -0
- package/dist/core/proposal-validators.js +69 -0
- package/dist/core/proposals.js +512 -42
- package/dist/core/state-db.js +1068 -0
- package/dist/core/text-truncation.js +107 -0
- package/dist/core/time.js +54 -0
- package/dist/core/tty.js +59 -0
- package/dist/core/warn.js +64 -1
- package/dist/core/write-source.js +3 -0
- package/dist/indexer/db-backup.js +391 -0
- package/dist/indexer/db-search.js +198 -489
- package/dist/indexer/db.js +990 -108
- package/dist/indexer/ensure-index.js +136 -0
- package/dist/indexer/file-context.js +3 -0
- package/dist/indexer/graph-boost.js +376 -101
- package/dist/indexer/graph-db.js +391 -0
- package/dist/indexer/graph-dedup.js +95 -0
- package/dist/indexer/graph-extraction.js +550 -114
- package/dist/indexer/index-context.js +4 -0
- package/dist/indexer/indexer.js +547 -309
- package/dist/indexer/llm-cache.js +52 -0
- package/dist/indexer/manifest.js +3 -0
- package/dist/indexer/matchers.js +167 -160
- package/dist/indexer/memory-inference.js +152 -74
- package/dist/indexer/metadata-contributors.js +29 -0
- package/dist/indexer/metadata.js +275 -196
- package/dist/indexer/path-resolver.js +92 -0
- package/dist/indexer/project-context.js +192 -0
- package/dist/indexer/ranking-contributors.js +331 -0
- package/dist/indexer/ranking.js +81 -0
- package/dist/indexer/search-fields.js +5 -9
- package/dist/indexer/search-hit-enrichers.js +111 -0
- package/dist/indexer/search-source.js +44 -10
- package/dist/indexer/semantic-status.js +6 -17
- package/dist/indexer/staleness-detect.js +447 -0
- package/dist/indexer/usage-events.js +12 -9
- package/dist/indexer/walker.js +28 -0
- package/dist/integrations/agent/builders.js +135 -0
- package/dist/integrations/agent/config.js +122 -230
- package/dist/integrations/agent/detect.js +3 -0
- package/dist/integrations/agent/index.js +7 -13
- package/dist/integrations/agent/model-aliases.js +55 -0
- package/dist/integrations/agent/profiles.js +70 -5
- package/dist/integrations/agent/prompts.js +250 -36
- package/dist/integrations/agent/runner.js +151 -0
- package/dist/integrations/agent/sdk-runner.js +126 -0
- package/dist/integrations/agent/spawn.js +183 -35
- package/dist/integrations/github.js +3 -0
- package/dist/integrations/lockfile.js +32 -69
- package/dist/integrations/session-logs/index.js +69 -0
- package/dist/integrations/session-logs/inline-refs.js +35 -0
- package/dist/integrations/session-logs/pre-filter.js +152 -0
- package/dist/integrations/session-logs/providers/claude-code.js +282 -0
- package/dist/integrations/session-logs/providers/opencode.js +258 -0
- package/dist/integrations/session-logs/types.js +4 -0
- package/dist/llm/call-ai.js +62 -0
- package/dist/llm/client.js +79 -88
- package/dist/llm/embedder.js +20 -29
- package/dist/llm/embedders/cache.js +3 -7
- package/dist/llm/embedders/local.js +42 -1
- package/dist/llm/embedders/remote.js +20 -8
- package/dist/llm/embedders/types.js +3 -7
- package/dist/llm/feature-gate.js +95 -48
- package/dist/llm/graph-extract.js +676 -72
- package/dist/llm/index-passes.js +44 -29
- package/dist/llm/memory-infer.js +80 -71
- package/dist/llm/metadata-enhance.js +42 -29
- package/dist/llm/prompts/extract-session.md +80 -0
- package/dist/llm/prompts/graph-extract-user-prompt.md +35 -0
- package/dist/output/cli-hints-full.md +292 -0
- package/dist/output/cli-hints-short.md +66 -0
- package/dist/output/cli-hints.js +7 -311
- package/dist/output/context.js +60 -8
- package/dist/output/renderers.js +306 -258
- package/dist/output/shapes/curate.js +56 -0
- package/dist/output/shapes/distill.js +10 -0
- package/dist/output/shapes/env-list.js +19 -0
- package/dist/output/shapes/events.js +11 -0
- package/dist/output/shapes/helpers.js +424 -0
- package/dist/output/shapes/history.js +7 -0
- package/dist/output/shapes/passthrough.js +102 -0
- package/dist/output/shapes/proposal-accept.js +7 -0
- package/dist/output/shapes/proposal-diff.js +7 -0
- package/dist/output/shapes/proposal-list.js +7 -0
- package/dist/output/shapes/proposal-producer.js +11 -0
- package/dist/output/shapes/proposal-reject.js +7 -0
- package/dist/output/shapes/proposal-show.js +7 -0
- package/dist/output/shapes/registry-search.js +6 -0
- package/dist/output/shapes/registry.js +30 -0
- package/dist/output/shapes/search.js +6 -0
- package/dist/output/shapes/secret-list.js +19 -0
- package/dist/output/shapes/show.js +6 -0
- package/dist/output/shapes/vault-list.js +19 -0
- package/dist/output/shapes.js +51 -511
- package/dist/output/text/add.js +6 -0
- package/dist/output/text/clone.js +6 -0
- package/dist/output/text/config.js +6 -0
- package/dist/output/text/curate.js +6 -0
- package/dist/output/text/distill.js +7 -0
- package/dist/output/text/enable-disable.js +7 -0
- package/dist/output/text/events.js +10 -0
- package/dist/output/text/feedback.js +6 -0
- package/dist/output/text/helpers.js +1039 -0
- package/dist/output/text/history.js +7 -0
- package/dist/output/text/import.js +6 -0
- package/dist/output/text/index.js +6 -0
- package/dist/output/text/info.js +6 -0
- package/dist/output/text/init.js +6 -0
- package/dist/output/text/list.js +6 -0
- package/dist/output/text/proposal-producer.js +8 -0
- package/dist/output/text/proposal.js +11 -0
- package/dist/output/text/registry-commands.js +11 -0
- package/dist/output/text/registry.js +30 -0
- package/dist/output/text/remember.js +6 -0
- package/dist/output/text/remove.js +6 -0
- package/dist/output/text/save.js +6 -0
- package/dist/output/text/search.js +6 -0
- package/dist/output/text/show.js +6 -0
- package/dist/output/text/update.js +6 -0
- package/dist/output/text/upgrade.js +6 -0
- package/dist/output/text/vault.js +16 -0
- package/dist/output/text/wiki.js +15 -0
- package/dist/output/text/workflow.js +14 -0
- package/dist/output/text.js +44 -1093
- package/dist/registry/build-index.js +3 -0
- package/dist/registry/create-provider-registry.js +3 -0
- package/dist/registry/factory.js +4 -1
- package/dist/registry/origin-resolve.js +3 -0
- package/dist/registry/providers/index.js +3 -0
- package/dist/registry/providers/skills-sh.js +71 -50
- package/dist/registry/providers/static-index.js +53 -48
- package/dist/registry/providers/types.js +3 -24
- package/dist/registry/resolve.js +11 -16
- package/dist/registry/types.js +3 -0
- package/dist/scripts/migrate-storage.js +17750 -0
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +9031 -0
- package/dist/scripts/migrations/v16-to-v17.js +141 -0
- package/dist/setup/detect.js +3 -0
- package/dist/setup/ripgrep-install.js +3 -0
- package/dist/setup/ripgrep-resolve.js +3 -0
- package/dist/setup/setup.js +775 -37
- package/dist/setup/steps.js +3 -15
- package/dist/sources/include.js +3 -0
- package/dist/sources/provider-factory.js +5 -12
- package/dist/sources/provider.js +3 -20
- package/dist/sources/providers/filesystem.js +19 -23
- package/dist/sources/providers/git.js +179 -20
- package/dist/sources/providers/index.js +3 -0
- package/dist/sources/providers/install-types.js +3 -13
- package/dist/sources/providers/npm.js +3 -4
- package/dist/sources/providers/provider-utils.js +3 -0
- package/dist/sources/providers/sync-from-ref.js +3 -11
- package/dist/sources/providers/tar-utils.js +3 -0
- package/dist/sources/providers/website.js +18 -22
- package/dist/sources/resolve.js +3 -0
- package/dist/sources/types.js +3 -0
- package/dist/sources/website-ingest.js +7 -0
- package/dist/tasks/backends/cron.js +203 -0
- package/dist/tasks/backends/exec-utils.js +28 -0
- package/dist/tasks/backends/index.js +24 -0
- package/dist/tasks/backends/launchd-template.xml +19 -0
- package/dist/tasks/backends/launchd.js +187 -0
- package/dist/tasks/backends/schtasks-template.xml +29 -0
- package/dist/tasks/backends/schtasks.js +215 -0
- package/dist/tasks/parser.js +211 -0
- package/dist/tasks/resolveAkmBin.js +87 -0
- package/dist/tasks/runner.js +458 -0
- package/dist/tasks/schedule.js +227 -0
- package/dist/tasks/schema.js +15 -0
- package/dist/tasks/validator.js +62 -0
- package/dist/version.js +3 -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 +15 -0
- package/dist/wiki/wiki.js +13 -61
- package/dist/workflows/authoring.js +8 -25
- package/dist/workflows/cli.js +3 -0
- package/dist/workflows/db.js +141 -2
- package/dist/workflows/document-cache.js +3 -10
- package/dist/workflows/parser.js +3 -0
- package/dist/workflows/renderer.js +11 -3
- package/dist/workflows/runs.js +91 -89
- package/dist/workflows/schema.js +3 -0
- package/dist/workflows/scope-key.js +79 -0
- package/dist/workflows/validator.js +4 -8
- package/dist/workflows/workflow-template.md +24 -0
- package/docs/README.md +10 -2
- package/docs/data-and-telemetry.md +225 -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 +48 -0
- package/docs/migration/v0.7-to-v0.8.md +1307 -0
- package/package.json +29 -11
- package/dist/commands/install-audit.js +0 -381
- package/dist/commands/vault.js +0 -333
- package/dist/templates/wiki-templates.js +0 -100
|
@@ -1,37 +1,24 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
*
|
|
7
|
-
* source-search.ts imports this module for the `searchLocal` export.
|
|
8
|
-
* sources/providers/filesystem.ts also imports `searchLocal` from here.
|
|
9
|
-
*
|
|
10
|
-
* Renamed from `local-search.ts` to signal that this is the DB-layer search
|
|
11
|
-
* implementation, not a "local vs. remote" distinction.
|
|
12
|
-
*/
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
13
4
|
import fs from "node:fs";
|
|
14
|
-
import
|
|
5
|
+
import { buildActionFromContributors, defaultActionContributors } from "../core/action-contributors";
|
|
15
6
|
import { makeAssetRef } from "../core/asset-ref";
|
|
16
7
|
import { defaultRendererRegistry } from "../core/asset-registry";
|
|
17
|
-
import { deriveCanonicalAssetNameFromStashRoot } from "../core/asset-spec";
|
|
18
8
|
import { getDbPath } from "../core/paths";
|
|
19
9
|
import { warn } from "../core/warn";
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
10
|
+
import { getCurrentWorkflowScopeKey } from "../workflows/scope-key";
|
|
11
|
+
import { closeDatabase, getAllEntries, getEntryById, getEntryCount, getMeta, getPositiveFeedbackCountsByIds, openExistingDatabase, sanitizeFtsQuery, searchFts, searchVec, } from "./db";
|
|
12
|
+
import { ensureIndex } from "./ensure-index";
|
|
13
|
+
import { collectGraphRelatedHit, computeGraphBoost, loadGraphBoostContext, } from "./graph-boost";
|
|
14
|
+
import { isProposedQuality } from "./metadata";
|
|
15
|
+
import { resolveProjectContext } from "./project-context";
|
|
16
|
+
import { applyRankingRules, combineSearchScores, normalizeFtsScores } from "./ranking";
|
|
17
|
+
import { enrichSearchHit } from "./search-hit-enrichers";
|
|
25
18
|
import { buildEditHint, findSourceForPath, isEditable } from "./search-source";
|
|
26
19
|
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
20
|
export function buildLocalAction(type, ref, registry = defaultRendererRegistry) {
|
|
33
|
-
|
|
34
|
-
return builder ? builder(ref) : `akm show ${ref}`;
|
|
21
|
+
return buildActionFromContributors({ type, ref }, defaultActionContributors(registry)) ?? `akm show ${ref}`;
|
|
35
22
|
}
|
|
36
23
|
function resolveSearchHitRef(entry, refName, source) {
|
|
37
24
|
if (source?.wikiName) {
|
|
@@ -42,81 +29,97 @@ function resolveSearchHitRef(entry, refName, source) {
|
|
|
42
29
|
function resolveSearchHitOrigin(source) {
|
|
43
30
|
return source?.wikiName ? null : (source?.registryId ?? null);
|
|
44
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Phase 2A / Rec 5: gate for the per-search `getPositiveFeedbackCountsByIds`
|
|
34
|
+
* lookup. Returns `true` only when the user has explicitly opted into
|
|
35
|
+
* `improve.utilityDecay` AND configured a `feedbackStabilityBoost > 1.0`.
|
|
36
|
+
* Either condition being false makes the DB query pure overhead (the ranking
|
|
37
|
+
* contributor ignores `positiveFeedbackCounts` when `utilityDecayConfig` is
|
|
38
|
+
* absent, and `1.0^count == 1` collapses the boost into a no-op).
|
|
39
|
+
*
|
|
40
|
+
* Exported for unit testing — keeps the gate decision pinned so a future edit
|
|
41
|
+
* can't quietly broaden the hot path.
|
|
42
|
+
*/
|
|
43
|
+
export function shouldQueryPositiveFeedbackCounts(utilityDecayRaw) {
|
|
44
|
+
if (utilityDecayRaw === undefined)
|
|
45
|
+
return false;
|
|
46
|
+
const boost = utilityDecayRaw.feedbackStabilityBoost ?? 1.5;
|
|
47
|
+
return boost > 1.0;
|
|
48
|
+
}
|
|
45
49
|
// ── Main search entrypoint ───────────────────────────────────────────────────
|
|
46
50
|
export async function searchLocal(input) {
|
|
47
51
|
const { query, searchType, limit, stashDir, sources, config } = input;
|
|
48
52
|
const filters = input.filters;
|
|
49
53
|
const includeProposed = input.includeProposed === true;
|
|
54
|
+
const beliefFilter = input.beliefFilter ?? "all";
|
|
55
|
+
const restrictToSources = input.restrictToSources === true;
|
|
50
56
|
const rendererRegistry = input.rendererRegistry ?? defaultRendererRegistry;
|
|
51
57
|
const allSourceDirs = sources.map((s) => s.path);
|
|
52
58
|
const rawStatus = readSemanticStatus();
|
|
53
59
|
const semanticStatus = getEffectiveSemanticStatus(config, rawStatus);
|
|
54
60
|
const warnings = [];
|
|
55
61
|
if (config.semanticSearchMode === "auto" && semanticStatus === "pending") {
|
|
56
|
-
// Distinguish between fingerprint mismatch (config changed) and never-set-up.
|
|
57
62
|
const currentFingerprint = deriveSemanticProviderFingerprint(config.embedding);
|
|
58
63
|
if (rawStatus && rawStatus.providerFingerprint !== currentFingerprint) {
|
|
59
64
|
warnings.push("Embedding config changed. Run 'akm index --full' to rebuild the semantic index with the new provider.");
|
|
60
65
|
}
|
|
66
|
+
else if (!config.embedding?.endpoint || !config.embedding?.model) {
|
|
67
|
+
// #480: when semantic mode is `auto` but no embedding provider is
|
|
68
|
+
// configured (e.g. `akm setup --yes` ran without picking one), telling
|
|
69
|
+
// the user to "run akm setup" is misleading — they just did. Surface
|
|
70
|
+
// the actual remediation: configure an embedding endpoint OR switch
|
|
71
|
+
// semanticSearchMode to `off` to silence the warning.
|
|
72
|
+
warnings.push("Semantic search is enabled (semanticSearchMode='auto') but no embedding provider is configured. " +
|
|
73
|
+
'Either: (a) `akm config set embedding \'{"endpoint":"...","model":"..."}\'`, or ' +
|
|
74
|
+
"(b) `akm config set semanticSearchMode off` to use keyword-only search.");
|
|
75
|
+
}
|
|
61
76
|
else {
|
|
62
|
-
warnings.push("Semantic search is pending verification. Run 'akm
|
|
77
|
+
warnings.push("Semantic search is pending verification. Run 'akm index --full' to build the semantic index now, or wait for the next background index pass.");
|
|
63
78
|
}
|
|
64
79
|
}
|
|
65
80
|
if (config.semanticSearchMode === "auto" && semanticStatus === "blocked") {
|
|
66
81
|
warnings.push("Semantic search is currently blocked. Using keyword search until the semantic backend is healthy again.");
|
|
67
82
|
}
|
|
68
|
-
//
|
|
83
|
+
// Auto-index when stale so the DB is always current before querying.
|
|
84
|
+
await ensureIndex(stashDir);
|
|
69
85
|
const dbPath = getDbPath();
|
|
86
|
+
if (!fs.existsSync(dbPath)) {
|
|
87
|
+
return {
|
|
88
|
+
hits: [],
|
|
89
|
+
tip: "No search index available. Run 'akm index' to build one.",
|
|
90
|
+
warnings: warnings.length > 0 ? warnings : undefined,
|
|
91
|
+
mode: "keyword",
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const db = openExistingDatabase(dbPath);
|
|
70
95
|
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
|
-
}
|
|
96
|
+
const entryCount = getEntryCount(db);
|
|
97
|
+
if (entryCount === 0) {
|
|
98
|
+
return {
|
|
99
|
+
hits: [],
|
|
100
|
+
tip: "Index is empty. Run 'akm index' to populate it.",
|
|
101
|
+
warnings: warnings.length > 0 ? warnings : undefined,
|
|
102
|
+
mode: "keyword",
|
|
103
|
+
};
|
|
105
104
|
}
|
|
105
|
+
const { hits, embedMs, rankMs } = await searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry, filters, includeProposed, beliefFilter, restrictToSources);
|
|
106
|
+
return {
|
|
107
|
+
hits,
|
|
108
|
+
tip: hits.length === 0
|
|
109
|
+
? "No matching stash assets were found. Try a different query or run 'akm index' to rebuild."
|
|
110
|
+
: undefined,
|
|
111
|
+
warnings: warnings.length > 0 ? warnings : undefined,
|
|
112
|
+
embedMs,
|
|
113
|
+
rankMs,
|
|
114
|
+
mode: embedMs !== undefined && embedMs > 0 ? "semantic" : "keyword",
|
|
115
|
+
};
|
|
106
116
|
}
|
|
107
|
-
|
|
108
|
-
|
|
117
|
+
finally {
|
|
118
|
+
closeDatabase(db);
|
|
109
119
|
}
|
|
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
120
|
}
|
|
118
121
|
// ── Database search ─────────────────────────────────────────────────────────
|
|
119
|
-
async function searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry = defaultRendererRegistry, filters, includeProposed = false) {
|
|
122
|
+
async function searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry = defaultRendererRegistry, filters, includeProposed = false, beliefFilter = "all", restrictToSources = false) {
|
|
120
123
|
const hasSearchableTokens = query.length > 0 && sanitizeFtsQuery(query).length > 0;
|
|
121
124
|
// Empty queries — including ones that sanitize down to no searchable FTS
|
|
122
125
|
// tokens such as "." — should enumerate matching entries instead of
|
|
@@ -132,18 +135,26 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
|
|
|
132
135
|
seenFilePaths.add(ie.filePath);
|
|
133
136
|
return true;
|
|
134
137
|
});
|
|
138
|
+
// Source filter: when the caller narrowed `sources` via `--source <name>`,
|
|
139
|
+
// drop entries whose filePath does not live under any of the requested
|
|
140
|
+
// sources. The FTS index spans every configured source, so without this
|
|
141
|
+
// filter a narrowed --source request would still leak results.
|
|
142
|
+
const sourceFiltered = restrictToSources
|
|
143
|
+
? uniqueEntries.filter((ie) => findSourceForPath(ie.filePath, sources) !== undefined)
|
|
144
|
+
: uniqueEntries;
|
|
135
145
|
// Scope filter: drop entries whose stored scope does not satisfy every
|
|
136
146
|
// supplied scope key. Filtering happens BEFORE the limit slice so a
|
|
137
147
|
// restrictive filter still returns up to `limit` results.
|
|
138
148
|
const scopeFiltered = filters
|
|
139
|
-
?
|
|
140
|
-
:
|
|
149
|
+
? sourceFiltered.filter((ie) => entryMatchesScope(ie.entry.scope, filters))
|
|
150
|
+
: sourceFiltered;
|
|
141
151
|
// Proposed-quality filter (v1 spec §4.2): exclude entries with
|
|
142
152
|
// `quality: "proposed"` unless the caller explicitly opts in.
|
|
143
153
|
const qualityFiltered = includeProposed
|
|
144
154
|
? scopeFiltered
|
|
145
155
|
: scopeFiltered.filter((ie) => !isProposedQuality(ie.entry.quality));
|
|
146
|
-
const
|
|
156
|
+
const beliefFiltered = qualityFiltered.filter((ie) => matchBeliefFilter(ie.entry.type, ie.entry.beliefState, beliefFilter));
|
|
157
|
+
const selected = beliefFiltered.slice(0, limit);
|
|
147
158
|
const hits = await Promise.all(selected.map((ie) => buildDbHit({
|
|
148
159
|
entry: ie.entry,
|
|
149
160
|
path: ie.filePath,
|
|
@@ -155,6 +166,7 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
|
|
|
155
166
|
sources,
|
|
156
167
|
config,
|
|
157
168
|
rendererRegistry,
|
|
169
|
+
db,
|
|
158
170
|
})));
|
|
159
171
|
return { hits };
|
|
160
172
|
}
|
|
@@ -170,22 +182,7 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
|
|
|
170
182
|
// ── Score normalization ──────────────────────────────────────────────
|
|
171
183
|
// Normalized BM25 + cosine similarity with weighted addition
|
|
172
184
|
// (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
|
-
}
|
|
185
|
+
const ftsScoreMap = normalizeFtsScores(ftsResults);
|
|
189
186
|
// Build embedding score map (cosine similarities already 0-1)
|
|
190
187
|
const embedScoreMap = new Map();
|
|
191
188
|
if (embeddingScores) {
|
|
@@ -194,46 +191,12 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
|
|
|
194
191
|
}
|
|
195
192
|
}
|
|
196
193
|
// ── 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
|
-
}
|
|
194
|
+
const scored = combineSearchScores({
|
|
195
|
+
ftsScoreMap,
|
|
196
|
+
embedScoreMap,
|
|
197
|
+
getEntryById: (id) => getEntryById(db, id) ?? undefined,
|
|
198
|
+
typeFilter,
|
|
199
|
+
});
|
|
237
200
|
// ── Scoring Phase ──────────────────────────────────────────────────────
|
|
238
201
|
// Apply boosts as multiplicative factors (all boosts in a single phase
|
|
239
202
|
// so that sort order and displayed scores are always consistent).
|
|
@@ -242,8 +205,6 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
|
|
|
242
205
|
// user's intent. An exact name match is the strongest signal. Actionable
|
|
243
206
|
// asset types (skills, commands, agents) are more useful than passive
|
|
244
207
|
// 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
208
|
// Graph boost context (#207). Built once per query and reused across
|
|
248
209
|
// every scored entry so the disk read + JSON parse only happens once
|
|
249
210
|
// per search invocation. `null` when no graph file is present, when
|
|
@@ -256,170 +217,48 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
|
|
|
256
217
|
// Search across all source dirs; the graph file lives next to the
|
|
257
218
|
// primary source root. Cache misses are silent — the helper handles
|
|
258
219
|
// missing files internally and returns `null` instead of throwing.
|
|
259
|
-
|
|
260
|
-
if (!primaryDir)
|
|
220
|
+
if (allSourceDirs.length === 0)
|
|
261
221
|
return null;
|
|
262
|
-
return loadGraphBoostContext(
|
|
222
|
+
return loadGraphBoostContext(allSourceDirs, query, config, db);
|
|
263
223
|
})();
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
}
|
|
224
|
+
// Resolve project-context tokens from the current working directory once
|
|
225
|
+
// per search invocation. Returns null when running from home dir / /tmp,
|
|
226
|
+
// or when the caller has set AKM_DISABLE_PROJECT_CONTEXT=1.
|
|
227
|
+
const projectContext = process.env.AKM_DISABLE_PROJECT_CONTEXT === "1" ? null : resolveProjectContext(process.cwd());
|
|
228
|
+
// Phase 2A / Rec 5: resolve forgetting-curve config and skip the feedback
|
|
229
|
+
// count query when the boost cannot make a difference (default ≤ 1.0 means
|
|
230
|
+
// boost^count == 1 — zero overhead for the common case).
|
|
231
|
+
const utilityDecayRaw = config.improve?.utilityDecay;
|
|
232
|
+
const halfLifeDays = utilityDecayRaw?.halfLifeDays ?? 30;
|
|
233
|
+
const feedbackStabilityBoost = utilityDecayRaw?.feedbackStabilityBoost ?? 1.5;
|
|
234
|
+
const utilityDecayConfig = utilityDecayRaw !== undefined ? { halfLifeDays, feedbackStabilityBoost } : undefined;
|
|
235
|
+
// Gate the feedback-count query on the user having explicitly opted into
|
|
236
|
+
// utilityDecay. Without an opt-in, `utilityDecayConfig` is undefined and the
|
|
237
|
+
// ranking contributor ignores `positiveFeedbackCounts` — so running the DB
|
|
238
|
+
// query here would be pure overhead. The boost > 1.0 sub-gate then skips the
|
|
239
|
+
// query when the configured boost is a no-op (1.5^count when boost==1 is 1).
|
|
240
|
+
const positiveFeedbackCounts = shouldQueryPositiveFeedbackCounts(utilityDecayRaw)
|
|
241
|
+
? getPositiveFeedbackCountsByIds(db, scored.map((item) => item.id))
|
|
242
|
+
: undefined;
|
|
243
|
+
// Resolve per-project scope key for scoped utility scoring.
|
|
244
|
+
// AKM_DISABLE_SCOPED_UTILITY=1 opts out (e.g. for registry searches or tests).
|
|
245
|
+
let scopeKey;
|
|
246
|
+
try {
|
|
247
|
+
scopeKey = process.env.AKM_DISABLE_SCOPED_UTILITY === "1" ? undefined : getCurrentWorkflowScopeKey();
|
|
422
248
|
}
|
|
249
|
+
catch {
|
|
250
|
+
// Non-fatal — ranking proceeds without scoped utility on any error.
|
|
251
|
+
}
|
|
252
|
+
applyRankingRules({
|
|
253
|
+
db,
|
|
254
|
+
query,
|
|
255
|
+
items: scored,
|
|
256
|
+
graphContext,
|
|
257
|
+
projectContext,
|
|
258
|
+
utilityDecayConfig,
|
|
259
|
+
positiveFeedbackCounts,
|
|
260
|
+
scopeKey,
|
|
261
|
+
});
|
|
423
262
|
// ── minScore floor ──────────────────────────────────────────────────────
|
|
424
263
|
// Drop semantic-only hits (cosine-only, no FTS match) whose score falls
|
|
425
264
|
// below the configured floor. FTS hits and hybrid hits are always kept.
|
|
@@ -433,18 +272,29 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
|
|
|
433
272
|
// a filename field all collapse to files[0]). Showing the same path/ref
|
|
434
273
|
// multiple times clutters results.
|
|
435
274
|
const deduped = deduplicateByPath(preFilter);
|
|
275
|
+
// Source filter: when the caller narrowed `sources` via `--source <name>`,
|
|
276
|
+
// drop hits whose filePath does not live under any of the requested
|
|
277
|
+
// sources. The FTS/vector index spans every configured source, so without
|
|
278
|
+
// this filter a narrowed --source request would still leak results from
|
|
279
|
+
// other sources that happened to match the query text.
|
|
280
|
+
const sourceFiltered = restrictToSources
|
|
281
|
+
? deduped.filter((item) => findSourceForPath(item.filePath, sources) !== undefined)
|
|
282
|
+
: deduped;
|
|
436
283
|
// Scope filter: drop hits whose stored scope does not satisfy every supplied
|
|
437
284
|
// key. Applied AFTER ranking — filtering narrows the result set without
|
|
438
285
|
// touching the single FTS5+boosts scoring pipeline.
|
|
439
|
-
const scopeFiltered = filters
|
|
286
|
+
const scopeFiltered = filters
|
|
287
|
+
? sourceFiltered.filter((item) => entryMatchesScope(item.entry.scope, filters))
|
|
288
|
+
: sourceFiltered;
|
|
440
289
|
// Proposed-quality filter (v1 spec §4.2): exclude entries with
|
|
441
290
|
// `quality: "proposed"` unless the caller passed `--include-proposed`.
|
|
442
291
|
// Applied AFTER ranking for the same reason as scope filtering.
|
|
443
292
|
const qualityFiltered = includeProposed
|
|
444
293
|
? scopeFiltered
|
|
445
294
|
: scopeFiltered.filter((item) => !isProposedQuality(item.entry.quality));
|
|
295
|
+
const beliefFiltered = qualityFiltered.filter((item) => matchBeliefFilter(item.entry.type, item.entry.beliefState, beliefFilter));
|
|
446
296
|
const rankMs = Date.now() - tRank0;
|
|
447
|
-
const selected =
|
|
297
|
+
const selected = beliefFiltered.slice(0, limit);
|
|
448
298
|
const hits = await Promise.all(selected.map(({ entry, filePath, score, rankingMode, utilityBoosted }) => {
|
|
449
299
|
// CLAUDE.md locks SearchHit.score in [0,1]. The boost loop above can
|
|
450
300
|
// exceed 1.0 (this was a pre-existing breach that #207's graph boost
|
|
@@ -463,11 +313,29 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
|
|
|
463
313
|
sources,
|
|
464
314
|
config,
|
|
465
315
|
utilityBoosted,
|
|
316
|
+
graphContext,
|
|
466
317
|
rendererRegistry,
|
|
318
|
+
db,
|
|
467
319
|
});
|
|
468
320
|
}));
|
|
469
321
|
return { embedMs, rankMs, hits };
|
|
470
322
|
}
|
|
323
|
+
function matchBeliefFilter(type, beliefState, filter) {
|
|
324
|
+
if (filter === "all")
|
|
325
|
+
return true;
|
|
326
|
+
if (type !== "memory")
|
|
327
|
+
return true;
|
|
328
|
+
if (filter === "current") {
|
|
329
|
+
// Phase 1A: `asserted` is a "current" state (stronger authority than `active`);
|
|
330
|
+
// `deprecated` is excluded from current results.
|
|
331
|
+
return beliefState === undefined || beliefState === "active" || beliefState === "asserted";
|
|
332
|
+
}
|
|
333
|
+
// historical
|
|
334
|
+
return (beliefState === "contradicted" ||
|
|
335
|
+
beliefState === "superseded" ||
|
|
336
|
+
beliefState === "deprecated" ||
|
|
337
|
+
beliefState === "archived");
|
|
338
|
+
}
|
|
471
339
|
// ── Vector scorer ───────────────────────────────────────────────────────────
|
|
472
340
|
async function tryVecScores(db, query, k, config) {
|
|
473
341
|
const semanticStatus = getEffectiveSemanticStatus(config, readSemanticStatus());
|
|
@@ -494,62 +362,10 @@ async function tryVecScores(db, query, k, config) {
|
|
|
494
362
|
return null;
|
|
495
363
|
}
|
|
496
364
|
}
|
|
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
365
|
// ── Hit building ────────────────────────────────────────────────────────────
|
|
548
366
|
export async function buildDbHit(input) {
|
|
549
367
|
const rendererRegistry = input.rendererRegistry ?? defaultRendererRegistry;
|
|
550
368
|
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
369
|
// Quality and confidence boosts are now applied in the main scoring
|
|
554
370
|
// phase (searchDatabase). buildDbHit receives the already-final score and
|
|
555
371
|
// passes it through without further multiplication. We still compute the
|
|
@@ -560,9 +376,11 @@ export async function buildDbHit(input) {
|
|
|
560
376
|
const confidenceBoost = typeof input.entry.confidence === "number" ? Math.min(0.05, Math.max(0, input.entry.confidence) * 0.05) : 0;
|
|
561
377
|
// Round to 4 decimal places, no boost multiplication
|
|
562
378
|
const score = Math.round(input.score * 10000) / 10000;
|
|
563
|
-
const
|
|
379
|
+
const graphBoost = input.graphContext ? computeGraphBoost(input.graphContext, input.path) : 0;
|
|
380
|
+
const whyMatched = buildWhyMatched(input.entry, input.query, input.rankingMode, qualityBoost, confidenceBoost, input.utilityBoosted, graphBoost);
|
|
381
|
+
const graphHit = input.graphContext ? collectGraphRelatedHit(input.graphContext, input.path) : null;
|
|
564
382
|
const source = findSourceForPath(input.path, input.sources);
|
|
565
|
-
const ref = resolveSearchHitRef(input.entry,
|
|
383
|
+
const ref = resolveSearchHitRef(input.entry, input.entry.name, source);
|
|
566
384
|
const editable = isEditable(input.path, input.config);
|
|
567
385
|
const estimatedTokens = typeof input.entry.fileSize === "number" ? Math.round(input.entry.fileSize / 4) : undefined;
|
|
568
386
|
const hit = {
|
|
@@ -572,7 +390,9 @@ export async function buildDbHit(input) {
|
|
|
572
390
|
ref,
|
|
573
391
|
origin: resolveSearchHitOrigin(source),
|
|
574
392
|
editable,
|
|
575
|
-
...(!editable
|
|
393
|
+
...(!editable
|
|
394
|
+
? { editHint: buildEditHint(input.path, input.entry.type, input.entry.name, source?.registryId) }
|
|
395
|
+
: {}),
|
|
576
396
|
description: input.entry.description,
|
|
577
397
|
tags: input.entry.tags,
|
|
578
398
|
size: deriveSize(input.entry.fileSize),
|
|
@@ -583,16 +403,21 @@ export async function buildDbHit(input) {
|
|
|
583
403
|
// Surface optional quality (v1 spec §4.2). Omitted when entry has
|
|
584
404
|
// no `quality` field so payloads stay compact for the common case.
|
|
585
405
|
...(input.entry.quality ? { quality: input.entry.quality } : {}),
|
|
406
|
+
...(input.entry.beliefState ? { beliefState: input.entry.beliefState } : {}),
|
|
407
|
+
...(input.entry.currentBeliefRefs ? { currentBeliefRefs: input.entry.currentBeliefRefs } : {}),
|
|
408
|
+
...(graphHit ? { graph: { entities: graphHit.entities, relations: graphHit.relations } } : {}),
|
|
586
409
|
};
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
410
|
+
await enrichSearchHit(hit, {
|
|
411
|
+
type: input.entry.type,
|
|
412
|
+
stashDir: entryStashDir,
|
|
413
|
+
rendererRegistry,
|
|
414
|
+
db: input.db,
|
|
415
|
+
});
|
|
591
416
|
return hit;
|
|
592
417
|
}
|
|
593
418
|
export function buildWhyMatched(entry, query,
|
|
594
419
|
// "hybrid" ranking mode
|
|
595
|
-
rankingMode, qualityBoost, confidenceBoost, utilityBoosted) {
|
|
420
|
+
rankingMode, qualityBoost, confidenceBoost, utilityBoosted, graphBoost) {
|
|
596
421
|
const reasons = [
|
|
597
422
|
rankingMode === "hybrid"
|
|
598
423
|
? "hybrid (fts + semantic)"
|
|
@@ -634,40 +459,24 @@ rankingMode, qualityBoost, confidenceBoost, utilityBoosted) {
|
|
|
634
459
|
reasons.push("curated metadata boost");
|
|
635
460
|
if (confidenceBoost > 0)
|
|
636
461
|
reasons.push("metadata confidence boost");
|
|
462
|
+
if (entry.beliefState === "active")
|
|
463
|
+
reasons.push("active belief state");
|
|
464
|
+
if (entry.beliefState === "asserted")
|
|
465
|
+
reasons.push("asserted belief state");
|
|
466
|
+
if (entry.beliefState === "contradicted")
|
|
467
|
+
reasons.push("contradicted belief state");
|
|
468
|
+
if (entry.beliefState === "superseded")
|
|
469
|
+
reasons.push("superseded belief state");
|
|
470
|
+
if (entry.beliefState === "deprecated")
|
|
471
|
+
reasons.push("deprecated belief state");
|
|
472
|
+
if (entry.beliefState === "archived")
|
|
473
|
+
reasons.push("archived belief state");
|
|
637
474
|
if (utilityBoosted)
|
|
638
475
|
reasons.push("usage history boost");
|
|
639
|
-
|
|
640
|
-
}
|
|
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);
|
|
476
|
+
if (typeof graphBoost === "number" && graphBoost > 0) {
|
|
477
|
+
reasons.push(`graph boost +${graphBoost.toFixed(2)}`);
|
|
669
478
|
}
|
|
670
|
-
return
|
|
479
|
+
return reasons;
|
|
671
480
|
}
|
|
672
481
|
// ── Utilities ────────────────────────────────────────────────────────────────
|
|
673
482
|
export function deriveSize(bytes) {
|
|
@@ -679,93 +488,13 @@ export function deriveSize(bytes) {
|
|
|
679
488
|
return "medium";
|
|
680
489
|
return "large";
|
|
681
490
|
}
|
|
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
491
|
/**
|
|
762
492
|
* Deduplicate scored results by file path, keeping only the highest-scored
|
|
763
493
|
* entry per unique path. Sorts by score descending internally to ensure the
|
|
764
494
|
* precondition is always met regardless of caller.
|
|
765
495
|
*/
|
|
766
496
|
function deduplicateByPath(items) {
|
|
767
|
-
|
|
768
|
-
const sorted = [...items].sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
|
497
|
+
const sorted = [...items].sort((a, b) => (b.score ?? 0) - (a.score ?? 0) || a.filePath.localeCompare(b.filePath));
|
|
769
498
|
const seen = new Set();
|
|
770
499
|
return sorted.filter((item) => {
|
|
771
500
|
if (seen.has(item.filePath))
|
|
@@ -774,18 +503,6 @@ function deduplicateByPath(items) {
|
|
|
774
503
|
return true;
|
|
775
504
|
});
|
|
776
505
|
}
|
|
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
506
|
/**
|
|
790
507
|
* Exact-match scope filter check. Legacy entries without a `scope` object only
|
|
791
508
|
* match when no filter is supplied — which is what the caller guards on
|
|
@@ -801,11 +518,3 @@ function entryMatchesScope(scope, filters) {
|
|
|
801
518
|
}
|
|
802
519
|
return true;
|
|
803
520
|
}
|
|
804
|
-
function realpathOrResolve(targetPath) {
|
|
805
|
-
try {
|
|
806
|
-
return fs.realpathSync(targetPath);
|
|
807
|
-
}
|
|
808
|
-
catch {
|
|
809
|
-
return path.resolve(targetPath);
|
|
810
|
-
}
|
|
811
|
-
}
|