Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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/<transactionId>`, 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.
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<transactionId>`.
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:
Expand Down
4 changes: 2 additions & 2 deletions api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
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": "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": {
Expand Down
60 changes: 59 additions & 1 deletion src/file-transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
})
Expand Down Expand Up @@ -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<KnowledgeFileTransaction | null> {
Expand Down Expand Up @@ -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),
})
Expand Down Expand Up @@ -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<boolean> {
Expand All @@ -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,
})
Expand Down Expand Up @@ -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,
Expand All @@ -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<boolean> {
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
Expand Down
3 changes: 3 additions & 0 deletions src/knowledge-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export interface CreateKnowledgeToolsOptions {
readonly pagesDirectory?: string
/** Intake settings for `knowledge_record`. Absent leaves the write ungated. */
readonly intake?: Omit<KnowledgeWriteIntakeRequest, 'inheritedPages'>
/** 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<KnowledgeBriefOptions, 'limit'>
/** Receipt sink. Exact visibility bytes are persisted in the run store before this is called. */
Expand Down Expand Up @@ -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) } }),
Expand Down
3 changes: 3 additions & 0 deletions src/proposals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export type KnowledgeWriteIntakeRequest = Omit<KnowledgeWriteIntakeOptions, 'vis
}

export interface ApplyKnowledgeWriteBlocksOptions extends KnowledgePagesOptions {
/** Preserve terminal transactions under .agent-knowledge/history; no automatic deletion. */
readonly retainHistory?: boolean
/**
* 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
Expand Down Expand Up @@ -77,6 +79,7 @@ export async function applyKnowledgeWriteBlocks(
purpose,
mutations,
pagesDirectory,
retainHistory: options.retainHistory,
assertOwned: lock.assertOwned,
})
}
Expand Down
37 changes: 37 additions & 0 deletions tests/file-transaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,43 @@ async function withRoot(fn: (root: string) => Promise<void>): Promise<void> {
}

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
Expand Down