---
title: "Building a filesystem-based agent memory on SurrealDB"
description: "Give an AI agent persistent memory as a filesystem in SurrealDB: computed file paths, fused full-text and vector search, and RBAC in a single PERMISSIONS clause"
url: https://surrealdb.com/blog/agent-memory-is-a-filesystem-building-one-on-surrealdb
date: 2026-08-27
authors: "Martin Schaer"
---

# Building a filesystem-based agent memory on SurrealDB

![Building a filesystem-based agent memory on SurrealDB](https://cdn.surrealdb.com/sykmlwmhs36q9liup419r8r2.auto)

Give a model no memory API, just filesystem tools like `mkdir`, `cat` and
`grep`, and it will naturally use them to store memories. That is what "keep
notes" looks like in the billions of shell transcripts it trained on. This post builds that filesystem in [SurrealDB](https://surrealdb.com): one
table, five bash-shaped tools, computed paths, and full-text plus vector search
over the same field.

Ask an agent framework what "memory" means and you'll get a different answer
every time: a vector store, a summarization pass, a key-value blob the
framework manages for you and won't show you. Every serious coding agent
already works the other way. It writes a scratch file, greps for something it
wrote three steps ago, edits a todo list in place. Nobody trained it to do
that specifically for memory.

Putting that filesystem in SurrealDB instead of on disk makes the same data
full-text searchable, semantically searchable, and shareable across processes
and users, without leaving SurrealQL.

If you want this off the shelf, it already exists:
[surrealfs](https://github.com/surrealdb/surrealfs) is a virtual filesystem for
AI agents built on SurrealDB, with the rough edges already handled. This post
builds a smaller version from scratch anyway. The useful part is
seeing *why* the pattern works and the SurrealQL that makes it short: computed
paths, one field carrying two indexes, and per-user isolation in a
`PERMISSIONS` clause.

---

## What is "agent memory" actually for?

A conversation ends and the context window empties with it. Memory is
whatever survives that: the user's preferences, the state of a project, a
decision made two sessions ago and never revisited. Concretely, it is always
one of three shapes:

- **notes**: free text an agent re-reads to remind itself what's going on
  (`/notes.md`, `/projects/<name>/todo.md`).
- **facts**: small structured statements about the user (`/preferences/editor.md`).
- **an index into both**: so "what did we decide about the release" finds
  the right note without the agent re-reading everything.

A folder of Markdown files covers the first two natively. The third stops being
a separate system once that folder lives in a database that can search its own
contents, as this post shows.

---

## Step 1: start a server

```bash
surreal start --user root --pass root memory
```

`memory` is SurrealDB's in-memory storage: everything is gone on restart.
Fine for following along, useless for an agent that's meant to remember
things. To keep notes across runs, point the server at a file on disk
instead:

```bash
surreal start --user root --pass root rocksdb://agent.db
```

That creates (or reopens) `agent.db` in the current directory and every query
below works unchanged.

---

## Step 2: one table, self-referential

A file tree is a table where every row can point at another row of the same
table as its parent. `path` is never stored, it's **computed** from the
parent chain, so renaming or moving a folder is one `UPDATE` and every
descendant's path follows for free.

Let's create the schema file `schema.surql`:

```surql
DEFINE TABLE OVERWRITE file SCHEMAFULL;

DEFINE FIELD OVERWRITE name   ON file TYPE string;
DEFINE FIELD OVERWRITE parent ON file TYPE option<record<file>> DEFAULT NONE;

-- A composite UNIQUE index on (parent, name) would let every root-level file
-- through unchecked: SurrealDB skips a unique index when one of its fields is
-- NONE. `parent_key` mirrors `parent` with the root spelled 'root' so the
-- index has something to compare. It cannot be COMPUTED -- SurrealDB refuses
-- to index a computed field -- so it is a stored VALUE, re-evaluated on write.
DEFINE FIELD OVERWRITE parent_key ON file TYPE string
    VALUE IF $this.parent IS NONE { 'root' } ELSE { <string>$this.parent };

-- <string> guards against $this.name being NONE mid-delete.
DEFINE FIELD OVERWRITE path ON file COMPUTED (
    ($this.parent.path || '') + '/' + <string>$this.name
) TYPE string;

-- A folder is a row with no content and no embedding -- nothing else marks it.
DEFINE FIELD OVERWRITE is_folder ON file COMPUTED (
    $this.content IS NONE
);

DEFINE FIELD OVERWRITE content    ON file TYPE option<string> DEFAULT NONE;
DEFINE FIELD OVERWRITE embedding ON file TYPE option<array<float>> DEFAULT NONE;
DEFINE FIELD OVERWRITE updated_at ON file TYPE datetime VALUE time::now();

-- Serves both the uniqueness constraint and "everything under this parent" --
-- the hottest lookup here (ls, path resolution). The reverse field order
-- would force a table scan for that query.
DEFINE INDEX OVERWRITE child_unique ON TABLE file FIELDS parent_key, name UNIQUE;

-- Full-text search over file contents. HIGHLIGHTS is what makes
-- search::highlight/search::offsets return real snippets instead of the
-- untouched content -- it stores term positions, so it's opt-in.
DEFINE ANALYZER OVERWRITE file_analyzer
    TOKENIZERS blank, class, camel, punct
    FILTERS snowball(english);

DEFINE INDEX OVERWRITE file_content_fts ON file
    FIELDS content
    FULLTEXT ANALYZER file_analyzer BM25(1.2, 0.75) HIGHLIGHTS;

-- Semantic search over the same field. Real embedders return 1536+ dimensions
-- (e.g. OpenAI's text-embedding-3-small); this tutorial uses toy 4-dimensional
-- vectors so the numbers stay readable -- the index and the query are identical
-- either way, just change DIMENSION to match your embedder.
DEFINE INDEX OVERWRITE file_embedding_hnsw ON file
    FIELDS embedding
    HNSW DIMENSION 4 DIST COSINE TYPE F32;
```

![file table diagram](https://cdn.surrealdb.com/w(1600)q(80)/zl4kmr5p3fppmmu0i9pohkp4.auto)

Four choices in there are worth calling out:

- **`is_folder` is computed, not stored.** A folder is defined as "has no
  content", one less field to keep in sync.
- **`parent_key` exists only because of an index quirk.** A `UNIQUE` index
  skips any row where one of its fields is `NONE`, so indexing `(parent,
  name)` directly would let two root-level `/notes.md` both get created.
  `parent_key` stores the same thing as a string, with the root spelled
  `'root'` instead of `NONE`, so the index has something to compare on every row.
- **`path` cannot be indexed.** SurrealDB refuses to index a `COMPUTED`
  field, so `WHERE path = $p` is a table scan, fine at the size an agent's
  notes actually reach (dozens to low thousands of files), and simpler to
  read than the alternative. A production version resolves a path by walking
  its segments through the `(parent_key, name)` index instead, as
  [`surrealfs`](https://github.com/surrealdb/surrealfs/blob/main/surrealfs/fs.py)
  does in its `_resolve()`.
- **One field, two search engines.** `content` carries a `FULLTEXT` index for
  keyword search and `embedding` carries an `HNSW` index for vector search.
  Nothing about the schema treats these as separate subsystems.

Now let's load the schema:

```bash
surreal sql --endpoint http://localhost:8000 \
  --user root --pass root --ns agent --db memory \
  --multi < schema.surql
```

---

## Step 3: seed a small tree

There is no `mkdir` here, and no directory type. Every row below is a plain
`CREATE file`, folders included. Two things turn six flat rows into a tree:
`parent` points a row at another row, and leaving `content` unset is what
makes a row a folder.

```surql
LET $notes = (CREATE ONLY file SET name = 'notes.md', content =
    "# Working notes\n- Building an internal tool to track deploys.\n" +
    "- User prefers terse commit messages, no emoji.");

LET $preferences = (CREATE ONLY file SET name = 'preferences');
LET $editor = (CREATE ONLY file SET name = 'editor.md', parent = $preferences.id, content =
    "# Editor preferences\nUses Neovim with a 2-space indent for everything. " +
    "Reviews diffs before every commit, never squashes without asking.");

LET $projects = (CREATE ONLY file SET name = 'projects');
LET $launch = (CREATE ONLY file SET name = 'launch', parent = $projects.id);
LET $todo = (CREATE ONLY file SET name = 'todo.md', parent = $launch.id, content =
    "# Launch checklist\n- [x] Write the changelog\n- [ ] Cut the release branch\n" +
    "- [ ] Announce in #eng-updates");

-- Toy embeddings standing in for a real embedder -- see the note in schema.surql.
UPDATE $editor.id SET embedding = [0.9, 0.1, 0.0, 0.0];
UPDATE $todo.id   SET embedding = [0.0, 0.1, 0.9, 0.2];
UPDATE $notes.id SET embedding = [0.6, 0.3, 0.3, 0.1];
```

No statement above wrote a path. Ask for one and the `COMPUTED` field walks
the parent chain at read time:

```surql
SELECT path, is_folder FROM file ORDER BY path;
```
```surql
[
  { path: '/notes.md', is_folder: false },
  { path: '/preferences', is_folder: true },
  { path: '/preferences/editor.md', is_folder: false },
  { path: '/projects', is_folder: true },
  { path: '/projects/launch', is_folder: true },
  { path: '/projects/launch/todo.md', is_folder: false }
]
```

Which is this tree, drawn the way an agent expects to see it:

```text
/
├── notes.md
├── preferences/
│   └── editor.md
└── projects/
    └── launch/
        └── todo.md
```

---

## Step 4: bash, in SurrealQL

Every tool a filesystem-shaped agent needs maps to one statement. Run these
against the seeded tree (`queries.surql` has all of them):

**`ls /`**

```surql
SELECT path, is_folder FROM file WHERE parent_key = 'root' ORDER BY path;
```
```surql
[
  { path: '/notes.md', is_folder: false },
  { path: '/preferences', is_folder: true },
  { path: '/projects', is_folder: true }
]
```

**`cat /notes.md`**

```surql
SELECT content FROM file WHERE path = '/notes.md';
```
```surql
[{ content: '# Working notes\n- Building an internal tool to track deploys.\n- User prefers terse commit messages, no emoji.' }]
```

**`grep -r commit`** full-text search, ranked:

```surql
SELECT path, search::score(1) AS score FROM file
    WHERE content @1@ 'commit' ORDER BY score DESC;
```
```surql
[
  { path: '/notes.md', score: 0.4417339265346527 },
  { path: '/preferences/editor.md', score: 0.4106069505214691 }
]
```

Real `grep` shows you the matching line, not just the filename. That's
`search::highlight`, and it needs the `HIGHLIGHTS` clause on the index,
without it the function silently returns the untouched content (and
`search::offsets` returns `NONE`). The clause is why `file_content_fts` above
ends in `BM25(1.2, 0.75) HIGHLIGHTS`; it stores the term positions the
highlighter needs, so it costs index space you only pay for if you want
snippets.

```surql
SELECT path, search::score(1) AS score, search::highlight('**', '**', 1) AS snippet
    FROM file WHERE content @1@ 'commit' ORDER BY score DESC;
```
```surql
[
  { path: '/notes.md', score: 0.4417339265346527,
    snippet: '# Working notes\n- Building an internal tool to track deploys.\n- User prefers terse **commit** messages, no emoji.' },
  { path: '/preferences/editor.md', score: 0.4106069505214691,
    snippet: '# Editor preferences\nUses Neovim with a 2-space indent for everything. Reviews diffs before every **commit**, never squashes without asking.' }
]
```

Here we surrounded the highlight with `**` because the content is in markdown
format, and `**` makes it bold.

`search::offsets(1)` gives the same information as `{ s, e }` character
ranges instead of inline markers, if your agent wants to build its own
snippet window.

**`mv /projects/launch /projects/v2-launch`**, the payoff for a computed path:

```surql
LET $launch = (SELECT VALUE id FROM ONLY file WHERE path = '/projects/launch');
UPDATE $launch SET name = 'v2-launch';
SELECT path FROM file WHERE parent_key = <string>$launch OR id = $launch;
```
```surql
[
  { path: '/projects/v2-launch' },
  { path: '/projects/v2-launch/todo.md' }
]
```

One `UPDATE` on the folder, and `todo.md` (never touched) now resolves to
`/projects/v2-launch/todo.md`. A real filesystem needs a recursive rename to
get this; here it's a side effect of `path` never having been stored.

**Edit a file in place**, the way you'd `sed -i` one:

```surql
LET $todo = (SELECT VALUE id FROM ONLY file WHERE path = '/projects/v2-launch/todo.md');
UPDATE $todo SET content = string::replace(
    content, '[ ] Cut the release branch', '[x] Cut the release branch'
);
```

*(`sed -i` is a common Unix command to edit files in place)*

**Semantic search**, same field, the other index, rank by *meaning* instead
of shared words:

```surql
SELECT path, vector::distance::knn() AS distance FROM file
    WHERE embedding <|3, 40|> [0.1, 0.1, 0.8, 0.3]
    ORDER BY distance ASC;
```
```surql
[
  { path: '/projects/v2-launch/todo.md', distance: 0.0163 },
  { path: '/notes.md', distance: 0.4395 },
  { path: '/preferences/editor.md', distance: 0.8725 }
]
```

The query vector above stands in for an embedded question like *"what's left
before we ship?"*, it never shares a word with the todo list, and still ranks
it first, well ahead of a note about editor preferences.

---

## Step 5: wiring it to an agent

An LLM tool-calling loop needs each of the above as a named function that
takes arguments and returns text. `fs_tools.py` (see [Example files](#files) below) is that, in
about 130 lines, `ls`, `cat`, `write_file`, `edit`, `search`, each one
SurrealQL statement wrapped in a Python function:

```python
async def search(db: AsyncSurreal, query: str) -> str:
    """Full-text search over every file's content, best match first."""
    rows = await _q(
        db,
        "SELECT path, search::score(1) AS score, "
        "search::highlight('**', '**', 1) AS snippet FROM file "
        "WHERE content @1@ $query ORDER BY score DESC",
        {"query": query},
    )
    if not rows:
        return "(no matches)"
    return "\n".join(
        f"{r['path']}: {next((l for l in r['snippet'].splitlines() if '**' in l), r['snippet'])}"
        for r in rows
    )
```

Run it: `uv run --with surrealdb python fs_tools.py`, and it exercises a
short session against a real server:

```text
>>> write_file('/preferences/editor.md', ...)
Wrote 50 bytes to /preferences/editor.md

>>> ls('/')
    87  notes.md
     -  preferences/

>>> cat('/preferences/editor.md')
Uses Neovim. Reviews every diff before committing.

>>> edit('/preferences/editor.md', 'Uses Neovim', 'Uses Neovim with a 2-space indent')
--- a/preferences/editor.md
+++ b/preferences/editor.md
@@ -1 +1 @@
-Uses Neovim. Reviews every diff before committing.
+Uses Neovim with a 2-space indent. Reviews every diff before committing.

>>> search('changelog')
/notes.md: # Working notes
```

Handing these to a model is deliberately straightforward: a dict of `{name:
function}`. There is nothing SurrealDB-specific about the wiring, that's the
point of building memory this way. The system prompt that makes an agent
actually *use* it is one paragraph:

```text
You have a persistent filesystem; treat it as your memory. Record what you
learn about the user's preferences under /preferences/. Give each project a
folder under /projects/<name>/. Search before you create — the file you want
may already exist.
```

That's [surrealfs](https://github.com/surrealdb/surrealfs)'s actual
instructions block, close to verbatim. What it adds over the code above:
binary files, `glob`, three ready-made integrations
(a plain async API, a [pydantic-ai](https://ai.pydantic.dev) toolset, raw
Anthropic/OpenAI tool schemas), and error handling that turns a wrong path
into a retry instead of a crash. Its
[`examples/anthropic_loop.py`](https://github.com/surrealdb/surrealfs/blob/main/examples/anthropic_loop.py)
is the same idea as `fs_tools.py` plumbed into a real tool-use loop, in about
40 lines.

---

## Step 6: why put this in SurrealDB, and not a real disk

Nothing above needed a database instead of a folder, `ls`, `cat` and `edit`
work identically over `open()` and `os.listdir()`. Three things change once
the "filesystem" is a table:

**Search comes for free, and it's already fused.** A real filesystem gives
you `grep`. It does not give you "rank these results by both shared keywords
*and* similar meaning," which is what makes an agent's memory usable once it
holds more than a handful of files. Here that's reciprocal-rank fusion over
two indexes on one field, no separate vector database to keep in sync, no
second write path. (`surrealfs.search()` does exactly this fusion; see `fs.py`
in the repo linked above.)

**RBAC is a `PERMISSIONS` clause, not an authorization layer you build.** Add
an `owner` field and one clause, and every read and write is scoped for free:

```surql
DEFINE FIELD OVERWRITE owner ON TABLE file TYPE option<record<user>> DEFAULT $auth.id;

DEFINE TABLE OVERWRITE file SCHEMAFULL
    PERMISSIONS FOR select, create, update, delete WHERE owner = $auth.id;
```

Under [record access](https://surrealdb.com/docs/surrealql/statements/define/access/record),
`$auth.id` is the signed-in user, so this one clause is the entire
multi-tenancy story: agent A's memory is invisible to agent B's queries, not
because your application code checks a `user_id` column, but because the
database refuses the row. `surrealfs` ships this as an optional
[`user.surql`](https://github.com/surrealdb/surrealfs/blob/main/surrealfs/schema/user.surql)
you apply only if you need it.

**The memory is a database, not a directory on one machine.** A file written
mid-conversation by an agent running on one host is immediately queryable from
another (the web UI in Step 7, a nightly job, a teammate's agent) because
it was never local to begin with. "Where does the agent's memory live" stops
being a filesystem-path question and becomes a connection string.

---

## Step 7: seeing it for real

Two examples ship in [`surrealfs`](https://github.com/surrealdb/surrealfs):

> [!NOTE]
> The examples uses [just](https://github.com/casey/just) to simplify the
> commands. If you don't have it, you can run the commands in the
> [Justfile](https://github.com/surrealdb/surrealfs/blob/main/Justfile)
> directly.

- **`examples/chat_agent.py`**: the pydantic-ai agent whose instructions you
  read in Step 5, as a full web chat (`just agent`, `:7932`). It writes to its
  own memory mid-conversation and rereads it in the next one.
- **`surrealfs/browser`**: a tree view of the same `file` table on the left, the
  selected file open and editable on the right, and a chat panel wired to the
  same agent on the far side (`just browser`, `:7933`). It's the fastest way
  to *see* what "memory as a filesystem" means: watch a folder appear under
  `/projects/` while you talk to the agent, open it, edit a line by hand, ask
  the agent about it in the same breath.

Both need only `just db` running underneath and the schema applied, no
separate memory subsystem to stand up.

---

## What to consider before production

- **Path lookups are a table scan.** Fine at hundreds of files; past a few
  thousand, resolve paths by walking segments through the `(parent_key,
  name)` index instead of scanning the computed field, `surrealfs._resolve()`
  is the reference.
- **This schema has no owner.** Every file is visible to every session. Layer
  on Step 6's `owner` field and `PERMISSIONS` clause before more than one
  person's agent touches the same database.
- **Deletes here are hard deletes.** If you need an undo, that's a
  `deleted_at` field with matching read filters, not a place this schema
  currently has an opinion.

---

## Get started

- [Create a free cloud instance](https://surrealdb.com/cloud)
- [surrealfs on GitHub](https://github.com/surrealdb/surrealfs): the production version of everything in this post
- [`DEFINE INDEX ... HNSW` docs](https://surrealdb.com/docs/surrealql/statements/define/indexes)
- [`DEFINE INDEX ... FULLTEXT` docs](https://surrealdb.com/docs/surrealql/statements/define/indexes)
- [Record access docs](https://surrealdb.com/docs/surrealql/statements/define/access/record)
- [Join our Discord server](https://discord.gg/surrealdb)

---

## Example files

### `fs_tools.py`

```python
"""The smallest version of agent memory: bash-shaped tools over one SurrealDB table.

An LLM has seen millions of `ls`, `cat`, `mkdir -p` and `grep -r` sessions, so a
tool named `ls` that returns text needs no explanation in a system prompt --
the shape is already in the weights. This module is that idea with nothing
else in it: five functions, each one SurrealQL statement against the `file`
table from schema.surql, each returning plain text a model can read.

Wiring these into an actual tool-calling loop is small -- hand a dict of
{name: function} to whatever provider SDK you use, one branch per tool call.
`surrealfs` (github.com/surrealdb/surrealfs) is the version of this with
error recovery, glob, binary files, and three ready-made integrations
(pydantic-ai, raw Anthropic/OpenAI schemas, a plain async API); its
`examples/anthropic_loop.py` is ~40 lines end to end.

    surreal start --user root --pass root memory   # in one terminal
    uv run --with surrealdb python fs_tools.py      # runs the self-check below
"""

from __future__ import annotations

import asyncio
import difflib
from pathlib import Path, PurePosixPath
from typing import Any

from surrealdb import AsyncSurreal

SCHEMA = (Path(__file__).parent / "schema.surql").read_text()

async def _q(db: AsyncSurreal, sql: str, vars: dict[str, Any] | None = None) -> Any:
    """Run one or more statements, return the last one's result."""
    return await db.query(sql, vars or {})

async def _resolve(db: AsyncSurreal, path: str) -> dict[str, Any] | None:
    if path == "/":
        return None
    row = await _q(db, "SELECT * FROM ONLY file WHERE path = $path LIMIT 1", {"path": path})
    return row

async def ls(db: AsyncSurreal, path: str = "/") -> str:
    """List a folder: one line per entry, `-` size for folders."""
    if path != "/":
        folder = await _resolve(db, path)
        if folder is None or not folder["is_folder"]:
            return f"Error: not a directory: {path}"
        key = str(folder["id"])
    else:
        key = "root"
    rows = await _q(
        db,
        "SELECT name, is_folder, "
        "IF content IS NOT NONE THEN string::len(content) ELSE 0 END AS size "
        "FROM file WHERE parent_key = $key ORDER BY name",
        {"key": key},
    )
    if not rows:
        return "(empty)"
    return "\n".join(
        f"{'-' if r['is_folder'] else r['size']:>6}  {r['name']}{'/' if r['is_folder'] else ''}"
        for r in rows
    )

async def cat(db: AsyncSurreal, path: str) -> str:
    """Read a file's full content."""
    entry = await _resolve(db, path)
    if entry is None:
        return f"Error: no such file: {path}"
    if entry["is_folder"]:
        return f"Error: is a directory: {path}"
    return entry["content"]

async def write_file(db: AsyncSurreal, path: str, content: str) -> str:
    """Create or replace a file, creating missing parent folders."""
    parts = PurePosixPath(path).parts[1:]  # drop the leading "/"
    if not parts:
        return "Error: cannot write to the root directory"

    parent_id = None
    for segment in parts[:-1]:
        existing = await _q(
            db,
            "SELECT * FROM ONLY file WHERE parent_key = $key AND name = $name LIMIT 1",
            {"key": "root" if parent_id is None else str(parent_id), "name": segment},
        )
        if existing is None:
            existing = await _q(
                db,
                "CREATE ONLY file SET name = $name, parent = $parent",
                {"name": segment, "parent": parent_id},
            )
        elif not existing["is_folder"]:
            return f"Error: not a directory: {existing['path']}"
        parent_id = existing["id"]

    name = parts[-1]
    existing = await _resolve(db, path)
    if existing is not None:
        if existing["is_folder"]:
            return f"Error: is a directory: {path}"
        await _q(db, "UPDATE $id SET content = $content", {"id": existing["id"], "content": content})
        return f"Wrote {len(content)} bytes to {path}"
    await _q(
        db,
        "CREATE file SET name = $name, parent = $parent, content = $content",
        {"name": name, "parent": parent_id, "content": content},
    )
    return f"Wrote {len(content)} bytes to {path}"

async def edit(db: AsyncSurreal, path: str, old: str, new: str) -> str:
    """Replace the first occurrence of `old` with `new`, returning a diff."""
    entry = await _resolve(db, path)
    if entry is None:
        return f"Error: no such file: {path}"
    current = entry["content"] or ""
    if old not in current:
        return f"Error: text not found in {path}: {old!r}"
    updated = current.replace(old, new, 1)
    await _q(db, "UPDATE $id SET content = $content", {"id": entry["id"], "content": updated})
    diff = difflib.unified_diff(
        current.splitlines(), updated.splitlines(), fromfile=f"a{path}", tofile=f"b{path}", lineterm=""
    )
    return "\n".join(diff)

async def search(db: AsyncSurreal, query: str) -> str:
    """Full-text search over every file's content, best match first."""
    rows = await _q(
        db,
        "SELECT path, search::score(1) AS score, "
        "search::highlight('**', '**', 1) AS snippet FROM file "
        "WHERE content @1@ $query ORDER BY score DESC",
        {"query": query},
    )
    if not rows:
        return "(no matches)"
    return "\n".join(
        f"{r['path']}: {next((l for l in r['snippet'].splitlines() if '**' in l), r['snippet'])}"
        for r in rows
    )

async def demo() -> None:
    """Self-check: a short bash-shaped session against a scratch namespace."""
    db = AsyncSurreal("ws://127.0.0.1:8010/rpc")
    await db.signin({"username": "root", "password": "root"})
    await db.use("tutorial", "fs_tools_demo")
    for statement in SCHEMA.split(";\n\n"):
        if statement.strip():
            await db.query(statement)

    assert "Wrote" in await write_file(db, "/preferences/editor.md", "Uses tabs.")
    assert await cat(db, "/preferences/editor.md") == "Uses tabs."
    assert "preferences/" in await ls(db, "/")
    diff = await edit(db, "/preferences/editor.md", "tabs", "2-space indent")
    assert "2-space indent" in diff
    hit = await search(db, "indent")
    assert "editor.md" in hit
    assert "**indent**" in hit  # fails if the index loses HIGHLIGHTS
    assert await cat(db, "/missing.md") == "Error: no such file: /missing.md"
    print("all checks passed")
    await db.close()

if __name__ == "__main__":
    asyncio.run(demo())
```
