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

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 (61) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/dist/cli/program.js +283 -105
  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 +5 -4
  10. package/dist/index.js +278 -100
  11. package/dist/index.js.map +1 -1
  12. package/docs/DOGFOOD.md +92 -0
  13. package/package.json +27 -9
  14. package/scripts/prepare.mjs +44 -0
  15. package/src/cli/program.ts +933 -0
  16. package/src/config/defaults.ts +63 -0
  17. package/src/config/define-config.ts +6 -0
  18. package/src/config/index.ts +15 -0
  19. package/src/config/load-config.ts +138 -0
  20. package/src/config/schema.ts +294 -0
  21. package/src/federation/llms.ts +145 -0
  22. package/src/gates/run-gates.ts +274 -0
  23. package/src/index-builder/build-handoffs.ts +217 -0
  24. package/src/index-builder/build-index.ts +137 -0
  25. package/src/index-builder/capabilities.ts +37 -0
  26. package/src/index-builder/content-hash.ts +19 -0
  27. package/src/index-builder/human-adapters/core.ts +100 -0
  28. package/src/index-builder/human-adapters/docusaurus.ts +113 -0
  29. package/src/index-builder/human-adapters/fumadocs.ts +70 -0
  30. package/src/index-builder/human-adapters/index.ts +52 -0
  31. package/src/index-builder/human-adapters/plain-markdown.ts +10 -0
  32. package/src/index-builder/llms-txt.ts +19 -0
  33. package/src/index-builder/plugins/human-markdown.ts +7 -0
  34. package/src/index-builder/plugins/pnpm-monorepo.ts +72 -0
  35. package/src/index-builder/scan-corpus.ts +181 -0
  36. package/src/index.ts +120 -0
  37. package/src/intelligence/adapter.ts +90 -0
  38. package/src/intelligence/chat.ts +148 -0
  39. package/src/intelligence/peers.ts +59 -0
  40. package/src/intelligence/rag.ts +98 -0
  41. package/src/lib/glob-expand.ts +33 -0
  42. package/src/lib/markdown.ts +90 -0
  43. package/src/lib/package-manager.ts +104 -0
  44. package/src/lib/paths.ts +6 -0
  45. package/src/lib/walk.ts +45 -0
  46. package/src/mcp/server.ts +276 -0
  47. package/src/memory/ingest.ts +51 -0
  48. package/src/memory/pipeline.ts +119 -0
  49. package/src/query/load-index.ts +25 -0
  50. package/src/query/query.ts +142 -0
  51. package/src/query/search.ts +58 -0
  52. package/src/retriever/doc-bridge-retriever.ts +62 -0
  53. package/src/schemas/agent-handoff.ts +82 -0
  54. package/src/schemas/doc-bridge-index.ts +106 -0
  55. package/src/schemas/json-schemas.ts +156 -0
  56. package/src/schemas/memory-candidate.ts +37 -0
  57. package/src/shims/agentskit-peers.d.ts +80 -0
  58. package/src/validate.ts +67 -0
  59. package/src/version.ts +1 -0
  60. package/tsconfig.json +21 -0
  61. package/tsup.config.ts +15 -0
