@agentskit/doc-bridge 1.7.44 → 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 (223) hide show
  1. package/CHANGELOG.md +471 -0
  2. package/CONTRIBUTING.md +29 -4
  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 +15888 -6061
  10. package/dist/cli/program.js.map +1 -1
  11. package/dist/config/index.d.ts +1 -1
  12. package/dist/config/index.js +91 -9
  13. package/dist/config/index.js.map +1 -1
  14. package/dist/index-Beor6Yhi.d.ts +792 -0
  15. package/dist/index.d.ts +9979 -3257
  16. package/dist/index.js +15954 -5774
  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/RELEASE.md +22 -8
  22. package/docs/adr/0002-documentation-audit-boundary.md +22 -0
  23. package/docs/adr/0003-study-protocol-and-historical-evidence.md +40 -0
  24. package/docs/adr/0004-controlled-study-runner.md +25 -0
  25. package/docs/adr/0005-documentation-quality-and-criticality.md +20 -0
  26. package/docs/adr/0006-registry-semantic-grounding.md +20 -0
  27. package/docs/adr/0007-longitudinal-study-metrics.md +21 -0
  28. package/docs/adr/0008-study-verification-boundary.md +21 -0
  29. package/docs/adr/0009-study-provider-cli-adapter.md +25 -0
  30. package/docs/agent-corpus/INDEX.md +14 -3
  31. package/docs/agent-corpus/OVERVIEW.md +25 -0
  32. package/docs/agent-corpus/chat.md +7 -3
  33. package/docs/agent-corpus/cli.md +18 -2
  34. package/docs/agent-corpus/conformance.md +14 -2
  35. package/docs/agent-corpus/doc-bridge.md +48 -1
  36. package/docs/agent-corpus/doctor.md +10 -2
  37. package/docs/agent-corpus/gates.md +6 -2
  38. package/docs/agent-corpus/mcp.md +15 -2
  39. package/docs/agent-corpus/memory.md +6 -2
  40. package/docs/agent-corpus/query.md +35 -2
  41. package/docs/bench/README.md +122 -0
  42. package/docs/bench/retrieval-baseline-v1.json +28 -0
  43. package/docs/bench/retrieval-suite-v1.json +1033 -0
  44. package/docs/chat-and-rag.md +3 -2
  45. package/docs/for-agents.md +9 -1
  46. package/docs/getting-started.md +4 -11
  47. package/docs/guides/gate-ci.md +11 -1
  48. package/docs/guides/install-and-run.md +9 -65
  49. package/docs/index.md +22 -1
  50. package/docs/knowledge-engine-runbook.md +51 -4
  51. package/docs/landing/assets/context-payload-reduction.svg +21 -0
  52. package/docs/landing/assets/controlled-ab-comparison.svg +30 -0
  53. package/docs/landing/index.html +119 -5
  54. package/docs/loop-workflow.md +117 -0
  55. package/docs/mcp.md +6 -1
  56. package/docs/parity/public-claims-v1.json +145 -0
  57. package/docs/playbook/doc-bridge-pattern.md +1 -1
  58. package/docs/query.md +90 -2
  59. package/docs/recipes/index-pipeline.md +1 -1
  60. package/docs/schemas/agent-handoff-v1.md +15 -0
  61. package/docs/schemas/doc-bridge-index-v1.md +65 -0
  62. package/docs/spec/benchmark-v1.md +39 -1
  63. package/docs/spec/cli.md +30 -10
  64. package/docs/spec/config-v1.md +192 -8
  65. package/docs/spec/documentation-audit-v1.md +61 -0
  66. package/docs/spec/enrichment-overlay-v1.md +241 -0
  67. package/docs/spec/graph-signals-v1.md +92 -0
  68. package/docs/spec/incremental-scan-v1.md +102 -0
  69. package/docs/spec/markdown-analyzer-v1.md +73 -0
  70. package/docs/spec/mcp-knowledge-tools-v1.md +147 -0
  71. package/docs/spec/measured-enrichment-v1.md +229 -0
  72. package/docs/spec/public-parity-v1.md +119 -0
  73. package/docs/spec/registry-agents.md +6 -0
  74. package/docs/spec/render-v1.md +122 -0
  75. package/docs/spec/retrieval-index-v1.md +164 -0
  76. package/docs/spec/study-metrics-v1.md +58 -0
  77. package/docs/spec/study-protocol-v1.md +46 -0
  78. package/docs/spec/study-provider-cli-v1.md +116 -0
  79. package/docs/spec/study-runner-v1.md +35 -0
  80. package/docs/spec/study-task-suite-v1.md +41 -0
  81. package/docs/spec/study-verification-v1.md +40 -0
  82. package/docs/study/README.md +84 -0
  83. package/docs/study/ab-adjudicated-cost-analysis-v1.md +29 -0
  84. package/docs/study/ab-adjudicated-cost-plan-v1.json +33 -0
  85. package/docs/study/ab-adjudicated-cost-plan-v2-v1.json +33 -0
  86. package/docs/study/ab-adjudicated-cost-result-v1.json +80 -0
  87. package/docs/study/ab-baseline-analysis-v1.md +21 -0
  88. package/docs/study/ab-baseline-plan-v1.json +33 -0
  89. package/docs/study/ab-baseline-recovery-plan-v1.json +33 -0
  90. package/docs/study/ab-baseline-result-v1.json +79 -0
  91. package/docs/study/documentation-audit-round-2026-08-31.json +183 -0
  92. package/docs/study/historical-evidence-v1.json +252 -0
  93. package/docs/study/observation-ledger-v1.json +30632 -0
  94. package/docs/study/phase3-task-coverage-v1.json +34 -0
  95. package/docs/study/phase4-public-pilot-ledger-v1.json +1344 -0
  96. package/docs/study/phase4-public-pilot-result-v1.json +52 -0
  97. package/docs/study/phase4-public-pilot-run-plan-v1.json +26 -0
  98. package/docs/study/phase4-public-pilot-task-suite-v1.json +71 -0
  99. package/docs/study/pilot-round-2026-08-31.json +46 -0
  100. package/docs/study/protocol-v1.json +90 -0
  101. package/docs/study/publication-gate-v1.md +45 -0
  102. package/docs/study/quality-scorecard-cycle-plan.md +545 -0
  103. package/docs/study/quality-scorecard-v1.json +38 -0
  104. package/docs/study/round-1-adjudicated-smoke-v1.json +30642 -0
  105. package/docs/study/round-1-instrumentation-plan-v1.md +39 -0
  106. package/docs/study/round-2-expanded-adjudication-v1.json +91 -0
  107. package/docs/study/round-2-expanded-validation-v1.md +58 -0
  108. package/docs/study/round-3-evidence-contract-v1.json +75 -0
  109. package/docs/study/round-3-evidence-contract-v1.md +57 -0
  110. package/docs/study/round-4-confirmation-v1.json +75 -0
  111. package/docs/study/round-4-confirmation-v1.md +55 -0
  112. package/docs/study/run-plan-v1.json +33 -0
  113. package/docs/study/semantic-adjudication-cycle-8.md +20 -0
  114. package/docs/study/task-suite-v1.json +96 -0
  115. package/docs/study/token-efficiency-plan-v1.md +337 -0
  116. package/docs/study/token-efficiency-protocol-v2.json +62 -0
  117. package/docs/study/verification-binding-v1.json +27 -0
  118. package/docs/validation-cycle-plan.md +33 -0
  119. package/docs/verification-harness.md +15 -6
  120. package/ecosystem-claims.json +2 -2
  121. package/ecosystem-upstream.json +2 -2
  122. package/ecosystem.json +4 -4
  123. package/mcpb/manifest.json +9 -1
  124. package/package.json +89 -72
  125. package/scripts/check-ecosystem-upstream.mjs +36 -7
  126. package/scripts/report-visual-check.mjs +20 -3
  127. package/skills/doc-bridge-handoff/fixtures/synthetic-repo/docs/for-agents/packages/payments.md +7 -0
  128. package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
  129. package/src/agents/registry-adapter.ts +192 -24
  130. package/src/audit/documentation.ts +513 -0
  131. package/src/bench/baseline.ts +198 -0
  132. package/src/bench/overlay-delta.ts +139 -0
  133. package/src/bench/retrieval.ts +319 -0
  134. package/src/budget/compile.ts +91 -0
  135. package/src/budget/sections.ts +70 -0
  136. package/src/cli/demo.ts +2 -2
  137. package/src/cli/program.ts +699 -79
  138. package/src/cli/usage.ts +71 -0
  139. package/src/config/defaults.ts +1 -0
  140. package/src/config/index.ts +4 -0
  141. package/src/config/load-config.ts +7 -1
  142. package/src/config/schema.ts +121 -4
  143. package/src/conformance/documentation-standard-v1.ts +22 -14
  144. package/src/discovery/areas.ts +182 -0
  145. package/src/discovery/documentation.ts +255 -23
  146. package/src/discovery/identity.ts +24 -0
  147. package/src/discovery/incremental.ts +314 -0
  148. package/src/discovery/inputs.ts +110 -0
  149. package/src/discovery/markdown.ts +481 -0
  150. package/src/discovery/repository.ts +557 -125
  151. package/src/doctor/run-doctor.ts +246 -27
  152. package/src/enrich/approvals.ts +190 -0
  153. package/src/enrich/cache.ts +93 -0
  154. package/src/enrich/context-pack.ts +272 -0
  155. package/src/enrich/overlay.ts +255 -0
  156. package/src/enrich/review.ts +106 -0
  157. package/src/enrich/stage.ts +374 -0
  158. package/src/enrich/stats.ts +100 -0
  159. package/src/enrich/validate.ts +410 -0
  160. package/src/federation/llms.ts +74 -24
  161. package/src/findings/report.ts +103 -0
  162. package/src/fixes/proposals.ts +4 -3
  163. package/src/graph/build.ts +356 -0
  164. package/src/graph/memory.ts +208 -0
  165. package/src/index-builder/build-handoffs.ts +22 -11
  166. package/src/index-builder/build-index.ts +132 -3
  167. package/src/index-builder/human-adapters/fumadocs.ts +1 -1
  168. package/src/index-builder/llms-txt.ts +48 -8
  169. package/src/index-builder/project-corpus.ts +111 -0
  170. package/src/index-builder/watch-index.ts +1 -1
  171. package/src/index.ts +630 -2
  172. package/src/lib/bounded-text.ts +15 -10
  173. package/src/lib/fuzzy-match.ts +235 -0
  174. package/src/mcp/knowledge.ts +554 -0
  175. package/src/mcp/server.ts +113 -18
  176. package/src/metrics/benchmark.ts +21 -0
  177. package/src/parity/check.ts +309 -0
  178. package/src/parity/claims.ts +259 -0
  179. package/src/parity/resolve.ts +160 -0
  180. package/src/query/handoff.ts +326 -0
  181. package/src/query/load-index.ts +53 -1
  182. package/src/query/query.ts +92 -59
  183. package/src/query/search.ts +289 -92
  184. package/src/query/text.ts +155 -0
  185. package/src/reconciliation/reconcile.ts +148 -15
  186. package/src/render/data.ts +356 -0
  187. package/src/render/engine.ts +398 -0
  188. package/src/render/generated.ts +77 -0
  189. package/src/render/render.ts +209 -0
  190. package/src/render/template-source.ts +52 -0
  191. package/src/render/templates.ts +289 -0
  192. package/src/report/html.ts +23 -17
  193. package/src/retrieval/bm25.ts +161 -0
  194. package/src/retrieval/project.ts +495 -0
  195. package/src/retrieval/rank.ts +383 -0
  196. package/src/retrieval/weights.ts +39 -0
  197. package/src/retriever/doc-bridge-retriever.ts +100 -15
  198. package/src/rules/engine.ts +45 -12
  199. package/src/safety/repository.ts +1 -1
  200. package/src/schemas/agent-handoff.ts +56 -0
  201. package/src/schemas/budget.ts +37 -0
  202. package/src/schemas/doc-bridge-index.ts +53 -2
  203. package/src/schemas/enrichment.ts +369 -0
  204. package/src/schemas/json-schemas.ts +39 -2
  205. package/src/schemas/knowledge.ts +19 -3
  206. package/src/schemas/retrieval-index.ts +152 -0
  207. package/src/shims/graphology.d.ts +91 -0
  208. package/src/study/adjudication.ts +196 -0
  209. package/src/study/execution.ts +350 -0
  210. package/src/study/expectations.ts +219 -0
  211. package/src/study/metrics.ts +467 -0
  212. package/src/study/protocol.ts +271 -0
  213. package/src/study/provider-cli.ts +115 -0
  214. package/src/study/provider-telemetry.ts +47 -0
  215. package/src/study/quality-scorecard.ts +164 -0
  216. package/src/study/runner.ts +461 -0
  217. package/src/study/task-suite.ts +321 -0
  218. package/src/study/verification.ts +134 -0
  219. package/src/validate.ts +8 -5
  220. package/src/version.ts +1 -1
  221. package/src/workflow/engine.ts +36 -11
  222. package/dist/index-C2PCQSrB.d.ts +0 -2251
  223. package/scripts/verification-harness.mjs +0 -483
