Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 `<shared>/.agent-knowledge/promotions/<digest>.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
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<shared>/.agent-knowledge/promotions/<digest>.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.
Expand Down
10 changes: 10 additions & 0 deletions api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -264,6 +267,10 @@
"KnowledgePagesOptions": "value",
"KnowledgePolicy": "value",
"KnowledgePolicyDispatch": "type",
"KnowledgePromotionEntry": "value",
"KnowledgePromotionError": "value",
"KnowledgePromotionErrorCode": "value",
"KnowledgePromotionRecord": "value",
"KnowledgeProposal": "value",
"KnowledgeProposalParseError": "value",
"KnowledgeReadOptions": "type",
Expand Down Expand Up @@ -348,6 +355,7 @@
"PoliteFetchOptions": "value",
"PoliteFetchResult": "value",
"PromoteKnowledgeCandidateOptions": "type",
"PromoteRunScopedPagesOptions": "value",
"ProposeFromFindingsResult": "value",
"READINESS_SPEC_DEFAULTS": "value",
"RUN_LINEAGE_BASENAME": "value",
Expand Down Expand Up @@ -640,6 +648,7 @@
"loadKnowledgeImprovementEvents": "value",
"loadKnowledgeImprovementState": "value",
"loadKnowledgePages": "value",
"loadKnowledgePromotionRecord": "value",
"loadSourceRegistry": "value",
"looksLikeBlockPage": "value",
"materialFactsSurfaced": "value",
Expand Down Expand Up @@ -669,6 +678,7 @@
"planInvalidationPropagation": "value",
"politeFetch": "value",
"promoteKnowledgeCandidate": "value",
"promoteRunScopedPages": "value",
"proposeFromFinding": "value",
"proposeFromFindings": "value",
"ragAnswerQualityJudge": "value",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
109 changes: 109 additions & 0 deletions src/promotion.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading