@agentskit/doc-bridge 1.7.45 → 1.9.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 (213) hide show
  1. package/CHANGELOG.md +465 -0
  2. package/CONTRIBUTING.md +23 -0
  3. package/README.md +87 -40
  4. package/SECURITY.md +7 -0
  5. package/action.yml +1 -1
  6. package/bin/ak-docs.js +2 -2
  7. package/bin/ak-verify.js +13 -7
  8. package/dist/cli/program.d.ts +3 -1
  9. package/dist/cli/program.js +15861 -6223
  10. package/dist/cli/program.js.map +1 -1
  11. package/dist/config/index.d.ts +1 -1
  12. package/dist/config/index.js +86 -6
  13. package/dist/config/index.js.map +1 -1
  14. package/dist/{index-BUL0q7s8.d.ts → index-Beor6Yhi.d.ts} +133 -1
  15. package/dist/index.d.ts +9871 -1062
  16. package/dist/index.js +16126 -6125
  17. package/dist/index.js.map +1 -1
  18. package/docs/MARKETPLACE.md +1 -1
  19. package/docs/PRD-documentation-efficiency-study.md +406 -0
  20. package/docs/PRD-knowledge-retrieval-and-enrichment.md +466 -0
  21. package/docs/adr/0002-documentation-audit-boundary.md +22 -0
  22. package/docs/adr/0003-study-protocol-and-historical-evidence.md +40 -0
  23. package/docs/adr/0004-controlled-study-runner.md +25 -0
  24. package/docs/adr/0005-documentation-quality-and-criticality.md +20 -0
  25. package/docs/adr/0006-registry-semantic-grounding.md +20 -0
  26. package/docs/adr/0007-longitudinal-study-metrics.md +21 -0
  27. package/docs/adr/0008-study-verification-boundary.md +21 -0
  28. package/docs/adr/0009-study-provider-cli-adapter.md +25 -0
  29. package/docs/agent-corpus/INDEX.md +12 -1
  30. package/docs/agent-corpus/OVERVIEW.md +25 -0
  31. package/docs/agent-corpus/chat.md +5 -1
  32. package/docs/agent-corpus/cli.md +16 -0
  33. package/docs/agent-corpus/conformance.md +12 -0
  34. package/docs/agent-corpus/doc-bridge.md +47 -0
  35. package/docs/agent-corpus/doctor.md +8 -0
  36. package/docs/agent-corpus/gates.md +4 -0
  37. package/docs/agent-corpus/mcp.md +13 -0
  38. package/docs/agent-corpus/memory.md +4 -0
  39. package/docs/agent-corpus/query.md +33 -0
  40. package/docs/bench/README.md +122 -0
  41. package/docs/bench/retrieval-baseline-v1.json +28 -0
  42. package/docs/bench/retrieval-suite-v1.json +1033 -0
  43. package/docs/chat-and-rag.md +3 -2
  44. package/docs/for-agents.md +9 -1
  45. package/docs/getting-started.md +4 -11
  46. package/docs/guides/gate-ci.md +11 -1
  47. package/docs/guides/install-and-run.md +9 -65
  48. package/docs/index.md +22 -1
  49. package/docs/knowledge-engine-runbook.md +38 -3
  50. package/docs/landing/assets/context-payload-reduction.svg +21 -0
  51. package/docs/landing/assets/controlled-ab-comparison.svg +30 -0
  52. package/docs/landing/index.html +119 -5
  53. package/docs/loop-workflow.md +117 -0
  54. package/docs/mcp.md +6 -1
  55. package/docs/parity/public-claims-v1.json +145 -0
  56. package/docs/playbook/doc-bridge-pattern.md +1 -1
  57. package/docs/query.md +90 -2
  58. package/docs/recipes/index-pipeline.md +1 -1
  59. package/docs/schemas/agent-handoff-v1.md +15 -0
  60. package/docs/schemas/doc-bridge-index-v1.md +65 -0
  61. package/docs/spec/benchmark-v1.md +33 -1
  62. package/docs/spec/cli.md +30 -10
  63. package/docs/spec/config-v1.md +147 -8
  64. package/docs/spec/documentation-audit-v1.md +61 -0
  65. package/docs/spec/enrichment-overlay-v1.md +241 -0
  66. package/docs/spec/graph-signals-v1.md +92 -0
  67. package/docs/spec/incremental-scan-v1.md +102 -0
  68. package/docs/spec/markdown-analyzer-v1.md +73 -0
  69. package/docs/spec/mcp-knowledge-tools-v1.md +147 -0
  70. package/docs/spec/measured-enrichment-v1.md +229 -0
  71. package/docs/spec/public-parity-v1.md +119 -0
  72. package/docs/spec/registry-agents.md +6 -0
  73. package/docs/spec/render-v1.md +122 -0
  74. package/docs/spec/retrieval-index-v1.md +164 -0
  75. package/docs/spec/study-metrics-v1.md +58 -0
  76. package/docs/spec/study-protocol-v1.md +46 -0
  77. package/docs/spec/study-provider-cli-v1.md +116 -0
  78. package/docs/spec/study-runner-v1.md +35 -0
  79. package/docs/spec/study-task-suite-v1.md +41 -0
  80. package/docs/spec/study-verification-v1.md +40 -0
  81. package/docs/study/README.md +84 -0
  82. package/docs/study/ab-adjudicated-cost-analysis-v1.md +29 -0
  83. package/docs/study/ab-adjudicated-cost-plan-v1.json +33 -0
  84. package/docs/study/ab-adjudicated-cost-plan-v2-v1.json +33 -0
  85. package/docs/study/ab-adjudicated-cost-result-v1.json +80 -0
  86. package/docs/study/ab-baseline-analysis-v1.md +21 -0
  87. package/docs/study/ab-baseline-plan-v1.json +33 -0
  88. package/docs/study/ab-baseline-recovery-plan-v1.json +33 -0
  89. package/docs/study/ab-baseline-result-v1.json +79 -0
  90. package/docs/study/documentation-audit-round-2026-08-31.json +183 -0
  91. package/docs/study/historical-evidence-v1.json +252 -0
  92. package/docs/study/observation-ledger-v1.json +30632 -0
  93. package/docs/study/phase3-task-coverage-v1.json +34 -0
  94. package/docs/study/phase4-public-pilot-ledger-v1.json +1344 -0
  95. package/docs/study/phase4-public-pilot-result-v1.json +52 -0
  96. package/docs/study/phase4-public-pilot-run-plan-v1.json +26 -0
  97. package/docs/study/phase4-public-pilot-task-suite-v1.json +71 -0
  98. package/docs/study/pilot-round-2026-08-31.json +46 -0
  99. package/docs/study/protocol-v1.json +90 -0
  100. package/docs/study/publication-gate-v1.md +45 -0
  101. package/docs/study/quality-scorecard-cycle-plan.md +545 -0
  102. package/docs/study/quality-scorecard-v1.json +38 -0
  103. package/docs/study/round-1-adjudicated-smoke-v1.json +30642 -0
  104. package/docs/study/round-1-instrumentation-plan-v1.md +39 -0
  105. package/docs/study/round-2-expanded-adjudication-v1.json +91 -0
  106. package/docs/study/round-2-expanded-validation-v1.md +58 -0
  107. package/docs/study/round-3-evidence-contract-v1.json +75 -0
  108. package/docs/study/round-3-evidence-contract-v1.md +57 -0
  109. package/docs/study/round-4-confirmation-v1.json +75 -0
  110. package/docs/study/round-4-confirmation-v1.md +55 -0
  111. package/docs/study/run-plan-v1.json +33 -0
  112. package/docs/study/semantic-adjudication-cycle-8.md +20 -0
  113. package/docs/study/task-suite-v1.json +96 -0
  114. package/docs/study/token-efficiency-plan-v1.md +337 -0
  115. package/docs/study/token-efficiency-protocol-v2.json +62 -0
  116. package/docs/study/verification-binding-v1.json +27 -0
  117. package/docs/validation-cycle-plan.md +14 -0
  118. package/docs/verification-harness.md +11 -6
  119. package/ecosystem-claims.json +2 -2
  120. package/ecosystem-upstream.json +2 -2
  121. package/ecosystem.json +4 -4
  122. package/mcpb/manifest.json +9 -1
  123. package/package.json +25 -6
  124. package/scripts/check-ecosystem-upstream.mjs +34 -6
  125. package/skills/doc-bridge-handoff/fixtures/synthetic-repo/docs/for-agents/packages/payments.md +7 -0
  126. package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
  127. package/src/agents/registry-adapter.ts +192 -24
  128. package/src/audit/documentation.ts +513 -0
  129. package/src/bench/baseline.ts +198 -0
  130. package/src/bench/overlay-delta.ts +139 -0
  131. package/src/bench/retrieval.ts +319 -0
  132. package/src/budget/compile.ts +91 -0
  133. package/src/budget/sections.ts +70 -0
  134. package/src/cli/program.ts +684 -74
  135. package/src/cli/usage.ts +71 -0
  136. package/src/config/defaults.ts +1 -0
  137. package/src/config/index.ts +4 -0
  138. package/src/config/schema.ts +117 -2
  139. package/src/conformance/documentation-standard-v1.ts +8 -6
  140. package/src/discovery/areas.ts +182 -0
  141. package/src/discovery/documentation.ts +211 -5
  142. package/src/discovery/identity.ts +24 -0
  143. package/src/discovery/incremental.ts +314 -0
  144. package/src/discovery/inputs.ts +110 -0
  145. package/src/discovery/markdown.ts +481 -0
  146. package/src/discovery/repository.ts +484 -101
  147. package/src/doctor/run-doctor.ts +244 -12
  148. package/src/enrich/approvals.ts +190 -0
  149. package/src/enrich/cache.ts +93 -0
  150. package/src/enrich/context-pack.ts +272 -0
  151. package/src/enrich/overlay.ts +255 -0
  152. package/src/enrich/review.ts +106 -0
  153. package/src/enrich/stage.ts +374 -0
  154. package/src/enrich/stats.ts +100 -0
  155. package/src/enrich/validate.ts +410 -0
  156. package/src/federation/llms.ts +2 -4
  157. package/src/findings/report.ts +103 -0
  158. package/src/graph/build.ts +356 -0
  159. package/src/graph/memory.ts +208 -0
  160. package/src/index-builder/build-handoffs.ts +22 -11
  161. package/src/index-builder/build-index.ts +132 -3
  162. package/src/index-builder/llms-txt.ts +48 -8
  163. package/src/index-builder/project-corpus.ts +111 -0
  164. package/src/index.ts +630 -2
  165. package/src/lib/fuzzy-match.ts +235 -0
  166. package/src/mcp/knowledge.ts +554 -0
  167. package/src/mcp/server.ts +113 -18
  168. package/src/metrics/benchmark.ts +21 -0
  169. package/src/parity/check.ts +309 -0
  170. package/src/parity/claims.ts +259 -0
  171. package/src/parity/resolve.ts +160 -0
  172. package/src/query/handoff.ts +326 -0
  173. package/src/query/load-index.ts +53 -1
  174. package/src/query/query.ts +92 -59
  175. package/src/query/search.ts +289 -92
  176. package/src/query/text.ts +155 -0
  177. package/src/reconciliation/reconcile.ts +101 -10
  178. package/src/render/data.ts +356 -0
  179. package/src/render/engine.ts +398 -0
  180. package/src/render/generated.ts +77 -0
  181. package/src/render/render.ts +209 -0
  182. package/src/render/template-source.ts +52 -0
  183. package/src/render/templates.ts +289 -0
  184. package/src/report/html.ts +2 -2
  185. package/src/retrieval/bm25.ts +161 -0
  186. package/src/retrieval/project.ts +495 -0
  187. package/src/retrieval/rank.ts +383 -0
  188. package/src/retrieval/weights.ts +39 -0
  189. package/src/retriever/doc-bridge-retriever.ts +100 -15
  190. package/src/rules/engine.ts +30 -10
  191. package/src/schemas/agent-handoff.ts +56 -0
  192. package/src/schemas/budget.ts +37 -0
  193. package/src/schemas/doc-bridge-index.ts +53 -2
  194. package/src/schemas/enrichment.ts +369 -0
  195. package/src/schemas/json-schemas.ts +39 -2
  196. package/src/schemas/knowledge.ts +14 -1
  197. package/src/schemas/retrieval-index.ts +152 -0
  198. package/src/shims/graphology.d.ts +91 -0
  199. package/src/study/adjudication.ts +196 -0
  200. package/src/study/execution.ts +350 -0
  201. package/src/study/expectations.ts +219 -0
  202. package/src/study/metrics.ts +467 -0
  203. package/src/study/protocol.ts +271 -0
  204. package/src/study/provider-cli.ts +115 -0
  205. package/src/study/provider-telemetry.ts +47 -0
  206. package/src/study/quality-scorecard.ts +164 -0
  207. package/src/study/runner.ts +461 -0
  208. package/src/study/task-suite.ts +321 -0
  209. package/src/study/verification.ts +134 -0
  210. package/src/validate.ts +1 -4
  211. package/src/version.ts +1 -1
  212. package/src/workflow/engine.ts +36 -11
  213. package/scripts/verification-harness.mjs +0 -482
