@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,104 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ export type PackageManager = 'pnpm' | 'npm' | 'yarn' | 'bun'
5
+
6
+ export const detectPackageManager = (root: string): PackageManager => {
7
+ if (existsSync(join(root, 'pnpm-lock.yaml')) || existsSync(join(root, 'pnpm-workspace.yaml'))) {
8
+ return 'pnpm'
9
+ }
10
+ if (existsSync(join(root, 'yarn.lock'))) return 'yarn'
11
+ if (existsSync(join(root, 'bun.lockb')) || existsSync(join(root, 'bun.lock'))) return 'bun'
12
+ if (existsSync(join(root, 'package-lock.json'))) return 'npm'
13
+
14
+ try {
15
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as {
16
+ packageManager?: string
17
+ }
18
+ const pm = pkg.packageManager?.split('@')[0]
19
+ if (pm === 'pnpm' || pm === 'npm' || pm === 'yarn' || pm === 'bun') return pm
20
+ } catch {
21
+ // ignore
22
+ }
23
+
24
+ return 'npm'
25
+ }
26
+
27
+ export const defaultChecksForTarget = (
28
+ root: string,
29
+ opts: {
30
+ readonly packageId: string
31
+ readonly packagePath: string
32
+ readonly packageName?: string
33
+ readonly strict?: boolean
34
+ },
35
+ ): string[] => {
36
+ const pm = detectPackageManager(root)
37
+
38
+ // Pattern / markdown ownership targets (playbook, etc.) — project-level scripts
39
+ if (/\.mdx?$/.test(opts.packagePath) || opts.packagePath.includes('/pillars/')) {
40
+ try {
41
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as {
42
+ scripts?: Record<string, string>
43
+ }
44
+ const scripts = pkg.scripts ?? {}
45
+ if (scripts['check:okf-type']) return ['pnpm run check:okf-type']
46
+ if (scripts['docs:bridge:gate']) return ['pnpm run docs:bridge:gate']
47
+ if (scripts.test) return [pm === 'pnpm' ? 'pnpm test' : 'npm test']
48
+ } catch {
49
+ // fall through
50
+ }
51
+ }
52
+
53
+ const isWorkspace =
54
+ existsSync(join(root, 'pnpm-workspace.yaml')) ||
55
+ existsSync(join(root, 'lerna.json')) ||
56
+ Boolean(
57
+ (() => {
58
+ try {
59
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as {
60
+ workspaces?: unknown
61
+ }
62
+ return Boolean(pkg.workspaces)
63
+ } catch {
64
+ return false
65
+ }
66
+ })(),
67
+ )
68
+
69
+ const filter =
70
+ opts.packageName ??
71
+ (opts.packagePath.startsWith('packages/') || opts.packagePath.startsWith('apps/')
72
+ ? opts.packageId
73
+ : undefined)
74
+
75
+ if (pm === 'pnpm') {
76
+ if (isWorkspace && filter) {
77
+ const base = [`pnpm --filter ${filter} test`]
78
+ if (opts.strict) base.push(`pnpm --filter ${filter} lint`)
79
+ return base
80
+ }
81
+ return opts.strict ? ['pnpm test', 'pnpm run lint'] : ['pnpm test']
82
+ }
83
+
84
+ if (pm === 'yarn') {
85
+ if (isWorkspace && filter) {
86
+ return opts.strict
87
+ ? [`yarn workspace ${filter} test`, `yarn workspace ${filter} lint`]
88
+ : [`yarn workspace ${filter} test`]
89
+ }
90
+ return opts.strict ? ['yarn test', 'yarn lint'] : ['yarn test']
91
+ }
92
+
93
+ if (pm === 'bun') {
94
+ return opts.strict ? ['bun test', 'bun run lint'] : ['bun test']
95
+ }
96
+
97
+ // npm
98
+ if (isWorkspace && filter) {
99
+ return opts.strict
100
+ ? [`npm test -w ${filter}`, `npm run lint -w ${filter}`]
101
+ : [`npm test -w ${filter}`]
102
+ }
103
+ return opts.strict ? ['npm test', 'npm run lint'] : ['npm test']
104
+ }
@@ -0,0 +1,6 @@
1
+ import { resolve } from 'node:path'
2
+
3
+ export const toPosix = (value: string): string => value.split('\\').join('/')
4
+
5
+ export const resolveFromRoot = (root: string, rel: string): string =>
6
+ resolve(root, rel)
@@ -0,0 +1,45 @@
1
+ import { readdirSync, statSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { toPosix } from './paths.js'
5
+
6
+ const DEFAULT_SKIP = new Set(['node_modules', '.git', 'dist', 'coverage', '.doc-bridge'])
7
+
8
+ export const walkFiles = (
9
+ root: string,
10
+ opts?: { readonly extensions?: readonly string[]; readonly skipDirs?: ReadonlySet<string> },
11
+ ): string[] => {
12
+ const extensions = opts?.extensions ?? ['.md']
13
+ const skip = opts?.skipDirs ?? DEFAULT_SKIP
14
+ const out: string[] = []
15
+
16
+ const visit = (dir: string) => {
17
+ let entries: string[] = []
18
+ try {
19
+ entries = readdirSync(dir)
20
+ } catch {
21
+ return
22
+ }
23
+ for (const name of entries) {
24
+ const abs = join(dir, name)
25
+ let st
26
+ try {
27
+ st = statSync(abs)
28
+ } catch {
29
+ continue
30
+ }
31
+ if (st.isDirectory()) {
32
+ if (skip.has(name)) continue
33
+ visit(abs)
34
+ continue
35
+ }
36
+ if (!st.isFile()) continue
37
+ if (extensions.some((ext) => name.endsWith(ext))) {
38
+ out.push(toPosix(abs))
39
+ }
40
+ }
41
+ }
42
+
43
+ visit(root)
44
+ return out.sort()
45
+ }
@@ -0,0 +1,276 @@
1
+ import { readFileSync, realpathSync } from 'node:fs'
2
+ import { relative, resolve } from 'node:path'
3
+
4
+ import { z, ZodError } from 'zod'
5
+
6
+ import type { DocBridgeConfigV1 } from '../config/schema.js'
7
+ import { retrieveDocBridgeChunks } from '../retriever/doc-bridge-retriever.js'
8
+ import { runGates } from '../gates/run-gates.js'
9
+ import { ingestMemoryCandidates } from '../memory/ingest.js'
10
+ import { classifyMemoryCandidates, draftMemoryPromotion } from '../memory/pipeline.js'
11
+ import { loadDocBridgeIndex } from '../query/load-index.js'
12
+ import { runQuery } from '../query/query.js'
13
+ import { searchIndex } from '../query/search.js'
14
+ import type { DocBridgeIndexV1 } from '../schemas/doc-bridge-index.js'
15
+ import { PACKAGE_VERSION } from '../version.js'
16
+
17
+ type JsonRpcRequest = {
18
+ readonly jsonrpc?: '2.0'
19
+ readonly id?: string | number | null
20
+ readonly method?: string
21
+ readonly params?: unknown
22
+ }
23
+
24
+ type McpContext = {
25
+ readonly root: string
26
+ readonly config: DocBridgeConfigV1
27
+ readonly loadIndex?: () => DocBridgeIndexV1
28
+ }
29
+
30
+ export const MCP_TOOLS = [
31
+ {
32
+ name: 'handoff.resolve',
33
+ description: 'Resolve a package or ownership id to an AgentHandoff.',
34
+ inputSchema: {
35
+ type: 'object',
36
+ properties: { id: { type: 'string' }, kind: { type: 'string', enum: ['package', 'ownership'] } },
37
+ required: ['id'],
38
+ },
39
+ },
40
+ {
41
+ name: 'doc.search',
42
+ description: 'Search the deterministic doc-bridge index.',
43
+ inputSchema: {
44
+ type: 'object',
45
+ properties: { term: { type: 'string' }, limit: { type: 'number' } },
46
+ required: ['term'],
47
+ },
48
+ },
49
+ {
50
+ name: 'doc.get',
51
+ description: 'Read an indexed agent documentation file by id or path.',
52
+ inputSchema: {
53
+ type: 'object',
54
+ properties: { id: { type: 'string' }, path: { type: 'string' } },
55
+ },
56
+ },
57
+ {
58
+ name: 'gate.status',
59
+ description: 'Run the index-freshness gate.',
60
+ inputSchema: { type: 'object', properties: {} },
61
+ },
62
+ {
63
+ name: 'retriever.query',
64
+ description: 'Return local doc-bridge retriever chunks for a query.',
65
+ inputSchema: {
66
+ type: 'object',
67
+ properties: { query: { type: 'string' }, limit: { type: 'number' } },
68
+ required: ['query'],
69
+ },
70
+ },
71
+ {
72
+ name: 'memory.classify',
73
+ description: 'Classify local memory candidates into agent/human/playbook/discard routes.',
74
+ inputSchema: { type: 'object', properties: {} },
75
+ },
76
+ {
77
+ name: 'memory.promoteDraft',
78
+ description: 'Build a safe draft promotion body for local memory candidates.',
79
+ inputSchema: { type: 'object', properties: {} },
80
+ },
81
+ {
82
+ name: 'registry.topology',
83
+ description: 'Return the doc-curator registry topology.',
84
+ inputSchema: { type: 'object', properties: {} },
85
+ },
86
+ ] as const
87
+
88
+ const asRecord = (value: unknown): Record<string, unknown> =>
89
+ value && typeof value === 'object' ? (value as Record<string, unknown>) : {}
90
+
91
+ const HandoffResolveArgsSchema = z.object({
92
+ id: z.string().min(1),
93
+ kind: z.enum(['package', 'ownership']).optional(),
94
+ })
95
+
96
+ const DocSearchArgsSchema = z.object({
97
+ term: z.string().min(1),
98
+ limit: z.number().int().positive().max(100).optional(),
99
+ })
100
+
101
+ const RetrieverQueryArgsSchema = z.object({
102
+ query: z.string().min(1),
103
+ limit: z.number().int().positive().max(100).optional(),
104
+ })
105
+
106
+ const DocGetArgsSchema = z
107
+ .object({
108
+ id: z.string().min(1).optional(),
109
+ path: z.string().min(1).optional(),
110
+ })
111
+ .refine((args) => args.id || args.path, 'doc.get requires id or path')
112
+
113
+ const parseToolArgs = <T>(tool: string, schema: z.ZodType<T>, value: unknown): T => {
114
+ try {
115
+ return schema.parse(value)
116
+ } catch (error) {
117
+ if (error instanceof ZodError) {
118
+ throw new Error(`${tool} invalid arguments: ${error.issues.map((issue) => issue.message).join(', ')}`)
119
+ }
120
+ throw error
121
+ }
122
+ }
123
+
124
+ const textResult = (value: unknown) => ({
125
+ content: [
126
+ {
127
+ type: 'text',
128
+ text: typeof value === 'string' ? value : JSON.stringify(value, null, 2),
129
+ },
130
+ ],
131
+ })
132
+
133
+ const findDocPath = (index: DocBridgeIndexV1, args: z.infer<typeof DocGetArgsSchema>): string => {
134
+ if (args.path) {
135
+ const doc = index.knowledge.find((entry) => entry.path === args.path)
136
+ if (!doc) throw new Error(`Unknown indexed doc path "${args.path}"`)
137
+ return doc.path
138
+ }
139
+ const id = args.id ?? ''
140
+ const doc = index.knowledge.find((entry) => entry.id === args.id)
141
+ if (!doc) throw new Error(`Unknown doc id "${id}"`)
142
+ return doc.path
143
+ }
144
+
145
+ const resolveDocPath = (root: string, relPath: string): string => {
146
+ const rootAbs = realpathSync.native(root)
147
+ const unresolved = resolve(rootAbs, relPath)
148
+ const unresolvedRel = relative(rootAbs, unresolved)
149
+ if (unresolvedRel.startsWith('..')) throw new Error('doc.get path escapes project root')
150
+ const abs = realpathSync.native(unresolved)
151
+ const rel = relative(rootAbs, abs)
152
+ if (rel.startsWith('..')) throw new Error('doc.get path escapes project root')
153
+ return abs
154
+ }
155
+
156
+ export const handleMcpRequest = (ctx: McpContext, request: JsonRpcRequest): unknown => {
157
+ if (request.method === 'initialize') {
158
+ return {
159
+ protocolVersion: '2024-11-05',
160
+ capabilities: { tools: {} },
161
+ serverInfo: { name: 'ak-docs', version: PACKAGE_VERSION },
162
+ }
163
+ }
164
+
165
+ if (request.method === 'tools/list') return { tools: MCP_TOOLS }
166
+
167
+ if (request.method === 'tools/call') {
168
+ const params = asRecord(request.params)
169
+ const name = params.name
170
+ const args = asRecord(params.arguments)
171
+ const index = () => ctx.loadIndex?.() ?? loadDocBridgeIndex(ctx.root, ctx.config)
172
+
173
+ if (name === 'handoff.resolve') {
174
+ const parsed = parseToolArgs('handoff.resolve', HandoffResolveArgsSchema, args)
175
+ return textResult(
176
+ runQuery(index(), ctx.config, {
177
+ kind: parsed.kind === 'package' ? 'package' : 'ownership',
178
+ id: parsed.id,
179
+ agent: true,
180
+ }),
181
+ )
182
+ }
183
+
184
+ if (name === 'doc.search') {
185
+ const parsed = parseToolArgs('doc.search', DocSearchArgsSchema, args)
186
+ return textResult(searchIndex(index(), parsed.term, parsed.limit ?? 20))
187
+ }
188
+
189
+ if (name === 'doc.get') {
190
+ const relPath = findDocPath(index(), parseToolArgs('doc.get', DocGetArgsSchema, args))
191
+ return textResult(readFileSync(resolveDocPath(ctx.root, relPath), 'utf8'))
192
+ }
193
+
194
+ if (name === 'gate.status') return textResult(runGates(ctx.root, ctx.config))
195
+
196
+ if (name === 'retriever.query') {
197
+ const parsed = parseToolArgs('retriever.query', RetrieverQueryArgsSchema, args)
198
+ return textResult(retrieveDocBridgeChunks(index(), parsed.query, parsed.limit ? { limit: parsed.limit } : {}))
199
+ }
200
+
201
+ if (name === 'memory.classify') {
202
+ return textResult(classifyMemoryCandidates(ingestMemoryCandidates(ctx.root), index()))
203
+ }
204
+
205
+ if (name === 'memory.promoteDraft') {
206
+ return textResult(draftMemoryPromotion(classifyMemoryCandidates(ingestMemoryCandidates(ctx.root), index())))
207
+ }
208
+
209
+ if (name === 'registry.topology') {
210
+ return textResult({
211
+ id: 'doc-curator',
212
+ delegates: ['docs-chat', 'knowledge-promoter', 'code-review'],
213
+ tools: ['handoff.resolve', 'doc.search', 'doc.get', 'gate.status', 'retriever.query'],
214
+ steps: ['classify', 'draft', 'verify', 'review'],
215
+ mergePolicy: { autoMerge: false, requiresHuman: true },
216
+ })
217
+ }
218
+
219
+ throw new Error(`Unknown tool "${String(name)}"`)
220
+ }
221
+
222
+ if (request.method?.startsWith('notifications/')) return undefined
223
+ throw new Error(`Unsupported MCP method "${request.method ?? ''}"`)
224
+ }
225
+
226
+ const writeFrame = (payload: unknown): void => {
227
+ const body = JSON.stringify(payload)
228
+ process.stdout.write(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`)
229
+ }
230
+
231
+ const respond = (ctx: McpContext, request: JsonRpcRequest): void => {
232
+ if (request.id === undefined) {
233
+ try {
234
+ handleMcpRequest(ctx, request)
235
+ } catch {
236
+ // Notifications do not get responses.
237
+ }
238
+ return
239
+ }
240
+
241
+ try {
242
+ const result = handleMcpRequest(ctx, request)
243
+ writeFrame({ jsonrpc: '2.0', id: request.id, result: result ?? {} })
244
+ } catch (error) {
245
+ writeFrame({
246
+ jsonrpc: '2.0',
247
+ id: request.id,
248
+ error: { code: -32000, message: error instanceof Error ? error.message : String(error) },
249
+ })
250
+ }
251
+ }
252
+
253
+ export const startMcpStdioServer = (ctx: McpContext): void => {
254
+ let buffer = Buffer.alloc(0)
255
+ process.stdin.on('data', (chunk: Buffer) => {
256
+ buffer = Buffer.concat([buffer, chunk])
257
+ 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)
264
+ continue
265
+ }
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)
273
+ }
274
+ })
275
+ process.stdin.resume()
276
+ }
@@ -0,0 +1,51 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { firstHeading, firstParagraph, slugFromPath } from '../lib/markdown.js'
5
+ import { toPosix } from '../lib/paths.js'
6
+ import { walkFiles } from '../lib/walk.js'
7
+ import type { MemoryCandidateV1 } from '../schemas/memory-candidate.js'
8
+
9
+ const memoryFact = (raw: string, id: string): string =>
10
+ firstParagraph(raw) ?? firstHeading(raw) ?? id
11
+
12
+ const relativePath = (root: string, abs: string): string =>
13
+ toPosix(abs).replace(`${toPosix(root)}/`, '')
14
+
15
+ const ingestMarkdownDir = (
16
+ root: string,
17
+ dir: string,
18
+ source: MemoryCandidateV1['source'],
19
+ confidence: number,
20
+ ): MemoryCandidateV1[] => {
21
+ if (!existsSync(dir)) return []
22
+
23
+ return walkFiles(dir, { extensions: ['.md', '.mdc'] }).map((abs) => {
24
+ const rel = relativePath(root, abs)
25
+ const raw = readFileSync(abs, 'utf8')
26
+ const id = slugFromPath(rel)
27
+ return {
28
+ schemaVersion: 1,
29
+ id,
30
+ source,
31
+ rawPath: rel,
32
+ fact: memoryFact(raw, id),
33
+ suggestedType: 'project',
34
+ confidence,
35
+ references: [],
36
+ }
37
+ })
38
+ }
39
+
40
+ export const ingestCursorRules = (root: string): MemoryCandidateV1[] => {
41
+ const dir = join(root, '.cursor', 'rules')
42
+ return ingestMarkdownDir(root, dir, 'cursor', 0.6)
43
+ }
44
+
45
+ export const ingestAgentMemory = (root: string): MemoryCandidateV1[] =>
46
+ ingestMarkdownDir(root, join(root, '.agent-memory'), 'agent-memory', 0.7)
47
+
48
+ export const ingestMemoryCandidates = (root: string): MemoryCandidateV1[] => [
49
+ ...ingestAgentMemory(root),
50
+ ...ingestCursorRules(root),
51
+ ]
@@ -0,0 +1,119 @@
1
+ import type { DocBridgeIndexV1 } from '../schemas/doc-bridge-index.js'
2
+ import type { MemoryCandidateV1 } from '../schemas/memory-candidate.js'
3
+ import { searchIndex } from '../query/search.js'
4
+
5
+ export type MemoryRoute = 'agent' | 'human' | 'playbook' | 'discard'
6
+
7
+ export type MemoryClassification = {
8
+ readonly candidate: MemoryCandidateV1
9
+ readonly route: MemoryRoute
10
+ readonly reason: string
11
+ readonly target?: string
12
+ readonly duplicateOf?: string
13
+ }
14
+
15
+ export type SafetyFinding = {
16
+ readonly candidateId: string
17
+ readonly kind: 'secret' | 'private-email'
18
+ readonly message: string
19
+ }
20
+
21
+ export type MemoryPromotionDraft = {
22
+ readonly ok: boolean
23
+ readonly title: string
24
+ readonly body: string
25
+ readonly findings: SafetyFinding[]
26
+ readonly classifications: MemoryClassification[]
27
+ }
28
+
29
+ const packageOwnership = /\b(?:package|module)\s+([a-z0-9._/-]+)\s+(?:owns|owner|responsible|routes?)\b/i
30
+ const secretPattern = /\b(?:api[_-]?key|token|secret|password)\s*[:=]\s*\S+/i
31
+ const privateEmailPattern = /\b[A-Z0-9._%+-]+@(?!example\.com\b)[A-Z0-9.-]+\.[A-Z]{2,}\b/i
32
+
33
+ const classifyRoute = (fact: string): Pick<MemoryClassification, 'route' | 'reason'> => {
34
+ if (/\b(noise|scratch|ignore|discard)\b/i.test(fact)) {
35
+ return { route: 'discard', reason: 'marked as noise' }
36
+ }
37
+ if (/\b(playbook|pattern|principle|best practice)\b/i.test(fact)) {
38
+ return { route: 'playbook', reason: 'generalizable pattern' }
39
+ }
40
+ if (/\b(user-facing|human guide|docs site|tutorial|guide)\b/i.test(fact)) {
41
+ return { route: 'human', reason: 'user-facing documentation' }
42
+ }
43
+ return { route: 'agent', reason: 'project convention or ownership note' }
44
+ }
45
+
46
+ export const classifyMemoryCandidates = (
47
+ candidates: readonly MemoryCandidateV1[],
48
+ index: DocBridgeIndexV1,
49
+ ): MemoryClassification[] =>
50
+ candidates.map((candidate) => {
51
+ const duplicate = searchIndex(index, candidate.fact, 1)[0]
52
+ const targetMatch = packageOwnership.exec(candidate.fact)
53
+ const route = classifyRoute(candidate.fact)
54
+ return {
55
+ candidate,
56
+ ...route,
57
+ ...(targetMatch?.[1] ? { target: targetMatch[1] } : {}),
58
+ ...(duplicate && duplicate.score >= 16 ? { duplicateOf: duplicate.path } : {}),
59
+ }
60
+ })
61
+
62
+ export const scanMemorySafety = (
63
+ classifications: readonly MemoryClassification[],
64
+ ): SafetyFinding[] =>
65
+ classifications.flatMap(({ candidate }) => {
66
+ const findings: SafetyFinding[] = []
67
+ if (secretPattern.test(candidate.fact)) {
68
+ findings.push({
69
+ candidateId: candidate.id,
70
+ kind: 'secret',
71
+ message: 'Potential secret-like value in memory fact',
72
+ })
73
+ }
74
+ if (privateEmailPattern.test(candidate.fact)) {
75
+ findings.push({
76
+ candidateId: candidate.id,
77
+ kind: 'private-email',
78
+ message: 'Potential private email in memory fact',
79
+ })
80
+ }
81
+ return findings
82
+ })
83
+
84
+ export const draftMemoryPromotion = (
85
+ classifications: readonly MemoryClassification[],
86
+ ): MemoryPromotionDraft => {
87
+ const findings = scanMemorySafety(classifications)
88
+ const safe = findings.length === 0
89
+ const lines = classifications.map(({ candidate, route, reason, target, duplicateOf }) =>
90
+ [
91
+ `- ${candidate.id}: ${route}`,
92
+ `reason=${reason}`,
93
+ target ? `target=${target}` : undefined,
94
+ duplicateOf ? `duplicateOf=${duplicateOf}` : undefined,
95
+ `fact=${candidate.fact}`,
96
+ ].filter(Boolean).join(' | '),
97
+ )
98
+
99
+ return {
100
+ ok: safe,
101
+ title: 'Draft doc-bridge memory promotion',
102
+ body: [
103
+ '# Draft doc-bridge memory promotion',
104
+ '',
105
+ safe ? 'Safety scan: pass.' : 'Safety scan: blocked. Redact findings before PR.',
106
+ '',
107
+ '## Candidates',
108
+ '',
109
+ ...lines,
110
+ '',
111
+ '## Policy',
112
+ '',
113
+ '- Draft only; never auto-merge.',
114
+ '- Run doc-bridge gates before opening a PR.',
115
+ ].join('\n'),
116
+ findings,
117
+ classifications: [...classifications],
118
+ }
119
+ }
@@ -0,0 +1,25 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+
4
+ import type { DocBridgeConfigV1 } from '../config/schema.js'
5
+ import type { DocBridgeIndexV1 } from '../schemas/doc-bridge-index.js'
6
+ import { parseDocBridgeIndex } from '../validate.js'
7
+
8
+ export class IndexNotFoundError extends Error {
9
+ constructor(readonly path: string) {
10
+ super(`Missing index at ${path}. Run: ak-docs index`)
11
+ this.name = 'IndexNotFoundError'
12
+ }
13
+ }
14
+
15
+ export const indexFilePath = (root: string, config: DocBridgeConfigV1): string =>
16
+ join(root, config.index?.outFile ?? '.doc-bridge/index.json')
17
+
18
+ export const loadDocBridgeIndex = (root: string, config: DocBridgeConfigV1): DocBridgeIndexV1 => {
19
+ const path = indexFilePath(root, config)
20
+ if (!existsSync(path)) throw new IndexNotFoundError(path)
21
+ const raw = JSON.parse(readFileSync(path, 'utf8')) as unknown
22
+ return parseDocBridgeIndex(raw)
23
+ }
24
+
25
+ export const resolveRoot = (cwd?: string): string => resolve(cwd ?? process.cwd())