From 4d82adac38e8bedf788f1485c183f71e5ccd54b3 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Wed, 30 Sep 2026 20:35:00 -0600 Subject: [PATCH 1/2] fix(knowledge): preserve authors and reject stale page edits --- CHANGELOG.md | 9 +++++ README.md | 12 +++++- api-surface.json | 2 +- package.json | 2 +- src/file-transaction.ts | 18 +++++++++ src/knowledge-tools.ts | 17 +++++++-- src/proposals.ts | 81 ++++++++++++++++++++++++++++++++++++++--- 7 files changed, 129 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 087d1bc..4e5c93b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## 19.0.0 + +Knowledge tools require the digest from knowledge_read when updating an existing page. +Stale writes refuse the complete proposal under the shared mutation lock. +An identical retry preserves the completed write. +Write transactions retain the host actor and run identity, with unknown authors explicitly null. +Knowledge tools retain prior and new page bytes by default; applications can explicitly disable history. +Lower-level proposal writers can select the same conditional-write contract. + # Changelog ## 18.0.0 — 2026-09-29 diff --git a/README.md b/README.md index da5d7f2..1fe239e 100644 --- a/README.md +++ b/README.md @@ -204,11 +204,21 @@ const tools = createKnowledgeTools({ `knowledge_record` writes into this run's store through the intake gate. Each proposal must contain complete `---FILE: ---` / `---END FILE---` blocks, with delimiters on separate lines. The tool rejects malformed, unsafe, or empty proposals before writing any pages. +Read an existing page before editing it. +Pass expectedPageDigests with the page path and pageDigest returned by knowledge_read. +The write checks each digest under the store lock. +A stale edit refuses the whole proposal and names the current digest. +New pages need no digest; an explicit null requires the path to be absent. +Retrying an identical completed write is safe. It does not report a partial write as tool success. The lower-level `applyKnowledgeWriteBlocks` API retains its explicit `written` and `warnings` result for callers that inspect partial proposals. `knowledge_resolve` returns the resolution status of each reference. -When a pursuit must preserve every edit for later refinement or branch reconciliation, pass `retainHistory: true`. +Knowledge tools retain write history by default. +Each transaction records the host actorId, runId, and exact prior and new bytes. +An absent author remains explicitly null. +An application can disable history with retainHistory: false. +Lower-level writers opt into history with retainHistory: true and conditional edits with expectedPageDigests. Completed write transactions then retain their existing manifest and before/after snapshots under `.agent-knowledge/history/`. The move is atomic and a repeated finish after a lost acknowledgement is idempotent. The default remains cleanup after completion, so callers choose retention deliberately and account for its unbounded storage growth. diff --git a/api-surface.json b/api-surface.json index 1f429b3..9994b83 100644 --- a/api-surface.json +++ b/api-surface.json @@ -66,7 +66,7 @@ "AgentMemoryWriteInput": "type 7f41599c27b7", "AgentMemoryWriteInputSchema": "value 373728f5643d", "AgentMemoryWriteResult": "type 4c444521a5ca", - "ApplyKnowledgeWriteBlocksOptions": "type 2961ecd91d81", + "ApplyKnowledgeWriteBlocksOptions": "type 20e42ffb8b0d", "ApplyWriteBlocksResult": "type f4bdaec8e709", "AuditKnowledgeCitationsOptions": "type 04e80cd4835a", "Bm25Hit": "type a6c513ea2447", diff --git a/package.json b/package.json index 07ce451..9e389d0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/agent-knowledge", - "version": "18.0.0", + "version": "19.0.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/file-transaction.ts b/src/file-transaction.ts index ce0f310..b9ee585 100644 --- a/src/file-transaction.ts +++ b/src/file-transaction.ts @@ -55,6 +55,8 @@ const transactionSchema = z transactionId: z.string().uuid(), purpose: z.string().min(1), recoveryOwner: z.string().min(1).max(256).optional(), + actorId: z.string().trim().min(1).max(256).nullable().optional(), + runId: z.string().trim().min(1).max(256).nullable().optional(), pagesDirectory: pagesDirectorySchema.optional(), researchState: z.boolean().optional(), retainHistory: z.boolean().optional(), @@ -102,6 +104,8 @@ export interface KnowledgeFileMutation { path: string content: string | Buffer | null mode?: number + /** Compare the exact prior bytes under the transaction lock; null requires a new file. */ + expectedBeforeHash?: string | null } export async function prepareKnowledgeFileTransaction(input: { @@ -109,6 +113,8 @@ export async function prepareKnowledgeFileTransaction(input: { transactionRoot: string purpose: string recoveryOwner?: string + actorId?: string + runId?: string mutations: readonly KnowledgeFileMutation[] /** Pages directory the mutations may write under; defaults to `knowledge`. */ pagesDirectory?: string @@ -147,6 +153,12 @@ export async function prepareKnowledgeFileTransaction(input: { } paths.add(path) const before = await withSafeDescendant(input.root, path, readRegularFile) + if (mutation.expectedBeforeHash !== undefined) { + const expected = digestSchema.nullable().parse(mutation.expectedBeforeHash) + const actual = before ? hashBytes(before.bytes) : null + if (actual !== expected) + throw new Error(`knowledge file changed before transaction: ${path}`) + } const after = mutation.content === null ? null @@ -201,6 +213,8 @@ export async function prepareKnowledgeFileTransaction(input: { kind: 'knowledge-file-transaction', transactionId: randomUUID(), purpose: input.purpose, + actorId: input.actorId ?? null, + runId: input.runId ?? null, ...(input.recoveryOwner ? { recoveryOwner: input.recoveryOwner } : {}), ...(pagesDirectory === undefined ? {} : { pagesDirectory }), ...(input.researchState === undefined ? {} : { researchState: input.researchState }), @@ -232,6 +246,8 @@ export async function commitKnowledgeFileMutations(input: { root: string transactionRoot: string purpose: string + actorId?: string + runId?: string mutations: readonly KnowledgeFileMutation[] /** Pages directory the mutations may write under; defaults to `knowledge`. */ pagesDirectory?: string @@ -253,6 +269,8 @@ export async function commitKnowledgeFileMutations(input: { root: input.root, transactionRoot: input.transactionRoot, purpose: input.purpose, + actorId: input.actorId, + runId: input.runId, mutations: input.mutations, ...(input.researchState === undefined ? {} : { researchState: input.researchState }), ...(input.retainHistory === undefined ? {} : { retainHistory: input.retainHistory }), diff --git a/src/knowledge-tools.ts b/src/knowledge-tools.ts index 60e7761..5ed04f6 100644 --- a/src/knowledge-tools.ts +++ b/src/knowledge-tools.ts @@ -12,7 +12,7 @@ import { join } from 'node:path' import { pathToFileURL } from 'node:url' -import type { ToolDefinition } from '@tangle-network/agent-interface' +import { sha256DigestSchema, type ToolDefinition } from '@tangle-network/agent-interface' import { z } from 'zod' import { parseKnowledgeCitationReference, @@ -28,6 +28,7 @@ import { type KnowledgeRetrievalReceipt, knowledgeVisibilityArtifactRef, } from './knowledge-use-receipts' +import { knowledgePageDigest } from './knowledge-visibility' import { withKnowledgeMutation } from './mutation-lock' import { normalizePagesDirectory } from './pages-directory' import { applyKnowledgeWriteBlocks, type KnowledgeWriteIntakeRequest } from './proposals' @@ -69,6 +70,12 @@ const searchInput = z.object({ }) const readInput = z.object({ pageId: z.string().min(1) }) const recordInput = z.object({ + expectedPageDigests: z + .record(z.string().min(1), sha256DigestSchema.nullable()) + .optional() + .describe( + 'For each existing page, pass its path and pageDigest from knowledge_read. Null requires a new page. Missing entries permit new pages only.', + ), proposal: z .string() .min(1) @@ -160,6 +167,7 @@ export function createKnowledgeTools(options: CreateKnowledgeToolsOptions): Tool origin: resolution.resolved.origin, path: resolution.resolved.page.path, title: resolution.resolved.page.title, + pageDigest: knowledgePageDigest(resolution.resolved.page), text: resolution.resolved.page.text, }, candidates: resolution.candidates.map((candidate) => ({ @@ -173,7 +181,7 @@ export function createKnowledgeTools(options: CreateKnowledgeToolsOptions): Tool tool( 'knowledge_record', - `Write pages into this run's store. Use complete blocks exactly like:\n---FILE: ${pagesDirectory}/example.md---\n# Example\nPage content.\n---END FILE---\nUse paths under ${pagesDirectory}/. Both delimiters must be on their own lines. Malformed, unsafe, or empty proposals are rejected before writing any pages.`, + `Write pages into this run's store. Use complete blocks exactly like:\n---FILE: ${pagesDirectory}/example.md---\n# Example\nPage content.\n---END FILE---\nUse paths under ${pagesDirectory}/. Both delimiters must be on their own lines. To update a page, first knowledge_read it and pass expectedPageDigests[path] = pageDigest. Stale edits and malformed, unsafe, or empty proposals are rejected before writing any pages.`, recordInput, async (input) => { const parsed = parseKnowledgeWriteBlocks(input.proposal, [`${pagesDirectory}/`]) @@ -186,7 +194,10 @@ export function createKnowledgeTools(options: CreateKnowledgeToolsOptions): Tool const intake = options.intake return applyKnowledgeWriteBlocks(stores.storePath(runId), input.proposal, { ...pages, - retainHistory: options.retainHistory, + actorId: options.actorId, + runId, + expectedPageDigests: input.expectedPageDigests ?? {}, + retainHistory: options.retainHistory ?? true, ...(intake === undefined ? {} : { intake: { ...intake, inheritedPages: await inheritedOf(stores, runId) } }), diff --git a/src/proposals.ts b/src/proposals.ts index 81acdd7..7075589 100644 --- a/src/proposals.ts +++ b/src/proposals.ts @@ -1,6 +1,10 @@ +import { createHash } from 'node:crypto' import { readFile } from 'node:fs/promises' import { contentHash } from '@tangle-network/agent-eval' -import { commitKnowledgeFileMutations } from './file-transaction' +import type { Sha256Digest } from '@tangle-network/agent-interface' +import { isMissingFile, readRegularFileWithinRoot } from './durable-fs' +import { commitKnowledgeFileMutations, type KnowledgeFileMutation } from './file-transaction' +import { knowledgePageDigest } from './knowledge-visibility' import { withKnowledgeMutation } from './mutation-lock' import { type KnowledgePagesOptions, normalizePagesDirectory } from './pages-directory' import { type OriginatedPage, originatedPages } from './run-scoped' @@ -24,6 +28,11 @@ export type KnowledgeWriteIntakeRequest = Omit> /** Preserve terminal transactions under .agent-knowledge/history; no automatic deletion. */ readonly retainHistory?: boolean /** @@ -48,16 +57,74 @@ export async function applyKnowledgeWriteBlocks( ): Promise { const pagesDirectory = normalizePagesDirectory(options.pagesDirectory) const parsed = parseKnowledgeWriteBlocks(proposalText, [`${pagesDirectory}/`]) - const purpose = `knowledge-proposal:${contentHash(parsed.blocks)}` + const identity = + options.actorId === undefined && + options.runId === undefined && + options.expectedPageDigests === undefined + ? parsed.blocks + : { + blocks: parsed.blocks, + actorId: options.actorId ?? null, + runId: options.runId ?? null, + expectedPageDigests: options.expectedPageDigests ?? null, + } + const purpose = 'knowledge-proposal:' + contentHash(identity) 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`, - })) + const mutations: Array = parsed.blocks.map( + (block) => ({ + path: block.path, + content: block.content.endsWith('\n') ? block.content : `${block.content}\n`, + }), + ) + if (options.expectedPageDigests !== undefined) { + const paths = new Set(mutations.map((mutation) => mutation.path)) + for (const path of Object.keys(options.expectedPageDigests)) { + if (!paths.has(path)) throw new Error(`Expected digest has no proposed page: ${path}`) + } + for (const mutation of mutations) { + let before: Awaited> | undefined + try { + before = await readRegularFileWithinRoot(root, mutation.path) + } catch (error) { + if (!isMissingFile(error)) throw error + } + const current = + before === undefined + ? null + : knowledgePageDigest( + knowledgePageFromMarkdown( + mutation.path, + Buffer.from(before.bytes).toString('utf8'), + pagesDirectory, + ), + ) + const expected = Object.hasOwn(options.expectedPageDigests, mutation.path) + ? options.expectedPageDigests[mutation.path] + : null + if (expected !== null && !/^sha256:[a-f0-9]{64}$/.test(expected ?? '')) { + throw new Error(`Invalid expected page digest: ${mutation.path}`) + } + // A lost acknowledgement can retry the exact completed write safely. + if ( + current !== expected && + !( + !(Object.hasOwn(options.expectedPageDigests, mutation.path) && expected === null) && + before !== undefined && + Buffer.from(before.bytes).equals(Buffer.from(mutation.content ?? '')) + ) + ) { + throw new Error( + `knowledge page changed: ${mutation.path}; current digest: ${current ?? 'absent'}. Read the current page before editing it.`, + ) + } + mutation.expectedBeforeHash = + before === undefined ? null : createHash('sha256').update(before.bytes).digest('hex') + } + } if (intake) { const { inheritedPages = [], ...settings } = intake const here = await loadKnowledgePages(root, { pagesDirectory }) @@ -77,6 +144,8 @@ export async function applyKnowledgeWriteBlocks( root, transactionRoot: lock.transactionRoot, purpose, + actorId: options.actorId, + runId: options.runId, mutations, pagesDirectory, retainHistory: options.retainHistory, From 7824d3fc8b8e3d0cc7a53049a7e71131cb32ea30 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Wed, 30 Sep 2026 20:43:11 -0600 Subject: [PATCH 2/2] refactor(knowledge): remove obsolete journal shape snapshot --- tests/file-transaction.test.ts | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/tests/file-transaction.test.ts b/tests/file-transaction.test.ts index 849a892..fec6a4b 100644 --- a/tests/file-transaction.test.ts +++ b/tests/file-transaction.test.ts @@ -454,32 +454,6 @@ describe('knowledge file transactions', () => { }) }) - it('keeps the default journal free of a pages directory field', async () => { - await withRoot(async (root) => { - const transactionRoot = join(root, '.agent-knowledge', 'file-transactions') - const prepared = await prepareKnowledgeFileTransaction({ - root, - transactionRoot, - purpose: 'default-pages', - mutations: [{ path: 'knowledge/page.md', content: '# Page\n' }], - }) - expect(prepared).not.toHaveProperty('pagesDirectory') - const journal = JSON.parse( - await readFile( - join(transactionRoot, `active-${prepared!.transactionId}`, 'transaction.json'), - 'utf8', - ), - ) as Record - expect(Object.keys(journal).sort()).toEqual([ - 'createdAt', - 'entries', - 'kind', - 'purpose', - 'transactionId', - ]) - }) - }) - it('rejects a forged pages directory in a recovery journal', async () => { await withRoot(async (root) => { const packagePath = join(root, 'package.json')