@@ -1,126 +1,323 @@
1
1
  import type { DocBridgeIndexV1 } from '../schemas/doc-bridge-index.js'
2
+ import type { Evidence } from '../schemas/knowledge.js'
3
+ import type { Confidence, RetrievalKind } from '../schemas/retrieval-index.js'
4
+ import { rankRetrieval, type RankExplanation, type RankedEntry } from '../retrieval/rank.js'
5
+ import { buildBm25Index, bm25Search, type Bm25Index, type Bm25Input } from '../retrieval/bm25.js'
6
+ import { isProjectedEntry } from '../index-builder/project-corpus.js'
7
+ import { foldAccents, hasSearchToken, searchTokens } from './text.js'
2
8
 
3
9
  export type SearchMatch = {
10
+ /** The record kind as consumers have always read it: `ownership`, `intent`, `change`, or the entry kind. */
4
11
  readonly type: string
5
12
  readonly id: string
6
13
  readonly path: string
7
14
  readonly summary?: string
8
15
  readonly score: number
16
+ /** The snapshot entity this result stands for, when it stands for one. */
17
+ readonly entityId?: string
18
+ readonly kind?: RetrievalKind
19
+ readonly provenance?: 'observed' | 'declared' | 'proposed'
20
+ readonly confidence?: Confidence
21
+ readonly evidence?: readonly Evidence[]
22
+ readonly explain?: RankExplanation
9
23
  }
