docs/filesystem_setup.md

# FileSystem Setup Guide

This guide covers how to set up, configure, and populate the `FileSystemServer` so agents can read and write files. It explains the relationship between the filesystem process, the `FileSystem` middleware, and your application code.

## Overview

The `FileSystem` middleware gives agents tools like `ls`, `read_file`, `write_file`, and `delete_file`. But the middleware itself holds no file data — it is a thin client that delegates every operation to a `FileSystemServer` GenServer, identified by a **scope key** like `{:user, 123}`.

This means:

- **`FileSystemServer` must be started before the agent runs.** The middleware will look up the server by scope key and fail if it isn't running.
- **`FileSystemServer` outlives individual agent sessions.** It runs under its own supervisor (`FileSystemSupervisor`), independent of agent lifecycles.
- **Multiple conversations can share one filesystem.** Any agent configured with `filesystem_scope: {:user, 123}` talks to the same server and sees the same files.

```
Your Application
  └── FileSystemSupervisor (DynamicSupervisor)
        └── FileSystemServer {:user, 1}   ← started by your app, not by the agent
        └── FileSystemServer {:user, 2}
        └── FileSystemServer {:project, 42}

  └── AgentsDynamicSupervisor
        └── AgentSupervisor (conversation-101)
              └── AgentServer   ← uses FileSystemServer {:user, 1} via scope key
        └── AgentSupervisor (conversation-102)
              └── AgentServer   ← also uses FileSystemServer {:user, 1}
```

## The Three Steps

### 1. Start the FileSystemServer

Call `FileSystem.ensure_filesystem/3` before starting any agent session. This is idempotent — safe to call on every page load or connection.

```elixir
alias Sagents.FileSystem
alias Sagents.FileSystem.FileSystemConfig
alias Sagents.FileSystem.Persistence.Disk

scope_key = {:user, user_id}

{:ok, fs_config} = FileSystemConfig.new(%{
  base_directory: "Memories",
  persistence_module: Disk,
  debounce_ms: 5_000,
  storage_opts: [path: "/var/lib/myapp/user_files/#{user_id}"]
})

{:ok, _pid} = FileSystem.ensure_filesystem(scope_key, [fs_config])
```

On startup, the `Disk` persistence module calls `list_persisted_files/2` to discover all existing files in the storage path and registers them in the server with `loaded: false`. Files are then loaded lazily on first access — the server only reads the actual content from disk when the agent calls `read_file`.

### 2. Pass the scope to the agent

Pass the same scope key to your `Factory` when creating the agent:

```elixir
{:ok, agent} = MyApp.Agents.Factory.create_agent(
  agent_id: agent_id,
  filesystem_scope: {:user, user_id}
)
```

In your `Factory`, this is forwarded to the `FileSystem` middleware:

```elixir
defp build_middleware(filesystem_scope, ...) do
  [
    {Sagents.Middleware.FileSystem, [filesystem_scope: filesystem_scope]},
    ...
  ]
end
```

### 3. Start the agent session

The agent connects to the already-running `FileSystemServer` automatically through the scope key. No further setup is needed.

```elixir
{:ok, session} = MyApp.Agents.Coordinator.start_conversation_session(
  conversation_id,
  filesystem_scope: {:user, user_id}
)
```

## Where to Start the Filesystem

The filesystem must be started before the agent, so the right place is wherever your application first needs it — typically the LiveView `mount/3`.

```elixir
# In your LiveView
def mount(_params, _session, socket) do
  user_id = socket.assigns.current_scope.user.id

  filesystem_scope =
    case MyApp.Agents.Setup.ensure_user_filesystem(user_id) do
      {:ok, scope} -> scope
      {:error, reason} ->
        Logger.warning("Failed to start filesystem: #{inspect(reason)}")
        nil
    end

  {:ok, assign(socket, filesystem_scope: filesystem_scope)}
end
```

Since `ensure_filesystem/3` is idempotent, it is safe to call on every `mount`. If the `FileSystemServer` is already running for that scope key, the call is a no-op that returns the existing PID.

The filesystem continues running after the LiveView disconnects and the agent shuts down due to inactivity. It persists for the lifetime of your application process (or until explicitly stopped), so returning users find their files intact.

## Scoping Strategies

The scope key determines how files are isolated and shared:

| Scope | Key format | Files visible to |
|---|---|---|
| Per-user | `{:user, user_id}` | All conversations for that user |
| Per-project | `{:project, project_id}` | All agents working on that project |
| Per-conversation | `{:agent, agent_id}` | Only that specific conversation |
| Custom | `{:team, team_id}` | Any tuple you choose |

For most user-facing chat applications, **user-scoped** is the right default. It lets the agent's files accumulate across conversations, creating a persistent long-term memory.

## Seeding Files for New Users

When a user's storage directory doesn't exist yet, you can copy template files to give them a helpful starting point. Check for the directory before starting the filesystem:

