@agentskit/doc-bridge 1.2.4 → 1.3.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 (43) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/CONTRIBUTING.md +6 -0
  3. package/PRIVACY.md +38 -0
  4. package/README.md +30 -3
  5. package/SECURITY.md +7 -2
  6. package/action.yml +1 -1
  7. package/dist/cli/program.js +400 -156
  8. package/dist/cli/program.js.map +1 -1
  9. package/dist/config/index.d.ts +1 -1
  10. package/dist/config/index.js +3 -1
  11. package/dist/config/index.js.map +1 -1
  12. package/dist/{index-DGI9TBLE.d.ts → index-DAeq_OIi.d.ts} +22 -22
  13. package/dist/index.d.ts +47 -12
  14. package/dist/index.js +376 -131
  15. package/dist/index.js.map +1 -1
  16. package/docs/POSITIONING.md +1 -1
  17. package/docs/examples.md +4 -0
  18. package/docs/getting-started.md +1 -1
  19. package/docs/landing/index.html +1 -1
  20. package/docs/mcp.md +16 -0
  21. package/docs/qa/vitepress-starlight-adapters.md +19 -0
  22. package/docs/spec/config-v1.md +33 -2
  23. package/examples/nextra-only.config.ts +17 -0
  24. package/examples/nx-monorepo.config.ts +11 -0
  25. package/examples/starlight-only.config.ts +17 -0
  26. package/examples/vitepress-only.config.ts +19 -0
  27. package/mcpb/.mcpbignore +8 -0
  28. package/mcpb/icon.png +0 -0
  29. package/mcpb/manifest.json +96 -0
  30. package/package.json +14 -11
  31. package/src/config/schema.ts +3 -1
  32. package/src/index-builder/build-handoffs.ts +2 -0
  33. package/src/index-builder/build-index.ts +7 -1
  34. package/src/index-builder/human-adapters/index.ts +6 -0
  35. package/src/index-builder/human-adapters/nextra.ts +43 -0
  36. package/src/index-builder/human-adapters/starlight.ts +40 -0
  37. package/src/index-builder/human-adapters/vitepress.ts +43 -0
  38. package/src/index-builder/plugins/nx.ts +161 -0
  39. package/src/index-builder/plugins/pnpm-monorepo.ts +1 -0
  40. package/src/index-builder/watch-index.ts +11 -2
  41. package/src/index.ts +1 -0
  42. package/src/mcp/server.ts +58 -26
  43. package/src/version.ts +1 -1
