Packages
fnord
0.8.88
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/cmd/ask.ex
defmodule Cmd.Ask do
@default_rounds 1
# Universal default: auto-deny after 180 seconds if no explicit auto flag
@default_auto_policy {:deny, 180_000}
@behaviour Cmd
@impl Cmd
def requires_project?(), do: true
@impl Cmd
def spec() do
[
ask: [
name: "ask",
about: "Ask the AI a question about the project",
options: [
project: Cmd.project_arg(),
workers: Cmd.workers_arg(),
question: [
value_name: "QUESTION",
long: "--question",
short: "-q",
help: "The prompt to ask the AI",
required: true
],
auto_approve_after: [
value_name: "SECONDS",
long: "--auto-approve-after",
short: "-A",
help:
"After notification, auto-APPROVE in SECONDS if no input. Default: auto-DENY after 180 seconds.",
parser: :integer,
required: false
],
auto_deny_after: [
value_name: "SECONDS",
long: "--auto-deny-after",
short: "-D",
help:
"After notification, auto-DENY in SECONDS if no input. Default: auto-DENY after 180 seconds.",
parser: :integer,
required: false
],
rounds: [
value_name: "ROUNDS",
long: "--rounds",
short: "-R",
help:
"The number of research rounds to perform. Additional rounds generally result in more thorough research.",
parser: :integer,
default: @default_rounds,
required: false
],
worktree: [
value_name: "WORKTREE",
long: "--worktree",
short: "-W",
help: "Override project source root for this run",
parser: :string,
required: false
],
follow: [
value_name: "UUID",
long: "--follow",
short: "-f",
help: "Continue an existing conversation by UUID",
required: false
],
fork: [
value_name: "UUID",
long: "--fork",
short: "-F",
help: "Fork (branch) an existing conversation by UUID",
required: false
]
],
flags: [
replay: [
long: "--replay",
short: "-r",
help: "Replay a conversation (with --follow)",
default: false
],
edit: [
long: "--edit",
short: "-e",
help: "Permit the AI to edit files in the project",
default: false
],
yes: [
long: "--yes",
short: "-y",
help: "Automatically approve edit/manage prompts (requires --edit)",
default: false,
multiple: true
]
]
]
]
end
@impl Cmd
def run(opts, _subcommands, _unknown) do
unless UI.quiet?() do
UI.info("fnord version", Util.get_running_version())
end
opts =
if opts[:edit] do
UI.warning_banner("EDITING MODE ENABLED! THE AI CAN MODIFY FILES. YOU MUST BE NUTS.")
# They obviously already know if they are using --worktree
unless opts[:worktree] do
UI.warn("You can work from a git worktree with --worktree <dir>.")
end
opts
else
Map.put(opts, :edit, false)
end
# Handle --yes auto-approval flag
if opts[:yes] == true or (is_integer(opts[:yes]) and opts[:yes] > 0) do
if opts[:edit] do
UI.warning_banner("AUTO-CONFIRMATION ENABLED FOR CODE EDIT PROMPTS.")
UI.warning_banner("ALL YOU'VE *REALLY* AUTO-CONFIRMED IS THAT YOU ARE INDEED NUTS.")
else
UI.warn("--yes has no effect unless you also pass --edit; ignoring")
end
end
# Handle --auto-approve-after warning
unless is_nil(opts[:auto_approve_after]) do
UI.warning_banner("APPROVALS WILL BE GRANTED AFTER #{opts[:auto_approve_after]} SECONDS.")
UI.warning_banner("MAY YOUR FUTURE SELF FORGIVE YOU FOR THIS DECISION.")
UI.warning_banner("...AND MAY THE ON-CALL HAVE MERCY ON YOUR SOUL.")
end
start_time = System.monotonic_time(:second)
# Start silent background indexers. This must happen BEFORE any project
# root override is applied, so that the indexers use the correct root.
file_indexer_pid = start_file_indexer()
conversation_indexer_pid = start_conversation_indexer()
try do
with {:ok, opts} <- validate(opts),
:ok <- set_auto_policy(opts),
:ok <- set_worktree(opts),
{:ok, opts} <- maybe_fork_conversation(opts),
{:ok, pid} <- Services.Conversation.start_link(opts[:follow]),
:ok <-
(
Services.Globals.put_env(:fnord, :current_conversation, pid)
:ok
),
:ok <- Memory.init(),
{:ok, usage, context, response} <- get_response(opts, pid),
{:ok, conversation_id} <- save_conversation(pid) do
end_time = System.monotonic_time(:second)
print_result(start_time, end_time, response, usage, context, conversation_id)
Clipboard.copy(conversation_id)
unless UI.quiet?() do
Notifier.notify("Fnord response ready", opts.question)
end
:ok
else
{:error, :testing} ->
:ok
{:error, :invalid_worktree} ->
UI.error("--worktree must be an existing directory")
{:error, :invalid_worktree}
{:error, :auto_approval_mutually_exclusive} ->
UI.error("--auto-approve-after and --auto-deny-after are mutually exclusive")
{:error, :auto_approval_mutually_exclusive}
{:error, :invalid_auto_approve_after} ->
UI.error("--auto-approve-after must be a positive integer")
{:error, :invalid_auto_approve_after}
{:error, :invalid_auto_deny_after} ->
UI.error("--auto-deny-after must be a positive integer")
{:error, :invalid_auto_deny_after}
{:error, :invalid_rounds} ->
UI.error("--rounds expects a positive integer")
{:error, :invalid_rounds}
{:error, :conversation_not_found} ->
UI.error("Conversation ID #{opts[:conversation]} not found")
{:error, :conversation_not_found}
{:error, other} ->
UI.error("An error occurred while generating the response:\n\n#{other}")
{:error, other}
end
after
# stop background indexers if still running
stop_file_indexer(file_indexer_pid)
stop_conversation_indexer(conversation_indexer_pid)
Services.BackupFile.offer_cleanup()
UI.spin(build_notes_spinner_label(), fn ->
Services.Notes.join()
{"Notes finalized", :ok}
end)
end
end
# ----------------------------------------------------------------------------
# Validation
# ----------------------------------------------------------------------------
defp validate(opts) do
with :ok <- validate_conversation(opts),
:ok <- validate_rounds(opts),
:ok <- validate_auto(opts),
:ok <- validate_worktree(opts) do
{:ok, opts}
end
end
defp validate_rounds(%{rounds: rounds}) when is_integer(rounds) and rounds > 0, do: :ok
defp validate_rounds(_opts), do: {:error, :invalid_rounds}
# Validate mutual exclusion and positivity of auto flags
@spec validate_auto(map) :: :ok | {:error, atom | binary}
def validate_auto(opts) do
case {opts[:auto_approve_after], opts[:auto_deny_after]} do
{a, d} when not is_nil(a) and not is_nil(d) ->
{:error, :auto_approval_mutually_exclusive}
{a, _} when not is_nil(a) and a <= 0 ->
{:error, :invalid_auto_approve_after}
{_, d} when not is_nil(d) and d <= 0 ->
{:error, :invalid_auto_deny_after}
_ ->
:ok
end
end
@spec validate_worktree(map) :: :ok | {:error, :invalid_worktree}
defp validate_worktree(%{worktree: dir}) when is_binary(dir) do
path = Path.expand(dir)
if File.dir?(path) do
:ok
else
{:error, :invalid_worktree}
end
end
defp validate_worktree(_), do: :ok
@spec validate_conversation(map) :: :ok | {:error, :conversation_not_found}
defp validate_conversation(%{follow: id}) when is_binary(id) do
id
|> Store.Project.Conversation.new()
|> Store.Project.Conversation.exists?()
|> case do
true -> :ok
false -> {:error, :conversation_not_found}
end
end
defp validate_conversation(_opts), do: :ok
# ----------------------------------------------------------------------------
# Services
# ----------------------------------------------------------------------------
defp start_file_indexer() do
case Services.BackgroundIndexer.start_link() do
{:ok, pid} -> pid
_ -> nil
end
end
defp start_conversation_indexer() do
case Services.ConversationIndexer.start_link() do
{:ok, pid} -> pid
_ -> nil
end
end
defp stop_file_indexer(pid) do
if is_pid(pid) && Process.alive?(pid) do
Services.BackgroundIndexer.stop(pid)
end
end
defp stop_conversation_indexer(pid) do
if is_pid(pid) && Process.alive?(pid) do
Services.ConversationIndexer.stop(pid)
end
end
# ----------------------------------------------------------------------------
# Worktree setting
# ----------------------------------------------------------------------------
# 1) explicit override via --worktree/-W
defp set_worktree(%{worktree: dir}) when is_binary(dir) do
Settings.set_project_root_override(dir)
UI.info("Project root overridden for session", dir)
end
# 2) no explicit override: detect a mismatched git worktree
defp set_worktree(opts) do
case Store.get_project() do
{:ok, project} ->
if opts[:project] do
UI.info("Skipping worktree detection due to explicit project flag")
:ok
else
if GitCli.is_worktree?() do
wt_root = GitCli.worktree_root()
if wt_root && wt_root != project.source_root do
if UI.is_tty?() do
msg = """
You are working on project "#{project.name}", which is rooted at:
#{project.source_root}
Your current directory is inside a git worktree at:
#{wt_root}
Would you like to set the project root to #{wt_root} for this run?
(equivalent to passing: --worktree #{wt_root})
"""
if UI.confirm(msg, false) do
Settings.set_project_root_override(wt_root)
UI.info("Project root overridden for session", wt_root)
end
else
UI.warn("""
Detected git worktree at #{wt_root} which differs from the configured project root:
#{project.source_root}
To operate from this worktree, re-run with:
fnord ask --edit --worktree #{wt_root} …
""")
end
end
end
:ok
end
_ ->
:ok
end
end
# -----------------------------------------------------------------------------
# Auto-approval policy
# -----------------------------------------------------------------------------
def set_auto_policy(%{auto_approve_after: seconds}) when is_integer(seconds) do
Settings.set_auto_policy({:approve, seconds * 1_000})
end
def set_auto_policy(%{auto_deny_after: seconds}) when is_integer(seconds) do
Settings.set_auto_policy({:deny, seconds * 1_000})
end
def set_auto_policy(_opts) do
# No explicit auto flags: apply universal default auto-deny policy
Settings.set_auto_policy(@default_auto_policy)
end
# -----------------------------------------------------------------------------
# Forking a conversation
# -----------------------------------------------------------------------------
defp maybe_fork_conversation(%{fork: fork_id} = opts) when is_binary(fork_id) do
fork_conv = Store.Project.Conversation.new(fork_id)
if Store.Project.Conversation.exists?(fork_conv) do
with {:ok, new_conv} <- Store.Project.Conversation.fork(fork_conv) do
UI.info("Conversation #{fork_id} forked as #{new_conv.id}")
{:ok, Map.put(opts, :follow, new_conv.id)}
end
else
{:error, :conversation_not_found}
end
end
defp maybe_fork_conversation(opts), do: {:ok, opts}
# ----------------------------------------------------------------------------
# Agent response
# ----------------------------------------------------------------------------
@spec get_response(map, pid) ::
{:ok, non_neg_integer, non_neg_integer, binary}
| {:error, any}
defp get_response(opts, conversation_server) do
opts
|> get_agent_response(conversation_server)
|> case do
{:ok, %{usage: usage, context: context, last_response: res}} ->
{:ok, usage, context, res}
{:error, reason} ->
{:error, reason}
end
end
@spec get_agent_response(map, pid) :: {:ok, map} | {:error, any}
defp get_agent_response(opts, conversation_server) do
Services.Conversation.get_response(conversation_server,
edit: opts.edit,
rounds: opts.rounds,
question: opts.question,
replay: Map.get(opts, :replay, false),
yes: Map.get(opts, :yes, false),
fonz: Map.get(opts, :fonz, false)
)
end
# ----------------------------------------------------------------------------
# Output
# ----------------------------------------------------------------------------
defp print_result(start_time, end_time, response, usage, context, conversation_id) do
time_taken = end_time - start_time
duration = Util.Duration.format(time_taken)
pct_context_used =
if usage > 0 and context > 0 do
Float.round(usage / context * 100, 2)
else
0.0
end
usage_str = Util.format_number(usage)
context_str = Util.format_number(context)
{:ok, project} = Store.get_project()
%{new: new, stale: stale, deleted: deleted} = Store.Project.index_status(project)
UI.say("""
#{response}
-----
### Response Summary:
- Response generated in #{duration}
- Tokens used: #{usage_str} | #{pct_context_used}% of context window (#{context_str})
- Conversation saved with ID #{conversation_id} (_copied to clipboard_)
### Project Search Index Status:
- Stale: #{Enum.count(stale)}
- New: #{Enum.count(new)}
- Deleted: #{Enum.count(deleted)}
_Run `fnord index` to update the index._
""")
UI.flush()
end
defp save_conversation(pid) do
with {:ok, conversation} <- Services.Conversation.save(pid) do
UI.debug("Conversation saved to file", conversation.store_path)
UI.report_step("Conversation saved", conversation.id)
{:ok, conversation.id}
end
end
defp build_notes_spinner_label() do
base =
if AI.Notes.has_new_facts?() do
"Consolidating notes..."
else
"Finalizing notes..."
end
if Services.Notes.pending?() do
count = Services.Notes.pending_count()
suffix =
if count > 0 do
" (" <> Integer.to_string(count) <> " remaining)"
else
""
end
base <> suffix
else
base
end
end
end