From 0b0933db71df79e9ee3239f9bb2afca468a0803a Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Tue, 8 Sep 2026 20:54:49 -0700 Subject: [PATCH 1/5] feat(knowledge): retain completed write history --- CHANGELOG.md | 6 +++++ package.json | 2 +- src/file-transaction.ts | 46 +++++++++++++++++++++++++++++++++- src/knowledge-tools.ts | 3 +++ src/proposals.ts | 3 +++ tests/file-transaction.test.ts | 30 ++++++++++++++++++++++ 6 files changed, 88 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0759f2e..46a24cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 15.1.0 — 2026-09-09 + +Completed Knowledge write transactions may opt into durable before/after history with `retainHistory: true`. +The archive reuses the existing transaction manifest and snapshots, moves atomically under `.agent-knowledge/history/`, and is safe to finish again after a lost acknowledgement. +The default remains unchanged; retained history has no automatic garbage collection. + ## 15.0.1 — 2026-09-08 The Eval peer range admits 0.177 and 0.178 while retaining support for 0.174 through 0.176. diff --git a/package.json b/package.json index 5f29f79..1d89540 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/agent-knowledge", - "version": "15.0.1", + "version": "15.1.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 29c05aa..2b865c8 100644 --- a/src/file-transaction.ts +++ b/src/file-transaction.ts @@ -57,6 +57,7 @@ const transactionSchema = z recoveryOwner: z.string().min(1).max(256).optional(), pagesDirectory: pagesDirectorySchema.optional(), researchState: z.boolean().optional(), + retainHistory: z.boolean().optional(), createdAt: z.string().min(1), entries: z.array(transactionEntrySchema).min(1), }) @@ -113,6 +114,8 @@ export async function prepareKnowledgeFileTransaction(input: { pagesDirectory?: string /** Explicitly permit authoritative claim-ledger and research-event records. */ researchState?: boolean + /** Preserve terminal before/after bytes in the root's write history. */ + retainHistory?: boolean includeUnchanged?: boolean now?: () => Date }): Promise { @@ -201,6 +204,7 @@ export async function prepareKnowledgeFileTransaction(input: { ...(input.recoveryOwner ? { recoveryOwner: input.recoveryOwner } : {}), ...(pagesDirectory === undefined ? {} : { pagesDirectory }), ...(input.researchState === undefined ? {} : { researchState: input.researchState }), + ...(input.retainHistory === undefined ? {} : { retainHistory: input.retainHistory }), createdAt: (input.now ?? (() => new Date()))().toISOString(), entries: changed.map((item) => item.entry), }) @@ -233,6 +237,7 @@ export async function commitKnowledgeFileMutations(input: { pagesDirectory?: string /** Explicitly permit authoritative claim-ledger and research-event records. */ researchState?: boolean + retainHistory?: boolean assertOwned?: () => void now?: () => Date }): Promise { @@ -250,6 +255,7 @@ export async function commitKnowledgeFileMutations(input: { purpose: input.purpose, mutations: input.mutations, ...(input.researchState === undefined ? {} : { researchState: input.researchState }), + ...(input.retainHistory === undefined ? {} : { retainHistory: input.retainHistory }), ...(input.pagesDirectory === undefined ? {} : { pagesDirectory: input.pagesDirectory }), now: input.now, }) @@ -401,6 +407,15 @@ export async function finishKnowledgeFileTransaction(input: { assertTransactionEntries(transaction) await withTransactionRoot(input.root, input.transactionRoot, false, async (transactionRoot) => { const activeName = activeTransactionDirectoryName(transaction.transactionId) + // A lost acknowledgement after the atomic archive move must not repeat a completed write. + if ( + transaction.retainHistory === true && + !(await activeTransactionDirectoryNames(transactionRoot)).includes(activeName) && + (await hasRetainedTransaction(input.root, transaction)) + ) { + await syncDirectory(transactionRoot) + return + } await withTransactionDirectory( input.root, input.transactionRoot, @@ -422,11 +437,40 @@ export async function finishKnowledgeFileTransaction(input: { }) }, ) - await rm(join(transactionRoot, activeName), { recursive: true, force: false }) + if (transaction.retainHistory === true) { + // Resolve from the store root: transactionRoot may be an open /proc/self/fd anchor. + await withSafeDirectory(input.root, '.agent-knowledge/history', true, async (historyRoot) => { + await renameDurable(join(transactionRoot, activeName), join(historyRoot, transaction.transactionId)) + }) + } else { + await rm(join(transactionRoot, activeName), { recursive: true, force: false }) + } await syncDirectory(transactionRoot) }) } +async function hasRetainedTransaction(root: string, transaction: KnowledgeFileTransaction): Promise { + try { + return await withSafeDirectory(root, `.agent-knowledge/history/${transaction.transactionId}`, false, async (historyDir) => { + await assertActiveTransaction(historyDir, transaction) + await readTransactionDirection(historyDir, transaction) + for (const entry of transaction.entries) { + for (const side of ['before', 'after'] as const) { + const expected = side === 'before' ? entry.beforeHash : entry.afterHash + if (expected === null) continue + const snapshot = await readRegularFileNoFollow(snapshotPath(historyDir, side, entry.index)) + if (hashBytes(snapshot.bytes) !== expected) throw new Error(`retained knowledge snapshot changed: ${entry.path}`) + } + } + await syncDirectory(historyDir) + return true + }) + } catch (error) { + if (isMissingFile(error)) return false + throw error + } +} + export async function rollbackKnowledgeFileTransaction(input: { root: string transactionRoot: string diff --git a/src/knowledge-tools.ts b/src/knowledge-tools.ts index 737cbb9..27bef37 100644 --- a/src/knowledge-tools.ts +++ b/src/knowledge-tools.ts @@ -46,6 +46,8 @@ export interface CreateKnowledgeToolsOptions { readonly pagesDirectory?: string /** Intake settings for `knowledge_record`. Absent leaves the write ungated. */ readonly intake?: Omit + /** Retain prior and new bytes for every completed knowledge_record transaction. */ + readonly retainHistory?: boolean /** Brief settings for `knowledge_search`, overridden per call by the tool input. */ readonly brief?: Omit /** Receipt sink. Exact visibility bytes are persisted in the run store before this is called. */ @@ -156,6 +158,7 @@ export function createKnowledgeTools(options: CreateKnowledgeToolsOptions): Tool const intake = options.intake return applyKnowledgeWriteBlocks(stores.storePath(runId), input.proposal, { ...pages, + retainHistory: options.retainHistory, ...(intake === undefined ? {} : { intake: { ...intake, inheritedPages: await inheritedOf(stores, runId) } }), diff --git a/src/proposals.ts b/src/proposals.ts index 3cc7619..81acdd7 100644 --- a/src/proposals.ts +++ b/src/proposals.ts @@ -24,6 +24,8 @@ export type KnowledgeWriteIntakeRequest = Omit Promise): Promise { } describe('knowledge file transactions', () => { + it('optionally retains every completed version and makes terminal archive retry-safe', async () => { + await withRoot(async (root) => { + const transactionRoot = join(root, '.transactions') + await mkdir(join(root, 'knowledge'), { recursive: true }) + await writeFile(join(root, 'knowledge', 'note.md'), 'before\n') + const versions: string[] = [] + for (const content of ['middle\n', 'after\n']) { + const transaction = await prepareKnowledgeFileTransaction({ + root, + transactionRoot, + purpose: `retain-${content}`, + mutations: [{ path: 'knowledge/note.md', content }], + retainHistory: true, + }) + expect(transaction).not.toBeNull() + versions.push(transaction!.transactionId) + await applyKnowledgeFileTransaction({ root, transactionRoot, transaction: transaction! }) + await finishKnowledgeFileTransaction({ root, transactionRoot, transaction: transaction! }) + // A lost response after the atomic move must not reapply or reject the completed write. + await expect(finishKnowledgeFileTransaction({ root, transactionRoot, transaction: transaction! })).resolves.toBeUndefined() + } + await expect(readFile(join(root, 'knowledge', 'note.md'), 'utf8')).resolves.toBe('after\n') + for (const [id, before, after] of [[versions[0], 'before\n', 'middle\n'], [versions[1], 'middle\n', 'after\n']]) { + const history = join(root, '.agent-knowledge', 'history', id!) + await expect(readFile(join(history, 'transaction.json'), 'utf8')).resolves.toMatch(/"retainHistory": true/u) + await expect(readFile(join(history, 'before', '0.bin'), 'utf8')).resolves.toBe(before) + await expect(readFile(join(history, 'after', '0.bin'), 'utf8')).resolves.toBe(after) + } + }) + }) /** * A root reached through a symbolic link canonicalizes to a different string * than the one the caller holds, while the transaction root arrives already From 9fb4404e17b73e4127c354750fbc7679ea475cc5 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Tue, 8 Sep 2026 20:55:03 -0700 Subject: [PATCH 2/5] chore(knowledge): refresh export surface --- api-surface.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api-surface.json b/api-surface.json index 408d2f7..72fad47 100644 --- a/api-surface.json +++ b/api-surface.json @@ -66,7 +66,7 @@ "AgentMemoryWriteInput": "value 7f41599c27b7", "AgentMemoryWriteInputSchema": "value 373728f5643d", "AgentMemoryWriteResult": "value 4c444521a5ca", - "ApplyKnowledgeWriteBlocksOptions": "value cb6e85f9792c", + "ApplyKnowledgeWriteBlocksOptions": "value 2961ecd91d81", "ApplyWriteBlocksResult": "value f4bdaec8e709", "AuditKnowledgeCitationsOptions": "value 04e80cd4835a", "Bm25Hit": "value a6c513ea2447", @@ -95,7 +95,7 @@ "CreateAgentMemoryBranchOptions": "value 924c7b6dcd11", "CreateKnowledgeRetrievalDispositionInput": "value 27fb6228c8c1", "CreateKnowledgeRetrievalReceiptInput": "value 2b6c8c6a078b", - "CreateKnowledgeToolsOptions": "value 8cf079704a21", + "CreateKnowledgeToolsOptions": "value 359d4edb185e", "CreateKnowledgeUseReceiptInput": "value eb120a93f4c7", "D1Adapter": "value fd8669db1f2e", "DEADLINE_EXIT_CODE": "value 16590df13e07", From ac183d3f4b41d7e36f632adb3c2928d99390c457 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Tue, 8 Sep 2026 20:55:29 -0700 Subject: [PATCH 3/5] chore(knowledge): bump version for write history API --- CHANGELOG.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46a24cc..3907423 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 15.1.0 — 2026-09-09 +## 16.0.0 — 2026-09-09 Completed Knowledge write transactions may opt into durable before/after history with `retainHistory: true`. The archive reuses the existing transaction manifest and snapshots, moves atomically under `.agent-knowledge/history/`, and is safe to finish again after a lost acknowledgement. diff --git a/package.json b/package.json index 1d89540..9f6f8d5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/agent-knowledge", - "version": "15.1.0", + "version": "16.0.0", "description": "Build, search, evaluate, and improve source-backed knowledge bases.", "homepage": "https://github.com/tangle-network/agent-knowledge#readme", "repository": { From 049d518bc04c68d5e2b78ced4ecfa42c2ce7029a Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Tue, 8 Sep 2026 20:56:06 -0700 Subject: [PATCH 4/5] test(knowledge): verify retained history recovery --- src/file-transaction.ts | 44 ++++++++++++++++++++++------------ tests/file-transaction.test.ts | 13 +++++++--- 2 files changed, 39 insertions(+), 18 deletions(-) diff --git a/src/file-transaction.ts b/src/file-transaction.ts index 2b865c8..ce0f310 100644 --- a/src/file-transaction.ts +++ b/src/file-transaction.ts @@ -440,7 +440,10 @@ export async function finishKnowledgeFileTransaction(input: { if (transaction.retainHistory === true) { // Resolve from the store root: transactionRoot may be an open /proc/self/fd anchor. await withSafeDirectory(input.root, '.agent-knowledge/history', true, async (historyRoot) => { - await renameDurable(join(transactionRoot, activeName), join(historyRoot, transaction.transactionId)) + await renameDurable( + join(transactionRoot, activeName), + join(historyRoot, transaction.transactionId), + ) }) } else { await rm(join(transactionRoot, activeName), { recursive: true, force: false }) @@ -449,22 +452,33 @@ export async function finishKnowledgeFileTransaction(input: { }) } -async function hasRetainedTransaction(root: string, transaction: KnowledgeFileTransaction): Promise { +async function hasRetainedTransaction( + root: string, + transaction: KnowledgeFileTransaction, +): Promise { try { - return await withSafeDirectory(root, `.agent-knowledge/history/${transaction.transactionId}`, false, async (historyDir) => { - await assertActiveTransaction(historyDir, transaction) - await readTransactionDirection(historyDir, transaction) - for (const entry of transaction.entries) { - for (const side of ['before', 'after'] as const) { - const expected = side === 'before' ? entry.beforeHash : entry.afterHash - if (expected === null) continue - const snapshot = await readRegularFileNoFollow(snapshotPath(historyDir, side, entry.index)) - if (hashBytes(snapshot.bytes) !== expected) throw new Error(`retained knowledge snapshot changed: ${entry.path}`) + return await withSafeDirectory( + root, + `.agent-knowledge/history/${transaction.transactionId}`, + false, + async (historyDir) => { + await assertActiveTransaction(historyDir, transaction) + await readTransactionDirection(historyDir, transaction) + for (const entry of transaction.entries) { + for (const side of ['before', 'after'] as const) { + const expected = side === 'before' ? entry.beforeHash : entry.afterHash + if (expected === null) continue + const snapshot = await readRegularFileNoFollow( + snapshotPath(historyDir, side, entry.index), + ) + if (hashBytes(snapshot.bytes) !== expected) + throw new Error(`retained knowledge snapshot changed: ${entry.path}`) + } } - } - await syncDirectory(historyDir) - return true - }) + await syncDirectory(historyDir) + return true + }, + ) } catch (error) { if (isMissingFile(error)) return false throw error diff --git a/tests/file-transaction.test.ts b/tests/file-transaction.test.ts index fd1cf1c..849a892 100644 --- a/tests/file-transaction.test.ts +++ b/tests/file-transaction.test.ts @@ -60,12 +60,19 @@ describe('knowledge file transactions', () => { await applyKnowledgeFileTransaction({ root, transactionRoot, transaction: transaction! }) await finishKnowledgeFileTransaction({ root, transactionRoot, transaction: transaction! }) // A lost response after the atomic move must not reapply or reject the completed write. - await expect(finishKnowledgeFileTransaction({ root, transactionRoot, transaction: transaction! })).resolves.toBeUndefined() + await expect( + finishKnowledgeFileTransaction({ root, transactionRoot, transaction: transaction! }), + ).resolves.toBeUndefined() } await expect(readFile(join(root, 'knowledge', 'note.md'), 'utf8')).resolves.toBe('after\n') - for (const [id, before, after] of [[versions[0], 'before\n', 'middle\n'], [versions[1], 'middle\n', 'after\n']]) { + for (const [id, before, after] of [ + [versions[0], 'before\n', 'middle\n'], + [versions[1], 'middle\n', 'after\n'], + ]) { const history = join(root, '.agent-knowledge', 'history', id!) - await expect(readFile(join(history, 'transaction.json'), 'utf8')).resolves.toMatch(/"retainHistory": true/u) + await expect(readFile(join(history, 'transaction.json'), 'utf8')).resolves.toMatch( + /"retainHistory": true/u, + ) await expect(readFile(join(history, 'before', '0.bin'), 'utf8')).resolves.toBe(before) await expect(readFile(join(history, 'after', '0.bin'), 'utf8')).resolves.toBe(after) } From d64100b215a8eaeabb98ade02f66aa47d3c76371 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Tue, 8 Sep 2026 21:05:08 -0700 Subject: [PATCH 5/5] docs(knowledge): document retained write history --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 874ca9a..6d93163 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,13 @@ const tools = createKnowledgeTools({ `knowledge_record` writes into this run's store through the intake gate. `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`. +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. +Retained bytes inherit the store's access boundary; this option does not publish or merge them. +Use the existing transaction and candidate-snapshot readers to inspect or restore them. + Supply `retrieverVersion` yourself: a bundled build cannot read its own manifest, and a receipt that guessed the version would be a receipt that lies about what ranked the results. When a retrieval influences nothing, record that too: