diff --git a/CHANGELOG.md b/CHANGELOG.md index c3111e0..614fd3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 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. +The default remains unchanged; retained history has no automatic garbage collection. + ## 15.0.2 — 2026-09-08 The Eval peer range admits 0.179 while retaining support for 0.174 through 0.178. 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: 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", diff --git a/package.json b/package.json index 7f2257a..e7d9c94 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/agent-knowledge", - "version": "15.0.2", + "version": "16.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 29c05aa..ce0f310 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,54 @@ 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