Packages
fnord
0.9.38
0.9.40
0.9.39
0.9.38
0.9.37
0.9.36
0.9.35
0.9.34
0.9.33
0.9.32
0.9.31
0.9.30
0.9.29
0.9.28
0.9.27
0.9.26
0.9.25
0.9.24
0.9.23
0.9.22
0.9.21
0.9.20
0.9.19
0.9.18
0.9.17
0.9.16
0.9.15
0.9.14
0.9.13
0.9.12
0.9.11
0.9.10
0.9.9
0.9.8
0.9.7
0.9.6
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.99
0.8.98
0.8.97
0.8.96
0.8.95
0.8.94
0.8.93
0.8.92
0.8.91
0.8.90
0.8.89
0.8.88
0.8.87
0.8.86
0.8.85
0.8.84
0.8.83
0.8.82
0.8.81
0.8.80
0.8.79
0.8.78
0.8.77
0.8.76
0.8.75
0.8.74
0.8.73
0.8.72
0.8.71
0.8.70
0.8.69
0.8.68
0.8.67
0.8.66
0.8.65
0.8.64
0.8.63
0.8.62
0.8.61
0.8.60
0.8.59
0.8.58
0.8.57
0.8.56
0.8.55
0.8.54
0.8.53
0.8.52
0.8.51
0.8.50
0.8.49
0.8.48
0.8.47
0.8.46
0.8.45
0.8.44
0.8.43
0.8.42
0.8.41
0.8.40
0.8.39
0.8.38
0.8.37
0.8.36
0.8.35
0.8.34
0.8.33
0.8.32
0.8.31
0.8.30
0.8.29
0.8.27
0.8.26
0.8.25
0.8.24
0.8.23
0.8.22
0.8.21
0.8.20
0.8.19
0.8.18
0.8.17
0.8.16
0.8.15
0.8.14
0.8.13
0.8.12
0.8.11
0.8.1
0.8.0
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.3
0.7.2
0.7.1
0.7.0
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.1
0.6.0
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.44
0.4.43
0.4.42
0.4.41
0.4.40
0.4.39
0.4.38
0.4.37
0.4.36
0.4.35
0.4.34
0.4.33
0.4.32
0.4.30
0.4.29
0.4.28
0.4.27
0.4.26
0.4.25
0.4.24
0.4.23
0.4.22
0.4.21
0.4.20
0.4.19
0.4.18
0.4.17
0.4.16
0.4.15
0.4.14
0.4.13
0.4.12
0.4.11
0.4.10
0.4.9
0.4.8
0.4.7
0.4.6
0.4.5
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.0
0.2.0
0.1.0
AI code archaeology
Current section
Files
Jump to
Current section
Files
lib/memory/file_store.ex
defmodule Memory.FileStore do
@moduledoc """
Shared file-backed storage for long-term memories.
This module underpins both global and project memory stores, preserving
compatibility with existing file-backed memory layouts while centralizing
shared lookup, allocation, listing, locking, and JSON marshalling behavior.
"""
@type t :: %__MODULE__{
storage_path: String.t(),
old_storage_path: String.t(),
debug_label: String.t()
}
defstruct [:storage_path, :old_storage_path, :debug_label]
@doc """
Ensures the configured storage path exists and removes the retired legacy
directory when present.
"""
@spec init(t()) :: :ok | {:error, term()}
def init(%__MODULE__{} = store) do
with {:ok, _path} <- ensure_storage_path(store) do
drop_old_storage(store)
:ok
end
end
@doc """
Returns all memory titles by reading each stored file once.
"""
@spec list(t()) :: {:ok, list(String.t())} | {:error, term()}
def list(%__MODULE__{} = store) do
with {:ok, memories} <- list_memories(store) do
titles =
memories
|> Enum.map(& &1.title)
|> Enum.uniq()
|> Enum.sort()
{:ok, titles}
end
end
@doc """
Returns all decodable memories in a single filesystem pass.
"""
@spec list_memories(t()) :: {:ok, list(Memory.t())} | {:error, term()}
def list_memories(%__MODULE__{} = store) do
with {:ok, files} <- File.ls(store.storage_path) do
memories =
files
|> json_files()
|> Enum.map(&Path.join(store.storage_path, &1))
|> Enum.reduce([], fn path, acc ->
case read_memory_file(path) do
{:ok, memory} -> [memory | acc]
{:error, _reason} -> acc
end
end)
|> Enum.sort_by(& &1.title)
{:ok, memories}
end
end
@doc """
Returns whether a memory with the given title is present.
"""
@spec exists?(t(), String.t()) :: boolean()
def exists?(%__MODULE__{} = store, title) do
case find_file_path_by_title(store, title) do
{:ok, _path} ->
true
{:error, _reason} ->
case base_slug_status(store, title) do
{:occupied, _path} -> true
_other -> false
end
end
end
@doc """
Reads a memory by title using slug-based lookup with collision fallback.
"""
@spec read(t(), String.t()) :: {:ok, Memory.t()} | {:error, term()}
def read(%__MODULE__{} = store, title) do
with {:ok, path} <- find_file_path_by_title(store, title),
{:ok, content} <- read_file(path),
{:ok, memory} <- Memory.unmarshal(content) do
{:ok, memory}
else
{:error, :not_found} = err -> err
{:error, reason} -> {:error, reason}
end
end
@doc """
Persists a memory, overwriting an existing file for the same title or
allocating a new collision-safe path when needed.
"""
@spec save(t(), Memory.t()) :: :ok | {:error, term()}
def save(%__MODULE__{} = store, %Memory{title: title} = memory) do
with {:ok, _path} <- ensure_storage_path(store),
{:ok, json} <- Memory.marshal(memory) do
lockfile = Path.join(store.storage_path, ".alloc.lock")
case FileLock.with_lock(lockfile, fn ->
path = resolve_save_path(store, title)
write_file(store, path, json)
end) do
{:ok, {:ok, :ok}} -> :ok
{:ok, {:ok, {:error, reason}}} -> {:error, reason}
{:ok, {:error, reason}} -> {:error, reason}
{:error, reason} -> {:error, reason}
end
end
end
defp base_slug_status(store, title) do
slug = Memory.title_to_slug(title)
path = Path.join(store.storage_path, "#{slug}.json")
case File.exists?(path) do
false ->
:missing
true ->
case match_title_in_file(path, title) do
{:ok, true} -> {:occupied, path}
{:ok, false} -> :collision
{:error, _reason} -> {:occupied, path}
end
end
end
@doc """
Removes the memory file identified by title.
"""
@spec forget(t(), String.t()) :: :ok | {:error, term()}
def forget(%__MODULE__{} = store, title) do
with {:ok, path} <- find_file_path_by_title(store, title) do
rm_path(path)
end
end
@doc """
Builds a store context from runtime paths.
"""
@spec new(keyword()) :: t()
def new(opts) do
%__MODULE__{
storage_path: Keyword.fetch!(opts, :storage_path),
old_storage_path: Keyword.fetch!(opts, :old_storage_path),
debug_label: Keyword.fetch!(opts, :debug_label)
}
end
@doc """
Resolves a title to a file path using the canonical slug location first and
only scanning suffix collisions when the base path does not match.
"""
@spec find_file_path_by_title(t(), String.t()) :: {:ok, String.t()} | {:error, term()}
def find_file_path_by_title(%__MODULE__{} = store, title) do
slug = Memory.title_to_slug(title)
base = Path.join(store.storage_path, "#{slug}.json")
case base_match(base, title) do
{:ok, path} -> {:ok, path}
:next -> find_collision_path(store, slug, title)
end
end
defp base_match(path, title) do
case File.exists?(path) do
true ->
case match_title_in_file(path, title) do
{:ok, true} -> {:ok, path}
{:ok, false} -> :next
{:error, _reason} -> normalize_fallback_match(fallback_slug_match(path, title))
end
false ->
:next
end
end
defp find_collision_path(store, slug, title) do
store
|> collision_paths(slug)
|> Enum.find_value({:error, :not_found}, fn path ->
case match_title_in_file(path, title) do
{:ok, true} -> {:ok, path}
{:ok, false} -> false
{:error, _reason} -> normalize_collision_fallback(fallback_slug_match(path, title))
end
end)
end
defp fallback_slug_match(path, title) do
case path_slug_to_title(path) do
^title -> {:ok, path}
_other -> false
end
end
defp normalize_fallback_match({:ok, path}), do: {:ok, path}
defp normalize_fallback_match(false), do: :next
defp normalize_collision_fallback({:ok, path}), do: {:ok, path}
defp normalize_collision_fallback(false), do: false
defp match_title_in_file(path, title) do
case read_file(path) do
{:ok, content} ->
case Memory.unmarshal(content) do
{:ok, %Memory{title: ^title}} -> {:ok, true}
{:ok, %Memory{}} -> {:ok, false}
{:error, reason} -> {:error, reason}
end
{:error, reason} ->
{:error, reason}
end
end
defp resolve_save_path(store, title) do
case find_file_path_by_title(store, title) do
{:ok, existing_path} -> existing_path
{:error, :not_found} -> allocate_unique_path_for_title(store, title)
end
end
defp allocate_unique_path_for_title(store, title) do
slug = Memory.title_to_slug(title)
case base_slug_status(store, title) do
:missing ->
Path.join(store.storage_path, "#{slug}.json")
{:occupied, path} ->
case match_title_in_file(path, title) do
{:ok, true} ->
path
{:ok, false} ->
generate_suffixed_path(store.storage_path, slug, next_suffix_index(store, slug))
{:error, _reason} ->
generate_suffixed_path(store.storage_path, slug, next_suffix_index(store, slug))
end
:collision ->
generate_suffixed_path(store.storage_path, slug, next_suffix_index(store, slug))
end
end
defp next_suffix_index(store, slug) do
store
|> collision_paths(slug)
|> Enum.map(&Path.basename(&1, ".json"))
|> Enum.map(&collision_suffix_index(&1, slug))
|> Enum.filter(&is_integer/1)
|> Enum.filter(&(&1 > 0))
|> case do
[] -> 1
numbers -> Enum.max(numbers) + 1
end
end
defp collision_paths(store, slug) do
[
collision_pattern(store.storage_path, slug),
legacy_collision_pattern(store.storage_path, slug)
]
|> Enum.flat_map(&Path.wildcard/1)
|> Enum.sort()
end
defp collision_pattern(storage_path, slug) do
Path.join(storage_path, "#{slug}-*.json")
end
defp legacy_collision_pattern(storage_path, slug) do
Path.join(storage_path, "#{slug}_*.json")
end
defp collision_suffix_index(filename, slug) do
cond do
String.starts_with?(filename, "#{slug}-") ->
filename
|> String.replace_prefix("#{slug}-", "")
|> Integer.parse()
|> parse_collision_index()
String.starts_with?(filename, "#{slug}_") ->
filename
|> String.replace_prefix("#{slug}_", "")
|> Integer.parse()
|> parse_collision_index()
true ->
nil
end
end
defp parse_collision_index({number, ""}), do: number
defp parse_collision_index({_number, _rest}), do: nil
defp parse_collision_index(:error), do: nil
defp path_slug_to_title(path) do
path
|> Path.rootname()
|> Path.basename()
|> String.replace(~r/[-_]\d+$/, "")
|> Memory.slug_to_title()
end
defp generate_suffixed_path(storage_path, slug, index) do
Path.join(storage_path, "#{slug}-#{index}.json")
end
defp json_files(files) do
Enum.filter(files, &String.ends_with?(&1, ".json"))
end
defp ensure_storage_path(store) do
path = store.storage_path
with false <- File.exists?(path),
:ok <- File.mkdir_p(path) do
{:ok, path}
else
true -> {:ok, path}
end
end
defp drop_old_storage(store) do
path = store.old_storage_path
case File.exists?(path) do
true ->
UI.debug(store.debug_label, "Removing old memory storage at #{path}")
File.rm_rf!(path)
false ->
:ok
end
end
defp read_memory_file(path) do
with {:ok, content} <- read_file(path),
{:ok, memory} <- Memory.unmarshal(content) do
{:ok, memory}
end
end
defp read_file(path) do
case FileLock.with_lock(path, fn -> File.read(path) end) do
{:ok, {:ok, contents}} -> {:ok, contents}
{:ok, {:error, reason}} -> {:error, reason}
{:error, reason} -> {:error, reason}
end
end
defp write_file(store, path, json) do
log_write(store, path, json)
case FileLock.with_lock(path, fn -> File.write(path, json) end) do
{:ok, :ok} -> {:ok, :ok}
{:ok, {:error, reason}} -> {:error, reason}
{:error, reason} -> {:error, reason}
end
end
defp log_write(store, path, json) do
case Util.Env.looks_truthy?("FNORD_DEBUG_MEMORY") do
true ->
UI.debug(
"#{store.debug_label}.write",
"writing memory to #{path} (#{byte_size(json)} bytes)"
)
false ->
:ok
end
end
defp rm_path(path) do
case FileLock.with_lock(path, fn -> File.rm(path) end) do
{:ok, :ok} -> :ok
{:ok, {:error, reason}} -> {:error, reason}
{:error, reason} -> {:error, reason}
{:callback_error, reason} -> {:error, reason}
end
end
end