akm-cli 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (333) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/dist/{cli.js → src/cli.js} +712 -34
  3. package/dist/{commands → src/commands}/config-cli.js +47 -4
  4. package/dist/src/commands/distill.js +283 -0
  5. package/dist/src/commands/events.js +108 -0
  6. package/dist/src/commands/history.js +191 -0
  7. package/dist/{commands → src/commands}/installed-stashes.js +1 -1
  8. package/dist/src/commands/proposal.js +119 -0
  9. package/dist/src/commands/propose.js +171 -0
  10. package/dist/src/commands/reflect.js +193 -0
  11. package/dist/{commands → src/commands}/registry-search.js +71 -7
  12. package/dist/{commands → src/commands}/remember.js +12 -0
  13. package/dist/{commands → src/commands}/search.js +104 -4
  14. package/dist/{commands → src/commands}/self-update.js +4 -3
  15. package/dist/{commands → src/commands}/show.js +73 -0
  16. package/dist/{commands → src/commands}/source-add.js +5 -1
  17. package/dist/{commands → src/commands}/source-manage.js +7 -1
  18. package/dist/{core → src/core}/asset-ref.js +5 -5
  19. package/dist/{core → src/core}/asset-spec.js +12 -0
  20. package/dist/{core → src/core}/common.js +1 -1
  21. package/dist/{core → src/core}/config.js +203 -121
  22. package/dist/{core → src/core}/errors.js +4 -0
  23. package/dist/src/core/events.js +239 -0
  24. package/dist/src/core/lesson-lint.js +86 -0
  25. package/dist/src/core/proposals.js +406 -0
  26. package/dist/src/core/warn.js +72 -0
  27. package/dist/{core → src/core}/write-source.js +80 -5
  28. package/dist/{indexer → src/indexer}/db-search.js +114 -24
  29. package/dist/{indexer → src/indexer}/db.js +76 -23
  30. package/dist/{indexer → src/indexer}/file-context.js +0 -3
  31. package/dist/src/indexer/graph-boost.js +179 -0
  32. package/dist/src/indexer/graph-extraction.js +212 -0
  33. package/dist/{indexer → src/indexer}/indexer.js +88 -7
  34. package/dist/{indexer → src/indexer}/matchers.js +1 -1
  35. package/dist/src/indexer/memory-inference.js +263 -0
  36. package/dist/{indexer → src/indexer}/metadata.js +111 -3
  37. package/dist/{indexer → src/indexer}/search-source.js +4 -2
  38. package/dist/src/integrations/agent/config.js +292 -0
  39. package/dist/src/integrations/agent/detect.js +94 -0
  40. package/dist/src/integrations/agent/index.js +17 -0
  41. package/dist/src/integrations/agent/profiles.js +65 -0
  42. package/dist/src/integrations/agent/prompts.js +167 -0
  43. package/dist/src/integrations/agent/spawn.js +272 -0
  44. package/dist/{integrations → src/integrations}/github.js +9 -3
  45. package/dist/{integrations → src/integrations}/lockfile.js +0 -26
  46. package/dist/{llm → src/llm}/client.js +33 -2
  47. package/dist/{llm → src/llm}/embedders/remote.js +37 -3
  48. package/dist/src/llm/feature-gate.js +108 -0
  49. package/dist/src/llm/graph-extract.js +107 -0
  50. package/dist/src/llm/index-passes.js +35 -0
  51. package/dist/src/llm/memory-infer.js +86 -0
  52. package/dist/{output → src/output}/cli-hints.js +15 -2
  53. package/dist/{output → src/output}/renderers.js +63 -2
  54. package/dist/src/output/shapes.js +523 -0
  55. package/dist/src/output/text.js +1116 -0
  56. package/dist/{registry → src/registry}/build-index.js +19 -8
  57. package/dist/{registry → src/registry}/factory.js +0 -8
  58. package/dist/{registry → src/registry}/providers/static-index.js +6 -3
  59. package/dist/{registry → src/registry}/resolve.js +68 -2
  60. package/dist/{setup → src/setup}/setup.js +52 -5
  61. package/dist/{sources → src/sources}/providers/git.js +7 -15
  62. package/dist/{wiki → src/wiki}/wiki.js +54 -6
  63. package/dist/{workflows → src/workflows}/runs.js +37 -3
  64. package/dist/tests/add-website-source.test.js +119 -0
  65. package/dist/tests/agent/agent-config-loader.test.js +70 -0
  66. package/dist/tests/agent/agent-config.test.js +221 -0
  67. package/dist/tests/agent/agent-detect.test.js +100 -0
  68. package/dist/tests/agent/agent-spawn.test.js +234 -0
  69. package/dist/tests/agent-output.test.js +186 -0
  70. package/dist/tests/architecture/agent-no-llm-sdk-guard.test.js +103 -0
  71. package/dist/tests/architecture/agent-spawn-seam.test.js +193 -0
  72. package/dist/tests/architecture/llm-stateless-seam.test.js +112 -0
  73. package/dist/tests/asset-ref.test.js +192 -0
  74. package/dist/tests/asset-registry.test.js +103 -0
  75. package/dist/tests/asset-spec.test.js +241 -0
  76. package/dist/tests/bench/attribution.test.js +996 -0
  77. package/dist/tests/bench/cleanup-sigint.test.js +83 -0
  78. package/dist/tests/bench/cleanup.js +234 -0
  79. package/dist/tests/bench/cleanup.test.js +166 -0
  80. package/dist/tests/bench/cli.js +1018 -0
  81. package/dist/tests/bench/cli.test.js +445 -0
  82. package/dist/tests/bench/compare.test.js +556 -0
  83. package/dist/tests/bench/corpus.js +317 -0
  84. package/dist/tests/bench/corpus.test.js +258 -0
  85. package/dist/tests/bench/doctor.js +525 -0
  86. package/dist/tests/bench/driver.js +401 -0
  87. package/dist/tests/bench/driver.test.js +584 -0
  88. package/dist/tests/bench/environment.js +233 -0
  89. package/dist/tests/bench/environment.test.js +199 -0
  90. package/dist/tests/bench/evolve-metrics.js +179 -0
  91. package/dist/tests/bench/evolve-metrics.test.js +187 -0
  92. package/dist/tests/bench/evolve.js +647 -0
  93. package/dist/tests/bench/evolve.test.js +624 -0
  94. package/dist/tests/bench/failure-modes.test.js +349 -0
  95. package/dist/tests/bench/feedback-integrity.test.js +457 -0
  96. package/dist/tests/bench/leakage.test.js +228 -0
  97. package/dist/tests/bench/learning-curve.test.js +134 -0
  98. package/dist/tests/bench/metrics.js +2395 -0
  99. package/dist/tests/bench/metrics.test.js +1150 -0
  100. package/dist/tests/bench/no-os-tmpdir-invariant.test.js +43 -0
  101. package/dist/tests/bench/opencode-config.js +194 -0
  102. package/dist/tests/bench/opencode-config.test.js +370 -0
  103. package/dist/tests/bench/report.js +1885 -0
  104. package/dist/tests/bench/report.test.js +1038 -0
  105. package/dist/tests/bench/run-config.js +355 -0
  106. package/dist/tests/bench/run-config.test.js +298 -0
  107. package/dist/tests/bench/run-curate-test.js +32 -0
  108. package/dist/tests/bench/run-failing-tasks.js +56 -0
  109. package/dist/tests/bench/run-full-bench.js +51 -0
  110. package/dist/tests/bench/run-items36-targeted.js +69 -0
  111. package/dist/tests/bench/run-nano-quick.js +42 -0
  112. package/dist/tests/bench/run-waveg-targeted.js +62 -0
  113. package/dist/tests/bench/runner.js +699 -0
  114. package/dist/tests/bench/runner.test.js +958 -0
  115. package/dist/tests/bench/search-bridge.test.js +331 -0
  116. package/dist/tests/bench/tmp.js +131 -0
  117. package/dist/tests/bench/trajectory.js +116 -0
  118. package/dist/tests/bench/trajectory.test.js +127 -0
  119. package/dist/tests/bench/verifier.js +114 -0
  120. package/dist/tests/bench/verifier.test.js +118 -0
  121. package/dist/tests/bench/workflow-evaluator.js +557 -0
  122. package/dist/tests/bench/workflow-evaluator.test.js +421 -0
  123. package/dist/tests/bench/workflow-spec.js +345 -0
  124. package/dist/tests/bench/workflow-spec.test.js +363 -0
  125. package/dist/tests/bench/workflow-trace.js +472 -0
  126. package/dist/tests/bench/workflow-trace.test.js +254 -0
  127. package/dist/tests/benchmark-search-quality.js +536 -0
  128. package/dist/tests/benchmark-suite.js +1441 -0
  129. package/dist/tests/capture-cli.test.js +112 -0
  130. package/dist/tests/cli-errors.test.js +204 -0
  131. package/dist/tests/commands/events.test.js +370 -0
  132. package/dist/tests/commands/history.test.js +418 -0
  133. package/dist/tests/commands/import.test.js +103 -0
  134. package/dist/tests/commands/proposal-cli.test.js +209 -0
  135. package/dist/tests/commands/reflect-propose-cli.test.js +333 -0
  136. package/dist/tests/commands/remember.test.js +97 -0
  137. package/dist/tests/commands/scope-flags.test.js +300 -0
  138. package/dist/tests/commands/search.test.js +537 -0
  139. package/dist/tests/commands/show-indexer-parity.test.js +117 -0
  140. package/dist/tests/commands/show.test.js +294 -0
  141. package/dist/tests/common.test.js +266 -0
  142. package/dist/tests/completions.test.js +142 -0
  143. package/dist/tests/config-cli.test.js +193 -0
  144. package/dist/tests/config-llm-features.test.js +139 -0
  145. package/dist/tests/config.test.js +569 -0
  146. package/dist/tests/contracts/migration-baseline.test.js +43 -0
  147. package/dist/tests/contracts/reflect-propose-envelope.test.js +139 -0
  148. package/dist/tests/contracts/spec-helpers.js +46 -0
  149. package/dist/tests/contracts/v1-spec-section-11-proposal-queue.test.js +228 -0
  150. package/dist/tests/contracts/v1-spec-section-12-agent-config.test.js +56 -0
  151. package/dist/tests/contracts/v1-spec-section-13-lesson-type.test.js +34 -0
  152. package/dist/tests/contracts/v1-spec-section-14-llm-features.test.js +94 -0
  153. package/dist/tests/contracts/v1-spec-section-4-1-asset-types.test.js +39 -0
  154. package/dist/tests/contracts/v1-spec-section-4-2-quality-rules.test.js +44 -0
  155. package/dist/tests/contracts/v1-spec-section-5-configuration.test.js +47 -0
  156. package/dist/tests/contracts/v1-spec-section-6-orchestration.test.js +40 -0
  157. package/dist/tests/contracts/v1-spec-section-7-module-layout.test.js +58 -0
  158. package/dist/tests/contracts/v1-spec-section-8-extension-points.test.js +34 -0
  159. package/dist/tests/contracts/v1-spec-section-9-4-cli-surface.test.js +75 -0
  160. package/dist/tests/contracts/v1-spec-section-9-7-llm-agent-boundary.test.js +36 -0
  161. package/dist/tests/core/write-source.test.js +366 -0
  162. package/dist/tests/curate-command.test.js +87 -0
  163. package/dist/tests/db-scoring.test.js +201 -0
  164. package/dist/tests/db.test.js +654 -0
  165. package/dist/tests/distill-cli-flag.test.js +208 -0
  166. package/dist/tests/distill.test.js +515 -0
  167. package/dist/tests/docker-install.test.js +120 -0
  168. package/dist/tests/e2e.test.js +1419 -0
  169. package/dist/tests/embedder.test.js +340 -0
  170. package/dist/tests/embedding-model-config.test.js +379 -0
  171. package/dist/tests/feedback-command.test.js +172 -0
  172. package/dist/tests/file-context.test.js +552 -0
  173. package/dist/tests/fixtures/scripts/git/summarize-diff.js +9 -0
  174. package/dist/tests/fixtures/scripts/lint/eslint-check.js +7 -0
  175. package/dist/tests/fixtures/stashes/load.js +166 -0
  176. package/dist/tests/fixtures/stashes/load.test.js +97 -0
  177. package/dist/tests/fixtures/stashes/ranking-baseline/scripts/mem0-search.js +12 -0
  178. package/dist/tests/frontmatter.test.js +190 -0
  179. package/dist/tests/fts-field-weighting.test.js +254 -0
  180. package/dist/tests/fuzzy-search.test.js +230 -0
  181. package/dist/tests/git-provider-clone.test.js +45 -0
  182. package/dist/tests/github.test.js +161 -0
  183. package/dist/tests/graph-boost-ranking.test.js +305 -0
  184. package/dist/tests/graph-extraction.test.js +282 -0
  185. package/dist/tests/helpers/usage-events.js +8 -0
  186. package/dist/tests/index-pass-llm.test.js +161 -0
  187. package/dist/tests/indexer.test.js +570 -0
  188. package/dist/tests/info-command.test.js +166 -0
  189. package/dist/tests/init.test.js +69 -0
  190. package/dist/tests/install-script.test.js +246 -0
  191. package/dist/tests/integration/agent-real-profile.test.js +94 -0
  192. package/dist/tests/issue-36-repro.test.js +304 -0
  193. package/dist/tests/issues-191-194.test.js +160 -0
  194. package/dist/tests/lesson-lint.test.js +111 -0
  195. package/dist/tests/llm-client.test.js +115 -0
  196. package/dist/tests/llm-feature-gate.test.js +151 -0
  197. package/dist/tests/llm.test.js +139 -0
  198. package/dist/tests/lockfile.test.js +216 -0
  199. package/dist/tests/manifest.test.js +205 -0
  200. package/dist/tests/markdown.test.js +126 -0
  201. package/dist/tests/matchers-unit.test.js +189 -0
  202. package/dist/tests/memory-inference.test.js +299 -0
  203. package/dist/tests/merge-scoring.test.js +136 -0
  204. package/dist/tests/metadata.test.js +313 -0
  205. package/dist/tests/migration-help.test.js +89 -0
  206. package/dist/tests/origin-resolve.test.js +124 -0
  207. package/dist/tests/output-baseline.test.js +218 -0
  208. package/dist/tests/output-shapes-unit.test.js +478 -0
  209. package/dist/tests/parallel-search.test.js +272 -0
  210. package/dist/tests/parameter-metadata.test.js +365 -0
  211. package/dist/tests/paths.test.js +177 -0
  212. package/dist/tests/progressive-disclosure.test.js +280 -0
  213. package/dist/tests/proposals.test.js +279 -0
  214. package/dist/tests/proposed-quality.test.js +271 -0
  215. package/dist/tests/provider-registry.test.js +32 -0
  216. package/dist/tests/ranking-regression.test.js +548 -0
  217. package/dist/tests/reflect-propose.test.js +455 -0
  218. package/dist/tests/registry-build-index.test.js +394 -0
  219. package/dist/tests/registry-cli.test.js +290 -0
  220. package/dist/tests/registry-index-v2.test.js +430 -0
  221. package/dist/tests/registry-install.test.js +728 -0
  222. package/dist/tests/registry-providers/parity.test.js +189 -0
  223. package/dist/tests/registry-providers/skills-sh.test.js +309 -0
  224. package/dist/tests/registry-providers/static-index.test.js +238 -0
  225. package/dist/tests/registry-resolve.test.js +126 -0
  226. package/dist/tests/registry-search.test.js +923 -0
  227. package/dist/tests/remember-frontmatter.test.js +378 -0
  228. package/dist/tests/remember-unit.test.js +123 -0
  229. package/dist/tests/ripgrep-install.test.js +251 -0
  230. package/dist/tests/ripgrep-resolve.test.js +108 -0
  231. package/dist/tests/ripgrep.test.js +163 -0
  232. package/dist/tests/save-command.test.js +94 -0
  233. package/dist/tests/save-trust-qa-fixes.test.js +270 -0
  234. package/dist/tests/scoring-pipeline.test.js +648 -0
  235. package/dist/tests/search-include-proposed-cli.test.js +118 -0
  236. package/dist/tests/self-update.test.js +442 -0
  237. package/dist/tests/semantic-search-e2e.test.js +512 -0
  238. package/dist/tests/semantic-status.test.js +471 -0
  239. package/dist/tests/setup-run.integration.js +877 -0
  240. package/dist/tests/setup-wizard.test.js +198 -0
  241. package/dist/tests/setup.test.js +131 -0
  242. package/dist/tests/source-add.test.js +11 -0
  243. package/dist/tests/source-clone.test.js +254 -0
  244. package/dist/tests/source-manage.test.js +366 -0
  245. package/dist/tests/source-providers/filesystem.test.js +82 -0
  246. package/dist/tests/source-providers/git.test.js +252 -0
  247. package/dist/tests/source-providers/website.test.js +128 -0
  248. package/dist/tests/source-qa-fixes.test.js +286 -0
  249. package/dist/tests/source-registry.test.js +350 -0
  250. package/dist/tests/source-resolve.test.js +100 -0
  251. package/dist/tests/source-source.test.js +281 -0
  252. package/dist/tests/source.test.js +533 -0
  253. package/dist/tests/tar-utils-scan.test.js +73 -0
  254. package/dist/tests/toggle-components.test.js +73 -0
  255. package/dist/tests/usage-telemetry.test.js +265 -0
  256. package/dist/tests/utility-scoring.test.js +558 -0
  257. package/dist/tests/vault-load-error.test.js +78 -0
  258. package/dist/tests/vault-qa-fixes.test.js +194 -0
  259. package/dist/tests/vault.test.js +429 -0
  260. package/dist/tests/vector-search.test.js +608 -0
  261. package/dist/tests/walker.test.js +252 -0
  262. package/dist/tests/wave2-cluster-bc.test.js +228 -0
  263. package/dist/tests/wave2-cluster-d.test.js +180 -0
  264. package/dist/tests/wave2-cluster-e.test.js +179 -0
  265. package/dist/tests/wiki-qa-fixes.test.js +270 -0
  266. package/dist/tests/wiki.test.js +529 -0
  267. package/dist/tests/workflow-cli.test.js +271 -0
  268. package/dist/tests/workflow-markdown.test.js +171 -0
  269. package/dist/tests/workflow-path-escape.test.js +132 -0
  270. package/dist/tests/workflow-qa-fixes.test.js +395 -0
  271. package/dist/tests/workflows/indexer-rejection.test.js +213 -0
  272. package/docs/README.md +8 -0
  273. package/docs/migration/release-notes/0.7.0.md +244 -0
  274. package/package.json +2 -2
  275. package/dist/core/warn.js +0 -27
  276. package/dist/output/shapes.js +0 -212
  277. package/dist/output/text.js +0 -520
  278. /package/dist/{commands → src/commands}/completions.js +0 -0
  279. /package/dist/{commands → src/commands}/curate.js +0 -0
  280. /package/dist/{commands → src/commands}/info.js +0 -0
  281. /package/dist/{commands → src/commands}/init.js +0 -0
  282. /package/dist/{commands → src/commands}/install-audit.js +0 -0
  283. /package/dist/{commands → src/commands}/migration-help.js +0 -0
  284. /package/dist/{commands → src/commands}/source-clone.js +0 -0
  285. /package/dist/{commands → src/commands}/vault.js +0 -0
  286. /package/dist/{core → src/core}/asset-registry.js +0 -0
  287. /package/dist/{core → src/core}/frontmatter.js +0 -0
  288. /package/dist/{core → src/core}/markdown.js +0 -0
  289. /package/dist/{core → src/core}/paths.js +0 -0
  290. /package/dist/{indexer → src/indexer}/manifest.js +0 -0
  291. /package/dist/{indexer → src/indexer}/search-fields.js +0 -0
  292. /package/dist/{indexer → src/indexer}/semantic-status.js +0 -0
  293. /package/dist/{indexer → src/indexer}/usage-events.js +0 -0
  294. /package/dist/{indexer → src/indexer}/walker.js +0 -0
  295. /package/dist/{llm → src/llm}/embedder.js +0 -0
  296. /package/dist/{llm → src/llm}/embedders/cache.js +0 -0
  297. /package/dist/{llm → src/llm}/embedders/local.js +0 -0
  298. /package/dist/{llm → src/llm}/embedders/types.js +0 -0
  299. /package/dist/{llm → src/llm}/metadata-enhance.js +0 -0
  300. /package/dist/{output → src/output}/context.js +0 -0
  301. /package/dist/{registry → src/registry}/create-provider-registry.js +0 -0
  302. /package/dist/{registry → src/registry}/origin-resolve.js +0 -0
  303. /package/dist/{registry → src/registry}/providers/index.js +0 -0
  304. /package/dist/{registry → src/registry}/providers/skills-sh.js +0 -0
  305. /package/dist/{registry → src/registry}/providers/types.js +0 -0
  306. /package/dist/{registry → src/registry}/types.js +0 -0
  307. /package/dist/{setup → src/setup}/detect.js +0 -0
  308. /package/dist/{setup → src/setup}/ripgrep-install.js +0 -0
  309. /package/dist/{setup → src/setup}/ripgrep-resolve.js +0 -0
  310. /package/dist/{setup → src/setup}/steps.js +0 -0
  311. /package/dist/{sources → src/sources}/include.js +0 -0
  312. /package/dist/{sources → src/sources}/provider-factory.js +0 -0
  313. /package/dist/{sources → src/sources}/provider.js +0 -0
  314. /package/dist/{sources → src/sources}/providers/filesystem.js +0 -0
  315. /package/dist/{sources → src/sources}/providers/index.js +0 -0
  316. /package/dist/{sources → src/sources}/providers/install-types.js +0 -0
  317. /package/dist/{sources → src/sources}/providers/npm.js +0 -0
  318. /package/dist/{sources → src/sources}/providers/provider-utils.js +0 -0
  319. /package/dist/{sources → src/sources}/providers/sync-from-ref.js +0 -0
  320. /package/dist/{sources → src/sources}/providers/tar-utils.js +0 -0
  321. /package/dist/{sources → src/sources}/providers/website.js +0 -0
  322. /package/dist/{sources → src/sources}/resolve.js +0 -0
  323. /package/dist/{sources → src/sources}/types.js +0 -0
  324. /package/dist/{templates → src/templates}/wiki-templates.js +0 -0
  325. /package/dist/{version.js → src/version.js} +0 -0
  326. /package/dist/{workflows → src/workflows}/authoring.js +0 -0
  327. /package/dist/{workflows → src/workflows}/cli.js +0 -0
  328. /package/dist/{workflows → src/workflows}/db.js +0 -0
  329. /package/dist/{workflows → src/workflows}/document-cache.js +0 -0
  330. /package/dist/{workflows → src/workflows}/parser.js +0 -0
  331. /package/dist/{workflows → src/workflows}/renderer.js +0 -0
  332. /package/dist/{workflows → src/workflows}/schema.js +0 -0
  333. /package/dist/{workflows → src/workflows}/validator.js +0 -0
