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.
@@ -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
- config["prompt_flag"] = raw["prompt_flag"] if raw["prompt_flag"]
32
- # resume_flag: when set, follow-up dispatches use this flag to continue the
33
- # most recent session in the working directory (e.g. "-c" or "--continue").
34
- config["resume_flag"] = raw["resume_flag"] if raw["resume_flag"]
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
- def prior_session_exists?(chdir, agent_cli)
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
- session_dir = File.join(chdir, ".#{cli_name}")
448
- return true if File.directory?(session_dir)
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 should call this BEFORE building the prompt to decide between
465
- # render_resume_prompt (lean) and render_prompt (full context).
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
- # Returns truthy (the resume flag string) if viable, false otherwise.
480
- # Logs a message when resume was requested but isn't possible.
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
- return resolved["resume_flag"] if prior_session_exists?(chdir, resolved["agent_cli"])
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
- cmd = build_agent_cmd(resolved, agent_config_name: agent_config_name, model: model, effort: effort, prompt_file: prompt_file, resume: should_resume)
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 written to #{prompt_file}"
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 true and the provider has a resume_flag, adds it to continue the last session.
581
- def build_agent_cmd(resolved, agent_config_name: nil, model: nil, effort: nil, prompt_file: nil, resume: false)
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
- cmd.concat(resolved["agent_cli_args"].split)
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
- # Pass the model if it's a mapped value (e.g. "claude-opus-4.5") or the key itself is mapped
593
- is_known = allowed.value?(model) || allowed.key?(model)
594
- cmd.push(resolved["agent_model_flag"], model) if is_known
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.push(resolved["agent_effort_flag"], effort) if resolved["agent_effort_flag"] && !resolved["agent_effort_flag"].empty? && effort
597
- # Resume the most recent session in the working directory (for multi-turn CLIs like grok)
598
- cmd.push(resolved["resume_flag"]) if resume && resolved["resume_flag"]
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
- resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider_override)
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
- resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider_override)
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
@@ -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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Brainiac
4
4
  # @return [String] the current gem version
5
- VERSION = "0.0.27"
5
+ VERSION = "0.0.29"
6
6
  end
@@ -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
+ }
@@ -7,6 +7,7 @@
7
7
  "prompt_mode": "flag",
8
8
  "prompt_flag": "--prompt-file",
9
9
  "resume_flag": "-c",
10
+ "list_models_command": "grok models",
10
11
  "models": {
11
12
  "build": "grok-build",
12
13
  "composer": "grok-composer-2.5-fast",
@@ -6,6 +6,7 @@
6
6
  "effort_flag": "--effort",
7
7
  "prompt_mode": "stdin",
8
8
  "resume_flag": "--resume",
9
+ "list_models_command": "kiro-cli chat --list-models --format json",
9
10
  "models": {
10
11
  "opus": "claude-opus-4.5",
11
12
  "sonnet": "claude-sonnet-4.6",
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.27
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