diff --git a/AGENTS.md b/AGENTS.md index d302647..a556b85 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,7 @@ Otherwise, it stays in this package. - Build the retrieval brief with `buildKnowledgeBrief` before a run starts, and mint a receipt from `brief.results`, so retrieval is recorded rather than instructed. - Run `agent-knowledge index` after page changes. - Run `planInvalidationPropagation` + `formatKnowledgeInvalidationProposal` after grading, so every citer of a refuted page records `citesInvalidated`. +- Move knowledge into shared scope only with `promoteRunScopedPages`. A run never writes the shared root itself. - Run `agent-knowledge lint` before trusting or promoting knowledge. - Treat `missing-source` lint findings as blocking. - Use `--json` for automation. diff --git a/CHANGELOG.md b/CHANGELOG.md index 49fbae9..d967d92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 10.5.0 — 2026-08-21 + +### Added + +- Add `promoteRunScopedPages(stores, runId, { pageIds, sharedRoot, actor, reason })`, the only path from run scope into the curated shared store. It carries the closure of the run-local pages a promoted page cites, each keeping its own evidence fields exactly as written, and refuses the promotion when any citation would not resolve in the target — including one qualified with `here::` or `inherited:`, whose scope does not exist in shared. Pages travel as the bytes their store holds, so a promoted page has one digest in both scopes. +- Every promotion writes a record at `/.agent-knowledge/promotions/.json` naming the source run, each page digest, which pages were requested and which were carried support, the actor, the reason, and the time. The record is content-addressed, so re-running one promotion writes the same bytes at the same path. Read it back with `loadKnowledgePromotionRecord(sharedRoot, digest)`. +- Add `RunScopedStores.storePath(runId)`. Promotion carries a page unchanged, which needs the store root a chain read hides. + ## 10.4.0 — 2026-08-21 ### Added diff --git a/README.md b/README.md index d3296d9..b827885 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,24 @@ support-kb/ index.json # generated search index ``` +## Promote a run's knowledge into the shared store + +A run writes only its own store. Knowledge reaches the curated shared store through one call, and every promotion leaves a record: + +```ts +const record = await promoteRunScopedPages(stores, runId, { + pageIds: ['latency-budget'], + sharedRoot, + actor: 'drew', + reason: 'The measurement replicated twice.', +}) +``` + +A claim's cited support travels with it. Promoting a claim and leaving the run-local pages it cites behind is what turns a resolved citation into a dangling one, so the closure of cited pages is carried, each keeping its own evidence fields exactly as written — a promoted claim cannot inherit a confidence its support does not carry. +The promotion is refused when any citation would not resolve in the shared store, including a citation qualified with `here::` or `inherited:`, whose scope does not exist there. +Pages travel as the bytes their store holds, so a promoted page has one digest in both scopes. +The record lands at `/.agent-knowledge/promotions/.json` with the source run, every page digest, which pages were requested and which were carried support, the actor, the reason, and the time. Re-running the same promotion writes the same record at the same path. + ## Brief a run before its first token "Search the store first" is an instruction an agent may or may not follow. A brief is infrastructure: it retrieves the settled knowledge a question can reach and hands it over with the ids a later write must cite. diff --git a/api-surface.json b/api-surface.json index 4d921d2..db819d1 100644 --- a/api-surface.json +++ b/api-surface.json @@ -147,6 +147,9 @@ "KB_INDEX_PATH": "value", "KB_STORE_DIR": "value", "KNOWLEDGE_EVENT_TYPES": "value", + "KNOWLEDGE_PROMOTIONS_DIRECTORY": "value", + "KNOWLEDGE_PROMOTION_DIGEST_ALGORITHM": "value", + "KNOWLEDGE_PROMOTION_SCHEMA_VERSION": "value", "KNOWLEDGE_RECEIPT_DIGEST_ALGORITHM": "value", "KNOWLEDGE_SEARCH_RETRIEVER_ID": "value", "KNOWLEDGE_USE_RECEIPT_SCHEMA_VERSION": "value", @@ -264,6 +267,10 @@ "KnowledgePagesOptions": "value", "KnowledgePolicy": "value", "KnowledgePolicyDispatch": "type", + "KnowledgePromotionEntry": "value", + "KnowledgePromotionError": "value", + "KnowledgePromotionErrorCode": "value", + "KnowledgePromotionRecord": "value", "KnowledgeProposal": "value", "KnowledgeProposalParseError": "value", "KnowledgeReadOptions": "type", @@ -348,6 +355,7 @@ "PoliteFetchOptions": "value", "PoliteFetchResult": "value", "PromoteKnowledgeCandidateOptions": "type", + "PromoteRunScopedPagesOptions": "value", "ProposeFromFindingsResult": "value", "READINESS_SPEC_DEFAULTS": "value", "RUN_LINEAGE_BASENAME": "value", @@ -640,6 +648,7 @@ "loadKnowledgeImprovementEvents": "value", "loadKnowledgeImprovementState": "value", "loadKnowledgePages": "value", + "loadKnowledgePromotionRecord": "value", "loadSourceRegistry": "value", "looksLikeBlockPage": "value", "materialFactsSurfaced": "value", @@ -669,6 +678,7 @@ "planInvalidationPropagation": "value", "politeFetch": "value", "promoteKnowledgeCandidate": "value", + "promoteRunScopedPages": "value", "proposeFromFinding": "value", "proposeFromFindings": "value", "ragAnswerQualityJudge": "value", diff --git a/package.json b/package.json index bd8fdcf..3935b86 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/agent-knowledge", - "version": "10.4.0", + "version": "10.5.0", "description": "Build, search, evaluate, and improve source-backed knowledge bases.", "homepage": "https://github.com/tangle-network/agent-knowledge#readme", "repository": { diff --git a/src/index.ts b/src/index.ts index 0dc3454..232bbad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,6 +50,7 @@ export { } from './mutation-lock' export * from './optimization' export * from './pages-directory' +export * from './promotion' export * from './proposals' export * from './propose-from-finding' export * from './rag-eval' diff --git a/src/promotion.test.ts b/src/promotion.test.ts new file mode 100644 index 0000000..c432293 --- /dev/null +++ b/src/promotion.test.ts @@ -0,0 +1,109 @@ +import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { KnowledgeCitationResolutionError } from './citation-resolution' +import { + KnowledgePromotionError, + loadKnowledgePromotionRecord, + promoteRunScopedPages, +} from './promotion' +import { createRunScopedStores, type RunScopedStores } from './run-scoped' +import { loadKnowledgePages } from './store' + +let root: string +let shared: string +let stores: RunScopedStores + +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'promotion-'))) + shared = await realpath(await mkdtemp(join(tmpdir(), 'promotion-shared-'))) + stores = createRunScopedStores({ root, sharedRoot: shared }) +}) +afterEach(async () => { + await rm(root, { recursive: true, force: true }) + await rm(shared, { recursive: true, force: true }) +}) + +async function addPage(runId: string, id: string, frontmatter: string, body: string) { + await writeFile( + join(stores.storePath(runId), 'knowledge', `${id}.md`), + `---\nid: ${id}\n${frontmatter}---\n\n${body}\n`, + ) +} + +describe('promoteRunScopedPages', () => { + it('carries the run-local support a promoted claim cites, at its own evidence level', async () => { + await stores.init('run-a') + await addPage('run-a', 'measurement', 'rung: 4\n', 'The measured latency was 32 ms.') + await addPage( + 'run-a', + 'claim', + 'rung: 2\ncites:\n - measurement\n', + 'Latency is the dominant term.', + ) + + const record = await promoteRunScopedPages(stores, 'run-a', { + pageIds: ['claim'], + sharedRoot: shared, + actor: 'drew', + reason: 'The measurement replicated twice.', + }) + + expect(record.entries.map((entry) => [entry.pageId, entry.requested])).toEqual([ + ['claim', true], + ['measurement', false], + ]) + const promoted = await loadKnowledgePages(shared) + expect(promoted.map((page) => page.id).sort()).toEqual(['claim', 'measurement']) + expect(promoted.find((page) => page.id === 'measurement')!.frontmatter.rung).toBe(4) + expect(promoted.find((page) => page.id === 'claim')!.frontmatter.rung).toBe(2) + expect(await readFile(join(shared, 'knowledge', 'claim.md'), 'utf8')).toBe( + await readFile(join(stores.storePath('run-a'), 'knowledge', 'claim.md'), 'utf8'), + ) + }) + + it('refuses a promotion whose citation would resolve to nothing in the shared store', async () => { + await stores.init('run-a') + await addPage('run-a', 'claim', 'cites:\n - absent\n', 'Built on a page that does not exist.') + + await expect( + promoteRunScopedPages(stores, 'run-a', { + pageIds: ['claim'], + sharedRoot: shared, + actor: 'drew', + reason: 'testing the gate', + }), + ).rejects.toThrow(KnowledgeCitationResolutionError) + expect(await loadKnowledgePages(shared)).toEqual([]) + }) + + it('refuses to promote a page the run did not author', async () => { + await stores.init('run-a') + + await expect( + promoteRunScopedPages(stores, 'run-a', { + pageIds: ['never-written'], + sharedRoot: shared, + actor: 'drew', + reason: 'testing the gate', + }), + ).rejects.toThrow(KnowledgePromotionError) + }) + + it('writes a record that reloads unchanged and re-promotes to the same bytes', async () => { + await stores.init('run-a') + await addPage('run-a', 'finding', '', 'A finding worth sharing.') + const options = { + pageIds: ['finding'], + sharedRoot: shared, + actor: 'drew', + reason: 'The finding held across three runs.', + now: () => new Date('2026-08-21T00:00:00.000Z'), + } + + const record = await promoteRunScopedPages(stores, 'run-a', options) + expect(await loadKnowledgePromotionRecord(shared, record.recordDigest)).toEqual(record) + expect(await promoteRunScopedPages(stores, 'run-a', options)).toEqual(record) + }) +}) diff --git a/src/promotion.ts b/src/promotion.ts new file mode 100644 index 0000000..7457405 --- /dev/null +++ b/src/promotion.ts @@ -0,0 +1,332 @@ +/** + * Run-scope to shared-scope promotion. + * + * A run writes only its own store. Knowledge reaches the curated shared store + * through this call and no other, and every promotion leaves a record naming + * the source run, the promoted bytes, the support carried with them, the actor, + * and the reason. + * + * A claim's cited support travels with it. Promoting a claim while leaving the + * run-local pages it cites behind is what turns a resolved citation into a + * dangling one, so the closure of cited pages is carried and the promotion is + * refused when any citation would not resolve in the target. + */ +import { canonicalCandidateDigest, type Sha256Digest } from '@tangle-network/agent-interface' +import { + assertKnowledgeCitationsResolved, + parseKnowledgeCitationReference, + resolveKnowledgeCitation, +} from './citation-resolution' +import { isMissingFile, readRegularFileWithinRoot, writeJsonDurableWithinRoot } from './durable-fs' +import { commitKnowledgeFileMutations } from './file-transaction' +import { knowledgePageDigest } from './knowledge-use-receipts' +import { withKnowledgeMutation } from './mutation-lock' +import { type KnowledgePagesOptions, normalizePagesDirectory } from './pages-directory' +import { + type OriginatedPage, + originatedPages, + type PageOrigin, + type RunScopedStores, +} from './run-scoped' +import { initKnowledgeBase, loadKnowledgePages } from './store' +import type { KnowledgeId, KnowledgePage } from './types' + +export const KNOWLEDGE_PROMOTION_SCHEMA_VERSION = '1.0.0' as const +export const KNOWLEDGE_PROMOTION_DIGEST_ALGORITHM = 'rfc8785-sha256' as const + +/** Root-relative directory holding one JSON record per promotion. */ +export const KNOWLEDGE_PROMOTIONS_DIRECTORY = '.agent-knowledge/promotions' + +export type KnowledgePromotionErrorCode = + | 'unknown-page' + | 'ambiguous-support' + | 'id-conflict' + | 'path-conflict' + +/** A promotion that cannot be performed exactly as asked. */ +export class KnowledgePromotionError extends Error { + readonly code: KnowledgePromotionErrorCode + + constructor(code: KnowledgePromotionErrorCode, message: string) { + super(message) + this.name = 'KnowledgePromotionError' + this.code = code + } +} + +export interface KnowledgePromotionEntry { + readonly pageId: KnowledgeId + readonly path: string + /** Where the page was read from in the source run's chain. */ + readonly sourceOrigin: PageOrigin + readonly pageDigest: Sha256Digest + /** False when the page travelled only because a promoted page cites it. */ + readonly requested: boolean +} + +/** Immutable record of one promotion into a shared store. */ +export interface KnowledgePromotionRecord { + readonly schemaVersion: typeof KNOWLEDGE_PROMOTION_SCHEMA_VERSION + readonly kind: 'knowledge-promotion' + readonly digestAlgorithm: typeof KNOWLEDGE_PROMOTION_DIGEST_ALGORITHM + readonly recordDigest: Sha256Digest + readonly createdAt: string + readonly runId: string + readonly actor: string + readonly reason: string + /** Promoted pages first, then carried support, each group in path order. */ + readonly entries: readonly KnowledgePromotionEntry[] +} + +export interface PromoteRunScopedPagesOptions extends KnowledgePagesOptions { + /** Stable ids of the pages this run authored and wants in shared scope. */ + readonly pageIds: readonly KnowledgeId[] + /** The curated store every run reads and no run writes directly. */ + readonly sharedRoot: string + /** Who decided to promote. */ + readonly actor: string + /** Why this knowledge belongs in shared scope. */ + readonly reason: string + readonly now?: () => Date +} + +/** + * Promote pages a run authored into the shared store, with their cited support. + * + * Support keeps its own evidence fields exactly as written, so a promoted claim + * cannot inherit a confidence its support does not carry. Pages travel as the + * bytes their store holds. + * + * Re-running the same promotion is safe: unchanged pages produce no file + * mutation and the record is content-addressed, so it lands at the same path + * with the same bytes. + */ +export async function promoteRunScopedPages( + stores: RunScopedStores, + runId: string, + options: PromoteRunScopedPagesOptions, +): Promise { + const pagesDirectory = normalizePagesDirectory(options.pagesDirectory) + const sharedRoot = nonEmpty(options.sharedRoot, 'promotion sharedRoot') + const actor = nonEmpty(options.actor, 'promotion actor') + const reason = nonEmpty(options.reason, 'promotion reason') + if (!Array.isArray(options.pageIds) || options.pageIds.length === 0) { + throw new TypeError('promotion pageIds must name at least one page') + } + + const chain = await stores.loadChain(runId) + const travellers = collectTravellers(chain, options.pageIds) + + await initKnowledgeBase(sharedRoot) + return withKnowledgeMutation(sharedRoot, async (lock) => { + const existing = await loadKnowledgePages(sharedRoot, { pagesDirectory }) + assertNoIdentityConflict(travellers, existing) + + const promotedPaths = new Set(travellers.map((traveller) => traveller.entry.page.path)) + const promotedView = originatedPages( + [ + ...existing.filter((page) => !promotedPaths.has(page.path)), + ...travellers.map((traveller) => traveller.entry.page), + ], + 'shared', + ) + assertKnowledgeCitationsResolved( + promotedView, + travellers.flatMap((traveller) => + (traveller.entry.page.cites ?? []).map((persisted: string) => + parseKnowledgeCitationReference(persisted), + ), + ), + ) + + const mutations = await Promise.all( + travellers.map(async (traveller) => ({ + path: traveller.entry.page.path, + content: await readPageBytes(stores, runId, traveller.entry), + })), + ) + await commitKnowledgeFileMutations({ + root: sharedRoot, + transactionRoot: lock.transactionRoot, + purpose: `knowledge-promotion:${runId}`, + mutations, + pagesDirectory, + assertOwned: lock.assertOwned, + }) + + const record = buildRecord({ + runId, + actor, + reason, + travellers, + createdAt: (options.now ?? (() => new Date()))().toISOString(), + }) + await writeJsonDurableWithinRoot( + sharedRoot, + `${KNOWLEDGE_PROMOTIONS_DIRECTORY}/${record.recordDigest}.json`, + record, + ) + return record + }) +} + +/** Read one promotion record written by `promoteRunScopedPages`. */ +export async function loadKnowledgePromotionRecord( + sharedRoot: string, + recordDigest: Sha256Digest, +): Promise { + try { + const snapshot = await readRegularFileWithinRoot( + sharedRoot, + `${KNOWLEDGE_PROMOTIONS_DIRECTORY}/${recordDigest}.json`, + ) + return JSON.parse(snapshot.bytes.toString('utf8')) as KnowledgePromotionRecord + } catch (error) { + if (isMissingFile(error)) return null + throw error + } +} + +interface Traveller { + readonly entry: OriginatedPage + requested: boolean +} + +/** + * The requested pages plus the closure of the run-local pages they cite. + * + * A citation into the shared store needs no carrying, and an unresolved + * citation is left to the target-side check, which reports every one of them + * together rather than failing on the first. + */ +function collectTravellers( + chain: readonly OriginatedPage[], + pageIds: readonly KnowledgeId[], +): Traveller[] { + const byId = new Map() + const queue: Array<{ entry: OriginatedPage; requested: boolean }> = [] + + for (const pageId of pageIds) { + const authored = chain.filter((entry) => entry.origin === 'here' && entry.page.id === pageId) + if (authored.length === 0) { + throw new KnowledgePromotionError( + 'unknown-page', + `promotion page "${pageId}" was not authored by this run`, + ) + } + if (authored.length > 1) { + throw new KnowledgePromotionError( + 'ambiguous-support', + `promotion page "${pageId}" names ${authored.length} pages in this run`, + ) + } + queue.push({ entry: authored[0]!, requested: true }) + } + + while (queue.length > 0) { + const next = queue.shift()! + const seen = byId.get(next.entry.page.id) + if (seen) { + if (next.requested) seen.requested = true + if (seen.entry.page.path !== next.entry.page.path) { + throw new KnowledgePromotionError( + 'ambiguous-support', + `promotion support "${next.entry.page.id}" names more than one visible page`, + ) + } + continue + } + byId.set(next.entry.page.id, { entry: next.entry, requested: next.requested }) + for (const persisted of next.entry.page.cites ?? []) { + const resolution = resolveKnowledgeCitation(chain, parseKnowledgeCitationReference(persisted)) + const target = resolution.resolved + if (target === undefined || target.origin === 'shared') continue + queue.push({ entry: { page: target.page, origin: target.origin }, requested: false }) + } + } + + return [...byId.values()].sort( + (left, right) => + Number(right.requested) - Number(left.requested) || + left.entry.page.path.localeCompare(right.entry.page.path), + ) +} + +function assertNoIdentityConflict( + travellers: readonly Traveller[], + existing: readonly KnowledgePage[], +): void { + for (const traveller of travellers) { + const page = traveller.entry.page + const idClash = existing.find((other) => other.id === page.id && other.path !== page.path) + if (idClash) { + throw new KnowledgePromotionError( + 'id-conflict', + `shared page "${idClash.path}" already holds id "${page.id}"; promoting "${page.path}" would create two`, + ) + } + const pathClash = existing.find((other) => other.path === page.path && other.id !== page.id) + if (pathClash) { + throw new KnowledgePromotionError( + 'path-conflict', + `shared page "${page.path}" holds id "${pathClash.id}", not "${page.id}"`, + ) + } + } +} + +/** + * The page as its own store holds it. A promoted page must be the same bytes + * in both scopes, so the record's digest describes what a reader will load. + */ +async function readPageBytes( + stores: RunScopedStores, + runId: string, + entry: OriginatedPage, +): Promise { + const sourceRunId = sourceRunOf(entry.origin, runId) + const snapshot = await readRegularFileWithinRoot(stores.storePath(sourceRunId), entry.page.path) + return snapshot.bytes.toString('utf8') +} + +function sourceRunOf(origin: PageOrigin, runId: string): string { + if (origin === 'here') return runId + if (origin.startsWith('inherited:')) return origin.slice('inherited:'.length) + throw new Error(`promotion cannot read the bytes of a ${origin} page`) +} + +function buildRecord(input: { + runId: string + actor: string + reason: string + travellers: readonly Traveller[] + createdAt: string +}): KnowledgePromotionRecord { + const entries: KnowledgePromotionEntry[] = input.travellers.map((traveller) => + Object.freeze({ + pageId: traveller.entry.page.id, + path: traveller.entry.page.path, + sourceOrigin: traveller.entry.origin, + pageDigest: knowledgePageDigest(traveller.entry.page), + requested: traveller.requested, + }), + ) + const material = { + schemaVersion: KNOWLEDGE_PROMOTION_SCHEMA_VERSION, + kind: 'knowledge-promotion' as const, + digestAlgorithm: KNOWLEDGE_PROMOTION_DIGEST_ALGORITHM, + createdAt: input.createdAt, + runId: input.runId, + actor: input.actor, + reason: input.reason, + entries, + } + return Object.freeze({ ...material, recordDigest: canonicalCandidateDigest(material) }) +} + +function nonEmpty(value: string, label: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new TypeError(`${label} must be a non-empty string`) + } + return value.trim() +} diff --git a/src/run-scoped.ts b/src/run-scoped.ts index 18f8e12..3f94cd1 100644 --- a/src/run-scoped.ts +++ b/src/run-scoped.ts @@ -77,6 +77,12 @@ const MAX_LINEAGE_HOPS = 64 export interface RunScopedStores { /** Create or open a run store and bind it to one exact parent identity. */ init(runId: string, options?: { parentRunId?: string | null }): Promise + /** + * Where one run's store lives. A caller that must read a run's bytes rather + * than its parsed pages — promotion carries a page unchanged — needs the + * root the chain read hides. + */ + storePath(runId: string): string /** The ancestor chain of a run, nearest first. */ lineage(runId: string): Promise /** @@ -127,6 +133,11 @@ export function createRunScopedStores(options: RunScopedStoresOptions): RunScope } return { + storePath(runId) { + assertRunId(runId) + return storePath(runId) + }, + async init(runId, initOptions = {}) { assertRunId(runId) const parentRunId = initOptions.parentRunId ?? null