Packages
fnord
0.9.36
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/external_configs/injector.ex
defmodule ExternalConfigs.Injector do
@moduledoc """
Runtime injection of Cursor auto-attached rules triggered by a file path
that the model has read or written.
This is the moving part that mirrors Cursor's own "auto-attach" semantics:
when a file is added to the chat context, any rule whose globs match that
file should be in context too. In fnord we interpret "added to context" as
"read by file_contents_tool" or "written by file_edit_tool".
Each rule-path pair fires at most once per session (gated via
`Services.Once`). Injected messages are system-role and are stripped from
the persisted conversation, just like the other coordinator scaffolding.
"""
alias ExternalConfigs.CursorRule
@doc """
Consider injecting auto-attached rules matching the given file path. Safe
to call in any context; returns `:ok` silently when no project is
selected, external-configs are disabled, Once is not running, or no rule
matches.
"""
@spec maybe_inject_for_path(String.t()) :: :ok
def maybe_inject_for_path(file_path) when is_binary(file_path) do
with true <- once_running?(),
{:ok, pid} <- active_conversation_pid(),
{:ok, project} <- Store.get_project(),
true <- Settings.ExternalConfigs.enabled?(project.name, :cursor_rules) do
rel = relative_path(project, file_path)
matches =
project
|> ExternalConfigs.Loader.load_cursor_rules()
|> Enum.filter(&(&1.mode == :auto_attached))
|> Enum.filter(&CursorRule.matches_path?(&1, rel))
debug_log_checked(rel, matches)
Enum.each(matches, fn rule -> inject_once(rule, rel, pid) end)
:ok
else
_ -> :ok
end
end
defp inject_once(%CursorRule{} = rule, rel, pid) do
# Key composition: rule.path scopes the gate per rule file so a
# different rule with the same name doesn't re-use a prior rule's
# slot; rel scopes it per triggering file so each distinct matched
# path earns its own injection. That gives the LLM a fresh (rule,
# file) pair every time it encounters a new file. The rule body is
# the same, but the cited path binds the rule to the file that just
# entered context.
key = {:external_configs_cursor_rule_auto_attached, rule.path, rel}
result =
Services.Once.run(key, fn ->
msg =
rule
|> ExternalConfigs.Catalog.render_auto_attached_rule(rel)
|> AI.Util.system_msg()
Services.Conversation.append_msg(msg, pid)
end)
case result do
:ignore -> debug_log_skipped(rule, rel)
_ -> debug_log_injected(rule, rel)
end
result
end
defp active_conversation_pid() do
case Services.Globals.get_env(:fnord, :current_conversation, nil) do
pid when is_pid(pid) -> {:ok, pid}
_ -> :error
end
end
# Load-bearing guard: file tools are exercised by tests that don't boot
# the full Services supervision tree, so Services.Once may not be
# running in the caller's process tree. Calling Services.Once.run/2 in
# that state would crash the tool call. Early-exit keeps the injector a
# no-op outside live sessions.
defp once_running?() do
Services.Once.running?()
end
defp relative_path(%Store.Project{source_root: nil}, path), do: path
defp relative_path(%Store.Project{source_root: root}, path) do
path
|> Path.expand(root)
|> Path.relative_to(root)
end
# ----------------------------------------------------------------------------
# Debug logging (gated by FNORD_DEBUG_CURSOR_RULES)
# ----------------------------------------------------------------------------
defp debug_log_checked(rel, matches) do
if Util.Env.cursor_rules_debug_enabled?() do
case matches do
[] ->
UI.debug(
"cursor_rules",
format_kv("no auto-attached rules matched", file: rel)
)
_ ->
UI.debug(
"cursor_rules",
format_kv("auto-attached rules matched file",
file: rel,
count: length(matches),
rules: Enum.map(matches, & &1.name)
)
)
end
end
end
defp debug_log_injected(%CursorRule{} = rule, rel) do
if Util.Env.cursor_rules_debug_enabled?() do
UI.debug(
"cursor_rules",
format_kv("injected auto-attached rule",
rule: rule.name,
file: rel,
globs: Enum.join(rule.globs, ", ")
)
)
end
end
defp debug_log_skipped(%CursorRule{} = rule, rel) do
if Util.Env.cursor_rules_debug_enabled?() do
UI.debug(
"cursor_rules",
format_kv("skipped auto-attached rule (already injected this session)",
rule: rule.name,
file: rel
)
)
end
end
# Render a keyword list as a multi-line markdown list. List-valued entries
# nest a second level of bullets for readability.
defp format_kv(header, pairs) do
lines =
Enum.map(pairs, fn
{k, values} when is_list(values) ->
nested = Enum.map_join(values, "\n", fn v -> " - #{v}" end)
"- #{k}:\n#{nested}"
{k, v} ->
"- #{k}: #{v}"
end)
Enum.join([header | lines], "\n")
end
end