@@ -0,0 +1,554 @@
1
+ import { applyBudget, type BudgetedSection } from '../budget/sections.js'
2
+ import type { DocBridgeConfigV1 } from '../config/schema.js'
3
+ import { handoffForEntity, resolveHandoffEntry } from '../query/handoff.js'
4
+ import { runQuery } from '../query/query.js'
5
+ import { searchIndex, type SearchMatch, type SearchOptions } from '../query/search.js'
6
+ import { formatRetrievedDocuments, type RetrievedDocument } from '../retriever/doc-bridge-retriever.js'
7
+ import type { AgentHandoffV1 } from '../schemas/agent-handoff.js'
8
+ import type { BudgetReport } from '../schemas/budget.js'
9
+ import type { DocBridgeIndexV1 } from '../schemas/doc-bridge-index.js'
10
+ import type { Evidence, KnowledgeDiagnostic, ReconciliationReportV1 } from '../schemas/knowledge.js'
11
+ import type { Confidence, RetrievalEntry, RetrievalIndexV1, RetrievalKind } from '../schemas/retrieval-index.js'
12
+
13
+ /**
14
+ * The two calls an agent makes before editing: search, and one lookup that answers "tell me
15
+ * about this" with the entity, its neighbourhood, the documents about it, its handoff, its open
16
+ * diagnostics and its evidence — bounded by a declared token budget when the caller has one.
17
+ *
18
+ * Both read the retrieval projection the CLI reads and rank with the same `searchIndex`, so a
19
+ * result here is the result `ak-docs search` prints for the same query and index; a test compares
20
+ * the two rather than assuming it. Neither imports anything under `src/agents`: this is the
21
+ * deterministic layer, and a lookup costs milliseconds with no model in the loop.
22
+ */
23
+
24
+ export const KNOWLEDGE_TOOLS_SCHEMA_VERSION = 1 as const
25
+
26
+ /** Characters of body text an evidence excerpt carries: enough to recognise the passage, not to read it. */
27
+ const EXCERPT_CHARS = 240
28
+ const MAX_NEIGHBOURS_PER_KIND = 32
29
+ const MAX_DIAGNOSTICS = 32
30
+ const MAX_EVIDENCE = 8
31
+ export const MAX_LOOKUP_DEPTH = 3
32
+
33
+ export type KnowledgeSearchRequest = {
34
+ readonly query: string
35
+ /** Keep only these entry kinds. Filtering happens after ranking, so it never changes an order. */
36
+ readonly kinds?: readonly RetrievalKind[] | undefined
37
+ readonly limit?: number | undefined
38
+ readonly explain?: boolean | undefined
39
+ readonly budgetTokens?: number | undefined
40
+ /** The `--agent` prior. */
41
+ readonly agent?: boolean | undefined
42
+ }
43
+
44
+ export type KnowledgeSearchResult = SearchMatch & {
45
+ readonly title?: string
46
+ /** The opening of the projected body: the evidence excerpt a budget sheds first. */
47
+ readonly excerpt?: string
48
+ }
49
+
50
+ export type KnowledgeSearchResponse = {
51
+ readonly type: 'knowledge-search'
52
+ readonly schemaVersion: typeof KNOWLEDGE_TOOLS_SCHEMA_VERSION
53
+ readonly source: string
54
+ readonly query: string
55
+ readonly kinds?: readonly RetrievalKind[]
56
+ readonly limit: number
57
+ readonly count: number
58
+ readonly results: readonly KnowledgeSearchResult[]
59
+ readonly budget?: BudgetReport
60
+ }
61
+
62
+ export type KnowledgeLookupRequest = {
63
+ readonly id?: string | undefined
64
+ readonly path?: string | undefined
65
+ /** How many hops of the graph to include as neighbours. 1 by default, at most `MAX_LOOKUP_DEPTH`. */
66
+ readonly depth?: number | undefined
67
+ readonly budgetTokens?: number | undefined
68
+ }
69
+
70
+ export type KnowledgeLookupEntity = {
71
+ readonly id: string
72
+ readonly kind: RetrievalKind
73
+ readonly path: string
74
+ readonly title: string
75
+ readonly summary?: string
76
+ readonly audience?: RetrievalEntry['audience']
77
+ readonly aliases: readonly string[]
78
+ readonly symbols?: readonly string[]
79
+ readonly tags: readonly string[]
80
+ readonly provenance: RetrievalEntry['provenance']
81
+ readonly confidence: Confidence
82
+ readonly contentHash: string
83
+ readonly ownershipId?: string
84
+ readonly areaId?: string
85
+ readonly packageId?: string
86
+ readonly pagerank: number
87
+ }
88
+
89
+ export type KnowledgeNeighbour = {
90
+ readonly id: string
91
+ readonly kind: RetrievalKind
92
+ readonly path: string
93
+ readonly title: string
94
+ readonly summary?: string
95
+ /** Whether the edge points at the entity or away from it. */
96
+ readonly direction: 'inbound' | 'outbound'
97
+ readonly confidence: Confidence
98
+ /** Hops from the entity. */
99
+ readonly distance: number
100
+ /** The entry this one was reached through, when it is more than one hop away. */
101
+ readonly via?: string
102
+ }
103
+
104
+ export type KnowledgeDocumentRef = {
105
+ readonly id: string
106
+ readonly path: string
107
+ readonly title: string
108
+ readonly summary?: string
109
+ readonly audience?: RetrievalEntry['audience']
110
+ readonly pagerank: number
111
+ }
112
+
113
+ export type KnowledgeEvidence = Evidence & {
114
+ readonly excerpt?: string
115
+ }
116
+
117
+ export type KnowledgeLookupResponse = {
118
+ readonly type: 'knowledge-lookup'
119
+ readonly schemaVersion: typeof KNOWLEDGE_TOOLS_SCHEMA_VERSION
120
+ readonly source: string
121
+ readonly depth: number
122
+ readonly entity: KnowledgeLookupEntity
123
+ /** Neighbours by relation kind, keys sorted, each list by distance then id. */
124
+ readonly neighbours: Readonly<Record<string, readonly KnowledgeNeighbour[]>>
125
+ readonly documents: {
126
+ readonly covering: readonly KnowledgeDocumentRef[]
127
+ readonly mentioning: readonly KnowledgeDocumentRef[]
128
+ }
129
+ readonly handoff: AgentHandoffV1
130
+ readonly diagnostics: {
131
+ /** The reconciliation report the diagnostics came from, or null when no workflow run exists. */
132
+ readonly reportHash: string | null
133
+ readonly open: readonly KnowledgeDiagnostic[]
134
+ }
135
+ readonly evidence: readonly KnowledgeEvidence[]
136
+ readonly budget?: BudgetReport
137
+ }
138
+
139
+ export type KnowledgeLookupOptions = {
140
+ readonly root?: string
141
+ /** The latest reconciliation report, when a workflow run has produced one. */
142
+ readonly report?: () => ReconciliationReportV1 | undefined
143
+ }
144
+
145
+ const requireProjection = (index: DocBridgeIndexV1, tool: string): RetrievalIndexV1 => {
146
+ if (!index.projection) throw new Error(`${tool} needs an index with a retrieval projection. Run: ak-docs index`)
147
+ return index.projection
148
+ }
149
+
150
+ /** The opening of a body, cut at a word boundary, so an excerpt never ends mid-token. */
151
+ const excerptOf = (body: string | undefined): string | undefined => {
152
+ const text = (body ?? '').replace(/\s+/g, ' ').trim()
153
+ if (!text) return undefined
154
+ if (text.length <= EXCERPT_CHARS) return text
155
+ const cut = text.lastIndexOf(' ', EXCERPT_CHARS)
156
+ return `${text.slice(0, cut > EXCERPT_CHARS / 2 ? cut : EXCERPT_CHARS).trimEnd()}…`
157
+ }
158
+
159
+ const evidenceSource = (kind: RetrievalKind): Evidence['source'] =>
160
+ kind === 'module' ? 'code' : kind === 'document' ? 'documentation' : kind === 'intent' || kind === 'change' ? 'configuration' : 'derived'
161
+
162
+ const withoutSummary = <T extends { readonly summary?: string }>(value: T): T => {
163
+ const { summary: _summary, ...rest } = value
164
+ return rest as T
165
+ }
166
+
167
+ /*
168
+ * ---------------------------------------------------------------------------------------------
169
+ * knowledge.search
170
+ * ---------------------------------------------------------------------------------------------
171
+ */
172
+
173
+ const searchSections = (): readonly BudgetedSection<KnowledgeSearchResponse>[] => [
174
+ {
175
+ name: 'evidenceExcerpts',
176
+ content: [],
177
+ strip: (payload) => ({ ...payload, results: payload.results.map(({ excerpt: _excerpt, ...result }) => result) }),
178
+ },
179
+ {
180
+ name: 'summaries',
181
+ content: [],
182
+ strip: (payload) => ({ ...payload, results: payload.results.map(withoutSummary) }),
183
+ },
184
+ ]
185
+
186
+ const sectionsWithContent = <T>(sections: readonly BudgetedSection<T>[], content: (name: BudgetedSection<T>['name']) => unknown): BudgetedSection<T>[] =>
187
+ sections.map((section) => ({ ...section, content: content(section.name) }))
188
+
189
+ /**
190
+ * Ranked entries for a query.
191
+ *
192
+ * With no `kinds`, this is exactly `searchIndex(index, query, limit)` — the CLI's call — with a
193
+ * title and an excerpt added to each result. With `kinds`, the ranking is fetched deeper and
194
+ * filtered, because a filter applied before the limit would return fewer than `limit` results
195
+ * for a query that mostly matched another kind.
196
+ */
197
+ export const knowledgeSearch = (index: DocBridgeIndexV1, request: KnowledgeSearchRequest): KnowledgeSearchResponse => {
198
+ const projection = requireProjection(index, 'knowledge.search')
199
+ const limit = request.limit ?? 20
200
+ const options: SearchOptions = { ...(request.explain ? { explain: true } : {}), ...(request.agent ? { agent: true } : {}) }
201
+ const kinds = request.kinds && request.kinds.length ? [...new Set(request.kinds)].sort() : undefined
202
+ const matches = kinds
203
+ ? searchIndex(index, request.query, Math.max(limit * 4, 40), options)
204
+ .filter((match) => match.kind !== undefined && kinds.includes(match.kind))
205
+ .slice(0, limit)
206
+ : searchIndex(index, request.query, limit, options)
207
+ const byId = new Map(projection.entries.map((entry) => [entry.id, entry]))
208
+
209
+ const results: KnowledgeSearchResult[] = matches.map((match) => {
210
+ const entry = match.entityId ? byId.get(match.entityId) : undefined
211
+ const excerpt = excerptOf(entry?.fields.body)
212
+ return { ...match, ...(entry ? { title: entry.title } : {}), ...(excerpt ? { excerpt } : {}) }
213
+ })
214
+
215
+ const response: KnowledgeSearchResponse = {
216
+ type: 'knowledge-search',
217
+ schemaVersion: KNOWLEDGE_TOOLS_SCHEMA_VERSION,
218
+ source: `projection:${projection.contentHash}`,
219
+ query: request.query,
220
+ ...(kinds ? { kinds } : {}),
221
+ limit,
222
+ count: results.length,
223
+ results,
224
+ }
225
+ if (request.budgetTokens === undefined) return response
226
+
227
+ const sections = sectionsWithContent(searchSections(), (name) =>
228
+ name === 'evidenceExcerpts' ? results.map((result) => result.excerpt).filter(Boolean) : results.map((result) => result.summary).filter(Boolean),
229
+ )
230
+ const budgeted = applyBudget(response, sections, request.budgetTokens)
231
+ return { ...budgeted.payload, budget: budgeted.budget }
232
+ }
233
+
234
+ /*
235
+ * ---------------------------------------------------------------------------------------------
236
+ * knowledge.lookup
237
+ * ---------------------------------------------------------------------------------------------
238
+ */
239
+
240
+ const toEntity = (entry: RetrievalEntry): KnowledgeLookupEntity => ({
241
+ id: entry.id,
242
+ kind: entry.kind,
243
+ path: entry.path,
244
+ title: entry.title,
245
+ ...(entry.summary ? { summary: entry.summary } : {}),
246
+ ...(entry.audience ? { audience: entry.audience } : {}),
247
+ aliases: entry.aliases,
248
+ ...(entry.symbols?.length ? { symbols: entry.symbols } : {}),
249
+ tags: entry.tags,
250
+ provenance: entry.provenance,
251
+ confidence: entry.confidence,
252
+ contentHash: entry.contentHash,
253
+ ...(entry.ownershipId ? { ownershipId: entry.ownershipId } : {}),
254
+ ...(entry.graph.areaId ? { areaId: entry.graph.areaId } : {}),
255
+ ...(entry.graph.packageId ? { packageId: entry.graph.packageId } : {}),
256
+ pagerank: entry.graph.pagerank,
257
+ })
258
+
259
+ const toDocumentRef = (entry: RetrievalEntry): KnowledgeDocumentRef => ({
260
+ id: entry.id,
261
+ path: entry.path,
262
+ title: entry.title,
263
+ ...(entry.summary ? { summary: entry.summary } : {}),
264
+ ...(entry.audience ? { audience: entry.audience } : {}),
265
+ pagerank: entry.graph.pagerank,
266
+ })
267
+
268
+ const byCanonicality = (a: RetrievalEntry, b: RetrievalEntry): number => b.graph.pagerank - a.graph.pagerank || a.id.localeCompare(b.id)
269
+
270
+ type Edge = { readonly kind: string; readonly id: string; readonly confidence: Confidence; readonly direction: KnowledgeNeighbour['direction'] }
271
+
272
+ /**
273
+ * Every edge at an entry, both directions, plus the hierarchy the projection records as fields
274
+ * rather than edges: a module is contained by its area, an area contains its modules. The
275
+ * projection carries `contains` nowhere in `inbound`/`outbound` — it is hierarchy, not a
276
+ * relation the ranker walks — but an agent asking about an area wants its modules listed.
277
+ */
278
+ const edgesAt = (projection: RetrievalIndexV1, entry: RetrievalEntry): Edge[] => {
279
+ const edges: Edge[] = [
280
+ ...entry.graph.inbound.map((edge) => ({ ...edge, direction: 'inbound' as const })),
281
+ ...entry.graph.outbound.map((edge) => ({ ...edge, direction: 'outbound' as const })),
282
+ ]
283
+ const parent = entry.kind === 'module' || entry.kind === 'area' ? (entry.graph.areaId ?? entry.graph.packageId) : undefined
284
+ if (parent && parent !== entry.id) edges.push({ kind: 'contains', id: parent, confidence: 'observed', direction: 'inbound' })
285
+ if (entry.kind === 'area' || entry.kind === 'package') {
286
+ for (const child of projection.entries) {
287
+ if (child.id === entry.id) continue
288
+ const childParent = child.kind === 'area' || child.kind === 'module' ? (child.graph.areaId ?? child.graph.packageId) : undefined
289
+ if (childParent === entry.id) edges.push({ kind: 'contains', id: child.id, confidence: 'observed', direction: 'outbound' })
290
+ }
291
+ }
292
+ return edges
293
+ }
294
+
295
+ /**
296
+ * The neighbourhood, breadth first, bounded by depth and by count per relation kind.
297
+ *
298
+ * An entry is recorded once, at the distance it was first reached, under the relation that
299
+ * reached it; the traversal visits entries in sorted id order at every level so the record is
300
+ * the same whatever order the projection listed them in.
301
+ */
302
+ const neighboursOf = (projection: RetrievalIndexV1, byId: ReadonlyMap<string, RetrievalEntry>, target: RetrievalEntry, depth: number): Record<string, KnowledgeNeighbour[]> => {
303
+ const seen = new Set<string>([target.id])
304
+ const grouped = new Map<string, KnowledgeNeighbour[]>()
305
+ let frontier: RetrievalEntry[] = [target]
306
+ for (let distance = 1; distance <= depth && frontier.length; distance += 1) {
307
+ const next: RetrievalEntry[] = []
308
+ for (const current of frontier) {
309
+ const edges = edgesAt(projection, current).sort((a, b) => a.kind.localeCompare(b.kind) || a.id.localeCompare(b.id) || a.direction.localeCompare(b.direction))
310
+ for (const edge of edges) {
311
+ const entry = byId.get(edge.id)
312
+ if (!entry || seen.has(entry.id)) continue
313
+ seen.add(entry.id)
314
+ next.push(entry)
315
+ const list = grouped.get(edge.kind) ?? []
316
+ list.push({
317
+ id: entry.id,
318
+ kind: entry.kind,
319
+ path: entry.path,
320
+ title: entry.title,
321
+ ...(entry.summary ? { summary: entry.summary } : {}),
322
+ direction: edge.direction,
323
+ confidence: edge.confidence,
324
+ distance,
325
+ ...(distance > 1 ? { via: current.id } : {}),
326
+ })
327
+ grouped.set(edge.kind, list)
328
+ }
329
+ }
330
+ frontier = next.sort((a, b) => a.id.localeCompare(b.id))
331
+ }
332
+ return Object.fromEntries(
333
+ [...grouped.entries()]
334
+ .sort(([a], [b]) => a.localeCompare(b))
335
+ .map(([kind, list]) => [kind, list.sort((a, b) => a.distance - b.distance || a.id.localeCompare(b.id)).slice(0, MAX_NEIGHBOURS_PER_KIND)]),
336
+ )
337
+ }
338
+
339
+ const SEVERITY_RANK: Readonly<Record<KnowledgeDiagnostic['severity'], number>> = { error: 0, warn: 1, info: 2, off: 3 }
340
+
341
+ /** Diagnostics that name the entity or point at its file and are not confirmed as fine. */
342
+ const openDiagnosticsFor = (report: ReconciliationReportV1 | undefined, target: RetrievalEntry): KnowledgeDiagnostic[] =>
343
+ (report?.diagnostics ?? [])
344
+ .filter((diagnostic) => diagnostic.status !== 'confirmed' && diagnostic.severity !== 'off')
345
+ .filter((diagnostic) => diagnostic.entityIds?.includes(target.id) || diagnostic.evidence.some((item) => item.path === target.path))
346
+ .sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || a.id.localeCompare(b.id))
347
+ .slice(0, MAX_DIAGNOSTICS)
348
+
349
+ const lookupSections = (): readonly BudgetedSection<KnowledgeLookupResponse>[] => [
350
+ {
351
+ name: 'evidenceExcerpts',
352
+ content: [],
353
+ strip: (payload) => ({ ...payload, evidence: payload.evidence.map(({ excerpt: _excerpt, ...item }) => item) }),
354
+ },
355
+ {
356
+ name: 'related',
357
+ content: [],
358
+ strip: (payload) => {
359
+ const { related: _related, ...handoff } = payload.handoff
360
+ return { ...payload, handoff }
361
+ },
362
+ },
363
+ {
364
+ name: 'neighbours',
365
+ content: [],
366
+ strip: (payload) => ({ ...payload, neighbours: {} }),
367
+ },
368
+ {
369
+ name: 'summaries',
370
+ content: [],
371
+ strip: (payload) => ({
372
+ ...payload,
373
+ entity: withoutSummary(payload.entity),
374
+ neighbours: Object.fromEntries(Object.entries(payload.neighbours).map(([kind, list]) => [kind, list.map(withoutSummary)])),
375
+ documents: { covering: payload.documents.covering.map(withoutSummary), mentioning: payload.documents.mentioning.map(withoutSummary) },
376
+ }),
377
+ },
378
+ ]
379
+
380
+ /** Which entry a lookup names: by id first, then by path, through the same resolution the handoff uses. */
381
+ export const resolveLookupEntry = (projection: RetrievalIndexV1, request: Pick<KnowledgeLookupRequest, 'id' | 'path'>): RetrievalEntry => {
382
+ const key = request.id ?? request.path
383
+ if (!key) throw new Error('knowledge.lookup requires id or path')
384
+ const path = key.replace(/\/$/, '')
385
+ const entry = request.id ? resolveHandoffEntry(projection, request.id) : (projection.entries.find((item) => item.path === path) ?? resolveHandoffEntry(projection, path))
386
+ if (!entry) throw new Error(`Unknown entity "${key}". Try: ak-docs search "${key}"`)
387
+ return entry
388
+ }
389
+
390
+ /**
391
+ * Everything an agent wants to know about one entity before editing, in one bounded response.
392
+ *
393
+ * The handoff is the one `handoff.resolve` returns for the same entity; the documents are the
394
+ * projection's `coveredBy` and `mentionedBy`, most canonical first; the diagnostics are the open
395
+ * ones from the latest reconciliation report that name the entity or its file; the evidence is
396
+ * the entity's own path and hash and those of the documents about it, each with the opening of
397
+ * its body as an excerpt — the first thing a budget sheds.
398
+ */
399
+ export const knowledgeLookup = (index: DocBridgeIndexV1, config: DocBridgeConfigV1, request: KnowledgeLookupRequest, options: KnowledgeLookupOptions = {}): KnowledgeLookupResponse => {
400
+ const projection = requireProjection(index, 'knowledge.lookup')
401
+ const depth = Math.min(MAX_LOOKUP_DEPTH, Math.max(1, request.depth ?? 1))
402
+ const target = resolveLookupEntry(projection, request)
403
+ const byId = new Map(projection.entries.map((entry) => [entry.id, entry]))
404
+ const handoffOptions = options.root ? { root: options.root } : {}
405
+
406
+ const handoff =
407
+ target.kind === 'intent' || target.kind === 'change'
408
+ ? (runQuery(index, config, { kind: target.kind, id: target.id, agent: true }, handoffOptions) as AgentHandoffV1)
409
+ : handoffForEntity(index, target.id, config, handoffOptions)
410
+
411
+ const documentsOf = (ids: readonly string[]): RetrievalEntry[] =>
412
+ ids.map((id) => byId.get(id)).filter((entry): entry is RetrievalEntry => entry !== undefined && entry.kind === 'document').sort(byCanonicality)
413
+ const covering = documentsOf(target.graph.coveredBy)
414
+ const mentioning = documentsOf(target.graph.mentionedBy).filter((entry) => !covering.includes(entry))
415
+
416
+ const report = options.report?.()
417
+ const evidence: KnowledgeEvidence[] = [
418
+ {
419
+ source: evidenceSource(target.kind),
420
+ path: target.path,
421
+ contentHash: target.contentHash,
422
+ context: `${target.kind} ${target.id}`,
423
+ ...(excerptOf(target.fields.body) ? { excerpt: excerptOf(target.fields.body) as string } : {}),
424
+ },
425
+ ...[...covering.map((entry) => ({ entry, context: `covers ${target.id}` })), ...mentioning.map((entry) => ({ entry, context: `mentions ${target.id}` }))]
426
+ .slice(0, MAX_EVIDENCE - 1)
427
+ .map(({ entry, context }) => ({
428
+ source: 'documentation' as const,
429
+ path: entry.path,
430
+ contentHash: entry.contentHash,
431
+ context,
432
+ ...(excerptOf(entry.fields.body) ? { excerpt: excerptOf(entry.fields.body) as string } : {}),
433
+ })),
434
+ ]
435
+
436
+ const response: KnowledgeLookupResponse = {
437
+ type: 'knowledge-lookup',
438
+ schemaVersion: KNOWLEDGE_TOOLS_SCHEMA_VERSION,
439
+ source: `projection:${projection.contentHash}`,
440
+ depth,
441
+ entity: toEntity(target),
442
+ neighbours: neighboursOf(projection, byId, target, depth),
443
+ documents: { covering: covering.map(toDocumentRef), mentioning: mentioning.map(toDocumentRef) },
444
+ handoff,
445
+ diagnostics: { reportHash: report?.contentHash ?? null, open: openDiagnosticsFor(report, target) },
446
+ evidence,
447
+ }
448
+ if (request.budgetTokens === undefined) return response
449
+
450
+ const sections = sectionsWithContent(lookupSections(), (name) => {
451
+ if (name === 'evidenceExcerpts') return response.evidence.map((item) => item.excerpt).filter(Boolean)
452
+ if (name === 'related') return response.handoff.related ?? []
453
+ if (name === 'neighbours') return response.neighbours
454
+ return [
455
+ response.entity.summary,
456
+ ...Object.values(response.neighbours).flatMap((list) => list.map((item) => item.summary)),
457
+ ...response.documents.covering.map((item) => item.summary),
458
+ ...response.documents.mentioning.map((item) => item.summary),
459
+ ].filter(Boolean)
460
+ })
461
+ const budgeted = applyBudget(response, sections, request.budgetTokens)
462
+ return { ...budgeted.payload, budget: budgeted.budget }
463
+ }
464
+
465
+ /*
466
+ * ---------------------------------------------------------------------------------------------
467
+ * handoff.resolve with a budget
468
+ * ---------------------------------------------------------------------------------------------
469
+ */
470
+
471
+ /**
472
+ * A handoff trimmed to a budget. Its droppable sections are `related` and the note that repeats
473
+ * the target's summary; `startHere`, `readBeforeEditing`, `editRoots`, `checks` and `evidence`
474
+ * are what an agent acts on and are never shed.
475
+ */
476
+ export const budgetedHandoff = (index: DocBridgeIndexV1, handoff: AgentHandoffV1, budgetTokens: number): AgentHandoffV1 => {
477
+ const entry = index.projection && handoff.metadata?.entityId ? resolveHandoffEntry(index.projection, String(handoff.metadata.entityId)) : undefined
478
+ const summaryNotes = entry?.summary ? handoff.notes.filter((note) => note === entry.summary) : []
479
+ const sections: BudgetedSection<AgentHandoffV1>[] = [
480
+ {
481
+ name: 'related',
482
+ content: handoff.related ?? [],
483
+ strip: (payload) => {
484
+ const { related: _related, ...rest } = payload
485
+ return rest
486
+ },
487
+ },
488
+ {
489
+ name: 'summaries',
490
+ content: summaryNotes,
491
+ strip: (payload) => ({ ...payload, notes: payload.notes.filter((note) => !summaryNotes.includes(note)) }),
492
+ },
493
+ ]
494
+ const budgeted = applyBudget(handoff, sections, budgetTokens)
495
+ return { ...budgeted.payload, budget: budgeted.budget }
496
+ }
497
+
498
+ /*
499
+ * ---------------------------------------------------------------------------------------------
500
+ * Text renderings
501
+ * ---------------------------------------------------------------------------------------------
502
+ */
503
+
504
+ const budgetLine = (budget: BudgetReport | undefined): string[] =>
505
+ budget
506
+ ? [`Budget: ${budget.tokens.total}/${budget.tokens.budget} tokens (${budget.tokenMethod}), fits: ${budget.fits ? 'yes' : 'no'}, dropped: ${budget.dropped.length ? budget.dropped.join(', ') : 'nothing'}`]
507
+ : []
508
+
509
+ const joined = (parts: readonly (string | undefined)[]): string => parts.filter((part): part is string => Boolean(part && part.trim())).join('\n\n')
510
+
511
+ /** The search payload as prose: one numbered block per result, through `formatRetrievedDocuments`. */
512
+ export const formatKnowledgeSearchText = (response: KnowledgeSearchResponse): string => {
513
+ const documents: RetrievedDocument[] = response.results.map((result) => ({
514
+ id: result.entityId ?? result.id,
515
+ source: result.path,
516
+ score: result.score,
517
+ content: joined([`${result.title ?? result.id} [${result.kind ?? result.type}] score=${result.score.toFixed(2)}${result.confidence ? ` confidence=${result.confidence}` : ''}`, result.summary, result.excerpt]),
518
+ }))
519
+ return [`Query: ${response.query} (${response.count} result${response.count === 1 ? '' : 's'})`, ...budgetLine(response.budget), formatRetrievedDocuments(documents)].filter(Boolean).join('\n\n')
520
+ }
521
+
522
+ /** The lookup payload as prose: the entity, then one block per section that has content. */
523
+ export const formatKnowledgeLookupText = (response: KnowledgeLookupResponse): string => {
524
+ const { entity, handoff } = response
525
+ const documents: RetrievedDocument[] = [
526
+ {
527
+ id: entity.id,
528
+ source: entity.path,
529
+ content: joined([`${entity.title} [${entity.kind}] confidence=${entity.confidence}`, entity.summary, response.evidence[0]?.excerpt]),
530
+ },
531
+ ]
532
+ const neighbourLines = Object.entries(response.neighbours).flatMap(([kind, list]) => list.map((item) => `${kind} ${item.direction === 'inbound' ? '←' : '→'} ${item.id} (${item.kind}, ${item.distance} hop${item.distance === 1 ? '' : 's'})`))
533
+ if (neighbourLines.length) documents.push({ id: 'neighbours', content: neighbourLines.join('\n') })
534
+ const documentLines = [
535
+ ...response.documents.covering.map((item) => `covers: ${item.path}${item.summary ? ` — ${item.summary}` : ''}`),
536
+ ...response.documents.mentioning.map((item) => `mentions: ${item.path}${item.summary ? ` — ${item.summary}` : ''}`),
537
+ ]
538
+ if (documentLines.length) documents.push({ id: 'documents', content: documentLines.join('\n') })
539
+ documents.push({
540
+ id: 'handoff',
541
+ content: [
542
+ `startHere: ${handoff.startHere}`,
543
+ `readBeforeEditing: ${handoff.readBeforeEditing.join(', ')}`,
544
+ `editRoots: ${handoff.editRoots.join(', ')}`,
545
+ `checks: ${handoff.checks.join(', ') || '(none)'}`,
546
+ ...(handoff.related?.length ? [`related: ${handoff.related.map((row) => `${row.direction} ${row.id} (${row.strength})`).join(', ')}`] : []),
547
+ ].join('\n'),
548
+ })
549
+ if (response.diagnostics.open.length) {
550
+ documents.push({ id: 'diagnostics', content: response.diagnostics.open.map((item) => `[${item.severity}] ${item.code}: ${item.message}`).join('\n') })
551
+ }
552
+ documents.push({ id: 'evidence', content: response.evidence.map((item) => `${item.path}${item.contentHash ? ` @${item.contentHash.slice(0, 12)}` : ''}${item.context ? ` (${item.context})` : ''}`).join('\n') })
553
+ return [...budgetLine(response.budget), formatRetrievedDocuments(documents)].join('\n\n')
554
+ }