akm-cli 0.9.17-alpha.2 → 0.9.17-alpha.4

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 (343) hide show
  1. package/CHANGELOG.md +756 -0
  2. package/dist/akm +94 -196
  3. package/dist/cli/shared.js +6 -2
  4. package/dist/cli.js +22 -9
  5. package/dist/commands/agent/agent-dispatch.js +1 -1
  6. package/dist/commands/command/command-execution.js +24 -62
  7. package/dist/commands/feedback-cli.js +0 -1
  8. package/dist/commands/health/accept-rate.js +2 -2
  9. package/dist/commands/health/checks.js +30 -75
  10. package/dist/commands/health/config-skew.js +38 -0
  11. package/dist/commands/health/egress.js +54 -0
  12. package/dist/commands/health/html-report.js +0 -38
  13. package/dist/commands/health/improve-metrics.js +123 -562
  14. package/dist/commands/health/plugin-staleness.js +53 -3
  15. package/dist/commands/health/renderers.js +12 -4
  16. package/dist/commands/health/report-view-model.js +11 -106
  17. package/dist/commands/health/types-improve.js +4 -19
  18. package/dist/commands/health/windows.js +64 -73
  19. package/dist/commands/health.js +122 -143
  20. package/dist/commands/improve/consolidate/chunking.js +25 -100
  21. package/dist/commands/improve/consolidate/sanitize.js +54 -149
  22. package/dist/commands/improve/consolidate.js +538 -1075
  23. package/dist/commands/improve/content-hash.js +16 -24
  24. package/dist/commands/improve/distill/content-repair.js +18 -100
  25. package/dist/commands/improve/distill-guards.js +20 -81
  26. package/dist/commands/improve/distill-promotion-policy.js +23 -243
  27. package/dist/commands/improve/distill.js +608 -1075
  28. package/dist/commands/improve/eligibility.js +126 -400
  29. package/dist/commands/improve/execution.js +3 -5
  30. package/dist/commands/improve/extract.js +487 -1046
  31. package/dist/commands/improve/feedback-valence.js +0 -25
  32. package/dist/commands/improve/improve-cli.js +29 -166
  33. package/dist/commands/improve/improve-result-file.js +10 -66
  34. package/dist/commands/improve/improve-strategies.js +12 -7
  35. package/dist/commands/improve/improve-usage-report.js +18 -64
  36. package/dist/commands/improve/improve.js +443 -1063
  37. package/dist/commands/improve/ledger.js +114 -0
  38. package/dist/commands/improve/locks.js +2 -8
  39. package/dist/commands/improve/loop-stages.js +459 -1172
  40. package/dist/commands/improve/memory/derived-ref.js +12 -77
  41. package/dist/commands/improve/memory/memory-belief.js +14 -118
  42. package/dist/commands/improve/memory/memory-improve.js +4 -3
  43. package/dist/commands/improve/outcome-loop.js +28 -156
  44. package/dist/commands/improve/planner.js +5 -10
  45. package/dist/commands/improve/preparation.js +851 -2339
  46. package/dist/commands/improve/proactive-maintenance.js +34 -101
  47. package/dist/commands/improve/reflect-noise.js +104 -280
  48. package/dist/commands/improve/reflect.js +621 -1367
  49. package/dist/commands/improve/salience.js +46 -232
  50. package/dist/commands/improve/session-asset.js +19 -100
  51. package/dist/commands/improve/stage.js +323 -0
  52. package/dist/commands/proposal/drain.js +251 -644
  53. package/dist/commands/proposal/proposal-cli.js +3 -18
  54. package/dist/commands/proposal/proposal-types.js +20 -41
  55. package/dist/commands/proposal/proposal.js +1 -2
  56. package/dist/commands/proposal/propose.js +134 -160
  57. package/dist/commands/proposal/repository.js +502 -1487
  58. package/dist/commands/proposal/validators/proposal-quality-validators.js +71 -174
  59. package/dist/commands/proposal/validators/proposal-validators.js +1 -1
  60. package/dist/commands/proposal/validators/proposals.js +13 -89
  61. package/dist/commands/read/curate.js +63 -413
  62. package/dist/commands/read/search-cli.js +16 -33
  63. package/dist/commands/read/search.js +17 -23
  64. package/dist/commands/read/show.js +2 -13
  65. package/dist/commands/sources/bundle-cli.js +25 -2
  66. package/dist/commands/sources/bundle-config-ops.js +7 -0
  67. package/dist/commands/sources/dangerous-env-audit.js +1 -2
  68. package/dist/commands/sources/info.js +2 -11
  69. package/dist/commands/sources/installed-stashes.js +197 -746
  70. package/dist/commands/sources/schema-repair.js +98 -129
  71. package/dist/commands/sources/source-add.js +62 -12
  72. package/dist/commands/sources/stash-cli.js +1 -1
  73. package/dist/commands/tasks/explain.js +10 -13
  74. package/dist/commands/tasks/tasks-cli.js +9 -8
  75. package/dist/commands/tasks/tasks.js +326 -930
  76. package/dist/commands/tasks/validate.js +42 -21
  77. package/dist/commands/workflow/plan.js +22 -29
  78. package/dist/commands/workflow-cli.js +4 -4
  79. package/dist/core/adapter/adapters/akm-adapter.js +0 -1
  80. package/dist/core/adapter/adapters/akm-lint.js +2 -3
  81. package/dist/core/adapter/adapters/akm-metadata.js +11 -12
  82. package/dist/core/adapter/adapters/akm-workflow-adapter.js +1 -1
  83. package/dist/core/adapter/execution-source.js +17 -29
  84. package/dist/core/asset/resolve-ref.js +1 -1
  85. package/dist/core/bundle-id.js +42 -5
  86. package/dist/core/bundle-rename.js +291 -0
  87. package/dist/core/config/config-io.js +1 -2
  88. package/dist/core/config/config-schema.js +1 -33
  89. package/dist/core/config/config-walker.js +1 -1
  90. package/dist/core/config/config.js +163 -68
  91. package/dist/core/config/legacy-source-shape-shim.js +38 -9
  92. package/dist/core/config/schema/embedding.js +20 -5
  93. package/dist/core/config/schema/engines.js +5 -0
  94. package/dist/core/config/schema/execution.js +1 -1
  95. package/dist/core/config/schema/experimental.js +1 -1
  96. package/dist/core/config/schema/improve-processes.js +21 -95
  97. package/dist/core/config/schema/improve.js +4 -42
  98. package/dist/core/config/schema/scheduler.js +12 -12
  99. package/dist/core/config/schema/search.js +6 -22
  100. package/dist/core/env-secret-ref.js +0 -1
  101. package/dist/core/errors.js +8 -9
  102. package/dist/core/file-lock.js +76 -173
  103. package/dist/core/logs-db.js +2 -2
  104. package/dist/core/paths.js +0 -27
  105. package/dist/core/redaction.js +109 -2
  106. package/dist/core/run-lock.js +2 -5
  107. package/dist/core/spawn-env.js +1 -1
  108. package/dist/core/state/migrations.js +108 -61
  109. package/dist/core/state-db-scope.js +2 -4
  110. package/dist/core/state-db.js +126 -692
  111. package/dist/core/type-presentation.js +1 -9
  112. package/dist/core/write-source.js +293 -1012
  113. package/dist/execution/input-contract.js +1 -1
  114. package/dist/execution/resolved-request.js +135 -689
  115. package/dist/execution/source.js +63 -257
  116. package/dist/execution/target-ref.js +1 -1
  117. package/dist/indexer/bundle-identity-guard.js +2 -2
  118. package/dist/indexer/db/graph-db.js +106 -46
  119. package/dist/indexer/ensure-index.js +44 -85
  120. package/dist/indexer/graph/graph-extraction.js +340 -562
  121. package/dist/indexer/graph/graph-related.js +130 -0
  122. package/dist/indexer/index-rebuild-lock.js +3 -11
  123. package/dist/indexer/index-writer-lock.js +8 -17
  124. package/dist/indexer/index-written-assets.js +139 -151
  125. package/dist/indexer/indexer.js +524 -846
  126. package/dist/indexer/materialize-embeddings.js +60 -397
  127. package/dist/indexer/passes/memory-inference.js +81 -90
  128. package/dist/indexer/passes/metadata.js +132 -200
  129. package/dist/indexer/read-preflight.js +0 -7
  130. package/dist/indexer/scan/doc-to-entry.js +1 -3
  131. package/dist/indexer/scan/drain-dir.js +1 -1
  132. package/dist/indexer/search/db-search.js +181 -590
  133. package/dist/indexer/search/fts-query.js +30 -41
  134. package/dist/indexer/search/ranking.js +28 -154
  135. package/dist/indexer/search/search-attribution.js +12 -32
  136. package/dist/indexer/search/search-fields.js +11 -15
  137. package/dist/indexer/search/search-hit-enrichers.js +54 -85
  138. package/dist/indexer/search/search-source.js +1 -4
  139. package/dist/indexer/usage/usage-events.js +2 -7
  140. package/dist/integrations/agent/engine-fallback.js +23 -40
  141. package/dist/integrations/agent/engine-resolution.js +93 -183
  142. package/dist/integrations/agent/execution.js +507 -0
  143. package/dist/integrations/agent/model-map.js +28 -156
  144. package/dist/integrations/agent/request-lowering.js +66 -141
  145. package/dist/integrations/agent/runner-dispatch.js +143 -321
  146. package/dist/integrations/agent/runner.js +54 -14
  147. package/dist/integrations/lockfile.js +53 -101
  148. package/dist/llm/embedders/deterministic.js +2 -3
  149. package/dist/llm/embedders/profile.js +71 -0
  150. package/dist/llm/embedders/remote.js +10 -15
  151. package/dist/llm/graph-extract.js +3 -12
  152. package/dist/llm/index-passes.js +3 -5
  153. package/dist/llm/memory-infer.js +1 -2
  154. package/dist/llm/metadata-enhance.js +1 -2
  155. package/dist/llm/structured-call.js +5 -24
  156. package/dist/output/generic-render.js +23 -11
  157. package/dist/output/html-render.js +13 -10
  158. package/dist/output/render-registry.js +3 -32
  159. package/dist/output/shapes/helpers.js +2 -34
  160. package/dist/output/shapes/passthrough.js +1 -9
  161. package/dist/{indexer/search/ranking-types.js → output/text/bundle-rename.js} +4 -1
  162. package/dist/output/text/command-format.js +60 -23
  163. package/dist/output/text/helpers.js +1 -1
  164. package/dist/output/text/migrate.js +5 -14
  165. package/dist/output/text/proposal-format.js +1 -2
  166. package/dist/output/text/workflow-format.js +0 -32
  167. package/dist/output/text.js +2 -0
  168. package/dist/registry/factory.js +4 -19
  169. package/dist/registry/network.js +66 -220
  170. package/dist/registry/providers/index.js +0 -2
  171. package/dist/registry/providers/skills-sh.js +3 -14
  172. package/dist/registry/providers/static-index.js +24 -26
  173. package/dist/registry/resolve.js +55 -131
  174. package/dist/scripts/akm-migrate-node.js +43937 -93313
  175. package/dist/scripts/akm-migrate.js +43697 -93071
  176. package/dist/setup/registry-stash-loader.js +4 -13
  177. package/dist/setup/semantic-assets.js +3 -44
  178. package/dist/setup/setup.js +1 -1
  179. package/dist/setup/steps/tasks.js +25 -15
  180. package/dist/sources/provider-factory.js +17 -18
  181. package/dist/sources/providers/filesystem.js +2 -3
  182. package/dist/sources/providers/git-install.js +7 -1
  183. package/dist/sources/providers/git-provider.js +0 -3
  184. package/dist/sources/providers/git-stash.js +0 -17
  185. package/dist/sources/providers/npm.js +2 -4
  186. package/dist/sources/providers/provider-utils.js +5 -10
  187. package/dist/sources/providers/website.js +0 -2
  188. package/dist/sources/snapshot-fetchers/website-ingest.js +1 -1
  189. package/dist/sources/website-url.js +2 -2
  190. package/dist/storage/database.js +9 -35
  191. package/dist/storage/repositories/improve-ledger-repository.js +168 -0
  192. package/dist/storage/repositories/index-connection.js +34 -70
  193. package/dist/storage/repositories/index-entries-repository.js +69 -111
  194. package/dist/storage/repositories/index-entry-mapper.js +1 -2
  195. package/dist/storage/repositories/index-entry-schema.js +83 -269
  196. package/dist/storage/repositories/index-fts-repository.js +86 -256
  197. package/dist/storage/repositories/index-llm-cache-repository.js +17 -0
  198. package/dist/storage/repositories/index-meta-repository.js +6 -4
  199. package/dist/storage/repositories/index-schema.js +192 -220
  200. package/dist/storage/repositories/index-utility-repository.js +8 -29
  201. package/dist/storage/repositories/index-vec-repository.js +133 -414
  202. package/dist/storage/repositories/outcome-repository.js +2 -1
  203. package/dist/storage/repositories/proposals-repository.js +35 -0
  204. package/dist/storage/repositories/registry-index-cache-repository.js +100 -0
  205. package/dist/storage/repositories/task-history-repository.js +26 -4
  206. package/dist/storage/repositories/workflow-runs-repository.js +53 -244
  207. package/dist/storage/sqlite-migrations.js +136 -0
  208. package/dist/storage/sqlite-pragmas.js +11 -9
  209. package/dist/storage/sqlite-transaction.js +170 -0
  210. package/dist/storage/state-db-integrity.js +34 -27
  211. package/dist/tasks/activation-config.js +134 -62
  212. package/dist/tasks/backends/cron.js +129 -277
  213. package/dist/tasks/backends/exec-utils.js +2 -5
  214. package/dist/tasks/backends/launchd.js +125 -745
  215. package/dist/tasks/backends/schtasks.js +101 -620
  216. package/dist/tasks/prepare/prepare-support.js +5 -15
  217. package/dist/tasks/prepare/prepare.js +0 -2
  218. package/dist/tasks/resolve-akm-bin.js +20 -79
  219. package/dist/tasks/run/attempt-lifecycle.js +0 -1
  220. package/dist/tasks/scheduler-binding.js +18 -238
  221. package/dist/tasks/scheduler-invocation.js +52 -52
  222. package/dist/tasks/scheduler-lock.js +53 -0
  223. package/dist/tasks/scheduler-sync.js +363 -679
  224. package/dist/tasks/source/parse-task-source.js +160 -10
  225. package/dist/tasks/source/task-source-v3-frozen.js +3 -4
  226. package/dist/tasks/source/task-to-v4.js +2 -2
  227. package/dist/workflows/authoring/authoring.js +3 -12
  228. package/dist/workflows/compile.js +211 -0
  229. package/dist/workflows/concurrency-policy.js +13 -74
  230. package/dist/workflows/exec/child-invocation.js +3 -17
  231. package/dist/workflows/exec/child-workflow.js +32 -141
  232. package/dist/workflows/exec/dispatch-redaction.js +13 -53
  233. package/dist/workflows/exec/environment.js +98 -0
  234. package/dist/workflows/exec/exec-unit.js +33 -140
  235. package/dist/workflows/exec/frozen-judge.js +7 -59
  236. package/dist/workflows/exec/native-executor.js +82 -341
  237. package/dist/workflows/exec/param-secrets.js +29 -47
  238. package/dist/workflows/exec/run-workflow.js +154 -387
  239. package/dist/workflows/exec/scheduler.js +9 -36
  240. package/dist/workflows/exec/step-work.js +127 -430
  241. package/dist/workflows/exec/unit-dispatch.js +11 -63
  242. package/dist/workflows/exec/unit-writer.js +8 -52
  243. package/dist/workflows/exec/worktree.js +39 -273
  244. package/dist/workflows/freeze/child-output-references.js +4 -15
  245. package/dist/workflows/freeze/environment.js +99 -92
  246. package/dist/workflows/freeze/freeze.js +172 -0
  247. package/dist/workflows/freeze/step-values.js +19 -21
  248. package/dist/workflows/freeze/targets/child-workflow.js +23 -92
  249. package/dist/workflows/freeze/targets/command.js +10 -33
  250. package/dist/workflows/freeze/targets/script.js +5 -12
  251. package/dist/workflows/freeze/targets/shell.js +3 -6
  252. package/dist/workflows/freeze/targets/task.js +25 -80
  253. package/dist/workflows/freeze/task-bindings.js +20 -67
  254. package/dist/workflows/{source-ir/github-yaml.js → github-yaml.js} +88 -206
  255. package/dist/workflows/ir/params.js +6 -51
  256. package/dist/workflows/ir/plan-hash.js +2 -34
  257. package/dist/workflows/parser.js +140 -43
  258. package/dist/{commands/improve/consolidate/types.js → workflows/plan.js} +2 -1
  259. package/dist/workflows/renderer.js +36 -69
  260. package/dist/workflows/resource-limits.js +12 -120
  261. package/dist/workflows/runtime/agent-identity.js +8 -40
  262. package/dist/workflows/runtime/run-outputs.js +3 -6
  263. package/dist/workflows/runtime/run-plan.js +316 -0
  264. package/dist/workflows/runtime/runs.js +48 -200
  265. package/dist/workflows/runtime/workflow-asset-loader.js +24 -57
  266. package/dist/workflows/{source-ir/semantics.js → source-semantics.js} +16 -20
  267. package/dist/workflows/validate-summary.js +2 -7
  268. package/docs/integration/bundling-akm.md +49 -42
  269. package/docs/migration/README.md +1 -0
  270. package/docs/migration/release-notes/0.9.17.md +41 -0
  271. package/docs/migration/v0.9.1-to-v0.9.2.md +19 -7
  272. package/docs/reference/cli.md +182 -125
  273. package/docs/reference/configuration.md +49 -56
  274. package/docs/reference/data-and-telemetry.md +19 -20
  275. package/docs/reference/tasks.md +86 -38
  276. package/docs/reference/workflow-schema.md +14 -18
  277. package/docs/reference/workflows.md +6 -9
  278. package/package.json +1 -1
  279. package/schemas/akm-config.json +87 -406
  280. package/dist/commands/health/advisories.js +0 -150
  281. package/dist/commands/health/metrics.js +0 -329
  282. package/dist/commands/health/surfaces.js +0 -102
  283. package/dist/commands/improve/anti-collapse.js +0 -83
  284. package/dist/commands/improve/collapse-detector.js +0 -432
  285. package/dist/commands/improve/consolidate/eligibility.js +0 -48
  286. package/dist/commands/improve/consolidate/merge.js +0 -146
  287. package/dist/commands/improve/distill/promote-memory.js +0 -329
  288. package/dist/commands/improve/distill/quality-gate.js +0 -500
  289. package/dist/commands/improve/memory/memory-contradiction-detect.js +0 -291
  290. package/dist/commands/improve/proposal-envelope.js +0 -31
  291. package/dist/commands/improve/run-context.js +0 -123
  292. package/dist/commands/improve/shared.js +0 -21
  293. package/dist/commands/improve/source-identity.js +0 -28
  294. package/dist/commands/improve/triage.js +0 -96
  295. package/dist/commands/proposal/drain-policies.js +0 -151
  296. package/dist/commands/sources/update-transaction.js +0 -220
  297. package/dist/core/action-contributors.js +0 -28
  298. package/dist/core/config/config-version-shim.js +0 -101
  299. package/dist/core/config/retired-experimental-keys-shim.js +0 -62
  300. package/dist/core/fs-txn.js +0 -405
  301. package/dist/core/lexical-score.js +0 -25
  302. package/dist/core/maintenance-barrier.js +0 -167
  303. package/dist/execution/executable-identity.js +0 -105
  304. package/dist/execution/guarded-source.js +0 -427
  305. package/dist/indexer/graph/graph-boost.js +0 -427
  306. package/dist/indexer/graph/graph-dedup.js +0 -95
  307. package/dist/indexer/search/name-match.js +0 -35
  308. package/dist/indexer/search/ranking-contributors.js +0 -515
  309. package/dist/indexer/walk/project-context.js +0 -192
  310. package/dist/integrations/agent/execution-cascade.js +0 -566
  311. package/dist/integrations/agent/execution-definitions.js +0 -202
  312. package/dist/integrations/agent/execution-lowering.js +0 -841
  313. package/dist/integrations/agent/execution-preparation.js +0 -98
  314. package/dist/integrations/agent/inline-execution.js +0 -74
  315. package/dist/registry/create-provider-registry.js +0 -29
  316. package/dist/registry/pinned-request-helper.js +0 -247
  317. package/dist/registry/pinned-transport.js +0 -717
  318. package/dist/sources/providers/index.js +0 -14
  319. package/dist/storage/engines/sqlite-migrations.js +0 -271
  320. package/dist/storage/repositories/canaries-repository.js +0 -107
  321. package/dist/storage/repositories/embedding-salvage-repository.js +0 -184
  322. package/dist/storage/repositories/registry-cache.js +0 -113
  323. package/dist/tasks/scheduler-sync-preview.js +0 -52
  324. package/dist/workflows/freeze/resolve-steps.js +0 -86
  325. package/dist/workflows/freeze/source-freeze.js +0 -64
  326. package/dist/workflows/ir/compile.js +0 -321
  327. package/dist/workflows/ir/environment-v4.js +0 -330
  328. package/dist/workflows/ir/freeze-v4.js +0 -153
  329. package/dist/workflows/ir/schema-v4.js +0 -745
  330. package/dist/workflows/ir/schema.js +0 -354
  331. package/dist/workflows/program/schema.js +0 -78
  332. package/dist/workflows/runtime/checkin.js +0 -57
  333. package/dist/workflows/runtime/plan-classifier.js +0 -196
  334. package/dist/workflows/runtime/unit-checkin.js +0 -45
  335. package/dist/workflows/runtime/unit-phases.js +0 -20
  336. package/dist/workflows/schema.js +0 -4
  337. package/dist/workflows/source-ir/compile.js +0 -200
  338. package/dist/workflows/source-ir/program.js +0 -50
  339. package/dist/workflows/source-ir/result.js +0 -26
  340. package/dist/workflows/source-ir/schema.js +0 -786
  341. package/dist/workflows/source-ir/triggers.js +0 -79
  342. package/dist/workflows/source-ir/uses.js +0 -40
  343. package/dist/workflows/validator.js +0 -60
