brainiac 0.0.27 → 0.0.29
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 +14 -3
- data/bin/brainiac +163 -12
- data/docs/resume.md +87 -0
- data/lib/brainiac/cron.rb +3 -1
- data/lib/brainiac/handlers/shared/belt.rb +409 -0
- data/lib/brainiac/handlers/shared/git.rb +52 -27
- data/lib/brainiac/helpers.rb +221 -32
- data/lib/brainiac/intent.rb +1 -1
- data/lib/brainiac/model_parser.rb +109 -0
- data/lib/brainiac/version.rb +1 -1
- data/monitor/waybar/session_slot.rb +1 -3
- data/receiver.rb +4 -0
- data/templates/cli-providers/codex.json.example +32 -0
- data/templates/cli-providers/grok.json.example +1 -0
- data/templates/cli-providers/kiro.json.example +1 -0
- metadata +5 -1
data/lib/brainiac/helpers.rb
CHANGED
|
@@ -18,7 +18,9 @@ def load_cli_provider(provider_name)
|
|
|
18
18
|
"agent_cli" => raw["binary"],
|
|
19
19
|
"agent_cli_args" => raw["default_args"],
|
|
20
20
|
"agent_model_flag" => raw["model_flag"],
|
|
21
|
+
"agent_model" => raw["agent_model"],
|
|
21
22
|
"agent_effort_flag" => raw["effort_flag"],
|
|
23
|
+
"agent_effort" => raw["agent_effort"],
|
|
22
24
|
"allowed_models" => raw["models"],
|
|
23
25
|
"allowed_efforts" => raw["efforts"]
|
|
24
26
|
}
|
|
@@ -28,10 +30,15 @@ def load_cli_provider(provider_name)
|
|
|
28
30
|
config["agent_flag"] = raw.key?("agent_flag") ? raw["agent_flag"] : "--agent"
|
|
29
31
|
# prompt_mode: "stdin" (default) or "flag" — how the prompt is delivered.
|
|
30
32
|
config["prompt_mode"] = raw["prompt_mode"] || "stdin"
|
|
31
|
-
|
|
32
|
-
#
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
# Copy optional fields from raw config when present.
|
|
34
|
+
# Each field controls a specific CLI behavior — see comments in the template.
|
|
35
|
+
%w[prompt_flag list_models_command resume_flag resume_args session_dir output_last_message_flag
|
|
36
|
+
cwd_flag config_override_flag effort_config_key effort_map].each do |key|
|
|
37
|
+
next unless raw[key]
|
|
38
|
+
next if raw[key].respond_to?(:empty?) && raw[key].empty?
|
|
39
|
+
|
|
40
|
+
config[key] = raw[key]
|
|
41
|
+
end
|
|
35
42
|
# Compact nil values except agent_flag (which uses nil to mean "don't pass agent name")
|
|
36
43
|
agent_flag_value = config["agent_flag"]
|
|
37
44
|
config.compact!
|
|
@@ -42,6 +49,30 @@ rescue JSON::ParserError => e
|
|
|
42
49
|
{}
|
|
43
50
|
end
|
|
44
51
|
|
|
52
|
+
# Run a CLI provider's list_models_command and return the parsed model list.
|
|
53
|
+
# Returns an array of model hashes on success, or nil on failure.
|
|
54
|
+
# Each model hash contains at least: "model_id" (or "slug"/"model_name"), and optionally "description", etc.
|
|
55
|
+
def list_models_for_provider(provider_name)
|
|
56
|
+
config = load_cli_provider(provider_name)
|
|
57
|
+
return nil if config.empty?
|
|
58
|
+
|
|
59
|
+
command = config["list_models_command"]
|
|
60
|
+
return nil unless command && !command.empty?
|
|
61
|
+
|
|
62
|
+
stdout, stderr, status = Open3.capture3(command)
|
|
63
|
+
unless status.success?
|
|
64
|
+
LOG.warn "list_models_command for '#{provider_name}' failed (exit #{status.exitstatus}): #{stderr.strip}"
|
|
65
|
+
return nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
parse_list_models_output(stdout)
|
|
69
|
+
rescue StandardError => e
|
|
70
|
+
LOG.warn "Failed to run list_models_command for '#{provider_name}': #{e.message}"
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
require_relative "model_parser"
|
|
75
|
+
|
|
45
76
|
# Resolve CLI config for a project by merging provider defaults with project overrides.
|
|
46
77
|
# Priority: cli_provider_override > agent-level cli_provider > project-level cli_provider > DEFAULT_PROJECT
|
|
47
78
|
def resolve_project_cli_config(project_config, cli_provider_override: nil, agent_name: nil)
|
|
@@ -435,17 +466,26 @@ end
|
|
|
435
466
|
# Check if a prior CLI session exists in the given directory for the specified CLI binary.
|
|
436
467
|
# This prevents resume attempts when the CLI provider changed (e.g., [cli:grok] in a thread
|
|
437
468
|
# started by kiro-cli) or when the session was started on a different machine.
|
|
438
|
-
|
|
469
|
+
# session_dir: optional centralized session directory (e.g. ~/.codex/sessions) for CLIs
|
|
470
|
+
# that store session state globally rather than per-project.
|
|
471
|
+
def prior_session_exists?(chdir, agent_cli, session_dir: nil)
|
|
439
472
|
return false unless chdir && agent_cli
|
|
440
473
|
|
|
441
474
|
cli_name = File.basename(agent_cli)
|
|
442
475
|
|
|
476
|
+
# Centralized session storage (e.g. Codex stores sessions in ~/.codex/sessions/).
|
|
477
|
+
# Search session files for ones that match the working directory (cwd field in session metadata).
|
|
478
|
+
if session_dir
|
|
479
|
+
expanded_session_dir = File.expand_path(session_dir)
|
|
480
|
+
return centralized_session_matches_cwd?(expanded_session_dir, chdir) if File.directory?(expanded_session_dir)
|
|
481
|
+
end
|
|
482
|
+
|
|
443
483
|
# Check for CLI-specific session markers:
|
|
444
484
|
# - grok uses .grok/ directory for session state
|
|
445
485
|
# - kiro-cli uses .kiro-cli/ or similar
|
|
446
486
|
# - Generic fallback: check tmp/ for agent logs from this CLI
|
|
447
|
-
|
|
448
|
-
return true if File.directory?(
|
|
487
|
+
session_dir_local = File.join(chdir, ".#{cli_name}")
|
|
488
|
+
return true if File.directory?(session_dir_local)
|
|
449
489
|
|
|
450
490
|
# Fallback: look for recent session logs in tmp/ that suggest this CLI ran here before.
|
|
451
491
|
# This covers CLIs that don't leave a dotdir but do leave logs via brainiac.
|
|
@@ -460,9 +500,60 @@ rescue StandardError
|
|
|
460
500
|
false
|
|
461
501
|
end
|
|
462
502
|
|
|
503
|
+
# Check if a centralized session directory has sessions matching the given cwd.
|
|
504
|
+
# Supports CLIs that store sessions as .jsonl files in date-partitioned directories (YYYY/MM/DD/)
|
|
505
|
+
# with a first-line JSON metadata object containing a "payload.cwd" field.
|
|
506
|
+
# Only considers sessions from the last 24 hours as resumable.
|
|
507
|
+
#
|
|
508
|
+
# Performance note: Dir.glob("**/*.jsonl") stats every file in the session directory before
|
|
509
|
+
# filtering by mtime. This is fine for typical usage (days/weeks of sessions) but could slow
|
|
510
|
+
# down if the directory accumulates months of files. The 24-hour mtime cutoff limits actual
|
|
511
|
+
# I/O (only recent files are read), but the glob itself still walks the full tree.
|
|
512
|
+
def centralized_session_matches_cwd?(session_base_dir, target_cwd)
|
|
513
|
+
# Only check recent session files (last 24 hours) to avoid scanning the full history
|
|
514
|
+
cutoff = Time.now - 86_400
|
|
515
|
+
target_cwd_resolved = begin
|
|
516
|
+
File.realpath(target_cwd)
|
|
517
|
+
rescue StandardError
|
|
518
|
+
target_cwd
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
Dir.glob(File.join(session_base_dir, "**", "*.jsonl")).any? do |session_file|
|
|
522
|
+
next unless File.mtime(session_file) > cutoff
|
|
523
|
+
|
|
524
|
+
# Read just the first line to get session_meta with cwd
|
|
525
|
+
first_line = begin
|
|
526
|
+
File.open(session_file, &:readline)
|
|
527
|
+
rescue StandardError
|
|
528
|
+
next
|
|
529
|
+
end
|
|
530
|
+
meta = begin
|
|
531
|
+
JSON.parse(first_line)
|
|
532
|
+
rescue StandardError
|
|
533
|
+
next
|
|
534
|
+
end
|
|
535
|
+
session_cwd = meta.dig("payload", "cwd")
|
|
536
|
+
next unless session_cwd
|
|
537
|
+
|
|
538
|
+
session_cwd_resolved = begin
|
|
539
|
+
File.realpath(session_cwd)
|
|
540
|
+
rescue StandardError
|
|
541
|
+
session_cwd
|
|
542
|
+
end
|
|
543
|
+
session_cwd_resolved == target_cwd_resolved
|
|
544
|
+
end
|
|
545
|
+
rescue StandardError
|
|
546
|
+
false
|
|
547
|
+
end
|
|
548
|
+
|
|
463
549
|
# Public helper: check if resume is viable for a given project + CLI provider combo.
|
|
464
|
-
# Plugins
|
|
465
|
-
#
|
|
550
|
+
# Plugins MUST use this method (not check resolved["resume_flag"] directly) to decide
|
|
551
|
+
# whether a session can be resumed. This handles both flag-based resume (e.g. kiro --resume,
|
|
552
|
+
# grok -c) and subcommand-based resume (e.g. codex exec resume --last).
|
|
553
|
+
#
|
|
554
|
+
# Call this BEFORE building the prompt to decide between:
|
|
555
|
+
# - render_resume_prompt (lean, for resumable sessions)
|
|
556
|
+
# - render_prompt with full context (for non-resumable sessions)
|
|
466
557
|
#
|
|
467
558
|
# Returns true if the CLI supports resume AND a prior session exists in the working directory.
|
|
468
559
|
# When this returns false, plugins should use render_prompt with thread history as card_context
|
|
@@ -470,17 +561,24 @@ end
|
|
|
470
561
|
def resume_viable?(project_config:, cli_provider: nil, agent_name: nil, chdir: nil)
|
|
471
562
|
resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider, agent_name: agent_name)
|
|
472
563
|
chdir ||= resolved["repo_path"]
|
|
473
|
-
return false unless resolved["resume_flag"]
|
|
564
|
+
return false unless resolved["resume_flag"] || resolved["resume_args"]
|
|
474
565
|
|
|
475
|
-
prior_session_exists?(chdir, resolved["agent_cli"])
|
|
566
|
+
prior_session_exists?(chdir, resolved["agent_cli"], session_dir: resolved["session_dir"])
|
|
476
567
|
end
|
|
477
568
|
|
|
478
569
|
# Determine whether a session resume should actually happen.
|
|
479
|
-
#
|
|
480
|
-
#
|
|
570
|
+
# Called by run_agent — plugins should NOT call this directly; pass `resume: true` to run_agent.
|
|
571
|
+
#
|
|
572
|
+
# Returns:
|
|
573
|
+
# - :resume_args — when the provider uses subcommand-based resume (build_agent_cmd replaces default_args)
|
|
574
|
+
# - String (the resume flag) — when the provider uses flag-based resume (appended to cmd)
|
|
575
|
+
# - false — when resume was not requested or not viable
|
|
481
576
|
def resolve_resume(resume, resolved, chdir)
|
|
482
|
-
return false unless resume && resolved["resume_flag"]
|
|
483
|
-
|
|
577
|
+
return false unless resume && (resolved["resume_flag"] || resolved["resume_args"])
|
|
578
|
+
if prior_session_exists?(chdir, resolved["agent_cli"], session_dir: resolved["session_dir"])
|
|
579
|
+
# Return :resume_args when the provider uses subcommand-based resume (e.g. Codex exec resume)
|
|
580
|
+
return resolved["resume_args"] ? :resume_args : resolved["resume_flag"]
|
|
581
|
+
end
|
|
484
582
|
|
|
485
583
|
LOG.info "[Dispatch] Resume requested but not viable for #{resolved["agent_cli"]} in #{chdir} — starting fresh session"
|
|
486
584
|
false
|
|
@@ -505,6 +603,10 @@ def intent_skip?(message, agent_name:, source: nil, channel: nil, context: nil)
|
|
|
505
603
|
false
|
|
506
604
|
end
|
|
507
605
|
|
|
606
|
+
# Dispatch an agent CLI process. Plugins call this with `resume: true` to request session
|
|
607
|
+
# continuation — the method internally resolves whether to use flag-based resume (appending
|
|
608
|
+
# e.g. --resume or -c) or subcommand-based resume (replacing default_args with resume_args).
|
|
609
|
+
# Plugins should NOT build their own resume logic; pass `resume: true` and let core handle it.
|
|
508
610
|
def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil, effort: nil, agent_name: nil, card_number: nil, comment_id: nil,
|
|
509
611
|
source: nil, source_context: {}, skip_column_move: false, cli_provider: nil, resume: false,
|
|
510
612
|
message: nil, channel: nil, context: nil, env: {})
|
|
@@ -528,14 +630,17 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
|
|
|
528
630
|
FileUtils.mkdir_p(File.dirname(log_file))
|
|
529
631
|
|
|
530
632
|
prompt_file = write_agent_prompt_file(prompt, log_name, timestamp)
|
|
531
|
-
|
|
633
|
+
output_file = prepare_output_file(resolved, log_name, timestamp)
|
|
634
|
+
|
|
635
|
+
cmd = build_agent_cmd(resolved, agent_config_name: agent_config_name, model: model, effort: effort,
|
|
636
|
+
prompt_file: prompt_file, resume: should_resume,
|
|
637
|
+
output_file: output_file, chdir: chdir)
|
|
532
638
|
prompt_mode = resolved["prompt_mode"] || "stdin"
|
|
533
639
|
|
|
534
640
|
spawn_env = agent_env_for(agent_name).merge(env)
|
|
535
641
|
|
|
536
642
|
LOG.info "Running #{resolved["agent_cli"]} in #{chdir}, logging to #{log_file}"
|
|
537
|
-
LOG.info "Prompt
|
|
538
|
-
LOG.info "Command: #{cmd.join(" ")}#{" (resuming session)" if should_resume}"
|
|
643
|
+
LOG.info "Prompt: #{prompt_file} | Output: #{output_file || "none"} | Command: #{cmd.join(" ")}#{" (resuming session)" if should_resume}"
|
|
539
644
|
LOG.info "Injecting #{spawn_env.size} env var(s) for agent #{agent_name}: #{spawn_env.keys.join(", ")}" unless spawn_env.empty?
|
|
540
645
|
|
|
541
646
|
project_key_for_restart = PROJECTS.find { |_k, v| v == project_config }&.first
|
|
@@ -555,6 +660,7 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
|
|
|
555
660
|
prompt_file: prompt_file, chdir: chdir, source: source,
|
|
556
661
|
source_context: source_context, project_config: project_config,
|
|
557
662
|
card_number: card_number, skip_column_move: skip_column_move,
|
|
663
|
+
output_file: output_file,
|
|
558
664
|
head_before: head_before, status_before: status_before,
|
|
559
665
|
project_key_for_restart: project_key_for_restart
|
|
560
666
|
)
|
|
@@ -575,32 +681,104 @@ def write_agent_prompt_file(prompt, log_name, timestamp)
|
|
|
575
681
|
prompt_file
|
|
576
682
|
end
|
|
577
683
|
|
|
684
|
+
# Generate output file path for structured output capture (--output-last-message).
|
|
685
|
+
# Returns nil if the provider doesn't support it.
|
|
686
|
+
def prepare_output_file(resolved, log_name, timestamp)
|
|
687
|
+
return nil unless resolved["output_last_message_flag"]
|
|
688
|
+
|
|
689
|
+
output_dir = File.join(BRAINIAC_DIR, "tmp", "output")
|
|
690
|
+
FileUtils.mkdir_p(output_dir)
|
|
691
|
+
File.join(output_dir, "agent-#{log_name}-#{timestamp}.md")
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
# Read the structured output file written by the agent CLI (--output-last-message).
|
|
695
|
+
# Returns the file content as a string, or nil if the file doesn't exist or is empty.
|
|
696
|
+
def read_output_file(output_file)
|
|
697
|
+
return nil unless output_file && File.exist?(output_file)
|
|
698
|
+
|
|
699
|
+
content = File.read(output_file).strip
|
|
700
|
+
if content.empty?
|
|
701
|
+
LOG.info "[Output] Output file exists but is empty: #{output_file}"
|
|
702
|
+
return nil
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
LOG.info "[Output] Captured structured output (#{content.bytesize} bytes) from #{output_file}"
|
|
706
|
+
content
|
|
707
|
+
rescue StandardError => e
|
|
708
|
+
LOG.warn "[Output] Failed to read output file #{output_file}: #{e.message}"
|
|
709
|
+
nil
|
|
710
|
+
end
|
|
711
|
+
|
|
578
712
|
# Build the CLI command array for an agent invocation.
|
|
579
713
|
# When prompt_file is provided and prompt_mode is "flag", appends the prompt as a CLI argument.
|
|
580
|
-
# When resume is
|
|
581
|
-
|
|
714
|
+
# When resume is truthy and the provider has a resume_flag, adds it to continue the last session.
|
|
715
|
+
# When resume is :resume_args, uses resume_args as the args instead of default_args (subcommand-based resume).
|
|
716
|
+
# When output_file is provided and the provider has output_last_message_flag, appends it.
|
|
717
|
+
# When chdir is provided and the provider has a cwd_flag, appends it so the CLI
|
|
718
|
+
# itself switches to the working directory (e.g. `codex -C /path/to/project`).
|
|
719
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
720
|
+
def build_agent_cmd(resolved, agent_config_name: nil, model: nil, effort: nil, prompt_file: nil, resume: false, output_file: nil, chdir: nil)
|
|
582
721
|
cmd = [resolved["agent_cli"]]
|
|
722
|
+
# cwd_flag: pass the working directory as a CLI argument (e.g. -C for Codex CLI).
|
|
723
|
+
# This is added early so it appears before subcommands/args (global option).
|
|
724
|
+
cmd.push(resolved["cwd_flag"], chdir) if resolved["cwd_flag"] && chdir
|
|
583
725
|
# agent_flag controls how the agent identity is passed. Defaults to "--agent".
|
|
584
726
|
# Provider configs can set it to a different flag or null to suppress entirely.
|
|
585
727
|
agent_flag = resolved.key?("agent_flag") ? resolved["agent_flag"] : "--agent"
|
|
586
728
|
cmd.push(agent_flag, agent_config_name) if agent_flag && agent_config_name
|
|
587
|
-
|
|
729
|
+
# When resuming via subcommand (resume_args), replace default_args entirely.
|
|
730
|
+
# e.g. "exec --full-auto" becomes "exec resume --last --full-auto"
|
|
731
|
+
args = resume == :resume_args && resolved["resume_args"] ? resolved["resume_args"] : resolved["agent_cli_args"]
|
|
732
|
+
cmd.concat(args.split)
|
|
588
733
|
# Only pass --model if the model is a valid ID for this provider.
|
|
589
734
|
# "auto" means "let the CLI choose" — skip passing it unless the provider explicitly maps it.
|
|
590
735
|
if model && resolved["agent_model_flag"] && !resolved["agent_model_flag"].empty?
|
|
591
736
|
allowed = resolved["allowed_models"] || {}
|
|
592
|
-
#
|
|
593
|
-
|
|
594
|
-
|
|
737
|
+
# If the model is a key in allowed_models, use the mapped value (e.g. "auto" -> "o4-mini")
|
|
738
|
+
# This handles cases where different projects use "auto" but each CLI provider maps it differently.
|
|
739
|
+
effective_model = allowed.key?(model) ? allowed[model] : model
|
|
740
|
+
is_known = allowed.value?(effective_model) || allowed.key?(effective_model)
|
|
741
|
+
cmd.push(resolved["agent_model_flag"], effective_model) if is_known
|
|
595
742
|
end
|
|
596
|
-
cmd
|
|
597
|
-
# Resume
|
|
598
|
-
cmd.push(
|
|
743
|
+
append_effort_to_cmd(cmd, effort, resolved) if effort
|
|
744
|
+
# Resume via flag (simple append, e.g. grok -c or kiro --resume) — only when not using resume_args
|
|
745
|
+
cmd.push(resume) if resume && resume != :resume_args && resume.is_a?(String)
|
|
599
746
|
# prompt_mode: "flag" passes the prompt file path via the configured prompt_flag (e.g. --prompt-file).
|
|
600
747
|
cmd.push(resolved["prompt_flag"], prompt_file) if prompt_file && resolved["prompt_mode"] == "flag" && resolved["prompt_flag"]
|
|
748
|
+
# output_last_message_flag: capture the agent's final message to a file (e.g. codex exec -o <path>).
|
|
749
|
+
cmd.push(resolved["output_last_message_flag"], output_file) if output_file && resolved["output_last_message_flag"]
|
|
601
750
|
cmd
|
|
602
751
|
end
|
|
752
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
753
|
+
|
|
754
|
+
# Map a Brainiac effort level through the provider's effort_map (if any).
|
|
755
|
+
# Returns the mapped level, or the original level if no mapping exists.
|
|
756
|
+
def map_effort_level(effort, resolved)
|
|
757
|
+
return nil unless effort
|
|
758
|
+
|
|
759
|
+
effort_map = resolved["effort_map"]
|
|
760
|
+
return effort unless effort_map
|
|
761
|
+
|
|
762
|
+
effort_map[effort] || effort
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
# Append effort flags to a command array based on provider config.
|
|
766
|
+
# Handles both dedicated effort flags (--effort high) and config overrides (-c 'key="value"').
|
|
767
|
+
def append_effort_to_cmd(cmd, effort, resolved)
|
|
768
|
+
return unless effort
|
|
769
|
+
|
|
770
|
+
mapped_effort = map_effort_level(effort, resolved)
|
|
771
|
+
return unless mapped_effort
|
|
772
|
+
|
|
773
|
+
if resolved["effort_config_key"]
|
|
774
|
+
flag = resolved["config_override_flag"] || "-c"
|
|
775
|
+
cmd.push(flag, "#{resolved["effort_config_key"]}=\"#{mapped_effort}\"")
|
|
776
|
+
elsif resolved["agent_effort_flag"] && !resolved["agent_effort_flag"].empty?
|
|
777
|
+
cmd.push(resolved["agent_effort_flag"], mapped_effort)
|
|
778
|
+
end
|
|
779
|
+
end
|
|
603
780
|
|
|
781
|
+
# Append --model flag if the model is valid for this provider.
|
|
604
782
|
def handle_agent_completion(**ctx)
|
|
605
783
|
agent_exit_status = $CHILD_STATUS.exitstatus
|
|
606
784
|
agent_signaled = $CHILD_STATUS.signaled?
|
|
@@ -614,6 +792,9 @@ def handle_agent_completion(**ctx)
|
|
|
614
792
|
)
|
|
615
793
|
end
|
|
616
794
|
|
|
795
|
+
# Read structured output if the provider wrote to an output file (--output-last-message).
|
|
796
|
+
output_content = read_output_file(ctx[:output_file])
|
|
797
|
+
|
|
617
798
|
# Emit lifecycle hook — plugins handle post-session actions (e.g., plugin moves card, appends footer)
|
|
618
799
|
Brainiac.emit(:agent_completed,
|
|
619
800
|
card_number: ctx[:card_number] || ctx[:source_context]&.dig(:card_number),
|
|
@@ -625,7 +806,12 @@ def handle_agent_completion(**ctx)
|
|
|
625
806
|
source_context: ctx[:source_context],
|
|
626
807
|
project_config: ctx[:project_config],
|
|
627
808
|
skip_column_move: ctx[:skip_column_move],
|
|
628
|
-
prompt_file: ctx[:prompt_file]
|
|
809
|
+
prompt_file: ctx[:prompt_file],
|
|
810
|
+
output_file: ctx[:output_file],
|
|
811
|
+
output_content: output_content)
|
|
812
|
+
|
|
813
|
+
# Clean up the output file after hook emission (content already captured above).
|
|
814
|
+
FileUtils.rm_f(ctx[:output_file]) if ctx[:output_file]
|
|
629
815
|
|
|
630
816
|
qmd_out, qmd_status = Open3.capture2e("qmd", "update")
|
|
631
817
|
if qmd_status.success?
|
|
@@ -656,8 +842,10 @@ def check_brainiac_restart(head_before, status_before, chdir, project_key_for_re
|
|
|
656
842
|
end
|
|
657
843
|
end
|
|
658
844
|
|
|
659
|
-
def detect_model(project_config, tags: [], text: "", cli_provider_override: nil)
|
|
660
|
-
|
|
845
|
+
def detect_model(project_config, tags: [], text: "", cli_provider_override: nil, agent_name: nil)
|
|
846
|
+
# If no explicit CLI provider override, check if the agent has one configured
|
|
847
|
+
effective_cli_provider = cli_provider_override || agent_cli_provider_for(agent_name)
|
|
848
|
+
resolved = resolve_project_cli_config(project_config, cli_provider_override: effective_cli_provider, agent_name: agent_name)
|
|
661
849
|
allowed_models = resolved["allowed_models"] || {}
|
|
662
850
|
return resolved["agent_model"] if allowed_models.empty?
|
|
663
851
|
|
|
@@ -678,8 +866,9 @@ end
|
|
|
678
866
|
# Returns the effort level string (e.g. "high") or nil.
|
|
679
867
|
# If the requested level isn't supported by the current model, returns the closest
|
|
680
868
|
# lower level from allowed_efforts.
|
|
681
|
-
def detect_effort(project_config, tags: [], text: "", cli_provider_override: nil)
|
|
682
|
-
|
|
869
|
+
def detect_effort(project_config, tags: [], text: "", cli_provider_override: nil, agent_name: nil)
|
|
870
|
+
effective_cli_provider = cli_provider_override || agent_cli_provider_for(agent_name)
|
|
871
|
+
resolved = resolve_project_cli_config(project_config, cli_provider_override: effective_cli_provider, agent_name: agent_name)
|
|
683
872
|
allowed = resolved["allowed_efforts"] || %w[low medium high xhigh max]
|
|
684
873
|
|
|
685
874
|
# Inline tag: [effort:high] — works in any channel
|
data/lib/brainiac/intent.rb
CHANGED
|
@@ -113,7 +113,7 @@ def validate_intent_model!(config)
|
|
|
113
113
|
end
|
|
114
114
|
|
|
115
115
|
true
|
|
116
|
-
rescue Errno::ECONNREFUSED
|
|
116
|
+
rescue Errno::ECONNREFUSED, Socket::ResolutionError, Errno::EADDRNOTAVAIL
|
|
117
117
|
raise "Ollama is not running at #{config["endpoint"]}. Start it with: ollama serve"
|
|
118
118
|
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
119
119
|
LOG.warn "[Intent] Could not validate model (Ollama timed out) — will check at first use"
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Standalone model output parsing — shared between bin/brainiac (CLI) and lib/brainiac/helpers.rb (server).
|
|
4
|
+
# No dependencies beyond Ruby stdlib (json).
|
|
5
|
+
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
# Parse the output of a list_models_command.
|
|
9
|
+
# Supports JSON output with a "models" array, or plain text lines with model names.
|
|
10
|
+
# Normalizes various field names (slug, model_name) to "model_id" for consistency.
|
|
11
|
+
def parse_list_models_output(output)
|
|
12
|
+
return nil if output.nil? || output.strip.empty?
|
|
13
|
+
|
|
14
|
+
# Try parsing the entire output as JSON first (handles well-formed JSON from any CLI)
|
|
15
|
+
begin
|
|
16
|
+
data = JSON.parse(output)
|
|
17
|
+
return normalize_model_list(data["models"]) if data.is_a?(Hash) && data["models"].is_a?(Array)
|
|
18
|
+
return normalize_model_list(data) if data.is_a?(Array)
|
|
19
|
+
rescue JSON::ParserError
|
|
20
|
+
# Not pure JSON — try to extract JSON from mixed output
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Some CLIs output non-JSON text before the JSON (like kiro-cli with --format json).
|
|
24
|
+
# Try parsing from any line that starts with "{" — handles both single-line JSON and
|
|
25
|
+
# pretty-printed JSON where "models" is on a subsequent line.
|
|
26
|
+
lines = output.lines
|
|
27
|
+
lines.each_with_index do |line, idx|
|
|
28
|
+
next unless line.strip.start_with?("{")
|
|
29
|
+
|
|
30
|
+
json_candidate = lines[idx..].join
|
|
31
|
+
begin
|
|
32
|
+
data = JSON.parse(json_candidate)
|
|
33
|
+
return normalize_model_list(data["models"]) if data.is_a?(Hash) && data["models"].is_a?(Array)
|
|
34
|
+
rescue JSON::ParserError
|
|
35
|
+
# Try next candidate
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Try to find a standalone JSON array starting with [{ (e.g. codex debug models raw output)
|
|
40
|
+
# Intentionally separate loop: {models:[]} has priority over bare arrays
|
|
41
|
+
lines.each_with_index do |line, idx| # rubocop:disable Style/CombinableLoops
|
|
42
|
+
next unless line.strip.start_with?("[{", "[")
|
|
43
|
+
|
|
44
|
+
json_candidate = lines[idx..].join
|
|
45
|
+
begin
|
|
46
|
+
data = JSON.parse(json_candidate)
|
|
47
|
+
return normalize_model_list(data) if data.is_a?(Array) && data.first.is_a?(Hash)
|
|
48
|
+
rescue JSON::ParserError
|
|
49
|
+
# Try next candidate
|
|
50
|
+
end
|
|
51
|
+
end # rubocop:enable Style/CombinableLoops
|
|
52
|
+
|
|
53
|
+
# Fallback: parse plain text output (one model per line, or tabular format)
|
|
54
|
+
parse_list_models_text(output)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Parse plain text model listing (e.g. "* auto 1.00x credits Description here")
|
|
58
|
+
# Lines prefixed with "*" are marked as the default model.
|
|
59
|
+
def parse_list_models_text(output)
|
|
60
|
+
models = []
|
|
61
|
+
output.each_line do |line|
|
|
62
|
+
line = line.strip
|
|
63
|
+
next if line.empty? || line.start_with?("Available") || line.start_with?("Default")
|
|
64
|
+
|
|
65
|
+
is_default = line.start_with?("*")
|
|
66
|
+
|
|
67
|
+
# Match lines like: "* auto 1.00x credits Description" or " claude-sonnet-4.6 1.30x credits Desc"
|
|
68
|
+
if (m = line.match(/^[*\s]*(\S+)\s+(\d+\.\d+x\s+\w+)\s+(.+)$/))
|
|
69
|
+
entry = { "model_id" => m[1], "rate" => m[2].strip, "description" => m[3].strip }
|
|
70
|
+
entry["default"] = true if is_default
|
|
71
|
+
models << entry
|
|
72
|
+
elsif (m = line.match(/^[*\s]*(\S+)\s*$/))
|
|
73
|
+
entry = { "model_id" => m[1] }
|
|
74
|
+
entry["default"] = true if is_default
|
|
75
|
+
models << entry
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
models.empty? ? nil : models
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Generate a short key from a model_id for use in the models config map.
|
|
82
|
+
# Strips common provider prefixes, lowercases, and normalizes to kebab-case.
|
|
83
|
+
# Examples:
|
|
84
|
+
# "claude-sonnet-4.6" => "sonnet-4-6"
|
|
85
|
+
# "GPT-4o" => "4o"
|
|
86
|
+
# "DeepSeek-V3" => "deepseek-v3"
|
|
87
|
+
def generate_short_model_key(model_id)
|
|
88
|
+
model_id
|
|
89
|
+
.downcase
|
|
90
|
+
.sub(/^claude-/, "")
|
|
91
|
+
.sub(/^grok-/, "")
|
|
92
|
+
.sub(/^gpt-/, "")
|
|
93
|
+
.gsub(/[^a-z0-9]/, "-").squeeze("-")
|
|
94
|
+
.sub(/^-/, "")
|
|
95
|
+
.sub(/-$/, "")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Normalize model hashes to always have "model_id" as the primary identifier.
|
|
99
|
+
# Handles various CLI output formats: "slug" (codex), "model_name" (generic), "model_id" (kiro-cli).
|
|
100
|
+
# Silently skips non-Hash elements (e.g. strings or numbers in a mixed array).
|
|
101
|
+
def normalize_model_list(models)
|
|
102
|
+
models.grep(Hash).map do |m|
|
|
103
|
+
next m if m["model_id"]
|
|
104
|
+
|
|
105
|
+
m = m.dup
|
|
106
|
+
m["model_id"] = m.delete("slug") || m.delete("model_name") || "unknown"
|
|
107
|
+
m
|
|
108
|
+
end
|
|
109
|
+
end
|
data/lib/brainiac/version.rb
CHANGED
|
@@ -18,9 +18,7 @@ INFRA_CMDS = %w[kiro-cli-chat ruby-lsp clangd gopls].freeze
|
|
|
18
18
|
DISCORD_CONFIG_FILE = File.join(BRAINIAC_DIR, "discord.json")
|
|
19
19
|
|
|
20
20
|
index = ARGV.find { |a| !a.start_with?("--") }&.to_i
|
|
21
|
-
unless index
|
|
22
|
-
exit
|
|
23
|
-
end
|
|
21
|
+
exit unless index
|
|
24
22
|
|
|
25
23
|
def load_discord_guild_id
|
|
26
24
|
return nil unless File.exist?(DISCORD_CONFIG_FILE)
|
data/receiver.rb
CHANGED
|
@@ -28,6 +28,7 @@ require_relative "lib/brainiac/intent"
|
|
|
28
28
|
require_relative "lib/brainiac/plugins"
|
|
29
29
|
require_relative "lib/brainiac/handlers/shared/git"
|
|
30
30
|
require_relative "lib/brainiac/handlers/shared/inline_tags"
|
|
31
|
+
require_relative "lib/brainiac/handlers/shared/belt"
|
|
31
32
|
|
|
32
33
|
# Namespace for gem-based plugins (brainiac-whatsapp, brainiac-slack, etc.)
|
|
33
34
|
module Brainiac
|
|
@@ -120,6 +121,9 @@ end
|
|
|
120
121
|
# from within Sinatra route handlers
|
|
121
122
|
helpers GitHelpers
|
|
122
123
|
|
|
124
|
+
# Register BeltHelpers for Belt app detection
|
|
125
|
+
helpers BeltHelpers
|
|
126
|
+
|
|
123
127
|
# Dashboard authentication helpers
|
|
124
128
|
helpers do
|
|
125
129
|
def authenticate_dashboard!
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"binary": "codex",
|
|
3
|
+
"default_args": "--full-auto",
|
|
4
|
+
"agent_flag": "--profile",
|
|
5
|
+
"model_flag": "--model",
|
|
6
|
+
"agent_model": "gpt-5.6-terra",
|
|
7
|
+
"effort_flag": null,
|
|
8
|
+
"effort_config_key": "model_reasoning_effort",
|
|
9
|
+
"config_override_flag": "-c",
|
|
10
|
+
"effort_map": {
|
|
11
|
+
"low": "low",
|
|
12
|
+
"medium": "medium",
|
|
13
|
+
"high": "high",
|
|
14
|
+
"xhigh": "xhigh",
|
|
15
|
+
"max": "xhigh"
|
|
16
|
+
},
|
|
17
|
+
"prompt_mode": "stdin",
|
|
18
|
+
"cwd_flag": "-C",
|
|
19
|
+
"resume_flag": null,
|
|
20
|
+
"resume_args": "exec resume --last --full-auto",
|
|
21
|
+
"session_dir": "~/.codex/sessions",
|
|
22
|
+
"output_last_message_flag": "-o",
|
|
23
|
+
"list_models_command": "codex debug models",
|
|
24
|
+
"models": {
|
|
25
|
+
"sol": "gpt-5.6-sol",
|
|
26
|
+
"terra": "gpt-5.6-terra",
|
|
27
|
+
"luna": "gpt-5.6-luna",
|
|
28
|
+
"gpt5.5": "gpt-5.5",
|
|
29
|
+
"auto": "gpt-5.6-terra"
|
|
30
|
+
},
|
|
31
|
+
"efforts": ["low", "medium", "high", "xhigh", "max"]
|
|
32
|
+
}
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: brainiac
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.29
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andy Davis
|
|
@@ -123,17 +123,20 @@ files:
|
|
|
123
123
|
- bin/brainiac
|
|
124
124
|
- bin/brainiac-completion.bash
|
|
125
125
|
- brainiac.gemspec
|
|
126
|
+
- docs/resume.md
|
|
126
127
|
- docs/waybar-config.md
|
|
127
128
|
- lib/brainiac.rb
|
|
128
129
|
- lib/brainiac/agents.rb
|
|
129
130
|
- lib/brainiac/brain.rb
|
|
130
131
|
- lib/brainiac/config.rb
|
|
131
132
|
- lib/brainiac/cron.rb
|
|
133
|
+
- lib/brainiac/handlers/shared/belt.rb
|
|
132
134
|
- lib/brainiac/handlers/shared/git.rb
|
|
133
135
|
- lib/brainiac/handlers/shared/inline_tags.rb
|
|
134
136
|
- lib/brainiac/helpers.rb
|
|
135
137
|
- lib/brainiac/hooks.rb
|
|
136
138
|
- lib/brainiac/intent.rb
|
|
139
|
+
- lib/brainiac/model_parser.rb
|
|
137
140
|
- lib/brainiac/notifications.rb
|
|
138
141
|
- lib/brainiac/plugins.rb
|
|
139
142
|
- lib/brainiac/prompts.rb
|
|
@@ -159,6 +162,7 @@ files:
|
|
|
159
162
|
- skills/brainiac-plugins/SKILL.md
|
|
160
163
|
- templates/agents.json.example
|
|
161
164
|
- templates/brainiac.json.example
|
|
165
|
+
- templates/cli-providers/codex.json.example
|
|
162
166
|
- templates/cli-providers/grok.json.example
|
|
163
167
|
- templates/cli-providers/kiro.json.example
|
|
164
168
|
- templates/hooks/pre-commit
|