@@ -19,7 +19,8 @@ import { getDbPath } from "../core/paths";
19
19
  import { warn } from "../core/warn";
20
20
  import { closeDatabase, getAllEntries, getEntryById, getEntryCount, getMeta, getUtilityScoresByIds, openDatabase, sanitizeFtsQuery, searchFts, searchVec, } from "./db";
21
21
  import { getRenderer } from "./file-context";
22
- import { generateMetadataFlat, loadStashFile, shouldIndexStashFile } from "./metadata";
22
+ import { computeGraphBoost, loadGraphBoostContext } from "./graph-boost";
23
+ import { generateMetadataFlat, isProposedQuality, loadStashFile, shouldIndexStashFile, } from "./metadata";
23
24
  import { buildSearchText } from "./search-fields";
24
25
  import { buildEditHint, findSourceForPath, isEditable } from "./search-source";
25
26
  import { deriveSemanticProviderFingerprint, getEffectiveSemanticStatus, isSemanticRuntimeReady, readSemanticStatus, } from "./semantic-status";
@@ -44,6 +45,8 @@ function resolveSearchHitOrigin(source) {
44
45
  // ── Main search entrypoint ───────────────────────────────────────────────────
45
46
  export async function searchLocal(input) {
46
47
  const { query, searchType, limit, stashDir, sources, config } = input;
48
+ const filters = input.filters;
49
+ const includeProposed = input.includeProposed === true;
47
50
  const rendererRegistry = input.rendererRegistry ?? defaultRendererRegistry;
48
51
  const allSourceDirs = sources.map((s) => s.path);
49
52
  const rawStatus = readSemanticStatus();
@@ -85,7 +88,7 @@ export async function searchLocal(input) {
85
88
  }
86
89
  }
87
90
  if (entryCount > 0 && stashDirMatch) {
88
- const { hits, embedMs, rankMs } = await searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry);
91
+ const { hits, embedMs, rankMs } = await searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry, filters, includeProposed);
89
92
  return {
90
93
  hits,
91
94
  tip: hits.length === 0
@@ -105,7 +108,7 @@ export async function searchLocal(input) {
105
108
  catch (error) {
106
109
  warn("Search index unavailable, falling back to substring search:", error instanceof Error ? error.message : String(error));
107
110
  }
108
- const hitArrays = await Promise.all(allSourceDirs.map((dir) => substringSearch(query, searchType, limit, dir, sources, config, rendererRegistry)));
111
+ const hitArrays = await Promise.all(allSourceDirs.map((dir) => substringSearch(query, searchType, limit, dir, sources, config, rendererRegistry, filters, includeProposed)));
109
112
  const hits = hitArrays.flat().slice(0, limit);
110
113
  return {
111
114
  hits,
@@ -114,7 +117,7 @@ export async function searchLocal(input) {
114
117
  };
115
118
  }
116
119
  // ── Database search ─────────────────────────────────────────────────────────
117
- async function searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry = defaultRendererRegistry) {
120
+ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry = defaultRendererRegistry, filters, includeProposed = false) {
118
121
  const hasSearchableTokens = query.length > 0 && sanitizeFtsQuery(query).length > 0;
119
122
  // Empty queries — including ones that sanitize down to no searchable FTS
120
123
  // tokens such as "." — should enumerate matching entries instead of
@@ -130,7 +133,18 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
130
133
  seenFilePaths.add(ie.filePath);
131
134
  return true;
132
135
  });
