@agentskit/doc-bridge 0.1.0-alpha.2 → 1.0.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.
- package/CHANGELOG.md +59 -0
- package/README.md +139 -57
- package/action.yml +78 -0
- package/dist/cli/program.js +1122 -114
- package/dist/cli/program.js.map +1 -1
- package/dist/index.d.ts +258 -28
- package/dist/index.js +824 -54
- package/dist/index.js.map +1 -1
- package/docs/DOGFOOD-ROUND2.md +142 -0
- package/docs/DOGFOOD-ROUND3.md +74 -0
- package/docs/POSITIONING.md +2 -0
- package/docs/RELEASE.md +5 -3
- package/docs/chat-and-rag.md +2 -0
- package/docs/getting-started.md +14 -1
- package/docs/landing/index.html +299 -0
- package/docs/mcp.md +10 -1
- package/docs/ollama-demo.md +64 -0
- package/docs/playbook/doc-bridge-pattern.md +114 -0
- package/docs/recipes/index-pipeline.md +89 -0
- package/docs/skills/doc-bridge.md +64 -0
- package/docs/spec/cli.md +6 -0
- package/docs/spec/playbook-feedback.md +12 -4
- package/examples/demo-example/doc-bridge.config.json +23 -0
- package/examples/demo-example/docs/for-agents/INDEX.md +3 -0
- package/examples/demo-example/docs/for-agents/packages/example.md +14 -0
- package/examples/demo-example/package.json +7 -0
- package/examples/demo-example/src/.gitkeep +0 -0
- package/examples/demo-monorepo/doc-bridge.config.json +37 -0
- package/examples/demo-monorepo/docs/for-agents/INDEX.md +4 -0
- package/examples/demo-monorepo/docs/for-agents/packages/auth.md +22 -0
- package/examples/demo-monorepo/docs/for-agents/packages/billing.md +19 -0
- package/examples/demo-monorepo/docs/human/guides/auth.md +7 -0
- package/examples/demo-monorepo/package.json +7 -0
- package/examples/demo-monorepo/packages/auth/package.json +8 -0
- package/examples/demo-monorepo/packages/billing/package.json +7 -0
- package/examples/demo-monorepo/pnpm-workspace.yaml +2 -0
- package/examples/ollama-chat.config.ts +42 -0
- package/package.json +12 -9
- package/src/cli/demo.ts +143 -0
- package/src/cli/program.ts +220 -26
- package/src/doctor/badge.ts +53 -0
- package/src/doctor/run-doctor.ts +286 -0
- package/src/federation/llms.ts +20 -11
- package/src/index-builder/build-handoffs.ts +35 -2
- package/src/index-builder/scan-corpus.ts +5 -1
- package/src/index-builder/watch-index.ts +94 -0
- package/src/index.ts +24 -0
- package/src/lib/markdown.ts +31 -4
- package/src/mcp/install.ts +84 -0
- package/src/memory/github-pr.ts +190 -0
- package/src/playbook/doc-bridge-pattern.ts +121 -0
- package/src/query/query.ts +19 -1
- package/src/query/search.ts +85 -17
- package/src/schemas/agent-handoff.ts +11 -0
- package/src/schemas/doc-bridge-index.ts +3 -1
- package/src/schemas/json-schemas.ts +2 -1
- package/src/version.ts +1 -1
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import type { DocBridgeConfigV1 } from '../config/schema.js'
|
|
2
|
+
import { buildDocBridgeIndex } from '../index-builder/build-index.js'
|
|
3
|
+
import { scanAgentCorpus } from '../index-builder/scan-corpus.js'
|
|
4
|
+
import { runGates, type GateRunResult } from '../gates/run-gates.js'
|
|
5
|
+
import { IndexNotFoundError, loadDocBridgeIndex } from '../query/load-index.js'
|
|
6
|
+
import type { DocBridgeIndexV1 } from '../schemas/doc-bridge-index.js'
|
|
7
|
+
import { doctorBadgeMetrics, type DoctorBadgeMetrics } from './badge.js'
|
|
8
|
+
|
|
9
|
+
export type DoctorIssue = {
|
|
10
|
+
readonly severity: 'error' | 'warn' | 'info'
|
|
11
|
+
readonly code: string
|
|
12
|
+
readonly message: string
|
|
13
|
+
readonly action?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type DoctorCoverage = {
|
|
17
|
+
readonly packages: {
|
|
18
|
+
readonly total: number
|
|
19
|
+
readonly withAgentDoc: number
|
|
20
|
+
readonly withHumanDoc: number
|
|
21
|
+
readonly missingAgentDoc: readonly string[]
|
|
22
|
+
readonly missingHumanDoc: readonly string[]
|
|
23
|
+
}
|
|
24
|
+
readonly agentDocs: {
|
|
25
|
+
readonly total: number
|
|
26
|
+
readonly indexed: number
|
|
27
|
+
readonly unindexed: readonly string[]
|
|
28
|
+
}
|
|
29
|
+
readonly freshness: {
|
|
30
|
+
readonly ok: boolean
|
|
31
|
+
readonly message: string
|
|
32
|
+
readonly hasIndex: boolean
|
|
33
|
+
}
|
|
34
|
+
readonly gates: GateRunResult
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type DoctorReport = {
|
|
38
|
+
readonly ok: boolean
|
|
39
|
+
readonly score: number
|
|
40
|
+
readonly grade: 'A' | 'B' | 'C' | 'D' | 'F'
|
|
41
|
+
readonly coverage: DoctorCoverage
|
|
42
|
+
readonly badge: DoctorBadgeMetrics
|
|
43
|
+
readonly issues: readonly DoctorIssue[]
|
|
44
|
+
readonly nextActions: readonly string[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const gradeForScore = (score: number): DoctorReport['grade'] => {
|
|
48
|
+
if (score >= 90) return 'A'
|
|
49
|
+
if (score >= 75) return 'B'
|
|
50
|
+
if (score >= 60) return 'C'
|
|
51
|
+
if (score >= 40) return 'D'
|
|
52
|
+
return 'F'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const agentDocPaths = (index: DocBridgeIndexV1): Set<string> => {
|
|
56
|
+
const paths = new Set<string>()
|
|
57
|
+
for (const owner of Object.values(index.lookup?.ownership ?? {})) {
|
|
58
|
+
if (owner.agentDoc) paths.add(owner.agentDoc)
|
|
59
|
+
}
|
|
60
|
+
for (const handoff of Object.values(index.handoffs ?? {})) {
|
|
61
|
+
if (handoff.startHere) paths.add(handoff.startHere)
|
|
62
|
+
}
|
|
63
|
+
return paths
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const computeScore = (coverage: DoctorCoverage): number => {
|
|
67
|
+
let score = 0
|
|
68
|
+
|
|
69
|
+
if (coverage.freshness.hasIndex) score += 15
|
|
70
|
+
if (coverage.freshness.ok) score += 15
|
|
71
|
+
|
|
72
|
+
const { total, withAgentDoc, withHumanDoc } = coverage.packages
|
|
73
|
+
if (total > 0) {
|
|
74
|
+
score += Math.round((withAgentDoc / total) * 35)
|
|
75
|
+
score += Math.round((withHumanDoc / total) * 20)
|
|
76
|
+
} else if (coverage.agentDocs.indexed > 0) {
|
|
77
|
+
score += 35
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (coverage.gates.ok) score += 15
|
|
81
|
+
else {
|
|
82
|
+
const passed = coverage.gates.results.filter((gate) => gate.ok).length
|
|
83
|
+
const totalGates = coverage.gates.results.length || 1
|
|
84
|
+
score += Math.round((passed / totalGates) * 10)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return Math.min(100, Math.max(0, score))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const buildIssues = (coverage: DoctorCoverage): DoctorIssue[] => {
|
|
91
|
+
const issues: DoctorIssue[] = []
|
|
92
|
+
|
|
93
|
+
if (!coverage.freshness.hasIndex) {
|
|
94
|
+
issues.push({
|
|
95
|
+
severity: 'error',
|
|
96
|
+
code: 'index-missing',
|
|
97
|
+
message: 'No doc-bridge index found.',
|
|
98
|
+
action: 'ak-docs index',
|
|
99
|
+
})
|
|
100
|
+
} else if (!coverage.freshness.ok) {
|
|
101
|
+
issues.push({
|
|
102
|
+
severity: 'error',
|
|
103
|
+
code: 'index-stale',
|
|
104
|
+
message: coverage.freshness.message,
|
|
105
|
+
action: 'ak-docs index',
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const id of coverage.packages.missingAgentDoc) {
|
|
110
|
+
issues.push({
|
|
111
|
+
severity: 'warn',
|
|
112
|
+
code: 'missing-agent-doc',
|
|
113
|
+
message: `Package "${id}" has no dedicated agent doc.`,
|
|
114
|
+
action: `ak-docs init --scaffold-workspaces # or edit docs/for-agents/packages/${id}.md`,
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const id of coverage.packages.missingHumanDoc) {
|
|
119
|
+
issues.push({
|
|
120
|
+
severity: 'info',
|
|
121
|
+
code: 'missing-human-doc',
|
|
122
|
+
message: `Package "${id}" has no linked human guide.`,
|
|
123
|
+
action: 'ak-docs bootstrap agent-docs',
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (const gate of coverage.gates.results.filter((result) => !result.ok)) {
|
|
128
|
+
issues.push({
|
|
129
|
+
severity: gate.id === 'index-freshness' ? 'error' : 'warn',
|
|
130
|
+
code: `gate-${gate.id}`,
|
|
131
|
+
message: gate.message,
|
|
132
|
+
action: gate.id === 'index-freshness' ? 'ak-docs index' : 'ak-docs gate run',
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return issues
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const buildNextActions = (issues: readonly DoctorIssue[], coverage: DoctorCoverage): string[] => {
|
|
140
|
+
const actions = new Set<string>()
|
|
141
|
+
for (const issue of issues) {
|
|
142
|
+
if (issue.action) actions.add(issue.action)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!coverage.freshness.hasIndex || !coverage.freshness.ok) {
|
|
146
|
+
actions.add('ak-docs index')
|
|
147
|
+
}
|
|
148
|
+
if (coverage.packages.missingHumanDoc.length) {
|
|
149
|
+
actions.add('ak-docs bootstrap agent-docs')
|
|
150
|
+
}
|
|
151
|
+
if (coverage.packages.total > 0) {
|
|
152
|
+
const sample =
|
|
153
|
+
coverage.packages.missingAgentDoc[0] ??
|
|
154
|
+
coverage.packages.missingHumanDoc[0]
|
|
155
|
+
if (sample) actions.add(`ak-docs query package ${sample} --agent`)
|
|
156
|
+
}
|
|
157
|
+
if (!actions.size) {
|
|
158
|
+
actions.add('ak-docs mcp install --cursor')
|
|
159
|
+
actions.add('ak-docs gate run')
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return [...actions].slice(0, 6)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export const runDoctor = (root: string, config: DocBridgeConfigV1): DoctorReport => {
|
|
166
|
+
let index: DocBridgeIndexV1 | undefined
|
|
167
|
+
let hasIndex = true
|
|
168
|
+
let freshnessOk = false
|
|
169
|
+
let freshnessMessage = 'Index is fresh'
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
index = loadDocBridgeIndex(root, config)
|
|
173
|
+
const next = buildDocBridgeIndex({ root, config, write: false }).index.contentHash
|
|
174
|
+
freshnessOk = index.contentHash === next
|
|
175
|
+
freshnessMessage = freshnessOk ? 'Index is fresh' : 'Index is stale. Run: ak-docs index'
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (error instanceof IndexNotFoundError) {
|
|
178
|
+
hasIndex = false
|
|
179
|
+
freshnessOk = false
|
|
180
|
+
freshnessMessage = error.message
|
|
181
|
+
index = buildDocBridgeIndex({ root, config, write: false }).index
|
|
182
|
+
} else {
|
|
183
|
+
throw error
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const ownership = Object.entries(index.lookup?.ownership ?? {})
|
|
188
|
+
const missingAgentDoc = ownership
|
|
189
|
+
.filter(([, owner]) => !owner.agentDoc || owner.agentDoc === config.corpus.agent.index)
|
|
190
|
+
.map(([id]) => id)
|
|
191
|
+
const missingHumanDoc = ownership.filter(([, owner]) => !owner.humanDoc).map(([id]) => id)
|
|
192
|
+
|
|
193
|
+
const indexedPaths = new Set(index.knowledge.map((entry) => entry.path))
|
|
194
|
+
const expectedAgentDocs = agentDocPaths(index)
|
|
195
|
+
const unindexed = [...expectedAgentDocs].filter((path) => !indexedPaths.has(path))
|
|
196
|
+
|
|
197
|
+
const corpusDocs = scanAgentCorpus(root, config).filter(
|
|
198
|
+
(doc) => doc.path !== config.corpus.agent.index,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
const gates = runGates(root, config)
|
|
202
|
+
|
|
203
|
+
const coverage: DoctorCoverage = {
|
|
204
|
+
packages: {
|
|
205
|
+
total: ownership.length,
|
|
206
|
+
withAgentDoc: ownership.length - missingAgentDoc.length,
|
|
207
|
+
withHumanDoc: ownership.length - missingHumanDoc.length,
|
|
208
|
+
missingAgentDoc,
|
|
209
|
+
missingHumanDoc,
|
|
210
|
+
},
|
|
211
|
+
agentDocs: {
|
|
212
|
+
total: corpusDocs.length,
|
|
213
|
+
indexed: corpusDocs.filter((doc) => indexedPaths.has(doc.path)).length,
|
|
214
|
+
unindexed: corpusDocs.filter((doc) => !indexedPaths.has(doc.path)).map((doc) => doc.path),
|
|
215
|
+
},
|
|
216
|
+
freshness: {
|
|
217
|
+
ok: freshnessOk,
|
|
218
|
+
message: freshnessMessage,
|
|
219
|
+
hasIndex,
|
|
220
|
+
},
|
|
221
|
+
gates,
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const issues = buildIssues(coverage)
|
|
225
|
+
const score = computeScore(coverage)
|
|
226
|
+
const nextActions = buildNextActions(issues, coverage)
|
|
227
|
+
|
|
228
|
+
const report: DoctorReport = {
|
|
229
|
+
ok: issues.every((issue) => issue.severity !== 'error') && gates.ok,
|
|
230
|
+
score,
|
|
231
|
+
grade: gradeForScore(score),
|
|
232
|
+
coverage,
|
|
233
|
+
badge: { handoffPct: 0, bridgePct: 0, score, grade: gradeForScore(score), packages: 0 },
|
|
234
|
+
issues,
|
|
235
|
+
nextActions,
|
|
236
|
+
}
|
|
237
|
+
return { ...report, badge: doctorBadgeMetrics(report) }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export const formatDoctorText = (report: DoctorReport): string[] => {
|
|
241
|
+
const { coverage } = report
|
|
242
|
+
const handoffPct =
|
|
243
|
+
coverage.packages.total > 0
|
|
244
|
+
? Math.round((coverage.packages.withAgentDoc / coverage.packages.total) * 100)
|
|
245
|
+
: 0
|
|
246
|
+
const humanPct =
|
|
247
|
+
coverage.packages.total > 0
|
|
248
|
+
? Math.round((coverage.packages.withHumanDoc / coverage.packages.total) * 100)
|
|
249
|
+
: 0
|
|
250
|
+
|
|
251
|
+
const lines = [
|
|
252
|
+
'doc-bridge doctor',
|
|
253
|
+
'─'.repeat(40),
|
|
254
|
+
`Score: ${report.score}/100 (${report.grade})`,
|
|
255
|
+
'',
|
|
256
|
+
'Coverage',
|
|
257
|
+
` Packages: ${coverage.packages.total}`,
|
|
258
|
+
` Agent docs: ${coverage.packages.withAgentDoc}/${coverage.packages.total} (${handoffPct}% handoff-ready)`,
|
|
259
|
+
` Human guides: ${coverage.packages.withHumanDoc}/${coverage.packages.total} (${humanPct}% bridged)`,
|
|
260
|
+
` Corpus indexed: ${coverage.agentDocs.indexed}/${coverage.agentDocs.total} agent docs`,
|
|
261
|
+
` Index freshness: ${coverage.freshness.ok ? 'fresh' : 'stale or missing'}`,
|
|
262
|
+
` Gates: ${coverage.gates.results.filter((g) => g.ok).length}/${coverage.gates.results.length} passing`,
|
|
263
|
+
` Badge: handoff ${report.badge.handoffPct}% · bridge ${report.badge.bridgePct}%`,
|
|
264
|
+
]
|
|
265
|
+
|
|
266
|
+
if (coverage.packages.missingHumanDoc.length) {
|
|
267
|
+
lines.push('', 'Missing humanDoc (bridge gap)', ...coverage.packages.missingHumanDoc.map((id) => ` • ${id}`))
|
|
268
|
+
}
|
|
269
|
+
if (coverage.packages.missingAgentDoc.length) {
|
|
270
|
+
lines.push('', 'Missing agent doc', ...coverage.packages.missingAgentDoc.map((id) => ` • ${id}`))
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (report.issues.length) {
|
|
274
|
+
lines.push('', 'Issues')
|
|
275
|
+
for (const issue of report.issues.slice(0, 8)) {
|
|
276
|
+
const icon = issue.severity === 'error' ? '✗' : issue.severity === 'warn' ? '!' : '·'
|
|
277
|
+
lines.push(` ${icon} [${issue.code}] ${issue.message}`)
|
|
278
|
+
}
|
|
279
|
+
if (report.issues.length > 8) {
|
|
280
|
+
lines.push(` … +${report.issues.length - 8} more`)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
lines.push('', 'Next actions', ...report.nextActions.map((action) => ` → ${action}`))
|
|
285
|
+
return lines
|
|
286
|
+
}
|
package/src/federation/llms.ts
CHANGED
|
@@ -35,11 +35,15 @@ const sourceText = async (
|
|
|
35
35
|
root: string,
|
|
36
36
|
source: string,
|
|
37
37
|
fetchText: FetchText,
|
|
38
|
-
): Promise<string> => {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
): Promise<string | null> => {
|
|
39
|
+
try {
|
|
40
|
+
if (/^https?:\/\//.test(source)) return await fetchText(source)
|
|
41
|
+
const path = resolve(root, source)
|
|
42
|
+
if (!existsSync(path)) return null
|
|
43
|
+
return readFileSync(path, 'utf8')
|
|
44
|
+
} catch {
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
const sameOrigin = (base: string, target: string): boolean => {
|
|
@@ -97,9 +101,14 @@ export const loadFederatedChunks = async (
|
|
|
97
101
|
): Promise<DocBridgeRetrievedChunk[]> => {
|
|
98
102
|
const fetchText = options.fetchText ?? defaultFetchText
|
|
99
103
|
const chunks: DocBridgeRetrievedChunk[] = []
|
|
104
|
+
const warnings: string[] = []
|
|
100
105
|
for (const source of config.federation?.sources ?? []) {
|
|
101
106
|
if (source.includeInRetriever === false || !source.llmsTxt) continue
|
|
102
107
|
const llms = await sourceText(root, source.llmsTxt, fetchText)
|
|
108
|
+
if (!llms) {
|
|
109
|
+
warnings.push(`federation source skipped (unavailable): ${source.id} → ${source.llmsTxt}`)
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
103
112
|
chunks.push(...chunksFromMarkdown(source.id, llms, source.llmsTxt))
|
|
104
113
|
const links = parseLlmsTxtLinks(llms)
|
|
105
114
|
for (const link of links) {
|
|
@@ -108,14 +117,14 @@ export const loadFederatedChunks = async (
|
|
|
108
117
|
: link.url
|
|
109
118
|
if (!/\.(md|txt)(?:$|\?)/.test(url)) continue
|
|
110
119
|
if (!sameOrigin(source.llmsTxt, url)) continue
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
}
|
|
120
|
+
const raw = await sourceText(root, url, fetchText)
|
|
121
|
+
if (raw) chunks.push(...chunksFromMarkdown(source.id, raw, url))
|
|
117
122
|
}
|
|
118
123
|
}
|
|
124
|
+
// Soft-fail: never throw for missing remote sources; one-line warn for agents/humans.
|
|
125
|
+
if (warnings.length && process.stderr.isTTY) {
|
|
126
|
+
for (const w of warnings) process.stderr.write(`${w}\n`)
|
|
127
|
+
}
|
|
119
128
|
return chunks
|
|
120
129
|
}
|
|
121
130
|
|
|
@@ -103,16 +103,31 @@ const resolveHumanDoc = (
|
|
|
103
103
|
if (override) return override
|
|
104
104
|
if (fmHuman) return fmHuman
|
|
105
105
|
if (humanDocs[packageId]) return humanDocs[packageId]
|
|
106
|
-
|
|
106
|
+
|
|
107
107
|
const aliases = [
|
|
108
108
|
packageId,
|
|
109
109
|
packageId.replace(/^@[^/]+\//, ''),
|
|
110
110
|
packageId.replace(/^os-/, ''),
|
|
111
111
|
packageId.replace(/-pattern$/, ''),
|
|
112
|
+
`packages/${packageId}`,
|
|
113
|
+
`reference/packages/${packageId}`,
|
|
114
|
+
`packages/${packageId}/index`,
|
|
112
115
|
]
|
|
113
116
|
for (const alias of aliases) {
|
|
114
117
|
if (humanDocs[alias]) return humanDocs[alias]
|
|
115
118
|
}
|
|
119
|
+
|
|
120
|
+
// Fuzzy: id is a path segment or suffix of a human doc id
|
|
121
|
+
for (const [key, url] of Object.entries(humanDocs)) {
|
|
122
|
+
if (
|
|
123
|
+
key === packageId ||
|
|
124
|
+
key.endsWith(`/${packageId}`) ||
|
|
125
|
+
key.endsWith(`/${packageId}/index`) ||
|
|
126
|
+
key.endsWith(`-${packageId}`)
|
|
127
|
+
) {
|
|
128
|
+
return url
|
|
129
|
+
}
|
|
130
|
+
}
|
|
116
131
|
return undefined
|
|
117
132
|
}
|
|
118
133
|
|
|
@@ -168,6 +183,18 @@ export const buildLookup = (
|
|
|
168
183
|
}
|
|
169
184
|
ownership[pkg.id] = record
|
|
170
185
|
|
|
186
|
+
const bridge = record.humanDoc
|
|
187
|
+
? /^https?:\/\//.test(record.humanDoc)
|
|
188
|
+
? { humanDoc: 'external' as const }
|
|
189
|
+
: { humanDoc: 'linked' as const }
|
|
190
|
+
: config.corpus.human
|
|
191
|
+
? {
|
|
192
|
+
humanDoc: 'missing' as const,
|
|
193
|
+
action: 'ak-docs bootstrap agent-docs',
|
|
194
|
+
bootstrap: `docs/for-agents/human/${pkg.id}.md`,
|
|
195
|
+
}
|
|
196
|
+
: undefined
|
|
197
|
+
|
|
171
198
|
handoffs[pkg.id] = {
|
|
172
199
|
type: 'agent-handoff',
|
|
173
200
|
schemaVersion: 1,
|
|
@@ -186,7 +213,13 @@ export const buildLookup = (
|
|
|
186
213
|
editRoots: [record.path],
|
|
187
214
|
checks: [...record.checks],
|
|
188
215
|
...(record.humanDoc ? { humanDoc: record.humanDoc } : {}),
|
|
189
|
-
|
|
216
|
+
...(bridge ? { bridge } : {}),
|
|
217
|
+
notes: [
|
|
218
|
+
...(record.purpose ? [record.purpose] : []),
|
|
219
|
+
...(!record.humanDoc && config.corpus.human
|
|
220
|
+
? [`Human guide missing for ${pkg.id}. Run: ak-docs bootstrap agent-docs`]
|
|
221
|
+
: []),
|
|
222
|
+
],
|
|
190
223
|
}
|
|
191
224
|
}
|
|
192
225
|
|
|
@@ -3,6 +3,7 @@ import { join } from 'node:path'
|
|
|
3
3
|
|
|
4
4
|
import type { DocBridgeConfigV1 } from '../config/schema.js'
|
|
5
5
|
import {
|
|
6
|
+
extractSearchBody,
|
|
6
7
|
firstHeading,
|
|
7
8
|
firstParagraph,
|
|
8
9
|
frontmatterString,
|
|
@@ -47,7 +48,9 @@ export const scanAgentCorpus = (root: string, config: DocBridgeConfigV1): Corpus
|
|
|
47
48
|
frontmatterString(frontmatter, 'package') ??
|
|
48
49
|
slugFromPath(relToCorpus)
|
|
49
50
|
const title = firstHeading(raw) ?? id
|
|
50
|
-
const
|
|
51
|
+
const purpose = frontmatterString(frontmatter, 'purpose')
|
|
52
|
+
const description = purpose ?? firstParagraph(raw, 400)
|
|
53
|
+
const body = extractSearchBody(raw)
|
|
51
54
|
return {
|
|
52
55
|
id,
|
|
53
56
|
type: 'agent-doc',
|
|
@@ -57,6 +60,7 @@ export const scanAgentCorpus = (root: string, config: DocBridgeConfigV1): Corpus
|
|
|
57
60
|
relPath,
|
|
58
61
|
frontmatter,
|
|
59
62
|
...(description ? { description } : {}),
|
|
63
|
+
...(body ? { body } : {}),
|
|
60
64
|
}
|
|
61
65
|
})
|
|
62
66
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { existsSync, watch } from 'node:fs'
|
|
2
|
+
import { dirname, join, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import type { DocBridgeConfigV1 } from '../config/schema.js'
|
|
5
|
+
import { buildDocBridgeIndex } from './build-index.js'
|
|
6
|
+
|
|
7
|
+
export type WatchIndexOptions = {
|
|
8
|
+
readonly root: string
|
|
9
|
+
readonly config: DocBridgeConfigV1
|
|
10
|
+
readonly configPath?: string
|
|
11
|
+
readonly debounceMs?: number
|
|
12
|
+
readonly onRebuild?: (summary: { knowledgeCount: number; handoffCount: number; hash: string }) => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const WATCH_PATTERN = /\.(md|mdx|json|ya?ml|mdc)$/i
|
|
16
|
+
|
|
17
|
+
const collectWatchRoots = (root: string, config: DocBridgeConfigV1, configPath?: string): string[] => {
|
|
18
|
+
const roots = new Set<string>()
|
|
19
|
+
roots.add(resolve(root, config.corpus.agent.root))
|
|
20
|
+
const humanSources = config.corpus.human
|
|
21
|
+
? Array.isArray(config.corpus.human)
|
|
22
|
+
? config.corpus.human
|
|
23
|
+
: [config.corpus.human]
|
|
24
|
+
: []
|
|
25
|
+
for (const source of humanSources) {
|
|
26
|
+
const humanOpts = source.options ?? {}
|
|
27
|
+
for (const key of ['contentDir', 'docsDir', 'root']) {
|
|
28
|
+
const value = humanOpts[key]
|
|
29
|
+
if (typeof value === 'string' && value.length) roots.add(resolve(root, value))
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (configPath) roots.add(dirname(resolve(configPath)))
|
|
33
|
+
return [...roots].filter((dir) => existsSync(dir))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const watchDocBridgeIndex = (opts: WatchIndexOptions): Promise<number> => {
|
|
37
|
+
const debounceMs = opts.debounceMs ?? 350
|
|
38
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
39
|
+
let running = false
|
|
40
|
+
|
|
41
|
+
const rebuild = (): void => {
|
|
42
|
+
if (timer) clearTimeout(timer)
|
|
43
|
+
timer = setTimeout(() => {
|
|
44
|
+
if (running) return
|
|
45
|
+
running = true
|
|
46
|
+
try {
|
|
47
|
+
const result = buildDocBridgeIndex({ root: opts.root, config: opts.config })
|
|
48
|
+
const handoffCount = Object.keys(result.index.handoffs ?? {}).length
|
|
49
|
+
const summary = {
|
|
50
|
+
knowledgeCount: result.index.knowledge.length,
|
|
51
|
+
handoffCount,
|
|
52
|
+
hash: result.index.contentHash.slice(0, 8),
|
|
53
|
+
}
|
|
54
|
+
opts.onRebuild?.(summary)
|
|
55
|
+
process.stdout.write(
|
|
56
|
+
`[ak-docs] indexed ${summary.knowledgeCount} docs, ${summary.handoffCount} handoffs (${summary.hash}…)\n`,
|
|
57
|
+
)
|
|
58
|
+
} catch (error) {
|
|
59
|
+
process.stderr.write(
|
|
60
|
+
`[ak-docs] index failed: ${error instanceof Error ? error.message : String(error)}\n`,
|
|
61
|
+
)
|
|
62
|
+
} finally {
|
|
63
|
+
running = false
|
|
64
|
+
}
|
|
65
|
+
}, debounceMs)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
rebuild()
|
|
69
|
+
|
|
70
|
+
for (const dir of collectWatchRoots(opts.root, opts.config, opts.configPath)) {
|
|
71
|
+
watch(dir, { recursive: true }, (_event, filename) => {
|
|
72
|
+
if (!filename || !WATCH_PATTERN.test(filename)) return
|
|
73
|
+
rebuild()
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const configDir = resolve(opts.root)
|
|
78
|
+
if (existsSync(configDir)) {
|
|
79
|
+
watch(configDir, (_event, filename) => {
|
|
80
|
+
if (!filename || !/doc-bridge\.config/.test(filename)) return
|
|
81
|
+
rebuild()
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
process.stdout.write(
|
|
86
|
+
`[ak-docs] watching ${collectWatchRoots(opts.root, opts.config, opts.configPath).join(', ') || opts.root} (Ctrl+C to stop)\n`,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
return new Promise((resolvePromise) => {
|
|
90
|
+
const onSignal = () => resolvePromise(0)
|
|
91
|
+
process.once('SIGINT', onSignal)
|
|
92
|
+
process.once('SIGTERM', onSignal)
|
|
93
|
+
})
|
|
94
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -20,7 +20,9 @@ export {
|
|
|
20
20
|
HandoffTargetTypeSchema,
|
|
21
21
|
HANDOFF_SCHEMA_VERSION,
|
|
22
22
|
normalizeAgentHandoff,
|
|
23
|
+
HandoffBridgeSchema,
|
|
23
24
|
type AgentHandoffV1,
|
|
25
|
+
type HandoffBridge,
|
|
24
26
|
type AgentSearchV1,
|
|
25
27
|
type HandoffTarget,
|
|
26
28
|
type HandoffTargetType,
|
|
@@ -74,6 +76,22 @@ export {
|
|
|
74
76
|
type GateRunResult,
|
|
75
77
|
} from './gates/run-gates.js'
|
|
76
78
|
export { MCP_TOOLS, handleMcpRequest, startMcpStdioServer } from './mcp/server.js'
|
|
79
|
+
export { installMcpConfig, mcpSnippet, type McpInstallResult, type McpInstallTarget } from './mcp/install.js'
|
|
80
|
+
export { runDoctor, formatDoctorText, type DoctorReport, type DoctorIssue, type DoctorCoverage } from './doctor/run-doctor.js'
|
|
81
|
+
export {
|
|
82
|
+
doctorBadgeMetrics,
|
|
83
|
+
formatDoctorBadgeJson,
|
|
84
|
+
formatDoctorBadgeMarkdown,
|
|
85
|
+
type DoctorBadgeMetrics,
|
|
86
|
+
} from './doctor/badge.js'
|
|
87
|
+
export { watchDocBridgeIndex, type WatchIndexOptions } from './index-builder/watch-index.js'
|
|
88
|
+
export {
|
|
89
|
+
promoteMemoryToGithubPr,
|
|
90
|
+
writePromotionDraft,
|
|
91
|
+
defaultPromotionDraftPath,
|
|
92
|
+
type GithubPrOptions,
|
|
93
|
+
type GithubPrResult,
|
|
94
|
+
} from './memory/github-pr.js'
|
|
77
95
|
export { sha256NormalizedV1 } from './index-builder/content-hash.js'
|
|
78
96
|
export { IndexNotFoundError, indexFilePath, loadDocBridgeIndex, resolveRoot } from './query/load-index.js'
|
|
79
97
|
export { runQuery, type QueryKind, type QueryRequest, type QueryResult } from './query/query.js'
|
|
@@ -118,3 +136,9 @@ export { projectRootFromConfigPath } from './config/load-config.js'
|
|
|
118
136
|
export { createDocBridgeRag } from './intelligence/rag.js'
|
|
119
137
|
export { runChatOnce, startInkChat } from './intelligence/chat.js'
|
|
120
138
|
export { PeerMissingError, layer1InstallHint } from './intelligence/peers.js'
|
|
139
|
+
export {
|
|
140
|
+
DOC_BRIDGE_PATTERN_ID,
|
|
141
|
+
DOC_BRIDGE_PATTERN_META,
|
|
142
|
+
docBridgePatternMarkdown,
|
|
143
|
+
docBridgePatternPayload,
|
|
144
|
+
} from './playbook/doc-bridge-pattern.js'
|
package/src/lib/markdown.ts
CHANGED
|
@@ -64,7 +64,8 @@ export const firstHeading = (markdown: string): string | undefined => {
|
|
|
64
64
|
return undefined
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
/** Complete first prose block — prefer full sentences, cap length without mid-word cuts. */
|
|
68
|
+
export const firstParagraph = (markdown: string, maxLen = 400): string | undefined => {
|
|
68
69
|
const { body } = parseFrontmatter(markdown)
|
|
69
70
|
const lines = body.split('\n')
|
|
70
71
|
const buf: string[] = []
|
|
@@ -76,11 +77,37 @@ export const firstParagraph = (markdown: string): string | undefined => {
|
|
|
76
77
|
}
|
|
77
78
|
if (t.startsWith('#')) continue
|
|
78
79
|
if (t.startsWith('---')) continue
|
|
80
|
+
if (t.startsWith('```')) break
|
|
81
|
+
if (t.startsWith('|') || t.startsWith('- [') || t.startsWith('* [')) {
|
|
82
|
+
if (buf.length) break
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
79
85
|
buf.push(t)
|
|
80
|
-
if (buf.join(' ').length
|
|
86
|
+
if (buf.join(' ').length >= maxLen) break
|
|
81
87
|
}
|
|
82
|
-
|
|
83
|
-
|
|
88
|
+
let text = buf.join(' ').replace(/\s+/g, ' ').trim()
|
|
89
|
+
if (!text) return undefined
|
|
90
|
+
if (text.length <= maxLen) return text
|
|
91
|
+
// Prefer ending on sentence boundary
|
|
92
|
+
const sliced = text.slice(0, maxLen)
|
|
93
|
+
const sentenceEnd = Math.max(sliced.lastIndexOf('. '), sliced.lastIndexOf('! '), sliced.lastIndexOf('? '))
|
|
94
|
+
if (sentenceEnd > maxLen * 0.4) return sliced.slice(0, sentenceEnd + 1).trim()
|
|
95
|
+
const wordEnd = sliced.lastIndexOf(' ')
|
|
96
|
+
return (wordEnd > 0 ? sliced.slice(0, wordEnd) : sliced).trim()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Flatten markdown body for search (strip fences/links noise lightly). */
|
|
100
|
+
export const extractSearchBody = (markdown: string, maxLen = 6_000): string => {
|
|
101
|
+
const { body } = parseFrontmatter(markdown)
|
|
102
|
+
const text = body
|
|
103
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
104
|
+
.replace(/!\[[^\]]*\]\([^)]+\)/g, ' ')
|
|
105
|
+
.replace(/\[[^\]]*\]\([^)]+\)/g, ' ')
|
|
106
|
+
.replace(/^#+\s+/gm, '')
|
|
107
|
+
.replace(/[|>*_`#]/g, ' ')
|
|
108
|
+
.replace(/\s+/g, ' ')
|
|
109
|
+
.trim()
|
|
110
|
+
return text.length > maxLen ? text.slice(0, maxLen) : text
|
|
84
111
|
}
|
|
85
112
|
|
|
86
113
|
export const slugFromPath = (relPath: string): string => {
|