@agentskit/doc-bridge 0.1.0-alpha.1 → 0.1.0-alpha.3

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 (62) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +1 -1
  3. package/dist/cli/program.js +416 -148
  4. package/dist/cli/program.js.map +1 -1
  5. package/dist/config/index.d.ts +1 -1
  6. package/dist/config/index.js +6 -3
  7. package/dist/config/index.js.map +1 -1
  8. package/dist/{index-CD_zmKXf.d.ts → index-CPUJbTbg.d.ts} +19 -10
  9. package/dist/index.d.ts +25 -6
  10. package/dist/index.js +398 -132
  11. package/dist/index.js.map +1 -1
  12. package/docs/DOGFOOD-ROUND2.md +142 -0
  13. package/docs/DOGFOOD.md +92 -0
  14. package/package.json +32 -14
  15. package/scripts/prepare.mjs +44 -0
  16. package/src/cli/program.ts +947 -0
  17. package/src/config/defaults.ts +63 -0
  18. package/src/config/define-config.ts +6 -0
  19. package/src/config/index.ts +15 -0
  20. package/src/config/load-config.ts +138 -0
  21. package/src/config/schema.ts +294 -0
  22. package/src/federation/llms.ts +154 -0
  23. package/src/gates/run-gates.ts +274 -0
  24. package/src/index-builder/build-handoffs.ts +232 -0
  25. package/src/index-builder/build-index.ts +137 -0
  26. package/src/index-builder/capabilities.ts +37 -0
  27. package/src/index-builder/content-hash.ts +19 -0
  28. package/src/index-builder/human-adapters/core.ts +100 -0
  29. package/src/index-builder/human-adapters/docusaurus.ts +113 -0
  30. package/src/index-builder/human-adapters/fumadocs.ts +70 -0
  31. package/src/index-builder/human-adapters/index.ts +52 -0
  32. package/src/index-builder/human-adapters/plain-markdown.ts +10 -0
  33. package/src/index-builder/llms-txt.ts +19 -0
  34. package/src/index-builder/plugins/human-markdown.ts +7 -0
  35. package/src/index-builder/plugins/pnpm-monorepo.ts +72 -0
  36. package/src/index-builder/scan-corpus.ts +185 -0
  37. package/src/index.ts +120 -0
  38. package/src/intelligence/adapter.ts +90 -0
  39. package/src/intelligence/chat.ts +148 -0
  40. package/src/intelligence/peers.ts +59 -0
  41. package/src/intelligence/rag.ts +98 -0
  42. package/src/lib/glob-expand.ts +33 -0
  43. package/src/lib/markdown.ts +117 -0
  44. package/src/lib/package-manager.ts +104 -0
  45. package/src/lib/paths.ts +6 -0
  46. package/src/lib/walk.ts +45 -0
  47. package/src/mcp/server.ts +276 -0
  48. package/src/memory/ingest.ts +51 -0
  49. package/src/memory/pipeline.ts +119 -0
  50. package/src/query/load-index.ts +25 -0
  51. package/src/query/query.ts +142 -0
  52. package/src/query/search.ts +126 -0
  53. package/src/retriever/doc-bridge-retriever.ts +62 -0
  54. package/src/schemas/agent-handoff.ts +82 -0
  55. package/src/schemas/doc-bridge-index.ts +108 -0
  56. package/src/schemas/json-schemas.ts +157 -0
  57. package/src/schemas/memory-candidate.ts +37 -0
  58. package/src/shims/agentskit-peers.d.ts +80 -0
  59. package/src/validate.ts +67 -0
  60. package/src/version.ts +1 -0
  61. package/tsconfig.json +21 -0
  62. package/tsup.config.ts +15 -0