133
- const selected = uniqueEntries.slice(0, limit);
136
+ // Scope filter: drop entries whose stored scope does not satisfy every
137
+ // supplied scope key. Filtering happens BEFORE the limit slice so a
138
+ // restrictive filter still returns up to `limit` results.
139
+ const scopeFiltered = filters
140
+ ? uniqueEntries.filter((ie) => entryMatchesScope(ie.entry.scope, filters))
141
+ : uniqueEntries;
142
+ // Proposed-quality filter (v1 spec §4.2): exclude entries with
143
+ // `quality: "proposed"` unless the caller explicitly opts in.
144
+ const qualityFiltered = includeProposed
145
+ ? scopeFiltered
146
+ : scopeFiltered.filter((ie) => !isProposedQuality(ie.entry.quality));
147
+ const selected = qualityFiltered.slice(0, limit);
134
148
  const hits = await Promise.all(selected.map((ie) => buildDbHit({
135
149
  entry: ie.entry,
136
150
  path: ie.filePath,
@@ -231,6 +245,23 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
231
245
  // reference docs. Curated metadata is more reliable than auto-generated.
232
246
  const queryTokens = query.toLowerCase().split(/\s+/).filter(Boolean);
233
247
  const queryLower = query.toLowerCase().trim();
248
+ // Graph boost context (#207). Built once per query and reused across
249
+ // every scored entry so the disk read + JSON parse only happens once
250
+ // per search invocation. `null` when no graph file is present, when
251
+ // the schema doesn't match, or when no query token matches a graph
252
+ // entity — in all of those cases the per-entry call is skipped and
253
+ // graph contributes nothing. The graph signal feeds this single
254
+ // FTS5+boosts loop as ONE additive component (CLAUDE.md / spec §6:
255
+ // one scoring pipeline, no parallel SearchHit scorer).
256
+ const graphContext = (() => {
257
+ // Search across all source dirs; the graph file lives next to the
258
+ // primary source root. Cache misses are silent — the helper handles
259
+ // missing files internally and returns `null` instead of throwing.
260
+ const primaryDir = allSourceDirs[0];
261
+ if (!primaryDir)
262
+ return null;
263
+ return loadGraphBoostContext(primaryDir, query);
264
+ })();
234
265
  for (const item of scored) {
235
266
  const entry = item.entry;
236
267
  let boostSum = 0;
@@ -263,6 +294,7 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
263
294
  const TYPE_BOOST = {
264
295
  skill: 0.4,
265
296
  command: 0.35,
297
+ workflow: 0.35,
266
298
  agent: 0.3,
267
299
  script: 0.2,
268
300
  memory: 0.1,
@@ -324,10 +356,26 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
324
356
  }
325
357
  }
326
358
  // ── 7. Metadata quality signals ──
327
- const qualityBoost = entry.quality === "generated" ? 0 : 0.05;
359
+ // Curated metadata is the only boost-bearing quality marker. `generated`
360
+ // and `proposed` (and unknown values) get no boost. `proposed` is also
361
+ // filtered out by default downstream (v1 spec §4.2).
362
+ const qualityBoost = entry.quality === "curated" ? 0.05 : 0;
328
363
  boostSum += qualityBoost;
329
364
  const confidenceBoost = typeof entry.confidence === "number" ? Math.min(0.05, Math.max(0, entry.confidence) * 0.05) : 0;
330
365
  boostSum += confidenceBoost;
366
+ // ── 8. Graph signal (opt-in, #207) ──
367
+ // When the graph-extraction pass has produced a `graph.json`,
368
+ // contribute an additive boost based on how many of this entry's
369
+ // extracted entities match the query (or are one hop away from a
370
+ // match). Computed inside the same loop so all boosts are in one
371
+ // place and the per-call cost is one map lookup when the graph is
372
+ // absent. There is no parallel scoring track — `boostSum` is the
373
+ // single accumulator and the existing `MAX_BOOST_SUM` cap below
374
+ // applies to graph contributions exactly as it does to every other
375
+ // boost.
376
+ if (graphContext) {
377
+ boostSum += computeGraphBoost(graphContext, item.filePath);
378
+ }
331
379
  const cappedBoost = Math.min(boostSum, MAX_BOOST_SUM);
332
380
  item.score = item.score * (1 + cappedBoost);
333
381
  }
@@ -371,22 +419,39 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
371
419
  // a filename field all collapse to files[0]). Showing the same path/ref
372
420
  // multiple times clutters results.
373
421
  const deduped = deduplicateByPath(preFilter);
422
+ // Scope filter: drop hits whose stored scope does not satisfy every supplied
423
+ // key. Applied AFTER ranking — filtering narrows the result set without
424
+ // touching the single FTS5+boosts scoring pipeline.
425
+ const scopeFiltered = filters ? deduped.filter((item) => entryMatchesScope(item.entry.scope, filters)) : deduped;
426
+ // Proposed-quality filter (v1 spec §4.2): exclude entries with
427
+ // `quality: "proposed"` unless the caller passed `--include-proposed`.
428
+ // Applied AFTER ranking for the same reason as scope filtering.
429
+ const qualityFiltered = includeProposed
430
+ ? scopeFiltered
431
+ : scopeFiltered.filter((item) => !isProposedQuality(item.entry.quality));
374
432
  const rankMs = Date.now() - tRank0;
375
- const selected = deduped.slice(0, limit);
376
- const hits = await Promise.all(selected.map(({ entry, filePath, score, rankingMode, utilityBoosted }) => buildDbHit({
377
- entry,
378
- path: filePath,
379
- // Round to 4 decimal places
380
- score: Math.round(score * 10000) / 10000,
381
- query,
382
- rankingMode,
383
- defaultStashDir: stashDir,
384
- allSourceDirs,
385
- sources,
386
- config,
387
- utilityBoosted,
388
- rendererRegistry,
389
- })));
433
+ const selected = qualityFiltered.slice(0, limit);
434
+ const hits = await Promise.all(selected.map(({ entry, filePath, score, rankingMode, utilityBoosted }) => {
435
+ // CLAUDE.md locks SearchHit.score in [0,1]. The boost loop above can
436
+ // exceed 1.0 (this was a pre-existing breach that #207's graph boost
437
+ // up to ~1.05 additive contribution — made detectable); clamp here
438
+ // so the score handed to buildDbHit always satisfies the spec.
439
+ const finalScore = Math.min(1, Math.max(0, score));
440
+ return buildDbHit({
441
+ entry,
442
+ path: filePath,
443
+ // Round to 4 decimal places
444
+ score: Math.round(finalScore * 10000) / 10000,
445
+ query,
446
+ rankingMode,
447
+ defaultStashDir: stashDir,
448
+ allSourceDirs,
449
+ sources,
450
+ config,
451
+ utilityBoosted,
452
+ rendererRegistry,
453
+ });
454
+ }));
390
455
  return { embedMs, rankMs, hits };
391
456
  }
392
457
  // ── Vector scorer ───────────────────────────────────────────────────────────
@@ -416,9 +481,13 @@ async function tryVecScores(db, query, k, config) {
416
481
  }
417
482
  }
418
483
  // ── Substring fallback (no index) ───────────────────────────────────────────
419
- async function substringSearch(query, searchType, limit, stashDir, sources, config, rendererRegistry = defaultRendererRegistry) {
484
+ async function substringSearch(query, searchType, limit, stashDir, sources, config, rendererRegistry = defaultRendererRegistry, filters, includeProposed = false) {
420
485
  const assets = await indexAssets(stashDir, searchType, sources);
421
- const matched = assets.filter((asset) => !query || buildSearchText(asset.entry).includes(query));
486
+ const scopeMatched = filters ? assets.filter((asset) => entryMatchesScope(asset.entry.scope, filters)) : assets;
487
+ const qualityMatched = includeProposed
488
+ ? scopeMatched
489
+ : scopeMatched.filter((asset) => !isProposedQuality(asset.entry.quality));
490
+ const matched = qualityMatched.filter((asset) => !query || buildSearchText(asset.entry).includes(query));
422
491
  if (!query) {
423
492
  const sorted = matched.sort(compareAssets);
424
493
  const unique = deduplicateAssetsByPath(sorted);
@@ -471,7 +540,9 @@ export async function buildDbHit(input) {
471
540
  // phase (searchDatabase). buildDbHit receives the already-final score and
472
541
  // passes it through without further multiplication. We still compute the
473
542
  // boost values here for buildWhyMatched reporting.
474
- const qualityBoost = input.entry.quality === "generated" ? 0 : 0.05;
543
+ // Mirrors the boost computation in `searchDatabase`; only `curated`
544
+ // contributes a positive boost. Used for `whyMatched` reporting only.
545
+ const qualityBoost = input.entry.quality === "curated" ? 0.05 : 0;
475
546
  const confidenceBoost = typeof input.entry.confidence === "number" ? Math.min(0.05, Math.max(0, input.entry.confidence) * 0.05) : 0;
476
547
  // Round to 4 decimal places, no boost multiplication
477
548
  const score = Math.round(input.score * 10000) / 10000;
@@ -495,6 +566,9 @@ export async function buildDbHit(input) {
495
566
  score,
496
567
  whyMatched,
497
568
  ...(estimatedTokens !== undefined ? { estimatedTokens } : {}),
569
+ // Surface optional quality (v1 spec §4.2). Omitted when entry has
570
+ // no `quality` field so payloads stay compact for the common case.
571
+ ...(input.entry.quality ? { quality: input.entry.quality } : {}),
498
572
  };
499
573
  const renderer = await rendererForType(input.entry.type, rendererRegistry);
500
574
  if (renderer?.enrichSearchHit) {
@@ -573,6 +647,7 @@ async function assetToSearchHit(asset, stashDir, sources, config, score, rendere
573
647
  action: buildLocalAction(asset.entry.type, ref, rendererRegistry),
574
648
  ...(score !== undefined ? { score } : {}),
575
649
  ...(estimatedTokens !== undefined ? { estimatedTokens } : {}),
650
+ ...(asset.entry.quality ? { quality: asset.entry.quality } : {}),
576
651
  };
577
652
  const renderer = await rendererForType(asset.entry.type, rendererRegistry);
578
653
  if (renderer?.enrichSearchHit) {
@@ -717,6 +792,21 @@ function deduplicateAssetsByPath(assets) {
717
792
  return true;
718
793
  });
719
794
  }
795
+ /**
796
+ * Exact-match scope filter check. Legacy entries without a `scope` object only
797
+ * match when no filter is supplied — which is what the caller guards on
798
+ * before invoking this helper.
799
+ */
800
+ function entryMatchesScope(scope, filters) {
801
+ for (const key of ["user", "agent", "run", "channel"]) {
802
+ const expected = filters[key];
803
+ if (expected === undefined)
804
+ continue;
805
+ if (!scope || scope[key] !== expected)
806
+ return false;
807
+ }
808
+ return true;
809
+ }
720
810
  function realpathOrResolve(targetPath) {
721
811
  try {
722
812
  return fs.realpathSync(targetPath);
@@ -240,7 +240,14 @@ function ensureSchema(db, embeddingDim) {
240
240
  */
241
241
  function handleVersionUpgrade(db) {
242
242
  const storedVersion = getMeta(db, "version");
243
- if (!storedVersion || storedVersion === String(DB_VERSION))
243
+ // BUG-L4: distinguish "missing" (undefined) from "present but empty" — both
244
+ // were previously coerced through `!storedVersion` and treated as "no
245
+ // upgrade needed", which caused fresh databases (with no version row) to
246
+ // skip the upgrade path correctly, but also caused the upgrade path to be
247
+ // taken when a corrupted/empty version string was persisted. The current
248
+ // tables get dropped only when the stored version exists AND differs from
249
+ // DB_VERSION; missing or empty version means a fresh DB and no upgrade.
250
+ if (storedVersion === undefined || storedVersion === "" || storedVersion === String(DB_VERSION))
244
251
  return [];
245
252
  let usageBackup = [];
246
253
  try {
@@ -258,7 +265,7 @@ function handleVersionUpgrade(db) {
258
265
  db.exec("DROP INDEX IF EXISTS idx_entries_type");
259
266
  db.exec("DROP TABLE IF EXISTS entries");
260
267
  db.exec("DELETE FROM index_meta");
261
- console.warn("[akm] Index rebuilt due to version upgrade. Run 'akm index' to repopulate.");
268
+ warn("[akm] Index rebuilt due to version upgrade. Run 'akm index' to repopulate.");
262
269
  return usageBackup;
263
270
  }
264
271
  /**
@@ -272,22 +279,49 @@ function restoreUsageEventsBackup(db, backup) {
272
279
  if (backup.length === 0)
273
280
  return;
274
281
  try {
282
+ // BUG-H4: introspect the *target* table's columns rather than relying on
283
+ // `row[0]`'s keys. The backup may carry columns the new schema dropped,
284
+ // and the new schema may have NOT-NULL columns without DEFAULT that the
285
+ // old backup never carried. Project the backup onto the intersection so
286
+ // we don't silently lose every row to per-row INSERT errors, and warn
287
+ // once if any backup column was dropped from the new schema.
288
+ const targetCols = db.prepare("PRAGMA table_info(usage_events)").all().map((c) => c.name);
289
+ if (targetCols.length === 0) {
290
+ warn("[db] restoreUsageEventsBackup: usage_events table missing — discarding %d backup row(s)", backup.length);
291
+ return;
292
+ }
293
+ const targetSet = new Set(targetCols);
294
+ const backupCols = Object.keys(backup[0] ?? {});
295
+ const projectedCols = backupCols.filter((c) => targetSet.has(c));
296
+ const droppedCols = backupCols.filter((c) => !targetSet.has(c));
297
+ if (projectedCols.length === 0) {
298
+ warn("[db] restoreUsageEventsBackup: no overlapping columns between backup and current schema — discarding %d row(s); dropped: %s", backup.length, droppedCols.join(", ") || "(none)");
299
+ return;
300
+ }
301
+ if (droppedCols.length > 0) {
302
+ warn("[db] restoreUsageEventsBackup: dropping columns no longer in usage_events schema: %s", droppedCols.join(", "));
303
+ }
304
+ let restored = 0;
305
+ let failed = 0;
275
306
  db.transaction(() => {
276
- const cols = Object.keys(backup[0]);
277
- const placeholders = cols.map(() => "?").join(", ");
278
- const insert = db.prepare(`INSERT INTO usage_events (${cols.join(", ")}) VALUES (${placeholders})`);
307
+ const placeholders = projectedCols.map(() => "?").join(", ");
308
+ const insert = db.prepare(`INSERT INTO usage_events (${projectedCols.join(", ")}) VALUES (${placeholders})`);
279
309
  for (const row of backup) {
280
310
  try {
281
- insert.run(...cols.map((c) => row[c]));
311
+ insert.run(...projectedCols.map((c) => row[c]));
312
+ restored++;
282
313
  }
283
314
  catch {
284
- /* skip rows that fail */
315
+ failed++;
285
316
  }
286
317
  }
287
318
  })();
319
+ if (failed > 0) {
320
+ warn("[db] restoreUsageEventsBackup: restored %d row(s); skipped %d incompatible row(s)", restored, failed);
321
+ }
288
322
  }
289
- catch {
290
- /* schema changed too muchdiscard backup gracefully */
323
+ catch (err) {
324
+ warn("[db] restoreUsageEventsBackup: discarded %d backup row(s) %s", backup.length, err instanceof Error ? err.message : String(err));
291
325
  }
292
326
  }
293
327
  // ── Meta helpers ────────────────────────────────────────────────────────────
@@ -488,17 +522,15 @@ export function rebuildFts(db, options) {
488
522
  if (skipped > 0) {
489
523
  warn(`[db] rebuildFts: skipped ${skipped} entr${skipped === 1 ? "y" : "ies"} with invalid entry_json`);
490
524
  }
491
- // Always drain the dirty queue — if it exists. A full rebuild also
492
- // clears it because the full path covers everything the dirty list
493
- // tracks.
494
- if (incremental) {
495
- db.exec("DELETE FROM entries_fts_dirty");
496
- }
497
- else {
498
- // Full path: drain the dirty queue too. The table is created by
499
- // ensureSchema(), so it always exists at this point.
500
- db.exec("DELETE FROM entries_fts_dirty");
501
- }
525
+ // Always drain the dirty queue — both paths converge here. The
526
+ // incremental path drains it because we just consumed every dirty row;
527
+ // the full path drains it because a full rebuild covers everything the
528
+ // dirty list tracks. The table is guaranteed to exist (created by
529
+ // ensureSchema()).
530
+ //
531
+ // BUG-L1: previously the if/else arms ran identical statements — the
532
+ // duplication has been collapsed.
533
+ db.exec("DELETE FROM entries_fts_dirty");
502
534
  })();
503
535
  }
504
536
  // ── Vector operations ───────────────────────────────────────────────────────
@@ -539,8 +571,26 @@ function float32Buffer(vec) {
539
571
  const f32 = new Float32Array(vec);
540
572
  return Buffer.from(f32.buffer);
541
573
  }
542
- function bufferToFloat32(buf) {
543
- const f32 = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
574
+ /**
575
+ * Decode a stored embedding BLOB into a Float32 array of `expectedDim`
576
+ * dimensions. Returns `null` (and emits a warning) when the byte length does
577
+ * not exactly match `expectedDim * 4`, including the legacy partial-trailing
578
+ * float case the previous truncating-divide silently swallowed.
579
+ *
580
+ * BUG-M2: the previous `buf.byteLength / 4` divide would truncate any
581
+ * trailing partial float and a misaligned `byteOffset` would throw — both
582
+ * surfaced as opaque generic errors caught upstream.
583
+ */
584
+ function bufferToFloat32(buf, expectedDim) {
585
+ if (buf.byteLength !== expectedDim * 4) {
586
+ warn("[db] bufferToFloat32: skipping embedding row — expected %d bytes (%d dim x 4), got %d", expectedDim * 4, expectedDim, buf.byteLength);
587
+ return null;
588
+ }
589
+ // Copy into a fresh ArrayBuffer to sidestep any byteOffset alignment
590
+ // requirements imposed by Float32Array's typed-array view contract.
591
+ const aligned = new ArrayBuffer(buf.byteLength);
592
+ new Uint8Array(aligned).set(buf);
593
+ const f32 = new Float32Array(aligned);
544
594
  return Array.from(f32);
545
595
  }
546
596
  function searchBlobVec(db, queryEmbedding, k) {
@@ -548,9 +598,12 @@ function searchBlobVec(db, queryEmbedding, k) {
548
598
  const rows = db.prepare("SELECT id, embedding FROM embeddings").all();
549
599
  if (rows.length === 0)
550
600
  return [];
601
+ const expectedDim = queryEmbedding.length;
551
602
  const scored = [];
552
603
  for (const row of rows) {
553
- const embedding = bufferToFloat32(row.embedding);
604
+ const embedding = bufferToFloat32(row.embedding, expectedDim);
605
+ if (embedding === null)
606
+ continue;
554
607
  const similarity = cosineSimilarity(queryEmbedding, embedding);
555
608
  scored.push({ id: row.id, similarity });
556
609
  }
@@ -69,9 +69,6 @@ const matchers = [];
69
69
  /** Renderer lookup by name. */
70
70
  const renderers = new Map();
71
71
  let builtinsPromise;
72
- export function resetBuiltinsCache() {
73
- builtinsPromise = undefined;
74
- }
75
72
  /**
76
73
  * Ensure that built-in matchers and renderers are registered.
77
74
  * Called lazily on first use of runMatchers/getRenderer.
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Search-time graph-boost integration for the `akm index` graph pass (#207).
3
+ *
4
+ * This module is the consumer half of the graph-extraction pass. It loads
5
+ * the persisted `graph.json` (when present) and exposes a single helper,
6
+ * {@link computeGraphBoost}, that the existing FTS5+boosts loop in
7
+ * `src/indexer/db-search.ts` calls per-entry to obtain an additive boost
8
+ * value.
9
+ *
10
+ * CLAUDE.md / v1 spec compliance:
11
+ * - The graph signal feeds the **single** FTS5+boosts pipeline as one
12
+ * additive boost component. There is no parallel scoring track.
13
+ * - There is no second `SearchHit` scorer. `searchDatabase` continues to
14
+ * own ranking; this module just answers "what additive boost does the
15
+ * graph contribute for this (query, entry) pair?".
16
+ * - Missing/stale/unparseable `graph.json` → boost is `0`. The pipeline
17
+ * degrades gracefully to its non-graph behaviour, exactly as today.
18
+ */
19
+ import fs from "node:fs";
20
+ import { warn } from "../core/warn";
21
+ import { GRAPH_FILE_SCHEMA_VERSION, getGraphFilePath } from "./graph-extraction";
22
+ /**
23
+ * Per-entry weights, exposed as constants so tests can read them and so the
24
+ * single-source-of-truth for "how much does the graph contribute" is here
25
+ * rather than inlined into `db-search.ts`. Kept conservative — the goal is
26
+ * a useful tiebreaker, not domination of the lexical signal.
27
+ */
28
+ export const GRAPH_DIRECT_BOOST_PER_ENTITY = 0.25;
29
+ export const GRAPH_DIRECT_BOOST_CAP = 0.75;
30
+ export const GRAPH_HOP_BOOST_PER_ENTITY = 0.1;
31
+ export const GRAPH_HOP_BOOST_CAP = 0.3;
32
+ /**
33
+ * Load the graph file for a stash root and pre-compute everything that's
34
+ * shared across all entries scored for one query. Returns `null` when:
35
+ * - `graph.json` does not exist.
36
+ * - The file fails to parse.
37
+ * - The schema version doesn't match (treated like "missing" so an old
38
+ * index keeps working until the next `akm index --full`).
39
+ * - The query produces no token-level entity matches (no boost is
40
+ * possible, so we skip the per-entry overhead entirely).
41
+ */
42
+ export function loadGraphBoostContext(stashRoot, query) {
43
+ const graph = readGraphFile(stashRoot);
44
+ if (!graph)
45
+ return null;
46
+ const queryTokens = query
47
+ .toLowerCase()
48
+ .split(/[\s\-_/]+/)
49
+ .filter((t) => t.length >= 2);
50
+ if (queryTokens.length === 0)
51
+ return null;
52
+ // Build a flat union of all extracted entities across the corpus. This
53
+ // is small (capped per-asset at extract time) and lets the per-entry
54
+ // path do a single set membership test.
55
+ const allEntities = new Set();
56
+ const nodesByPath = new Map();
57
+ for (const node of graph.files) {
58
+ nodesByPath.set(node.path, node);
59
+ for (const entity of node.entities)
60
+ allEntities.add(entity);
61
+ }
62
+ // An entity matches the query when any of its sub-tokens equals or
63
+ // contains a query token. Cheap and forgiving — exact substring match is
64
+ // sufficient because both sides are already lower-cased at extract time.
65
+ const matchedEntities = new Set();
66
+ for (const entity of allEntities) {
67
+ const entityTokens = entity.split(/[\s\-_/]+/).filter(Boolean);
68
+ for (const qt of queryTokens) {
69
+ if (entity === qt || entity.includes(qt) || entityTokens.some((et) => et === qt)) {
70
+ matchedEntities.add(entity);
71
+ break;
72
+ }
73
+ }
74
+ }
75
+ if (matchedEntities.size === 0)
76
+ return null;
77
+ // One-hop neighbours: any entity that appears on the other end of a
78
+ // relation whose other endpoint is in matchedEntities.
79
+ const oneHopEntities = new Set();
80
+ for (const node of graph.files) {
81
+ for (const rel of node.relations) {
82
+ if (matchedEntities.has(rel.from) && !matchedEntities.has(rel.to)) {
83
+ oneHopEntities.add(rel.to);
84
+ }
85
+ else if (matchedEntities.has(rel.to) && !matchedEntities.has(rel.from)) {
86
+ oneHopEntities.add(rel.from);
87
+ }
88
+ }
89
+ }
90
+ return { nodesByPath, matchedEntities, oneHopEntities };
91
+ }
92
+ /**
93
+ * Compute the graph-boost contribution for a single scored entry.
94
+ *
95
+ * The return value is added directly into `boostSum` in `searchDatabase`'s
96
+ * existing scoring loop — same units, same cap policy. Returns `0` when
97
+ * the entry's file isn't in the graph or when no entity overlap exists.
98
+ */
99
+ export function computeGraphBoost(context, filePath) {
100
+ const node = context.nodesByPath.get(filePath);
101
+ if (!node)
102
+ return 0;
103
+ let directHits = 0;
104
+ let hopHits = 0;
105
+ for (const entity of node.entities) {
106
+ if (context.matchedEntities.has(entity))
107
+ directHits += 1;
108
+ else if (context.oneHopEntities.has(entity))
109
+ hopHits += 1;
110
+ }
111
+ const directBoost = Math.min(GRAPH_DIRECT_BOOST_CAP, directHits * GRAPH_DIRECT_BOOST_PER_ENTITY);
112
+ const hopBoost = Math.min(GRAPH_HOP_BOOST_CAP, hopHits * GRAPH_HOP_BOOST_PER_ENTITY);
113
+ return directBoost + hopBoost;
114
+ }
115
+ /**
116
+ * Lightweight reader — extracted so the boost loader and tests share one
117
+ * code path. Tolerant of missing files (returns null) but logs a warning
118
+ * when an existing file fails to parse so corruption is visible.
119
+ */
120
+ function readGraphFile(stashRoot) {
121
+ const target = getGraphFilePath(stashRoot);
122
+ let raw;
123
+ try {
124
+ raw = fs.readFileSync(target, "utf8");
125
+ }
126
+ catch {
127
+ // Missing → no boost. Not an error: the user simply hasn't enabled
128
+ // graph extraction yet, or the pass hasn't run.
129
+ return null;
130
+ }
131
+ let parsed;
132
+ try {
133
+ parsed = JSON.parse(raw);
134
+ }
135
+ catch (err) {
136
+ warn(`graph boost: failed to parse ${target}: ${err instanceof Error ? err.message : String(err)}`);
137
+ return null;
138
+ }
139
+ if (!isGraphFile(parsed) || parsed.schemaVersion !== GRAPH_FILE_SCHEMA_VERSION) {
140
+ return null;
141
+ }
142
+ return parsed;
143
+ }
144
+ function isGraphFile(value) {
145
+ if (typeof value !== "object" || value === null)
146
+ return false;
147
+ const obj = value;
148
+ if (typeof obj.schemaVersion !== "number")
149
+ return false;
150
+ if (typeof obj.generatedAt !== "string")
151
+ return false;
152
+ if (typeof obj.stashRoot !== "string")
153
+ return false;
154
+ if (!Array.isArray(obj.files))
155
+ return false;
156
+ for (const f of obj.files) {
157
+ if (typeof f !== "object" || f === null)
158
+ return false;
159
+ const node = f;
160
+ if (typeof node.path !== "string")
161
+ return false;
162
+ if (typeof node.type !== "string")
163
+ return false;
164
+ if (!Array.isArray(node.entities) || !node.entities.every((e) => typeof e === "string"))
165
+ return false;
166
+ if (!Array.isArray(node.relations))
167
+ return false;
168
+ for (const r of node.relations) {
169
+ if (typeof r !== "object" || r === null)
170
+ return false;
171
+ const rel = r;
172
+ if (typeof rel.from !== "string" || typeof rel.to !== "string")
173
+ return false;
174
+ if (rel.type !== undefined && typeof rel.type !== "string")
175
+ return false;
176
+ }
177
+ }
178
+ return true;
179
+ }