@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.
- package/CHANGELOG.md +28 -0
- package/README.md +1 -1
- package/dist/cli/program.js +416 -148
- package/dist/cli/program.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +6 -3
- package/dist/config/index.js.map +1 -1
- package/dist/{index-CD_zmKXf.d.ts → index-CPUJbTbg.d.ts} +19 -10
- package/dist/index.d.ts +25 -6
- package/dist/index.js +398 -132
- package/dist/index.js.map +1 -1
- package/docs/DOGFOOD-ROUND2.md +142 -0
- package/docs/DOGFOOD.md +92 -0
- package/package.json +32 -14
- package/scripts/prepare.mjs +44 -0
- package/src/cli/program.ts +947 -0
- package/src/config/defaults.ts +63 -0
- package/src/config/define-config.ts +6 -0
- package/src/config/index.ts +15 -0
- package/src/config/load-config.ts +138 -0
- package/src/config/schema.ts +294 -0
- package/src/federation/llms.ts +154 -0
- package/src/gates/run-gates.ts +274 -0
- package/src/index-builder/build-handoffs.ts +232 -0
- package/src/index-builder/build-index.ts +137 -0
- package/src/index-builder/capabilities.ts +37 -0
- package/src/index-builder/content-hash.ts +19 -0
- package/src/index-builder/human-adapters/core.ts +100 -0
- package/src/index-builder/human-adapters/docusaurus.ts +113 -0
- package/src/index-builder/human-adapters/fumadocs.ts +70 -0
- package/src/index-builder/human-adapters/index.ts +52 -0
- package/src/index-builder/human-adapters/plain-markdown.ts +10 -0
- package/src/index-builder/llms-txt.ts +19 -0
- package/src/index-builder/plugins/human-markdown.ts +7 -0
- package/src/index-builder/plugins/pnpm-monorepo.ts +72 -0
- package/src/index-builder/scan-corpus.ts +185 -0
- package/src/index.ts +120 -0
- package/src/intelligence/adapter.ts +90 -0
- package/src/intelligence/chat.ts +148 -0
- package/src/intelligence/peers.ts +59 -0
- package/src/intelligence/rag.ts +98 -0
- package/src/lib/glob-expand.ts +33 -0
- package/src/lib/markdown.ts +117 -0
- package/src/lib/package-manager.ts +104 -0
- package/src/lib/paths.ts +6 -0
- package/src/lib/walk.ts +45 -0
- package/src/mcp/server.ts +276 -0
- package/src/memory/ingest.ts +51 -0
- package/src/memory/pipeline.ts +119 -0
- package/src/query/load-index.ts +25 -0
- package/src/query/query.ts +142 -0
- package/src/query/search.ts +126 -0
- package/src/retriever/doc-bridge-retriever.ts +62 -0
- package/src/schemas/agent-handoff.ts +82 -0
- package/src/schemas/doc-bridge-index.ts +108 -0
- package/src/schemas/json-schemas.ts +157 -0
- package/src/schemas/memory-candidate.ts +37 -0
- package/src/shims/agentskit-peers.d.ts +80 -0
- package/src/validate.ts +67 -0
- package/src/version.ts +1 -0
- package/tsconfig.json +21 -0
- 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
|
+
}
|
package/src/lib/paths.ts
ADDED
package/src/lib/walk.ts
ADDED
|
@@ -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())
|