@@ -0,0 +1,274 @@
1
+ import { readFileSync } from 'node:fs'
2
+
3
+ import type { DocBridgeConfigV1 } from '../config/schema.js'
4
+ import { buildDocBridgeIndex } from '../index-builder/build-index.js'
5
+ import { scanAgentCorpus } from '../index-builder/scan-corpus.js'
6
+ import { scanHumanDocRecords } from '../index-builder/human-adapters/index.js'
7
+ import { IndexNotFoundError, loadDocBridgeIndex } from '../query/load-index.js'
8
+
9
+ export type GateId = 'index-freshness' | 'human-guide-links' | 'okf-type' | 'docs-style'
10
+
11
+ const SUPPORTED_GATES = ['index-freshness', 'human-guide-links', 'okf-type', 'docs-style'] as const
12
+
13
+ export type GateResult = {
14
+ readonly id: GateId
15
+ readonly ok: boolean
16
+ readonly message: string
17
+ readonly expected?: string
18
+ readonly actual?: string
19
+ }
20
+
21
+ export type GateRunResult = {
22
+ readonly ok: boolean
23
+ readonly results: GateResult[]
24
+ }
25
+
26
+ export const runGate = (
27
+ root: string,
28
+ config: DocBridgeConfigV1,
29
+ id: GateId,
30
+ ): GateResult => {
31
+ if (id === 'human-guide-links') return runHumanGuideLinksGate(root, config)
32
+ if (id === 'okf-type') return runOkfTypeGate(root, config)
33
+ if (id === 'docs-style') return runDocsStyleGate(root, config)
34
+ if (id !== 'index-freshness') throw new Error(`Unsupported gate "${id}"`)
35
+
36
+ let current: string
37
+ try {
38
+ current = loadDocBridgeIndex(root, config).contentHash
39
+ } catch (error) {
40
+ if (error instanceof IndexNotFoundError) {
41
+ return { id, ok: false, message: error.message }
42
+ }
43
+ throw error
44
+ }
45
+
46
+ const next = buildDocBridgeIndex({ root, config, write: false }).index.contentHash
47
+ if (current !== next) {
48
+ return {
49
+ id,
50
+ ok: false,
51
+ message: 'Index is stale. Run: ak-docs index',
52
+ expected: next,
53
+ actual: current,
54
+ }
55
+ }
56
+
57
+ return { id, ok: true, message: 'Index is fresh', expected: next, actual: current }
58
+ }
59
+
60
+ const runHumanGuideLinksGate = (root: string, config: DocBridgeConfigV1): GateResult => {
61
+ const urls = new Set(scanHumanDocRecords(root, config).map((doc) => doc.url))
62
+ const index = buildDocBridgeIndex({ root, config, write: false }).index
63
+ const localHumanDocs = Object.values(index.handoffs ?? {})
64
+ .map((handoff) => handoff.humanDoc)
65
+ .filter((url): url is string => typeof url === 'string' && url.length > 0)
66
+ .filter((url) => !/^https?:\/\//.test(url))
67
+ const missing = localHumanDocs.filter((url, i, all) => !urls.has(url) && all.indexOf(url) === i)
68
+
69
+ if (missing.length) {
70
+ return {
71
+ id: 'human-guide-links',
72
+ ok: false,
73
+ message: `Broken humanDoc links: ${missing.join(', ')}`,
74
+ expected: 'all local humanDoc links resolve',
75
+ actual: `${missing.length} broken`,
76
+ }
77
+ }
78
+
79
+ return {
80
+ id: 'human-guide-links',
81
+ ok: true,
82
+ message: `Resolved ${localHumanDocs.length} humanDoc link(s)`,
83
+ }
84
+ }
85
+
86
+ const frontmatterType = (markdown: string): string | undefined => {
87
+ const match = /^---\n([\s\S]*?)\n---/.exec(markdown)
88
+ return match?.[1]?.match(/^type:\s*['"]?([^'"\n#]+)['"]?/m)?.[1]?.trim()
89
+ }
90
+
91
+ const frontmatterField = (markdown: string, field: string): string | undefined => {
92
+ const match = /^---\n([\s\S]*?)\n---/.exec(markdown)
93
+ return match?.[1]?.match(new RegExp(`^${field}:\\s*['"]?([^'\"\\n#]+)['"]?`, 'm'))?.[1]?.trim()
94
+ }
95
+
96
+ const runOkfTypeGate = (root: string, config: DocBridgeConfigV1): GateResult => {
97
+ const required = config.corpus.agent.okf?.requireType ?? config.gates?.preset === 'strict'
98
+ if (!required) return { id: 'okf-type', ok: true, message: 'OKF type frontmatter not required' }
99
+
100
+ const allowed = config.corpus.agent.okf?.allowedTypes
101
+ const bad = scanAgentCorpus(root, config)
102
+ .filter((doc) => doc.path !== config.corpus.agent.index)
103
+ .map((doc) => ({ path: doc.path, type: frontmatterType(readFileSync(doc.absPath, 'utf8')) }))
104
+ .filter((doc) => !doc.type || (allowed && !allowed.includes(doc.type)))
105
+
106
+ if (bad.length) {
107
+ return {
108
+ id: 'okf-type',
109
+ ok: false,
110
+ message: `Missing or invalid OKF type frontmatter: ${bad.map((doc) => doc.path).join(', ')}`,
111
+ expected: allowed?.length ? `type: ${allowed.join(' | ')}` : 'type frontmatter',
112
+ actual: `${bad.length} invalid`,
113
+ }
114
+ }
115
+
116
+ return { id: 'okf-type', ok: true, message: 'All agent docs have OKF type frontmatter' }
117
+ }
118
+
119
+ type DocsStyleRule =
120
+ | 'title'
121
+ | 'purpose'
122
+ | 'audience'
123
+ | 'task-orientation'
124
+ | 'examples'
125
+ | 'owner-source'
126
+ | 'no-stale-wording'
127
+
128
+ type DocsStyleProfile =
129
+ | 'google-dev-docs'
130
+ | 'playbook-okf'
131
+ | 'playbook-okf-soft'
132
+ | 'title-only'
133
+ | 'custom'
134
+
135
+ const DOCS_STYLE_RULES: Record<Exclude<DocsStyleProfile, 'custom'>, DocsStyleRule[]> = {
136
+ 'google-dev-docs': [
137
+ 'title',
138
+ 'purpose',
139
+ 'audience',
140
+ 'task-orientation',
141
+ 'examples',
142
+ 'no-stale-wording',
143
+ ],
144
+ /** Full playbook OKF style (strict). */
145
+ 'playbook-okf': ['title', 'purpose', 'owner-source', 'no-stale-wording'],
146
+ /** Soft profile for large existing OKF corpora (title + no stale placeholders). */
147
+ 'playbook-okf-soft': ['title', 'no-stale-wording'],
148
+ 'title-only': ['title'],
149
+ }
150
+
151
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
152
+ typeof value === 'object' && value !== null && !Array.isArray(value)
153
+
154
+ const docsStyleOptions = (
155
+ config: DocBridgeConfigV1,
156
+ ): { profile: DocsStyleProfile; required: DocsStyleRule[] } => {
157
+ const raw = config.gates?.options?.['docs-style']
158
+ if (!isRecord(raw)) {
159
+ if (config.gates?.preset === 'playbook') {
160
+ return { profile: 'playbook-okf-soft', required: DOCS_STYLE_RULES['playbook-okf-soft'] }
161
+ }
162
+ return { profile: 'playbook-okf', required: DOCS_STYLE_RULES['playbook-okf'] }
163
+ }
164
+
165
+ const profile =
166
+ raw.profile === 'google-dev-docs' ||
167
+ raw.profile === 'playbook-okf' ||
168
+ raw.profile === 'playbook-okf-soft' ||
169
+ raw.profile === 'title-only' ||
170
+ raw.profile === 'custom'
171
+ ? raw.profile
172
+ : 'playbook-okf'
173
+ const customRequired = Array.isArray(raw.required)
174
+ ? raw.required.filter((rule): rule is DocsStyleRule =>
175
+ [
176
+ 'title',
177
+ 'purpose',
178
+ 'audience',
179
+ 'task-orientation',
180
+ 'examples',
181
+ 'owner-source',
182
+ 'no-stale-wording',
183
+ ].includes(String(rule)),
184
+ )
185
+ : []
186
+
187
+ return {
188
+ profile,
189
+ required: profile === 'custom' ? customRequired : DOCS_STYLE_RULES[profile],
190
+ }
191
+ }
192
+
193
+ const missingStyleRules = (markdown: string, rules: readonly DocsStyleRule[]): DocsStyleRule[] => {
194
+ const checks: Record<DocsStyleRule, boolean> = {
195
+ title: /^#\s+\S+/m.test(markdown),
196
+ purpose: Boolean(frontmatterField(markdown, 'purpose') || /^##\s+(Purpose|Goal)\b/im.test(markdown)),
197
+ audience: Boolean(frontmatterField(markdown, 'audience') || /^##\s+Audience\b/im.test(markdown)),
198
+ 'task-orientation': /^##\s+(How to|Usage|Workflow|Tasks?)\b/im.test(markdown),
199
+ examples: /^##\s+Examples?\b/im.test(markdown) || /```[\s\S]*?```/.test(markdown),
200
+ 'owner-source': Boolean(
201
+ frontmatterField(markdown, 'owner') ||
202
+ frontmatterField(markdown, 'source') ||
203
+ /^(Owner|Source):\s+\S+/im.test(markdown),
204
+ ),
205
+ 'no-stale-wording': !/\b(TODO|TBD|coming soon|eventually|placeholder)\b/i.test(markdown),
206
+ }
207
+
208
+ return rules.filter((rule) => !checks[rule])
209
+ }
210
+
211
+ const runDocsStyleGate = (root: string, config: DocBridgeConfigV1): GateResult => {
212
+ const { profile, required } = docsStyleOptions(config)
213
+ if (!required.length) {
214
+ return { id: 'docs-style', ok: true, message: 'No docs-style rules configured' }
215
+ }
216
+
217
+ const bad = scanAgentCorpus(root, config)
218
+ .filter((doc) => doc.path !== config.corpus.agent.index)
219
+ .map((doc) => ({
220
+ path: doc.path,
221
+ missing: missingStyleRules(readFileSync(doc.absPath, 'utf8'), required),
222
+ }))
223
+ .filter((doc) => doc.missing.length > 0)
224
+
225
+ if (bad.length) {
226
+ return {
227
+ id: 'docs-style',
228
+ ok: false,
229
+ message: `Docs style issues: ${bad
230
+ .map((doc) => `${doc.path} (${doc.missing.join(', ')})`)
231
+ .join('; ')}`,
232
+ expected: `${profile}: ${required.join(', ')}`,
233
+ actual: `${bad.length} file(s) with style issues`,
234
+ }
235
+ }
236
+
237
+ return {
238
+ id: 'docs-style',
239
+ ok: true,
240
+ message: `All agent docs pass ${profile} docs-style rules`,
241
+ }
242
+ }
243
+
244
+ export const runGates = (
245
+ root: string,
246
+ config: DocBridgeConfigV1,
247
+ ids?: readonly GateId[],
248
+ ): GateRunResult => {
249
+ const results = (ids ?? resolveGateIds(config)).map((id) => runGate(root, config, id))
250
+ return { ok: results.every((result) => result.ok), results }
251
+ }
252
+
253
+ export const resolveGateIds = (config: DocBridgeConfigV1): GateId[] => {
254
+ const preset = config.gates?.preset ?? 'minimal'
255
+ const ids = new Set<GateId>(
256
+ preset === 'minimal'
257
+ ? ['index-freshness']
258
+ : preset === 'strict'
259
+ ? ['index-freshness', 'human-guide-links', 'okf-type', 'docs-style']
260
+ : preset === 'playbook'
261
+ ? // okf-type only — docs-style soft is opt-in via include (large corpora vary)
262
+ ['index-freshness', 'okf-type']
263
+ : ['index-freshness', 'human-guide-links'],
264
+ )
265
+
266
+ for (const id of config.gates?.include ?? []) {
267
+ if (SUPPORTED_GATES.includes(id as GateId)) ids.add(id as GateId)
268
+ }
269
+ for (const id of config.gates?.exclude ?? []) {
270
+ if (SUPPORTED_GATES.includes(id as GateId)) ids.delete(id as GateId)
271
+ }
272
+
273
+ return [...ids]
274
+ }
@@ -0,0 +1,232 @@
1
+ import type { DocBridgeConfigV1 } from '../config/schema.js'
2
+ import { defaultChecksForTarget } from '../lib/package-manager.js'
3
+ import type { AgentHandoffV1 } from '../schemas/agent-handoff.js'
4
+ import {
5
+ guessAgentDocForPackage,
6
+ ownershipFromCorpus,
7
+ ownershipFromFrontmatter,
8
+ type CorpusDoc,
9
+ } from './scan-corpus.js'
10
+ import type { DiscoveredPackage } from './plugins/pnpm-monorepo.js'
11
+ import type { HumanDocMap } from './plugins/human-markdown.js'
12
+
13
+ export type IndexLookup = {
14
+ packages: string[]
15
+ ownership: Record<string, OwnershipRecord>
16
+ intents: Record<string, IntentRecord>
17
+ changes: Record<string, ChangeRecord>
18
+ }
19
+
20
+ export type OwnershipRecord = {
21
+ id: string
22
+ path: string
23
+ group?: string
24
+ layer?: string
25
+ purpose?: string
26
+ checks: string[]
27
+ agentDoc?: string
28
+ humanDoc?: string
29
+ readme?: string
30
+ }
31
+
32
+ export type IntentRecord = {
33
+ id: string
34
+ title: string
35
+ paths: string[]
36
+ }
37
+
38
+ export type ChangeRecord = {
39
+ id: string
40
+ title: string
41
+ startHere: string
42
+ relatedPackages?: string[]
43
+ }
44
+
45
+ /**
46
+ * Merge package identity from:
47
+ * 1. routing.options.ownership (explicit)
48
+ * 2. pnpm / workspace discovery
49
+ * 3. agent-doc frontmatter (package + editRoot)
50
+ * 4. corpus path heuristics (packages/<id>.md, pillars patterns, etc.)
51
+ */
52
+ export const collectPackages = (
53
+ config: DocBridgeConfigV1,
54
+ discovered: readonly DiscoveredPackage[],
55
+ corpus: readonly CorpusDoc[],
56
+ ): DiscoveredPackage[] => {
57
+ const byId = new Map<string, DiscoveredPackage>()
58
+
59
+ for (const [id, entry] of Object.entries(config.routing?.options?.ownership ?? {})) {
60
+ byId.set(id, { id, path: entry.path })
61
+ }
62
+
63
+ for (const pkg of discovered) {
64
+ const existing = byId.get(pkg.id)
65
+ if (!existing) {
66
+ byId.set(pkg.id, pkg)
67
+ continue
68
+ }
69
+ byId.set(pkg.id, {
70
+ id: pkg.id,
71
+ path: existing.path || pkg.path,
72
+ ...(pkg.name ? { name: pkg.name } : existing.name ? { name: existing.name } : {}),
73
+ })
74
+ }
75
+
76
+ const seeds = [
77
+ ...ownershipFromFrontmatter(corpus),
78
+ ...(config.routing?.options?.ownershipFromCorpus === false
79
+ ? []
80
+ : ownershipFromCorpus(corpus)),
81
+ ]
82
+
83
+ for (const seed of seeds) {
84
+ const existing = byId.get(seed.id)
85
+ if (!existing) {
86
+ byId.set(seed.id, { id: seed.id, path: seed.path })
87
+ continue
88
+ }
89
+ if (!existing.path) {
90
+ byId.set(seed.id, { ...existing, path: seed.path })
91
+ }
92
+ }
93
+
94
+ return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id))
95
+ }
96
+
97
+ const resolveHumanDoc = (
98
+ packageId: string,
99
+ override?: string,
100
+ fmHuman?: string,
101
+ humanDocs: HumanDocMap = {},
102
+ ): string | undefined => {
103
+ if (override) return override
104
+ if (fmHuman) return fmHuman
105
+ if (humanDocs[packageId]) return humanDocs[packageId]
106
+
107
+ const aliases = [
108
+ packageId,
109
+ packageId.replace(/^@[^/]+\//, ''),
110
+ packageId.replace(/^os-/, ''),
111
+ packageId.replace(/-pattern$/, ''),
112
+ `packages/${packageId}`,
113
+ `reference/packages/${packageId}`,
114
+ `packages/${packageId}/index`,
115
+ ]
116
+ for (const alias of aliases) {
117
+ if (humanDocs[alias]) return humanDocs[alias]
118
+ }
119
+
120
+ // Fuzzy: id is a path segment or suffix of a human doc id
121
+ for (const [key, url] of Object.entries(humanDocs)) {
122
+ if (
123
+ key === packageId ||
124
+ key.endsWith(`/${packageId}`) ||
125
+ key.endsWith(`/${packageId}/index`) ||
126
+ key.endsWith(`-${packageId}`)
127
+ ) {
128
+ return url
129
+ }
130
+ }
131
+ return undefined
132
+ }
133
+
134
+ export const buildLookup = (
135
+ config: DocBridgeConfigV1,
136
+ packages: readonly DiscoveredPackage[],
137
+ corpus: readonly CorpusDoc[],
138
+ indexOutFile: string,
139
+ humanDocs: HumanDocMap = {},
140
+ root = process.cwd(),
141
+ ): { lookup: IndexLookup; handoffs: Record<string, AgentHandoffV1> } => {
142
+ const ownership: Record<string, OwnershipRecord> = {}
143
+ const handoffs: Record<string, AgentHandoffV1> = {}
144
+ const strict = (config.gates?.preset ?? 'minimal') !== 'minimal'
145
+ const fmSeeds = new Map(
146
+ [...ownershipFromFrontmatter(corpus), ...ownershipFromCorpus(corpus)].map((seed) => [
147
+ seed.id,
148
+ seed,
149
+ ]),
150
+ )
151
+
152
+ for (const pkg of packages) {
153
+ const override = config.routing?.options?.ownership?.[pkg.id]
154
+ const fm = fmSeeds.get(pkg.id)
155
+ const agentDoc =
156
+ override?.agentDoc ??
157
+ fm?.agentDoc ??
158
+ guessAgentDocForPackage(corpus, pkg.id) ??
159
+ config.corpus.agent.index
160
+ const startHere = agentDoc ?? ''
161
+ const purpose = override?.purpose ?? fm?.purpose
162
+ const path = override?.path ?? fm?.path ?? pkg.path
163
+ const checks = [
164
+ ...(override?.checks ??
165
+ fm?.checks ??
166
+ defaultChecksForTarget(root, {
167
+ packageId: pkg.id,
168
+ packagePath: path,
169
+ ...(pkg.name ? { packageName: pkg.name } : {}),
170
+ strict,
171
+ })),
172
+ ]
173
+ const humanDoc = resolveHumanDoc(pkg.id, override?.humanDoc, fm?.humanDoc, humanDocs)
174
+ const record: OwnershipRecord = {
175
+ id: pkg.id,
176
+ path,
177
+ checks,
178
+ ...(override?.group ? { group: override.group } : {}),
179
+ ...(override?.layer ? { layer: override.layer } : {}),
180
+ ...(purpose ? { purpose } : {}),
181
+ ...(humanDoc ? { humanDoc } : {}),
182
+ ...(agentDoc ? { agentDoc } : {}),
183
+ }
184
+ ownership[pkg.id] = record
185
+
186
+ handoffs[pkg.id] = {
187
+ type: 'agent-handoff',
188
+ schemaVersion: 1,
189
+ source: indexOutFile,
190
+ target: {
191
+ type: 'package',
192
+ id: pkg.id,
193
+ path: record.path,
194
+ ...(record.group ? { group: record.group } : {}),
195
+ ...(record.layer ? { layer: record.layer } : {}),
196
+ },
197
+ startHere,
198
+ readBeforeEditing: [agentDoc, 'AGENTS.md'].filter((v, i, a): v is string =>
199
+ Boolean(v) && a.indexOf(v) === i,
200
+ ),
201
+ editRoots: [record.path],
202
+ checks: [...record.checks],
203
+ ...(record.humanDoc ? { humanDoc: record.humanDoc } : {}),
204
+ notes: record.purpose ? [record.purpose] : [],
205
+ }
206
+ }
207
+
208
+ const intents: Record<string, IntentRecord> = {}
209
+ for (const intent of config.routing?.options?.intents ?? []) {
210
+ intents[intent.id] = { id: intent.id, title: intent.title, paths: intent.paths }
211
+ }
212
+
213
+ const changes: Record<string, ChangeRecord> = {}
214
+ for (const change of config.routing?.options?.changes ?? []) {
215
+ changes[change.id] = {
216
+ id: change.id,
217
+ title: change.title,
218
+ startHere: change.startHere,
219
+ ...(change.relatedPackages ? { relatedPackages: change.relatedPackages } : {}),
220
+ }
221
+ }
222
+
223
+ return {
224
+ lookup: {
225
+ packages: packages.map((p) => p.id),
226
+ ownership,
227
+ intents,
228
+ changes,
229
+ },
230
+ handoffs,
231
+ }
232
+ }
@@ -0,0 +1,137 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ import type { DocBridgeConfigV1 } from '../config/schema.js'
5
+ import { toPosix } from '../lib/paths.js'
6
+ import type { DocBridgeIndexV1 } from '../schemas/doc-bridge-index.js'
7
+ import { buildLookup, collectPackages } from './build-handoffs.js'
8
+ import { renderCapabilitiesJson } from './capabilities.js'
9
+ import { sha256NormalizedV1 } from './content-hash.js'
10
+ import { renderLlmsTxt } from './llms-txt.js'
11
+ import { scanHumanDocs } from './human-adapters/index.js'
12
+ import { discoverPnpmPackages } from './plugins/pnpm-monorepo.js'
13
+ import { scanAgentCorpus } from './scan-corpus.js'
14
+
15
+ export type BuildIndexOptions = {
16
+ readonly root?: string
17
+ readonly config: DocBridgeConfigV1
18
+ readonly write?: boolean
19
+ }
20
+
21
+ export type BuildIndexResult = {
22
+ readonly index: DocBridgeIndexV1
23
+ readonly indexPath: string
24
+ readonly llmsTxtPath?: string
25
+ readonly capabilitiesPath?: string
26
+ }
27
+
28
+ const projectName = (root: string, config: DocBridgeConfigV1): string => {
29
+ if (config.project?.name) return config.project.name
30
+ try {
31
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { name?: string }
32
+ if (pkg.name) return pkg.name
33
+ } catch {
34
+ // ignore
35
+ }
36
+ return toPosix(root.split('/').pop() ?? 'project')
37
+ }
38
+
39
+ const existingGeneratedAt = (indexPath: string, contentHash: string): string | undefined => {
40
+ try {
41
+ const index = JSON.parse(readFileSync(indexPath, 'utf8')) as {
42
+ contentHash?: unknown
43
+ generatedAt?: unknown
44
+ }
45
+ return index.contentHash === contentHash && typeof index.generatedAt === 'string'
46
+ ? index.generatedAt
47
+ : undefined
48
+ } catch {
49
+ return undefined
50
+ }
51
+ }
52
+
53
+ export const buildDocBridgeIndex = (opts: BuildIndexOptions): BuildIndexResult => {
54
+ const root = opts.root ?? process.cwd()
55
+ const config = opts.config
56
+ const write = opts.write ?? true
57
+ const outFile = config.index?.outFile ?? '.doc-bridge/index.json'
58
+ const indexPath = join(root, outFile)
59
+
60
+ const corpus = scanAgentCorpus(root, config)
61
+ const knowledge = corpus.map(({ absPath: _a, relPath: _r, frontmatter: _f, ...entry }) => entry)
62
+
63
+ const shouldDiscover =
64
+ config.routing?.plugin === 'pnpm-monorepo' ||
65
+ Boolean(config.routing?.options?.packages?.length) ||
66
+ config.routing?.plugin === 'npm-workspaces' ||
67
+ config.routing?.plugin === 'yarn-workspaces'
68
+
69
+ const discovered = shouldDiscover ? discoverPnpmPackages(root, config) : []
70
+ const packages = collectPackages(config, discovered, corpus)
71
+ const humanDocs = scanHumanDocs(root, config)
72
+
73
+ const { lookup, handoffs } = buildLookup(config, packages, corpus, outFile, humanDocs, root)
74
+
75
+ const hashPayload = {
76
+ schemaVersion: 1,
77
+ knowledge,
78
+ handoffs,
79
+ lookup,
80
+ }
81
+
82
+ const contentHash = sha256NormalizedV1(hashPayload)
83
+ const index: DocBridgeIndexV1 = {
84
+ schemaVersion: 1,
85
+ contentHash,
86
+ contentHashAlgo: 'sha256-normalized-v1',
87
+ generatedAt: existingGeneratedAt(indexPath, contentHash) ?? new Date().toISOString(),
88
+ project: { name: projectName(root, config), root: '.' },
89
+ knowledge,
90
+ handoffs,
91
+ lookup,
92
+ }
93
+
94
+ if (write) {
95
+ mkdirSync(dirname(indexPath), { recursive: true })
96
+ writeFileSync(indexPath, `${JSON.stringify(index, null, 2)}\n`, 'utf8')
97
+ }
98
+
99
+ let llmsTxtPath: string | undefined
100
+ let llmsTxtRelPath: string | undefined
101
+ if (config.index?.llmsTxt?.enabled !== false) {
102
+ const llmsOut = config.index?.llmsTxt?.outFile ?? 'llms.txt'
103
+ llmsTxtRelPath = toPosix(llmsOut)
104
+ llmsTxtPath = join(root, llmsOut)
105
+ if (write) {
106
+ writeFileSync(
107
+ llmsTxtPath,
108
+ renderLlmsTxt(config, knowledge, index.project?.name ?? 'project'),
109
+ 'utf8',
110
+ )
111
+ }
112
+ }
113
+
114
+ let capabilitiesPath: string | undefined
115
+ if (config.index?.capabilities?.enabled !== false) {
116
+ const capabilitiesOut = config.index?.capabilities?.outFile ?? '.doc-bridge/capabilities.json'
117
+ capabilitiesPath = join(root, capabilitiesOut)
118
+ if (write) {
119
+ mkdirSync(dirname(capabilitiesPath), { recursive: true })
120
+ writeFileSync(
121
+ capabilitiesPath,
122
+ renderCapabilitiesJson(config, index, {
123
+ index: toPosix(outFile),
124
+ ...(llmsTxtRelPath ? { llmsTxt: llmsTxtRelPath } : {}),
125
+ }),
126
+ 'utf8',
127
+ )
128
+ }
129
+ }
130
+
131
+ return {
132
+ index,
133
+ indexPath: toPosix(indexPath),
134
+ ...(llmsTxtPath ? { llmsTxtPath: toPosix(llmsTxtPath) } : {}),
135
+ ...(capabilitiesPath ? { capabilitiesPath: toPosix(capabilitiesPath) } : {}),
136
+ }
137
+ }
@@ -0,0 +1,37 @@
1
+ import type { DocBridgeConfigV1 } from '../config/schema.js'
2
+ import type { DocBridgeIndexV1 } from '../schemas/doc-bridge-index.js'
3
+ import { PACKAGE_VERSION } from '../version.js'
4
+
5
+ export const renderCapabilitiesJson = (
6
+ config: DocBridgeConfigV1,
7
+ index: DocBridgeIndexV1,
8
+ paths: { readonly index: string; readonly llmsTxt?: string },
9
+ ): string => {
10
+ const payload = {
11
+ schemaVersion: 1,
12
+ type: 'doc-bridge-capabilities',
13
+ package: '@agentskit/doc-bridge',
14
+ version: PACKAGE_VERSION,
15
+ project: index.project,
16
+ contentHash: index.contentHash,
17
+ artifacts: {
18
+ index: paths.index,
19
+ ...(paths.llmsTxt ? { llmsTxt: paths.llmsTxt } : {}),
20
+ },
21
+ capabilities: [
22
+ 'index',
23
+ 'query',
24
+ 'search',
25
+ 'agent-handoff',
26
+ 'gates',
27
+ ...(config.surfaces?.mcp?.enabled === false ? [] : ['mcp']),
28
+ ...(config.corpus.human ? ['human-docs'] : []),
29
+ ],
30
+ surfaces: {
31
+ cli: config.surfaces?.cli,
32
+ mcp: config.surfaces?.mcp,
33
+ },
34
+ }
35
+
36
+ return `${JSON.stringify(payload, null, 2)}\n`
37
+ }
@@ -0,0 +1,19 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ const sortValue = (value: unknown): unknown => {
4
+ if (Array.isArray(value)) return value.map(sortValue)
5
+ if (value && typeof value === 'object') {
6
+ const record = value as Record<string, unknown>
7
+ return Object.fromEntries(
8
+ Object.keys(record)
9
+ .sort()
10
+ .map((key) => [key, sortValue(record[key])]),
11
+ )
12
+ }
13
+ return value
14
+ }
15
+
16
+ export const sha256NormalizedV1 = (payload: unknown): string => {
17
+ const normalized = JSON.stringify(sortValue(payload))
18
+ return createHash('sha256').update(normalized, 'utf8').digest('hex')
19
+ }