@@ -0,0 +1,161 @@
1
+ import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs'
2
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path'
3
+
4
+ import type { DocBridgeConfigV1 } from '../../config/schema.js'
5
+ import { detectPackageManager } from '../../lib/package-manager.js'
6
+ import { toPosix } from '../../lib/paths.js'
7
+ import { walkFiles } from '../../lib/walk.js'
8
+ import type { DiscoveredPackage } from './pnpm-monorepo.js'
9
+
10
+ const NX_SCAN_SKIP = new Set([
11
+ 'node_modules',
12
+ '.git',
13
+ '.nx',
14
+ 'dist',
15
+ 'coverage',
16
+ '.doc-bridge',
17
+ ])
18
+ const NX_PROJECT_NAME = /^(?:@[A-Za-z0-9._-]+\/)?[A-Za-z0-9][A-Za-z0-9._-]*$/
19
+
20
+ type JsonRecord = Record<string, unknown>
21
+
22
+ const isRecord = (value: unknown): value is JsonRecord =>
23
+ typeof value === 'object' && value !== null && !Array.isArray(value)
24
+
25
+ const readJsonRecord = (path: string): JsonRecord | undefined => {
26
+ try {
27
+ const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
28
+ return isRecord(parsed) ? parsed : undefined
29
+ } catch {
30
+ return undefined
31
+ }
32
+ }
33
+
34
+ const safeProjectPath = (
35
+ root: string,
36
+ manifestDir: string,
37
+ declaredRoot?: unknown,
38
+ ): string | undefined => {
39
+ const candidate = typeof declaredRoot === 'string' ? resolve(root, declaredRoot) : manifestDir
40
+ const rel = toPosix(relative(root, candidate)) || '.'
41
+ if (isAbsolute(rel) || rel === '..' || rel.startsWith('../')) return undefined
42
+ try {
43
+ if (!lstatSync(candidate).isDirectory()) return undefined
44
+ const canonicalRoot = realpathSync.native(root)
45
+ const canonicalCandidate = realpathSync.native(candidate)
46
+ const canonicalRel = relative(canonicalRoot, canonicalCandidate)
47
+ if (
48
+ isAbsolute(canonicalRel) ||
49
+ canonicalRel === '..' ||
50
+ canonicalRel.startsWith(`..${sep}`)
51
+ ) {
52
+ return undefined
53
+ }
54
+ } catch {
55
+ return undefined
56
+ }
57
+ return rel
58
+ }
59
+
60
+ const packageId = (name: string): string =>
61
+ name.startsWith('@') ? (name.split('/').at(-1) ?? name) : name
62
+
63
+ const commandPrefix = (root: string): string => {
64
+ const manager = detectPackageManager(root)
65
+ if (manager === 'pnpm') return 'pnpm exec nx run'
66
+ if (manager === 'yarn') return 'yarn nx run'
67
+ if (manager === 'bun') return 'bunx nx run'
68
+ return 'npx nx run'
69
+ }
70
+
71
+ const inferredChecks = (
72
+ root: string,
73
+ config: DocBridgeConfigV1,
74
+ projectName: string,
75
+ targets: ReadonlySet<string>,
76
+ ): string[] | undefined => {
77
+ const prefix = commandPrefix(root)
78
+ const strict = (config.gates?.preset ?? 'minimal') !== 'minimal'
79
+ const checks = [
80
+ ...(targets.has('test') ? [`${prefix} ${projectName}:test`] : []),
81
+ ...(strict && targets.has('lint') ? [`${prefix} ${projectName}:lint`] : []),
82
+ ]
83
+ return checks.length ? checks : undefined
84
+ }
85
+
86
+ type RankedProject = DiscoveredPackage & {
87
+ readonly rank: number
88
+ readonly targets: readonly string[]
89
+ }
90
+
91
+ export const discoverNxProjects = (
92
+ root: string,
93
+ config: DocBridgeConfigV1,
94
+ ): DiscoveredPackage[] => {
95
+ if (!existsSync(resolve(root, 'nx.json'))) return []
96
+
97
+ const manifests = walkFiles(root, {
98
+ extensions: ['project.json', 'package.json'],
99
+ skipDirs: NX_SCAN_SKIP,
100
+ maxFiles: 10_000,
101
+ })
102
+ const projects = new Map<string, RankedProject>()
103
+
104
+ for (const manifest of manifests) {
105
+ const json = readJsonRecord(manifest)
106
+ if (!json) continue
107
+ const manifestDir = dirname(manifest)
108
+ const manifestName = basename(manifest)
109
+ const isProjectJson = manifestName === 'project.json'
110
+ if (!isProjectJson && manifestName !== 'package.json') continue
111
+ const nx = json.nx
112
+ if (!isProjectJson && !isRecord(nx)) continue
113
+
114
+ const path = safeProjectPath(root, manifestDir, isProjectJson ? json.root : undefined)
115
+ if (!path) continue
116
+ const projectPackage = isProjectJson
117
+ ? readJsonRecord(resolve(root, path, 'package.json'))
118
+ : undefined
119
+ const projectName =
120
+ typeof json.name === 'string'
121
+ ? json.name
122
+ : typeof projectPackage?.name === 'string'
123
+ ? projectPackage.name
124
+ : basename(path === '.' ? root : path)
125
+ if (!NX_PROJECT_NAME.test(projectName)) continue
126
+
127
+ const targets = new Set<string>()
128
+ const targetRecord = isProjectJson ? json.targets : isRecord(nx) ? nx.targets : undefined
129
+ if (isRecord(targetRecord)) {
130
+ for (const target of Object.keys(targetRecord)) targets.add(target)
131
+ }
132
+ if (!isProjectJson && isRecord(json.scripts)) {
133
+ for (const script of Object.keys(json.scripts)) targets.add(script)
134
+ }
135
+
136
+ const id = packageId(projectName)
137
+ const rank = isProjectJson ? 2 : 1
138
+ const existing = projects.get(id)
139
+ if (existing && existing.path !== path) {
140
+ throw new Error(
141
+ `Nx project identity collision for "${id}": "${existing.name ?? id}" at "${existing.path}" and "${projectName}" at "${path}". Use unique Nx project names.`,
142
+ )
143
+ }
144
+ const mergedTargets = [...new Set([...(existing?.targets ?? []), ...targets])]
145
+ const projectJsonWins = rank >= (existing?.rank ?? 0)
146
+ const resolvedName = projectJsonWins ? projectName : (existing?.name ?? projectName)
147
+ const checks = inferredChecks(root, config, resolvedName, new Set(mergedTargets))
148
+ projects.set(id, {
149
+ id,
150
+ path,
151
+ name: resolvedName,
152
+ ...(checks ? { checks } : {}),
153
+ rank: projectJsonWins ? rank : (existing?.rank ?? rank),
154
+ targets: mergedTargets,
155
+ })
156
+ }
157
+
158
+ return [...projects.values()]
159
+ .sort((a, b) => a.id.localeCompare(b.id))
160
+ .map(({ rank: _rank, targets: _targets, ...project }) => project)
161
+ }
@@ -9,6 +9,7 @@ export type DiscoveredPackage = {
9
9
  readonly id: string
10
10
  readonly path: string
11
11
  readonly name?: string
12
+ readonly checks?: readonly string[]
12
13
  }
