Packages
fnord
0.9.25
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/outputs.ex
defmodule Outputs do
@moduledoc """
Helpers for persisting raw assistant outputs for a project.
Outputs are written under:
~/fnord/outputs/<project_id>/<slug>.md
The content saved is the *raw* assistant response.
"""
@typedoc """
Represents a project identifier.
"""
@type project_id :: String.t()
@typedoc """
The raw response text from the assistant.
"""
@type raw_response :: String.t()
@typedoc """
A conversation identifier used for default slug generation.
"""
@type conversation_id :: String.t()
@typedoc """
A slug generated for output filenames.
"""
@type slug :: String.t()
@typedoc """
The path to an output file.
"""
@type output_path :: String.t()
@typedoc """
Options for the save function, currently supported keys:
- :conversation_id - a conversation_id
"""
@type save_opts :: [conversation_id: conversation_id]
@typedoc """
Result of a save operation.
On success returns {:ok, output_path}, on failure {:error, reason}.
"""
@type save_result :: {:ok, output_path} | {:error, term()}
@doc """
Returns the directory path for outputs for a given project.
Uses Settings.get_user_home() and returns ~/fnord/outputs/<project_id>.
"""
@spec outputs_dir(project_id()) :: String.t()
def outputs_dir(project_id) when is_binary(project_id) do
Path.join([
Settings.get_user_home(),
"fnord",
"outputs",
project_id
])
end
@doc """
Saves the raw assistant markdown response for a project.
Derives the filename slug from the first line "# Title: ..." if present, falls back to "conversation-<conversation_id>" or "untitled".
Resolves filename collisions by appending "-N".
Uses FileLock.with_lock and Settings.write_atomic! for atomic writes.
Returns {:ok, output_path} on success or {:error, reason} on failure.
"""
@spec save(project_id(), raw_response(), save_opts()) :: save_result()
def save(project_id, raw_response, opts \\ [])
when is_binary(project_id) and
is_binary(raw_response) and
is_list(opts) do
dir = outputs_dir(project_id)
File.mkdir_p!(dir)
slug =
case extract_title(raw_response) do
title when is_binary(title) and title != "" ->
slugified = slugify(title)
if slugified != "", do: slugified, else: default_slug(opts)
_ ->
default_slug(opts)
end
path = unique_path(dir, slug)
lock_path = path <> ".lock"
case FileLock.with_lock(lock_path, fn ->
Settings.write_atomic!(path, raw_response)
path
end) do
{:ok, result} when is_binary(result) ->
{:ok, result}
{:ok, other} ->
{:error, other}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Extracts the title from the raw response.
Expects the first line in the format "# Title: <title>" (case-insensitive).
Returns the trimmed title or nil if missing or blank.
"""
@spec extract_title(raw_response()) :: String.t() | nil
def extract_title(raw_response) when is_binary(raw_response) do
raw_response
|> String.split("\n", parts: 2)
|> List.first()
|> case do
nil ->
nil
first_line ->
case Regex.run(~r/^# Title:\s*(.+)$/i, first_line) do
[_, title] ->
trimmed = String.trim(title)
if trimmed == "", do: nil, else: trimmed
_ ->
nil
end
end
end
@doc """
Converts a text string into a URL-friendly slug.
Lowercases, trims whitespace, replaces non-alphanumeric characters with hyphens, and trims leading/trailing hyphens.
"""
@spec slugify(String.t()) :: slug()
def slugify(text) when is_binary(text) do
text
|> String.downcase()
|> String.trim()
|> then(&Regex.replace(~r/[^a-z0-9]+/, &1, "-"))
|> String.trim("-")
end
@spec default_slug(Keyword.t()) :: String.t()
defp default_slug(opts) when is_list(opts) do
case Keyword.get(opts, :conversation_id) do
id when is_binary(id) and id != "" ->
"conversation-#{id}"
_ ->
"untitled"
end
end
@spec unique_path(String.t(), String.t(), non_neg_integer()) :: String.t()
defp unique_path(dir, slug, counter \\ 0)
when is_binary(dir) and is_binary(slug) and is_integer(counter) and counter >= 0 do
filename =
if counter == 0 do
"#{slug}.md"
else
"#{slug}-#{counter}.md"
end
path = Path.join(dir, filename)
if File.exists?(path) do
unique_path(dir, slug, counter + 1)
else
path
end
end
end