```elixir
defmodule MyApp.Agents.Setup do
  alias Sagents.FileSystem
  alias Sagents.FileSystem.{FileSystemConfig, Persistence.Disk}

  def ensure_user_filesystem(user_id) do
    storage_path = user_storage_path(user_id)
    scope_key = {:user, user_id}

    # Seed template files for new users before starting the server
    if not File.exists?(storage_path) do
      File.mkdir_p!(storage_path)
      seed_new_user_files(storage_path)
    end

    {:ok, fs_config} = FileSystemConfig.new(%{
      base_directory: "Memories",
      persistence_module: Disk,
      debounce_ms: 5_000,
      storage_opts: [path: storage_path]
    })

    case FileSystem.ensure_filesystem(scope_key, [fs_config]) do
      {:ok, _pid} -> {:ok, scope_key}
      {:error, reason} -> {:error, reason}
    end
  end

  defp seed_new_user_files(storage_path) do
    template_path = Path.join(:code.priv_dir(:my_app), "new_user_template")

    if File.exists?(template_path) do
      copy_directory_contents(template_path, storage_path)
    end
  end

  defp copy_directory_contents(source, dest) do
    {:ok, entries} = File.ls(source)

    Enum.each(entries, fn entry ->
      src = Path.join(source, entry)
      dst = Path.join(dest, entry)

      if File.dir?(src) do
        File.mkdir_p!(dst)
        copy_directory_contents(src, dst)
      else
        File.cp!(src, dst)
      end
    end)
  end

  defp user_storage_path(user_id) do
    base = Application.get_env(:my_app, :user_files_path, "user_files")
    Path.join([base, to_string(user_id), "memories"])
  end
end
```

Place template files in `priv/new_user_template/` in your application. They are copied once when the directory is first created and left alone for returning users.

## Database-Backed Persistence

For production applications, you may want to store files in your database instead of on disk. Implement the `Sagents.FileSystem.Persistence` behaviour:

```elixir
defmodule MyApp.FileSystem.DBPersistence do
  @behaviour Sagents.FileSystem.Persistence

  alias MyApp.{Repo, UserFile}
  import Ecto.Query

  @impl true
  def list_persisted_files(_scope_key, opts) do
    user_id = Keyword.fetch!(opts, :user_id)

    paths =
      UserFile
      |> where([f], f.user_id == ^user_id)
      |> select([f], f.path)
      |> Repo.all()

    {:ok, paths}
  end

  @impl true
  def load_from_storage(entry, opts) do
    user_id = Keyword.fetch!(opts, :user_id)

    case Repo.get_by(UserFile, user_id: user_id, path: entry.path) do
      nil ->
        {:error, :enoent}

      file ->
        {:ok, %{entry | content: file.content, loaded: true, dirty: false}}
    end
  end

  @impl true
  def write_to_storage(entry, opts) do
    user_id = Keyword.fetch!(opts, :user_id)

    result =
      case Repo.get_by(UserFile, user_id: user_id, path: entry.path) do
        nil ->
          %UserFile{}
          |> UserFile.changeset(%{user_id: user_id, path: entry.path, content: entry.content})
          |> Repo.insert()

        existing ->
          existing
          |> UserFile.changeset(%{content: entry.content})
          |> Repo.update()
      end

    case result do
      {:ok, _} -> {:ok, %{entry | dirty: false}}
      {:error, changeset} -> {:error, changeset}
    end
  end

  @impl true
  def delete_from_storage(entry, opts) do
    user_id = Keyword.fetch!(opts, :user_id)

    UserFile
    |> where([f], f.user_id == ^user_id and f.path == ^entry.path)
    |> Repo.delete_all()

    :ok
  end
end
```

Configure it in place of `Disk`:

```elixir
{:ok, fs_config} = FileSystemConfig.new(%{
  base_directory: "Memories",
  persistence_module: MyApp.FileSystem.DBPersistence,
  debounce_ms: 5_000,
  storage_opts: [user_id: user_id]
})
```

The `storage_opts` keyword list is passed through to every persistence callback, so you can include whatever context your implementation needs (`user_id`, `tenant_id`, S3 bucket name, etc.).

## Default Configs — Catch-All Persistence

When you want a single persistence backend to handle **all** file paths (not just a specific directory), set `default: true` on your `FileSystemConfig`. This makes it a catch-all that matches any path not claimed by a more specific config.

With `default: true`, the `base_directory` field is **optional** — if omitted, a sentinel value is used internally. This is useful when all files should go to one backend regardless of path:

```elixir
# All files persisted to DB, no directory restriction
{:ok, fs_config} = FileSystemConfig.new(%{
  default: true,
  persistence_module: MyApp.FileSystem.DBPersistence,
  debounce_ms: 5_000,
  storage_opts: [user_id: user_id]
})

{:ok, _pid} = FileSystem.ensure_filesystem({:user, user_id}, [fs_config])
```