@@ -2,41 +2,34 @@
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  /**
5
- * Database-backed (SQLite + FTS5/vector) source search implementation.
5
+ * Database-backed (SQLite FTS5 + vector) search.
6
6
  *
7
- * Extracted from source-search.ts to break the circular import:
8
- * source-search.ts → sources/providers/filesystem.ts → db-search.ts (no cycle)
9
- *
10
- * source-search.ts imports this module for the `searchLocal` export.
11
- * sources/providers/filesystem.ts also imports `searchLocal` from here.
12
- *
13
- * Renamed from `local-search.ts` to signal that this is the DB-layer search
14
- * implementation, not a "local vs. remote" distinction.
7
+ * Ranking is two candidate channels fused by reciprocal rank (`ranking.ts`):
8
+ * BM25 over whole documents matching any query word, and the nearest document
9
+ * vectors to the query embedding. Filters narrow the fused list; nothing else
10
+ * reorders it.
15
11
  */
16
12
  import path from "node:path";
17
- import { buildActionFromContributors, defaultActionContributors } from "../../core/action-contributors.js";
18
13
  import { stashDirFor } from "../../core/asset/asset-placement.js";
19
14
  import { displayRef } from "../../core/asset/resolve-ref.js";
20
15
  import { compareCodePoints } from "../../core/common.js";