@@ -0,0 +1,63 @@
1
+ import type { DocBridgeConfigV1 } from './schema.js'
2
+
3
+ const DEFAULT_AGENT_EXCLUDE = ['**/node_modules/**', '**/.git/**'] as const
4
+
5
+ export const applyConfigDefaults = (config: DocBridgeConfigV1): DocBridgeConfigV1 => {
6
+ const agentRoot = config.corpus.agent.root
7
+ const agentIndex = config.corpus.agent.index ?? `${agentRoot}/INDEX.md`
8
+
9
+ return {
10
+ ...config,
11
+ corpus: {
12
+ ...config.corpus,
13
+ agent: {
14
+ ...config.corpus.agent,
15
+ index: agentIndex,
16
+ include: config.corpus.agent.include ?? ['**/*.{md,mdx}'],
17
+ exclude: config.corpus.agent.exclude ?? [...DEFAULT_AGENT_EXCLUDE],
18
+ },
19
+ },
20
+ index: {
21
+ outFile: '.doc-bridge/index.json',
22
+ contentHash: 'sha256-normalized-v1',
23
+ llmsTxt: {
24
+ enabled: true,
25
+ outFile: 'llms.txt',
26
+ ...config.index?.llmsTxt,
27
+ },
28
+ capabilities: {
29
+ enabled: true,
30
+ outFile: '.doc-bridge/capabilities.json',
31
+ ...config.index?.capabilities,
32
+ },
33
+ ...config.index,
34
+ },
35
+ gates: {
36
+ preset: 'minimal',
37
+ ...config.gates,
38
+ },
39
+ surfaces: {
40
+ cli: {
41
+ bin: 'ak-docs',
42
+ defaultFormat: 'json',
43
+ ...config.surfaces?.cli,
44
+ },
45
+ mcp: {
46
+ enabled: true,
47
+ tools: ['handoff.resolve', 'doc.search', 'doc.get', 'gate.status'],
48
+ transport: 'stdio',
49
+ ...config.surfaces?.mcp,
50
+ },
51
+ ...config.surfaces,
52
+ },
53
+ intelligence: config.intelligence
54
+ ? {
55
+ enabled: false,
56
+ ...config.intelligence,
57
+ chat: config.intelligence.chat
58
+ ? { handoffFirst: true, ...config.intelligence.chat }
59
+ : undefined,
60
+ }
61
+ : { enabled: false },
62
+ }
63
+ }
@@ -0,0 +1,6 @@
1
+ import { applyConfigDefaults } from './defaults.js'
2
+ import { DocBridgeConfigV1Schema, type DocBridgeConfigV1 } from './schema.js'
3
+
4
+ /** Type-safe config helper for `doc-bridge.config.ts`. */
5
+ export const defineConfig = (config: DocBridgeConfigV1): DocBridgeConfigV1 =>
6
+ applyConfigDefaults(DocBridgeConfigV1Schema.parse(config))
@@ -0,0 +1,15 @@
1
+ export { defineConfig } from './define-config.js'
2
+ export { applyConfigDefaults } from './defaults.js'
3
+ export {
4
+ loadConfig,
5
+ resolveProjectRoot,
6
+ projectRootFromConfigPath,
7
+ ConfigNotFoundError,
8
+ } from './load-config.js'
9
+ export {
10
+ DocBridgeConfigV1Schema,
11
+ AgentCorpusConfigSchema,
12
+ HumanCorpusConfigSchema,
13
+ type DocBridgeConfigV1,
14
+ type AgentCorpusConfig,
15
+ } from './schema.js'
@@ -0,0 +1,138 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { basename, dirname, join, resolve } from 'node:path'
3
+ import vm from 'node:vm'
4
+
5
+ import { applyConfigDefaults } from './defaults.js'
6
+ import { DocBridgeConfigV1Schema, type DocBridgeConfigV1 } from './schema.js'
7
+
8
+ const CONFIG_CANDIDATES = [
9
+ 'doc-bridge.config.ts',
10
+ 'doc-bridge.config.mts',
11
+ 'doc-bridge.config.js',
12
+ 'doc-bridge.config.mjs',
13
+ 'doc-bridge.config.json',
14
+ 'doc-bridge.config.yaml',
15
+ 'doc-bridge.config.yml',
16
+ 'package.json',
17
+ ] as const
18
+
19
+ export type LoadConfigOptions = {
20
+ readonly cwd?: string
21
+ readonly explicitPath?: string
22
+ }
23
+
24
+ export type LoadConfigResult = {
25
+ readonly config: DocBridgeConfigV1
26
+ readonly path: string
27
+ }
28
+
29
+ export class ConfigNotFoundError extends Error {
30
+ constructor(readonly cwd: string) {
31
+ super(
32
+ `No doc-bridge config found in ${cwd}. Run: ak-docs init — or pass --config <path>.`,
33
+ )
34
+ this.name = 'ConfigNotFoundError'
35
+ }
36
+ }
37
+
38
+ const findConfigPath = (cwd: string, explicitPath?: string): string | null => {
39
+ if (explicitPath) {
40
+ const abs = resolve(cwd, explicitPath)
41
+ return existsSync(abs) ? abs : null
42
+ }
43
+ for (const name of CONFIG_CANDIDATES) {
44
+ const candidate = join(cwd, name)
45
+ if (!existsSync(candidate)) continue
46
+ if (name !== 'package.json') return candidate
47
+ const pkg = parseJsonConfig(readFileSync(candidate, 'utf8'), candidate) as { docBridge?: unknown }
48
+ if (pkg.docBridge) return candidate
49
+ }
50
+ return null
51
+ }
52
+
53
+ const parseJsonConfig = (raw: string, path: string): unknown => {
54
+ try {
55
+ return JSON.parse(raw) as unknown
56
+ } catch (error) {
57
+ const message = error instanceof Error ? error.message : String(error)
58
+ throw new Error(`Failed to parse JSON config at ${path}: ${message}`)
59
+ }
60
+ }
61
+
62
+ const parseCodeConfig = (raw: string, path: string): unknown => {
63
+ const code = raw
64
+ .replace(/import\s+type\s+[\s\S]*?;?\n/g, '')
65
+ .replace(/import\s+\{\s*defineConfig\s*\}\s+from\s+['"]@agentskit\/doc-bridge(?:\/config)?['"];?\n?/g, '')
66
+ .replace(/\s+satisfies\s+[A-Za-z0-9_.$<>{}\[\],\s]+(?=\s*(?:;|\)|$))/g, '')
67
+ .replace(/export\s+default/, 'module.exports.default =')
68
+
69
+ if (/\bimport\b/.test(code)) {
70
+ throw new Error(`Unsupported import in ${path}. Static config only supports defineConfig imports.`)
71
+ }
72
+
73
+ const sandbox = {
74
+ module: { exports: {} as { default?: unknown } },
75
+ exports: {},
76
+ defineConfig: (config: unknown) => config,
77
+ }
78
+ try {
79
+ vm.runInNewContext(code, sandbox, { timeout: 250, filename: path })
80
+ } catch (error) {
81
+ const message = error instanceof Error ? error.message : String(error)
82
+ throw new Error(`Failed to load config at ${path}: ${message}`)
83
+ }
84
+ return sandbox.module.exports.default
85
+ }
86
+
87
+ const parseConfig = (input: unknown): DocBridgeConfigV1 => {
88
+ const result = DocBridgeConfigV1Schema.safeParse(input)
89
+ if (result.success) return result.data
90
+ throw new Error(
91
+ `Invalid doc-bridge config:\n${result.error.issues.map((issue) =>
92
+ ` - ${issue.path.join('.') || '(root)'}: ${issue.message}`,
93
+ ).join('\n')}`,
94
+ )
95
+ }
96
+
97
+ /** v0.1 — static JS/TS config, JSON config files, and package.json#docBridge. */
98
+ export const loadConfig = (opts: LoadConfigOptions = {}): LoadConfigResult => {
99
+ const cwd = resolve(opts.cwd ?? process.cwd())
100
+ const path = findConfigPath(cwd, opts.explicitPath)
101
+ if (!path) throw new ConfigNotFoundError(cwd)
102
+
103
+ const raw = readFileSync(path, 'utf8')
104
+ const ext = path.split('.').pop()?.toLowerCase()
105
+ if (ext === 'yaml' || ext === 'yml') {
106
+ throw new Error(`YAML config is not supported yet (${path}). Use doc-bridge.config.json for now.`)
107
+ }
108
+
109
+ const parsed =
110
+ basename(path) === 'package.json'
111
+ ? (parseJsonConfig(raw, path) as { docBridge?: unknown }).docBridge
112
+ : ext === 'ts' || ext === 'mts' || ext === 'js' || ext === 'mjs'
113
+ ? parseCodeConfig(raw, path)
114
+ : parseJsonConfig(raw, path)
115
+ const config = applyConfigDefaults(parseConfig(parsed))
116
+ return { config, path }
117
+ }
118
+
119
+ export const resolveProjectRoot = (start = process.cwd()): string => {
120
+ let cur = resolve(start)
121
+ for (let i = 0; i < 12; i += 1) {
122
+ if (findConfigPath(cur)) return cur
123
+ const parent = dirname(cur)
124
+ if (parent === cur) break
125
+ cur = parent
126
+ }
127
+ return resolve(start)
128
+ }
129
+
130
+ /** Project root for a loaded config file path (directory of the config). */
131
+ export const projectRootFromConfigPath = (
132
+ configFilePath: string,
133
+ projectRootField?: string,
134
+ ): string => {
135
+ const configDir = dirname(resolve(configFilePath))
136
+ if (projectRootField) return resolve(configDir, projectRootField)
137
+ return configDir
138
+ }
@@ -0,0 +1,294 @@
1
+ import { z } from 'zod'
2
+
3
+ export const CONFIG_SCHEMA_VERSION = 1 as const
4
+
5
+ export const HumanCorpusPluginIdSchema = z.enum([
6
+ 'plain-markdown',
7
+ 'fumadocs',
8
+ 'docusaurus',
9
+ 'mkdocs',
10
+ 'vitepress',
11
+ 'custom',
12
+ ])
13
+
14
+ export const AgentCorpusConfigSchema = z
15
+ .object({
16
+ root: z.string().min(1).max(512),
17
+ index: z.string().min(1).max(512).optional(),
18
+ include: z.array(z.string().min(1).max(256)).max(64).optional(),
19
+ exclude: z.array(z.string().min(1).max(256)).max(64).optional(),
20
+ okf: z
21
+ .object({
22
+ requireType: z.boolean().optional(),
23
+ allowedTypes: z.array(z.string().min(1).max(128)).max(64).optional(),
24
+ })
25
+ .strict()
26
+ .optional(),
27
+ })
28
+ .strict()
29
+
30
+ export const HumanCorpusConfigSchema = z
31
+ .object({
32
+ plugin: HumanCorpusPluginIdSchema,
33
+ options: z.record(z.string(), z.unknown()).optional(),
34
+ })
35
+ .strict()
36
+
37
+ export const IndexConfigSchema = z
38
+ .object({
39
+ outFile: z.string().min(1).max(512).optional(),
40
+ llmsTxt: z
41
+ .object({
42
+ enabled: z.boolean().optional(),
43
+ outFile: z.string().min(1).max(512).optional(),
44
+ preamble: z.string().max(4_000).optional(),
45
+ })
46
+ .strict()
47
+ .optional(),
48
+ capabilities: z
49
+ .object({
50
+ enabled: z.boolean().optional(),
51
+ outFile: z.string().min(1).max(512).optional(),
52
+ })
53
+ .strict()
54
+ .optional(),
55
+ contentHash: z.literal('sha256-normalized-v1').optional(),
56
+ })
57
+ .strict()
58
+
59
+ export const OwnershipEntrySchema = z
60
+ .object({
61
+ path: z.string().min(1).max(512),
62
+ group: z.string().min(1).max(128).optional(),
63
+ layer: z.string().min(1).max(32).optional(),
64
+ purpose: z.string().max(1024).optional(),
65
+ checks: z.array(z.string().min(1).max(256)).max(32).optional(),
66
+ agentDoc: z.string().min(1).max(512).optional(),
67
+ humanDoc: z.string().min(1).max(512).optional(),
68
+ })
69
+ .strict()
70
+
71
+ export const RoutingConfigSchema = z
72
+ .object({
73
+ plugin: z
74
+ .enum(['pnpm-monorepo', 'npm-workspaces', 'yarn-workspaces', 'pattern-files', 'custom'])
75
+ .optional(),
76
+ options: z
77
+ .object({
78
+ packages: z.array(z.string().min(1).max(256)).max(128).optional(),
79
+ /** Infer ownership from corpus paths/frontmatter (default true). */
80
+ ownershipFromCorpus: z.boolean().optional(),
81
+ ownership: z.record(z.string().min(1).max(256), OwnershipEntrySchema).optional(),
82
+ intents: z
83
+ .array(
84
+ z
85
+ .object({
86
+ id: z.string().min(1).max(128),
87
+ title: z.string().min(1).max(256),
88
+ paths: z.array(z.string().min(1).max(512)).max(32),
89
+ })
90
+ .strict(),
91
+ )
92
+ .max(128)
93
+ .optional(),
94
+ changes: z
95
+ .array(
96
+ z
97
+ .object({
98
+ id: z.string().min(1).max(128),
99
+ title: z.string().min(1).max(256),
100
+ startHere: z.string().min(1).max(512),
101
+ relatedPackages: z.array(z.string().min(1).max(256)).max(32).optional(),
102
+ })
103
+ .strict(),
104
+ )
105
+ .max(128)
106
+ .optional(),
107
+ })
108
+ .strict()
109
+ .optional(),
110
+ })
111
+ .strict()
112
+
113
+ export const GatesConfigSchema = z
114
+ .object({
115
+ /** minimal: freshness · standard: + human links · strict: + okf-type · playbook: freshness + okf soft style */
116
+ preset: z.enum(['minimal', 'standard', 'strict', 'playbook']).optional(),
117
+ include: z
118
+ .array(
119
+ z.enum([
120
+ 'index-freshness',
121
+ 'human-guide-links',
122
+ 'link-rot',
123
+ 'okf-type',
124
+ 'docs-style',
125
+ 'routing-currency',
126
+ 'bootstrap-size',
127
+ ]),
128
+ )
129
+ .max(16)
130
+ .optional(),
131
+ exclude: z
132
+ .array(
133
+ z.enum([
134
+ 'index-freshness',
135
+ 'human-guide-links',
136
+ 'link-rot',
137
+ 'okf-type',
138
+ 'docs-style',
139
+ 'routing-currency',
140
+ 'bootstrap-size',
141
+ ]),
142
+ )
143
+ .max(16)
144
+ .optional(),
145
+ options: z.record(z.string(), z.unknown()).optional(),
146
+ })
147
+ .strict()
148
+
149
+ export const SurfacesConfigSchema = z
150
+ .object({
151
+ cli: z
152
+ .object({
153
+ bin: z.string().min(1).max(64).optional(),
154
+ defaultFormat: z.enum(['json', 'text']).optional(),
155
+ })
156
+ .strict()
157
+ .optional(),
158
+ mcp: z
159
+ .object({
160
+ enabled: z.boolean().optional(),
161
+ tools: z
162
+ .array(
163
+ z.enum([
164
+ 'handoff.resolve',
165
+ 'doc.search',
166
+ 'doc.get',
167
+ 'gate.status',
168
+ 'playbook.pattern.get',
169
+ 'retriever.query',
170
+ 'memory.classify',
171
+ 'memory.promoteDraft',
172
+ 'registry.topology',
173
+ ]),
174
+ )
175
+ .max(16)
176
+ .optional(),
177
+ transport: z.enum(['stdio', 'http']).optional(),
178
+ http: z
179
+ .object({
180
+ port: z.number().int().min(1).max(65_535).optional(),
181
+ path: z.string().min(1).max(256).optional(),
182
+ })
183
+ .strict()
184
+ .optional(),
185
+ })
186
+ .strict()
187
+ .optional(),
188
+ })
189
+ .strict()
190
+
191
+ export const IntelligenceConfigSchema = z
192
+ .object({
193
+ enabled: z.boolean().optional(),
194
+ adapter: z
195
+ .object({
196
+ provider: z.enum(['openai', 'anthropic', 'ollama', 'openrouter', 'custom']),
197
+ model: z.string().min(1).max(128).optional(),
198
+ apiKeyEnv: z.string().min(0).max(128).optional(),
199
+ baseUrl: z.string().url().optional(),
200
+ options: z.record(z.string(), z.unknown()).optional(),
201
+ })
202
+ .strict()
203
+ .optional(),
204
+ chat: z
205
+ .object({
206
+ enabled: z.boolean().optional(),
207
+ sources: z.array(z.enum(['agent', 'human', 'federation'])).max(8).optional(),
208
+ handoffFirst: z.boolean().optional(),
209
+ })
210
+ .strict()
211
+ .optional(),
212
+ retriever: z
213
+ .object({
214
+ enabled: z.boolean().optional(),
215
+ mode: z.enum(['local', 'remote', 'bm25', 'agentskit-rag']).optional(),
216
+ embedModel: z.string().min(1).max(128).optional(),
217
+ chunkSize: z.number().int().min(128).max(16_384).optional(),
218
+ options: z.record(z.string(), z.unknown()).optional(),
219
+ })
220
+ .strict()
221
+ .optional(),
222
+ memory: z
223
+ .object({
224
+ enabled: z.boolean().optional(),
225
+ adapters: z
226
+ .array(z.enum(['playbook-memory', 'cursor-rules', 'session-export', 'bootstrap-delta']))
227
+ .max(8)
228
+ .optional(),
229
+ ingestDir: z.string().min(1).max(512).optional(),
230
+ classify: z.boolean().optional(),
231
+ promote: z
232
+ .object({
233
+ enabled: z.boolean().optional(),
234
+ targets: z.array(z.enum(['agent', 'human', 'agents-md'])).max(8).optional(),
235
+ requireApproval: z.boolean().optional(),
236
+ })
237
+ .strict()
238
+ .optional(),
239
+ })
240
+ .strict()
241
+ .optional(),
242
+ runtime: z.enum(['agentskit', 'custom']).optional(),
243
+ runtimeModule: z.string().min(1).max(512).optional(),
244
+ })
245
+ .strict()
246
+
247
+ export const FederationConfigSchema = z
248
+ .object({
249
+ enabled: z.boolean().optional(),
250
+ sources: z
251
+ .array(
252
+ z
253
+ .object({
254
+ id: z.string().min(1).max(64),
255
+ llmsTxt: z.string().min(1).max(512).optional(),
256
+ rawBaseUrl: z.string().url().optional(),
257
+ includeInRetriever: z.boolean().optional(),
258
+ includeInChat: z.boolean().optional(),
259
+ })
260
+ .strict(),
261
+ )
262
+ .max(16)
263
+ .optional(),
264
+ })
265
+ .strict()
266
+
267
+ export const DocBridgeConfigV1Schema = z
268
+ .object({
269
+ schemaVersion: z.literal(CONFIG_SCHEMA_VERSION),
270
+ project: z
271
+ .object({
272
+ name: z.string().min(1).max(128).optional(),
273
+ root: z.string().min(1).max(512).optional(),
274
+ })
275
+ .strict()
276
+ .optional(),
277
+ corpus: z
278
+ .object({
279
+ agent: AgentCorpusConfigSchema,
280
+ human: z.union([HumanCorpusConfigSchema, z.array(HumanCorpusConfigSchema).max(8)]).optional(),
281
+ })
282
+ .strict(),
283
+ index: IndexConfigSchema.optional(),
284
+ routing: RoutingConfigSchema.optional(),
285
+ gates: GatesConfigSchema.optional(),
286
+ surfaces: SurfacesConfigSchema.optional(),
287
+ intelligence: IntelligenceConfigSchema.optional(),
288
+ federation: FederationConfigSchema.optional(),
289
+ })
290
+ .strict()
291
+
292
+ export type DocBridgeConfigV1 = z.infer<typeof DocBridgeConfigV1Schema>
293
+ export type AgentCorpusConfig = z.infer<typeof AgentCorpusConfigSchema>
294
+ export type HumanCorpusConfig = z.infer<typeof HumanCorpusConfigSchema>
@@ -0,0 +1,145 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+
4
+ import type { DocBridgeConfigV1 } from '../config/schema.js'
5
+ import { slugFromPath } from '../lib/markdown.js'
6
+ import type { DocBridgeIndexV1 } from '../schemas/doc-bridge-index.js'
7
+ import {
8
+ retrieveDocBridgeChunks,
9
+ type DocBridgeRetrievedChunk,
10
+ } from '../retriever/doc-bridge-retriever.js'
11
+
12
+ export type FetchText = (url: string) => Promise<string>
13
+
14
+ export type FederatedRetrieverOptions = {
15
+ readonly fetchText?: FetchText
16
+ readonly limit?: number
17
+ }
18
+
19
+ const tokenize = (value: string): string[] =>
20
+ value.toLowerCase().split(/[^a-z0-9]+/).filter((token) => token.length >= 2)
21
+
22
+ const scoreText = (query: string, text: string): number => {
23
+ const hay = text.toLowerCase()
24
+ return tokenize(query).reduce((score, token) => score + (hay.includes(token) ? token.length : 0), 0)
25
+ }
26
+
27
+ const defaultFetchText: FetchText = async (url) => {
28
+ const signal = AbortSignal.timeout(5_000)
29
+ const res = await fetch(url, { signal })
30
+ if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`)
31
+ return res.text()
32
+ }
33
+
34
+ const sourceText = async (
35
+ root: string,
36
+ source: string,
37
+ fetchText: FetchText,
38
+ ): Promise<string> => {
39
+ if (/^https?:\/\//.test(source)) return fetchText(source)
40
+ const path = resolve(root, source)
41
+ if (!existsSync(path)) throw new Error(`Federation source not found: ${source}`)
42
+ return readFileSync(path, 'utf8')
43
+ }
44
+
45
+ const sameOrigin = (base: string, target: string): boolean => {
46
+ if (!/^https?:\/\//.test(base) || !/^https?:\/\//.test(target)) return true
47
+ return new URL(base).origin === new URL(target).origin
48
+ }
49
+
50
+ export const parseLlmsTxtLinks = (raw: string): { title: string; url: string; description?: string }[] => {
51
+ const links = [...raw.matchAll(/\[([^\]]+)\]\(([^)]+)\)(?::\s*([^\n]+))?/g)].map((match) => ({
52
+ title: match[1] ?? match[2] ?? 'link',
53
+ url: match[2] ?? '',
54
+ ...(match[3]?.trim() ? { description: match[3].trim() } : {}),
55
+ })).filter((link) => link.url)
56
+
57
+ for (const match of raw.matchAll(/(?:^|\s)(?:Raw|llms\.txt|Full bundle|ZIP bundle)?:?\s*(https?:\/\/\S+)/gi)) {
58
+ const url = match[1]?.replace(/[),.;]+$/, '')
59
+ if (url && !links.some((link) => link.url === url)) links.push({ title: slugFromPath(url), url })
60
+ }
61
+ return links
62
+ }
63
+
64
+ export const chunksFromMarkdown = (
65
+ property: string,
66
+ raw: string,
67
+ sourceUrl: string,
68
+ ): DocBridgeRetrievedChunk[] => {
69
+ const searchable = raw.includes('\n==== ') ? raw : raw.replace(/^---\n[\s\S]*?\n---\n?/, '')
70
+ const sections = searchable.includes('\n==== ')
71
+ ? searchable.split(/\n====\s+/).filter((section) => section.trim())
72
+ : searchable.split(/\n(?=##?\s+)/)
73
+ return sections.map((section, index) => {
74
+ const title =
75
+ /^title:\s*(.+)$/m.exec(section)?.[1]?.trim() ??
76
+ /^https?:\/\/\S+\/([^/\s]+)$/m.exec(section)?.[1]?.trim() ??
77
+ /^#+\s+(.+)$/m.exec(section)?.[1]?.trim() ??
78
+ slugFromPath(sourceUrl)
79
+ const id = slugFromPath(title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')) || `${index}`
80
+ return {
81
+ chunkKey: `${property}:federated:${id}`,
82
+ property,
83
+ type: 'federated',
84
+ id,
85
+ path: sourceUrl,
86
+ title,
87
+ summary: section.trim().slice(0, 500),
88
+ score: 0,
89
+ }
90
+ })
91
+ }
92
+
93
+ export const loadFederatedChunks = async (
94
+ root: string,
95
+ config: DocBridgeConfigV1,
96
+ options: FederatedRetrieverOptions = {},
97
+ ): Promise<DocBridgeRetrievedChunk[]> => {
98
+ const fetchText = options.fetchText ?? defaultFetchText
99
+ const chunks: DocBridgeRetrievedChunk[] = []
100
+ for (const source of config.federation?.sources ?? []) {
101
+ if (source.includeInRetriever === false || !source.llmsTxt) continue
102
+ const llms = await sourceText(root, source.llmsTxt, fetchText)
103
+ chunks.push(...chunksFromMarkdown(source.id, llms, source.llmsTxt))
104
+ const links = parseLlmsTxtLinks(llms)
105
+ for (const link of links) {
106
+ const url = !/^https?:\/\//.test(link.url) && source.rawBaseUrl
107
+ ? `${source.rawBaseUrl.replace(/\/$/, '')}/${link.url.replace(/^\//, '')}`
108
+ : link.url
109
+ if (!/\.(md|txt)(?:$|\?)/.test(url)) continue
110
+ if (!sameOrigin(source.llmsTxt, url)) continue
111
+ try {
112
+ const raw = await sourceText(root, url, fetchText)
113
+ chunks.push(...chunksFromMarkdown(source.id, raw, url))
114
+ } catch {
115
+ // ponytail: federation is best-effort; surface-level health belongs in a future diagnostic command.
116
+ }
117
+ }
118
+ }
119
+ return chunks
120
+ }
121
+
122
+ export const retrieveHybridChunks = async (
123
+ root: string,
124
+ config: DocBridgeConfigV1,
125
+ index: DocBridgeIndexV1,
126
+ query: string,
127
+ options: FederatedRetrieverOptions = {},
128
+ ): Promise<DocBridgeRetrievedChunk[]> => {
129
+ const limit = options.limit ?? 8
130
+ const local = retrieveDocBridgeChunks(index, query, {
131
+ property: config.project?.name ?? index.project?.name ?? 'local',
132
+ limit,
133
+ })
134
+ const federated = (await loadFederatedChunks(root, config, options)).map((chunk) => ({
135
+ ...chunk,
136
+ score: scoreText(query, `${chunk.title ?? ''} ${chunk.summary ?? ''} ${chunk.path}`),
137
+ })).filter((chunk) => chunk.score > 0)
138
+
139
+ const byKey = new Map<string, DocBridgeRetrievedChunk>()
140
+ for (const chunk of [...local, ...federated]) {
141
+ const existing = byKey.get(chunk.chunkKey)
142
+ if (!existing || chunk.score > existing.score) byKey.set(chunk.chunkKey, chunk)
143
+ }
144
+ return [...byKey.values()].sort((a, b) => b.score - a.score).slice(0, limit)
145
+ }