With this setup, the agent can write to any path — `/notes.txt`, `/projects/plan.md`, `/deep/nested/file.txt` — and all files are persisted through the same backend.

You can still provide an explicit `base_directory` with `default: true` if you want a meaningful label, but it is not required and has no effect on path matching.

## Multiple Persistence Backends

A single `FileSystemServer` can serve files from multiple backends simultaneously. Pass a list of `FileSystemConfig` structs, one per virtual directory:

```elixir
# Writable user memories stored on disk
{:ok, memories_config} = FileSystemConfig.new(%{
  base_directory: "Memories",
  persistence_module: Disk,
  debounce_ms: 5_000,
  storage_opts: [path: "/var/lib/myapp/user_files/#{user_id}"]
})

# Read-only shared reference documents from S3
{:ok, reference_config} = FileSystemConfig.new(%{
  base_directory: "Reference",
  persistence_module: MyApp.S3Persistence,
  readonly: true,
  storage_opts: [bucket: "my-app-shared", prefix: "reference-docs/"]
})

{:ok, _pid} = FileSystem.ensure_filesystem(
  {:user, user_id},
  [memories_config, reference_config]
)
```

The agent will see both directories via `ls` but will receive an error if it tries to write to `/Reference/`.

### Default Config with Specific Overrides

You can combine a `default: true` catch-all with specific directory overrides. The specific configs take priority for their directories, and everything else falls through to the default:

```elixir
# Default: all files go to DB
{:ok, default_config} = FileSystemConfig.new(%{
  default: true,
  persistence_module: MyApp.FileSystem.DBPersistence,
  storage_opts: [user_id: user_id]
})

# Override: /Reference/* served read-only from S3
{:ok, reference_config} = FileSystemConfig.new(%{
  base_directory: "Reference",
  persistence_module: MyApp.S3Persistence,
  readonly: true,
  storage_opts: [bucket: "my-app-shared", prefix: "reference-docs/"]
})

{:ok, _pid} = FileSystem.ensure_filesystem(
  {:user, user_id},
  [default_config, reference_config]
)
```

With this setup:
- `/Reference/guide.pdf` → read-only S3 config (specific match wins)
- `/notes.txt` → writable DB config (default catch-all)
- `/projects/plan.md` → writable DB config (default catch-all)

## Pre-Populating Files at Runtime

For files that don't come from a persistence backend — for example, injecting dynamic context before a conversation begins — use `FileSystemServer.register_files/2`:

```elixir
alias Sagents.FileSystemServer
alias Sagents.FileSystem.FileEntry

scope_key = {:user, user_id}

# Ensure the server is running first
{:ok, _pid} = FileSystem.ensure_filesystem(scope_key, [fs_config])

# Inject a dynamic file into memory (not persisted)
{:ok, entry} = FileEntry.new_memory_file(
  "/session/context.md",
  "User's current project: #{project.name}\nRecent activity: ..."
)

FileSystemServer.register_files(scope_key, entry)
```

Memory-only files (created with `FileEntry.new_memory_file/2`) exist only for the lifetime of the `FileSystemServer` process and are not written to any persistence backend.

## Real-Time File Change Notifications

Subscribe to the `FileSystemServer`'s PubSub topic to receive events when the agent creates, edits, or deletes files. This is useful for updating a file browser in your UI.

Requires the server to be started with a PubSub configuration:

```elixir
{:ok, _pid} = FileSystem.ensure_filesystem(
  scope_key,
  [fs_config],
  pubsub: {Phoenix.PubSub, MyApp.PubSub}
)
```

Then subscribe in your LiveView:

```elixir
# In mount/3 (connected socket only)
if connected?(socket) do
  FileSystemServer.subscribe(scope_key)
end

# Handle events
def handle_info({:file_system, {:file_updated, path}}, socket) do
  # Refresh your file list UI
  {:noreply, update_file_list(socket)}
end

def handle_info({:file_system, {:file_deleted, path}}, socket) do
  {:noreply, update_file_list(socket)}
end
```

## Reference: agents_demo

The `agents_demo` project demonstrates all of the above patterns in a complete Phoenix application:

| File | Role |
|---|---|
| `lib/agents_demo/agents/demo_setup.ex` | `ensure_user_filesystem/1` — starts the `FileSystemServer` with disk persistence, seeds template files for new users |
| `lib/agents_demo_web/live/chat_live.ex` | Calls `DemoSetup.ensure_user_filesystem/1` in `mount/3`, subscribes to file change events, passes the scope to the Coordinator |
| `lib/agents_demo/agents/coordinator.ex` | Receives `filesystem_scope` from the LiveView and forwards it to the Factory |
| `lib/agents_demo/agents/factory.ex` | Passes `filesystem_scope` to `{Sagents.Middleware.FileSystem, [filesystem_scope: scope]}` |
| `priv/new_user_template/` | Template files copied to new users' storage directories |