21
16
  import { classifyPathAccess } from "../../core/path-access.js";
22
17
  import { getDbPath } from "../../core/paths.js";
23
18
  import { systemErrorCode } from "../../core/system-error.js";
24
- import { allowsFragmentRef, defaultRendererRegistry } from "../../core/type-presentation.js";
19
+ import { presentationFor } from "../../core/type-presentation.js";
20
+ import { embed } from "../../llm/embedder.js";
21
+ import { applyEmbeddingTemplate, resolveEmbeddingProfile } from "../../llm/embedders/profile.js";
25
22
  import { normalizeEmbeddingEndpoint } from "../../llm/embedders/remote.js";
26
23
  import { assertIndexPathReadable, closeDatabase, openExistingDatabase, } from "../../storage/repositories/index-connection.js";
27
- import { getAllEntries, getBaseBeliefStatesForDerivedTwins, getEntryById, getEntryCount, getPositiveFeedbackCountsByIds, } from "../../storage/repositories/index-entries-repository.js";
28
- import { getIndexedMarkdownFragment, getIndexedMarkdownFragments, searchFts, } from "../../storage/repositories/index-fts-repository.js";
24
+ import { getAllEntries, getBaseBeliefStatesForDerivedTwins, getEntryById, getEntryCount, getEntryRefsAndTypes, } from "../../storage/repositories/index-entries-repository.js";
25
+ import { searchFts } from "../../storage/repositories/index-fts-repository.js";
29
26
  import { getMeta } from "../../storage/repositories/index-meta-repository.js";
30
27
  import { getEmbeddingCount, searchVec } from "../../storage/repositories/index-vec-repository.js";
31
- import { getCurrentWorkflowScopeKey } from "../../workflows/authoring/scope-key.js";
32
28
  import { ensureIndex } from "../ensure-index.js";
33
- import { collectGraphRelatedHit, loadGraphBoostContext } from "../graph/graph-boost.js";
34
29
  import { isProposedQuality } from "../passes/metadata.js";
35
- import { resolveProjectContext } from "../walk/project-context.js";
36
- import { buildLexicalQueryPlan, parseRefPrefixQuery, parseRetiredTypePrefixQuery, } from "./fts-query.js";
37
- import { applyRankingRules, combineSearchScores, lexicalNameMatchTier, normalizeFtsScores } from "./ranking.js";
38
- import { typeBoostFor } from "./ranking-contributors.js";
39
- import { attachSearchHitAttribution, copySearchHitAttribution, getSearchHitAttribution } from "./search-attribution.js";
30
+ import { ftsQueryTokens, parseRefPrefixQuery, parseRetiredTypePrefixQuery } from "./fts-query.js";
31
+ import { reciprocalRankFusion } from "./ranking.js";
32
+ import { attachSearchHitAttribution } from "./search-attribution.js";
40
33
  import { enrichSearchHit } from "./search-hit-enrichers.js";
41
34
  import { buildEditHint, findSourceForPath, isEditable } from "./search-source.js";
42
35
  /**
@@ -47,6 +40,16 @@ import { buildEditHint, findSourceForPath, isEditable } from "./search-source.js
47
40
  * actionable without re-introducing read-triggered reindexing.
48
41
  */
49
42
  const STALE_INDEX_HINT_MS = 7 * 24 * 60 * 60 * 1000;
43
+ /** Candidates each channel contributes to fusion. */
44
+ const CHANNEL_DEPTH = 100;
45
+ /** How long search waits for the query embedding unless `embedding.queryTimeoutMs` says otherwise. */
46
+ export const DEFAULT_QUERY_EMBED_TIMEOUT_MS = 3000;
47
+ /** Each search hit's indexed content, kept off the output object for curate's reranker. */
48
+ const hitContent = new WeakMap();
49
+ /** The indexed (safe-projected) content of a hit this process's search returned. */
50
+ export function searchHitContent(hit) {
51
+ return hitContent.get(hit);
52
+ }
50
53
  function hasIndexedProvenance(entry) {
51
54
  return Boolean(entry.itemRef && entry.bundleId && entry.conceptId);
52
55
  }
@@ -65,11 +68,8 @@ function buildStaleIndexHint(db) {
65
68
  return undefined;
66
69
  }
67
70
  }
