Packages

ExClaw — OpenClaw rebuilt on ADK Elixir

Current section

Files

Jump to
ex_claw lib ex_claw workspace.ex
Raw

lib/ex_claw/workspace.ex

defmodule ExClaw.Workspace do
@moduledoc """
Manages workspace identity files — the core files that define an agent's
identity, memory, and operating context.
Files are organized into categories:
- **Identity**: SOUL.md, IDENTITY.md, USER.md (who the agent is)
- **Operating**: AGENTS.md, TOOLS.md, HEARTBEAT.md (how it works)
- **Memory**: MEMORY.md, memory/YYYY-MM-DD.md (what it remembers)
"""
require Logger
alias ExClaw.Workspace.Validator
@file_names %{
soul: "SOUL.md",
identity: "IDENTITY.md",
user: "USER.md",
agents: "AGENTS.md",
tools: "TOOLS.md",
heartbeat: "HEARTBEAT.md",
memory: "MEMORY.md"
}
@identity_files [:soul, :identity, :user]
@operating_files [:agents, :tools, :heartbeat]
@all_files @identity_files ++ @operating_files ++ [:memory]
@default_contents %{
soul: """
# SOUL.md
_Define who you are here._
## Core Truths
- Be genuinely helpful
- Have opinions
- Be resourceful before asking
## Vibe
Concise, helpful, not a corporate drone.
""",
identity: """
# IDENTITY.md
- **Name:** (unnamed)
- **Creature:** AI assistant
""",
user: """
# USER.md
- **Name:** (unknown)
- **Timezone:** UTC
""",
agents: """
# AGENTS.md
## Every Session
1. Read SOUL.md — this is who you are
2. Read USER.md — this is who you're helping
3. Check memory/ for recent context
## Safety
- Don't exfiltrate private data
- Ask before destructive commands
- When in doubt, ask
""",
tools: """
# TOOLS.md - Environment
_Document your tools, credentials, and environment here._
""",
heartbeat: """
# HEARTBEAT.md
_Define periodic checks and tasks here._
If nothing needs attention, reply HEARTBEAT_OK.
""",
memory: """
# MEMORY.md - Long-Term Memory
_Curated memories go here. Daily files are raw notes; this is distilled wisdom._
"""
}
@doc "Get the workspace root directory (agent identity files)."
@spec root() :: String.t()
def root do
safe_config_get([:agent_dir]) ||
Application.get_env(:ex_claw, :agent_dir) ||
Path.join(System.get_env("HOME", "/tmp"), ".ex_claw/agent")
end
@doc "Get the workspace context directory (memory, skills, tools)."
@spec context_dir() :: String.t()
def context_dir do
safe_config_get([:workspace_dir]) ||
Application.get_env(:ex_claw, :workspace_dir) ||
Path.join(System.get_env("HOME", "/tmp"), ".ex_claw/context")
end
@doc false
defp safe_config_get(path) do
if Process.whereis(ExClaw.Config) do
ExClaw.Config.get(path)
else
nil
end
end
@doc """
Read a workspace file by name.
Valid names: #{inspect(@all_files)}
"""
@spec read_file(atom()) :: {:ok, String.t()} | {:error, term()}
def read_file(name) when name in @all_files do
path = file_path(name)
case File.read(path) do
{:ok, content} -> {:ok, content}
{:error, :enoent} -> {:error, :not_found}
{:error, reason} -> {:error, reason}
end
end
def read_file(_name), do: {:error, :unknown_file}
@doc """
Write a workspace file. Validates content before writing.
Valid names: #{inspect(@all_files)}
"""
@spec write_file(atom(), String.t()) :: :ok | {:error, term()}
def write_file(name, content) when name in @all_files and is_binary(content) do
case Validator.validate(name, content) do
:ok ->
do_write(name, content)
{:warning, reason} ->
Logger.warning("Writing #{file_name(name)} with warning: #{reason}")
do_write(name, content)
{:error, reason} ->
{:error, {:validation_failed, reason}}
end
end
def write_file(_name, _content), do: {:error, :unknown_file}
@doc "Read today's daily memory file."
@spec read_daily_memory() :: {:ok, String.t()} | {:error, :not_found}
def read_daily_memory do
path = daily_memory_path()
case File.read(path) do
{:ok, content} -> {:ok, content}
{:error, _} -> {:error, :not_found}
end
end
@doc "Append to today's daily memory file. Creates it if it doesn't exist."
@spec append_daily_memory(String.t()) :: :ok | {:error, term()}
def append_daily_memory(content) when is_binary(content) do
path = daily_memory_path()
dir = Path.dirname(path)
File.mkdir_p!(dir)
if File.exists?(path) do
File.write(path, content, [:append])
else
today = Date.to_iso8601(Date.utc_today())
header = "# Daily Memory — #{today}\n\n"
File.write(path, header <> content)
end
end
@doc """
Get all identity/operating files as a map for session context injection.
Returns a map like `%{soul: "...", identity: "...", ...}` with all readable files.
Files that don't exist are omitted.
"""
@spec load_context() :: map()
def load_context do
@all_files
|> Enum.reduce(%{}, fn name, acc ->
case read_file(name) do
{:ok, content} -> Map.put(acc, name, content)
{:error, _} -> acc
end
end)
|> maybe_add_daily_memory()
end
@doc """
Ensure all required workspace files exist. Creates missing files with defaults.
Called at boot to set up the workspace.
"""
@spec ensure_files!() :: :ok
def ensure_files! do
root_dir = root()
ctx_dir = context_dir()
File.mkdir_p!(root_dir)
File.mkdir_p!(ctx_dir)
File.mkdir_p!(Path.join(ctx_dir, "memory/daily"))
Enum.each(@all_files, fn name ->
path = file_path(name)
unless File.exists?(path) do
File.mkdir_p!(Path.dirname(path))
File.write!(path, Map.fetch!(@default_contents, name))
end
end)
:ok
end
@doc "Return the file path for a workspace file atom."
@spec file_path(atom()) :: String.t()
def file_path(name) when name in @all_files do
Path.join(root(), file_name(name))
end
@doc "Return the filename string for a workspace file atom."
@spec file_name(atom()) :: String.t()
def file_name(name) when is_map_key(@file_names, name) do
Map.fetch!(@file_names, name)
end
@doc "Return the map of all file atom names to filenames."
@spec file_names() :: map()
def file_names, do: @file_names
@doc "Return the default content for a workspace file."
@spec default_content(atom()) :: String.t()
def default_content(name) when is_map_key(@default_contents, name) do
Map.fetch!(@default_contents, name)
end
@doc "Return all file atom names."
@spec all_files() :: [atom()]
def all_files, do: @all_files
# --- Private ---
defp daily_memory_path do
today = Date.to_iso8601(Date.utc_today())
Path.join([context_dir(), "memory", "daily", "#{today}.md"])
end
defp do_write(name, content) do
path = file_path(name)
File.mkdir_p!(Path.dirname(path))
case File.write(path, content) do
:ok -> :ok
{:error, reason} -> {:error, reason}
end
end
defp maybe_add_daily_memory(context) do
case read_daily_memory() do
{:ok, content} -> Map.put(context, :daily_memory, content)
{:error, _} -> context
end
end
end