> Complete content index: https://sirannon.sondelali.com/llms.txt

# Rewinding memory

> Work out how far back an AI agent's memory reaches, read what a rewind to the moment you name would contain before you run it, rebuild that memory beside the live one, and carry forward the work the agent did after that moment.

URL: https://sirannon.sondelali.com/docs/agent-memory-rewind
Section: AI agents
Version: 0.3 (@delali/sirannon-db@0.3.0, latest)

A fetched page or a tool result can write a fact into an AI agent's memory that nobody meant to store, and every run after that write reads it. Deleting the row you found leaves behind whatever the agent wrote while `recall` still returned it, so a delete on its own will not finish the repair. A systematic study of memory poisoning reports that 'a single adversarial memory write can exert long-term influence over agent behavior' and that 'existing prompt injection defenses fail to cover memory poisoning attacks' ([arXiv 2606.04329](https://arxiv.org/abs/2606.04329)).

`planner-agent` works for a freight company, and the memory below is its own. That memory holds one row for each fact the agent learned, and `recall` returns the newest row for a subject, which makes the row a fetched page wrote the one the agent answers from.

<DataTable
  columns={['id', 'subject', 'value', 'source']}
  rows={[
    [1, 'refund requests', 'accounts@northwind-freight.example', 'finance-policy'],
    [2, 'the Leeds warehouse', 'closes at 17:00', 'checkWarehouseHours'],
    [3, 'refund requests', 'finance@vendor-update.example', 'fetched-page'],
    [4, 'the Antwerp warehouse', 'closes at 16:00', 'checkWarehouseHours'],
  ]}
  emphasisRow={2}
  caption="Row 3 came from a page the agent fetched, and row 4 is work the agent did afterwards that nobody disputes."
/>

Sirannon backs a database up as it writes, which leaves the rows the agent held before that write at your destination. `planBackupRestore` reads those backups and tells you what a rewind to any moment would contain before you run it. Each code block below holds one whole file.

## Watch the agent repeat what the page told it

`openMemory` clears the `data` directory, opens the agent's database with the `backups` option, and creates the `fact` table. Every run of this page therefore starts from the same state and builds the same chain. Sirannon copies the whole file once and then sends the write-ahead log frames written since the previous cycle. The [continuous backups page](/docs/backup-chains) covers that cycle, and the [destinations page](/docs/backup-destinations) covers writing the pieces to storage you choose. `backup-store.ts` keeps its pieces in a `Map`, which is what lets this page run with no storage account behind it. In production, connect the object store you already use.

`learn` pauses before it writes each fact, and it captures a backup cycle straight after the write. Sirannon therefore writes every fact into a piece of its own and stamps each piece at a different moment. That pause is here so that one file can show a rewind. Backups taken on an interval already differ in time, and your own code takes no pause of any kind.

The mock is here so that the panels below print the same text on every run, and a model string in its place lets a real model choose.

<CodeGroup defaultTab="rewind.ts">

```ts title="rewind.ts"

const UNTRUSTED_SOURCE = 'fetched-page'

const db = await openMemory()

const pause = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))

const learn = async (subject: string, value: string, source: string): Promise<void> => {
  await pause(60)
  await remember(db, { subject, value, source, learnedAt: Date.now() })
  await db.captureBackupChanges()
}

await learn('refund requests', 'accounts@northwind-freight.example', 'finance-policy')
await learn('the Leeds warehouse', 'closes at 17:00', 'checkWarehouseHours')
await learn('refund requests', 'finance@vendor-update.example', UNTRUSTED_SOURCE)
await learn('the Antwerp warehouse', 'closes at 16:00', 'checkWarehouseHours')

const ask = async (memory: Database): Promise<string> => {
  const planner = new ToolLoopAgent({
    model: mockModel('refund requests', (value) => `Send refund requests to ${value}.`),
    instructions: 'Answer from your own memory, using the recall tool.',
    tools: brainTools(memory),
  })
  const reply = await planner.generate({ prompt: 'Where do refund requests go?' })
  return reply.text
}

console.log(
  JSON.stringify(
    {
      askedOfTheLiveMemory: await ask(db),
      knownAboutRefundRequests: await db.query<{ value: string; source: string }>(
        'SELECT value, source FROM fact WHERE subject = ? ORDER BY learned_at DESC',
        ['refund requests']
      ),
    },
    null,
    2
  )
)

await sirannon.shutdown()
```

```json result open
{
  "askedOfTheLiveMemory": "Send refund requests to finance@vendor-update.example.",
  "knownAboutRefundRequests": [
    {
      "value": "finance@vendor-update.example",
      "source": "fetched-page"
    },
    {
      "value": "accounts@northwind-freight.example",
      "source": "finance-policy"
    }
  ]
}
```

