@agentskit/doc-bridge 1.7.45 → 1.10.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 +488 -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 +15871 -6218
  10. package/dist/cli/program.js.map +1 -1
  11. package/dist/config/index.d.ts +1 -1
  12. package/dist/config/index.js +95 -6
  13. package/dist/config/index.js.map +1 -1
  14. package/dist/{index-BUL0q7s8.d.ts → index-7wYGbllW.d.ts} +134 -1
  15. package/dist/index.d.ts +9880 -1062
  16. package/dist/index.js +16143 -6127
  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 +157 -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 +34 -15
  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 +126 -2
  139. package/src/conformance/documentation-standard-v1.ts +8 -6
  140. package/src/discovery/areas.ts +197 -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 +485 -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
@@ -0,0 +1,495 @@
1
+ import { basename, extname } from 'node:path'
2
+
3
+ import type { DocBridgeConfigV1 } from '../config/schema.js'
4
+ import { GRAPH_ANALYZER_VERSION, canonicality } from '../graph/build.js'
5
+ import { indexConfigurationHash } from '../index-builder/project-corpus.js'
6
+ import { sha256NormalizedV1 } from '../index-builder/content-hash.js'
7
+ import { extractSearchBody } from '../lib/markdown.js'
8
+ import { SEARCH_LEXICON_VERSION } from '../query/text.js'
9
+ import type { KnowledgeEntry } from '../schemas/doc-bridge-index.js'
10
+ import type { DiscoverySnapshotV1, KnowledgeEntity, KnowledgeRelation, Provenance } from '../schemas/knowledge.js'
11
+ import {
12
+ RetrievalIndexV1Schema,
13
+ type Audience,
14
+ type Confidence,
15
+ type RetrievalEdge,
16
+ type RetrievalEntry,
17
+ type RetrievalFields,
18
+ type RetrievalIndexV1,
19
+ type RetrievalKind,
20
+ } from '../schemas/retrieval-index.js'
21
+ import { BM25_VERSION, buildBm25Index } from './bm25.js'
22
+ import { resolveSearchParams, resolveSearchWeights } from './weights.js'
23
+
24
+ /**
25
+ * Project the snapshot into the index retrieval reads.
26
+ *
27
+ * Retrieval used to run a scanner of its own: it walked the repository, parsed every module and
28
+ * document a second time, and produced records that shared nothing with the snapshot but a file
29
+ * path. Two views of one repository, built by two pipelines, could disagree — and did, which is
30
+ * how a search could return a module the snapshot said did not exist.
31
+ *
32
+ * This is a function, not a scan. Every entry is a snapshot entity (or a route the configuration
33
+ * declares); every content hash is the entity's own; every graph number is computed from the
34
+ * snapshot's relations. The one thing read from disk is the body text of a document the snapshot
35
+ * already names, because a 4 KB search excerpt does not belong in a snapshot — and that read is
36
+ * verified against the entity's content hash, so a file that changed since the scan is projected
37
+ * from what the snapshot says about it rather than from what is on disk now.
38
+ */
39
+
40
+ export const RETRIEVAL_PROJECTION_VERSION = 1 as const
41
+
42
+ /** Documentation body kept for search. Long enough to answer a question, short enough to ship. */
43
+ export const DOCUMENT_BODY_LIMIT = 4_000
44
+ const MAX_EDGES = 64
45
+ const MAX_ALIASES = 32
46
+ const MAX_TAGS = 32
47
+ const MAX_SYMBOLS = 256
48
+ /** A summary is what a result carries into an agent's context; the cap is a token budget, not a display limit. */
49
+ const MAX_SUMMARY = 400
50
+
51
+ /** The overlay hash when there is no overlay: the hash of an empty accepted set. */
52
+ export const EMPTY_OVERLAY_HASH = sha256NormalizedV1({ accepted: [] })
53
+
54
+ const CONFIDENCE_RANK: Readonly<Record<Confidence, number>> = { observed: 0, declared: 1, fuzzy: 2, proposed: 3 }
55
+
56
+ /** The weaker of two confidences: a chain is as trustworthy as its least trustworthy link. */
57
+ export const weakerConfidence = (a: Confidence, b: Confidence): Confidence =>
58
+ CONFIDENCE_RANK[a] >= CONFIDENCE_RANK[b] ? a : b
59
+
60
+ /** What a relation's confidence is: its provenance, unless it resolved by similarity. */
61
+ export const relationConfidence = (relation: Pick<KnowledgeRelation, 'provenance' | 'metadata'>): Confidence =>
62
+ relation.metadata?.confidence === 'fuzzy' ? 'fuzzy' : relation.provenance
63
+
64
+ /** The routes the configuration declares. Passed in rather than re-read, so the projection stays a function. */
65
+ export type RetrievalRoutes = {
66
+ readonly ownership?: Readonly<
67
+ Record<
68
+ string,
69
+ {
70
+ readonly id: string
71
+ readonly path: string
72
+ readonly group?: string
73
+ readonly layer?: string
74
+ readonly purpose?: string
75
+ readonly agentDoc?: string
76
+ readonly humanDoc?: string
77
+ }
78
+ >
79
+ >
80
+ readonly intents?: Readonly<Record<string, { readonly id: string; readonly title: string; readonly paths: readonly string[] }>>
81
+ readonly changes?: Readonly<
82
+ Record<string, { readonly id: string; readonly title: string; readonly startHere: string; readonly relatedPackages?: readonly string[] }>
83
+ >
84
+ }
85
+
86
+ /** A curated sidecar the corpus scan found: it lends its id and summary to the document it is. */
87
+ export type CuratedDocument = {
88
+ readonly id: string
89
+ readonly path: string
90
+ readonly title?: string
91
+ readonly description?: string
92
+ }
93
+
94
+ /**
95
+ * The accepted enrichment overlay, as the projection reads it.
96
+ *
97
+ * Already expired against the snapshot by `projectEnrichmentOverlay`: every map here names an
98
+ * entity whose content hash still matches the entry that describes it. The hash is part of the
99
+ * projection's identity; everything else is additive — an alias joins the entity's own, a
100
+ * summary fills a gap, a relation is one more edge with `provenance: proposed` — and nothing
101
+ * here can remove or rewrite what the snapshot observed.
102
+ */
103
+ export type RetrievalOverlayInput = {
104
+ readonly hash: string
105
+ /** Per-entry share of the bounded agent weight, 0..1. */
106
+ readonly signals?: ReadonlyMap<string, number>
107
+ readonly aliases?: ReadonlyMap<string, readonly string[]>
108
+ /** Used only when the entity has no summary of its own; ranked in either case. */
109
+ readonly summaries?: ReadonlyMap<string, string>
110
+ /** Entity id → the scope it is canonical for. */
111
+ readonly canonical?: ReadonlyMap<string, string>
112
+ readonly intents?: readonly { readonly id: string; readonly title: string; readonly paths: readonly string[] }[]
113
+ /** Accepted relations, `provenance: proposed`. Added next to the observed ones, never in place of any. */
114
+ readonly relations?: readonly KnowledgeRelation[]
115
+ }
116
+
117
+ export type ProjectRetrievalOptions = {
118
+ readonly snapshot: Pick<DiscoverySnapshotV1, 'contentHash' | 'entities' | 'relations'>
119
+ readonly config: DocBridgeConfigV1 | undefined
120
+ readonly routes?: RetrievalRoutes
121
+ readonly curated?: readonly CuratedDocument[]
122
+ readonly overlay?: RetrievalOverlayInput
123
+ /** Body text of a document entity, by path. Return `undefined` when the file is not readable. */
124
+ readonly readDocument?: (path: string) => string | undefined
125
+ }
126
+
127
+ const stringList = (value: unknown): readonly string[] =>
128
+ Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
129
+
130
+ const unique = (values: readonly (string | undefined)[], limit: number): string[] =>
131
+ [...new Set(values.filter((value): value is string => Boolean(value)))].slice(0, limit)
132
+
133
+ const audienceOf = (entity: KnowledgeEntity): Audience | undefined => {
134
+ const classification = entity.metadata?.classification
135
+ if (classification === 'human-and-agent' || classification === 'agent' || classification === 'human') return classification
136
+ if (classification === 'project') return 'human'
137
+ return undefined
138
+ }
139
+
140
+ const headingTexts = (entity: KnowledgeEntity): readonly string[] =>
141
+ Array.isArray(entity.metadata?.headings)
142
+ ? entity.metadata.headings
143
+ .map((heading) => (heading && typeof heading === 'object' && 'text' in heading ? (heading as { text: unknown }).text : undefined))
144
+ .filter((text): text is string => typeof text === 'string')
145
+ : []
146
+
147
+ const TEST_MODULE_PATTERN = /(?:\.test|\.spec|__tests__)/
148
+
149
+ /** The tags the legacy index carried per projected entry, so its readers see the same record. */
150
+ const tagsFor = (kind: RetrievalKind, entity: KnowledgeEntity | undefined, path: string, extra: readonly (string | undefined)[]): string[] =>
151
+ unique(
152
+ [
153
+ kind,
154
+ ...(kind === 'document' ? [String(entity?.metadata?.classification ?? '') || undefined, String(entity?.metadata?.frontmatter && typeof entity.metadata.frontmatter === 'object' && 'type' in entity.metadata.frontmatter ? (entity.metadata.frontmatter as { type?: unknown }).type ?? '' : '') || undefined] : []),
155
+ ...(kind === 'module'
156
+ ? [extname(path).replace('.', '') || undefined, TEST_MODULE_PATTERN.test(path) ? 'test' : undefined, path.split('/').slice(0, -1).pop()]
157
+ : []),
158
+ ...extra,
159
+ ],
160
+ MAX_TAGS,
161
+ )
162
+
163
+ type EntryDraft = Omit<RetrievalEntry, 'fields' | 'graph' | 'confidence'> & { readonly bodyText: string; readonly headingText: string }
164
+
165
+ const fieldsFor = (draft: EntryDraft): RetrievalFields => ({
166
+ title: draft.title.slice(0, 512),
167
+ headings: draft.headingText.slice(0, 4_096),
168
+ path: draft.path.slice(0, 1_024),
169
+ symbols: (draft.symbols ?? []).join(' ').slice(0, 8_192),
170
+ summary: (draft.summary ?? '').slice(0, 2_048),
171
+ body: draft.bodyText.slice(0, 16_000),
172
+ aliases: [...draft.aliases, ...draft.tags].join(' ').slice(0, 2_048),
173
+ })
174
+
175
+ /** A hash for an entry the repository does not store as one file: the entity itself, canonically. */
176
+ const derivedHash = (value: unknown): string => sha256NormalizedV1(value)
177
+
178
+ /**
179
+ * Build the retrieval index.
180
+ *
181
+ * Entries are emitted in id order and every list inside them is sorted, so the artifact — and
182
+ * its content hash — is a function of its inputs and not of the order the snapshot arrived in.
183
+ */
184
+ export const projectRetrievalIndex = (options: ProjectRetrievalOptions): RetrievalIndexV1 => {
185
+ const { snapshot, config } = options
186
+ const routes = options.routes ?? {}
187
+ const overlayHash = options.overlay?.hash ?? EMPTY_OVERLAY_HASH
188
+ const overlay = options.overlay
189
+ // Proposed relations sit next to the observed ones: one more edge each, with its own confidence.
190
+ const relations = overlay?.relations?.length ? [...snapshot.relations, ...overlay.relations] : snapshot.relations
191
+ const entities = new Map(snapshot.entities.map((entity) => [entity.id, entity]))
192
+ const byPath = new Map<string, KnowledgeEntity>()
193
+ for (const entity of snapshot.entities) if (entity.path && !byPath.has(entity.path)) byPath.set(entity.path, entity)
194
+ const curatedByPath = new Map((options.curated ?? []).map((document) => [document.path, document]))
195
+
196
+ /*
197
+ * Ownership records attach to the entity at their path — an area, a package, a module or a
198
+ * document — and lend it their id as an alias. A record that matches no entity still has to be
199
+ * findable, because a query for it has an answer (the record), so it becomes a declared entry.
200
+ */
201
+ const ownershipByEntity = new Map<string, NonNullable<RetrievalRoutes['ownership']>[string]>()
202
+ /** Further records at the same path: the same unit under another name, so aliases, not entries. */
203
+ const ownershipAliases = new Map<string, string[]>()
204
+ const orphanOwnership: NonNullable<RetrievalRoutes['ownership']>[string][] = []
205
+ for (const record of Object.values(routes.ownership ?? {}).sort((a, b) => a.id.localeCompare(b.id))) {
206
+ const entity = byPath.get(record.path.replace(/\/$/, ''))
207
+ if (!entity) orphanOwnership.push(record)
208
+ else if (!ownershipByEntity.has(entity.id)) ownershipByEntity.set(entity.id, record)
209
+ else ownershipAliases.set(entity.id, [...(ownershipAliases.get(entity.id) ?? []), record.id])
210
+ }
211
+
212
+ // Containment, for area and package ids.
213
+ const parentOf = new Map<string, string>()
214
+ const packageOfArea = new Map<string, string>()
215
+ for (const relation of relations) {
216
+ if (relation.kind !== 'contains') continue
217
+ const parent = entities.get(relation.from)
218
+ const child = entities.get(relation.to)
219
+ if (!parent || !child) continue
220
+ if (parent.kind === 'area' && child.kind === 'module') parentOf.set(child.id, parent.id)
221
+ if (parent.kind === 'package' && child.kind === 'module' && !parentOf.has(child.id)) parentOf.set(child.id, parent.id)
222
+ if (parent.kind === 'package' && child.kind === 'area') packageOfArea.set(child.id, parent.id)
223
+ if (parent.kind === 'area' && child.kind === 'area') parentOf.set(child.id, parent.id)
224
+ }
225
+ const areaOf = (id: string): string | undefined => {
226
+ const parent = parentOf.get(id)
227
+ return parent && entities.get(parent)?.kind === 'area' ? parent : undefined
228
+ }
229
+ const packageOf = (id: string): string | undefined => {
230
+ const entity = entities.get(id)
231
+ if (entity?.kind === 'package') return id
232
+ if (entity?.kind === 'area') {
233
+ let current: string | undefined = id
234
+ while (current && entities.get(current)?.kind === 'area') {
235
+ const owner = packageOfArea.get(current)
236
+ if (owner) return owner
237
+ current = parentOf.get(current)
238
+ }
239
+ return undefined
240
+ }
241
+ const parent = parentOf.get(id)
242
+ return parent ? packageOf(parent) : undefined
243
+ }
244
+
245
+ // Documentation edges, per endpoint, with the confidence of the relation that made them.
246
+ const inbound = new Map<string, RetrievalEdge[]>()
247
+ const outbound = new Map<string, RetrievalEdge[]>()
248
+ const push = (map: Map<string, RetrievalEdge[]>, key: string, edge: RetrievalEdge): void => {
249
+ const list = map.get(key)
250
+ if (list) list.push(edge)
251
+ else map.set(key, [edge])
252
+ }
253
+ for (const relation of relations) {
254
+ const confidence = relationConfidence(relation)
255
+ if (relation.kind === 'covers' || relation.kind === 'mentions' || relation.kind === 'mentions-symbol' || relation.kind === 'links-to') {
256
+ push(inbound, relation.to, { kind: relation.kind, id: relation.from, confidence })
257
+ push(outbound, relation.from, { kind: relation.kind, id: relation.to, confidence })
258
+ }
259
+ if ((relation.kind === 'imports' || relation.kind === 're-exports') && entities.get(relation.to)?.kind === 'module') {
260
+ push(outbound, relation.from, { kind: relation.kind, id: relation.to, confidence })
261
+ push(inbound, relation.to, { kind: relation.kind, id: relation.from, confidence })
262
+ }
263
+ }
264
+ const sortedEdges = (list: readonly RetrievalEdge[] | undefined): RetrievalEdge[] =>
265
+ [...(list ?? [])]
266
+ .sort((a, b) => a.kind.localeCompare(b.kind) || a.id.localeCompare(b.id) || a.confidence.localeCompare(b.confidence))
267
+ .filter((edge, index, all) => index === 0 || edge.kind !== all[index - 1]?.kind || edge.id !== all[index - 1]?.id)
268
+ .slice(0, MAX_EDGES)
269
+
270
+ const pagerank = canonicality({ entities: snapshot.entities, relations })
271
+
272
+ const drafts: EntryDraft[] = []
273
+
274
+ for (const entity of snapshot.entities) {
275
+ if (entity.kind !== 'document' && entity.kind !== 'module' && entity.kind !== 'area' && entity.kind !== 'package') continue
276
+ if (!entity.path) continue
277
+ const kind = entity.kind as RetrievalKind
278
+ const ownership = ownershipByEntity.get(entity.id)
279
+ const curated = kind === 'document' ? curatedByPath.get(entity.path) : undefined
280
+ const hash = entity.evidence[0]?.contentHash
281
+ const symbols = kind === 'module' ? unique(stringList(entity.metadata?.exports).filter((name) => name !== '*'), MAX_SYMBOLS) : undefined
282
+
283
+ let bodyText = ''
284
+ if (kind === 'document') {
285
+ const raw = options.readDocument?.(entity.path)
286
+ // A body that no longer matches the entity is not this entity's body.
287
+ if (raw !== undefined && (!hash || sha256NormalizedV1(raw.replace(/^/, '')) === hash)) bodyText = extractSearchBody(raw, DOCUMENT_BODY_LIMIT)
288
+ }
289
+
290
+ /*
291
+ * An ownership record's agent document is the documentation of the unit it owns. The unit
292
+ * inherits that text — title, headings, summary, body — so a query the document answers
293
+ * routes to the unit, which is what an agent asking "who owns X" needs, and what the record
294
+ * carried before the projection existed.
295
+ */
296
+ const agentDoc = ownership?.agentDoc ? byPath.get(ownership.agentDoc) : undefined
297
+ const agentDocCurated = ownership?.agentDoc ? curatedByPath.get(ownership.agentDoc) : undefined
298
+ if (agentDoc && !bodyText) {
299
+ const raw = options.readDocument?.(ownership?.agentDoc ?? '')
300
+ const docHash = agentDoc.evidence[0]?.contentHash
301
+ if (raw !== undefined && (!docHash || sha256NormalizedV1(raw.replace(/^\uFEFF/, '')) === docHash)) bodyText = extractSearchBody(raw, DOCUMENT_BODY_LIMIT)
302
+ }
303
+
304
+ const title =
305
+ curated?.title ??
306
+ agentDocCurated?.title ??
307
+ (typeof entity.metadata?.title === 'string' ? entity.metadata.title : undefined) ??
308
+ (typeof agentDoc?.metadata?.title === 'string' ? agentDoc.metadata.title : undefined) ??
309
+ (kind === 'document' || kind === 'module' ? basename(entity.path) : entity.name)
310
+ const summary =
311
+ ownership?.purpose ??
312
+ curated?.description ??
313
+ agentDocCurated?.description ??
314
+ (typeof entity.metadata?.summary === 'string' ? entity.metadata.summary : undefined) ??
315
+ (typeof agentDoc?.metadata?.summary === 'string' ? agentDoc.metadata.summary : undefined) ??
316
+ overlay?.summaries?.get(entity.id)
317
+ const shortName = kind === 'package' ? entity.name.split('/').pop() : undefined
318
+
319
+ drafts.push({
320
+ id: entity.id,
321
+ kind,
322
+ path: entity.path,
323
+ title: title.slice(0, 256),
324
+ ...(summary ? { summary: summary.slice(0, MAX_SUMMARY) } : {}),
325
+ ...(() => {
326
+ const audience = kind === 'document' ? audienceOf(entity) : agentDoc ? audienceOf(agentDoc) : undefined
327
+ return audience ? { audience } : {}
328
+ })(),
329
+ aliases: unique(
330
+ [ownership?.id, ...(ownershipAliases.get(entity.id) ?? []), curated?.id, shortName, kind === 'package' ? entity.name : undefined, ...(entity.aliases ?? []), ...(overlay?.aliases?.get(entity.id) ?? [])],
331
+ MAX_ALIASES,
332
+ ),
333
+ ...(symbols?.length ? { symbols } : {}),
334
+ tags: tagsFor(kind, entity, entity.path, [ownership ? 'ownership' : undefined, ownership?.group, ownership?.layer, overlay?.canonical?.has(entity.id) ? 'canonical' : undefined]),
335
+ contentHash: hash ?? derivedHash({ id: entity.id, kind, path: entity.path, name: entity.name, metadata: entity.metadata ?? {} }),
336
+ provenance: entity.provenance,
337
+ ...(ownership ? { ownershipId: ownership.id } : {}),
338
+ bodyText,
339
+ headingText: [...headingTexts(entity), ...(agentDoc ? headingTexts(agentDoc) : [])].join(' '),
340
+ })
341
+ }
342
+
343
+ for (const record of orphanOwnership) {
344
+ drafts.push({
345
+ id: `ownership:${record.id}`,
346
+ kind: 'package',
347
+ path: record.path,
348
+ title: record.id,
349
+ ...(record.purpose ? { summary: record.purpose } : {}),
350
+ aliases: unique([record.id, record.agentDoc, record.humanDoc], MAX_ALIASES),
351
+ tags: unique(['package', 'ownership', record.group, record.layer], MAX_TAGS),
352
+ contentHash: derivedHash(record),
353
+ provenance: 'declared',
354
+ ownershipId: record.id,
355
+ bodyText: '',
356
+ headingText: '',
357
+ })
358
+ }
359
+
360
+ for (const intent of Object.values(routes.intents ?? {})) {
361
+ drafts.push({
362
+ id: intent.id,
363
+ kind: 'intent',
364
+ path: intent.paths[0] ?? intent.id,
365
+ title: intent.title,
366
+ summary: intent.title,
367
+ aliases: [intent.id],
368
+ tags: ['intent'],
369
+ contentHash: derivedHash(intent),
370
+ provenance: 'declared',
371
+ bodyText: intent.paths.join(' '),
372
+ headingText: '',
373
+ })
374
+ }
375
+
376
+ // An accepted intent is a route an agent proposed and a validator let through: declared by it, not observed.
377
+ for (const intent of overlay?.intents ?? []) {
378
+ drafts.push({
379
+ id: intent.id,
380
+ kind: 'intent',
381
+ path: intent.paths[0] ?? intent.id,
382
+ title: intent.title,
383
+ summary: intent.title,
384
+ aliases: [intent.id],
385
+ tags: ['intent', 'proposed'],
386
+ contentHash: derivedHash(intent),
387
+ provenance: 'proposed',
388
+ bodyText: intent.paths.join(' '),
389
+ headingText: '',
390
+ })
391
+ }
392
+
393
+ for (const change of Object.values(routes.changes ?? {})) {
394
+ drafts.push({
395
+ id: change.id,
396
+ kind: 'change',
397
+ path: change.startHere,
398
+ title: change.title,
399
+ summary: change.title,
400
+ aliases: unique([change.id, ...(change.relatedPackages ?? [])], MAX_ALIASES),
401
+ tags: unique(['change', ...(change.relatedPackages ?? [])], MAX_TAGS),
402
+ contentHash: derivedHash(change),
403
+ provenance: 'declared',
404
+ bodyText: '',
405
+ headingText: '',
406
+ })
407
+ }
408
+
409
+ const entries: RetrievalEntry[] = drafts
410
+ .sort((a, b) => a.id.localeCompare(b.id))
411
+ .map((draft) => {
412
+ const { bodyText: _body, headingText: _headings, ...rest } = draft
413
+ const into = sortedEdges(inbound.get(draft.id))
414
+ const out = sortedEdges(outbound.get(draft.id))
415
+ const areaId = areaOf(draft.id)
416
+ const packageId = packageOf(draft.id)
417
+ const confidence: Confidence = draft.provenance as Provenance
418
+ const agentSignal = overlay?.signals?.get(draft.id)
419
+ return {
420
+ ...rest,
421
+ fields: fieldsFor(draft),
422
+ ...(agentSignal ? { agentSignal: Math.min(1, Math.max(0, Math.round(agentSignal * 1_000) / 1_000)) } : {}),
423
+ graph: {
424
+ pagerank: pagerank.get(draft.id) ?? 0,
425
+ inboundLinks: into.filter((edge) => edge.kind === 'links-to' || edge.kind === 'covers' || edge.kind === 'mentions' || edge.kind === 'mentions-symbol').length,
426
+ coveredBy: into.filter((edge) => edge.kind === 'covers').map((edge) => edge.id),
427
+ mentionedBy: into.filter((edge) => edge.kind === 'mentions' || edge.kind === 'mentions-symbol').map((edge) => edge.id),
428
+ ...(areaId ? { areaId } : {}),
429
+ ...(packageId ? { packageId } : {}),
430
+ inbound: into,
431
+ outbound: out,
432
+ },
433
+ confidence,
434
+ }
435
+ })
436
+
437
+ const weights = resolveSearchWeights(config?.retrieval?.weights)
438
+ const params = resolveSearchParams(config?.retrieval?.params)
439
+ const lexical = buildBm25Index(entries.map((entry) => ({ ref: entry.id, fields: entry.fields })), weights, params)
440
+
441
+ const base = {
442
+ type: 'retrieval-index' as const,
443
+ schemaVersion: 1 as const,
444
+ contentHash: '0'.repeat(64),
445
+ contentHashAlgo: 'sha256-normalized-v1' as const,
446
+ snapshotHash: snapshot.contentHash,
447
+ overlayHash,
448
+ configurationHash: indexConfigurationHash(config),
449
+ lexiconVersion: SEARCH_LEXICON_VERSION,
450
+ graphMetricsVersion: GRAPH_ANALYZER_VERSION,
451
+ weights,
452
+ params,
453
+ lexical: {
454
+ version: BM25_VERSION,
455
+ documentCount: lexical.documents.length,
456
+ fieldNames: Object.keys(weights).sort(),
457
+ averageFieldLength: Object.fromEntries([...lexical.averageFieldLength.entries()].sort(([a], [b]) => a.localeCompare(b))),
458
+ },
459
+ entries,
460
+ }
461
+ /*
462
+ * The hash is over the inputs, not the output: the projection is a function, so three equal
463
+ * input hashes mean an equal artifact, and a reader checking freshness compares three hashes
464
+ * instead of re-projecting.
465
+ */
466
+ const contentHash = sha256NormalizedV1({
467
+ projectionVersion: RETRIEVAL_PROJECTION_VERSION,
468
+ snapshotHash: base.snapshotHash,
469
+ overlayHash: base.overlayHash,
470
+ configurationHash: base.configurationHash,
471
+ lexiconVersion: base.lexiconVersion,
472
+ graphMetricsVersion: base.graphMetricsVersion,
473
+ weights,
474
+ params,
475
+ })
476
+ return RetrievalIndexV1Schema.parse({ ...base, contentHash })
477
+ }
478
+
479
+ /**
480
+ * The legacy record for a projected entry.
481
+ *
482
+ * `knowledge[]` is what the harness, llms.txt, the doctor and every older reader consume, so it
483
+ * keeps carrying every document and module — but without the body, which now lives once in the
484
+ * projection. A document projected here and a document ranked there are the same entry.
485
+ */
486
+ export const toKnowledgeEntry = (entry: RetrievalEntry): KnowledgeEntry => ({
487
+ id: entry.id,
488
+ type: entry.kind,
489
+ title: entry.title,
490
+ path: entry.path,
491
+ ...(entry.summary ? { description: entry.summary } : {}),
492
+ ...(entry.symbols?.length ? { symbols: entry.symbols } : {}),
493
+ tags: entry.tags,
494
+ contentHash: entry.contentHash,
495
+ })