Packages

fnord

0.9.32

AI code archaeology

Current section

Files

Jump to
fnord lib cmd conversations.ex
Raw

lib/cmd/conversations.ex

defmodule Cmd.Conversations do
@behaviour Cmd
@impl Cmd
def requires_project?(), do: true
@impl Cmd
def spec do
[
conversations: [
name: "conversations",
about: "List all conversations in the project",
options: [
project: Cmd.project_arg(),
prune: [
value_name: "PRUNE",
long: "--prune",
short: "-P",
help: "Prune by age (days) or delete a specific conversation by ID",
parser: :string
],
query: [
value_name: "QUERY",
long: "--query",
short: "-q",
help: "Semantic search query",
parser: :string
],
limit: [
value_name: "LIMIT",
long: "--limit",
short: "-l",
help: "Max search results",
parser: :integer,
default: 5
]
]
]
]
end
@impl Cmd
def run(%{prune: prune} = opts, _subcommands, _unknown)
when is_integer(prune) or is_binary(prune) do
with {:ok, project} <- Store.get_project() do
case prune(opts, project) do
:ok -> :ok
{:error, :cancelled} -> UI.error("Operation cancelled.")
{:error, :invalid_prune_value} -> UI.error("Invalid --prune value: #{prune}")
{:error, :not_found} -> UI.error("Conversation #{prune} not found.")
end
else
{:error, :project_not_set} ->
UI.error("No project selected; use --project or run in a project directory.")
end
end
@impl Cmd
def run(%{query: query} = opts, _subcommands, _unknown) when is_binary(query) do
AI.Embeddings.Pool.ensure_started()
AI.Embeddings.Migration.maybe_migrate(:read_only)
with {:ok, project} <- Store.get_project() do
search(opts, project)
else
{:error, :project_not_set} ->
UI.error(
"No project selected; please specify --project or run inside a project directory."
)
end
end
@impl Cmd
def run(opts, _subcommands, _unknown) do
with {:ok, project} <- Store.get_project(),
:ok <- display(opts, project) do
:ok
else
{:error, :project_not_set} ->
UI.error(
"No project selected; please specify --project or run inside a project directory."
)
end
end
defp prune(%{prune: prune_str} = opts, project) when is_binary(prune_str) do
case Integer.parse(prune_str) do
{days, ""} ->
prune(%{opts | prune: days}, project)
_ ->
prune_by_id(prune_str, project)
end
end
defp prune(%{prune: days}, project) when is_integer(days) and days >= 0 do
cutoff = DateTime.utc_now() |> DateTime.add(-days, :day)
UI.info("Pruning conversations older than #{days} days")
to_delete =
project
|> Store.Project.conversations()
|> Enum.reduce([], fn conversation, acc ->
timestamp = Store.Project.Conversation.timestamp(conversation)
if DateTime.compare(timestamp, cutoff) == :lt do
[conversation | acc]
else
acc
end
end)
if to_delete == [] do
UI.info("No conversations to prune.")
:ok
else
UI.info("Preparing to delete the following conversations:")
to_delete
|> Enum.each(fn conversation ->
UI.info(conversation.id)
end)
if UI.confirm("Confirm deletion of the listed conversations. This action cannot be undone.") do
to_delete
|> Enum.each(fn conversation ->
Cmd.WorktreeLifecycle.cleanup_worktree_for_conversation(conversation)
Store.Project.Conversation.delete(conversation)
Store.Project.ConversationIndex.delete(project, conversation.id)
end)
count = length(to_delete)
UI.info("Deleted #{count} conversation(s).")
:ok
else
{:error, :cancelled}
end
end
end
defp prune(%{prune: days}, _project) when is_integer(days) and days < 0 do
{:error, :invalid_prune_value}
end
defp prune(_opts, _project), do: :ok
defp prune_by_id(id, project) do
conversation = Store.Project.Conversation.new(id, project)
with true <- Store.Project.Conversation.exists?(conversation),
{:ok, question} <- Store.Project.Conversation.question(conversation) do
message =
"Confirm deletion of conversation #{id} (\"#{question}\"). This action cannot be undone."
if UI.confirm(message) do
Cmd.WorktreeLifecycle.cleanup_worktree_for_conversation(conversation)
case Store.Project.Conversation.delete(conversation) do
:ok ->
Store.Project.ConversationIndex.delete(project, id)
UI.info("Deleted conversation #{id}.")
:ok
{:error, :not_found} ->
{:error, :not_found}
end
else
UI.error("Operation cancelled for conversation #{id}.")
{:error, :cancelled}
end
else
_ ->
UI.error("Conversation #{id} not found.")
{:error, :not_found}
end
end
defp display(_opts, project) do
project
|> Store.Project.conversations()
|> case do
[] ->
UI.puts("No conversations found.")
conversations ->
conversations
|> Enum.map(fn conversation ->
%{
id: conversation.id,
timestamp: Store.Project.Conversation.timestamp(conversation),
file: conversation.store_path,
question: get_question(conversation),
length: Store.Project.Conversation.num_messages(conversation)
}
end)
|> SafeJson.encode!(pretty: true)
|> UI.puts()
end
:ok
end
defp search(opts, project) do
query = Map.get(opts, :query)
limit = Map.get(opts, :limit, 5)
case Search.Conversations.search(project, query, limit: limit) do
{:ok, results} ->
Enum.each(results, &UI.puts(print_search_row(&1)))
:ok
{:error, reason} ->
UI.error("Search failed: #{inspect(reason)}")
end
end
defp print_search_row(%{
conversation_id: id,
score: score,
timestamp: timestamp,
title: title,
length: length
}) do
[
format_score(score),
format_timestamp(timestamp),
to_string(length),
to_string(id),
truncate_title(title)
]
|> Enum.join("\t")
end
defp truncate_title(title) when is_binary(title) do
case String.split(title, "\n", parts: 2) do
[first] -> first
[first, _rest] -> first <> "..."
end
end
defp format_score(score) when is_number(score) do
:erlang.float_to_binary(score, decimals: 3)
end
defp format_timestamp(timestamp) do
case timestamp do
%DateTime{} ->
unix = DateTime.to_unix(timestamp)
{cmd, args} =
case :os.type() do
{:unix, :darwin} ->
{"date", ["-r", to_string(unix), "+%Y-%m-%d %H:%M:%S"]}
{:unix, _} ->
{"date", ["-d", "@#{unix}", "+%Y-%m-%d %H:%M:%S"]}
end
case System.cmd(cmd, args, stderr_to_stdout: true) do
{result, 0} -> String.trim(result)
_ -> DateTime.to_string(timestamp)
end
other ->
to_string(other)
end
end
defp get_question(conversation) do
with {:ok, question} <- Store.Project.Conversation.question(conversation) do
question
else
{:error, :no_question} -> "(not found)"
end
end
end