10
24
 
11
- const tokenize = (value: string): string[] =>
12
- value
13
- .toLowerCase()
14
- .split(/[^a-z0-9@/_-]+/)
15
- .filter((t) => t.length >= 2)
25
+ export type SearchOptions = {
26
+ /** Attach the matched terms and each scoring component to every result. Never changes the ranking. */
27
+ readonly explain?: boolean
28
+ /** The `--agent` prior. */
29
+ readonly agent?: boolean
30
+ }
16
31
 
17
- const PACKAGE_INTENT =
18
- /\b(package|module|pkg|edit|change|where|owns?|ownership|handoff|start)\b/i
32
+ /**
33
+ * Search the index.
34
+ *
35
+ * An index that carries the retrieval projection is ranked over it — every snapshot entity, with
36
+ * evidence, provenance and confidence on each result. An index built before the projection
37
+ * existed is ranked the way it always was, over its `knowledge[]` and lookup records, so an older
38
+ * artifact keeps answering until it is rebuilt.
39
+ */
40
+ export const searchIndex = (index: DocBridgeIndexV1, term: string, limit = 20, options: SearchOptions = {}): SearchMatch[] => {
41
+ if (index.projection) return projectedSearch(index, term, limit, options)
42
+ return legacySearch(index, term, limit)
43
+ }
19
44
 
