akm-cli 0.7.5 → 0.8.0-rc.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/{.github/CHANGELOG.md → CHANGELOG.md} +113 -2
  2. package/README.md +20 -4
  3. package/SECURITY.md +93 -0
  4. package/dist/cli/config-migrate.js +144 -0
  5. package/dist/cli/config-validate.js +39 -0
  6. package/dist/cli/confirm.js +73 -0
  7. package/dist/cli/parse-args.js +133 -0
  8. package/dist/cli.js +1995 -551
  9. package/dist/commands/agent-dispatch.js +110 -0
  10. package/dist/commands/agent-support.js +68 -0
  11. package/dist/commands/completions.js +3 -0
  12. package/dist/commands/config-cli.js +130 -534
  13. package/dist/commands/consolidate.js +1531 -0
  14. package/dist/commands/curate.js +44 -3
  15. package/dist/commands/db-cli.js +23 -0
  16. package/dist/commands/distill-promotion-policy.js +660 -0
  17. package/dist/commands/distill.js +990 -75
  18. package/dist/commands/eval-cases.js +43 -0
  19. package/dist/commands/events.js +5 -23
  20. package/dist/commands/graph.js +477 -0
  21. package/dist/commands/health.js +400 -0
  22. package/dist/commands/help/help-accept.md +9 -0
  23. package/dist/commands/help/help-improve.md +77 -0
  24. package/dist/commands/help/help-proposals.md +15 -0
  25. package/dist/commands/help/help-propose.md +17 -0
  26. package/dist/commands/help/help-reject.md +8 -0
  27. package/dist/commands/history.js +54 -46
  28. package/dist/commands/improve-profiles.js +146 -0
  29. package/dist/commands/improve-result-file.js +103 -0
  30. package/dist/commands/improve.js +2175 -0
  31. package/dist/commands/info.js +5 -2
  32. package/dist/commands/init.js +50 -2
  33. package/dist/commands/installed-stashes.js +102 -139
  34. package/dist/commands/knowledge.js +136 -0
  35. package/dist/commands/lint/agent-linter.js +49 -0
  36. package/dist/commands/lint/base-linter.js +479 -0
  37. package/dist/commands/lint/command-linter.js +49 -0
  38. package/dist/commands/lint/default-linter.js +16 -0
  39. package/dist/commands/lint/index.js +183 -0
  40. package/dist/commands/lint/knowledge-linter.js +16 -0
  41. package/dist/commands/lint/markdown-insertion.js +343 -0
  42. package/dist/commands/lint/memory-linter.js +61 -0
  43. package/dist/commands/lint/registry.js +36 -0
  44. package/dist/commands/lint/skill-linter.js +45 -0
  45. package/dist/commands/lint/task-linter.js +50 -0
  46. package/dist/commands/lint/types.js +4 -0
  47. package/dist/commands/lint/vault-key-rules.js +139 -0
  48. package/dist/commands/lint/workflow-linter.js +56 -0
  49. package/dist/commands/lint.js +4 -0
  50. package/dist/commands/migration-help.js +5 -2
  51. package/dist/commands/proposal.js +66 -12
  52. package/dist/commands/propose.js +86 -31
  53. package/dist/commands/reflect.js +1119 -73
  54. package/dist/commands/registry-search.js +5 -2
  55. package/dist/commands/remember.js +69 -6
  56. package/dist/commands/schema-repair.js +203 -0
  57. package/dist/commands/search.js +115 -14
  58. package/dist/commands/self-update.js +3 -0
  59. package/dist/commands/show.js +144 -25
  60. package/dist/commands/source-add.js +17 -45
  61. package/dist/commands/source-clone.js +3 -0
  62. package/dist/commands/source-manage.js +14 -19
  63. package/dist/commands/tasks.js +438 -0
  64. package/dist/commands/url-checker.js +42 -0
  65. package/dist/commands/vault.js +130 -77
  66. package/dist/core/action-contributors.js +28 -0
  67. package/dist/core/asset-ref.js +7 -0
  68. package/dist/core/asset-registry.js +7 -16
  69. package/dist/core/asset-serialize.js +88 -0
  70. package/dist/core/asset-spec.js +22 -0
  71. package/dist/core/common.js +157 -0
  72. package/dist/core/concurrent.js +25 -0
  73. package/dist/core/config-io.js +347 -0
  74. package/dist/core/config-migration.js +625 -0
  75. package/dist/core/config-schema.js +501 -0
  76. package/dist/core/config-sources.js +108 -0
  77. package/dist/core/config-types.js +4 -0
  78. package/dist/core/config-walker.js +337 -0
  79. package/dist/core/config.js +327 -987
  80. package/dist/core/errors.js +40 -19
  81. package/dist/core/events.js +91 -138
  82. package/dist/core/file-lock.js +104 -0
  83. package/dist/core/frontmatter.js +3 -6
  84. package/dist/core/lesson-lint.js +3 -0
  85. package/dist/core/markdown.js +20 -0
  86. package/dist/core/memory-belief.js +62 -0
  87. package/dist/core/memory-contradiction-detect.js +274 -0
  88. package/dist/core/memory-improve.js +806 -0
  89. package/dist/core/parse.js +158 -0
  90. package/dist/core/paths.js +326 -14
  91. package/dist/core/proposal-quality-validators.js +364 -0
  92. package/dist/core/proposal-validators.js +69 -0
  93. package/dist/core/proposals.js +498 -42
  94. package/dist/core/state-db.js +927 -0
  95. package/dist/core/text-truncation.js +107 -0
  96. package/dist/core/time.js +54 -0
  97. package/dist/core/warn.js +62 -1
  98. package/dist/core/write-source.js +3 -0
  99. package/dist/indexer/db-backup.js +391 -0
  100. package/dist/indexer/db-search.js +152 -253
  101. package/dist/indexer/db.js +933 -103
  102. package/dist/indexer/ensure-index.js +64 -0
  103. package/dist/indexer/file-context.js +3 -0
  104. package/dist/indexer/graph-boost.js +376 -101
  105. package/dist/indexer/graph-db.js +391 -0
  106. package/dist/indexer/graph-dedup.js +95 -0
  107. package/dist/indexer/graph-extraction.js +550 -124
  108. package/dist/indexer/index-context.js +4 -0
  109. package/dist/indexer/indexer.js +506 -291
  110. package/dist/indexer/llm-cache.js +47 -0
  111. package/dist/indexer/manifest.js +3 -0
  112. package/dist/indexer/matchers.js +148 -160
  113. package/dist/indexer/memory-inference.js +99 -74
  114. package/dist/indexer/metadata-contributors.js +29 -0
  115. package/dist/indexer/metadata.js +255 -196
  116. package/dist/indexer/path-resolver.js +92 -0
  117. package/dist/indexer/project-context.js +192 -0
  118. package/dist/indexer/ranking-contributors.js +331 -0
  119. package/dist/indexer/ranking.js +81 -0
  120. package/dist/indexer/search-fields.js +5 -9
  121. package/dist/indexer/search-hit-enrichers.js +111 -0
  122. package/dist/indexer/search-source.js +44 -10
  123. package/dist/indexer/semantic-status.js +5 -16
  124. package/dist/indexer/staleness-detect.js +447 -0
  125. package/dist/indexer/usage-events.js +12 -9
  126. package/dist/indexer/walker.js +28 -0
  127. package/dist/integrations/agent/builders.js +135 -0
  128. package/dist/integrations/agent/config.js +122 -230
  129. package/dist/integrations/agent/detect.js +3 -0
  130. package/dist/integrations/agent/index.js +7 -13
  131. package/dist/integrations/agent/model-aliases.js +55 -0
  132. package/dist/integrations/agent/profiles.js +70 -5
  133. package/dist/integrations/agent/prompts.js +150 -74
  134. package/dist/integrations/agent/runner.js +151 -0
  135. package/dist/integrations/agent/sdk-runner.js +126 -0
  136. package/dist/integrations/agent/spawn.js +118 -23
  137. package/dist/integrations/github.js +3 -0
  138. package/dist/integrations/lockfile.js +32 -69
  139. package/dist/integrations/session-logs/index.js +68 -0
  140. package/dist/integrations/session-logs/providers/claude-code.js +59 -0
  141. package/dist/integrations/session-logs/providers/opencode.js +55 -0
  142. package/dist/integrations/session-logs/types.js +4 -0
  143. package/dist/llm/call-ai.js +62 -0
  144. package/dist/llm/client.js +72 -124
  145. package/dist/llm/embedder.js +3 -19
  146. package/dist/llm/embedders/cache.js +3 -7
  147. package/dist/llm/embedders/local.js +3 -0
  148. package/dist/llm/embedders/remote.js +20 -8
  149. package/dist/llm/embedders/types.js +3 -7
  150. package/dist/llm/feature-gate.js +89 -48
  151. package/dist/llm/graph-extract.js +676 -70
  152. package/dist/llm/index-passes.js +9 -23
  153. package/dist/llm/memory-infer.js +52 -71
  154. package/dist/llm/metadata-enhance.js +42 -29
  155. package/dist/llm/prompts/graph-extract-user-prompt.md +35 -0
  156. package/dist/output/cli-hints-full.md +281 -0
  157. package/dist/output/cli-hints-short.md +65 -0
  158. package/dist/output/cli-hints.js +5 -318
  159. package/dist/output/context.js +3 -0
  160. package/dist/output/renderers.js +223 -256
  161. package/dist/output/shapes.js +150 -105
  162. package/dist/output/text.js +318 -30
  163. package/dist/registry/build-index.js +3 -0
  164. package/dist/registry/create-provider-registry.js +3 -0
  165. package/dist/registry/factory.js +3 -0
  166. package/dist/registry/origin-resolve.js +3 -0
  167. package/dist/registry/providers/index.js +3 -0
  168. package/dist/registry/providers/skills-sh.js +70 -49
  169. package/dist/registry/providers/static-index.js +53 -48
  170. package/dist/registry/providers/types.js +3 -24
  171. package/dist/registry/resolve.js +11 -16
  172. package/dist/registry/types.js +3 -0
  173. package/dist/scripts/migrate-storage.js +17307 -0
  174. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +8900 -0
  175. package/dist/scripts/migrations/v16-to-v17.js +141 -0
  176. package/dist/setup/detect.js +3 -0
  177. package/dist/setup/ripgrep-install.js +3 -0
  178. package/dist/setup/ripgrep-resolve.js +3 -0
  179. package/dist/setup/setup.js +775 -37
  180. package/dist/setup/steps.js +3 -15
  181. package/dist/sources/include.js +3 -0
  182. package/dist/sources/provider-factory.js +5 -12
  183. package/dist/sources/provider.js +3 -20
  184. package/dist/sources/providers/filesystem.js +19 -23
  185. package/dist/sources/providers/git.js +7 -5
  186. package/dist/sources/providers/index.js +3 -0
  187. package/dist/sources/providers/install-types.js +3 -13
  188. package/dist/sources/providers/npm.js +3 -4
  189. package/dist/sources/providers/provider-utils.js +3 -0
  190. package/dist/sources/providers/sync-from-ref.js +3 -11
  191. package/dist/sources/providers/tar-utils.js +3 -0
  192. package/dist/sources/providers/website.js +18 -22
  193. package/dist/sources/resolve.js +3 -0
  194. package/dist/sources/types.js +3 -0
  195. package/dist/sources/website-ingest.js +7 -0
  196. package/dist/tasks/backends/cron.js +203 -0
  197. package/dist/tasks/backends/exec-utils.js +28 -0
  198. package/dist/tasks/backends/index.js +24 -0
  199. package/dist/tasks/backends/launchd-template.xml +19 -0
  200. package/dist/tasks/backends/launchd.js +187 -0
  201. package/dist/tasks/backends/schtasks-template.xml +29 -0
  202. package/dist/tasks/backends/schtasks.js +215 -0
  203. package/dist/tasks/parser.js +211 -0
  204. package/dist/tasks/resolveAkmBin.js +87 -0
  205. package/dist/tasks/runner.js +458 -0
  206. package/dist/tasks/schedule.js +211 -0
  207. package/dist/tasks/schema.js +15 -0
  208. package/dist/tasks/validator.js +62 -0
  209. package/dist/version.js +3 -0
  210. package/dist/wiki/index-template.md +12 -0
  211. package/dist/wiki/ingest-workflow-template.md +54 -0
  212. package/dist/wiki/log-template.md +8 -0
  213. package/dist/wiki/schema-template.md +61 -0
  214. package/dist/wiki/wiki-templates.js +15 -0
  215. package/dist/wiki/wiki.js +13 -61
  216. package/dist/workflows/authoring.js +8 -25
  217. package/dist/workflows/cli.js +3 -0
  218. package/dist/workflows/db.js +140 -10
  219. package/dist/workflows/document-cache.js +3 -10
  220. package/dist/workflows/parser.js +3 -0
  221. package/dist/workflows/renderer.js +11 -3
  222. package/dist/workflows/runs.js +62 -91
  223. package/dist/workflows/schema.js +3 -0
  224. package/dist/workflows/scope-key.js +3 -0
  225. package/dist/workflows/validator.js +4 -8
  226. package/dist/workflows/workflow-template.md +24 -0
  227. package/docs/README.md +9 -2
  228. package/docs/data-and-telemetry.md +225 -0
  229. package/docs/migration/release-notes/0.7.0.md +1 -1
  230. package/docs/migration/release-notes/0.7.5.md +2 -2
  231. package/docs/migration/release-notes/0.8.0.md +48 -0
  232. package/docs/migration/v0.7-to-v0.8.md +1307 -0
  233. package/package.json +20 -8
  234. package/.github/LICENSE +0 -374
  235. package/dist/commands/install-audit.js +0 -381
  236. package/dist/templates/wiki-templates.js +0 -100