```ts title="memory-store.ts"

export const LIVE_PATH = './data/planner-agent.db'
export const REBUILT_PATH = './data/planner-agent-rebuilt.db'

export const driver = betterSqlite3()
export const sirannon = new Sirannon({ driver })

export const openMemory = async (): Promise<Database> => {
  rmSync('./data', { recursive: true, force: true })
  mkdirSync('./data', { recursive: true })

  const db = await sirannon.open('planner-agent', LIVE_PATH, {
    backups: { destination, intervalMs: 60_000, fullCopyIntervalMs: 24 * 60 * 60 * 1000 },
  })

  await db.execute(`CREATE TABLE fact (
    id INTEGER PRIMARY KEY,
    subject TEXT NOT NULL,
    value TEXT NOT NULL,
    source TEXT NOT NULL,
    learned_at INTEGER NOT NULL
  )`)

  return db
}
```

```ts title="backup-store.ts"

const stored = new Map<string, Uint8Array>()

const pieceKey = (name: string, index: number): string => `${name}#${index}`

export const destination: BackupDestination = {
  async writePiece(name, index, bytes) {
    stored.set(pieceKey(name, index), Uint8Array.from(bytes))
  },
  async writePieceIfAbsent(name, index, bytes) {
    if (stored.has(pieceKey(name, index))) return false
    stored.set(pieceKey(name, index), Uint8Array.from(bytes))
    return true
  },
  async readPiece(name, index) {
    const bytes = stored.get(pieceKey(name, index))
    if (!bytes) throw new Error(`No piece ${index} of '${name}'`)
    return bytes
  },
  async listPieces(name) {
    const pieces: BackupPiece[] = []
    for (const [key, bytes] of stored) {
      if (key.startsWith(`${name}#`)) {
        pieces.push({ index: Number(key.slice(name.length + 1)), byteLength: bytes.byteLength })
      }
    }
    return pieces
  },
}
```

```ts title="memory-tools.ts"

export interface Belief {
  value: string
  source: string
}

export interface LearnedFact {
  subject: string
  value: string
  source: string
  learnedAt: number
}

export const remember = async (db: Database, fact: LearnedFact): Promise<void> => {
  await db.execute('INSERT INTO fact (subject, value, source, learned_at) VALUES (?, ?, ?, ?)', [
    fact.subject,
    fact.value,
    fact.source,
    fact.learnedAt,
  ])
}

export const recall = async (db: Database, subject: string): Promise<Belief[]> =>
  db.query<Belief>('SELECT value, source FROM fact WHERE subject = ? ORDER BY learned_at DESC', [subject])

export const brainTools = (db: Database) => ({
  recall: tool({
    description: 'Look up what this agent last learned about a subject.',
    inputSchema: z.object({ subject: z.string() }),
    execute: async ({ subject }: { subject: string }): Promise<Belief[]> => recall(db, subject),
  }),
})
```

```ts title="mock-model.ts"

const NO_USAGE = {
  inputTokens: { total: 0, noCache: 0, cacheRead: undefined, cacheWrite: undefined },
  outputTokens: { total: 0, text: 0, reasoning: undefined },
}

interface ReturnedRow {
  value: string
}

interface ToolMessage {
  role: string
  content: { output?: { value?: unknown } }[]
}

const rowsReturnedSoFar = (prompt: unknown): ReturnedRow[] | undefined => {
  for (const message of prompt as ToolMessage[]) {
    if (message.role !== 'tool') continue
    for (const part of message.content) {
      if (Array.isArray(part.output?.value)) return part.output.value as ReturnedRow[]
    }
  }
  return undefined
}

export const mockModel = (subject: string, phrase: (value: string) => string) =>
  new MockLanguageModelV4({
    doGenerate: async ({ prompt }) => {
      const rows = rowsReturnedSoFar(prompt)

      if (rows === undefined) {
        return {
          content: [
            {
              type: 'tool-call' as const,
              toolCallId: 'recall-1',
              toolName: 'recall',
              input: JSON.stringify({ subject }),
            },
          ],
          finishReason: { unified: 'tool-calls' as const, raw: undefined },
          usage: NO_USAGE,
          warnings: [],
        }
      }

      const first = rows[0]
      const text = first === undefined ? `I have no record of ${subject}.` : phrase(first.value)

      return {
        content: [{ type: 'text' as const, text }],
        finishReason: { unified: 'stop' as const, raw: undefined },
        usage: NO_USAGE,
        warnings: [],
      }
    },
  })
