@agentskit/doc-bridge 1.7.44 → 1.9.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 +471 -0
- package/CONTRIBUTING.md +29 -4
- package/README.md +87 -40
- package/SECURITY.md +7 -0
- package/action.yml +1 -1
- package/bin/ak-docs.js +2 -2
- package/bin/ak-verify.js +13 -7
- package/dist/cli/program.d.ts +3 -1
- package/dist/cli/program.js +15888 -6061
- package/dist/cli/program.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +91 -9
- package/dist/config/index.js.map +1 -1
- package/dist/index-Beor6Yhi.d.ts +792 -0
- package/dist/index.d.ts +9979 -3257
- package/dist/index.js +15954 -5774
- package/dist/index.js.map +1 -1
- package/docs/MARKETPLACE.md +1 -1
- package/docs/PRD-documentation-efficiency-study.md +406 -0
- package/docs/PRD-knowledge-retrieval-and-enrichment.md +466 -0
- package/docs/RELEASE.md +22 -8
- package/docs/adr/0002-documentation-audit-boundary.md +22 -0
- package/docs/adr/0003-study-protocol-and-historical-evidence.md +40 -0
- package/docs/adr/0004-controlled-study-runner.md +25 -0
- package/docs/adr/0005-documentation-quality-and-criticality.md +20 -0
- package/docs/adr/0006-registry-semantic-grounding.md +20 -0
- package/docs/adr/0007-longitudinal-study-metrics.md +21 -0
- package/docs/adr/0008-study-verification-boundary.md +21 -0
- package/docs/adr/0009-study-provider-cli-adapter.md +25 -0
- package/docs/agent-corpus/INDEX.md +14 -3
- package/docs/agent-corpus/OVERVIEW.md +25 -0
- package/docs/agent-corpus/chat.md +7 -3
- package/docs/agent-corpus/cli.md +18 -2
- package/docs/agent-corpus/conformance.md +14 -2
- package/docs/agent-corpus/doc-bridge.md +48 -1
- package/docs/agent-corpus/doctor.md +10 -2
- package/docs/agent-corpus/gates.md +6 -2
- package/docs/agent-corpus/mcp.md +15 -2
- package/docs/agent-corpus/memory.md +6 -2
- package/docs/agent-corpus/query.md +35 -2
- package/docs/bench/README.md +122 -0
- package/docs/bench/retrieval-baseline-v1.json +28 -0
- package/docs/bench/retrieval-suite-v1.json +1033 -0
- package/docs/chat-and-rag.md +3 -2
- package/docs/for-agents.md +9 -1
- package/docs/getting-started.md +4 -11
- package/docs/guides/gate-ci.md +11 -1
- package/docs/guides/install-and-run.md +9 -65
- package/docs/index.md +22 -1
- package/docs/knowledge-engine-runbook.md +51 -4
- package/docs/landing/assets/context-payload-reduction.svg +21 -0
- package/docs/landing/assets/controlled-ab-comparison.svg +30 -0
- package/docs/landing/index.html +119 -5
- package/docs/loop-workflow.md +117 -0
- package/docs/mcp.md +6 -1
- package/docs/parity/public-claims-v1.json +145 -0
- package/docs/playbook/doc-bridge-pattern.md +1 -1
- package/docs/query.md +90 -2
- package/docs/recipes/index-pipeline.md +1 -1
- package/docs/schemas/agent-handoff-v1.md +15 -0
- package/docs/schemas/doc-bridge-index-v1.md +65 -0
- package/docs/spec/benchmark-v1.md +39 -1
- package/docs/spec/cli.md +30 -10
- package/docs/spec/config-v1.md +192 -8
- package/docs/spec/documentation-audit-v1.md +61 -0
- package/docs/spec/enrichment-overlay-v1.md +241 -0
- package/docs/spec/graph-signals-v1.md +92 -0
- package/docs/spec/incremental-scan-v1.md +102 -0
- package/docs/spec/markdown-analyzer-v1.md +73 -0
- package/docs/spec/mcp-knowledge-tools-v1.md +147 -0
- package/docs/spec/measured-enrichment-v1.md +229 -0
- package/docs/spec/public-parity-v1.md +119 -0
- package/docs/spec/registry-agents.md +6 -0
- package/docs/spec/render-v1.md +122 -0
- package/docs/spec/retrieval-index-v1.md +164 -0
- package/docs/spec/study-metrics-v1.md +58 -0
- package/docs/spec/study-protocol-v1.md +46 -0
- package/docs/spec/study-provider-cli-v1.md +116 -0
- package/docs/spec/study-runner-v1.md +35 -0
- package/docs/spec/study-task-suite-v1.md +41 -0
- package/docs/spec/study-verification-v1.md +40 -0
- package/docs/study/README.md +84 -0
- package/docs/study/ab-adjudicated-cost-analysis-v1.md +29 -0
- package/docs/study/ab-adjudicated-cost-plan-v1.json +33 -0
- package/docs/study/ab-adjudicated-cost-plan-v2-v1.json +33 -0
- package/docs/study/ab-adjudicated-cost-result-v1.json +80 -0
- package/docs/study/ab-baseline-analysis-v1.md +21 -0
- package/docs/study/ab-baseline-plan-v1.json +33 -0
- package/docs/study/ab-baseline-recovery-plan-v1.json +33 -0
- package/docs/study/ab-baseline-result-v1.json +79 -0
- package/docs/study/documentation-audit-round-2026-08-31.json +183 -0
- package/docs/study/historical-evidence-v1.json +252 -0
- package/docs/study/observation-ledger-v1.json +30632 -0
- package/docs/study/phase3-task-coverage-v1.json +34 -0
- package/docs/study/phase4-public-pilot-ledger-v1.json +1344 -0
- package/docs/study/phase4-public-pilot-result-v1.json +52 -0
- package/docs/study/phase4-public-pilot-run-plan-v1.json +26 -0
- package/docs/study/phase4-public-pilot-task-suite-v1.json +71 -0
- package/docs/study/pilot-round-2026-08-31.json +46 -0
- package/docs/study/protocol-v1.json +90 -0
- package/docs/study/publication-gate-v1.md +45 -0
- package/docs/study/quality-scorecard-cycle-plan.md +545 -0
- package/docs/study/quality-scorecard-v1.json +38 -0
- package/docs/study/round-1-adjudicated-smoke-v1.json +30642 -0
- package/docs/study/round-1-instrumentation-plan-v1.md +39 -0
- package/docs/study/round-2-expanded-adjudication-v1.json +91 -0
- package/docs/study/round-2-expanded-validation-v1.md +58 -0
- package/docs/study/round-3-evidence-contract-v1.json +75 -0
- package/docs/study/round-3-evidence-contract-v1.md +57 -0
- package/docs/study/round-4-confirmation-v1.json +75 -0
- package/docs/study/round-4-confirmation-v1.md +55 -0
- package/docs/study/run-plan-v1.json +33 -0
- package/docs/study/semantic-adjudication-cycle-8.md +20 -0
- package/docs/study/task-suite-v1.json +96 -0
- package/docs/study/token-efficiency-plan-v1.md +337 -0
- package/docs/study/token-efficiency-protocol-v2.json +62 -0
- package/docs/study/verification-binding-v1.json +27 -0
- package/docs/validation-cycle-plan.md +33 -0
- package/docs/verification-harness.md +15 -6
- package/ecosystem-claims.json +2 -2
- package/ecosystem-upstream.json +2 -2
- package/ecosystem.json +4 -4
- package/mcpb/manifest.json +9 -1
- package/package.json +89 -72
- package/scripts/check-ecosystem-upstream.mjs +36 -7
- package/scripts/report-visual-check.mjs +20 -3
- package/skills/doc-bridge-handoff/fixtures/synthetic-repo/docs/for-agents/packages/payments.md +7 -0
- package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
- package/src/agents/registry-adapter.ts +192 -24
- package/src/audit/documentation.ts +513 -0
- package/src/bench/baseline.ts +198 -0
- package/src/bench/overlay-delta.ts +139 -0
- package/src/bench/retrieval.ts +319 -0
- package/src/budget/compile.ts +91 -0
- package/src/budget/sections.ts +70 -0
- package/src/cli/demo.ts +2 -2
- package/src/cli/program.ts +699 -79
- package/src/cli/usage.ts +71 -0
- package/src/config/defaults.ts +1 -0
- package/src/config/index.ts +4 -0
- package/src/config/load-config.ts +7 -1
- package/src/config/schema.ts +121 -4
- package/src/conformance/documentation-standard-v1.ts +22 -14
- package/src/discovery/areas.ts +182 -0
- package/src/discovery/documentation.ts +255 -23
- package/src/discovery/identity.ts +24 -0
- package/src/discovery/incremental.ts +314 -0
- package/src/discovery/inputs.ts +110 -0
- package/src/discovery/markdown.ts +481 -0
- package/src/discovery/repository.ts +557 -125
- package/src/doctor/run-doctor.ts +246 -27
- package/src/enrich/approvals.ts +190 -0
- package/src/enrich/cache.ts +93 -0
- package/src/enrich/context-pack.ts +272 -0
- package/src/enrich/overlay.ts +255 -0
- package/src/enrich/review.ts +106 -0
- package/src/enrich/stage.ts +374 -0
- package/src/enrich/stats.ts +100 -0
- package/src/enrich/validate.ts +410 -0
- package/src/federation/llms.ts +74 -24
- package/src/findings/report.ts +103 -0
- package/src/fixes/proposals.ts +4 -3
- package/src/graph/build.ts +356 -0
- package/src/graph/memory.ts +208 -0
- package/src/index-builder/build-handoffs.ts +22 -11
- package/src/index-builder/build-index.ts +132 -3
- package/src/index-builder/human-adapters/fumadocs.ts +1 -1
- package/src/index-builder/llms-txt.ts +48 -8
- package/src/index-builder/project-corpus.ts +111 -0
- package/src/index-builder/watch-index.ts +1 -1
- package/src/index.ts +630 -2
- package/src/lib/bounded-text.ts +15 -10
- package/src/lib/fuzzy-match.ts +235 -0
- package/src/mcp/knowledge.ts +554 -0
- package/src/mcp/server.ts +113 -18
- package/src/metrics/benchmark.ts +21 -0
- package/src/parity/check.ts +309 -0
- package/src/parity/claims.ts +259 -0
- package/src/parity/resolve.ts +160 -0
- package/src/query/handoff.ts +326 -0
- package/src/query/load-index.ts +53 -1
- package/src/query/query.ts +92 -59
- package/src/query/search.ts +289 -92
- package/src/query/text.ts +155 -0
- package/src/reconciliation/reconcile.ts +148 -15
- package/src/render/data.ts +356 -0
- package/src/render/engine.ts +398 -0
- package/src/render/generated.ts +77 -0
- package/src/render/render.ts +209 -0
- package/src/render/template-source.ts +52 -0
- package/src/render/templates.ts +289 -0
- package/src/report/html.ts +23 -17
- package/src/retrieval/bm25.ts +161 -0
- package/src/retrieval/project.ts +495 -0
- package/src/retrieval/rank.ts +383 -0
- package/src/retrieval/weights.ts +39 -0
- package/src/retriever/doc-bridge-retriever.ts +100 -15
- package/src/rules/engine.ts +45 -12
- package/src/safety/repository.ts +1 -1
- package/src/schemas/agent-handoff.ts +56 -0
- package/src/schemas/budget.ts +37 -0
- package/src/schemas/doc-bridge-index.ts +53 -2
- package/src/schemas/enrichment.ts +369 -0
- package/src/schemas/json-schemas.ts +39 -2
- package/src/schemas/knowledge.ts +19 -3
- package/src/schemas/retrieval-index.ts +152 -0
- package/src/shims/graphology.d.ts +91 -0
- package/src/study/adjudication.ts +196 -0
- package/src/study/execution.ts +350 -0
- package/src/study/expectations.ts +219 -0
- package/src/study/metrics.ts +467 -0
- package/src/study/protocol.ts +271 -0
- package/src/study/provider-cli.ts +115 -0
- package/src/study/provider-telemetry.ts +47 -0
- package/src/study/quality-scorecard.ts +164 -0
- package/src/study/runner.ts +461 -0
- package/src/study/task-suite.ts +321 -0
- package/src/study/verification.ts +134 -0
- package/src/validate.ts +8 -5
- package/src/version.ts +1 -1
- package/src/workflow/engine.ts +36 -11
- package/dist/index-C2PCQSrB.d.ts +0 -2251
- package/scripts/verification-harness.mjs +0 -483
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import type { DocBridgeConfigV1 } from '../config/schema.js'
|
|
5
|
+
import { sha256NormalizedV1 } from '../index-builder/content-hash.js'
|
|
6
|
+
import { redactSecrets, redactValue } from '../safety/repository.js'
|
|
7
|
+
import type { DiscoverySnapshotV1, Evidence, KnowledgeDiagnostic, KnowledgeEntity, ReconciliationReportV1 } from '../schemas/knowledge.js'
|
|
8
|
+
import { entityContentHash } from './validate.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Context packs: what an agent receives instead of the snapshot.
|
|
12
|
+
*
|
|
13
|
+
* `ak-docs suggest` sent the whole redacted snapshot — 740 KB on this repository — in one call,
|
|
14
|
+
* for every question. A pack is one entity, its depth-one neighbours, the open diagnostics that
|
|
15
|
+
* touch it and a bounded excerpt of its own file, built in a fixed order and truncated in a fixed
|
|
16
|
+
* order under a byte budget. Cost becomes proportional to what changed, because the pack hash
|
|
17
|
+
* covers exactly the content hashes the pack is built from, and a pack whose hash has not moved
|
|
18
|
+
* is a pack the cache already answered.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const CONTEXT_PACK_VERSION = 1 as const
|
|
22
|
+
export const DEFAULT_PACK_BYTES = 64 * 1024
|
|
23
|
+
export const MAX_PACK_NEIGHBOURS = 32
|
|
24
|
+
const MAX_PACK_DIAGNOSTICS = 16
|
|
25
|
+
const MAX_EXCERPT_BYTES = 12 * 1024
|
|
26
|
+
const MAX_MESSAGE = 512
|
|
27
|
+
const MAX_METADATA_STRING = 512
|
|
28
|
+
|
|
29
|
+
export type EnrichmentTask = 'curate' | 'review' | 'adjudicate'
|
|
30
|
+
|
|
31
|
+
export type PackNeighbour = {
|
|
32
|
+
readonly id: string
|
|
33
|
+
readonly kind: string
|
|
34
|
+
readonly name: string
|
|
35
|
+
readonly path?: string
|
|
36
|
+
readonly contentHash: string
|
|
37
|
+
readonly relation: string
|
|
38
|
+
readonly direction: 'out' | 'in'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type PackDiagnostic = Pick<KnowledgeDiagnostic, 'id' | 'code' | 'status' | 'severity'> & { readonly message: string }
|
|
42
|
+
|
|
43
|
+
export type PackEvidence = {
|
|
44
|
+
readonly path: string
|
|
45
|
+
readonly contentHash: string
|
|
46
|
+
/** Redacted, bounded, possibly truncated — `truncated` says so. */
|
|
47
|
+
readonly excerpt: string
|
|
48
|
+
readonly truncated: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type ContextPack = {
|
|
52
|
+
readonly type: 'context-pack'
|
|
53
|
+
readonly schemaVersion: typeof CONTEXT_PACK_VERSION
|
|
54
|
+
/** Over the target's and every neighbour's content hash: the pack's identity for the cache. */
|
|
55
|
+
readonly packHash: string
|
|
56
|
+
readonly baseSnapshotHash: string
|
|
57
|
+
readonly areaId: string
|
|
58
|
+
readonly target: {
|
|
59
|
+
readonly id: string
|
|
60
|
+
readonly kind: string
|
|
61
|
+
readonly name: string
|
|
62
|
+
readonly path?: string
|
|
63
|
+
readonly contentHash: string
|
|
64
|
+
readonly aliases: readonly string[]
|
|
65
|
+
/** The entity's own evidence items: what a proposal about it may cite, verbatim. */
|
|
66
|
+
readonly evidence: readonly Pick<Evidence, 'source' | 'path' | 'lineStart' | 'lineEnd'>[]
|
|
67
|
+
readonly metadata: Readonly<Record<string, unknown>>
|
|
68
|
+
}
|
|
69
|
+
readonly neighbours: readonly PackNeighbour[]
|
|
70
|
+
readonly diagnostics: readonly PackDiagnostic[]
|
|
71
|
+
readonly evidence: readonly PackEvidence[]
|
|
72
|
+
/**
|
|
73
|
+
* `bytes` is the serialised size measured with this field at its widest (`maxBytes`), so the
|
|
74
|
+
* value written is never smaller than the pack it describes and never exceeds the budget.
|
|
75
|
+
*/
|
|
76
|
+
readonly budget: { readonly maxBytes: number; readonly bytes: number; readonly dropped: readonly string[] }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type BuildContextPacksOptions = {
|
|
80
|
+
readonly snapshot: Pick<DiscoverySnapshotV1, 'contentHash' | 'entities' | 'relations'>
|
|
81
|
+
readonly report?: Pick<ReconciliationReportV1, 'diagnostics'>
|
|
82
|
+
readonly config?: DocBridgeConfigV1
|
|
83
|
+
/** Which entity kinds to build packs for. Defaults to documents. */
|
|
84
|
+
readonly kinds?: readonly string[]
|
|
85
|
+
/** Only these entity ids. Unset means every entity of the selected kinds. */
|
|
86
|
+
readonly targets?: ReadonlySet<string>
|
|
87
|
+
/** File contents by repository-relative path. Defaults to reading under `root`. */
|
|
88
|
+
readonly readFile?: (path: string) => string | undefined
|
|
89
|
+
readonly root?: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const packByteBudget = (config: DocBridgeConfigV1 | undefined): number => config?.intelligence?.registry?.maxPackBytes ?? DEFAULT_PACK_BYTES
|
|
93
|
+
|
|
94
|
+
const bytes = (value: unknown): number => Buffer.byteLength(JSON.stringify(value), 'utf8')
|
|
95
|
+
|
|
96
|
+
const boundedMetadata = (metadata: Record<string, unknown> | undefined): Record<string, unknown> => {
|
|
97
|
+
const out: Record<string, unknown> = {}
|
|
98
|
+
for (const key of Object.keys(metadata ?? {}).sort()) {
|
|
99
|
+
const value = metadata?.[key]
|
|
100
|
+
if (typeof value === 'string') out[key] = redactSecrets(value).slice(0, MAX_METADATA_STRING)
|
|
101
|
+
else if (typeof value === 'number' || typeof value === 'boolean') out[key] = value
|
|
102
|
+
else if (Array.isArray(value)) out[key] = redactValue(value.slice(0, 64))
|
|
103
|
+
else if (value && typeof value === 'object') out[key] = redactValue(value)
|
|
104
|
+
}
|
|
105
|
+
return out
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const defaultReader = (root: string | undefined) => (path: string): string | undefined => {
|
|
109
|
+
if (!root) return undefined
|
|
110
|
+
try {
|
|
111
|
+
return readFileSync(join(resolve(root), path), 'utf8')
|
|
112
|
+
} catch {
|
|
113
|
+
return undefined
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The area a path belongs to: the most specific area entity whose path prefixes it. */
|
|
118
|
+
const areaFor = (path: string | undefined, areas: readonly KnowledgeEntity[]): string => {
|
|
119
|
+
if (!path) return 'root'
|
|
120
|
+
let best: KnowledgeEntity | undefined
|
|
121
|
+
for (const area of areas) {
|
|
122
|
+
const areaPath = area.path?.replace(/\/$/, '')
|
|
123
|
+
if (!areaPath) continue
|
|
124
|
+
if (path === areaPath || path.startsWith(`${areaPath}/`)) {
|
|
125
|
+
if (!best || areaPath.length > (best.path?.length ?? 0)) best = area
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return best?.id ?? 'root'
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Fit a pack under its budget by dropping in a declared order: excerpt bytes first (halved
|
|
133
|
+
* until it fits, then dropped), then diagnostics from the end, then neighbours from the end.
|
|
134
|
+
* The target itself is never dropped — a pack with no target is not a pack.
|
|
135
|
+
*
|
|
136
|
+
* This mirrors `compileBudget` from `@agentskit/core` with a byte counter and the
|
|
137
|
+
* `drop-oldest` strategy over sections ordered least-important-first; a test cross-checks the
|
|
138
|
+
* two so the mirror cannot drift, and the mirror is what runs, because a pack must be the same
|
|
139
|
+
* pack whether or not an optional peer is installed.
|
|
140
|
+
*/
|
|
141
|
+
export const fitContextPack = (pack: ContextPack, maxBytes: number): ContextPack => {
|
|
142
|
+
let current: ContextPack = { ...pack, budget: { maxBytes, bytes: maxBytes, dropped: [] } }
|
|
143
|
+
const dropped: string[] = []
|
|
144
|
+
const measure = (): number => bytes({ ...current, budget: { maxBytes, bytes: maxBytes, dropped } })
|
|
145
|
+
let size = measure()
|
|
146
|
+
while (size > maxBytes && current.evidence.some((item) => item.excerpt.length > 0)) {
|
|
147
|
+
current = {
|
|
148
|
+
...current,
|
|
149
|
+
evidence: current.evidence.map((item) => {
|
|
150
|
+
if (!item.excerpt.length) return item
|
|
151
|
+
const next = item.excerpt.length > 256 ? item.excerpt.slice(0, Math.floor(item.excerpt.length / 2)) : ''
|
|
152
|
+
return { ...item, excerpt: next, truncated: true }
|
|
153
|
+
}),
|
|
154
|
+
}
|
|
155
|
+
if (!dropped.includes('evidence.excerpt')) dropped.push('evidence.excerpt')
|
|
156
|
+
size = measure()
|
|
157
|
+
}
|
|
158
|
+
while (size > maxBytes && current.diagnostics.length) {
|
|
159
|
+
current = { ...current, diagnostics: current.diagnostics.slice(0, -1) }
|
|
160
|
+
if (!dropped.includes('diagnostics')) dropped.push('diagnostics')
|
|
161
|
+
size = measure()
|
|
162
|
+
}
|
|
163
|
+
while (size > maxBytes && current.neighbours.length) {
|
|
164
|
+
current = { ...current, neighbours: current.neighbours.slice(0, -1) }
|
|
165
|
+
if (!dropped.includes('neighbours')) dropped.push('neighbours')
|
|
166
|
+
size = measure()
|
|
167
|
+
}
|
|
168
|
+
return { ...current, budget: { maxBytes, bytes: measure(), dropped } }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The pack hash: the target and its neighbours by content hash, nothing else. */
|
|
172
|
+
export const contextPackHash = (target: { readonly id: string; readonly contentHash: string }, neighbours: readonly Pick<PackNeighbour, 'id' | 'contentHash'>[]): string =>
|
|
173
|
+
sha256NormalizedV1({
|
|
174
|
+
version: CONTEXT_PACK_VERSION,
|
|
175
|
+
target: { id: target.id, contentHash: target.contentHash },
|
|
176
|
+
neighbours: [...neighbours].map((item) => ({ id: item.id, contentHash: item.contentHash })).sort((left, right) => left.id.localeCompare(right.id)),
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Build one pack per target entity, in id order.
|
|
181
|
+
*
|
|
182
|
+
* Neighbours are the other ends of every relation touching the target, sorted by kind then id
|
|
183
|
+
* and capped; diagnostics are the open ones naming the target or citing its file; the excerpt is
|
|
184
|
+
* the target's own file, read only if its bytes still hash to what the snapshot recorded.
|
|
185
|
+
*/
|
|
186
|
+
export const buildContextPacks = (options: BuildContextPacksOptions): ContextPack[] => {
|
|
187
|
+
const { snapshot } = options
|
|
188
|
+
const kinds = new Set(options.kinds ?? ['document'])
|
|
189
|
+
const readFile = options.readFile ?? defaultReader(options.root)
|
|
190
|
+
const maxBytes = packByteBudget(options.config)
|
|
191
|
+
const entities = new Map(snapshot.entities.map((entity) => [entity.id, entity]))
|
|
192
|
+
const areas = snapshot.entities.filter((entity) => entity.kind === 'area')
|
|
193
|
+
const touching = new Map<string, { readonly other: string; readonly relation: string; readonly direction: 'out' | 'in' }[]>()
|
|
194
|
+
const push = (id: string, item: { other: string; relation: string; direction: 'out' | 'in' }): void => {
|
|
195
|
+
const list = touching.get(id)
|
|
196
|
+
if (list) list.push(item)
|
|
197
|
+
else touching.set(id, [item])
|
|
198
|
+
}
|
|
199
|
+
for (const relation of snapshot.relations) {
|
|
200
|
+
push(relation.from, { other: relation.to, relation: relation.kind, direction: 'out' })
|
|
201
|
+
push(relation.to, { other: relation.from, relation: relation.kind, direction: 'in' })
|
|
202
|
+
}
|
|
203
|
+
const open = (options.report?.diagnostics ?? []).filter((diagnostic) => diagnostic.status !== 'confirmed')
|
|
204
|
+
|
|
205
|
+
const packs: ContextPack[] = []
|
|
206
|
+
for (const entity of [...snapshot.entities].sort((left, right) => left.id.localeCompare(right.id))) {
|
|
207
|
+
if (!kinds.has(entity.kind)) continue
|
|
208
|
+
if (options.targets && !options.targets.has(entity.id)) continue
|
|
209
|
+
const contentHash = entityContentHash(entity)
|
|
210
|
+
const neighbours: PackNeighbour[] = (touching.get(entity.id) ?? [])
|
|
211
|
+
.flatMap((item) => {
|
|
212
|
+
const other = entities.get(item.other)
|
|
213
|
+
return other
|
|
214
|
+
? [{ id: other.id, kind: other.kind, name: other.name, ...(other.path ? { path: other.path } : {}), contentHash: entityContentHash(other), relation: item.relation, direction: item.direction }]
|
|
215
|
+
: []
|
|
216
|
+
})
|
|
217
|
+
.sort((left, right) => left.kind.localeCompare(right.kind) || left.id.localeCompare(right.id) || left.relation.localeCompare(right.relation))
|
|
218
|
+
.filter((item, index, all) => index === 0 || item.id !== all[index - 1]?.id || item.relation !== all[index - 1]?.relation)
|
|
219
|
+
.slice(0, MAX_PACK_NEIGHBOURS)
|
|
220
|
+
const diagnostics: PackDiagnostic[] = open
|
|
221
|
+
.filter((diagnostic) => diagnostic.entityIds?.includes(entity.id) || (entity.path !== undefined && diagnostic.evidence.some((item) => item.path === entity.path)))
|
|
222
|
+
.sort((left, right) => left.id.localeCompare(right.id))
|
|
223
|
+
.slice(0, MAX_PACK_DIAGNOSTICS)
|
|
224
|
+
.map((diagnostic) => ({ id: diagnostic.id, code: diagnostic.code, status: diagnostic.status, severity: diagnostic.severity, message: redactSecrets(diagnostic.message).slice(0, MAX_MESSAGE) }))
|
|
225
|
+
const evidence: PackEvidence[] = []
|
|
226
|
+
if (entity.path && entity.evidence[0]?.contentHash) {
|
|
227
|
+
const raw = readFile(entity.path)
|
|
228
|
+
// A file that no longer hashes to the entity is not the entity's file: the pack describes the snapshot.
|
|
229
|
+
if (raw !== undefined && sha256NormalizedV1(raw.replace(/^/, '')) === entity.evidence[0].contentHash) {
|
|
230
|
+
const redacted = redactSecrets(raw)
|
|
231
|
+
const truncated = Buffer.byteLength(redacted, 'utf8') > MAX_EXCERPT_BYTES
|
|
232
|
+
evidence.push({ path: entity.path, contentHash: entity.evidence[0].contentHash, excerpt: truncated ? redacted.slice(0, MAX_EXCERPT_BYTES) : redacted, truncated })
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const draft: ContextPack = {
|
|
236
|
+
type: 'context-pack',
|
|
237
|
+
schemaVersion: CONTEXT_PACK_VERSION,
|
|
238
|
+
packHash: contextPackHash({ id: entity.id, contentHash }, neighbours),
|
|
239
|
+
baseSnapshotHash: snapshot.contentHash,
|
|
240
|
+
areaId: entity.kind === 'area' ? entity.id : areaFor(entity.path, areas),
|
|
241
|
+
target: {
|
|
242
|
+
id: entity.id,
|
|
243
|
+
kind: entity.kind,
|
|
244
|
+
name: redactSecrets(entity.name),
|
|
245
|
+
...(entity.path ? { path: entity.path } : {}),
|
|
246
|
+
contentHash,
|
|
247
|
+
aliases: [...(entity.aliases ?? [])].map(redactSecrets).sort(),
|
|
248
|
+
evidence: entity.evidence.slice(0, 8).map((item) => ({ source: item.source, path: item.path, ...(item.lineStart !== undefined ? { lineStart: item.lineStart } : {}), ...(item.lineEnd !== undefined ? { lineEnd: item.lineEnd } : {}) })),
|
|
249
|
+
metadata: boundedMetadata(entity.metadata),
|
|
250
|
+
},
|
|
251
|
+
neighbours,
|
|
252
|
+
diagnostics,
|
|
253
|
+
evidence,
|
|
254
|
+
budget: { maxBytes, bytes: 0, dropped: [] },
|
|
255
|
+
}
|
|
256
|
+
packs.push(fitContextPack(draft, maxBytes))
|
|
257
|
+
}
|
|
258
|
+
return packs
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Packs grouped by area, areas in id order, packs in id order within each: one batch per call. */
|
|
262
|
+
export const batchContextPacks = (packs: readonly ContextPack[]): readonly { readonly areaId: string; readonly packs: readonly ContextPack[] }[] => {
|
|
263
|
+
const groups = new Map<string, ContextPack[]>()
|
|
264
|
+
for (const pack of packs) {
|
|
265
|
+
const list = groups.get(pack.areaId)
|
|
266
|
+
if (list) list.push(pack)
|
|
267
|
+
else groups.set(pack.areaId, [pack])
|
|
268
|
+
}
|
|
269
|
+
return [...groups.entries()]
|
|
270
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
271
|
+
.map(([areaId, list]) => ({ areaId, packs: [...list].sort((left, right) => left.target.id.localeCompare(right.target.id)) }))
|
|
272
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { sha256NormalizedV1 } from '../index-builder/content-hash.js'
|
|
5
|
+
import type { RetrievalOverlayInput } from '../retrieval/project.js'
|
|
6
|
+
import {
|
|
7
|
+
ENRICHMENT_POLICY,
|
|
8
|
+
EnrichmentOverlayV1Schema,
|
|
9
|
+
enrichmentOverlayContentHash,
|
|
10
|
+
type AcceptedEnrichment,
|
|
11
|
+
type EnrichmentOverlayV1,
|
|
12
|
+
type EnrichmentProposalOf,
|
|
13
|
+
} from '../schemas/enrichment.js'
|
|
14
|
+
import type { DiscoverySnapshotV1, KnowledgeEntity, KnowledgeRelation } from '../schemas/knowledge.js'
|
|
15
|
+
import { entityContentHash } from './validate.js'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The overlay on disk, and what the deterministic layer reads from it.
|
|
19
|
+
*
|
|
20
|
+
* The file is written by the enrich stage and by an approval; it is never written by a read.
|
|
21
|
+
* `index`, `search`, `query` and MCP consult it through `projectEnrichmentOverlay`, which is a
|
|
22
|
+
* pure function of the overlay and the snapshot: an accepted entry whose target hash no longer
|
|
23
|
+
* matches its entity is expired *in the result*, not in the file, so a stale entry costs nothing
|
|
24
|
+
* and a read leaves no trace.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export const ENRICHMENT_DIR = '.doc-bridge/enrich'
|
|
28
|
+
export const ENRICHMENT_OVERLAY_FILE = 'overlay.json'
|
|
29
|
+
|
|
30
|
+
export const enrichmentDir = (root: string): string => join(resolve(root), ENRICHMENT_DIR)
|
|
31
|
+
export const enrichmentOverlayPath = (root: string): string => join(enrichmentDir(root), ENRICHMENT_OVERLAY_FILE)
|
|
32
|
+
export const enrichmentCacheDir = (root: string): string => join(enrichmentDir(root), 'cache')
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Read the overlay, or nothing.
|
|
36
|
+
*
|
|
37
|
+
* Missing, unreadable, malformed or failing its own hash all mean the same thing to a reader:
|
|
38
|
+
* there is no overlay. Failing closed here is what keeps a corrupt file from changing a `check`
|
|
39
|
+
* result — a reader that threw would make the deterministic layer depend on the enrichment one.
|
|
40
|
+
*/
|
|
41
|
+
export const readEnrichmentOverlay = (root: string): EnrichmentOverlayV1 | undefined => {
|
|
42
|
+
const path = enrichmentOverlayPath(root)
|
|
43
|
+
if (!existsSync(path)) return undefined
|
|
44
|
+
try {
|
|
45
|
+
const parsed = EnrichmentOverlayV1Schema.parse(JSON.parse(readFileSync(path, 'utf8')) as unknown)
|
|
46
|
+
return parsed.contentHash === enrichmentOverlayContentHash(parsed) ? parsed : undefined
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Parse an overlay from memory with the same rules as a read: schema and hash, or nothing. */
|
|
53
|
+
export const parseEnrichmentOverlay = (value: unknown): EnrichmentOverlayV1 | undefined => {
|
|
54
|
+
const parsed = EnrichmentOverlayV1Schema.safeParse(value)
|
|
55
|
+
return parsed.success && parsed.data.contentHash === enrichmentOverlayContentHash(parsed.data) ? parsed.data : undefined
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const writeEnrichmentOverlay = (root: string, overlay: EnrichmentOverlayV1): string => {
|
|
59
|
+
const path = enrichmentOverlayPath(root)
|
|
60
|
+
mkdirSync(enrichmentDir(root), { recursive: true })
|
|
61
|
+
const sealed = EnrichmentOverlayV1Schema.parse({ ...overlay, contentHash: enrichmentOverlayContentHash(overlay) })
|
|
62
|
+
const temporary = `${path}.tmp-${process.pid}`
|
|
63
|
+
writeFileSync(temporary, `${JSON.stringify(sealed, null, 2)}\n`, 'utf8')
|
|
64
|
+
renameSync(temporary, path)
|
|
65
|
+
return path
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Seal an overlay: every list sorted, the hash recomputed. The shape every writer goes through. */
|
|
69
|
+
export const sealEnrichmentOverlay = (overlay: Omit<EnrichmentOverlayV1, 'contentHash'>): EnrichmentOverlayV1 => {
|
|
70
|
+
const sorted = {
|
|
71
|
+
...overlay,
|
|
72
|
+
accepted: [...overlay.accepted].sort((left, right) => left.proposal.proposalId.localeCompare(right.proposal.proposalId)),
|
|
73
|
+
pending: [...overlay.pending].sort((left, right) => left.proposal.proposalId.localeCompare(right.proposal.proposalId)),
|
|
74
|
+
rejected: [...overlay.rejected].sort((left, right) => left.proposalId.localeCompare(right.proposalId) || left.reason.localeCompare(right.reason)),
|
|
75
|
+
}
|
|
76
|
+
return EnrichmentOverlayV1Schema.parse({ ...sorted, contentHash: enrichmentOverlayContentHash(sorted) })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type EffectiveOverlay = {
|
|
80
|
+
/** Accepted entries whose target still has the hash they were made against. */
|
|
81
|
+
readonly live: readonly AcceptedEnrichment[]
|
|
82
|
+
/** Accepted entries whose target moved: excluded from every projection, left in the file. */
|
|
83
|
+
readonly expired: readonly AcceptedEnrichment[]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Per-entry staleness.
|
|
88
|
+
*
|
|
89
|
+
* An entry binds to the content hash of the entity it describes, not to the snapshot hash, so one
|
|
90
|
+
* changed file expires one entry and its siblings survive. An entity that no longer exists expires
|
|
91
|
+
* its entries too: there is nothing left to describe.
|
|
92
|
+
*/
|
|
93
|
+
export const effectiveEnrichment = (overlay: Pick<EnrichmentOverlayV1, 'accepted'>, snapshot: Pick<DiscoverySnapshotV1, 'entities'>): EffectiveOverlay => {
|
|
94
|
+
const hashes = new Map(snapshot.entities.map((entity) => [entity.id, entityContentHash(entity)]))
|
|
95
|
+
const live: AcceptedEnrichment[] = []
|
|
96
|
+
const expired: AcceptedEnrichment[] = []
|
|
97
|
+
for (const entry of overlay.accepted) {
|
|
98
|
+
const current = hashes.get(entry.proposal.entity)
|
|
99
|
+
const still = current !== undefined && current === entry.proposal.targetContentHash && endpointsPresent(entry, hashes)
|
|
100
|
+
;(still ? live : expired).push(entry)
|
|
101
|
+
}
|
|
102
|
+
return { live, expired }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A relation or a flag names a second entity; if that one is gone, the entry has nothing to point at. */
|
|
106
|
+
const endpointsPresent = (entry: AcceptedEnrichment, hashes: ReadonlyMap<string, string>): boolean => {
|
|
107
|
+
const { proposal } = entry
|
|
108
|
+
switch (proposal.kind) {
|
|
109
|
+
case 'propose-relation':
|
|
110
|
+
return hashes.has(proposal.payload.from) && hashes.has(proposal.payload.to)
|
|
111
|
+
case 'mark-canonical':
|
|
112
|
+
return hashes.has(proposal.payload.scope)
|
|
113
|
+
case 'flag-contradiction':
|
|
114
|
+
return hashes.has(proposal.payload.against)
|
|
115
|
+
case 'flag-redundancy':
|
|
116
|
+
return hashes.has(proposal.payload.with)
|
|
117
|
+
case 'flag-gap':
|
|
118
|
+
return hashes.has(proposal.payload.area)
|
|
119
|
+
default:
|
|
120
|
+
return true
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The hash of what an overlay contributes: the live accepted set. Empty when nothing is live. */
|
|
125
|
+
export const enrichmentOverlayHash = (live: readonly AcceptedEnrichment[]): string =>
|
|
126
|
+
sha256NormalizedV1({
|
|
127
|
+
accepted: [...live]
|
|
128
|
+
.map((entry) => ({ proposalId: entry.proposal.proposalId, targetContentHash: entry.proposal.targetContentHash }))
|
|
129
|
+
.sort((left, right) => left.proposalId.localeCompare(right.proposalId)),
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
/** Signals are shares of the bounded weight: a strong hint is the full share, a canonical marker most of it, a weak hint half. */
|
|
133
|
+
const SIGNAL_STRONG = 1
|
|
134
|
+
const SIGNAL_CANONICAL = 0.8
|
|
135
|
+
const SIGNAL_WEAK = 0.5
|
|
136
|
+
|
|
137
|
+
const relationOf = (proposal: EnrichmentProposalOf<'propose-relation'>): KnowledgeRelation => ({
|
|
138
|
+
id: `relation:${proposal.payload.from}|${proposal.payload.kind}|${proposal.payload.to}|proposed:${proposal.proposalId.slice(0, 12)}`,
|
|
139
|
+
kind: proposal.payload.kind,
|
|
140
|
+
from: proposal.payload.from,
|
|
141
|
+
to: proposal.payload.to,
|
|
142
|
+
provenance: 'proposed',
|
|
143
|
+
evidence: proposal.evidence,
|
|
144
|
+
metadata: { proposalId: proposal.proposalId, detection: proposal.payload.detection, confidence: 'proposed', agentId: proposal.origin.agentId },
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* What the projection reads from an overlay: aliases, summaries, intents, canonical markers,
|
|
149
|
+
* rank hints and relations, each already expired against the snapshot.
|
|
150
|
+
*
|
|
151
|
+
* Findings (`flag-*`) and area suggestions contribute nothing here on purpose. A gap is a thing to
|
|
152
|
+
* review, not a fact to rank on; an area suggestion becomes configuration or nothing.
|
|
153
|
+
*/
|
|
154
|
+
export const projectEnrichmentOverlay = (
|
|
155
|
+
overlay: EnrichmentOverlayV1 | undefined,
|
|
156
|
+
snapshot: Pick<DiscoverySnapshotV1, 'entities'>,
|
|
157
|
+
): RetrievalOverlayInput | undefined => {
|
|
158
|
+
if (!overlay) return undefined
|
|
159
|
+
const { live } = effectiveEnrichment(overlay, snapshot)
|
|
160
|
+
const signals = new Map<string, number>()
|
|
161
|
+
const aliases = new Map<string, string[]>()
|
|
162
|
+
const summaries = new Map<string, string>()
|
|
163
|
+
const canonical = new Map<string, string>()
|
|
164
|
+
const intents: { id: string; title: string; paths: string[]; language: string }[] = []
|
|
165
|
+
const relations: KnowledgeRelation[] = []
|
|
166
|
+
const paths = new Map(snapshot.entities.map((entity) => [entity.id, entity.path]))
|
|
167
|
+
const bump = (id: string, share: number): void => {
|
|
168
|
+
signals.set(id, Math.min(1, (signals.get(id) ?? 0) + share))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
for (const entry of live) {
|
|
172
|
+
const { proposal } = entry
|
|
173
|
+
switch (proposal.kind) {
|
|
174
|
+
case 'add-alias':
|
|
175
|
+
aliases.set(proposal.entity, [...(aliases.get(proposal.entity) ?? []), proposal.payload.alias])
|
|
176
|
+
break
|
|
177
|
+
case 'summarize':
|
|
178
|
+
if (!summaries.has(proposal.entity)) summaries.set(proposal.entity, proposal.payload.summary)
|
|
179
|
+
break
|
|
180
|
+
case 'add-intent':
|
|
181
|
+
intents.push({
|
|
182
|
+
id: `intent:proposed:${proposal.proposalId.slice(0, 16)}`,
|
|
183
|
+
title: proposal.payload.phrase,
|
|
184
|
+
paths: [paths.get(proposal.entity) ?? proposal.entity],
|
|
185
|
+
language: proposal.payload.language,
|
|
186
|
+
})
|
|
187
|
+
break
|
|
188
|
+
case 'mark-canonical':
|
|
189
|
+
canonical.set(proposal.entity, proposal.payload.scope)
|
|
190
|
+
bump(proposal.entity, SIGNAL_CANONICAL)
|
|
191
|
+
break
|
|
192
|
+
case 'rank-hint':
|
|
193
|
+
bump(proposal.entity, proposal.payload.relevance === 'strong' ? SIGNAL_STRONG : SIGNAL_WEAK)
|
|
194
|
+
break
|
|
195
|
+
case 'propose-relation':
|
|
196
|
+
relations.push(relationOf(proposal))
|
|
197
|
+
break
|
|
198
|
+
default:
|
|
199
|
+
break
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
for (const list of aliases.values()) list.sort()
|
|
203
|
+
intents.sort((left, right) => left.id.localeCompare(right.id))
|
|
204
|
+
relations.sort((left, right) => left.id.localeCompare(right.id))
|
|
205
|
+
return {
|
|
206
|
+
hash: enrichmentOverlayHash(live),
|
|
207
|
+
...(signals.size ? { signals } : {}),
|
|
208
|
+
...(aliases.size ? { aliases } : {}),
|
|
209
|
+
...(summaries.size ? { summaries } : {}),
|
|
210
|
+
...(canonical.size ? { canonical } : {}),
|
|
211
|
+
...(intents.length ? { intents } : {}),
|
|
212
|
+
...(relations.length ? { relations } : {}),
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The snapshot plus the live accepted relations, for the graph, the report and the memory view.
|
|
218
|
+
*
|
|
219
|
+
* Additive only: every observed entity and relation is carried unchanged, and a proposed relation
|
|
220
|
+
* whose id collides with an observed one is dropped rather than allowed to shadow it. The
|
|
221
|
+
* assertion below is what the projection tests run; it is here so any consumer can run it.
|
|
222
|
+
*/
|
|
223
|
+
export const withAcceptedRelations = <T extends Pick<DiscoverySnapshotV1, 'entities' | 'relations'>>(
|
|
224
|
+
snapshot: T,
|
|
225
|
+
overlay: EnrichmentOverlayV1 | undefined,
|
|
226
|
+
): T => {
|
|
227
|
+
if (!overlay) return snapshot
|
|
228
|
+
const { live } = effectiveEnrichment(overlay, snapshot)
|
|
229
|
+
const observedIds = new Set(snapshot.relations.map((relation) => relation.id))
|
|
230
|
+
const proposed = live
|
|
231
|
+
.flatMap((entry) => (entry.proposal.kind === 'propose-relation' ? [relationOf(entry.proposal as EnrichmentProposalOf<'propose-relation'>)] : []))
|
|
232
|
+
.filter((relation) => !observedIds.has(relation.id))
|
|
233
|
+
.sort((left, right) => left.id.localeCompare(right.id))
|
|
234
|
+
if (!proposed.length) return snapshot
|
|
235
|
+
const merged = { ...snapshot, relations: [...snapshot.relations, ...proposed] }
|
|
236
|
+
assertObservedSurvive(snapshot, merged)
|
|
237
|
+
return merged
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Every observed entity and relation must be present, unchanged, after enrichment. */
|
|
241
|
+
export const assertObservedSurvive = (
|
|
242
|
+
observed: Pick<DiscoverySnapshotV1, 'entities' | 'relations'>,
|
|
243
|
+
enriched: Pick<DiscoverySnapshotV1, 'entities' | 'relations'>,
|
|
244
|
+
): void => {
|
|
245
|
+
const entities = new Map(enriched.entities.map((entity) => [entity.id, entity]))
|
|
246
|
+
const relations = new Map(enriched.relations.map((relation) => [relation.id, relation]))
|
|
247
|
+
const same = (left: KnowledgeEntity | KnowledgeRelation, right: KnowledgeEntity | KnowledgeRelation | undefined): boolean =>
|
|
248
|
+
right !== undefined && sha256NormalizedV1(left) === sha256NormalizedV1(right)
|
|
249
|
+
for (const entity of observed.entities) if (!same(entity, entities.get(entity.id))) throw new Error(`Enrichment removed or altered observed entity ${entity.id}.`)
|
|
250
|
+
for (const relation of observed.relations) if (!same(relation, relations.get(relation.id))) throw new Error(`Enrichment removed or altered observed relation ${relation.id}.`)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Accepted entries that are findings rather than facts, for a reviewer or a renderer. */
|
|
254
|
+
export const enrichmentFindings = (overlay: EnrichmentOverlayV1): readonly AcceptedEnrichment[] =>
|
|
255
|
+
overlay.accepted.filter((entry) => ENRICHMENT_POLICY[entry.proposal.kind] === 'finding')
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { ENRICHMENT_POLICY, type AcceptedEnrichment, type EnrichmentOverlayV1, type PendingEnrichment, type RejectedEnrichment } from '../schemas/enrichment.js'
|
|
2
|
+
import type { DiscoverySnapshotV1 } from '../schemas/knowledge.js'
|
|
3
|
+
import { approvalsDir, createFileApprovalStore, enrichmentApprovalId, loadApprovalGate, ENRICHMENT_APPROVAL_GATE, type ApprovalGate } from './approvals.js'
|
|
4
|
+
import { effectiveEnrichment, readEnrichmentOverlay, sealEnrichmentOverlay, writeEnrichmentOverlay } from './overlay.js'
|
|
5
|
+
import { entityContentHash } from './validate.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Reviewing the overlay: what a person does with it.
|
|
9
|
+
*
|
|
10
|
+
* Shared by `ak-docs enrich list|approve|reject`, the MCP `docbridge.proposals` tool and a
|
|
11
|
+
* rendered review page, so there is one way to decide and one record of the decision. A
|
|
12
|
+
* decision goes through the approval gate first and the overlay second: if the gate refuses —
|
|
13
|
+
* already decided, unknown — the overlay is untouched.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type EnrichmentReview = {
|
|
17
|
+
readonly overlayHash: string
|
|
18
|
+
readonly baseSnapshotHash: string
|
|
19
|
+
readonly accepted: readonly AcceptedEnrichment[]
|
|
20
|
+
readonly pending: readonly PendingEnrichment[]
|
|
21
|
+
readonly rejected: readonly RejectedEnrichment[]
|
|
22
|
+
readonly stats: EnrichmentOverlayV1['stats']
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const listEnrichment = (root: string): EnrichmentReview | undefined => {
|
|
26
|
+
const overlay = readEnrichmentOverlay(root)
|
|
27
|
+
if (!overlay) return undefined
|
|
28
|
+
return { overlayHash: overlay.contentHash, baseSnapshotHash: overlay.baseSnapshotHash, accepted: overlay.accepted, pending: overlay.pending, rejected: overlay.rejected, stats: overlay.stats }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type DecideEnrichmentOptions = {
|
|
32
|
+
readonly root: string
|
|
33
|
+
readonly proposalId: string
|
|
34
|
+
readonly decision: 'approved' | 'rejected'
|
|
35
|
+
/** Who decided. Must not be the proposal's author, and must not be `policy`. */
|
|
36
|
+
readonly by: string
|
|
37
|
+
readonly reason?: string
|
|
38
|
+
/** When given, the proposal must still describe the entity as it is now. */
|
|
39
|
+
readonly snapshot?: Pick<DiscoverySnapshotV1, 'entities'>
|
|
40
|
+
readonly gate?: ApprovalGate
|
|
41
|
+
readonly now?: () => string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type DecideEnrichmentResult = {
|
|
45
|
+
readonly overlay: EnrichmentOverlayV1
|
|
46
|
+
readonly approvalId: string
|
|
47
|
+
readonly entry: AcceptedEnrichment | RejectedEnrichment
|
|
48
|
+
readonly gateSource: 'ecosystem' | 'mirror' | 'supplied'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Approve or reject one pending proposal.
|
|
53
|
+
*
|
|
54
|
+
* Binding is to both the proposal id and the target content hash: the approval record's id is
|
|
55
|
+
* their hash, so an approval given for one version of a document cannot be replayed against the
|
|
56
|
+
* next one. A person approving their own agent's output is refused by identity, and `policy` is
|
|
57
|
+
* not a person.
|
|
58
|
+
*/
|
|
59
|
+
export const decideEnrichment = async (options: DecideEnrichmentOptions): Promise<DecideEnrichmentResult> => {
|
|
60
|
+
const overlay = readEnrichmentOverlay(options.root)
|
|
61
|
+
if (!overlay) throw new Error('No enrichment overlay to review. Run `ak-docs enrich` first.')
|
|
62
|
+
const entry = overlay.pending.find((item) => item.proposal.proposalId === options.proposalId || item.proposal.proposalId.startsWith(options.proposalId))
|
|
63
|
+
if (!entry) throw new Error(`No pending enrichment proposal "${options.proposalId}".`)
|
|
64
|
+
const { proposal } = entry
|
|
65
|
+
if (options.by === 'policy') throw new Error(`${proposal.kind} requires a person; "policy" cannot approve it.`)
|
|
66
|
+
if (options.by === proposal.origin.agentId) throw new Error(`Rejected: "${options.by}" proposed ${proposal.proposalId} and cannot approve its own output.`)
|
|
67
|
+
if (options.snapshot) {
|
|
68
|
+
const entity = options.snapshot.entities.find((item) => item.id === proposal.entity)
|
|
69
|
+
if (!entity || entityContentHash(entity) !== proposal.targetContentHash) throw new Error(`Proposal ${proposal.proposalId} is stale: ${proposal.entity} changed since it was made. Re-run enrichment.`)
|
|
70
|
+
if (effectiveEnrichment({ accepted: [{ proposal, acceptedAt: '1970-01-01T00:00:00.000Z', acceptedBy: options.by }] }, options.snapshot).live.length !== 1) throw new Error(`Proposal ${proposal.proposalId} names an entity that no longer exists.`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const approvalId = enrichmentApprovalId(proposal.proposalId, proposal.targetContentHash)
|
|
74
|
+
const loaded = options.gate ? { gate: options.gate, source: 'supplied' as const } : await loadApprovalGate(createFileApprovalStore(approvalsDir(options.root)))
|
|
75
|
+
const existing = await loaded.gate.request({
|
|
76
|
+
id: approvalId,
|
|
77
|
+
name: ENRICHMENT_APPROVAL_GATE,
|
|
78
|
+
payload: { proposalId: proposal.proposalId, targetContentHash: proposal.targetContentHash, kind: proposal.kind, entity: proposal.entity, policy: ENRICHMENT_POLICY[proposal.kind] },
|
|
79
|
+
})
|
|
80
|
+
if (existing.status !== 'pending') throw new Error(`Approval ${approvalId} was already ${existing.status}.`)
|
|
81
|
+
const approval = await loaded.gate.decide(approvalId, options.decision, { by: options.by, proposalId: proposal.proposalId, targetContentHash: proposal.targetContentHash, ...(options.reason ? { reason: options.reason } : {}) })
|
|
82
|
+
|
|
83
|
+
const pending = overlay.pending.filter((item) => item.proposal.proposalId !== proposal.proposalId)
|
|
84
|
+
const decided: AcceptedEnrichment | RejectedEnrichment =
|
|
85
|
+
options.decision === 'approved'
|
|
86
|
+
? { proposal, acceptedAt: approval.decidedAt ?? (options.now ?? (() => new Date().toISOString()))(), acceptedBy: options.by, approvalId }
|
|
87
|
+
: { proposalId: proposal.proposalId, kind: proposal.kind, entity: proposal.entity, reason: 'human-rejected', detail: `${options.by}${options.reason ? `: ${options.reason}` : ''}`.slice(0, 1_024), origin: proposal.origin }
|
|
88
|
+
const stats = { ...overlay.stats, byKind: { ...overlay.stats.byKind }, rejectionReasons: { ...overlay.stats.rejectionReasons } }
|
|
89
|
+
const counts = { ...(stats.byKind[proposal.kind] ?? { proposed: 0, accepted: 0, pending: 0, rejected: 0 }) }
|
|
90
|
+
counts.pending = Math.max(0, counts.pending - 1)
|
|
91
|
+
if (options.decision === 'approved') counts.accepted += 1
|
|
92
|
+
else {
|
|
93
|
+
counts.rejected += 1
|
|
94
|
+
stats.rejectionReasons['human-rejected'] = (stats.rejectionReasons['human-rejected'] ?? 0) + 1
|
|
95
|
+
}
|
|
96
|
+
stats.byKind[proposal.kind] = counts
|
|
97
|
+
const next = sealEnrichmentOverlay({
|
|
98
|
+
...overlay,
|
|
99
|
+
pending,
|
|
100
|
+
accepted: options.decision === 'approved' ? [...overlay.accepted, decided as AcceptedEnrichment] : overlay.accepted,
|
|
101
|
+
rejected: options.decision === 'rejected' ? [...overlay.rejected, decided as RejectedEnrichment] : overlay.rejected,
|
|
102
|
+
stats,
|
|
103
|
+
})
|
|
104
|
+
writeEnrichmentOverlay(options.root, next)
|
|
105
|
+
return { overlay: next, approvalId, entry: decided, gateSource: loaded.source }
|
|
106
|
+
}
|