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 @@ -33,6 +33,7 @@ Otherwise, it stays in this package.
- Register sources before citing them: `agent-knowledge source-add <path>`.
- Generated pages live under `knowledge/` unless the caller names another root-relative directory with `pagesDirectory` (CLI `--pages-dir`).
- Raw evidence lives under `raw/sources/` and should not be edited.
- Pass `intake` to `applyKnowledgeWriteBlocks` (CLI `--intake`) so a write that duplicates a visible page or cites a page id that exists nowhere is refused before any byte lands.
- Run `agent-knowledge index` after page changes.
- Run `agent-knowledge lint` before trusting or promoting knowledge.
- Treat `missing-source` lint findings as blocking.
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.2.0 — 2026-08-21

### Added

- Add `assertKnowledgeWriteIntake(candidates, { visiblePages, nearDuplicates?, citations? })`, a write-time gate that refuses a page which restates visible knowledge without relating itself to it, and a page whose citation resolves to no visible page. A duplicate is cleared by citing the matched page, naming it in `contradicts`, or reusing its id so the write updates it. The candidates are part of the corpus both checks see, so a batch may cite a page it writes in the same call.
- Add the `intake` option to `applyKnowledgeWriteBlocks` and `applyKnowledgeWriteBlocksFile`, and `--intake` / `--intake-threshold` to `agent-knowledge apply-write-blocks`. The gate runs inside the write lock and refuses the whole proposal, so a refused write leaves nothing on disk.
- Add `knowledgePageFromMarkdown(path, content, pagesDirectory?)` and `isKnowledgePagePath(path)`. The reader and the intake gate build pages through this one constructor, so a gate judges exactly what the store loads back.

## 10.1.0 — 2026-08-21

### Changed
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,19 @@ pnpm exec agent-knowledge validate --strict --root ./support-kb
Run `pnpm exec agent-knowledge help` for every command.
Pass `--pages-dir <dir>` to `apply-write-blocks`, `index`, `search`, and the other index-reading commands when the pages live outside `knowledge/`.

## Gate a write before it lands

A store degrades in two ways no later report reverses: a page restates knowledge already in the store without relating itself to it, and a page cites an id that exists nowhere.
`assertKnowledgeWriteIntake(candidates, { visiblePages })` refuses both, and `applyKnowledgeWriteBlocks(root, text, { intake })` runs it inside the write lock, so a refused proposal writes nothing.

```bash
pnpm exec agent-knowledge apply-write-blocks ./proposal.txt --root ./support-kb --intake
```

A duplicate is cleared by one authoring action, each of which turns the duplication into structure: cite the matched page, name it in `contradicts`, or give the candidate that page's id so the write updates it.
The candidates are part of the corpus both checks see, so a proposal may cite a page it writes in the same call.
`--intake-threshold` sets the duplicate similarity; the near-duplicate detector's own default applies when it is absent.

The default layout is:

```text
Expand Down
8 changes: 8 additions & 0 deletions api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"AgentMemoryWriteInput": "value",
"AgentMemoryWriteInputSchema": "value",
"AgentMemoryWriteResult": "value",
"ApplyKnowledgeWriteBlocksOptions": "value",
"ApplyWriteBlocksResult": "value",
"AuditKnowledgeCitationsOptions": "value",
"Bm25Hit": "value",
Expand Down Expand Up @@ -192,6 +193,8 @@
"KnowledgeControlLoopState": "value",
"KnowledgeDiscoveryDispatcher": "value",
"KnowledgeDiscoveryWorker": "value",
"KnowledgeDuplicateIntakeError": "value",
"KnowledgeDuplicateIntakePair": "value",
"KnowledgeEvaluationPhase": "type",
"KnowledgeEvent": "value",
"KnowledgeEventQuery": "value",
Expand Down Expand Up @@ -301,6 +304,8 @@
"KnowledgeVisibilityUnavailableError": "value",
"KnowledgeVisibilityUnavailableReason": "value",
"KnowledgeWriteBlock": "value",
"KnowledgeWriteIntakeOptions": "value",
"KnowledgeWriteIntakeRequest": "value",
"KnowledgeWriteParseResult": "value",
"LoadKnowledgeImprovementActivationResultOptions": "type",
"MAX_RESPONSE_BYTES": "value",
Expand Down Expand Up @@ -502,6 +507,7 @@
"assertKnowledgeCitationsResolved": "value",
"assertKnowledgeRetrievalMatchesVisibility": "value",
"assertKnowledgeRetrievalMatchesVisibilityArtifact": "value",
"assertKnowledgeWriteIntake": "value",
"assertResearchClaimEvidenceIntegrity": "value",
"assertResearchClaimLedgerIntegrity": "value",
"assertRunScopedCitationsResolved": "value",
Expand Down Expand Up @@ -598,6 +604,7 @@
"investmentThesisSet": "value",
"isKernelAnchoredPath": "value",
"isKnowledgeMemoryBenchmarkCase": "value",
"isKnowledgePagePath": "value",
"isMissingFile": "value",
"isReachable": "value",
"isSafeKnowledgePath": "value",
Expand All @@ -611,6 +618,7 @@
"knowledgeImprovementRunDir": "value",
"knowledgeImprovementRunId": "value",
"knowledgePageDigest": "value",
"knowledgePageFromMarkdown": "value",
"knowledgePageRelations": "value",
"knowledgeReleaseReport": "value",
"knowledgeVisibilityArtifactRef": "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.1.0",
"version": "10.2.0",
"description": "Build, search, evaluate, and improve source-backed knowledge bases.",
"homepage": "https://github.com/tangle-network/agent-knowledge#readme",
"repository": {
Expand Down
27 changes: 24 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { type buildKnowledgeIndex, writeKnowledgeIndex } from './indexer'
import { explainKnowledgeTarget, inspectKnowledgeIndex } from './inspect'
import { lintKnowledgeIndex } from './lint'
import type { KnowledgePagesOptions } from './pages-directory'
import { applyKnowledgeWriteBlocksFile } from './proposals'
import { type ApplyKnowledgeWriteBlocksOptions, applyKnowledgeWriteBlocksFile } from './proposals'
import { searchKnowledge } from './search'
import { addSourcePath, loadSourceRegistry } from './sources'
import { initKnowledgeBase, layoutFor } from './store'
Expand Down Expand Up @@ -57,8 +57,13 @@ Commands:
Copy a file or directory into raw/sources and register immutable source records.
sources [--root .] [--json]
List registered sources.
apply-write-blocks <proposal-file> [--root .] [--pages-dir knowledge] [--json]
apply-write-blocks <proposal-file> [--root .] [--pages-dir knowledge] [--intake]
[--intake-threshold 0.82] [--json]
Apply safe ---FILE: <pages-dir>/...--- blocks emitted by an agent.
--intake refuses the whole proposal when a block duplicates a page already
in the store without citing it, naming it in contradicts, or reusing its
id, or when a block cites a page id that exists nowhere.
--intake-threshold sets the duplicate Jaccard similarity.
inspect [--root .] [--json]
Summarize page/source/edge counts, top pages, and lint state.
explain <page|id|query> [--root .] [--json]
Expand Down Expand Up @@ -127,7 +132,10 @@ async function main(): Promise<number> {
return 1
}
await initKnowledgeBase(root)
const result = await applyKnowledgeWriteBlocksFile(root, resolve(proposalPath), pages)
const result = await applyKnowledgeWriteBlocksFile(root, resolve(proposalPath), {
...pages,
...intakeOption(args),
})
await writeKnowledgeIndex(root, pages)
if (args.flags.json === 'true') process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
else {
Expand Down Expand Up @@ -279,6 +287,19 @@ function pagesOptions(args: Args): KnowledgePagesOptions {
return { pagesDirectory }
}

function intakeOption(args: Args): Pick<ApplyKnowledgeWriteBlocksOptions, 'intake'> {
const threshold = args.flags['intake-threshold']
if (args.flags.intake !== 'true') {
if (threshold !== undefined) throw new Error('--intake-threshold requires --intake')
return {}
}
if (threshold === undefined) return { intake: {} }
if (threshold === 'true') throw new Error('--intake-threshold requires a number')
const parsed = Number(threshold)
if (!Number.isFinite(parsed)) throw new Error(`--intake-threshold is not a number: ${threshold}`)
return { intake: { nearDuplicates: { threshold: parsed } } }
}

async function loadOrBuildIndex(root: string, pages: KnowledgePagesOptions) {
const path = join(layoutFor(root).cacheDir, 'index.json')
if (existsSync(path))
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,5 @@ export * from './validate'
export * from './verified-research-loop'
export * from './web-research-worker'
export * from './wikilinks'
export * from './write-intake'
export * from './write-protocol'
54 changes: 48 additions & 6 deletions src/proposals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,35 @@ import { contentHash } from '@tangle-network/agent-eval'
import { commitKnowledgeFileMutations } from './file-transaction'
import { withKnowledgeMutation } from './mutation-lock'
import { type KnowledgePagesOptions, normalizePagesDirectory } from './pages-directory'
import type { OriginatedPage } from './run-scoped'
import { isKnowledgePagePath, knowledgePageFromMarkdown, loadKnowledgePages } from './store'
import { assertKnowledgeWriteIntake, type KnowledgeWriteIntakeOptions } from './write-intake'
import { parseKnowledgeWriteBlocks } from './write-protocol'

export interface ApplyWriteBlocksResult {
written: string[]
warnings: string[]
}

/** Intake settings for a write, minus the pages the target root supplies itself. */
export type KnowledgeWriteIntakeRequest = Omit<KnowledgeWriteIntakeOptions, 'visiblePages'> & {
/**
* Pages visible to the write beyond the target root, such as the inherited
* and shared entries of a run-scoped chain. The target root's own pages are
* read under the same lock and are always part of the corpus.
*/
readonly inheritedPages?: readonly OriginatedPage[]
}

export interface ApplyKnowledgeWriteBlocksOptions extends KnowledgePagesOptions {
/**
* Refuse the write when a block duplicates visible knowledge without relating
* itself to it, or cites a page that exists nowhere. The whole proposal is
* refused, so a refused batch leaves no partial write behind.
*/
intake?: KnowledgeWriteIntakeRequest
}

/**
* Apply the FILE blocks of a proposal under the pages directory.
*
Expand All @@ -20,23 +42,43 @@ export interface ApplyWriteBlocksResult {
export async function applyKnowledgeWriteBlocks(
root: string,
proposalText: string,
options: KnowledgePagesOptions = {},
options: ApplyKnowledgeWriteBlocksOptions = {},
): Promise<ApplyWriteBlocksResult> {
const pagesDirectory = normalizePagesDirectory(options.pagesDirectory)
const parsed = parseKnowledgeWriteBlocks(proposalText, [`${pagesDirectory}/`])
const purpose = `knowledge-proposal:${contentHash(parsed.blocks)}`
const intake = options.intake
return withKnowledgeMutation(
root,
async (lock) => {
if (parsed.blocks.length > 0) {
const mutations = parsed.blocks.map((block) => ({
path: block.path,
content: block.content.endsWith('\n') ? block.content : `${block.content}\n`,
}))
if (intake) {
const { inheritedPages = [], ...settings } = intake
const here = await loadKnowledgePages(root, { pagesDirectory })
assertKnowledgeWriteIntake(
mutations
.filter((mutation) => isKnowledgePagePath(mutation.path))
.map((mutation) =>
knowledgePageFromMarkdown(mutation.path, mutation.content, pagesDirectory),
),
{
...settings,
visiblePages: [
...here.map((page) => ({ page, origin: 'here' as const })),
...inheritedPages,
],
},
)
}
await commitKnowledgeFileMutations({
root,
transactionRoot: lock.transactionRoot,
purpose,
mutations: parsed.blocks.map((block) => ({
path: block.path,
content: block.content.endsWith('\n') ? block.content : `${block.content}\n`,
})),
mutations,
pagesDirectory,
assertOwned: lock.assertOwned,
})
Expand All @@ -50,7 +92,7 @@ export async function applyKnowledgeWriteBlocks(
export async function applyKnowledgeWriteBlocksFile(
root: string,
proposalPath: string,
options: KnowledgePagesOptions = {},
options: ApplyKnowledgeWriteBlocksOptions = {},
): Promise<ApplyWriteBlocksResult> {
return applyKnowledgeWriteBlocks(root, await readFile(proposalPath, 'utf8'), options)
}
73 changes: 45 additions & 28 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,41 +104,58 @@ async function loadKnowledgePagesUnlocked(
throw error
}
const pages: KnowledgePage[] = []
const pagesPrefix = `${pagesDirectory}/`
for (const file of files) {
const rel = file.path.replace(/\\/g, '/')
if (!rel.endsWith('.md')) continue
if (isScaffoldPath(rel)) continue
const content = file.bytes.toString('utf8')
const { frontmatter, body } = parseFrontmatter(content)
const title =
stringField(frontmatter.title) ??
firstHeading(body) ??
rel.split('/').pop()!.replace(/\.md$/, '')
const sourceIds = arrayField(frontmatter.sources)
const tags = arrayField(frontmatter.tags)
const cites = idListField(frontmatter.cites)
const contradicts = idListField(frontmatter.contradicts)
const invalidation = KnowledgePageInvalidationSchema.safeParse(frontmatter.invalidation)
const pageRelativePath = rel.startsWith(pagesPrefix) ? rel.slice(pagesPrefix.length) : rel
pages.push({
id: stringField(frontmatter.id) ?? slugify(pageRelativePath.replace(/\.md$/, '')),
path: rel,
title,
text: body,
frontmatter,
sourceIds,
tags,
outLinks: extractWikilinks(body).map(normalizeLinkTarget),
...(cites.length > 0 ? { cites } : {}),
...(contradicts.length > 0 ? { contradicts } : {}),
...(invalidation.success ? { invalidation: invalidation.data } : {}),
})
if (!isKnowledgePagePath(rel)) continue
pages.push(knowledgePageFromMarkdown(rel, file.bytes.toString('utf8'), pagesDirectory))
}
pages.sort((a, b) => a.path.localeCompare(b.path))
return pages
}

/** True when a root-relative path names authored Markdown rather than scaffolding. */
export function isKnowledgePagePath(path: string): boolean {
const normalized = path.replace(/\\/g, '/')
return normalized.endsWith('.md') && !isScaffoldPath(normalized)
}

/**
* Build a page from the bytes of one Markdown file.
*
* The reader and every gate that inspects a page before it is written share
* this constructor, so what a gate judges is exactly what the store loads back.
*/
export function knowledgePageFromMarkdown(
path: string,
content: string,
pagesDirectory?: string,
): KnowledgePage {
const rel = path.replace(/\\/g, '/')
const pagesPrefix = `${normalizePagesDirectory(pagesDirectory)}/`
const { frontmatter, body } = parseFrontmatter(content)
const title =
stringField(frontmatter.title) ??
firstHeading(body) ??
rel.split('/').pop()!.replace(/\.md$/, '')
const cites = idListField(frontmatter.cites)
const contradicts = idListField(frontmatter.contradicts)
const invalidation = KnowledgePageInvalidationSchema.safeParse(frontmatter.invalidation)
const pageRelativePath = rel.startsWith(pagesPrefix) ? rel.slice(pagesPrefix.length) : rel
return {
id: stringField(frontmatter.id) ?? slugify(pageRelativePath.replace(/\.md$/, '')),
path: rel,
title,
text: body,
frontmatter,
sourceIds: arrayField(frontmatter.sources),
tags: arrayField(frontmatter.tags),
outLinks: extractWikilinks(body).map(normalizeLinkTarget),
...(cites.length > 0 ? { cites } : {}),
...(contradicts.length > 0 ? { contradicts } : {}),
...(invalidation.success ? { invalidation: invalidation.data } : {}),
}
}

export async function writeJson(path: string, value: unknown): Promise<void> {
await writeJsonDurable(path, value)
}
Expand Down
Loading