68
- function indexedProvenance(entry) {
69
- return { itemRef: entry.itemRef, bundleId: entry.bundleId, conceptId: entry.conceptId };
70
- }
71
- export function buildLocalAction(type, ref, registry = defaultRendererRegistry) {
72
- return buildActionFromContributors({ type, ref }, defaultActionContributors(registry)) ?? `akm show ${ref}`;
71
+ export function buildLocalAction(type, ref) {
72
+ return presentationFor(type).action?.(ref) ?? `akm show ${ref}`;
73
73
  }
74
74
  function resolveSearchHitRef(entry, provenance, defaultBundleId) {
75
75
  return displayRef({
@@ -79,26 +79,6 @@ function resolveSearchHitRef(entry, provenance, defaultBundleId) {
79
79
  bundleId: provenance.bundleId,
80
80
  }, defaultBundleId);
81
81
  }
82
- function resolveSearchHitOrigin(source) {
83
- return source?.registryId ?? null;
84
- }
85
- /**
86
- * Phase 2A / Rec 5: gate for the per-search `getPositiveFeedbackCountsByIds`
87
- * lookup. Returns `true` only when the user has explicitly opted into
88
- * `improve.utilityDecay` AND configured a `feedbackStabilityBoost > 1.0`.
89
- * Either condition being false makes the DB query pure overhead (the ranking
90
- * contributor ignores `positiveFeedbackCounts` when `utilityDecayConfig` is
91
- * absent, and `1.0^count == 1` collapses the boost into a no-op).
92
- *
93
- * Exported for unit testing — keeps the gate decision pinned so a future edit
94
- * can't quietly broaden the hot path.
95
- */
96
- export function shouldQueryPositiveFeedbackCounts(utilityDecayRaw) {
97
- if (utilityDecayRaw === undefined)
98
- return false;
99
- const boost = utilityDecayRaw.feedbackStabilityBoost ?? 1.5;
100
- return boost > 1.0;
101
- }
102
82
  // ── Main search entrypoint ───────────────────────────────────────────────────
103
83
  /**
104
84
  * Whether an embedding provider is actually configured.
@@ -116,18 +96,9 @@ function hasConfiguredEmbeddingProvider(config) {
116
96
  return Boolean(config.embedding?.endpoint && config.embedding?.model);
117
97
  }
118
98
  export async function searchLocal(input) {
119
- const { query, searchType, limit, stashDir, sources, config } = input;
120
- const filters = input.filters;
121
- const includeProposed = input.includeProposed === true;
122
- const beliefFilter = input.beliefFilter ?? "all";
123
- const restrictToSources = input.restrictToSources === true;
124
- const includeExcludedTypes = input.includeExcludedTypes === true;
125
- const disableProjectContext = input.disableProjectContext === true;
126
- const disableScopedUtility = input.disableScopedUtility === true;
127
- const rendererRegistry = input.rendererRegistry ?? defaultRendererRegistry;
128
- const allSourceDirs = sources.map((s) => s.path);
99
+ const { query, stashDir, config } = input;
129
100
  const warnings = [];
130
- // Semantic search is attempted fresh on every query (see `tryVecScores`);
101
+ // Semantic search is attempted fresh on every query (see `startVectorChannel`);
131
102
  // there is no cached readiness verdict to consult here. The only thing
132
103
  // worth flagging ahead of the attempt is a config that can never succeed.
133
104
  if (config.semanticSearchMode === "auto" && !hasConfiguredEmbeddingProvider(config)) {
@@ -167,7 +138,7 @@ export async function searchLocal(input) {
167
138
  const staleHint = buildStaleIndexHint(db);
168
139
  if (staleHint)
169
140
  warnings.push(staleHint);
170
- const { hits, embedMs, rankMs, mode, semanticWarning } = await searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry, filters, includeProposed, beliefFilter, restrictToSources, includeExcludedTypes, disableProjectContext, disableScopedUtility);
141
+ const { hits, embedMs, rankMs, mode, semanticWarning } = await searchDatabase(db, input);
171
142
  if (semanticWarning)
172
143
  warnings.push(semanticWarning);
173
144
  return {
@@ -177,7 +148,7 @@ export async function searchLocal(input) {
177
148
  embedMs,
178
149
  rankMs,
179
150
  // Report the mode the search ACTUALLY used, carried explicitly from the
180
- // vector scorer — not inferred from elapsed embedding milliseconds.
151
+ // vector channel — not inferred from elapsed embedding milliseconds.
181
152
  mode,
182
153
  };
183
154
  }
@@ -186,326 +157,133 @@ export async function searchLocal(input) {
186
157
  }
187
158
  }
188
159
  // ── Database search ─────────────────────────────────────────────────────────
189
- /**
190
- * Keep public scores in [0, 1] without flattening every boosted result to the
191
- * same hard-clamped value. The ranking pipeline deliberately keeps its raw
192
- * score for deterministic ordering before stable path deduplication; this
193
- * monotone display projection preserves that order and leaves visible
194
- * separation for graph, type, and project-context signals.
195
- */
196
- function displaySearchScore(score) {
197
- return 1 - Math.exp(-Math.max(0, score));
198
- }
199
- /**
200
- * A final deterministic key for genuinely tied candidates. It deliberately
201
- * excludes the asset name, filename, path, durable ref, and SQLite id: callers
202
- * such as the memory-pack adapter generate each of those from an opaque source
203
- * id, so using one here makes an otherwise equal search depend on that id.
204
- *
205
- * The normal AKM Markdown adapter keeps an H1 title in `content`; strip that
206
- * one synthetic title too, because the adapter may derive it from the opaque
207
- * filename. Identical remaining bodies are semantically indistinguishable at
208
- * this ranking stage and intentionally continue to the existing name/path
209
- * fallback for repeatable local presentation.
210
- */
211
- function asciiCaseFold(value) {
212
- // SQLite's built-in lower() folds ASCII only unless a build opts into ICU.
213
- // Keep this key deliberately in that portable shared subset instead of
214
- // introducing locale-dependent JavaScript ordering for non-ASCII content.
215
- return value.replace(/[A-Z]/g, (letter) => String.fromCharCode(letter.charCodeAt(0) + 32));
216
- }
217
- /** The portable byte-level title/body rule mirrored in index-fts-repository. */
218
- export function canonicalContentTieKey(entry) {
219
- const content = entry.content ?? "";
220
- const newline = content.startsWith("# ") ? content.indexOf("\n") : -1;
221
- // SQLite uses ltrim(value, char(13) || char(10) || ' ') after an exact '# '
222
- // title and trim(value, ' ') otherwise. Keep exactly that deliberately
223
- // narrow byte contract; do not use locale or Unicode-whitespace helpers.
224
- const body = newline >= 0 ? content.slice(newline + 1).replace(/^[\r\n ]+/, "") : content;
225
- const source = (body || entry.description || "").replace(/^ +| +$/g, "");
226
- return Buffer.from(asciiCaseFold(source), "utf8").toString("hex");
227
- }
228
- function buildSearchResultComparator(query) {
229
- const queryTokens = buildLexicalQueryPlan(query).tokens.map((token) => token.toLowerCase());
230
- const displayScore = (score) => Math.round(displaySearchScore(score) * 10000) / 10000;
231
- const stableRankScore = (score) => Math.round(score * 10000) / 10000;
232
- return (a, b) => {
233
- const aNameTier = lexicalNameMatchTier(a.entry, queryTokens);
234
- const bNameTier = lexicalNameMatchTier(b.entry, queryTokens);
235
- if (aNameTier === 3 || bNameTier === 3) {
236
- const nameDiff = bNameTier - aNameTier;
237
- if (nameDiff !== 0)
238
- return nameDiff;
239
- }
240
- const scoreDiff = displayScore(b.score) - displayScore(a.score);
241
- if (scoreDiff !== 0)
242
- return scoreDiff;
243
- const rawScoreDiff = stableRankScore(b.score) - stableRankScore(a.score);
244
- if (rawScoreDiff !== 0)
245
- return rawScoreDiff;
246
- // Ceiling values are intentionally allowed to demote visibility, but not
247
- // to erase relevance. Prefer the score before a relaxed body-only ceiling;
248
- // a later belief-state ceiling has its own minScore handoff and must not
249
- // overwrite this ordering evidence. Belief-only ceilings fall back to
250
- // their `preCeilingScore`.
251
- const preCeilingRelevance = (item) => item.preRelaxedCeilingScore ?? item.preCeilingScore ?? item.score;
252
- const ceilingDiff = stableRankScore(preCeilingRelevance(b)) - stableRankScore(preCeilingRelevance(a));
253
- if (ceilingDiff !== 0)
254
- return ceilingDiff;
255
- const nameDiff = bNameTier - aNameTier;
256
- if (nameDiff !== 0)
257
- return nameDiff;
258
- const typeDiff = typeBoostFor(b.entry.type) - typeBoostFor(a.entry.type);
259
- if (typeDiff !== 0)
260
- return typeDiff;
261
- // Keep opaque generated IDs out of the final relevance tie-break. This
262
- // runs only after every ranking contributor (including the #940 preserved
263
- // pre-ceiling evidence), exact-name, and type comparison has tied.
264
- const contentDiff = compareCodePoints(canonicalContentTieKey(a.entry), canonicalContentTieKey(b.entry));
265
- if (contentDiff !== 0)
266
- return contentDiff;
267
- return a.filePath.localeCompare(b.filePath);
160
+ async function searchDatabase(db, input) {
161
+ const { query, searchType, limit, stashDir, sources, config, filters } = input;
162
+ const filterOptions = {
163
+ db,
164
+ sources,
165
+ restrictToSources: input.restrictToSources === true,
166
+ filters,
167
+ includeProposed: input.includeProposed === true,
168
+ beliefFilter: input.beliefFilter ?? "all",
268
169
  };
269
- }
270
- async function searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry = defaultRendererRegistry, filters, includeProposed = false, beliefFilter = "all", restrictToSources = false, includeExcludedTypes = false, disableProjectContext = false, disableScopedUtility = false) {
271
- const hasSearchableTokens = query.length > 0 && buildLexicalQueryPlan(query).tokens.length > 0;
272
170
  // #627 — resolve the default type-exclusion policy. It applies ONLY on the
273
171
  // untyped ('any') path and only when the caller did not opt back in via
274
172
  // `includeExcludedTypes`. When the config key is ABSENT a built-in default of
275
173
  // ['session'] is applied; an explicit empty list disables exclusion.
276
- const defaultExcludes = searchType === "any" && !includeExcludedTypes ? (config.search?.defaultExcludeTypes ?? ["session"]) : [];
174
+ const defaultExcludes = searchType === "any" && !input.includeExcludedTypes ? (config.search?.defaultExcludeTypes ?? ["session"]) : [];
277
175
  // D4 — conceptId-prefix queries (`memories/projecta/`, `bundle//`,
278
176
  // `bundle//skills/`) translate to a deterministic enumeration narrowed by
279
- // conceptId, instead of degenerating into the AND-token FTS query their
280
- // sanitized form would produce ("memories projecta" — noise). The branch
177
+ // conceptId instead of a keyword search over their path words. The branch
281
178
  // fires only on the untyped path: an explicit `--type` flag expresses
282
179
  // stronger intent and wins. The PREFIX is itself explicit intent, so
283
180
  // `defaultExcludeTypes` does not apply — `sessions/` enumerates sessions
284
181
  // exactly like `--type session` does, and `bundle//` means the whole bundle.
285
182
  const refPrefix = searchType === "any" ? parseRefPrefixQuery(query) : null;
286
- // Shared args for the two browse paths below; browse never runs semantic
287
- // ranking, so both return usedSemantic: false.
288
- const browseArgs = {
289
- db,
290
- query,
291
- limit,
292
- stashDir,
293
- allSourceDirs,
294
- sources,
295
- config,
296
- rendererRegistry,
297
- filters,
298
- includeProposed,
299
- beliefFilter,
300
- restrictToSources,
301
- };
302
183
  if (refPrefix) {
303
- // Browse path (conceptId-prefix enumeration).
304
184
  return {
305
- ...(await enumerateEntries({
306
- ...browseArgs,
185
+ hits: await enumerateEntries({
186
+ ...filterOptions,
187
+ limit,
188
+ stashDir,
189
+ config,
307
190
  excludeTypes: [],
308
191
  conceptIdPrefix: refPrefix.conceptIdPrefix,
309
192
  ...(refPrefix.bundle !== undefined ? { bundle: refPrefix.bundle } : {}),
310
- })),
193
+ }),
311
194
  mode: "keyword",
312
195
  };
313
196
  }
314
- // Empty queries — including ones that sanitize down to no searchable FTS
315
- // tokens such as "." — should enumerate matching entries instead of
316
- // returning an empty result set from FTS.
317
- if (!hasSearchableTokens) {
318
- // Browse path (empty/unsearchable query).
197
+ // Empty queries — including ones with no searchable token such as "." —
198
+ // enumerate matching entries instead of returning nothing.
199
+ if (ftsQueryTokens(query).length === 0) {
319
200
  return {
320
- ...(await enumerateEntries({
321
- ...browseArgs,
201
+ hits: await enumerateEntries({
202
+ ...filterOptions,
203
+ limit,
204
+ stashDir,
205
+ config,
322
206
  typeFilter: searchType === "any" ? undefined : searchType,
323
207
  excludeTypes: defaultExcludes,
324
- })),
208
+ }),
325
209
  mode: "keyword",
326
210
  };
327
211
  }
328
- // Start the async embedding request without awaiting, then run FTS
329
- // synchronously while the HTTP/local embedding request is in-flight.
330
212
  const typeFilter = searchType === "any" ? undefined : searchType;
331
- const { ftsResults, embeddingScores, embedMs, mode, semanticWarning } = await collectSearchSignals(db, query, limit * 3, typeFilter, defaultExcludes, config);
213
+ const startedAt = Date.now();
214
+ // The query embedding request goes out first, so FTS runs while it is in flight.
215
+ const vectorChannel = startVectorChannel(db, query, config);
216
+ const lexical = searchFts(db, query, CHANNEL_DEPTH, typeFilter, defaultExcludes);
217
+ const vector = await vectorChannel;
218
+ const embedMs = Date.now() - startedAt;
332
219
  const tRank0 = Date.now();
333
- // ── Score normalization ──────────────────────────────────────────────
334
- // Stable bounded BM25 transform + cosine similarity with weighted addition
335
- // (FTS 0.7, vector 0.3). The lexical transform is per-row, so widening the
336
- // candidate set cannot alter a pre-existing row's base score.
337
- const ftsScoreMap = normalizeFtsScores(ftsResults);
338
- // Build embedding score map (cosine similarities already 0-1)
339
- const embedScoreMap = new Map();
340
- if (embeddingScores) {
341
- for (const [id, cosine] of embeddingScores) {
342
- embedScoreMap.set(id, cosine);
220
+ const vectorCandidates = vector.neighbors ? keepAllowedTypes(db, vector.neighbors, typeFilter, defaultExcludes) : [];
221
+ const fused = reciprocalRankFusion([lexical, vectorCandidates]);
222
+ const selected = selectFusedEntries(db, fused, limit, filterOptions);
223
+ const rankMs = Date.now() - tRank0;
224
+ const hits = await Promise.all(selected.map(({ candidate, row }) => buildDbHit({
225
+ entry: row.entry,
226
+ path: row.filePath,
227
+ itemRef: row.itemRef,
228
+ bundleId: row.bundleId,
229
+ conceptId: row.conceptId,
230
+ score: Math.round(candidate.score * 1e6) / 1e6,
231
+ whyMatched: describeRanks(candidate),
232
+ defaultStashDir: stashDir,
233
+ sources,
234
+ config,
235
+ db,
236
+ })));
237
+ const mode = vector.warning ? "fts-fallback" : vector.neighbors ? "semantic" : "keyword";
238
+ return { embedMs, rankMs, hits, mode, semanticWarning: vector.warning };
239
+ }
240
+ /**
241
+ * Walk the fused list in order, loading entries a batch at a time, and keep
242
+ * the first `limit` that survive path deduplication and the filters.
243
+ */
244
+ function selectFusedEntries(db, fused, limit, filterOptions) {
245
+ const selected = [];
246
+ const seenPaths = new Set();
247
+ const batchSize = Math.max(limit * 2, 20);
248
+ for (let offset = 0; offset < fused.length && selected.length < limit; offset += batchSize) {
249
+ const batch = [];
250
+ for (const candidate of fused.slice(offset, offset + batchSize)) {
251
+ const row = getEntryById(db, candidate.id);
252
+ if (!row || !hasIndexedProvenance(row) || seenPaths.has(row.filePath))
253
+ continue;
254
+ seenPaths.add(row.filePath);
255
+ batch.push({ ...row, id: candidate.id, candidate });
256
+ }
257
+ for (const kept of applyEntryFilters(batch, filterOptions)) {
258
+ const { candidate, ...row } = kept;
259
+ selected.push({ candidate, row });
343
260
  }
344
261
  }
345
- // ── Combine FTS + vector scores ──────────────────────────────────────
346
- const scored = combineSearchScores({
347
- ftsScoreMap,
348
- embedScoreMap,
349
- getEntryById: (id) => getEntryById(db, id) ?? undefined,
350
- typeFilter,
351
- // #627 — also exclude default-hidden types from the vector-only branch so a
352
- // session asset that is a top-k vector neighbor (but not an FTS match) does
353
- // not leak into default ('any') results. defaultExcludes is already []
354
- // unless this is the untyped path without includeExcludedTypes.
355
- excludeTypes: defaultExcludes,
356
- }).filter(hasIndexedProvenance);
357
- // ── Scoring Phase ──────────────────────────────────────────────────────
358
- // Apply boosts as multiplicative factors (all boosts in a single phase
359
- // so that sort order and displayed scores are always consistent).
360
- // Ranking philosophy: the goal is to surface the MOST USEFUL result for the
361
- // user's intent. An exact name match is the strongest signal. Actionable
362
- // asset types (skills, commands, agents) are more useful than passive
363
- // reference docs. Curated metadata is more reliable than auto-generated.
364
- // Graph boost context (#207). Built once per query and reused across
365
- // every scored entry so the disk read + JSON parse only happens once
366
- // per search invocation. `null` when no graph file is present, when
367
- // the schema doesn't match, or when no query token matches a graph
368
- // entity — in all of those cases the per-entry call is skipped and
369
- // graph contributes nothing. The graph signal feeds this single
370
- // FTS5+boosts loop as ONE additive component (CLAUDE.md / spec §6:
371
- // one scoring pipeline, no parallel SearchHit scorer).
372
- const graphContext = (() => {
373
- // Search across all source dirs; the graph file lives next to the
374
- // primary source root. Cache misses are silent — the helper handles
375
- // missing files internally and returns `null` instead of throwing.
376
- if (allSourceDirs.length === 0)
377
- return null;
378
- return loadGraphBoostContext(allSourceDirs, query, config, db);
379
- })();
380
- // Resolve project-context tokens from the current working directory once
381
- // per search invocation. Returns null when running from home dir / /tmp,
382
- // or when the caller passed `--no-project-context` (disableProjectContext).
383
- const projectContext = disableProjectContext ? null : resolveProjectContext(process.cwd());
384
- // Phase 2A / Rec 5: resolve forgetting-curve config and skip the feedback
385
- // count query when the boost cannot make a difference (default ≤ 1.0 means
386
- // boost^count == 1 — zero overhead for the common case).
387
- const utilityDecayRaw = config.improve?.utilityDecay;
388
- const halfLifeDays = utilityDecayRaw?.halfLifeDays ?? 30;
389
- const feedbackStabilityBoost = utilityDecayRaw?.feedbackStabilityBoost ?? 1.5;
390
- const utilityDecayConfig = utilityDecayRaw !== undefined ? { halfLifeDays, feedbackStabilityBoost } : undefined;
391
- // Gate the feedback-count query on the user having explicitly opted into
392
- // utilityDecay. Without an opt-in, `utilityDecayConfig` is undefined and the
393
- // ranking contributor ignores `positiveFeedbackCounts` — so running the DB
394
- // query here would be pure overhead. The boost > 1.0 sub-gate then skips the
395
- // query when the configured boost is a no-op (1.5^count when boost==1 is 1).
396
- const positiveFeedbackCounts = shouldQueryPositiveFeedbackCounts(utilityDecayRaw)
397
- ? getPositiveFeedbackCountsByIds(scored.map((item) => item.id))
398
- : undefined;
399
- // Resolve per-project scope key for scoped utility scoring.
400
- // `disableScopedUtility` (wired from `akm search --no-project-context`)
401
- // opts out (e.g. for registry searches or tests).
402
- let scopeKey;
403
- try {
404
- scopeKey = disableScopedUtility ? undefined : getCurrentWorkflowScopeKey();
405
- }
406
- catch {
407
- // Non-fatal — ranking proceeds without scoped utility on any error.
408
- }
409
- // 03-R3: derived twins inherit their base's demoting belief state before
410
- // ranking, so the (03) belief-state ranker demotes a stale flag-free twin.
411
- inheritDerivedTwinBeliefStates(db, scored);
412
- applyRankingRules({
413
- db,
414
- query,
415
- items: scored,
416
- graphContext,
417
- projectContext,
418
- utilityDecayConfig,
419
- positiveFeedbackCounts,
420
- scopeKey,
421
- });
422
- // ── minScore floor ──────────────────────────────────────────────────────
423
- // Drop semantic-only hits (cosine-only, no FTS match) whose score falls
424
- // below the configured floor. FTS hits and hybrid hits are always kept.
425
- // Default floor: 0.2. Set search.minScore = 0 in config to disable.
426
- // Judged on the PRE-ceiling score when a demoting belief state clamped the
427
- // item (`preCeilingScore`): the belief ceilings can sit below this floor
428
- // (archived 0.15 < 0.2), and a demotion must rank the hit last, not
429
- // silently remove a result that would otherwise have listed.
430
- const minScore = config.search?.minScore ?? 0.2;
431
- const preFilter = minScore > 0
432
- ? scored.filter((item) => item.rankingMode !== "semantic" || (item.preCeilingScore ?? item.score) >= minScore)
433
- : scored;
434
- preFilter.sort(buildSearchResultComparator(query));
435
- // Deduplicate by file path — keep only the highest-scored entry per file.
436
- const deduped = deduplicateByPath(preFilter);
437
- // Source → scope → proposed-quality → derived-twin belief inheritance →
438
- // belief: the post-candidate filter chain shared with enumerateEntries (see
439
- // applyEntryFilters). Applied AFTER ranking so filtering narrows the result
440
- // set without touching the single FTS5+boosts scoring pipeline. The twin
441
- // inheritance inside the chain re-runs here as an idempotent no-op — it
442
- // already ran on the full candidate pool before ranking (:460) to feed the
443
- // belief-state ranker.
444
- const beliefFiltered = applyEntryFilters(deduped, {
445
- db,
446
- sources,
447
- restrictToSources,
448
- filters,
449
- includeProposed,
450
- beliefFilter,
451
- });
452
- const rankMs = Date.now() - tRank0;
453
- const selected = beliefFiltered.slice(0, limit);
454
- const fragmentSelections = selected.flatMap((ranked) => ranked.fragmentId && allowsFragmentRef(ranked.entry.type) && hasIndexedProvenance(ranked)
455
- ? [{ entryId: ranked.id, itemRef: ranked.itemRef, fragmentId: ranked.fragmentId }]
456
- : []);
457
- const selectedFragments = getIndexedMarkdownFragments(db, fragmentSelections);
458
- const selectedFragmentByEntryId = new Map();
459
- fragmentSelections.forEach((selection, index) => {
460
- selectedFragmentByEntryId.set(selection.entryId, selectedFragments[index]);
461
- });
462
- const hits = await Promise.all(selected.map((ranked) => {
463
- const { entry, filePath, score, rankingMode, utilityBoosted } = ranked;
464
- // CLAUDE.md locks SearchHit.score in [0,1]. The boost loop deliberately
465
- // remains raw for ranking, then takes a monotone bounded projection at
466
- // the public boundary so contributors do not collapse into hard-clamped
467
- // ties.
468
- const finalScore = displaySearchScore(score);
469
- return buildDbHit({
470
- entry,
471
- path: filePath,
472
- ...indexedProvenance(ranked),
473
- score: Math.round(finalScore * 10000) / 10000,
474
- query,
475
- rankingMode,
476
- lexicalMatch: ranked.lexicalMatch,
477
- fragmentId: ranked.fragmentId,
478
- indexedFragment: ranked.fragmentId ? (selectedFragmentByEntryId.get(ranked.id) ?? null) : undefined,
479
- defaultStashDir: stashDir,
480
- allSourceDirs,
481
- sources,
482
- config,
483
- utilityBoosted,
484
- graphContext,
485
- attributionSource: ranked,
486
- rendererRegistry,
487
- db,
488
- });
489
- }));
490
- return { embedMs, rankMs, hits, mode, semanticWarning };
262
+ return selected.slice(0, limit);
491
263
  }
492
- async function collectSearchSignals(db, query, candidateLimit, typeFilter, excludeTypes, config) {
493
- const startedAt = Date.now();
494
- const embeddingPromise = tryVecScores(db, query, candidateLimit, config);
495
- const ftsResults = searchFts(db, query, candidateLimit, typeFilter, excludeTypes);
496
- const embeddingResult = await embeddingPromise;
497
- const mode = embeddingResult.warning
498
- ? "fts-fallback"
499
- : embeddingResult.scores !== null
500
- ? "semantic"
501
- : "keyword";
502
- return {
503
- ftsResults,
504
- embeddingScores: embeddingResult.scores,
505
- embedMs: Date.now() - startedAt,
506
- mode,
507
- semanticWarning: embeddingResult.warning,
508
- };
264
+ /** Vector candidates of the requested type, nearest first; equal distances are ordered by ref. */
265
+ function keepAllowedTypes(db, neighbors, typeFilter, excludeTypes) {
266
+ const rows = getEntryRefsAndTypes(db, neighbors.map((neighbor) => neighbor.id));
267
+ const excluded = new Set(excludeTypes);
268
+ return neighbors
269
+ .flatMap(({ id, distance }) => {
270
+ const row = rows.get(id);
271
+ if (!row)
272
+ return [];
273
+ if (typeFilter ? row.type !== typeFilter : excluded.has(row.type))
274
+ return [];
275
+ return [{ id, itemRef: row.itemRef, distance }];
276
+ })
277
+ .sort((a, b) => a.distance - b.distance || compareCodePoints(a.itemRef, b.itemRef))
278
+ .map(({ id, itemRef }) => ({ id, itemRef }));
279
+ }
280
+ /** `whyMatched` for a fused hit: its rank in each channel that returned it. */
281
+ function describeRanks(candidate) {
282
+ const [lexicalRank, vectorRank] = candidate.ranks;
283
+ return [
284
+ ...(lexicalRank !== undefined ? [`lexical rank ${lexicalRank}`] : []),
285
+ ...(vectorRank !== undefined ? [`vector rank ${vectorRank}`] : []),
286
+ ];
509
287
  }
510
288
  /**
511
289
  * The no-hits tip. A query in the retired `<type>:` / `<type>:<prefix>/` browse
@@ -526,13 +304,13 @@ function emptyResultTip(query) {
526
304
  /**
527
305
  * Enumerate index entries without FTS scoring — the browse path shared by
528
306
  * empty/unsearchable queries and D4 conceptId-prefix queries (`memories/`,
529
- * `bundle//`, `bundle//skills/`). Applies the same post-ranking filters as the scored
307
+ * `bundle//`, `bundle//skills/`). Applies the same filters as the scored
530
308
  * path (source narrowing, scope, proposed-quality, belief) before the limit
531
309
  * slice. Hits carry the fixed browse score 1 in type-then-name order — this is
532
310
  * a deterministic listing, not a relevance ranking.
533
311
  */
534
312
  async function enumerateEntries(opts) {
535
- const { db, query, sources, config, rendererRegistry, filters, beliefFilter } = opts;
313
+ const { db, sources, config } = opts;
536
314
  const allEntries = getAllEntries(db, opts.typeFilter, opts.excludeTypes).filter(hasIndexedProvenance);
537
315
  // Explicit listing order: type, then name, then filePath. The underlying
538
316
  // SELECT carries no ORDER BY, so its row order tracks the query plan and the
@@ -552,78 +330,34 @@ async function enumerateEntries(opts) {
552
330
  const prefixFiltered = conceptIdPrefix.length > 0
553
331
  ? bundleFiltered.filter((ie) => ie.conceptId.toLowerCase().startsWith(conceptIdPrefix))
554
332
  : bundleFiltered;
555
- // Deduplicate by file path — multiple entries can share the same file
556
- const seenFilePaths = new Set();
557
- const uniqueEntries = prefixFiltered.filter((ie) => {
558
- if (seenFilePaths.has(ie.filePath))
559
- return false;
560
- seenFilePaths.add(ie.filePath);
561
- return true;
562
- });
563
- // Source → scope → proposed-quality → derived-twin belief inheritance →
564
- // belief: the post-candidate filter chain shared with searchDatabase's
565
- // scored path (see applyEntryFilters). Filtering happens BEFORE the limit
566
- // slice so a restrictive filter still returns up to `limit` results. On this
567
- // path the twin inheritance is the ONLY place it runs (there is no ranking
568
- // pass), keeping the belief filter and reported hit state consistent with
569
- // the scored path.
570
- const beliefFiltered = applyEntryFilters(uniqueEntries, {
571
- db,
572
- sources,
573
- restrictToSources: opts.restrictToSources,
574
- filters,
575
- includeProposed: opts.includeProposed,
576
- beliefFilter,
577
- });
578
- const selected = beliefFiltered.slice(0, opts.limit);
579
- const hits = await Promise.all(selected.map((ie) => buildDbHit({
333
+ // Deduplicate by file path — multiple entries can share the same file.
334
+ // Filtering happens BEFORE the limit slice so a restrictive filter still
335
+ // returns up to `limit` results.
336
+ const selected = applyEntryFilters(deduplicateByPath(prefixFiltered), opts).slice(0, opts.limit);
337
+ return Promise.all(selected.map((ie) => buildDbHit({
580
338
  entry: ie.entry,
581
339
  path: ie.filePath,
582
340
  itemRef: ie.itemRef,
583
341
  bundleId: ie.bundleId,
584
342
  conceptId: ie.conceptId,
585
343
  score: 1,
586
- query,
587
- rankingMode: "fts",
588
344
  defaultStashDir: opts.stashDir,
589
- allSourceDirs: opts.allSourceDirs,
590
345
  sources,
591
346
  config,
592
- rendererRegistry,
593
347
  db,
594
348
  })));
595
- return { hits };
596
349
  }
597
350
  /**
598
- * Post-candidate filter chain shared by BOTH search paths — the scored path
599
- * (`searchDatabase`) and the browse path (`enumerateEntries`). Applies, in this
600
- * exact order: source-narrowing → scope → proposed-quality → derived-twin
601
- * belief inheritance → belief filter. Extracting the chain removes the two
602
- * paths' formerly-duplicated filter sequences so the predicates, their order,
603
- * and the twin-inheritance placement can never drift apart (plan §4.3).
604
- *
605
- * What this does NOT unify — and deliberately leaves divergent — is CANDIDATE-
606
- * POOL construction, which is inherent search-vs-browse semantics: the scored
607
- * path's pool is `searchFts`/vector matches for the query's own tokens (FTS
608
- * includes structured fields and bounded adapter content), while the
609
- * enumerate path's pool is `getAllEntries` for the type, independent of query
610
- * text. A derived twin sharing no indexed token with the query is therefore an
611
- * enumerate-path candidate but never a scored-path candidate. (A golden
612
- * fixture used to pin that divergence; the golden suites were deleted in
613
- * 0.9.8, so this comment is now the record of it.)
614
- *
615
- * `inheritDerivedTwinBeliefStates` is idempotent, so running it here is safe on
616
- * the scored path, which must ALSO call it before ranking (the belief-state
617
- * ranker demotes inherited states): by the time this chain runs, those twins
618
- * already carry a state and the call here is a no-op for them. The enumerate
619
- * path never ranks, so this is the only place it inherits.
351
+ * Filter chain shared by the scored and browse paths, in this order:
352
+ * source-narrowing → scope → proposed-quality → derived-twin belief
353
+ * inheritance → belief filter.
620
354
  */
621
355
  function applyEntryFilters(items, opts) {
622
356
  const { filters } = opts;
623
357
  // Source filter: when the caller narrowed `sources` via `--from <name>`,
624
358
  // drop entries whose filePath does not live under any requested source. The
625
- // FTS/enumerate index spans every configured source, so without this filter a
626
- // narrowed --from request would still leak results from other sources.
359
+ // index spans every configured source, so without this filter a narrowed
360
+ // --from request would still leak results from other sources.
627
361
  const sourceFiltered = opts.restrictToSources
628
362
  ? items.filter((item) => findSourceForPath(item.filePath, opts.sources) !== undefined)
629
363
  : items;
@@ -645,14 +379,12 @@ function applyEntryFilters(items, opts) {
645
379
  }
646
380
  /**
647
381
  * 03-R3: let each `.derived` twin inherit its base memory's demoting belief
648
- * state for this ranking pass, so a stale flag-free twin is demoted like its
649
- * corrected base. The base carries the flag (a contradicted base takes a real
650
- * ranking penalty); its near-duplicate `.derived` twin carries none and would
651
- * otherwise outrank the corrected copy. Done in-memory at search time — NOT by
652
- * writing the twin's frontmatter — because the SCC belief resolver refreshes any
653
- * non-frozen state written to a derived memory back to `active` on the next
654
- * improve run, erasing it. Only twins with no state of their own inherit; an
655
- * explicit twin state always wins. Reuses the (03) belief-state ranker + filter.
382
+ * state, so `--belief` treats a stale flag-free twin like its corrected base.
383
+ * The base carries the flag; its near-duplicate `.derived` twin carries none.
384
+ * Done in-memory at search time — NOT by writing the twin's frontmatter —
385
+ * because the SCC belief resolver refreshes any non-frozen state written to a
386
+ * derived memory back to `active` on the next improve run, erasing it. Only
387
+ * twins with no state of their own inherit; an explicit twin state always wins.
656
388
  */
657
389
  function inheritDerivedTwinBeliefStates(db, items) {
658
390
  const DEMOTING = new Set(["contradicted", "superseded", "deprecated", "archived"]);
@@ -687,33 +419,36 @@ function matchBeliefFilter(beliefState, filter) {
687
419
  beliefState === "deprecated" ||
688
420
  beliefState === "archived");
689
421
  }
690
- // ── Vector scorer ───────────────────────────────────────────────────────────
691
- async function tryVecScores(db, query, k, config) {
422
+ // ── Vector channel ──────────────────────────────────────────────────────────
423
+ /**
424
+ * Embed the query and return its nearest document ids, best first. The
425
+ * embedding request is dispatched before this returns, so the caller's FTS
426
+ * query overlaps it. A slow or failing embedder degrades the search to keyword
427
+ * ranking with a warning after `embedding.queryTimeoutMs`.
428
+ */
429
+ function startVectorChannel(db, query, config) {
692
430
  if (config.semanticSearchMode === "off")
693
- return { scores: null };
694
- // A real-time completeness fact, not a cached verdict: skip the network
695
- // round trip only when the index has never embedded anything. A PARTIAL
696
- // failure (some entries embedded, one write degraded) still attempts —
697
- // and if the endpoint is genuinely down, the failure surfaces as a live
698
- // `semanticWarning` below instead of silently skipping with no signal.
431
+ return Promise.resolve({ neighbors: null });
432
+ // A real-time completeness fact, not a cached verdict: skip the round trip
433
+ // only when the index has never embedded anything. A PARTIAL failure still
434
+ // attempts — and if the endpoint is genuinely down, the failure surfaces as
435
+ // a live warning instead of silently skipping with no signal.
699
436
  if (getEmbeddingCount(db) === 0)
700
- return { scores: null };
701
- try {
702
- const { embed } = await import("../../llm/embedder.js");
703
- const queryEmbedding = await embed(query, config.embedding);
704
- const vecResults = searchVec(db, queryEmbedding, k);
705
- const scores = new Map();
706
- for (const { id, distance } of vecResults) {
707
- // Convert L2 distance to cosine similarity (vectors are normalized).
708
- // Guard against NaN/Infinity from sqlite-vec edge cases.
709
- const raw = 1 - (distance * distance) / 2;
710
- scores.set(id, Number.isFinite(raw) ? Math.max(0, raw) : 0);
711
- }
712
- return { scores };
713
- }
714
- catch (error) {
715
- return { scores: null, warning: buildVectorFallbackWarning(config, error) };
716
- }
437
+ return Promise.resolve({ neighbors: null });
438
+ const timeoutMs = config.embedding?.queryTimeoutMs ?? DEFAULT_QUERY_EMBED_TIMEOUT_MS;
439
+ const controller = new AbortController();
440
+ let timer;
441
+ const timeout = new Promise((_, reject) => {
442
+ timer = setTimeout(() => {
443
+ controller.abort();
444
+ reject(new Error(`Query embedding timed out after ${timeoutMs}ms`));
445
+ }, timeoutMs);
446
+ });
447
+ const queryText = applyEmbeddingTemplate(resolveEmbeddingProfile(config.embedding).queryTemplate, query);
448
+ return Promise.race([embed(queryText, config.embedding, controller.signal), timeout])
449
+ .then((vector) => ({ neighbors: searchVec(db, vector, CHANNEL_DEPTH) }))
450
+ .catch((error) => ({ neighbors: null, warning: buildVectorFallbackWarning(config, error) }))
451
+ .finally(() => clearTimeout(timer));
717
452
  }
718
453
  function buildVectorFallbackWarning(config, error) {
719
454
  const endpoint = safeEmbeddingEndpoint(config);
@@ -770,99 +505,39 @@ function classifyVectorFailure(error) {
770
505
  }
771
506
  // ── Hit building ────────────────────────────────────────────────────────────
772
507
  export async function buildDbHit(input) {
773
- const rendererRegistry = input.rendererRegistry ?? defaultRendererRegistry;
774
508
  const absolutePath = path.resolve(input.path);
775
- const entryStashDir = findSourceForPath(absolutePath, input.sources)?.path ?? input.defaultStashDir;
776
- // Quality and confidence boosts are now applied in the main scoring
777
- // phase (searchDatabase). buildDbHit receives the already-final score and
778
- // passes it through without further multiplication. We still compute the
779
- // boost values here for buildWhyMatched reporting.
780
- // Mirrors the boost computation in `searchDatabase`; only `curated`
781
- // contributes a positive boost. Used for `whyMatched` reporting only.
782
- const qualityBoost = input.entry.quality === "curated" ? 0.05 : 0;
783
- const confidenceBoost = typeof input.entry.confidence === "number" ? Math.min(0.05, Math.max(0, input.entry.confidence) * 0.05) : 0;
784
- // Round to 4 decimal places, no boost multiplication
785
- const score = Math.round(input.score * 10000) / 10000;
786
- const graphBoost = getSearchHitAttribution(input.attributionSource ?? {})?.graphExtraction?.boost ?? 0;
787
- const whyMatched = buildWhyMatched(input.entry, input.query, input.rankingMode, qualityBoost, confidenceBoost, input.utilityBoosted, graphBoost, input.lexicalMatch);
788
- const graphHit = input.graphContext ? collectGraphRelatedHit(input.graphContext, absolutePath) : null;
789
509
  const source = findSourceForPath(absolutePath, input.sources);
510
+ const entryStashDir = source?.path ?? input.defaultStashDir;
790
511
  const defaultBundleId = input.config?.defaultBundle ??
791
512
  (source && path.resolve(source.path) === path.resolve(input.defaultStashDir)
792
513
  ? (input.bundleId ?? undefined)
793
514
  : undefined);
794
- const parentRef = resolveSearchHitRef(input.entry, input, defaultBundleId);
795
- // Fragments prove lexical relevance, but executable assets must retain the
796
- // parent ref consumed by their advertised action (for example workflow run).
797
- // The central type-presentation contract opts those types out explicitly.
798
- const ref = input.fragmentId && allowsFragmentRef(input.entry.type) ? `${parentRef}#${input.fragmentId}` : parentRef;
515
+ const ref = resolveSearchHitRef(input.entry, input, defaultBundleId);
799
516
  const editable = isEditable(absolutePath, input.config, input.sources);
800
- const indexedFragment = input.indexedFragment === undefined
801
- ? input.fragmentId && input.db
802
- ? getIndexedMarkdownFragment(input.db, input.itemRef, input.fragmentId)
803
- : undefined
804
- : (input.indexedFragment ?? undefined);
805
- const selectedRef = input.fragmentId && ref !== parentRef ? `${parentRef}#${input.fragmentId}` : undefined;
806
- const parentEstimatedTokens = typeof input.entry.fileSize === "number"
807
- ? Math.round(input.entry.fileSize / 4)
808
- : indexedFragment
809
- ? Math.round(indexedFragment.parentChars / 4)
810
- : undefined;
811
- const fragmentEstimatedTokens = indexedFragment ? Math.round(indexedFragment.fragmentChars / 4) : undefined;
812
- const estimatedTokens = selectedRef === ref && fragmentEstimatedTokens !== undefined ? fragmentEstimatedTokens : parentEstimatedTokens;
517
+ const estimatedTokens = typeof input.entry.fileSize === "number" ? Math.round(input.entry.fileSize / 4) : undefined;
813
518
  const hit = {
814
519
  type: input.entry.type,
815
520
  name: input.entry.name,
816
521
  path: absolutePath,
817
522
  ref,
818
- origin: resolveSearchHitOrigin(source),
523
+ origin: source?.registryId ?? null,
819
524
  editable,
820
525
  ...(!editable ? { editHint: buildEditHint(ref) } : {}),
821
526
  description: input.entry.description,
822
527
  tags: input.entry.tags,
823
528
  size: deriveSize(input.entry.fileSize),
824
- action: buildLocalAction(input.entry.type, ref, rendererRegistry),
825
- score,
826
- whyMatched,
529
+ action: buildLocalAction(input.entry.type, ref),
530
+ score: input.score,
531
+ ...(input.whyMatched ? { whyMatched: input.whyMatched } : {}),
827
532
  ...(estimatedTokens !== undefined ? { estimatedTokens } : {}),
828
- ...(selectedRef
829
- ? {
830
- selectedRef,
831
- parentRef,
832
- ...(indexedFragment
833
- ? {
834
- fragmentOrdinal: indexedFragment.ordinal + 1,
835
- fragmentCount: indexedFragment.count,
836
- startLine: indexedFragment.startLine,
837
- endLine: indexedFragment.endLine,
838
- ...(indexedFragment.previousFragmentId
839
- ? { previousRef: `${parentRef}#${indexedFragment.previousFragmentId}` }
840
- : {}),
841
- ...(indexedFragment.nextFragmentId
842
- ? { nextRef: `${parentRef}#${indexedFragment.nextFragmentId}` }
843
- : {}),
844
- fragmentChars: indexedFragment.fragmentChars,
845
- fragmentEstimatedTokens,
846
- parentChars: indexedFragment.parentChars,
847
- ...(parentEstimatedTokens !== undefined ? { parentEstimatedTokens } : {}),
848
- }
849
- : parentEstimatedTokens !== undefined
850
- ? { parentEstimatedTokens }
851
- : {}),
852
- }
853
- : {}),
854
533
  // Surface optional quality (v1 spec §4.2). Omitted when entry has
855
534
  // no `quality` field so payloads stay compact for the common case.
856
535
  ...(input.entry.quality ? { quality: input.entry.quality } : {}),
857
536
  ...(input.entry.beliefState ? { beliefState: input.entry.beliefState } : {}),
858
537
  ...(input.entry.currentBeliefRefs ? { currentBeliefRefs: input.entry.currentBeliefRefs } : {}),
859
- ...(graphHit ? { graph: { entities: graphHit.entities, relations: graphHit.relations } } : {}),
860
- // Which stage of the progressive AND->OR lexical ladder produced this
861
- // hit. Omitted when the hit has no FTS component (pure-semantic hybrid
862
- // contribution).
863
- ...(input.lexicalMatch ? { matchStage: input.lexicalMatch } : {}),
864
538
  };
865
- attachDbHitAttribution(hit, input);
539
+ if (input.entry.content)
540
+ hitContent.set(hit, input.entry.content);
866
541
  if (input.entry.derivedFrom) {
867
542
  attachSearchHitAttribution(hit, {
868
543
  memoryInference: { exposure: "direct" },
@@ -872,90 +547,10 @@ export async function buildDbHit(input) {
872
547
  type: input.entry.type,
873
548
  stashDir: entryStashDir,
874
549
  bundleId: input.bundleId,
875
- rendererRegistry,
876
550
  db: input.db,
877
551
  });
878
552
  return hit;
879
553
  }
880
- function attachDbHitAttribution(hit, input) {
881
- if (input.lexicalMatch) {
882
- attachSearchHitAttribution(hit, {
883
- lexical: {
884
- execution: input.lexicalMatch,
885
- nameMatchTier: lexicalNameMatchTier(input.entry, buildLexicalQueryPlan(input.query).tokens),
886
- },
887
- });
888
- }
889
- if (input.attributionSource)
890
- copySearchHitAttribution(input.attributionSource, hit);
891
- }
892
- export function buildWhyMatched(entry, query,
893
- // "hybrid" ranking mode
894
- rankingMode, qualityBoost, confidenceBoost, utilityBoosted, graphBoost, lexicalMatch) {
895
- const reasons = [
896
- rankingMode === "hybrid"
897
- ? "hybrid (fts + semantic)"
898
- : rankingMode === "semantic"
899
- ? "semantic similarity"
900
- : "fts bm25 relevance",
901
- ];
902
- if (lexicalMatch === "relaxed")
903
- reasons.push("lexical recovery after strict query returned no hits");
904
- if (lexicalMatch === "prefix")
905
- reasons.push("prefix match after strict query returned no hits");
906
- const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
907
- const queryLower = query.toLowerCase().trim();
908
- const name = entry.name.toLowerCase();
909
- const nameBase = name.split("/").pop() ?? name;
910
- const tags = entry.tags?.join(" ").toLowerCase() ?? "";
911
- const searchHints = entry.searchHints?.join(" ").toLowerCase() ?? "";
912
- const aliases = entry.aliases?.join(" ").toLowerCase() ?? "";
913
- const desc = entry.description?.toLowerCase() ?? "";
914
- // Name match quality
915
- if (nameBase === queryLower || name === queryLower) {
916
- reasons.push("exact name match");
917
- }
918
- else if (nameBase.includes(queryLower) || queryLower.includes(nameBase)) {
919
- reasons.push("near-exact name match");
920
- }
921
- else if (tokens.some((t) => nameBase.includes(t))) {
922
- reasons.push("matched name tokens");
923
- }
924
- // Type relevance
925
- if (entry.type === "skill" || entry.type === "command" || entry.type === "agent") {
926
- reasons.push(`${entry.type} type boost`);
927
- }
928
- if (tokens.some((t) => tags.includes(t)))
929
- reasons.push("matched tags");
930
- if (tokens.some((t) => searchHints.includes(t)))
931
- reasons.push("matched searchHints");
932
- if (tokens.some((t) => aliases.includes(t)))
933
- reasons.push("matched aliases");
934
- if (tokens.some((t) => desc.includes(t)))
935
- reasons.push("matched description");
936
- if (qualityBoost > 0)
937
- reasons.push("curated metadata boost");
938
- if (confidenceBoost > 0)
939
- reasons.push("metadata confidence boost");
940
- if (entry.beliefState === "active")
941
- reasons.push("active belief state");
942
- if (entry.beliefState === "asserted")
943
- reasons.push("asserted belief state");
944
- if (entry.beliefState === "contradicted")
945
- reasons.push("contradicted belief state");
946
- if (entry.beliefState === "superseded")
947
- reasons.push("superseded belief state");
948
- if (entry.beliefState === "deprecated")
949
- reasons.push("deprecated belief state");
950
- if (entry.beliefState === "archived")
951
- reasons.push("archived belief state");
952
- if (utilityBoosted)
953
- reasons.push("usage history boost");
954
- if (typeof graphBoost === "number" && graphBoost > 0) {
955
- reasons.push(`graph boost +${graphBoost.toFixed(2)}`);
956
- }
957
- return reasons;
958
- }
959
554
  // ── Utilities ────────────────────────────────────────────────────────────────
960
555
  export function deriveSize(bytes) {
961
556
  if (bytes === undefined)
@@ -966,11 +561,7 @@ export function deriveSize(bytes) {
966
561
  return "medium";
967
562
  return "large";
968
563
  }
969
- /**
970
- * Deduplicate the already-ranked result stream by file path. The caller owns
971
- * the one ranking order; re-sorting here would silently discard exact-name and
972
- * relaxed-recovery ordering in favor of an internal pre-clamp score.
973
- */
564
+ /** Keep the first entry per file path; the caller owns the order. */
974
565
  function deduplicateByPath(items) {
975
566
  const seen = new Set();
976
567
  return items.filter((item) => {