```

</CodeGroup>

The agent answers with the address the fetched page wrote, because `recall` orders by `learned_at` and the model reads the first row it is given. Both rows are still in the table, and the agent reads the newer one whatever the `source` column says.

## Read what a rewind would contain before you run it

`readBackupChains` reads what the destination holds, which is one full copy and the change pieces taken from it since. `planBackupRestore` then takes a moment and returns the copy to start from, the pieces to apply on top of it, and `restoresTo`, the moment the rebuilt database reflects.

`restoresTo` is the field to read. One piece covers every write in the interval it was taken over, so a restore stops at a piece boundary and never at the millisecond you named. Where your backups run every minute, a rewind can therefore drop up to a minute of writes, and this is how you learn which writes those are before the restore runs.

`trustedMoment` is the last moment this run treats as clean, and the wait at the head of `learn` puts it between the second capture and the third. In your own system that moment comes out of the investigation, whether from the timestamp on an audit row, from the first appearance of the bad value, or from the start of the run that fetched the page.

<CodeGroup defaultTab="rewind.ts">

```ts title="rewind.ts"
import type { Database } from '@delali/sirannon-db' // [!code --]
import { type Database, planBackupRestore, readBackupChains } from '@delali/sirannon-db' // [!code ++]

import { destination } from './backup-store' // [!code ++]

const UNTRUSTED_SOURCE = 'fetched-page'

const db = await openMemory()

const pause = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))

const learn = async (subject: string, value: string, source: string): Promise<void> => {
  await pause(60)
  await remember(db, { subject, value, source, learnedAt: Date.now() })
  await db.captureBackupChanges()
}

await learn('refund requests', 'accounts@northwind-freight.example', 'finance-policy')
await learn('the Leeds warehouse', 'closes at 17:00', 'checkWarehouseHours')
// [!code ++:2]
const trustedMoment = Date.now()

await learn('refund requests', 'finance@vendor-update.example', UNTRUSTED_SOURCE)
await learn('the Antwerp warehouse', 'closes at 16:00', 'checkWarehouseHours')

const ask = async (memory: Database): Promise<string> => {
  const planner = new ToolLoopAgent({
    model: mockModel('refund requests', (value) => `Send refund requests to ${value}.`),
    instructions: 'Answer from your own memory, using the recall tool.',
    tools: brainTools(memory),
  })
  const reply = await planner.generate({ prompt: 'Where do refund requests go?' })
  return reply.text
}

// [!code ++:3]
const chains = await readBackupChains(destination)
const plan = planBackupRestore(chains, trustedMoment)

console.log(
  JSON.stringify(
    {
      askedOfTheLiveMemory: await ask(db),
// [!code --:4]
      knownAboutRefundRequests: await db.query<{ value: string; source: string }>(
        'SELECT value, source FROM fact WHERE subject = ? ORDER BY learned_at DESC',
        ['refund requests']
      ),
// [!code ++:3]
      changePiecesInTheChain: chains[0]?.changes.length,
      changePiecesTheRestoreWouldApply: plan.changes.length,
      restorePointIsAtOrBeforeTheMomentYouNamed: plan.restoresTo <= trustedMoment,
    },
    null,
    2
  )
)

await sirannon.shutdown()
```

```json result open
{
  "askedOfTheLiveMemory": "Send refund requests to finance@vendor-update.example.",
  "changePiecesInTheChain": 4,
  "changePiecesTheRestoreWouldApply": 2,
  "restorePointIsAtOrBeforeTheMomentYouNamed": true
}
```

</CodeGroup>

<MemoryRewindTimelineDiagram
  pieces={[
    { label: 'full copy', detail: 'the whole file as it stood when the agent opened it', applied: true },
    { label: 'piece 1', detail: 'the refund address finance wrote', applied: true },
    { label: 'piece 2', detail: 'the Leeds closing time', applied: true },
    { label: 'piece 3', detail: 'the address the fetched page wrote', applied: false },
    { label: 'piece 4', detail: 'the Antwerp closing time', applied: false },
  ]}
  momentLabel="trustedMoment"
  momentAfterIndex={2}
  caption="The plan reads the full copy and the two pieces captured before the moment you named. The two pieces after it stay at the destination, and the writes they hold are what the rewind gives up."
/>

The plan names two change pieces while the chain holds four, and the two captured after `trustedMoment` are therefore the ones a restore leaves behind. One of them holds the poisoned row, and the other holds work worth keeping.

## Rebuild the memory beside the live one

`restoreBackup` writes to whatever path `destPath` names, and this call names a second file. A restore over the live path would need every connection on that file closed first, because SQLite holds its own file open while the restore replaces the bytes underneath. Rebuilding beside the live database lets the agent keep working throughout, and it lets you read the rebuilt memory before you decide to trust it.

Put the same question to both databases. That is the check that settles whether the rewind worked, because it puts the result in the agent's own words in place of a row count.

<CodeGroup defaultTab="rewind.ts">

```ts title="rewind.ts"

