brainiac 0.0.2 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile.lock +2 -2
- data/README.md +2 -2
- data/bin/brainiac +317 -0
- data/bin/brainiac-completion.bash +178 -0
- data/brainiac.gemspec +0 -4
- data/lib/brainiac/agents.rb +34 -0
- data/lib/brainiac/brain.rb +25 -0
- data/lib/brainiac/config.rb +4 -0
- data/lib/brainiac/cron.rb +9 -6
- data/lib/brainiac/handlers/discord.rb +289 -30
- data/lib/brainiac/handlers/fizzy.rb +129 -86
- data/lib/brainiac/helpers.rb +125 -41
- data/lib/brainiac/prompts.rb +97 -105
- data/lib/brainiac/version.rb +1 -1
- data/receiver.rb +13 -0
- data/templates/cli-providers/grok.json.example +15 -0
- data/templates/cli-providers/kiro.json.example +15 -0
- data/templates/roles/code-reviewer.md.example +28 -0
- data/templates/roles/general-engineer.md.example +36 -0
- data/templates/roles/test-engineer.md.example +33 -0
- metadata +8 -30
- checksums.yaml.gz.sig +0 -0
- data/certs/stowzilla.pem +0 -26
- data.tar.gz.sig +0 -0
- metadata.gz.sig +0 -3
data/lib/brainiac/cron.rb
CHANGED
|
@@ -542,7 +542,9 @@ def execute_cron_job(job)
|
|
|
542
542
|
FileUtils.mkdir_p(File.dirname(log_file))
|
|
543
543
|
|
|
544
544
|
prompt_file = write_cron_prompt_file(job, prompt_data[:full_prompt], timestamp)
|
|
545
|
-
|
|
545
|
+
resolved = resolve_project_cli_config(project, agent_name: agent_name)
|
|
546
|
+
cmd = build_cron_agent_cmd(job, project, prompt_file: prompt_file)
|
|
547
|
+
prompt_mode = resolved["prompt_mode"] || "stdin"
|
|
546
548
|
|
|
547
549
|
LOG.info "[Cron] Dispatching job #{job[:id]} with #{agent_name}, tail -f #{log_file}"
|
|
548
550
|
|
|
@@ -551,7 +553,7 @@ def execute_cron_job(job)
|
|
|
551
553
|
|
|
552
554
|
pid = spawn(spawn_env, *cmd,
|
|
553
555
|
chdir: project["repo_path"],
|
|
554
|
-
in: prompt_file,
|
|
556
|
+
**(prompt_mode == "stdin" ? { in: prompt_file } : {}),
|
|
555
557
|
out: [log_file, "w"],
|
|
556
558
|
err: %i[child out])
|
|
557
559
|
|
|
@@ -573,15 +575,16 @@ def write_cron_prompt_file(job, prompt_content, timestamp)
|
|
|
573
575
|
end
|
|
574
576
|
|
|
575
577
|
# Build the CLI command array for a cron agent invocation.
|
|
576
|
-
def build_cron_agent_cmd(job, project)
|
|
578
|
+
def build_cron_agent_cmd(job, project, prompt_file: nil)
|
|
577
579
|
agent_config_name = job[:agent].downcase.gsub(/[^a-z0-9-]/, "-")
|
|
578
|
-
resolved = resolve_project_cli_config(project)
|
|
580
|
+
resolved = resolve_project_cli_config(project, agent_name: job[:agent])
|
|
581
|
+
agent_flag = resolved.key?("agent_flag") ? resolved["agent_flag"] : "--agent"
|
|
579
582
|
cmd = [resolved["agent_cli"]]
|
|
580
|
-
cmd.push(
|
|
583
|
+
cmd.push(agent_flag, agent_config_name) if agent_flag
|
|
581
584
|
cmd.concat(resolved["agent_cli_args"].split)
|
|
582
|
-
add_trust_tools!(cmd, resolved["agent_cli_args"])
|
|
583
585
|
cmd.push(resolved["agent_model_flag"], job[:model]) if resolved["agent_model_flag"]&.length&.positive? && job[:model]
|
|
584
586
|
cmd.push(resolved["agent_effort_flag"], job[:effort]) if resolved["agent_effort_flag"]&.length&.positive? && job[:effort]
|
|
587
|
+
cmd.push(resolved["prompt_flag"], prompt_file) if prompt_file && resolved["prompt_mode"] == "flag" && resolved["prompt_flag"]
|
|
585
588
|
cmd
|
|
586
589
|
end
|
|
587
590
|
|
|
@@ -32,6 +32,24 @@ DISCORD_ALL_READY_LOGGED = { done: false }
|
|
|
32
32
|
DISCORD_SHARED_THREADS = {}
|
|
33
33
|
DISCORD_SHARED_THREADS_MUTEX = Mutex.new
|
|
34
34
|
|
|
35
|
+
# Discord thread worktree map: tracks worktrees created for Discord thread conversations.
|
|
36
|
+
# Keyed by "agent_key:channel_id" → { worktree, branch, project, created_at }
|
|
37
|
+
# Persisted to disk so sessions survive restarts.
|
|
38
|
+
DISCORD_THREAD_MAP_FILE = File.join(BRAINIAC_DIR, "discord_thread_map.json")
|
|
39
|
+
DISCORD_THREAD_MAP_MUTEX = Mutex.new
|
|
40
|
+
|
|
41
|
+
def load_discord_thread_map
|
|
42
|
+
return {} unless File.exist?(DISCORD_THREAD_MAP_FILE)
|
|
43
|
+
|
|
44
|
+
JSON.parse(File.read(DISCORD_THREAD_MAP_FILE))
|
|
45
|
+
rescue JSON::ParserError
|
|
46
|
+
{}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def save_discord_thread_map(map)
|
|
50
|
+
File.write(DISCORD_THREAD_MAP_FILE, JSON.pretty_generate(map))
|
|
51
|
+
end
|
|
52
|
+
|
|
35
53
|
# Brainiac restart queue: when an agent works on brainiac itself, queue a restart
|
|
36
54
|
# instead of doing it immediately. A background thread checks every 30s and only
|
|
37
55
|
# restarts when no other agents are running, preventing mid-session kills.
|
|
@@ -721,6 +739,17 @@ def handle_discord_message(message, agent_key, bot_token, bot_user_id)
|
|
|
721
739
|
# Strip effort tag (e.g. [effort:high]) from prompt content
|
|
722
740
|
clean_content_for_prompt = clean_content_for_prompt.sub(/\[effort:\w+\]/i, "").strip
|
|
723
741
|
|
|
742
|
+
# Strip CLI provider tag (e.g. [cli:grok]) from prompt content
|
|
743
|
+
clean_content_for_prompt = clean_content_for_prompt.sub(/\[cli:\w+\]/i, "").strip
|
|
744
|
+
|
|
745
|
+
# Detect chat mode tag: [chat], [question], [?] — skips worktree, uses tmp dir instead
|
|
746
|
+
chat_mode = clean_content.match?(/\[(chat|question|\?)\]/i)
|
|
747
|
+
if chat_mode
|
|
748
|
+
clean_content = clean_content.sub(/\[(chat|question|\?)\]/i, "").strip
|
|
749
|
+
clean_content_for_prompt = clean_content_for_prompt.sub(/\[(chat|question|\?)\]/i, "").strip
|
|
750
|
+
LOG.info "[Discord:#{agent_name}] Chat mode detected — will skip worktree creation"
|
|
751
|
+
end
|
|
752
|
+
|
|
724
753
|
# Find project: inline override > channel mapping > default_project
|
|
725
754
|
if inline_project_key && PROJECTS.key?(inline_project_key)
|
|
726
755
|
project_key = inline_project_key
|
|
@@ -816,14 +845,37 @@ def handle_discord_message(message, agent_key, bot_token, bot_user_id)
|
|
|
816
845
|
# Also captures root message content so the agent always has the original context.
|
|
817
846
|
root_message = find_root_message(message, channel_id, bot_token)
|
|
818
847
|
root_message_id = root_message[:id]
|
|
819
|
-
|
|
848
|
+
|
|
849
|
+
# Build a stable card_id for memory files. Inside a thread, use parent_channel + thread_id
|
|
850
|
+
# so all messages in the thread share one memory file. The thread's channel_id IS the thread
|
|
851
|
+
# starter message ID, which is stable across all messages within it.
|
|
852
|
+
card_id = if is_thread
|
|
853
|
+
"discord-#{parent_channel_id}-#{channel_id}"
|
|
854
|
+
else
|
|
855
|
+
"discord-#{channel_id}-#{root_message_id}"
|
|
856
|
+
end
|
|
820
857
|
|
|
821
858
|
# Build thread root context — inject the original question/message that started
|
|
822
859
|
# this thread so the agent never loses sight of it, even in long conversations.
|
|
823
860
|
thread_root_context = ""
|
|
824
|
-
if is_thread
|
|
825
|
-
|
|
826
|
-
|
|
861
|
+
if is_thread
|
|
862
|
+
root_content = root_message[:content]
|
|
863
|
+
root_author = root_message[:author]
|
|
864
|
+
|
|
865
|
+
# Fallback: if find_root_message didn't capture content (no message_reference chain),
|
|
866
|
+
# fetch the parent message directly. In Discord, a thread's channel_id equals the
|
|
867
|
+
# message_id that the thread was created on in the parent channel.
|
|
868
|
+
if root_content.nil? || root_content.empty?
|
|
869
|
+
parent_msg = fetch_discord_message(parent_channel_id, channel_id, token: bot_token, log_errors: false)
|
|
870
|
+
if parent_msg && parent_msg["content"] && !parent_msg["content"].strip.empty?
|
|
871
|
+
root_content = parent_msg["content"].strip
|
|
872
|
+
root_author = parent_msg.dig("author", "username") || "unknown"
|
|
873
|
+
end
|
|
874
|
+
end
|
|
875
|
+
|
|
876
|
+
if root_content && !root_content.empty?
|
|
877
|
+
thread_root_context = "### Original Message (thread starter)\n#{root_author || "unknown"}: #{root_content}\n\n"
|
|
878
|
+
end
|
|
827
879
|
end
|
|
828
880
|
|
|
829
881
|
# Detect planning mode
|
|
@@ -836,7 +888,165 @@ def handle_discord_message(message, agent_key, bot_token, bot_user_id)
|
|
|
836
888
|
|
|
837
889
|
brain_context = build_brain_context(agent_name: agent_name, card_title: clean_content, comment_body: clean_content)
|
|
838
890
|
|
|
839
|
-
|
|
891
|
+
# --- Worktree management & session resume ---
|
|
892
|
+
# Every dispatch with a project gets a dedicated worktree per agent+thread/channel.
|
|
893
|
+
# For channel messages, create a Discord thread upfront so we have a stable ID
|
|
894
|
+
# for the worktree, then dispatch in it. This ensures grok sessions persist
|
|
895
|
+
# across the thread conversation and can be resumed with -c.
|
|
896
|
+
should_resume = false
|
|
897
|
+
thread_worktree_path = nil
|
|
898
|
+
thread_cli_provider = nil
|
|
899
|
+
thread_model = nil
|
|
900
|
+
thread_effort = nil
|
|
901
|
+
|
|
902
|
+
# For channel messages (not DMs, not already in a thread), create a thread immediately
|
|
903
|
+
# so we can use its ID for the worktree. This also means the response delivery
|
|
904
|
+
# will find the pre-existing thread via the API lookup.
|
|
905
|
+
pre_created_thread_id = nil
|
|
906
|
+
if !is_thread && !is_dm && project_config
|
|
907
|
+
display_name = fizzy_display_name(agent_key)
|
|
908
|
+
thread = create_discord_thread(channel_id, message_id, name: "#{display_name}: #{clean_content[0..80]}", token: bot_token)
|
|
909
|
+
if thread && thread["id"]
|
|
910
|
+
pre_created_thread_id = thread["id"]
|
|
911
|
+
DISCORD_SHARED_THREADS_MUTEX.synchronize { DISCORD_SHARED_THREADS[message_id] = pre_created_thread_id }
|
|
912
|
+
|
|
913
|
+
# Propagate dispatch depth to the new thread
|
|
914
|
+
parent_depth_key = "discord-#{channel_id}"
|
|
915
|
+
thread_depth_key = "discord-#{pre_created_thread_id}"
|
|
916
|
+
parent_info = AGENT_DISPATCH_DEPTH[parent_depth_key]
|
|
917
|
+
if parent_info
|
|
918
|
+
AGENT_DISPATCH_DEPTH[thread_depth_key] = { count: 0, last_human_at: parent_info[:last_human_at] }
|
|
919
|
+
else
|
|
920
|
+
record_human_comment(thread_depth_key)
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
LOG.info "[Discord:#{agent_name}] Pre-created thread #{pre_created_thread_id} for worktree isolation"
|
|
924
|
+
else
|
|
925
|
+
LOG.warn "[Discord:#{agent_name}] Failed to pre-create thread — will run without worktree isolation"
|
|
926
|
+
end
|
|
927
|
+
end
|
|
928
|
+
|
|
929
|
+
# Determine the thread map key: use the thread ID (existing thread or pre-created one)
|
|
930
|
+
effective_thread_id = is_thread ? channel_id : pre_created_thread_id
|
|
931
|
+
thread_map_key = "#{agent_key}:#{effective_thread_id}" if effective_thread_id
|
|
932
|
+
|
|
933
|
+
if project_config && thread_map_key
|
|
934
|
+
repo_path = project_config["repo_path"]
|
|
935
|
+
thread_map = DISCORD_THREAD_MAP_MUTEX.synchronize { load_discord_thread_map }
|
|
936
|
+
existing = thread_map[thread_map_key]
|
|
937
|
+
|
|
938
|
+
if existing && existing["chat_mode"]
|
|
939
|
+
# Thread was started in chat mode — reuse the tmp dir, don't create a worktree
|
|
940
|
+
thread_worktree_path = existing["worktree"]
|
|
941
|
+
thread_cli_provider = existing["cli_provider"]
|
|
942
|
+
thread_model = existing["model"]
|
|
943
|
+
thread_effort = existing["effort"]
|
|
944
|
+
chat_mode = true
|
|
945
|
+
effective_provider = detect_cli_provider(text: clean_content) || thread_cli_provider
|
|
946
|
+
resolved_for_resume = resolve_project_cli_config(project_config, cli_provider_override: effective_provider, agent_name: agent_name)
|
|
947
|
+
should_resume = resolved_for_resume["resume_flag"] ? true : false
|
|
948
|
+
LOG.info "[Discord:#{agent_name}] Reusing chat mode tmp dir at #{thread_worktree_path} (resume: #{should_resume})"
|
|
949
|
+
elsif chat_mode
|
|
950
|
+
# New chat mode session — skip worktree, tmp dir will be created below
|
|
951
|
+
LOG.info "[Discord:#{agent_name}] Chat mode — skipping worktree creation"
|
|
952
|
+
elsif existing && existing["worktree"] && File.directory?(existing["worktree"])
|
|
953
|
+
# Existing worktree — resume session. Inherit tags from the thread's first dispatch.
|
|
954
|
+
thread_worktree_path = existing["worktree"]
|
|
955
|
+
thread_cli_provider = existing["cli_provider"]
|
|
956
|
+
thread_model = existing["model"]
|
|
957
|
+
thread_effort = existing["effort"]
|
|
958
|
+
effective_provider = detect_cli_provider(text: clean_content) || thread_cli_provider
|
|
959
|
+
resolved_for_resume = resolve_project_cli_config(project_config, cli_provider_override: effective_provider, agent_name: agent_name)
|
|
960
|
+
should_resume = resolved_for_resume["resume_flag"] ? true : false
|
|
961
|
+
LOG.info "[Discord:#{agent_name}] Reusing thread worktree at #{thread_worktree_path} (resume: #{should_resume}, cli: #{effective_provider || "default"})"
|
|
962
|
+
else
|
|
963
|
+
# First worktree creation. Inherit tags from a seeded thread map entry if present.
|
|
964
|
+
seeded_cli_provider = existing&.dig("cli_provider")
|
|
965
|
+
seeded_model = existing&.dig("model")
|
|
966
|
+
seeded_effort = existing&.dig("effort")
|
|
967
|
+
|
|
968
|
+
thread_slug = effective_thread_id[-8..]
|
|
969
|
+
branch = "discord-#{agent_key}-#{thread_slug}"
|
|
970
|
+
thread_worktree_path = File.join(File.dirname(repo_path), "#{File.basename(repo_path)}--#{branch}")
|
|
971
|
+
|
|
972
|
+
debounced_repo_fetch(repo_path)
|
|
973
|
+
default_branch = get_default_branch(repo_path)
|
|
974
|
+
|
|
975
|
+
branch_exists = system("git", "rev-parse", "--verify", branch, chdir: repo_path, out: File::NULL, err: File::NULL)
|
|
976
|
+
|
|
977
|
+
if branch_exists
|
|
978
|
+
worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path)
|
|
979
|
+
has_worktree = worktree_list.lines.any? { |l| l.strip == "worktree #{thread_worktree_path}" }
|
|
980
|
+
if has_worktree && File.directory?(thread_worktree_path)
|
|
981
|
+
LOG.info "[Discord:#{agent_name}] Reusing existing worktree at #{thread_worktree_path}"
|
|
982
|
+
else
|
|
983
|
+
run_cmd("git", "worktree", "add", thread_worktree_path, branch, chdir: repo_path)
|
|
984
|
+
end
|
|
985
|
+
else
|
|
986
|
+
run_cmd("git", "worktree", "add", "-b", branch, thread_worktree_path, "origin/#{default_branch}", chdir: repo_path)
|
|
987
|
+
end
|
|
988
|
+
|
|
989
|
+
trust_version_manager(thread_worktree_path, chdir: thread_worktree_path)
|
|
990
|
+
apply_worktree_includes(repo_path, thread_worktree_path)
|
|
991
|
+
run_project_hook(repo_path, "worktree-setup", extra_env: { "WORKTREE_PATH" => thread_worktree_path })
|
|
992
|
+
|
|
993
|
+
# Store tags: prefer current message tag, then seeded value from initial dispatch
|
|
994
|
+
first_cli_provider = detect_cli_provider(text: clean_content) || seeded_cli_provider
|
|
995
|
+
first_model = (project_config ? detect_model(project_config, text: clean_content) : nil) || seeded_model
|
|
996
|
+
first_effort = (project_config ? detect_effort(project_config, text: clean_content) : nil) || seeded_effort
|
|
997
|
+
thread_cli_provider = first_cli_provider
|
|
998
|
+
thread_model = first_model
|
|
999
|
+
thread_effort = first_effort
|
|
1000
|
+
|
|
1001
|
+
DISCORD_THREAD_MAP_MUTEX.synchronize do
|
|
1002
|
+
map = load_discord_thread_map
|
|
1003
|
+
map[thread_map_key] = { "worktree" => thread_worktree_path, "branch" => branch,
|
|
1004
|
+
"project" => PROJECTS.find { |_k, v| v == project_config }&.first,
|
|
1005
|
+
"channel_id" => effective_thread_id, "cli_provider" => first_cli_provider,
|
|
1006
|
+
"model" => first_model, "effort" => first_effort,
|
|
1007
|
+
"created_at" => Time.now.iso8601 }
|
|
1008
|
+
save_discord_thread_map(map)
|
|
1009
|
+
end
|
|
1010
|
+
LOG.info "[Discord:#{agent_name}] Created thread worktree at #{thread_worktree_path}#{" (cli: #{first_cli_provider})" if first_cli_provider}#{" (model: #{first_model})" if first_model}#{" (effort: #{first_effort})" if first_effort}"
|
|
1011
|
+
end
|
|
1012
|
+
end
|
|
1013
|
+
|
|
1014
|
+
# Chat mode: create a tmp directory instead of a worktree for lightweight conversational sessions
|
|
1015
|
+
if chat_mode && !thread_worktree_path && thread_map_key
|
|
1016
|
+
chat_tmp_dir = File.join(BRAINIAC_DIR, "tmp", "chat", "#{agent_key}-#{effective_thread_id}")
|
|
1017
|
+
FileUtils.mkdir_p(chat_tmp_dir)
|
|
1018
|
+
thread_worktree_path = chat_tmp_dir
|
|
1019
|
+
|
|
1020
|
+
first_cli_provider = detect_cli_provider(text: clean_content)
|
|
1021
|
+
first_model = project_config ? detect_model(project_config, text: clean_content) : nil
|
|
1022
|
+
first_effort = project_config ? detect_effort(project_config, text: clean_content) : nil
|
|
1023
|
+
thread_cli_provider = first_cli_provider
|
|
1024
|
+
thread_model = first_model
|
|
1025
|
+
thread_effort = first_effort
|
|
1026
|
+
|
|
1027
|
+
DISCORD_THREAD_MAP_MUTEX.synchronize do
|
|
1028
|
+
map = load_discord_thread_map
|
|
1029
|
+
map[thread_map_key] = { "worktree" => chat_tmp_dir, "chat_mode" => true,
|
|
1030
|
+
"project" => PROJECTS.find { |_k, v| v == project_config }&.first,
|
|
1031
|
+
"channel_id" => effective_thread_id, "cli_provider" => first_cli_provider,
|
|
1032
|
+
"model" => first_model, "effort" => first_effort,
|
|
1033
|
+
"created_at" => Time.now.iso8601 }
|
|
1034
|
+
save_discord_thread_map(map)
|
|
1035
|
+
end
|
|
1036
|
+
LOG.info "[Discord:#{agent_name}] Created chat mode tmp dir at #{chat_tmp_dir}"
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
if should_resume && thread_worktree_path
|
|
1040
|
+
# Lean resume prompt — prior session has full context
|
|
1041
|
+
prompt = render_discord_resume_prompt(
|
|
1042
|
+
message_body: clean_content_for_prompt,
|
|
1043
|
+
discord_user: discord_user,
|
|
1044
|
+
response_file: response_file,
|
|
1045
|
+
agent_name: agent_name,
|
|
1046
|
+
card_id: card_id
|
|
1047
|
+
)
|
|
1048
|
+
LOG.info "[Discord:#{agent_name}] Using resume prompt for thread #{channel_id}"
|
|
1049
|
+
elsif planning_info
|
|
840
1050
|
# Planning mode — use planning prompt
|
|
841
1051
|
planning_card_id = planning_info[:card_id]
|
|
842
1052
|
LOG.info "[Discord:#{agent_name}] Planning mode detected for #{discord_user}"
|
|
@@ -875,11 +1085,55 @@ def handle_discord_message(message, agent_key, bot_token, bot_user_id)
|
|
|
875
1085
|
channel: :discord)
|
|
876
1086
|
end
|
|
877
1087
|
|
|
878
|
-
|
|
1088
|
+
# Chat mode fallback: if no thread_map_key was available (no thread created, e.g. DM or no project),
|
|
1089
|
+
# still use a tmp dir so we don't fall back to the project repo_path.
|
|
1090
|
+
if chat_mode && !thread_worktree_path
|
|
1091
|
+
chat_tmp_dir = File.join(BRAINIAC_DIR, "tmp", "chat", "#{agent_key}-#{message_id}")
|
|
1092
|
+
FileUtils.mkdir_p(chat_tmp_dir)
|
|
1093
|
+
thread_worktree_path = chat_tmp_dir
|
|
1094
|
+
LOG.info "[Discord:#{agent_name}] Chat mode fallback tmp dir at #{chat_tmp_dir}"
|
|
1095
|
+
end
|
|
1096
|
+
|
|
1097
|
+
work_dir = thread_worktree_path || (project_config ? project_config["repo_path"] : Dir.pwd)
|
|
879
1098
|
|
|
880
1099
|
prompt_file = File.join(response_dir, "discord-prompt-#{timestamp}-#{agent_key}-#{message_id}.md")
|
|
881
1100
|
File.write(prompt_file, prompt)
|
|
882
1101
|
|
|
1102
|
+
# Detect overrides from inline tags — [opus], [effort:high], [cli:grok]
|
|
1103
|
+
# Current message tags override thread defaults; thread defaults apply when no tag is present.
|
|
1104
|
+
cli_provider_override = detect_cli_provider(text: clean_content) || thread_cli_provider
|
|
1105
|
+
|
|
1106
|
+
# For model/effort: detect_model/detect_effort return the project default when no tag matches,
|
|
1107
|
+
# so we check for explicit tag presence to know whether the thread default should apply.
|
|
1108
|
+
has_explicit_model = false
|
|
1109
|
+
if project_config
|
|
1110
|
+
allowed_models = resolve_project_cli_config(project_config)["allowed_models"] || {}
|
|
1111
|
+
model_tag_match = clean_content.match(/\[(\w+)\]/i)
|
|
1112
|
+
has_explicit_model = model_tag_match && allowed_models.key?(model_tag_match[1].downcase)
|
|
1113
|
+
end
|
|
1114
|
+
has_explicit_effort = clean_content.match?(/\[effort:\w+\]/i)
|
|
1115
|
+
|
|
1116
|
+
model = if has_explicit_model
|
|
1117
|
+
detect_model(project_config, text: clean_content)
|
|
1118
|
+
elsif thread_model
|
|
1119
|
+
thread_model
|
|
1120
|
+
else
|
|
1121
|
+
project_config ? detect_model(project_config, text: clean_content) : nil
|
|
1122
|
+
end
|
|
1123
|
+
|
|
1124
|
+
effort = if has_explicit_effort
|
|
1125
|
+
detect_effort(project_config, text: clean_content)
|
|
1126
|
+
elsif thread_effort
|
|
1127
|
+
thread_effort
|
|
1128
|
+
else
|
|
1129
|
+
project_config ? detect_effort(project_config, text: clean_content) : nil
|
|
1130
|
+
end
|
|
1131
|
+
|
|
1132
|
+
# Determine the "explicit" values from this message only (not thread defaults)
|
|
1133
|
+
# for seeding into the thread map on initial dispatch.
|
|
1134
|
+
explicit_model = has_explicit_model ? model : nil
|
|
1135
|
+
explicit_effort = has_explicit_effort ? effort : nil
|
|
1136
|
+
|
|
883
1137
|
# Write delivery metadata sidecar so the poller can post this response
|
|
884
1138
|
# even if the monitoring thread dies (e.g. server restart).
|
|
885
1139
|
meta_file = File.join(DISCORD_DRAFT_DIR, "#{response_basename}.meta.json")
|
|
@@ -891,32 +1145,35 @@ def handle_discord_message(message, agent_key, bot_token, bot_user_id)
|
|
|
891
1145
|
is_dm: is_dm,
|
|
892
1146
|
is_thread: is_thread,
|
|
893
1147
|
clean_content: clean_content[0..80],
|
|
1148
|
+
cli_provider: detect_cli_provider(text: clean_content),
|
|
1149
|
+
model: explicit_model,
|
|
1150
|
+
effort: explicit_effort,
|
|
894
1151
|
created_at: Time.now.iso8601
|
|
895
1152
|
}))
|
|
896
1153
|
|
|
897
|
-
# Detect model override — same [opus]/[sonnet]/[haiku] syntax as Fizzy comments
|
|
898
|
-
model = project_config ? detect_model(project_config, text: clean_content) : nil
|
|
899
|
-
|
|
900
|
-
# Detect effort override — [effort:high] syntax
|
|
901
|
-
effort = project_config ? detect_effort(project_config, text: clean_content) : nil
|
|
902
|
-
|
|
903
1154
|
agent_config_name = agent_key.downcase.gsub(/[^a-z0-9-]/, "-")
|
|
904
1155
|
log_file = File.join(response_dir, "discord-agent-#{timestamp}-#{agent_key}-#{message_id}.log")
|
|
905
1156
|
|
|
906
|
-
resolved = project_config ? resolve_project_cli_config(project_config) : {}
|
|
1157
|
+
resolved = project_config ? resolve_project_cli_config(project_config, cli_provider_override: cli_provider_override, agent_name: agent_name) : {}
|
|
907
1158
|
agent_cli = resolved["agent_cli"] || "kiro-cli"
|
|
908
1159
|
agent_cli_args = resolved["agent_cli_args"] || "chat --trust-all-tools --no-interactive"
|
|
909
1160
|
agent_model_flag = resolved["agent_model_flag"] || "--model"
|
|
910
1161
|
agent_effort_flag = resolved["agent_effort_flag"] || "--effort"
|
|
1162
|
+
agent_flag = resolved.key?("agent_flag") ? resolved["agent_flag"] : "--agent"
|
|
1163
|
+
prompt_mode = resolved["prompt_mode"] || "stdin"
|
|
911
1164
|
|
|
912
1165
|
cmd = [agent_cli]
|
|
913
|
-
cmd.push(
|
|
1166
|
+
cmd.push(agent_flag, agent_config_name) if agent_flag
|
|
914
1167
|
cmd.concat(agent_cli_args.split)
|
|
915
|
-
|
|
916
|
-
|
|
1168
|
+
if model && agent_model_flag && !agent_model_flag.empty?
|
|
1169
|
+
allowed = resolved["allowed_models"] || {}
|
|
1170
|
+
cmd.push(agent_model_flag, model) if allowed.value?(model) || allowed.key?(model)
|
|
1171
|
+
end
|
|
917
1172
|
cmd.push(agent_effort_flag, effort) if agent_effort_flag && !agent_effort_flag.empty? && effort
|
|
1173
|
+
cmd.push(resolved["resume_flag"]) if should_resume && resolved["resume_flag"]
|
|
1174
|
+
cmd.push(resolved["prompt_flag"], prompt_file) if prompt_mode == "flag" && resolved["prompt_flag"]
|
|
918
1175
|
|
|
919
|
-
LOG.info "[Discord:#{agent_name}] Dispatching for #{discord_user} (model: #{model || "default"}, effort: #{effort || "default"}), tail -f #{log_file}"
|
|
1176
|
+
LOG.info "[Discord:#{agent_name}] Dispatching for #{discord_user} (model: #{model || "default"}, effort: #{effort || "default"}, cli: #{agent_cli}#{", resuming" if should_resume}), tail -f #{log_file}"
|
|
920
1177
|
LOG.info "[Discord:#{agent_name}] Command: #{cmd.join(" ")}"
|
|
921
1178
|
|
|
922
1179
|
spawn_env = {}
|
|
@@ -926,19 +1183,17 @@ def handle_discord_message(message, agent_key, bot_token, bot_user_id)
|
|
|
926
1183
|
LOG.info "[Discord:#{agent_name}] Injecting #{agent_env.size} env var(s): #{agent_env.keys.join(", ")}"
|
|
927
1184
|
end
|
|
928
1185
|
|
|
929
|
-
# Capture HEAD before spawning so we can detect if THIS session made
|
|
1186
|
+
# Capture HEAD and dirty state before spawning so we can detect if THIS session made changes
|
|
930
1187
|
head_before = nil
|
|
1188
|
+
status_before = nil
|
|
931
1189
|
if project_config
|
|
932
1190
|
pk = PROJECTS.find { |_k, v| v == project_config }&.first
|
|
933
|
-
if pk == "brainiac"
|
|
934
|
-
head_before, = Open3.capture2("git", "rev-parse", "HEAD", chdir: work_dir)
|
|
935
|
-
head_before = head_before.strip
|
|
936
|
-
end
|
|
1191
|
+
head_before, status_before = capture_git_state(work_dir) if pk == "brainiac"
|
|
937
1192
|
end
|
|
938
1193
|
|
|
939
1194
|
pid = spawn(spawn_env, *cmd,
|
|
940
1195
|
chdir: work_dir,
|
|
941
|
-
in: prompt_file,
|
|
1196
|
+
**(prompt_mode == "stdin" ? { in: prompt_file } : {}),
|
|
942
1197
|
out: [log_file, "w"],
|
|
943
1198
|
err: %i[child out])
|
|
944
1199
|
|
|
@@ -1043,14 +1298,12 @@ def handle_discord_message(message, agent_key, bot_token, bot_user_id)
|
|
|
1043
1298
|
brain_push(message: "#{agent_name}: discord-#{message_id}")
|
|
1044
1299
|
|
|
1045
1300
|
# Restart brainiac if THIS session actually changed code
|
|
1046
|
-
# Compare HEAD now vs before
|
|
1301
|
+
# Compare HEAD and dirty state now vs before — only restart if the agent made commits or new dirty files
|
|
1047
1302
|
if project_config && head_before
|
|
1048
1303
|
project_key = PROJECTS.find { |_k, v| v == project_config }&.first
|
|
1049
1304
|
if project_key == "brainiac"
|
|
1050
|
-
|
|
1051
|
-
head_after
|
|
1052
|
-
git_status, = Open3.capture2("git", "status", "--porcelain", chdir: chdir)
|
|
1053
|
-
if head_after.strip != head_before || !git_status.strip.empty?
|
|
1305
|
+
head_after, status_after = capture_git_state(project_config["repo_path"])
|
|
1306
|
+
if head_after != head_before || status_after != status_before
|
|
1054
1307
|
queue_brainiac_restart(agent_name)
|
|
1055
1308
|
else
|
|
1056
1309
|
LOG.info "[Brainiac] #{agent_name} Discord session on brainiac had no changes — skipping restart"
|
|
@@ -1624,10 +1877,16 @@ def start_all_discord_gateways
|
|
|
1624
1877
|
end
|
|
1625
1878
|
|
|
1626
1879
|
LOG.info "[Discord] Starting #{tokens.size} bot(s): #{tokens.keys.join(", ")}"
|
|
1627
|
-
|
|
1628
|
-
|
|
1880
|
+
|
|
1881
|
+
# Register all bots upfront so the "all ready" check sees the full set
|
|
1882
|
+
# before any individual READY event fires.
|
|
1883
|
+
DISCORD_BOTS_MUTEX.synchronize do
|
|
1884
|
+
tokens.each do |agent_key, token|
|
|
1629
1885
|
DISCORD_BOTS[agent_key] = { token: token, status: "starting", user_id: nil }
|
|
1630
1886
|
end
|
|
1887
|
+
end
|
|
1888
|
+
|
|
1889
|
+
tokens.each do |agent_key, token|
|
|
1631
1890
|
start_discord_gateway_for(agent_key, token)
|
|
1632
1891
|
sleep 1 # Stagger connections to avoid rate limits
|
|
1633
1892
|
end
|