Packages

fnord

0.9.25

AI code archaeology

Current section

Files

Jump to
fnord lib store project conversation.ex
Raw

lib/store/project/conversation.ex

defmodule Store.Project.Conversation do
@moduledoc """
Conversations are stored per project in the project's store dir, under
`converations/`. Each file is *mostly* JSON, but with a timestamp prepended
to the JSON data, separated by a colon. This allows for easy sorting, without
having to parse dozens or hundreds of messages for each file.
The JSON object currently has the following keys:
- `messages`: a list of messages in the conversation
- `metadata`: a map of metadata for the conversation
- `memory`: a list of memory objects associated with the conversation
Existing conversations are retrieved by their UUID identifier.
"""
defstruct [
:project_home,
:store_path,
:id
]
@type t :: %__MODULE__{}
@type data :: %{
timestamp: DateTime.t(),
messages: AI.Util.msg_list(),
metadata: map,
memory: list,
tasks: %{binary => %{description: binary | nil, tasks: Services.Task.task_list()}}
}
@store_dir "conversations"
@doc """
Lists all conversations in the given project in ascending order by timestamp.
"""
@spec list(Store.Project.t()) :: [t]
def list(%Store.Project{store_path: store_path}), do: list(store_path)
@spec list(binary) :: [t]
def list(project_home) do
project_home
|> Path.join(["conversations/*.json"])
|> Path.wildcard()
|> Enum.map(&Path.basename(&1, ".json"))
|> Enum.map(&Store.Project.Conversation.new(&1, project_home))
|> Enum.sort_by(
fn conv ->
case Store.Project.Conversation.timestamp(conv) do
%DateTime{} = dt -> DateTime.to_unix(dt)
int when is_integer(int) -> int
end
end,
:asc
)
end
@doc """
Create a new conversation with a new UUID identifier and the globally
selected project.
"""
def new do
with {:ok, project} <- Store.get_project() do
new(Uniq.UUID.uuid4(), project.store_path)
else
{:error, _} -> raise "No project selected"
end
end
@doc """
Create a new conversation from an existing UUID identifier and the globally
selected project.
"""
def new(id) do
with {:ok, project} <- Store.get_project() do
new(id, project.store_path)
else
{:error, _} -> raise "No project selected"
end
end
@doc """
Create a new conversation from an existing UUID identifier and an explicitly
specified project.
"""
def new(id, project_home) when is_binary(project_home) do
%__MODULE__{
project_home: project_home,
store_path: build_store_path(project_home, id),
id: id
}
end
def new(id, %Store.Project{store_path: store_path}) do
new(id, store_path)
end
@doc """
Returns true if the conversation exists on disk, false otherwise.
"""
@spec exists?(t) :: boolean()
def exists?(%__MODULE__{store_path: store_path}) do
File.exists?(store_path)
end
@doc """
Saves the conversation in the store.
For new conversations, generates a fresh timestamp.
For existing conversations, only updates the on-disk timestamp if the conversation's messages have changed;
otherwise reuses the existing timestamp. Incoming data is merged with existing conversation data so
that missing keys fallback to the existing values.
"""
@spec write(t, map) :: {:ok, t} | {:error, any()}
def write(conversation, data \\ %{}) do
# Ensure the project store directory exists
conversation.project_home
|> build_store_dir()
|> File.mkdir_p()
{timestamp, final_data} =
if exists?(conversation) do
case read(conversation) do
{:ok, existing} ->
# Prepare existing fields for merge
existing_data = %{
messages: existing.messages,
metadata: existing.metadata,
memory: existing.memory,
tasks: existing.tasks
}
# Merge incoming data over existing data
merged = Map.merge(existing_data, data)
# Normalize incoming messages (string vs atom keys) before comparing
merged_messages =
merged
|> Map.get(:messages, [])
|> Util.string_keys_to_atoms()
merged = Map.put(merged, :messages, merged_messages)
# Reuse timestamp if messages unchanged
existing_msgs = existing_data.messages
timestamp =
if merged_messages == existing_msgs do
DateTime.to_unix(existing.timestamp)
else
marshal_ts()
end
{timestamp, merged}
_ ->
# Unable to read existing; treat as new
ts = marshal_ts()
new_data =
data
|> Map.put_new(:messages, [])
|> Map.put_new(:metadata, %{})
|> Map.put_new(:memory, [])
|> Map.put_new(:tasks, %{})
{ts, new_data}
end
else
# New conversation
ts = marshal_ts()
new_data =
data
|> Map.put_new(:messages, [])
|> Map.put_new(:metadata, %{})
|> Map.put_new(:memory, [])
|> Map.put_new(:tasks, %{})
{ts, new_data}
end
# Persist task list descriptions alongside tasks: normalize to new shape
normalized =
final_data
|> Map.update(:tasks, %{}, fn tasks_map ->
tasks_map
|> Enum.map(fn {list_id, raw} ->
{items, desc} =
cond do
is_list(raw) ->
# Legacy list shape: bare list of tasks, no description
{raw, nil}
is_map(raw) and Map.has_key?(raw, :tasks) ->
# New shape: map with :tasks and :description
{Map.get(raw, :tasks), Map.get(raw, :description)}
true ->
{[], nil}
end
{list_id, %{tasks: items, description: desc}}
end)
|> Map.new()
end)
# Encode and write JSON with timestamp prefix
with {:ok, json} <- SafeJson.encode(normalized),
:ok <- File.write(conversation.store_path, "#{timestamp}:" <> json) do
{:ok, conversation}
end
end
@doc """
Reads the conversation from the store. Returns a map with the timestamp,
messages, metadata, and memory in the conversation.
"""
@spec read(t) :: {:ok, data} | {:error, any}
def read(conversation) do
with {:ok, contents} <- File.read(conversation.store_path),
[timestamp_str, json] <- String.split(contents, ":", parts: 2),
{:ok, timestamp} <- unmarshal_ts(timestamp_str),
{:ok, data} <- SafeJson.decode(json) do
# If persisted tasks use legacy shapes, heal and re-save the file so callers get canonical shape
Store.Project.Conversation.TaskListStatusMigration.heal_and_maybe_write(
data,
conversation,
timestamp_str
)
# A now-removed code path stored tool call arguments as decoded maps.
# Re-encode them to JSON strings before atomizing to prevent garbage
# LLM-generated keys from exhausting the atom table.
data = heal_tool_call_arguments_and_maybe_write(data, conversation, timestamp_str)
msgs =
data
|> Map.get("messages", [])
|> Util.string_keys_to_atoms()
metadata =
data
|> Map.get("metadata", %{})
|> Util.string_keys_to_atoms()
memories =
data
|> Map.get("memory", [])
|> Util.string_keys_to_atoms()
|> Enum.map(&Memory.new_from_map/1)
tasks =
data
|> Map.get("tasks", %{})
|> Enum.map(fn {list_id, value} ->
# Normalize to %{tasks: [...], description: ...} format
{raw_tasks, desc} =
cond do
is_list(value) ->
# Legacy format: bare list of tasks
{value, nil}
is_map(value) ->
# New format: map with tasks and description
val = Util.string_keys_to_atoms(value)
{Map.get(val, :tasks, []), Map.get(val, :description)}
true ->
# Invalid/empty
{[], nil}
end
# Parse tasks with outcome normalization
tasks_list =
raw_tasks
|> Util.string_keys_to_atoms()
|> Enum.map(fn %{id: task_id, data: data} = task_data ->
opts =
task_data
|> Map.drop([:id, :data])
|> Keyword.new()
# Normalize outcome using shared utility
opts =
case Keyword.get(opts, :outcome) do
outcome when not is_nil(outcome) ->
Keyword.put(opts, :outcome, Services.Task.Util.normalize_outcome(outcome))
_ ->
opts
end
Services.Task.new_task(task_id, data, opts)
end)
{list_id, %{description: desc, tasks: tasks_list}}
end)
|> Map.new()
{:ok,
%{
timestamp: timestamp,
messages: msgs,
metadata: metadata,
memory: memories,
tasks: tasks
}}
end
rescue
e ->
UI.warn("Skipping corrupt conversation file", conversation.store_path)
{:error, {:corrupt_conversation, Exception.message(e)}}
end
@doc """
Forks the given conversation, returning a new conversation with a new UUID
and identical messages. Saves the forked conversation to disk with the
current timestamp.
"""
@spec fork(t) :: {:ok, t} | {:error, any}
def fork(conversation) do
with {:ok, data} <- read(conversation) do
# Mark all existing session memories as :ignore so the indexer won't
# re-process them in the forked conversation. They remain visible to
# the LLM for context but won't generate duplicate long-term memories.
forked_memory =
Enum.map(data.memory, fn
%Memory{scope: :session} = m -> %{m | index_status: :ignore}
other -> other
end)
with {:ok, forked} <- write(new(), %{data | memory: forked_memory}) do
{:ok, forked}
end
end
end
@doc """
Returns the timestamp of the conversation. If the conversation has not yet
been saved to the store, returns 0.
"""
@spec timestamp(t) :: DateTime.t() | 0
def timestamp(conversation) do
if exists?(conversation) do
with {:ok, contents} <- File.read(conversation.store_path),
[timestamp, _] <- String.split(contents, ":", parts: 2),
{:ok, timestamp} <- timestamp |> String.to_integer() |> DateTime.from_unix() do
timestamp
else
_ -> 0
end
else
0
end
end
@doc """
Returns the user's prompting message in the conversation. This is considered
to be the first "user" role message in the conversation.
"""
@spec question(t) :: {:ok, binary} | {:error, :no_question}
def question(conversation) do
case read(conversation) do
{:ok, data} ->
case Enum.find(data.messages, &(Map.get(&1, :role) == "user")) do
nil -> {:error, :no_question}
msg -> Map.fetch(msg, :content)
end
_ ->
{:error, :no_question}
end
end
@doc """
Returns the number of messages in the conversation. If the conversation does
not exist, returns 0.
"""
@spec num_messages(t) :: non_neg_integer()
def num_messages(conversation) do
case read(conversation) do
{:ok, data} -> length(data.messages)
_ -> 0
end
end
@doc """
Deletes the conversation from the store. If the conversation does not exist,
returns an error tuple.
"""
@spec delete(t) :: :ok | {:error, :not_found}
def delete(conversation) do
if exists?(conversation) do
File.rm!(conversation.store_path)
:ok
else
{:error, :not_found}
end
end
# -----------------------------------------------------------------------------
# Private functions
# -----------------------------------------------------------------------------
@spec build_store_dir(binary) :: binary
defp build_store_dir(project_home) do
Path.join([project_home, @store_dir])
end
@spec build_store_path(binary, binary) :: binary
defp build_store_path(project_home, id) do
file = build_store_dir(project_home) |> Path.join(id)
file <> ".json"
end
defp unmarshal_ts(numerical_string) do
numerical_string
|> String.to_integer()
|> DateTime.from_unix()
end
defp marshal_ts() do
DateTime.utc_now()
|> DateTime.to_unix()
end
# A now-removed code path stored tool call arguments as decoded maps instead
# of their canonical JSON-string form. Re-encode any map arguments and
# persist the fix so subsequent reads don't need to heal again.
defp heal_tool_call_arguments_and_maybe_write(data, conversation, timestamp_str) do
messages = Map.get(data, "messages", [])
{healed_messages, changed} =
Enum.map_reduce(messages, false, fn msg, changed ->
case Map.get(msg, "tool_calls") do
tool_calls when is_list(tool_calls) ->
{healed_tcs, tc_changed} =
Enum.map_reduce(tool_calls, false, fn tc, tc_changed ->
function = Map.get(tc, "function", %{})
arguments = Map.get(function, "arguments")
if is_map(arguments) do
case SafeJson.encode(arguments) do
{:ok, json_str} ->
healed_fn = Map.put(function, "arguments", json_str)
{Map.put(tc, "function", healed_fn), true}
{:error, _} ->
{tc, tc_changed}
end
else
{tc, tc_changed}
end
end)
if tc_changed do
{Map.put(msg, "tool_calls", healed_tcs), true}
else
{msg, changed}
end
_ ->
{msg, changed}
end
end)
if changed do
repaired = Map.put(data, "messages", healed_messages)
persist_healed_data(repaired, conversation, timestamp_str)
repaired
else
data
end
end
defp persist_healed_data(data, conversation, timestamp_str) do
tmp = conversation.store_path <> ".tmp"
with {:ok, json} <- SafeJson.encode(data),
:ok <- File.write(tmp, "#{timestamp_str}:" <> json),
:ok <- File.rename(tmp, conversation.store_path) do
:ok
else
{:error, reason} ->
File.rm(tmp)
UI.warn(
"Could not persist healed tool arguments for conversation #{conversation.id}: #{inspect(reason)}"
)
end
end
end