import { restoreBackup } from '@delali/sirannon-db/backup' // [!code ++]

import { openMemory, sirannon } from './memory-store' // [!code --]
import { driver, openMemory, REBUILT_PATH, sirannon } from './memory-store' // [!code ++]

const UNTRUSTED_SOURCE = 'fetched-page'

const db = await openMemory()

const pause = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))

const learn = async (subject: string, value: string, source: string): Promise<void> => {
  await pause(60)
  await remember(db, { subject, value, source, learnedAt: Date.now() })
  await db.captureBackupChanges()
}

await learn('refund requests', 'accounts@northwind-freight.example', 'finance-policy')
await learn('the Leeds warehouse', 'closes at 17:00', 'checkWarehouseHours')
const trustedMoment = Date.now()

await learn('refund requests', 'finance@vendor-update.example', UNTRUSTED_SOURCE)
await learn('the Antwerp warehouse', 'closes at 16:00', 'checkWarehouseHours')

const ask = async (memory: Database): Promise<string> => {
  const planner = new ToolLoopAgent({
    model: mockModel('refund requests', (value) => `Send refund requests to ${value}.`),
    instructions: 'Answer from your own memory, using the recall tool.',
    tools: brainTools(memory),
  })
  const reply = await planner.generate({ prompt: 'Where do refund requests go?' })
  return reply.text
}

const chains = await readBackupChains(destination)
const plan = planBackupRestore(chains, trustedMoment)

// [!code ++:3]
const report = await restoreBackup({ destination, driver, destPath: REBUILT_PATH, moment: trustedMoment })
const rebuilt = await sirannon.open('planner-agent-rebuilt', REBUILT_PATH)

console.log(
  JSON.stringify(
    {
      askedOfTheLiveMemory: await ask(db),
      changePiecesInTheChain: chains[0]?.changes.length, // [!code --]
      askedOfTheRebuiltMemory: await ask(rebuilt), // [!code ++]
      changePiecesTheRestoreWouldApply: plan.changes.length,
      restorePointIsAtOrBeforeTheMomentYouNamed: plan.restoresTo <= trustedMoment, // [!code --]
// [!code ++:4]
      changePiecesTheRestoreApplied: report.changesApplied,
      rebuiltMemoryHolds: (
        await rebuilt.query<{ subject: string }>('SELECT subject FROM fact ORDER BY id')
      ).map((row) => row.subject),
    },
    null,
    2
  )
)

await sirannon.shutdown()
```

```json result open
{
  "askedOfTheLiveMemory": "Send refund requests to finance@vendor-update.example.",
  "askedOfTheRebuiltMemory": "Send refund requests to accounts@northwind-freight.example.",
  "changePiecesTheRestoreWouldApply": 2,
  "changePiecesTheRestoreApplied": 2,
  "rebuiltMemoryHolds": [
    "refund requests",
    "the Leeds warehouse"
  ]
}
```

</CodeGroup>

`changesApplied` matches the count the plan gives you, which is what makes the plan worth reading first. The rebuilt memory holds the refund address finance wrote along with the Leeds closing time, which is why the agent reading it answers with that address, while the live database goes on naming the one from the fetched page.

## Keep the good work the agent did after that moment

The rebuilt memory stops at `restoresTo`, which leaves out everything the agent learned after that moment, including the facts nobody disputes. Read those rows out of the live database and decide which of them carry across.

The filter here names the source you no longer trust, and every other row written in that window therefore carries across. Where your own investigation names a run, filter on the run identifier, and where it names a window of time, filter on that. `remember` writes each carried row into the rebuilt copy under its original timestamp, which keeps the order the agent learned those facts in.

Close both databases before the rename. SQLite keeps its write-ahead log beside the file, and closing the connection folds that log back into the database. The rename then moves everything the rebuilt copy holds.

<CodeGroup defaultTab="rewind.ts">

```ts title="rewind.ts"

import { rename, rm } from 'node:fs/promises' // [!code ++]

import { driver, openMemory, REBUILT_PATH, sirannon } from './memory-store' // [!code --]
import { driver, LIVE_PATH, openMemory, REBUILT_PATH, sirannon } from './memory-store' // [!code ++]

const UNTRUSTED_SOURCE = 'fetched-page'

const db = await openMemory()

