Packages
fnord
0.9.31
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/ai/notes.ex
defmodule AI.Notes do
@moduledoc """
Coordinates the mini-agents that manage project research notes. The workflow for this is:
1. Initialize the notes with `init/1`, which loads existing notes from disk.
2. Consolidate new notes from the prior research session.
This incorporates the new facts in the `NEW NOTES` section into the main body of the notes file.
3. As user prompts arrive, ingest user messages with `ingest_user_msg/2`, which updates the user traits based on the user's messages.
4. As tool calls are made, ingest research results with `ingest_research/4`, which extracts facts from the tool call results.
5. Commit the newly gathered facts to disk with `commit/1`, which appends the new facts to the existing notes.
The new facts are stored in a special `NEW NOTES` section that is recognized by the consolidation agent.
These will be incorporated into the main notes body at the beginning of the next session.
The reason for this inverted workflow is because the consolidation process takes much longer than any of the other steps.
By performing it at the outset of a session, we reduce the impact of that unfortunate, abeit necessary, time delay on the user experience.
"""
defstruct [
:user,
:new_facts
]
@type t :: %__MODULE__{
user: binary,
new_facts: list(binary)
}
@typep mini_agent :: %{
model: AI.Model.t(),
prompt: binary
}
@attempts 2
# Time (in ms) to allow for the consolidation task to complete: 10 minutes
@consolidation_task_timeout 1000 * 60 * 10
# ----------------------------------------------------------------------------
# Mini Agent defs
# ----------------------------------------------------------------------------
@user %{
model: AI.Model.fast(),
prompt: """
You are a highly empathetic AI assistant that gleans knowledge about the user from the tone and content of their messages.
Your role is to attempt to deduce the user's coding preferences, learning style, personality, and other relevant traits based on their interactions.
Respond with a formatted markdown list of user traits without any additional text.
Additionally, note if you identify that a prior learning about the user was incorrect or is no longer valid.
Each item should mention that is is a trait of the user, e.g.: "User is experienced with Elixir"
If nothing was identified, respond with "N/A" on a single line.
"""
}
@research %{
model: AI.Model.fast(),
prompt: """
You are a research assistant that extracts facts about the project from tool call results.
Extract **non-transient** facts about the project from the tool call result (e.g. about overall design and architecture, NOT speculative exploration of designs for future changes).
If the tool call is a notification (notify_tool) and its message includes explicit memory memos (e.g., lines starting with "note to self:" or "remember:"), extract those memos verbatim as new facts. Treat them as high-priority, non-transient notes unless they clearly refer to ephemeral states (e.g., temporary delays, transient errors).
Normalize prefixes to a single dash list item.
Transient facts are those that are not persistently relevant, such as current repo state, individual tickets or projects, PRs, or changes.
You are concerned with the overall project architecture, design, and implementation details that are relevant to understanding the project as a whole.
Focus on the most important details that would help someone understand the project quickly and effectively.
Topics of interest include (but are not limited to):
- Project purpose and goals
- Languages, frameworks, and technologies used
- Coding, style, and testing conventions
- Repo/app layout and organization
- Applications and components, their locations, and dependencies
- Any other notes about how the code behaves, integrates, etc.
- Details about individual features, components, modules, tests, etc.
- Gotchas and pitfalls to avoid
- "Always check X before doing Y" type of advice
- "The user prefers Z" type of advice
- Useful commands and command sequences for common tasks (when using the `cmd_tool`)
- Useful prompts for common tasks when using an LLM-backed tool call (e.g. the `research_tool` or `file_info_tool`)
- Useful prompting patterns observed on successful calls to the `coder_tool` or `file_edit_tool` (e.g. "When using the coder_tool to insert a new function, always tell it exactly *where* to put it using anchors like 'above the function foo' or 'immediately below the function bar'")
- Prompting patterns that FAIL to produce good results with the `coder_tool` or `file_edit_tool` (e.g. "When using the coder_tool to insert code, never say 'at the top of the file' because that confuses the model; instead, always use referential anchors like 'above the function foo' or 'immediately below the function bar'")
**Focus on concrete, verifiable facts that are unlikely to change frequently.**
Respond with a formatted markdown list of facts without any additional text.
If nothing was identified, respond with "N/A" on a single line.
Just the facts, ma'am!
"""
}
@ask %{
model: AI.Model.fast(),
prompt: """
You are a research assistant that answers questions about past research done on the project.
Your role is to provide concise, accurate answers based on the existing project notes.
When asked a question, extract all relevant information from the existing notes and organize it effectively based on your understanding of the requestor's needs.
Branch-aware retrieval rules:
- The current Git branch will be provided in the context.
- Include code facts tagged with [src:<current_branch>] or untagged; ignore code facts tagged for other branches.
- Do not filter or exclude USER facts based on branch tags.
"""
}
@consolidate %{
model: AI.Model.large_context(:smart),
prompt: """
You are a research assistant that consolidates and organizes project notes.
Your role is to incorporate newly extracted facts into existing project notes, ensuring that all information is accurate, up-to-date, and well-organized.
DO NOT lose ANY prior facts that were not disproven by the new notes.
Respond with the updated notes in markdown format, without any additional text, wrapping code fences, or explanations.
Newly organized facts will appear at the end of the existing notes in the document you are provided.
There may be multiple `# NEW NOTES (unconsolidated)` sections, each containing a list of new facts.
These should be consolidated into the existing notes.
The `# NEW NOTES (unconsolidated)` section(s) are removed after being incorporated into the document proper.
The purpose of these notes:
- Inform future sessions, reducing the likelihood that the researching LLM gets tripped up by unexpected details or quirks of the project.
- Improve your baseline understanding of the project, reducing the need to re-research common topics.
Goals:
- **Overall Goal:** Manage a well-organized, comprehensive, archive of research notes about the project.
- Dismiss ephemeral facts that (e.g. about individual tickets, PRs, or changes) that are not persistently relevant.
- Conflicting facts should be resolved by keeping the most recent information.
- Ensure the notes are organized by topic and concise.
- Combine nearly identical facts into a single entry.
- Collect important details that are scattered across multiple facts into single, comprehensive entries.
- Focus on concrete, verifiable facts that are unlikely to change frequently.
- Of particular interest are "gotchas" and "pitfalls to avoid" type of advice.
These are the types of details that are most likely to trip up future sessions.
- Reorganize as needed to improve clarity and flow.
- **DISCARD TRANSIENT INFORMATION:**
- Examples: Temporary states, tickets, PRs, in-progress work, bugs (unless still present), migrations, and other ephemeral details).
- This can't be emphasized enough!
- Leaving such details in the notes risks leaking those transient details into new sessions, causing hallucination and a bad user experience.
- **Provenance Rules:**
- Facts may include a suffix like "[src: main]" or "[src: feature/foo]". Preserve these tags.
- If identical fact text appears with multiple [src: ...] tags, collapse to one; prefer [src: main] if present; else aggregate tags like [src: a,b].
- If fact content differs across branches, keep separate entries with their own tags.
- Branch scoping applies only to code-base facts; do not branch-filter the USER section or global guidance.
- Group identical-content facts and collect all [src: ...] tags.
- If any tag is 'main', emit [src: main].
- Otherwise, emit a single consolidated fact with a sorted, deduplicated [src: a,b,...].
- If content differs across branches, keep separate facts.
- Ensure idempotence and canonical formatting of tag lists.
Response Template:
# SYNOPSIS
[Summary of project purpose]
# USER
[Bullet list of knowledge about the user, preferences, and relevant traits]
# LANGUAGES AND TECHNOLOGIES
[Bullet list of languages, frameworks, and technologies used in the project]
# CONVENTIONS
[Bullet list of coding conventions, style pointers, test conventions followed in the project]
# LAYOUT
[Repo/app layout, interaction, organization]
# APPLICATIONS
[
- Common details:
- How do apps interact?
- Conventions for organization within apps
- Deployment or runtime details
- Shared services or libraries
- How are they shared?
- For each discrete app in the project:
- App name
- Brief description of purpose
- Location in the repo
- Dependencies on other apps or services
- Exported interfaces
- Internal interfaces from other apps consumed
- Differences in conventions or organization from other apps
- Integration quirks (e.g. "uses DB but is NOT the source of truth for the schema; that is $other_app")
- Deployment or runtime details that differ from other apps
- For each component within the app:
- Component name
- Brief description of purpose
- Location in the repo
- Dependencies on other components, apps, or services
]
# FEATURES & WORKFLOWS
[
- Cover:
- Technical and background workflows (e.g. cron jobs, data pipelines, build/deploy processes), not user workflows
- User-facing features (e.g. "user can reset password via email link")
- For each major feature/workflow:
- brief description
- location
- key modules
- terminology
- behaviorial walkthrough
- relevant details
]
# USE CASES
[For each major use case: purpose, pre-conditions, steps, post-conditions, entry-point modules]
# USEFUL COMMANDS
[
- Project-specific commands and command sequences for common tasks that the LLM frequently performs while researching or coding
- Project-specific scripts and internal tools, their purpose, and useful invocations
]
# USEFUL PROMPTS AND TOOL CALL GUIDANCE
[
- Useful prompts for common tasks when using an LLM-backed tool call (e.g. the `research_tool` or `file_info_tool`)
- Useful prompting patterns observed on successful calls to the `coder_tool` or `file_edit_tool`
- Prompting patterns that FAIL to produce good results with the `coder_tool` or `file_edit_tool`
]
# NOTES
[Organized by topic: subheading per topic, then a list of facts]
"""
}
# ----------------------------------------------------------------------------
# Public API
# ----------------------------------------------------------------------------
@spec new() :: t
def new() do
%__MODULE__{
user: "",
new_facts: []
}
end
@spec init(t) :: t
def init(state) do
notes = load_notes()
user = extract_user_section(notes)
%{state | user: user, new_facts: []}
end
@spec commit(t) :: {:ok, t} | {:error, any}
def commit(%{new_facts: []} = state) do
# Nothing to persist. Avoid rewriting notes.md (and avoid appending an
# empty `# NEW NOTES (unconsolidated)` section) when no facts have
# accumulated since the last commit.
{:ok, state}
end
def commit(state) do
# Get the most recent copy of the notes, just in case another session was
# running in parallel and modified them on disk.
notes = load_notes()
# Format the new facts into a markdown list
facts = format_new_notes(state)
# If there is already a `# NEW NOTES (unconsolidated)` section, append to it.
notes =
if Regex.match?(~r/(?mi)^# NEW NOTES \(unconsolidated\)$/, notes) do
"""
#{notes}
#{facts}
"""
else
"""
#{notes}
# NEW NOTES (unconsolidated)
#{facts}
"""
end
Store.Project.Notes.write(notes)
|> case do
:ok ->
{:ok, %{state | new_facts: []}}
{:error, reason} ->
UI.error("Error saving new notes", reason)
{:error, reason}
end
end
@spec ingest_user_msg(t, binary) :: t
def ingest_user_msg(state, msg_text) do
"""
Background on the user (from your previous notes):
#{state.user}
The user said:
> #{msg_text}
"""
|> complete(@user)
|> case do
{:ok, response} ->
response
|> String.trim()
|> String.downcase()
|> case do
"n/a" -> state
facts -> %{state | new_facts: [facts | state.new_facts]}
end
otherwise ->
log_failure(otherwise, "Failed to ingest user message")
state
end
end
@spec ingest_research(t, binary, binary, any) :: t
def ingest_research(state, func, args_json, result) do
# Prepare input for AI and detect any memos from notify_tool
input = """
The following tool call was made:
**Function:** #{func}
**Arguments:**
```json
#{args_json}
```
The result of the tool call was:
#{inspect(result, pretty: true)}
"""
extra_facts =
case func do
"notify_tool" ->
with {:ok, decoded} <- SafeJson.decode(args_json),
%{"message" => msg} <- decoded do
msg
|> String.split(["\n", "\r\n"], trim: true)
|> Enum.filter(fn line ->
l = String.downcase(String.trim_leading(line))
String.starts_with?(l, "note to self:") or String.starts_with?(l, "remember:")
end)
|> Enum.map(fn line ->
line
|> String.replace(~r/^\s*[-*]\s*/, "")
|> String.trim()
end)
|> Enum.map(&("- " <> &1))
|> Enum.join("\n")
else
_ -> nil
end
_ ->
nil
end
complete(input, @research)
|> case do
{:ok, response} ->
# Merge AI facts and any extra_facts
new_state =
case String.downcase(String.trim(response)) do
"n/a" ->
state
facts ->
branch = GitCli.current_branch()
tagged =
if branch do
tag_code_facts_with_branch(facts, branch)
else
facts
end
%{state | new_facts: [tagged | state.new_facts]}
end
new_state =
case extra_facts do
nil -> new_state
"" -> new_state
ef -> %{new_state | new_facts: [ef | new_state.new_facts]}
end
new_state
otherwise ->
log_failure(otherwise, "Failed to ingest research")
state
end
end
@spec consolidate(t, non_neg_integer) ::
{:ok, t}
| {:error, binary}
| {:error, :lock_failed}
| {:callback_error, Exception.t()}
def consolidate(state, attempt \\ 1) do
with_lock(fn ->
fresh = load_notes() |> collapse_unconsolidated_sections()
"""
Please reorganize and consolidate the following project notes according to the specified guidelines.
-----
#{fresh}
"""
|> accumulate(@consolidate)
|> case do
{:ok, response} ->
response
|> clean_notes_string()
|> case do
{:error, :empty_string} ->
{:error, "Notes Consolidation Agent returned an empty string"}
{:ok, notes} ->
notes
|> Store.Project.Notes.write()
|> case do
:ok -> {:ok, %{state | new_facts: []}}
otherwise -> otherwise
end
end
otherwise ->
if attempt < @attempts do
consolidate(state, attempt + 1)
else
log_failure(otherwise, "Failed to consolidate notes after #{@attempts} attempts")
otherwise
end
end
end)
end
@spec ask(t, binary, non_neg_integer) :: binary
def ask(state, question, attempt \\ 1) do
with_lock(fn ->
fresh = load_notes() |> collapse_unconsolidated_sections()
"""
The following are the existing research notes about the project.
#{fresh}
# New facts:
The following are the new facts that have been collected during the current session.
#{format_new_notes(state)}
# Question
Please answer the following question based on the existing notes and new facts:
#{question}
"""
|> complete(@ask)
|> case do
{:ok, response} ->
response
|> String.trim()
|> case do
"" -> "No relevant information found."
answer -> answer
end
otherwise ->
if attempt < @attempts do
ask(state, question, attempt + 1)
else
log_failure(otherwise, "Failed to answer question after #{@attempts} attempts")
"Error processing request."
end
end
end)
|> case do
ans when is_binary(ans) -> ans
{:error, :lock_failed} -> "Notes unavailable due to lock contention. Please retry."
{:callback_error, _} -> "Error processing request."
{:error, _} -> "Error processing request."
_ -> "Error processing request."
end
end
@doc """
Returns `true` if the given notes struct's text contains the section header
"# new notes (unconsolidated)" (case-insensitive), indicating uncategorized
notes pending consolidation.
"""
@spec has_new_facts?() :: boolean
def has_new_facts?() do
case Store.Project.Notes.read() do
{:ok, notes} ->
notes
|> String.downcase()
|> String.contains?("# new notes (unconsolidated)")
{:error, :no_notes} ->
false
_ ->
false
end
end
@deprecated "use has_new_facts?/0"
@spec has_new_facts?(t) :: boolean
def has_new_facts?(_state), do: has_new_facts?()
# ----------------------------------------------------------------------------
# Utility Functions
# ----------------------------------------------------------------------------
@spec with_lock((-> any)) :: any
defp with_lock(func) do
with {:ok, path} <- Store.Project.Notes.file_path(),
{:ok, result} <- FileLock.with_lock(path, func) do
result
end
end
@spec load_notes() :: binary
defp load_notes() do
with {:ok, notes} <- Store.Project.Notes.read() do
notes
else
{:error, :no_notes} ->
""
otherwise ->
log_failure(otherwise, "Failed to load notes")
""
end
end
@spec clean_notes_string(binary) :: {:ok, binary} | {:error, :empty_string}
defp clean_notes_string(input) do
trimmed =
input
|> String.split("\n", trim: false)
|> Enum.map(&String.trim_trailing/1)
|> Enum.join("\n")
if Regex.match?(~r/[[:graph:]]/, trimmed) do
{:ok, trimmed}
else
{:error, :empty_string}
end
end
@spec extract_user_section(binary) :: binary
defp extract_user_section(""), do: ""
defp extract_user_section(text) do
text
|> String.split(~r/\n{2,}/)
|> Enum.drop_while(&(!String.starts_with?(&1, "# USER")))
|> case do
[section | _] -> section
[] -> ""
end
end
@spec format_new_notes(t) :: binary
defp format_new_notes(state) do
state.new_facts
|> Enum.reverse()
|> Enum.flat_map(&String.split(&1, "\n", trim: true))
|> Enum.map(fn fact ->
cond do
# list item w/o indentation
String.starts_with?(fact, "- ") -> fact
# list item with indentation
fact |> String.trim() |> String.starts_with?("- ") -> fact
# not a list item, so make it one
true -> "- #{fact}"
end
end)
|> Enum.join("\n")
end
@spec annotate_with_src(binary | nil, binary) :: binary
defp annotate_with_src(nil, line) do
line
end
defp annotate_with_src(branch, line) do
if Regex.match?(~r/\[src\s*:\s*[^\]]+\]\s*$/i, line) do
line
else
if String.starts_with?(branch, "@") do
line <> " [src: " <> branch <> ", main]"
else
line <> " [src: " <> branch <> "]"
end
end
end
@spec tag_code_facts_with_branch(binary, binary) :: binary
defp tag_code_facts_with_branch(text, branch) do
lines = String.split(text, "\n", trim: true)
annotated =
Enum.map(lines, fn line ->
trimmed = String.trim_leading(line)
if String.starts_with?(trimmed, ["- ", "* ", "• "]) do
annotate_with_src(branch, line)
else
line
end
end)
Enum.join(annotated, "\n")
end
@spec run_ai((-> any), non_neg_integer) :: any
defp run_ai(fun, timeout \\ 120_000) do
HttpPool.with_pool(:ai_notes, fn ->
fun
|> Task.async()
|> Task.await(timeout)
end)
end
@spec complete(binary, mini_agent) :: {:ok, binary} | {:error, any}
defp complete(input, agent) do
run_ai(fn ->
AI.Completion.get(
log_messages: false,
model: agent.model,
messages: [
AI.Util.system_msg(agent.prompt),
AI.Util.user_msg(input)
]
)
end)
|> case do
{:ok, %{response: response}} -> {:ok, response}
{:error, reason} -> {:error, reason}
end
end
@spec accumulate(binary, mini_agent) :: {:ok, binary} | {:error, any}
defp accumulate(input, agent) do
run_ai(
fn ->
AI.Accumulator.get_response(
model: agent.model,
prompt: agent.prompt,
input: input,
question:
"Please consolidate the following notes according to the specified guidelines."
)
end,
@consolidation_task_timeout
)
|> case do
{:ok, %{response: response}} -> {:ok, response}
{:error, reason} -> {:error, reason}
end
end
defp log_failure({:error, %{response: reason}}, of_what) do
UI.warn("#{of_what}: #{inspect(reason, pretty: true)}")
end
defp log_failure({:error, reason}, of_what) do
UI.warn("#{of_what}: #{inspect(reason, pretty: true)}")
end
def collapse_unconsolidated_sections(text) do
pattern = ~r/^# NEW NOTES \(unconsolidated\)\r?\n([\s\S]*?)(?=^# |\z)/mi
blocks =
Regex.scan(pattern, text)
|> Enum.map(fn [_full, content] -> content end)
items =
blocks
|> Enum.flat_map(fn block ->
block
|> String.split("\n", trim: false)
|> Enum.map(&String.trim/1)
|> Enum.filter(&(&1 != ""))
|> Enum.map(fn line ->
line
|> String.replace(~r/^\s*[-*]\s*/, "")
|> (&("- " <> &1)).()
end)
end)
|> Enum.reduce({[], MapSet.new()}, fn item, {acc, seen} ->
key = String.downcase(item)
if MapSet.member?(seen, key) do
{acc, seen}
else
{[item | acc], MapSet.put(seen, key)}
end
end)
|> elem(0)
|> Enum.reverse()
doc = Regex.replace(pattern, text, "")
if items == [] do
doc
else
# ensure exactly one blank line before the canonical block
doc_trim = Regex.replace(~r/\n+\z/, doc, "")
doc_trim <> "\n\n# NEW NOTES (unconsolidated)\n" <> Enum.join(items, "\n") <> "\n"
end
end
end