20
- const scoreHay = (tokens: readonly string[], hay: string, weight = 1): number => {
21
- let score = 0
22
- for (const token of tokens) {
23
- if (!hay.includes(token)) continue
24
- score += token.length * weight
25
- // whole-word-ish bonus
26
- if (new RegExp(`(?:^|[^a-z0-9])${token}(?:[^a-z0-9]|$)`).test(hay)) {
27
- score += token.length
45
+ /**
46
+ * How a projected result presents itself to a reader of the old shape.
47
+ *
48
+ * An entry that stands for an ownership record is reported as that record — `type: ownership`,
49
+ * the record's id, its agent document as path — because that is what a routing command expects
50
+ * to receive and what `ak-docs query ownership <id>` accepts. Everything else is reported by its
51
+ * entity id and its kind.
52
+ */
53
+ type Presented = { readonly agentDocs: ReadonlyMap<string, string>; readonly curatedIds: ReadonlyMap<string, string> }
54
+
55
+ const presentation = (index: DocBridgeIndexV1, known: Presented, item: RankedEntry): Pick<SearchMatch, 'type' | 'id' | 'path'> => {
56
+ const { entry } = item
57
+ // The unit an ownership record names, or the agent document that record points at: one answer.
58
+ const ownershipId = entry.ownershipId ?? (entry.kind === 'document' ? known.agentDocs.get(entry.path) : undefined)
59
+ if (ownershipId) {
60
+ const record = index.lookup?.ownership?.[ownershipId]
61
+ return { type: 'ownership', id: ownershipId, path: record?.agentDoc ?? entry.path }
62
+ }
63
+ // A curated sidecar keeps the id its frontmatter gave it: that is the id `doc.get` and llms.txt know.
64
+ const curatedId = entry.kind === 'document' ? known.curatedIds.get(entry.path) : undefined
65
+ return { type: entry.kind, id: curatedId ?? entry.id, path: entry.path }
66
+ }
67
+
68
+ const projectedSearch = (index: DocBridgeIndexV1, term: string, limit: number, options: SearchOptions): SearchMatch[] => {
69
+ const projection = index.projection
70
+ if (!projection) return []
71
+ const ranked = rankRetrieval(projection, term, { limit: Math.max(limit * 2, 40), ...(options.agent ? { agent: true } : {}) })
72
+ /*
73
+ * Which ownership record an agent document stands for. A record attached to a projected unit
74
+ * is authoritative; a corpus-derived seed that points at the same document is the same unit
75
+ * under another name and must not win the presentation over it.
76
+ */
77
+ const agentDocs = new Map<string, string>()
78
+ for (const entry of projection.entries) {
79
+ const record = entry.ownershipId ? index.lookup?.ownership?.[entry.ownershipId] : undefined
80
+ if (record?.agentDoc && !agentDocs.has(record.agentDoc)) agentDocs.set(record.agentDoc, record.id)
81
+ }
82
+ for (const record of Object.values(index.lookup?.ownership ?? {})) {
83
+ if (record.agentDoc && !agentDocs.has(record.agentDoc)) agentDocs.set(record.agentDoc, record.id)
84
+ }
85
+ const known: Presented = {
86
+ agentDocs,
87
+ curatedIds: new Map(index.knowledge.filter((entry) => !isProjectedEntry(entry)).map((entry) => [entry.path, entry.id])),
88
+ }
89
+ const byPath = new Map<string, SearchMatch>()
90
+
91
+ for (const item of ranked) {
92
+ const presented = presentation(index, known, item)
93
+ const match: SearchMatch = {
94
+ ...presented,
95
+ ...(item.entry.summary ? { summary: item.entry.summary } : {}),
96
+ score: item.score,
97
+ entityId: item.entry.id,
98
+ kind: item.entry.kind,
99
+ provenance: item.entry.provenance,
100
+ confidence: item.confidence,
101
+ evidence: [{ source: evidenceSource(item.entry.kind), path: item.entry.path, contentHash: item.entry.contentHash }],
102
+ ...(options.explain ? { explain: item.explanation } : {}),
103
+ }
104
+ const existing = byPath.get(match.path)
105
+ if (!existing || match.score > existing.score || (match.score === existing.score && match.type === 'ownership' && existing.type !== 'ownership')) {
106
+ byPath.set(match.path, match)
28
107
  }
29
108
  }
30
- return score
109
+
110
+ return [...byPath.values()].sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)).slice(0, limit)
31
111
  }
32
112
 
33
- /** Exact / near-exact identity boost so "core" ranks the core package over mentions of @agentskit/core. */
34
- const identityBoost = (id: string, path: string, tokens: readonly string[], term: string): number => {
35
- const idLower = id.toLowerCase()
36
- const termLower = term.toLowerCase().trim()
37
- const base = path.split('/').pop()?.replace(/\.mdx?$/i, '').toLowerCase() ?? ''
38
- let boost = 0
113
+ const evidenceSource = (kind: RetrievalKind): Evidence['source'] =>
114
+ kind === 'module' ? 'code' : kind === 'document' ? 'documentation' : kind === 'intent' || kind === 'change' ? 'configuration' : 'derived'
39
115
 
40
- if (idLower === termLower || base === termLower) boost += 200
41
- if (tokens.length === 1 && (idLower === tokens[0] || base === tokens[0])) boost += 200
116
+ /*
117
+ * ---------------------------------------------------------------------------------------------
118
+ * The pre-projection ranking, kept verbatim for indexes that carry no projection.
119
+ * ---------------------------------------------------------------------------------------------
120
+ */
42
121
 
43
- for (const token of tokens) {
44
- if (idLower === token) boost += 120
45
- else if (idLower.startsWith(`${token}-`) || idLower.endsWith(`-${token}`)) boost += 40
46
- else if (idLower.includes(token) && idLower.length <= token.length + 4) boost += 30
47
- if (base === token) boost += 100
48
- }
122
+ const BM25_SCALE = 50
123
+ const EXACT_IDENTITY = 240
124
+ const EXACT_SYMBOL = 200
125
+ const DIRECTORY_MATCH = 120
126
+ const TOKEN_ID = 120
127
+ const TOKEN_BASE = 100
128
+ const TOKEN_SYMBOL = 90
129
+ const SHORT_ID_BONUS = 40
130
+ const CURATED_FACTOR = 1.15
131
+ const OWNERSHIP_FACTOR = 1.1
132
+ const ROUTE_TITLE_FACTOR = 1.6
133
+ const CHANGE_INTENT_FACTOR = 1.4
134
+ const CHANGE_WITHOUT_INTENT_FACTOR = 0.15
135
+ const KIND_FACTOR = 1.15
136
+ const PARTIAL_ID_FACTOR = 1.2
137
+ const RELEVANCE_FLOOR = 1 / 3
49
138
 
50
- // Prefer short ids when term is the id (package name)
51
- if (tokens.includes(idLower)) boost += Math.max(0, 40 - idLower.length)
139
+ const LEGACY_WEIGHTS = { id: 8, symbols: 7, title: 6, path: 4, tags: 3, description: 2, body: 1 } as const
52
140
 
53
- return boost
141
+ const PACKAGE_INTENT =
142
+ /\b(package|module|pkg|edit|change|where|owns?|ownership|handoff|start)\b|\b(?:pacote|pacotes|modulo|modulos|onde|quem|dono|donos|responsavel|responsaveis|comec\w*|inici\w*|edit\w*|mud\w*|alter\w*)/i
143
+ const CHANGE_INTENT =
144
+ /\b(change|edit|modify|update|fix|migrate|replace)\b|\b(?:alter\w*|mud\w*|modific\w*|edit\w*|atualiz\w*|corrig\w*|migr\w*|substitu\w*|troc\w*)/i
145
+ const SYMBOL_SHAPED = /[a-z0-9][A-Z]|^[A-Za-z_$][A-Za-z0-9_$]{2,}$/
146
+ const PATH_SHAPED = /\/|\.[A-Za-z]{1,4}$/
147
+
148
+ type CandidateFields = {
149
+ readonly id: string
150
+ readonly title: string
151
+ readonly path: string
152
+ readonly description?: string
153
+ readonly body?: string
154
+ readonly tags?: readonly string[]
155
+ readonly symbols?: readonly string[]
54
156
  }
55
157
 
56
- const preferOwnership = (term: string): boolean =>
57
- PACKAGE_INTENT.test(term) || /^(where|how).*(edit|change|package|module)/i.test(term)
158
+ type Candidate = {
159
+ readonly ref: string
160
+ readonly match: Omit<SearchMatch, 'score'>
161
+ readonly fields: CandidateFields
162
+ readonly entryType?: string
163
+ }
58
164
 
59
- export const searchIndex = (index: DocBridgeIndexV1, term: string, limit = 20): SearchMatch[] => {
60
- const tokens = tokenize(term)
61
- if (!tokens.length) return []
165
+ const pathBase = (path: string): string => foldAccents((path.split('/').pop() ?? '').replace(/\.[A-Za-z0-9]+$/, '').toLowerCase())
62
166
 
63
- const wantOwnership = preferOwnership(term)
64
- const byPath = new Map<string, SearchMatch>()
167
+ const preferOwnership = (term: string): boolean => PACKAGE_INTENT.test(term) || /^(where|how).*(edit|change|package|module)/i.test(term)
65
168
 
66
- const consider = (match: SearchMatch) => {
67
- const key = match.path
68
- const existing = byPath.get(key)
69
- if (!existing) {
70
- byPath.set(key, match)
71
- return
72
- }
73
- // Prefer ownership over knowledge for same path; else higher score
74
- const prefer =
75
- match.score > existing.score ||
76
- (match.score === existing.score && match.type === 'ownership' && existing.type !== 'ownership') ||
77
- (wantOwnership && match.type === 'ownership' && existing.type !== 'ownership' && match.score >= existing.score - 20)
78
- if (prefer) byPath.set(key, match)
169
+ const titleCoversQuery = (title: string, tokens: readonly string[]): boolean =>
170
+ tokens.length > 1 && tokens.every((token) => hasSearchToken(foldAccents(title.toLowerCase()), token))
171
+
172
+ const identityBoost = (fields: CandidateFields, tokens: readonly string[], term: string): number => {
173
+ const idLower = foldAccents(fields.id.toLowerCase())
174
+ const pathLower = foldAccents(fields.path.toLowerCase())
175
+ const termLower = foldAccents(term.toLowerCase().trim())
176
+ const base = pathBase(fields.path)
177
+ const symbols = new Set((fields.symbols ?? []).map((symbol) => foldAccents(symbol.toLowerCase())))
178
+ let boost = 0
179
+ if (idLower === termLower || base === termLower || pathLower === termLower) boost += EXACT_IDENTITY
180
+ if (symbols.has(termLower)) boost += EXACT_SYMBOL
181
+ if (termLower && (pathLower.startsWith(`${termLower}/`) || pathLower.includes(`/${termLower}/`))) boost += DIRECTORY_MATCH
182
+ for (const token of tokens) {
183
+ if (idLower === token) boost += TOKEN_ID
184
+ if (base === token) boost += TOKEN_BASE
185
+ if (symbols.has(token)) boost += TOKEN_SYMBOL
79
186
  }
187
+ if (tokens.includes(idLower)) boost += Math.max(0, SHORT_ID_BONUS - idLower.length)
188
+ return boost
189
+ }
80
190
 
81
- for (const entry of index.knowledge) {
82
- const body = (entry as { body?: string }).body ?? ''
83
- const hay = `${entry.id} ${entry.title} ${entry.description ?? ''} ${entry.path} ${body}`.toLowerCase()
84
- let score = scoreHay(tokens, hay, 1)
85
- score += identityBoost(entry.id, entry.path, tokens, term)
86
- if (score > 0) {
87
- consider({
88
- type: 'knowledge',
89
- id: entry.id,
90
- path: entry.path,
91
- ...(entry.description ? { summary: entry.description } : {}),
92
- score,
93
- })
191
+ const candidateRef = (kind: string, position: number, id: string): string => `${kind}#${position}#${id}`
192
+
193
+ const knowledgeCandidates = (index: DocBridgeIndexV1): Candidate[] =>
194
+ index.knowledge.map((entry, position) => ({
195
+ ref: candidateRef('knowledge', position, entry.id),
196
+ match: { type: 'knowledge', id: entry.id, path: entry.path, ...(entry.description ? { summary: entry.description } : {}) },
197
+ fields: {
198
+ id: entry.id,
199
+ title: entry.title,
200
+ path: entry.path,
201
+ ...(entry.description ? { description: entry.description } : {}),
202
+ ...(entry.body ? { body: entry.body } : {}),
203
+ ...(entry.tags ? { tags: entry.tags } : {}),
204
+ ...(entry.symbols ? { symbols: entry.symbols } : {}),
205
+ },
206
+ entryType: entry.type,
207
+ }))
208
+
209
+ const ownershipCandidates = (index: DocBridgeIndexV1): Candidate[] =>
210
+ Object.entries(index.lookup?.ownership ?? {}).map(([id, owner], position) => {
211
+ const agentDoc = owner.agentDoc ? index.knowledge.find((entry) => entry.path === owner.agentDoc) : undefined
212
+ const description = owner.purpose ?? agentDoc?.description
213
+ return {
214
+ ref: candidateRef('ownership', position, id),
215
+ match: { type: 'ownership', id, path: owner.agentDoc ?? owner.path, ...(owner.purpose ? { summary: owner.purpose } : {}) },
216
+ fields: {
217
+ id,
218
+ title: agentDoc?.title ?? id,
219
+ path: [owner.path, owner.agentDoc, owner.humanDoc].filter(Boolean).join(' '),
220
+ ...(description ? { description } : {}),
221
+ ...(agentDoc?.body ? { body: agentDoc.body } : {}),
222
+ tags: [owner.group, owner.layer, 'ownership'].filter((value): value is string => Boolean(value)),
223
+ },
224
+ entryType: 'ownership',
94
225
  }
226
+ })
227
+
228
+ const intentCandidates = (index: DocBridgeIndexV1): Candidate[] =>
229
+ Object.values(index.lookup?.intents ?? {}).map((intent, position) => ({
230
+ ref: candidateRef('intent', position, intent.id),
231
+ match: { type: 'intent', id: intent.id, path: intent.paths[0] ?? '', summary: intent.title },
232
+ fields: { id: intent.id, title: intent.title, path: intent.paths.join(' '), tags: ['intent'] },
233
+ entryType: 'intent',
234
+ }))
235
+
236
+ const changeCandidates = (index: DocBridgeIndexV1): Candidate[] =>
237
+ Object.values(index.lookup?.changes ?? {}).map((change, position) => ({
238
+ ref: candidateRef('change', position, change.id),
239
+ match: { type: 'change', id: change.id, path: change.startHere, summary: change.title },
240
+ fields: { id: change.id, title: change.title, path: change.startHere, tags: ['change', ...(change.relatedPackages ?? [])] },
241
+ entryType: 'change',
242
+ }))
243
+
244
+ const partialIdMatch = (id: string, tokens: readonly string[]): boolean => {
245
+ const idLower = foldAccents(id.toLowerCase())
246
+ return tokens.some(
247
+ (token) => idLower !== token && (idLower.startsWith(`${token}-`) || idLower.endsWith(`-${token}`) || (idLower.includes(token) && idLower.length <= token.length + 4)),
248
+ )
249
+ }
250
+
251
+ const priorFactor = (candidate: Candidate, tokens: readonly string[], term: string, wantOwnership: boolean): number => {
252
+ const type = candidate.match.type
253
+ const trimmed = term.trim()
254
+ const covered = titleCoversQuery(candidate.fields.title, tokens)
255
+ let factor = 1
256
+ if (!isProjectedEntry({ type: candidate.entryType ?? '' })) factor *= CURATED_FACTOR
257
+ if (partialIdMatch(candidate.fields.id, tokens)) factor *= PARTIAL_ID_FACTOR
258
+ if (type === 'ownership' && wantOwnership) factor *= OWNERSHIP_FACTOR
259
+ if ((type === 'intent' || type === 'change') && covered) factor *= ROUTE_TITLE_FACTOR
260
+ if (type === 'change') factor *= CHANGE_INTENT.test(term) ? CHANGE_INTENT_FACTOR : covered ? 1 : CHANGE_WITHOUT_INTENT_FACTOR
261
+ const oneWord = !/\s/.test(trimmed)
262
+ const looksLikeSymbol = oneWord && SYMBOL_SHAPED.test(trimmed)
263
+ const looksLikePath = oneWord && PATH_SHAPED.test(trimmed)
264
+ if ((looksLikeSymbol || looksLikePath) && candidate.entryType === 'module') factor *= KIND_FACTOR
265
+ if (!looksLikeSymbol && !looksLikePath && tokens.length >= 3 && candidate.entryType === 'document') factor *= KIND_FACTOR
266
+ return factor
267
+ }
268
+
269
+ type PreparedIndex = { readonly byRef: ReadonlyMap<string, Candidate>; readonly bm25: Bm25Index }
270
+
271
+ const prepared = new WeakMap<DocBridgeIndexV1, PreparedIndex>()
272
+
273
+ const prepare = (index: DocBridgeIndexV1): PreparedIndex | undefined => {
274
+ const cached = prepared.get(index)
275
+ if (cached) return cached
276
+ const candidates = [...knowledgeCandidates(index), ...ownershipCandidates(index), ...intentCandidates(index), ...changeCandidates(index)]
277
+ if (!candidates.length) return undefined
278
+ const inputs: Bm25Input[] = candidates.map((candidate) => ({ ref: candidate.ref, fields: candidate.fields }))
279
+ const weights = { ...LEGACY_WEIGHTS, ...Object.fromEntries(Object.entries(index.retrieval?.weights ?? {}).filter(([field]) => field in LEGACY_WEIGHTS)) }
280
+ const value: PreparedIndex = {
281
+ byRef: new Map(candidates.map((candidate) => [candidate.ref, candidate])),
282
+ bm25: buildBm25Index(inputs, weights, index.retrieval?.params ?? {}),
95
283
  }
284
+ prepared.set(index, value)
285
+ return value
286
+ }
96
287
 
97
- for (const [id, owner] of Object.entries(index.lookup?.ownership ?? {})) {
98
- const path = owner.agentDoc ?? owner.path
99
- const hay = `${id} ${owner.path} ${owner.purpose ?? ''} ${owner.group ?? ''} ${owner.agentDoc ?? ''} ${owner.humanDoc ?? ''}`.toLowerCase()
100
- let score = scoreHay(tokens, hay, 2)
101
- score += identityBoost(id, path, tokens, term)
102
- // Ownership is primary for routing questions
103
- if (wantOwnership) score += 25
104
- score += 15 // slight base preference for actionable ownership targets
105
- if (score > 0) {
106
- consider({
107
- type: 'ownership',
108
- id,
109
- path,
110
- ...(owner.purpose ? { summary: owner.purpose } : {}),
111
- score,
112
- })
288
+ const legacySearch = (index: DocBridgeIndexV1, term: string, limit: number): SearchMatch[] => {
289
+ const tokens = searchTokens(term)
290
+ if (!tokens.length) return []
291
+ const ready = prepare(index)
292
+ if (!ready) return []
293
+ const { byRef, bm25 } = ready
294
+ const hits = bm25Search(bm25, tokens)
295
+ const wantOwnership = preferOwnership(term)
296
+ const byPath = new Map<string, SearchMatch>()
297
+
298
+ for (const hit of hits) {
299
+ const candidate = byRef.get(hit.ref)
300
+ if (!candidate) continue
301
+ const score = hit.score * BM25_SCALE * priorFactor(candidate, tokens, term, wantOwnership) + identityBoost(candidate.fields, tokens, term)
302
+ const match: SearchMatch = { ...candidate.match, score }
303
+ const existing = byPath.get(match.path)
304
+ if (!existing || match.score > existing.score || (match.score === existing.score && match.type === 'ownership' && existing.type !== 'ownership')) {
305
+ byPath.set(match.path, match)
113
306
  }
114
307
  }
115
308
 
116
- return [...byPath.values()].sort((a, b) => {
117
- if (b.score !== a.score) return b.score - a.score
118
- // Tie-break: exact id match, then ownership, then shorter id
119
- const aExact = tokens.includes(a.id.toLowerCase()) ? 1 : 0
120
- const bExact = tokens.includes(b.id.toLowerCase()) ? 1 : 0
121
- if (bExact !== aExact) return bExact - aExact
122
- if (a.type === 'ownership' && b.type !== 'ownership') return -1
123
- if (b.type === 'ownership' && a.type !== 'ownership') return 1
124
- return a.id.localeCompare(b.id)
125
- }).slice(0, limit)
309
+ const ranked = [...byPath.values()]
310
+ .sort((a, b) => {
311
+ if (b.score !== a.score) return b.score - a.score
312
+ const aExact = tokens.includes(foldAccents(a.id.toLowerCase())) ? 1 : 0
313
+ const bExact = tokens.includes(foldAccents(b.id.toLowerCase())) ? 1 : 0
314
+ if (bExact !== aExact) return bExact - aExact
315
+ if (a.type === 'ownership' && b.type !== 'ownership') return -1
316
+ if (b.type === 'ownership' && a.type !== 'ownership') return 1
317
+ return a.id.localeCompare(b.id)
318
+ })
319
+ .slice(0, limit)
320
+
321
+ const best = ranked[0]?.score ?? 0
322
+ return best > 0 ? ranked.filter((match) => match.score >= best * RELEVANCE_FLOOR) : ranked
126
323
  }
@@ -0,0 +1,155 @@
1
+ const SEARCH_TOKEN_SEPARATOR = /[^\p{L}\p{N}@/_-]+/gu
2
+
3
+ /**
4
+ * Bumped whenever a stopword list or the token expansion changes. The index records it, so a
5
+ * ranking that depended on a different list is visible rather than silently incomparable.
6
+ */
7
+ export const SEARCH_LEXICON_VERSION = 1 as const
8
+
9
+ /**
10
+ * Words that carry no retrieval signal because they appear in nearly every sentence of the
11
+ * corpus. Without them `search and` scores every record that has a prose summary.
12
+ *
13
+ * Portuguese is here for the same reason English is: the repository targets non-English
14
+ * documentation, and a query in one language must be ranked by the same rules as the other.
15
+ * Single characters never reach these sets, since tokens shorter than two characters are dropped.
16
+ */
17
+ const ENGLISH_STOPWORDS = [
18
+ 'about', 'after', 'again', 'all', 'also', 'an', 'and', 'any', 'are', 'as', 'at', 'be', 'been',
19
+ 'before', 'being', 'between', 'both', 'but', 'by', 'can', 'could', 'did', 'do', 'does', 'doing',
20
+ 'done', 'during', 'each', 'either', 'else', 'for', 'from', 'had', 'has', 'have', 'he', 'her',
21
+ 'here', 'hers', 'him', 'his', 'how', 'if', 'in', 'into', 'is', 'it', 'its', 'just', 'me', 'more',
22
+ 'most', 'much', 'must', 'my', 'no', 'nor', 'not', 'of', 'off', 'on', 'once', 'only', 'or',
23
+ 'other', 'our', 'out', 'over', 'own', 'same', 'she', 'should', 'so', 'some', 'such', 'than',
24
+ 'that', 'the', 'their', 'them', 'then', 'there', 'these', 'they', 'this', 'those', 'through',
25
+ 'to', 'too', 'under', 'until', 'up', 'us', 'very', 'was', 'we', 'were', 'what', 'when', 'where',
26
+ 'which', 'while', 'who', 'whom', 'why', 'will', 'with', 'would', 'you', 'your',
27
+ ] as const
28
+
29
+ const PORTUGUESE_STOPWORDS = [
30
+ 'ainda', 'ao', 'aos', 'aquela', 'aquelas', 'aquele', 'aqueles', 'aquilo', 'as', 'até', 'com',
31
+ 'como', 'da', 'das', 'de', 'dela', 'delas', 'dele', 'deles', 'depois', 'do', 'dos', 'ela',
32
+ 'elas', 'ele', 'eles', 'em', 'entre', 'era', 'eram', 'essa', 'essas', 'esse', 'esses', 'esta',
33
+ 'estas', 'este', 'estes', 'está', 'estão', 'eu', 'fica', 'ficam', 'foi', 'foram', 'isso',
34
+ 'isto', 'já', 'lhe', 'lhes', 'mais', 'mas', 'me', 'menos', 'mesmo', 'meu', 'minha', 'muito',
35
+ 'na', 'nas', 'nem', 'no', 'nos', 'nossa', 'nosso', 'num', 'numa', 'não', 'onde', 'os', 'ou',
36
+ 'para', 'pela', 'pelas', 'pelo', 'pelos', 'per', 'por', 'porque', 'pra', 'qual', 'quais',
37
+ 'quando', 'que', 'quem', 'se', 'sem', 'ser', 'seu', 'seus', 'sobre', 'sua', 'suas', 'são',
38
+ 'também', 'tem', 'ter', 'teu', 'toda', 'todas', 'todo', 'todos', 'tua', 'têm', 'um', 'uma',
39
+ 'umas', 'uns', 'vira', 'você', 'vocês',
40
+ ] as const
41
+
42
+ /**
43
+ * Strip diacritics so `reconciliacao` and `reconciliação` are the same term.
44
+ *
45
+ * Non-English documentation is written with accents and queried without them at least as often as
46
+ * the reverse, and a retrieval path that treats the two as different words fails a question it has
47
+ * the answer to. Folding happens on both sides — the indexed text and the query — so the two can
48
+ * never disagree.
49
+ */
50
+ export const foldAccents = (value: string): string =>
51
+ value.normalize('NFD').replace(/\p{Diacritic}/gu, '').normalize('NFC')
52
+
53
+ export const SEARCH_STOPWORDS: ReadonlySet<string> = new Set<string>(
54
+ [...ENGLISH_STOPWORDS, ...PORTUGUESE_STOPWORDS].map(foldAccents),
55
+ )
56
+
57
+ export const isSearchStopword = (token: string): boolean => SEARCH_STOPWORDS.has(foldAccents(token))
58
+
59
+ /** Keep the deterministic search path usable for non-English documentation too. */
60
+ export const tokenizeSearchText = (value: string): string[] =>
61
+ value
62
+ .toLowerCase()
63
+ .split(SEARCH_TOKEN_SEPARATOR)
64
+ .filter((token) => token.length >= 2)
65
+
66
+ const CAMEL_BOUNDARY = /(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/g
67
+
68
+ const CJK = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u
69
+ const CJK_RUN = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+/gu
70
+
71
+ /**
72
+ * Characters and character bigrams of a CJK run.
73
+ *
74
+ * CJK text has no spaces, so a whole sentence arrives as one token and an exact-term index would
75
+ * never match a query for two of its characters. Indexing unigrams and bigrams on both sides is
76
+ * the standard answer: the bigram carries the signal, the unigram keeps a one-character query
77
+ * answerable.
78
+ */
79
+ const cjkGrams = (run: string): string[] => {
80
+ const characters = [...run]
81
+ const grams = [...characters]
82
+ for (let position = 0; position + 1 < characters.length; position += 1) {
83
+ grams.push(`${characters[position]}${characters[position + 1]}`)
84
+ }
85
+ return grams
86
+ }
87
+
88
+ /**
89
+ * A code identifier or path, plus the words inside it.
90
+ *
91
+ * `reconcileKnowledge` yields the whole identifier and `reconcile` and `knowledge`, so an agent
92
+ * that types the exact symbol gets an exact match while a human who half-remembers it still
93
+ * lands nearby. `src/mcp/server.ts` yields the whole path and each segment without its extension.
94
+ */
95
+ export const expandSearchToken = (token: string): string[] => {
96
+ const whole = foldAccents(token.toLowerCase())
97
+ const parts = foldAccents(token)
98
+ .replace(CAMEL_BOUNDARY, ' ')
99
+ .split(/[^\p{L}\p{N}]+/u)
100
+ .map((part) => part.toLowerCase())
101
+ .filter((part) => part.length >= 2 && part !== whole)
102
+ const grams = CJK.test(token) ? [...token.matchAll(CJK_RUN)].flatMap(([run]) => cjkGrams(run)) : []
103
+ return [...new Set([whole, ...parts, ...grams])]
104
+ }
105
+
106
+ /**
107
+ * Collapse a plural to its singular so `schema` finds `schemas`.
108
+ *
109
+ * The substring matcher this path replaced accepted an optional plural suffix, and losing that
110
+ * would have made an exact-term index quietly worse at the most ordinary query there is. These
111
+ * are the safe rules only: applied to both the indexed text and the query, an imperfect stem
112
+ * still matches itself, so the worst case is a missed relation rather than a wrong one.
113
+ */
114
+ export const singularizeSearchToken = (token: string): string => {
115
+ if (token.length <= 3) return token
116
+ if (token.endsWith('ies')) return `${token.slice(0, -3)}y`
117
+ if (/(?:sses|xes|ches|shes|zes)$/.test(token)) return token.slice(0, -2)
118
+ if (/(?:ss|us|is|os)$/.test(token)) return token
119
+ if (token.endsWith('es') && token.length > 4) return token.slice(0, -1)
120
+ if (token.endsWith('s')) return token.slice(0, -1)
121
+ return token
122
+ }
123
+
124
+ export type SearchTokenizeOptions = {
125
+ /** Drop stopwords. On for queries and for indexed text; off only when a caller needs raw tokens. */
126
+ readonly dropStopwords?: boolean
127
+ /** Also emit the words inside an identifier or path. */
128
+ readonly expand?: boolean
129
+ }
130
+
131
+ /**
132
+ * The tokenizer both indexing and querying use, so a term can never be present on one side and
133
+ * absent on the other.
134
+ */
135
+ export const searchTokens = (value: string, options: SearchTokenizeOptions = {}): string[] => {
136
+ const dropStopwords = options.dropStopwords ?? true
137
+ const expand = options.expand ?? true
138
+ const tokens: string[] = []
139
+ for (const raw of value.split(SEARCH_TOKEN_SEPARATOR)) {
140
+ if (!raw) continue
141
+ for (const token of expand ? expandSearchToken(raw) : [raw.toLowerCase()]) {
142
+ if (token.length < 2 && !CJK.test(token)) continue
143
+ if (dropStopwords && isSearchStopword(token)) continue
144
+ tokens.push(CJK.test(token) ? token : singularizeSearchToken(token))
145
+ }
146
+ }
147
+ return tokens
148
+ }
149
+
150
+ export const hasSearchToken = (hay: string, token: string): boolean => {
151
+ // CJK text commonly has no whitespace; substring matching is the native word boundary there.
152
+ if (/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u.test(token)) return hay.includes(token)
153
+ const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
154
+ return new RegExp(`(?:^|[^\\p{L}\\p{N}])${escaped}(?:s|es)?(?:[^\\p{L}\\p{N}]|$)`, 'u').test(hay)
155
+ }