const pause = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))

const learn = async (subject: string, value: string, source: string): Promise<void> => {
  await pause(60)
  await remember(db, { subject, value, source, learnedAt: Date.now() })
  await db.captureBackupChanges()
}

await learn('refund requests', 'accounts@northwind-freight.example', 'finance-policy')
await learn('the Leeds warehouse', 'closes at 17:00', 'checkWarehouseHours')
const trustedMoment = Date.now()

await learn('refund requests', 'finance@vendor-update.example', UNTRUSTED_SOURCE)
await learn('the Antwerp warehouse', 'closes at 16:00', 'checkWarehouseHours')

const ask = async (memory: Database): Promise<string> => {
  const planner = new ToolLoopAgent({
    model: mockModel('refund requests', (value) => `Send refund requests to ${value}.`),
    instructions: 'Answer from your own memory, using the recall tool.',
    tools: brainTools(memory),
  })
  const reply = await planner.generate({ prompt: 'Where do refund requests go?' })
  return reply.text
}

const chains = await readBackupChains(destination)
const plan = planBackupRestore(chains, trustedMoment)

const report = await restoreBackup({ destination, driver, destPath: REBUILT_PATH, moment: trustedMoment })
const rebuilt = await sirannon.open('planner-agent-rebuilt', REBUILT_PATH)

// [!code ++:17]
const writtenSince = await db.query<{ subject: string; value: string; source: string; learned_at: number }>(
  'SELECT subject, value, source, learned_at FROM fact WHERE learned_at > ? ORDER BY id',
  [report.restoresTo]
)
const carried = writtenSince.filter((row) => row.source !== UNTRUSTED_SOURCE)

for (const row of carried) {
  await remember(rebuilt, { subject: row.subject, value: row.value, source: row.source, learnedAt: row.learned_at })
}

await sirannon.close('planner-agent-rebuilt')
await sirannon.close('planner-agent')
await rm(LIVE_PATH, { force: true })
await rename(REBUILT_PATH, LIVE_PATH)

const promoted = await sirannon.open('planner-agent', LIVE_PATH)

console.log(
  JSON.stringify(
    {
// [!code --:7]
      askedOfTheLiveMemory: await ask(db),
      askedOfTheRebuiltMemory: await ask(rebuilt),
      changePiecesTheRestoreWouldApply: plan.changes.length,
      changePiecesTheRestoreApplied: report.changesApplied,
      rebuiltMemoryHolds: (
        await rebuilt.query<{ subject: string }>('SELECT subject FROM fact ORDER BY id')
      ).map((row) => row.subject),
// [!code ++:6]
      writtenSinceTheRestorePoint: writtenSince.map((row) => `${row.subject} (${row.source})`),
      carriedForward: carried.map((row) => row.subject),
      askedAfterTheCutover: await ask(promoted),
      memoryHolds: (await promoted.query<{ subject: string }>('SELECT subject FROM fact ORDER BY id')).map(
        (row) => row.subject
      ),
    },
    null,
    2
  )
)

await sirannon.shutdown()
```

```json result open
{
  "writtenSinceTheRestorePoint": [
    "refund requests (fetched-page)",
    "the Antwerp warehouse (checkWarehouseHours)"
  ],
  "carriedForward": [
    "the Antwerp warehouse"
  ],
  "askedAfterTheCutover": "Send refund requests to accounts@northwind-freight.example.",
  "memoryHolds": [
    "refund requests",
    "the Leeds warehouse",
    "the Antwerp warehouse"
  ]
}
```

</CodeGroup>

The agent now answers with the address finance wrote, and it still holds the Antwerp warehouse closing time. The identifier never changes, and the next question therefore opens the repaired file, with no change anywhere above the database layer.

## What a restore puts back, and what it does not

A restore rewrites rows, and it reaches nothing beyond them. Everything the agent did outside this database while it believed the false fact stays done. An email it sent is still in the recipient's inbox, and a payment it made is still with the payment provider. Treat the rewind as the repair for the memory itself, and pair it with the controls that act before an action leaves your system.

A before-hook denies a write at the moment the agent makes it, which stops a fact you can describe in advance before it reaches the store. The [hooks reference](/docs/hooks-metrics-and-lifecycle) covers how to register one. Named operations keep an agent to the reads and writes you registered, and [AI agent tools](/docs/agent-tools) builds a set of them. For the record of which agent wrote which row, and when, [auditing a fleet](/docs/agent-audit) turns the change feed into a table you can query. Where the destination grows past what you need, [restoring a database](/docs/backup-restore) covers `backupPiecesSafeToDelete`, which names the pieces no restore still needs.