13
14
 
14
15
  const parsePnpmWorkspace = (yaml: string): string[] => {
@@ -13,6 +13,7 @@ export type WatchIndexOptions = {
13
13
  }
14
14
 
15
15
  const WATCH_PATTERN = /\.(md|mdx|json|ya?ml|mdc)$/i
16
+ const NX_MANIFEST_PATTERN = /(^|[/\\])(project|package)\.json$/i
16
17
 
17
18
  const collectWatchRoots = (root: string, config: DocBridgeConfigV1, configPath?: string): string[] => {
18
19
  const roots = new Set<string>()
@@ -24,7 +25,7 @@ const collectWatchRoots = (root: string, config: DocBridgeConfigV1, configPath?:
24
25
  : []
25
26
  for (const source of humanSources) {
26
27
  const humanOpts = source.options ?? {}
27
- for (const key of ['contentDir', 'docsDir', 'root']) {
28
+ for (const key of ['contentDir', 'docsDir', 'root', 'srcDir']) {
28
29
  const value = humanOpts[key]
29
30
  if (typeof value === 'string' && value.length) roots.add(resolve(root, value))
30
31
  }
@@ -74,6 +75,14 @@ export const watchDocBridgeIndex = (opts: WatchIndexOptions): Promise<number> =>
74
75
  })
75
76
  }
76
77
 
78
+ const nxRoot = resolve(opts.root)
79
+ if (opts.config.routing?.plugin === 'nx' && existsSync(nxRoot)) {
80
+ watch(nxRoot, { recursive: true }, (_event, filename) => {
81
+ if (!filename || !NX_MANIFEST_PATTERN.test(filename)) return
82
+ rebuild()
83
+ })
84
+ }
85
+
77
86
  const configDir = resolve(opts.root)
78
87
  if (existsSync(configDir)) {
79
88
  watch(configDir, (_event, filename) => {
@@ -91,4 +100,4 @@ export const watchDocBridgeIndex = (opts: WatchIndexOptions): Promise<number> =>
91
100
  process.once('SIGINT', onSignal)
92
101
  process.once('SIGTERM', onSignal)
93
102
  })
94
- }
103
+ }
package/src/index.ts CHANGED
@@ -155,6 +155,7 @@ export {
155
155
  export { PACKAGE_VERSION } from './version.js'
156
156
 
157
157
  export { collectPackages, buildLookup } from './index-builder/build-handoffs.js'
158
+ export { discoverNxProjects } from './index-builder/plugins/nx.js'
158
159
  export { projectRootFromConfigPath } from './config/load-config.js'
159
160
  export { createDocBridgeRag } from './intelligence/rag.js'
160
161
  export { runChatOnce, startInkChat } from './intelligence/chat.js'
package/src/mcp/server.ts CHANGED
@@ -30,7 +30,9 @@ type McpContext = {
30
30
  export const MCP_TOOLS = [
31
31
  {
32
32
  name: 'handoff.resolve',
33
- description: 'Resolve a package or ownership id to an AgentHandoff.',
33
+ title: 'Resolve repository handoff',
34
+ description: 'Resolve a package or ownership id to its deterministic AgentHandoff.',
35
+ annotations: { readOnlyHint: true },
34
36
  inputSchema: {
35
37
  type: 'object',
36
38
  properties: { id: { type: 'string' }, kind: { type: 'string', enum: ['package', 'ownership'] } },
@@ -39,7 +41,9 @@ export const MCP_TOOLS = [
39
41
  },
40
42
  {
41
43
  name: 'doc.search',
42
- description: 'Search the deterministic doc-bridge index.',
44
+ title: 'Search repository documentation',
45
+ description: 'Search the deterministic Doc Bridge index for repository documentation.',
46
+ annotations: { readOnlyHint: true },
43
47
  inputSchema: {
44
48
  type: 'object',
45
49
  properties: { term: { type: 'string' }, limit: { type: 'number' } },
@@ -48,7 +52,9 @@ export const MCP_TOOLS = [
48
52
  },
49
53
  {
50
54
  name: 'doc.get',
51
- description: 'Read an indexed agent documentation file by id or path.',
55
+ title: 'Read indexed documentation',
56
+ description: 'Read one indexed agent documentation file by id or indexed path.',
57
+ annotations: { readOnlyHint: true },
52
58
  inputSchema: {
53
59
  type: 'object',
54
60
  properties: { id: { type: 'string' }, path: { type: 'string' } },
@@ -56,12 +62,16 @@ export const MCP_TOOLS = [
56
62
  },
57
63
  {
58
64
  name: 'gate.status',
59
- description: 'Run the index-freshness gate.',
65
+ title: 'Check documentation gates',
66
+ description: 'Evaluate documentation gates without writing files.',
67
+ annotations: { readOnlyHint: true },
60
68
  inputSchema: { type: 'object', properties: {} },
61
69
  },
62
70
  {
63
71
  name: 'retriever.query',
64
- description: 'Return local doc-bridge retriever chunks for a query.',
72
+ title: 'Retrieve documentation context',
73
+ description: 'Return relevant local Doc Bridge index chunks for a query.',
74
+ annotations: { readOnlyHint: true },
65
75
  inputSchema: {
66
76
  type: 'object',
67
77
  properties: { query: { type: 'string' }, limit: { type: 'number' } },
@@ -70,17 +80,23 @@ export const MCP_TOOLS = [
70
80
  },
71
81
  {
72
82
  name: 'memory.classify',
73
- description: 'Classify local memory candidates into agent/human/playbook/discard routes.',
83
+ title: 'Classify memory candidates',
84
+ description: 'Classify local memory candidates into agent, human, playbook, or discard routes.',
85
+ annotations: { readOnlyHint: true },
74
86
  inputSchema: { type: 'object', properties: {} },
75
87
  },
76
88
  {
77
89
  name: 'memory.promoteDraft',
78
- description: 'Build a safe draft promotion body for local memory candidates.',
90
+ title: 'Draft memory promotion',
91
+ description: 'Build a reviewable draft promotion body from local memory candidates without publishing it.',
92
+ annotations: { readOnlyHint: true },
79
93
  inputSchema: { type: 'object', properties: {} },
80
94
  },
81
95
  {
82
96
  name: 'registry.topology',
83
- description: 'Return the doc-curator registry topology.',
97
+ title: 'Inspect registry topology',
98
+ description: 'Return the static Doc Bridge curator and delegate topology.',
99
+ annotations: { readOnlyHint: true },
84
100
  inputSchema: { type: 'object', properties: {} },
85
101
  },
86
102
  ] as const
@@ -223,12 +239,14 @@ export const handleMcpRequest = (ctx: McpContext, request: JsonRpcRequest): unkn
223
239
  throw new Error(`Unsupported MCP method "${request.method ?? ''}"`)
224
240
  }
225
241
 
226
- const writeFrame = (payload: unknown): void => {
242
+ type StdioFraming = 'content-length' | 'json-line'
243
+
244
+ const writeFrame = (payload: unknown, framing: StdioFraming): void => {
227
245
  const body = JSON.stringify(payload)
228
- process.stdout.write(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`)
246
+ process.stdout.write(framing === 'json-line' ? `${body}\n` : `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`)
229
247
  }
230
248
 
231
- const respond = (ctx: McpContext, request: JsonRpcRequest): void => {
249
+ const respond = (ctx: McpContext, request: JsonRpcRequest, framing: StdioFraming): void => {
232
250
  if (request.id === undefined) {
233
251
  try {
234
252
  handleMcpRequest(ctx, request)
@@ -240,13 +258,13 @@ const respond = (ctx: McpContext, request: JsonRpcRequest): void => {
240
258
 
241
259
  try {
242
260
  const result = handleMcpRequest(ctx, request)
243
- writeFrame({ jsonrpc: '2.0', id: request.id, result: result ?? {} })
261
+ writeFrame({ jsonrpc: '2.0', id: request.id, result: result ?? {} }, framing)
244
262
  } catch (error) {
245
263
  writeFrame({
246
264
  jsonrpc: '2.0',
247
265
  id: request.id,
248
266
  error: { code: -32000, message: error instanceof Error ? error.message : String(error) },
249
- })
267
+ }, framing)
250
268
  }
251
269
  }
252
270
 
@@ -255,21 +273,35 @@ export const startMcpStdioServer = (ctx: McpContext): void => {
255
273
  process.stdin.on('data', (chunk: Buffer) => {
256
274
  buffer = Buffer.concat([buffer, chunk])
257
275
  while (true) {
258
- const headerEnd = buffer.indexOf('\r\n\r\n')
259
- if (headerEnd === -1) return
260
- const header = buffer.subarray(0, headerEnd).toString('utf8')
261
- const match = /content-length:\s*(\d+)/i.exec(header)
262
- if (!match?.[1]) {
263
- buffer = buffer.subarray(headerEnd + 4)
276
+ if (/^content-length:/i.test(buffer.subarray(0, Math.min(buffer.length, 32)).toString('utf8'))) {
277
+ const headerEnd = buffer.indexOf('\r\n\r\n')
278
+ if (headerEnd === -1) return
279
+ const header = buffer.subarray(0, headerEnd).toString('utf8')
280
+ const match = /content-length:\s*(\d+)/i.exec(header)
281
+ if (!match?.[1]) {
282
+ buffer = buffer.subarray(headerEnd + 4)
283
+ continue
284
+ }
285
+ const length = Number(match[1])
286
+ const bodyStart = headerEnd + 4
287
+ const bodyEnd = bodyStart + length
288
+ if (buffer.length < bodyEnd) return
289
+ const raw = buffer.subarray(bodyStart, bodyEnd).toString('utf8')
290
+ buffer = buffer.subarray(bodyEnd)
291
+ respond(ctx, JSON.parse(raw) as JsonRpcRequest, 'content-length')
264
292
  continue
265
293
  }
266
- const length = Number(match[1])
267
- const bodyStart = headerEnd + 4
268
- const bodyEnd = bodyStart + length
269
- if (buffer.length < bodyEnd) return
270
- const raw = buffer.subarray(bodyStart, bodyEnd).toString('utf8')
271
- buffer = buffer.subarray(bodyEnd)
272
- respond(ctx, JSON.parse(raw) as JsonRpcRequest)
294
+
295
+ const lineEnd = buffer.indexOf('\n')
296
+ if (lineEnd === -1) return
297
+ const raw = buffer.subarray(0, lineEnd).toString('utf8').trim()
298
+ buffer = buffer.subarray(lineEnd + 1)
299
+ if (!raw) continue
300
+ try {
301
+ respond(ctx, JSON.parse(raw) as JsonRpcRequest, 'json-line')
302
+ } catch {
303
+ writeFrame({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }, 'json-line')
304
+ }
273
305
  }
274
306
  })
275
307
  process.stdin.resume()
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const PACKAGE_VERSION = '1.2.4'
1
+ export const PACKAGE_VERSION = '1.3.0'