@@ -1,34 +1,24 @@
1
- /**
2
- * Database-backed (SQLite + FTS5/vector) source search implementation.
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";
5
+ import { buildActionFromContributors, defaultActionContributors } from "../core/action-contributors";
14
6
  import { makeAssetRef } from "../core/asset-ref";
15
7
  import { defaultRendererRegistry } from "../core/asset-registry";
16
8
  import { getDbPath } from "../core/paths";
17
9
  import { warn } from "../core/warn";
18
- import { closeDatabase, getAllEntries, getEntryById, getEntryCount, getMeta, getUtilityScoresByIds, openExistingDatabase, sanitizeFtsQuery, searchFts, searchVec, } from "./db";
10
+ import { getCurrentWorkflowScopeKey } from "../workflows/scope-key";
11
+ import { closeDatabase, getAllEntries, getEntryById, getEntryCount, getMeta, getPositiveFeedbackCountsByIds, openExistingDatabase, sanitizeFtsQuery, searchFts, searchVec, } from "./db";
19
12
  import { ensureIndex } from "./ensure-index";
20
- import { getRenderer } from "./file-context";
21
- import { computeGraphBoost, loadGraphBoostContext } from "./graph-boost";
13
+ import { collectGraphRelatedHit, computeGraphBoost, loadGraphBoostContext, } from "./graph-boost";
22
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";
23
18
  import { buildEditHint, findSourceForPath, isEditable } from "./search-source";
24
19
  import { deriveSemanticProviderFingerprint, getEffectiveSemanticStatus, isSemanticRuntimeReady, readSemanticStatus, } from "./semantic-status";
25
- export async function rendererForType(type, registry = defaultRendererRegistry) {
26
- const name = registry.rendererNameFor(type);
27
- return name ? getRenderer(name) : undefined;
28
- }
29
20
  export function buildLocalAction(type, ref, registry = defaultRendererRegistry) {
30
- const builder = registry.actionBuilderFor(type);
31
- return builder ? builder(ref) : `akm show ${ref}`;
21
+ return buildActionFromContributors({ type, ref }, defaultActionContributors(registry)) ?? `akm show ${ref}`;
32
22
  }
33
23
  function resolveSearchHitRef(entry, refName, source) {
34
24
  if (source?.wikiName) {
@@ -39,11 +29,30 @@ function resolveSearchHitRef(entry, refName, source) {
39
29
  function resolveSearchHitOrigin(source) {
40
30
  return source?.wikiName ? null : (source?.registryId ?? null);
41
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
+ }
42
49
  // ── Main search entrypoint ───────────────────────────────────────────────────
43
50
  export async function searchLocal(input) {
44
51
  const { query, searchType, limit, stashDir, sources, config } = input;
45
52
  const filters = input.filters;
46
53
  const includeProposed = input.includeProposed === true;
54
+ const beliefFilter = input.beliefFilter ?? "all";
55
+ const restrictToSources = input.restrictToSources === true;
47
56
  const rendererRegistry = input.rendererRegistry ?? defaultRendererRegistry;
48
57
  const allSourceDirs = sources.map((s) => s.path);
49
58
  const rawStatus = readSemanticStatus();
@@ -69,6 +78,7 @@ export async function searchLocal(input) {
69
78
  hits: [],
70
79
  tip: "No search index available. Run 'akm index' to build one.",
71
80
  warnings: warnings.length > 0 ? warnings : undefined,
81
+ mode: "keyword",
72
82
  };
73
83
  }
74
84
  const db = openExistingDatabase(dbPath);
@@ -79,9 +89,10 @@ export async function searchLocal(input) {
79
89
  hits: [],
80
90
  tip: "Index is empty. Run 'akm index' to populate it.",
81
91
  warnings: warnings.length > 0 ? warnings : undefined,
92
+ mode: "keyword",
82
93
  };
83
94
  }
84
- const { hits, embedMs, rankMs } = await searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry, filters, includeProposed);
95
+ const { hits, embedMs, rankMs } = await searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry, filters, includeProposed, beliefFilter, restrictToSources);
85
96
  return {
86
97
  hits,
87
98
  tip: hits.length === 0
@@ -90,6 +101,7 @@ export async function searchLocal(input) {
90
101
  warnings: warnings.length > 0 ? warnings : undefined,
91
102
  embedMs,
92
103
  rankMs,
104
+ mode: embedMs !== undefined && embedMs > 0 ? "semantic" : "keyword",
93
105
  };
94
106
  }
95
107
  finally {
@@ -97,7 +109,7 @@ export async function searchLocal(input) {
97
109
  }
98
110
  }
99
111
  // ── Database search ─────────────────────────────────────────────────────────
100
- async function searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry = defaultRendererRegistry, filters, includeProposed = false) {
112
+ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry = defaultRendererRegistry, filters, includeProposed = false, beliefFilter = "all", restrictToSources = false) {
101
113
  const hasSearchableTokens = query.length > 0 && sanitizeFtsQuery(query).length > 0;
102
114
  // Empty queries — including ones that sanitize down to no searchable FTS
103
115
  // tokens such as "." — should enumerate matching entries instead of
@@ -113,18 +125,26 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
113
125
  seenFilePaths.add(ie.filePath);
114
126
  return true;
115
127
  });
128
+ // Source filter: when the caller narrowed `sources` via `--source <name>`,
129
+ // drop entries whose filePath does not live under any of the requested
130
+ // sources. The FTS index spans every configured source, so without this
131
+ // filter a narrowed --source request would still leak results.
132
+ const sourceFiltered = restrictToSources
133
+ ? uniqueEntries.filter((ie) => findSourceForPath(ie.filePath, sources) !== undefined)
134
+ : uniqueEntries;
116
135
  // Scope filter: drop entries whose stored scope does not satisfy every
117
136
  // supplied scope key. Filtering happens BEFORE the limit slice so a
118
137
  // restrictive filter still returns up to `limit` results.
119
138
  const scopeFiltered = filters
120
- ? uniqueEntries.filter((ie) => entryMatchesScope(ie.entry.scope, filters))
121
- : uniqueEntries;
139
+ ? sourceFiltered.filter((ie) => entryMatchesScope(ie.entry.scope, filters))
140
+ : sourceFiltered;
122
141
  // Proposed-quality filter (v1 spec §4.2): exclude entries with
123
142
  // `quality: "proposed"` unless the caller explicitly opts in.
124
143
  const qualityFiltered = includeProposed
125
144
  ? scopeFiltered
126
145
  : scopeFiltered.filter((ie) => !isProposedQuality(ie.entry.quality));
127
- const selected = qualityFiltered.slice(0, limit);
146
+ const beliefFiltered = qualityFiltered.filter((ie) => matchBeliefFilter(ie.entry.type, ie.entry.beliefState, beliefFilter));
147
+ const selected = beliefFiltered.slice(0, limit);
128
148
  const hits = await Promise.all(selected.map((ie) => buildDbHit({
129
149
  entry: ie.entry,
130
150
  path: ie.filePath,
@@ -136,6 +156,7 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
136
156
  sources,
137
157
  config,
138
158
  rendererRegistry,
159
+ db,
139
160
  })));
140
161
  return { hits };
141
162
  }
@@ -151,22 +172,7 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
151
172
  // ── Score normalization ──────────────────────────────────────────────
152
173
  // Normalized BM25 + cosine similarity with weighted addition
153
174
  // (FTS 0.7, vector 0.3) for well-differentiated combined scores.
154
- // Normalize FTS BM25 scores to 0-1 range
155
- const ftsScoreMap = new Map();
156
- if (ftsResults.length > 0) {
157
- // BM25 scores are negative; most negative = best match
158
- const bestBm25 = ftsResults[0].bm25Score; // most negative (best)
159
- const worstBm25 = ftsResults[ftsResults.length - 1].bm25Score; // least negative (worst)
160
- const range = bestBm25 - worstBm25; // negative range
161
- for (const r of ftsResults) {
162
- // Normalize: best match = 1.0, worst match approaches 0
163
- // When range is 0 (all same score), all get 1.0
164
- const normalized = range !== 0 ? (r.bm25Score - worstBm25) / range : 1.0;
165
- // Scale to 0.3-1.0 range so even the worst FTS hit has a meaningful base score
166
- const ftsScore = 0.3 + normalized * 0.7;
167
- ftsScoreMap.set(r.id, { score: ftsScore, result: r });
168
- }
169
- }
175
+ const ftsScoreMap = normalizeFtsScores(ftsResults);
170
176
  // Build embedding score map (cosine similarities already 0-1)
171
177
  const embedScoreMap = new Map();
172
178
  if (embeddingScores) {
@@ -175,46 +181,12 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
175
181
  }
176
182
  }
177
183
  // ── Combine FTS + vector scores ──────────────────────────────────────
178
- const FTS_WEIGHT = 0.7;
179
- const VEC_WEIGHT = 0.3;
180
- const MAX_BOOST_SUM = 3.0;
181
- const scored = [];
182
- const seenIds = new Set();
183
- // Process FTS results
184
- for (const [id, { score: ftsScore, result }] of ftsScoreMap) {
185
- seenIds.add(id);
186
- const embedScore = embedScoreMap.get(id);
187
- let combinedScore;
188
- let rankingMode;
189
- if (embedScore !== undefined) {
190
- combinedScore = ftsScore * FTS_WEIGHT + embedScore * VEC_WEIGHT;
191
- rankingMode = "hybrid";
192
- }
193
- else {
194
- combinedScore = ftsScore;
195
- rankingMode = "fts";
196
- }
197
- scored.push({ id, entry: result.entry, filePath: result.filePath, score: combinedScore, rankingMode });
198
- }
199
- // Add vec-only results not already in FTS results
200
- if (embeddingScores) {
201
- for (const [id, cosine] of embeddingScores) {
202
- if (seenIds.has(id))
203
- continue;
204
- const found = getEntryById(db, id);
205
- if (found) {
206
- if (typeFilter && found.entry.type !== typeFilter)
207
- continue;
208
- scored.push({
209
- id,
210
- entry: found.entry,
211
- filePath: found.filePath,
212
- score: cosine * VEC_WEIGHT, // Only vector score, no FTS
213
- rankingMode: "semantic",
214
- });
215
- }
216
- }
217
- }
184
+ const scored = combineSearchScores({
185
+ ftsScoreMap,
186
+ embedScoreMap,
187
+ getEntryById: (id) => getEntryById(db, id) ?? undefined,
188
+ typeFilter,
189
+ });
218
190
  // ── Scoring Phase ──────────────────────────────────────────────────────
219
191
  // Apply boosts as multiplicative factors (all boosts in a single phase
220
192
  // so that sort order and displayed scores are always consistent).
@@ -223,8 +195,6 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
223
195
  // user's intent. An exact name match is the strongest signal. Actionable
224
196
  // asset types (skills, commands, agents) are more useful than passive
225
197
  // reference docs. Curated metadata is more reliable than auto-generated.
226
- const queryTokens = query.toLowerCase().split(/\s+/).filter(Boolean);
227
- const queryLower = query.toLowerCase().trim();
228
198
  // Graph boost context (#207). Built once per query and reused across
229
199
  // every scored entry so the disk read + JSON parse only happens once
230
200
  // per search invocation. `null` when no graph file is present, when
@@ -237,170 +207,48 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
237
207
  // Search across all source dirs; the graph file lives next to the
238
208
  // primary source root. Cache misses are silent — the helper handles
239
209
  // missing files internally and returns `null` instead of throwing.
240
- const primaryDir = allSourceDirs[0];
241
- if (!primaryDir)
210
+ if (allSourceDirs.length === 0)
242
211
  return null;
243
- return loadGraphBoostContext(primaryDir, query);
212
+ return loadGraphBoostContext(allSourceDirs, query, config, db);
244
213
  })();
245
- for (const item of scored) {
246
- const entry = item.entry;
247
- let boostSum = 0;
248
- // ── 1. Exact / near-exact name match (strongest signal) ──
249
- // If the query IS the asset name (or very close), this is almost certainly
250
- // what the user wants. This is the single most important ranking signal.
251
- const nameLower = entry.name.toLowerCase();
252
- const rawNameBase = nameLower.split("/").pop() ?? nameLower; // last segment for path-based names
253
- const nameBase = entry.type === "memory" && rawNameBase.endsWith(".derived")
254
- ? rawNameBase.slice(0, -".derived".length)
255
- : rawNameBase;
256
- if (nameBase === queryLower || nameLower === queryLower) {
257
- // Exact match: massive boost
258
- boostSum += 2.0;
259
- }
260
- else if (nameBase.includes(queryLower) || queryLower.includes(nameBase)) {
261
- // Near-exact: query is substring of name or vice versa
262
- boostSum += 1.0;
263
- }
264
- else {
265
- // Token overlap: how many query tokens appear in the base name?
266
- const nameTokens = nameBase.split(/[-_\s]+/).filter(Boolean);
267
- const matchCount = queryTokens.filter((qt) => nameTokens.some((nt) => nt === qt || nt.includes(qt))).length;
268
- if (matchCount > 0) {
269
- // Proportional to how many query tokens match (0.3 per token, max 0.9)
270
- boostSum += Math.min(0.9, matchCount * 0.3);
271
- }
272
- }
273
- // ── 2. Type relevance boost ──
274
- // Actionable assets (skills, commands, agents) are generally more useful
275
- // than passive reference material when the user is searching for something
276
- // to use. Knowledge docs are reference — valuable but secondary.
277
- const TYPE_BOOST = {
278
- skill: 0.4,
279
- command: 0.35,
280
- workflow: 0.35,
281
- agent: 0.3,
282
- script: 0.2,
283
- memory: 0.1,
284
- knowledge: 0,
285
- };
286
- boostSum += TYPE_BOOST[entry.type] ?? 0;
287
- // ── 2.5. Derived-vs-raw memory preference ──
288
- // Raw memories are user notes and may be incomplete or unvetted. Compressed
289
- // `.derived` memories are the higher-signal retrieval target, but the
290
- // preference should stay modest so stronger relevance signals still dominate.
291
- if (entry.type === "memory") {
292
- if (entry.name.toLowerCase().endsWith(".derived")) {
293
- boostSum += 0.18;
294
- }
295
- else {
296
- boostSum -= 0.08;
297
- }
298
- }
299
- // ── 3. Tag exact match ──
300
- // Exact tag equality is a strong signal — the author explicitly tagged
301
- // this asset with the user's search term.
302
- if (entry.tags) {
303
- let tagBoost = 0;
304
- for (const tag of entry.tags) {
305
- if (queryTokens.some((t) => tag.toLowerCase() === t)) {
306
- tagBoost += 0.15;
307
- }
308
- }
309
- boostSum += Math.min(0.3, tagBoost);
310
- }
311
- // ── 4. Search hint match ──
312
- // Hints are author-curated retrieval cues (e.g. "use when deploying to k8s").
313
- if (entry.searchHints) {
314
- let hintBoost = 0;
315
- for (const hint of entry.searchHints) {
316
- const hintLower = hint.toLowerCase();
317
- for (const token of queryTokens) {
318
- if (hintLower.includes(token)) {
319
- hintBoost += 0.12;
320
- break;
321
- }
322
- }
323
- }
324
- boostSum += Math.min(0.24, hintBoost);
325
- }
326
- // ── 5. Alias match ──
327
- // Aliases are alternate names the author defined for discovery.
328
- if (entry.aliases) {
329
- for (const alias of entry.aliases) {
330
- const aliasLower = alias.toLowerCase();
331
- if (aliasLower === queryLower) {
332
- boostSum += 1.5; // Nearly as strong as exact name match
333
- break;
334
- }
335
- if (queryTokens.some((t) => aliasLower.includes(t))) {
336
- boostSum += 0.3;
337
- }
338
- }
339
- }
340
- // ── 6. Description relevance ──
341
- // All query tokens appearing in description suggests strong relevance.
342
- if (entry.description) {
343
- const descLower = entry.description.toLowerCase();
344
- const descMatchCount = queryTokens.filter((t) => descLower.includes(t)).length;
345
- if (descMatchCount === queryTokens.length && queryTokens.length > 1) {
346
- // All query tokens found in description — high relevance
347
- boostSum += 0.25;
348
- }
349
- else if (descMatchCount > 0) {
350
- boostSum += 0.1;
351
- }
352
- }
353
- // ── 7. Metadata quality signals ──
354
- // Curated metadata is the only boost-bearing quality marker. `generated`
355
- // and `proposed` (and unknown values) get no boost. `proposed` is also
356
- // filtered out by default downstream (v1 spec §4.2).
357
- const qualityBoost = entry.quality === "curated" ? 0.05 : 0;
358
- boostSum += qualityBoost;
359
- const confidenceBoost = typeof entry.confidence === "number" ? Math.min(0.05, Math.max(0, entry.confidence) * 0.05) : 0;
360
- boostSum += confidenceBoost;
361
- // ── 8. Graph signal (opt-in, #207) ──
362
- // When the graph-extraction pass has produced a `graph.json`,
363
- // contribute an additive boost based on how many of this entry's
364
- // extracted entities match the query (or are one hop away from a
365
- // match). Computed inside the same loop so all boosts are in one
366
- // place and the per-call cost is one map lookup when the graph is
367
- // absent. There is no parallel scoring track — `boostSum` is the
368
- // single accumulator and the existing `MAX_BOOST_SUM` cap below
369
- // applies to graph contributions exactly as it does to every other
370
- // boost.
371
- if (graphContext) {
372
- boostSum += computeGraphBoost(graphContext, item.filePath);
373
- }
374
- const cappedBoost = Math.min(boostSum, MAX_BOOST_SUM);
375
- item.score = item.score * (1 + cappedBoost);
214
+ // Resolve project-context tokens from the current working directory once
215
+ // per search invocation. Returns null when running from home dir / /tmp,
216
+ // or when the caller has set AKM_DISABLE_PROJECT_CONTEXT=1.
217
+ const projectContext = process.env.AKM_DISABLE_PROJECT_CONTEXT === "1" ? null : resolveProjectContext(process.cwd());
218
+ // Phase 2A / Rec 5: resolve forgetting-curve config and skip the feedback
219
+ // count query when the boost cannot make a difference (default 1.0 means
220
+ // boost^count == 1 — zero overhead for the common case).
221
+ const utilityDecayRaw = config.improve?.utilityDecay;
222
+ const halfLifeDays = utilityDecayRaw?.halfLifeDays ?? 30;
223
+ const feedbackStabilityBoost = utilityDecayRaw?.feedbackStabilityBoost ?? 1.5;
224
+ const utilityDecayConfig = utilityDecayRaw !== undefined ? { halfLifeDays, feedbackStabilityBoost } : undefined;
225
+ // Gate the feedback-count query on the user having explicitly opted into
226
+ // utilityDecay. Without an opt-in, `utilityDecayConfig` is undefined and the
227
+ // ranking contributor ignores `positiveFeedbackCounts` — so running the DB
228
+ // query here would be pure overhead. The boost > 1.0 sub-gate then skips the
229
+ // query when the configured boost is a no-op (1.5^count when boost==1 is 1).
230
+ const positiveFeedbackCounts = shouldQueryPositiveFeedbackCounts(utilityDecayRaw)
231
+ ? getPositiveFeedbackCountsByIds(db, scored.map((item) => item.id))
232
+ : undefined;
233
+ // Resolve per-project scope key for scoped utility scoring.
234
+ // AKM_DISABLE_SCOPED_UTILITY=1 opts out (e.g. for registry searches or tests).
235
+ let scopeKey;
236
+ try {
237
+ scopeKey = process.env.AKM_DISABLE_SCOPED_UTILITY === "1" ? undefined : getCurrentWorkflowScopeKey();
376
238
  }
377
- // Utility-based re-ranking (MemRL pattern).
378
- // After the FTS+boost scoring pass, apply a multiplicative
379
- // utility factor based on aggregated usage telemetry.
380
- // Batch-load all utility scores in one query to avoid N+1.
381
- const UTILITY_WEIGHT = 0.5;
382
- const UTILITY_MAX_BOOST = 1.5; // Cap at 1.5x multiplier
383
- const RECENCY_DECAY_DAYS = 30;
384
- const utilScoresMap = getUtilityScoresByIds(db, scored.map((s) => s.id));
385
- for (const item of scored) {
386
- const utilScore = utilScoresMap.get(item.id);
387
- if (utilScore && utilScore.utility > 0) {
388
- // Compute recency factor: exponential decay based on days since last use
389
- let recencyFactor = 1;
390
- if (utilScore.lastUsedAt) {
391
- const lastUsedMs = new Date(utilScore.lastUsedAt).getTime();
392
- const daysSinceLastUse = Number.isNaN(lastUsedMs)
393
- ? Infinity
394
- : Math.max(0, (Date.now() - lastUsedMs) / (1000 * 60 * 60 * 24));
395
- recencyFactor = Math.exp(-daysSinceLastUse / RECENCY_DECAY_DAYS);
396
- }
397
- // Compute raw utility boost and cap it
398
- const rawBoost = 1 + utilScore.utility * recencyFactor * UTILITY_WEIGHT;
399
- const cappedBoost = Math.min(rawBoost, UTILITY_MAX_BOOST);
400
- item.score = item.score * cappedBoost;
401
- item.utilityBoosted = true;
402
- }
239
+ catch {
240
+ // Non-fatal ranking proceeds without scoped utility on any error.
403
241
  }
242
+ applyRankingRules({
243
+ db,
244
+ query,
245
+ items: scored,
246
+ graphContext,
247
+ projectContext,
248
+ utilityDecayConfig,
249
+ positiveFeedbackCounts,
250
+ scopeKey,
251
+ });
404
252
  // ── minScore floor ──────────────────────────────────────────────────────
405
253
  // Drop semantic-only hits (cosine-only, no FTS match) whose score falls
406
254
  // below the configured floor. FTS hits and hybrid hits are always kept.
@@ -414,18 +262,29 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
414
262
  // a filename field all collapse to files[0]). Showing the same path/ref
415
263
  // multiple times clutters results.
416
264
  const deduped = deduplicateByPath(preFilter);
265
+ // Source filter: when the caller narrowed `sources` via `--source <name>`,
266
+ // drop hits whose filePath does not live under any of the requested
267
+ // sources. The FTS/vector index spans every configured source, so without
268
+ // this filter a narrowed --source request would still leak results from
269
+ // other sources that happened to match the query text.
270
+ const sourceFiltered = restrictToSources
271
+ ? deduped.filter((item) => findSourceForPath(item.filePath, sources) !== undefined)
272
+ : deduped;
417
273
  // Scope filter: drop hits whose stored scope does not satisfy every supplied
418
274
  // key. Applied AFTER ranking — filtering narrows the result set without
419
275
  // touching the single FTS5+boosts scoring pipeline.
420
- const scopeFiltered = filters ? deduped.filter((item) => entryMatchesScope(item.entry.scope, filters)) : deduped;
276
+ const scopeFiltered = filters
277
+ ? sourceFiltered.filter((item) => entryMatchesScope(item.entry.scope, filters))
278
+ : sourceFiltered;
421
279
  // Proposed-quality filter (v1 spec §4.2): exclude entries with
422
280
  // `quality: "proposed"` unless the caller passed `--include-proposed`.
423
281
  // Applied AFTER ranking for the same reason as scope filtering.
424
282
  const qualityFiltered = includeProposed
425
283
  ? scopeFiltered
426
284
  : scopeFiltered.filter((item) => !isProposedQuality(item.entry.quality));
285
+ const beliefFiltered = qualityFiltered.filter((item) => matchBeliefFilter(item.entry.type, item.entry.beliefState, beliefFilter));
427
286
  const rankMs = Date.now() - tRank0;
428
- const selected = qualityFiltered.slice(0, limit);
287
+ const selected = beliefFiltered.slice(0, limit);
429
288
  const hits = await Promise.all(selected.map(({ entry, filePath, score, rankingMode, utilityBoosted }) => {
430
289
  // CLAUDE.md locks SearchHit.score in [0,1]. The boost loop above can
431
290
  // exceed 1.0 (this was a pre-existing breach that #207's graph boost
@@ -444,11 +303,29 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
444
303
  sources,
445
304
  config,
446
305
  utilityBoosted,
306
+ graphContext,
447
307
  rendererRegistry,
308
+ db,
448
309
  });
449
310
  }));
450
311
  return { embedMs, rankMs, hits };
451
312
  }
313
+ function matchBeliefFilter(type, beliefState, filter) {
314
+ if (filter === "all")
315
+ return true;
316
+ if (type !== "memory")
317
+ return true;
318
+ if (filter === "current") {
319
+ // Phase 1A: `asserted` is a "current" state (stronger authority than `active`);
320
+ // `deprecated` is excluded from current results.
321
+ return beliefState === undefined || beliefState === "active" || beliefState === "asserted";
322
+ }
323
+ // historical
324
+ return (beliefState === "contradicted" ||
325
+ beliefState === "superseded" ||
326
+ beliefState === "deprecated" ||
327
+ beliefState === "archived");
328
+ }
452
329
  // ── Vector scorer ───────────────────────────────────────────────────────────
453
330
  async function tryVecScores(db, query, k, config) {
454
331
  const semanticStatus = getEffectiveSemanticStatus(config, readSemanticStatus());
@@ -489,7 +366,9 @@ export async function buildDbHit(input) {
489
366
  const confidenceBoost = typeof input.entry.confidence === "number" ? Math.min(0.05, Math.max(0, input.entry.confidence) * 0.05) : 0;
490
367
  // Round to 4 decimal places, no boost multiplication
491
368
  const score = Math.round(input.score * 10000) / 10000;
492
- const whyMatched = buildWhyMatched(input.entry, input.query, input.rankingMode, qualityBoost, confidenceBoost, input.utilityBoosted);
369
+ const graphBoost = input.graphContext ? computeGraphBoost(input.graphContext, input.path) : 0;
370
+ const whyMatched = buildWhyMatched(input.entry, input.query, input.rankingMode, qualityBoost, confidenceBoost, input.utilityBoosted, graphBoost);
371
+ const graphHit = input.graphContext ? collectGraphRelatedHit(input.graphContext, input.path) : null;
493
372
  const source = findSourceForPath(input.path, input.sources);
494
373
  const ref = resolveSearchHitRef(input.entry, input.entry.name, source);
495
374
  const editable = isEditable(input.path, input.config);
@@ -514,16 +393,21 @@ export async function buildDbHit(input) {
514
393
  // Surface optional quality (v1 spec §4.2). Omitted when entry has
515
394
  // no `quality` field so payloads stay compact for the common case.
516
395
  ...(input.entry.quality ? { quality: input.entry.quality } : {}),
396
+ ...(input.entry.beliefState ? { beliefState: input.entry.beliefState } : {}),
397
+ ...(input.entry.currentBeliefRefs ? { currentBeliefRefs: input.entry.currentBeliefRefs } : {}),
398
+ ...(graphHit ? { graph: { entities: graphHit.entities, relations: graphHit.relations } } : {}),
517
399
  };
518
- const renderer = await rendererForType(input.entry.type, rendererRegistry);
519
- if (renderer?.enrichSearchHit) {
520
- renderer.enrichSearchHit(hit, entryStashDir);
521
- }
400
+ await enrichSearchHit(hit, {
401
+ type: input.entry.type,
402
+ stashDir: entryStashDir,
403
+ rendererRegistry,
404
+ db: input.db,
405
+ });
522
406
  return hit;
523
407
  }
524
408
  export function buildWhyMatched(entry, query,
525
409
  // "hybrid" ranking mode
526
- rankingMode, qualityBoost, confidenceBoost, utilityBoosted) {
410
+ rankingMode, qualityBoost, confidenceBoost, utilityBoosted, graphBoost) {
527
411
  const reasons = [
528
412
  rankingMode === "hybrid"
529
413
  ? "hybrid (fts + semantic)"
@@ -565,8 +449,23 @@ rankingMode, qualityBoost, confidenceBoost, utilityBoosted) {
565
449
  reasons.push("curated metadata boost");
566
450
  if (confidenceBoost > 0)
567
451
  reasons.push("metadata confidence boost");
452
+ if (entry.beliefState === "active")
453
+ reasons.push("active belief state");
454
+ if (entry.beliefState === "asserted")
455
+ reasons.push("asserted belief state");
456
+ if (entry.beliefState === "contradicted")
457
+ reasons.push("contradicted belief state");
458
+ if (entry.beliefState === "superseded")
459
+ reasons.push("superseded belief state");
460
+ if (entry.beliefState === "deprecated")
461
+ reasons.push("deprecated belief state");
462
+ if (entry.beliefState === "archived")
463
+ reasons.push("archived belief state");
568
464
  if (utilityBoosted)
569
465
  reasons.push("usage history boost");
466
+ if (typeof graphBoost === "number" && graphBoost > 0) {
467
+ reasons.push(`graph boost +${graphBoost.toFixed(2)}`);
468
+ }
570
469
  return reasons;
571
470
  }
572
471
  // ── Utilities ────────────────────────────────────────────────────────────────
@@ -585,7 +484,7 @@ export function deriveSize(bytes) {
585
484
  * precondition is always met regardless of caller.
586
485
  */
587
486
  function deduplicateByPath(items) {
588
- const sorted = [...items].sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
487
+ const sorted = [...items].sort((a, b) => (b.score ?? 0) - (a.score ?? 0) || a.filePath.localeCompare(b.filePath));
589
488
  const seen = new Set();
590
489
  return sorted.filter((item) => {
591
490
  if (seen.has(item.filePath))