brainiac 0.0.32 → 0.0.33
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/lib/brainiac/helpers.rb +322 -52
- data/lib/brainiac/routes/api.rb +49 -3
- data/lib/brainiac/version.rb +1 -1
- data/templates/cli-providers/opencode.json.example +19 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aa14f181c9965192fcc2507837be14e29cc9bce6a0273d806dd556eb2bfb72e2
|
|
4
|
+
data.tar.gz: c1d2fcbed4ce125a150d442e4cc3a1d68dfa856c424addf981904b4b88393b70
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: beaf113fb5acf361c73df0da4d93dd65a7a7159397c0b1c559e4b84df9396376148a95a7e09824744ca1d272ef51c6008da334bc906a3bd6ce8d940d0568f43a
|
|
7
|
+
data.tar.gz: 5382b695151def7319fcadfbb51ae84f6f4401b43b9de92ecd51e5c17b20a8e05723cd9943dfba4229ca70a7097f317c70f44b973e9d815f9815ee994b8414c9
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
brainiac (0.0.
|
|
4
|
+
brainiac (0.0.33)
|
|
5
5
|
puma (~> 7.2)
|
|
6
6
|
rackup (~> 2.3)
|
|
7
7
|
sinatra (~> 4.1)
|
|
@@ -84,7 +84,7 @@ DEPENDENCIES
|
|
|
84
84
|
CHECKSUMS
|
|
85
85
|
ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
|
|
86
86
|
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
|
|
87
|
-
brainiac (0.0.
|
|
87
|
+
brainiac (0.0.33)
|
|
88
88
|
json (2.19.9) sha256=9b9025b7cdddafa38d316eca0b2358488e42d417045c1b90d216a9fefe46b79a
|
|
89
89
|
language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
|
|
90
90
|
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
|
data/lib/brainiac/helpers.rb
CHANGED
|
@@ -32,8 +32,10 @@ def load_cli_provider(provider_name)
|
|
|
32
32
|
config["prompt_mode"] = raw["prompt_mode"] || "stdin"
|
|
33
33
|
# Copy optional fields from raw config when present.
|
|
34
34
|
# Each field controls a specific CLI behavior — see comments in the template.
|
|
35
|
-
%w[prompt_flag list_models_command resume_flag resume_args
|
|
36
|
-
|
|
35
|
+
%w[prompt_flag list_models_command resume_flag resume_args resume_id_flag new_session_id_flag
|
|
36
|
+
session_list_command session_id_field session_directory_field session_updated_field
|
|
37
|
+
session_list_path session_dir output_last_message_flag cwd_flag config_override_flag
|
|
38
|
+
effort_config_key effort_map title_flag].each do |key|
|
|
37
39
|
next unless raw[key]
|
|
38
40
|
next if raw[key].respond_to?(:empty?) && raw[key].empty?
|
|
39
41
|
|
|
@@ -247,6 +249,67 @@ def find_work_item_by_card(card_internal_id)
|
|
|
247
249
|
nil
|
|
248
250
|
end
|
|
249
251
|
|
|
252
|
+
# Find a work item by worktree path. Returns [work_item_id, info] or nil.
|
|
253
|
+
# Compares realpaths so symlink worktrees still match.
|
|
254
|
+
def find_work_item_by_worktree(worktree)
|
|
255
|
+
return nil unless worktree
|
|
256
|
+
|
|
257
|
+
target = path_for_compare(worktree)
|
|
258
|
+
return nil unless target
|
|
259
|
+
|
|
260
|
+
map = load_work_item_map
|
|
261
|
+
map.each do |work_item_id, info|
|
|
262
|
+
next unless info.is_a?(Hash) && info["worktree"]
|
|
263
|
+
next unless path_for_compare(info["worktree"]) == target
|
|
264
|
+
|
|
265
|
+
return [work_item_id, info]
|
|
266
|
+
end
|
|
267
|
+
nil
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def path_for_compare(path)
|
|
271
|
+
return nil unless path
|
|
272
|
+
|
|
273
|
+
File.realpath(path)
|
|
274
|
+
rescue StandardError
|
|
275
|
+
File.expand_path(path.to_s)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Key used in work_item["cli_sessions"] — the CLI binary basename (e.g. "opencode").
|
|
279
|
+
def cli_session_key(agent_cli)
|
|
280
|
+
return nil if agent_cli.nil? || agent_cli.to_s.empty?
|
|
281
|
+
|
|
282
|
+
File.basename(agent_cli.to_s)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Stored OpenCode/CLI session id for this work item + CLI, or nil.
|
|
286
|
+
def cli_session_id_for(agent_cli:, worktree: nil, work_item_id: nil)
|
|
287
|
+
key = cli_session_key(agent_cli)
|
|
288
|
+
return nil unless key
|
|
289
|
+
|
|
290
|
+
info = find_work_item_by_id(work_item_id) if work_item_id
|
|
291
|
+
info ||= find_work_item_by_worktree(worktree)&.last
|
|
292
|
+
info&.dig("cli_sessions", key)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Persist a CLI session id on the work item, keyed by CLI binary name.
|
|
296
|
+
# Returns true if stored, false if no matching work item.
|
|
297
|
+
def update_work_item_cli_session(agent_cli:, session_id:, worktree: nil, work_item_id: nil) # rubocop:disable Naming/PredicateMethod
|
|
298
|
+
key = cli_session_key(agent_cli)
|
|
299
|
+
return false unless key && session_id && !session_id.to_s.empty?
|
|
300
|
+
|
|
301
|
+
map = load_work_item_map
|
|
302
|
+
target_id = work_item_id
|
|
303
|
+
target_id ||= find_work_item_by_worktree(worktree)&.first
|
|
304
|
+
return false unless target_id && map[target_id]
|
|
305
|
+
|
|
306
|
+
map[target_id]["cli_sessions"] ||= {}
|
|
307
|
+
map[target_id]["cli_sessions"][key] = session_id.to_s
|
|
308
|
+
save_work_item_map(map)
|
|
309
|
+
LOG.info "[WorkItem] Stored #{key} session #{session_id} on #{target_id}"
|
|
310
|
+
true
|
|
311
|
+
end
|
|
312
|
+
|
|
250
313
|
# Register a new work item or update an existing one.
|
|
251
314
|
# Returns the work item ID.
|
|
252
315
|
def register_work_item(branch:, worktree: nil, project: nil, agent: nil, source: nil, source_data: {})
|
|
@@ -561,11 +624,24 @@ end
|
|
|
561
624
|
def resume_viable?(project_config:, cli_provider: nil, agent_name: nil, chdir: nil)
|
|
562
625
|
resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider, agent_name: agent_name)
|
|
563
626
|
chdir ||= resolved["repo_path"]
|
|
627
|
+
return stored_cli_session_viable?(resolved, chdir) if resolved["resume_id_flag"]
|
|
564
628
|
return false unless resolved["resume_flag"] || resolved["resume_args"]
|
|
565
629
|
|
|
566
630
|
prior_session_exists?(chdir, resolved["agent_cli"], session_dir: resolved["session_dir"])
|
|
567
631
|
end
|
|
568
632
|
|
|
633
|
+
# True when this CLI resumes by session id AND the work item has a still-existing id.
|
|
634
|
+
def stored_cli_session_viable?(resolved, chdir)
|
|
635
|
+
session_id = cli_session_id_for(agent_cli: resolved["agent_cli"], worktree: chdir)
|
|
636
|
+
return false if session_id.to_s.empty?
|
|
637
|
+
return true unless resolved["session_list_command"]
|
|
638
|
+
|
|
639
|
+
sessions = list_cli_sessions(resolved["session_list_command"], **session_list_fields(resolved))
|
|
640
|
+
return false unless sessions
|
|
641
|
+
|
|
642
|
+
sessions.any? { |s| s["id"] == session_id }
|
|
643
|
+
end
|
|
644
|
+
|
|
569
645
|
# Determine whether a session resume should actually happen.
|
|
570
646
|
# Called by run_agent — plugins should NOT call this directly; pass `resume: true` to run_agent.
|
|
571
647
|
#
|
|
@@ -573,8 +649,19 @@ end
|
|
|
573
649
|
# - :resume_args — when the provider uses subcommand-based resume (build_agent_cmd replaces default_args)
|
|
574
650
|
# - String (the resume flag) — when the provider uses flag-based resume (appended to cmd)
|
|
575
651
|
# - false — when resume was not requested or not viable
|
|
576
|
-
def resolve_resume(resume, resolved, chdir)
|
|
577
|
-
return false unless resume
|
|
652
|
+
def resolve_resume(resume, resolved, chdir, session_id: nil)
|
|
653
|
+
return false unless resume
|
|
654
|
+
|
|
655
|
+
if resolved["resume_id_flag"]
|
|
656
|
+
id = session_id.to_s.empty? ? cli_session_id_for(agent_cli: resolved["agent_cli"], worktree: chdir) : session_id
|
|
657
|
+
if id.to_s.empty?
|
|
658
|
+
LOG.info "[Dispatch] Resume requested but no stored #{resolved["agent_cli"]} session for #{chdir} — starting fresh"
|
|
659
|
+
return false
|
|
660
|
+
end
|
|
661
|
+
return { "flag" => resolved["resume_id_flag"], "id" => id }
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
return false unless resolved["resume_flag"] || resolved["resume_args"]
|
|
578
665
|
if prior_session_exists?(chdir, resolved["agent_cli"], session_dir: resolved["session_dir"])
|
|
579
666
|
# Return :resume_args when the provider uses subcommand-based resume (e.g. Codex exec resume)
|
|
580
667
|
return resolved["resume_args"] ? :resume_args : resolved["resume_flag"]
|
|
@@ -584,6 +671,16 @@ def resolve_resume(resume, resolved, chdir)
|
|
|
584
671
|
false
|
|
585
672
|
end
|
|
586
673
|
|
|
674
|
+
# Some CLIs (e.g. grok --session-id) let the caller name a new session up front.
|
|
675
|
+
# Mint a UUID only on the first run, when we are not resuming an existing id.
|
|
676
|
+
def mint_cli_session_id(resolved, stored_session:, resuming:)
|
|
677
|
+
return nil if resuming
|
|
678
|
+
return nil unless resolved["new_session_id_flag"]
|
|
679
|
+
return nil unless stored_session.to_s.empty?
|
|
680
|
+
|
|
681
|
+
SecureRandom.uuid
|
|
682
|
+
end
|
|
683
|
+
|
|
587
684
|
# Check if intent detection says to skip dispatching the agent.
|
|
588
685
|
# Returns true if the message should be skipped, false otherwise.
|
|
589
686
|
# Only runs if a raw message is provided, an agent is named, and intent is enabled.
|
|
@@ -619,8 +716,12 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
|
|
|
619
716
|
effort ||= resolved["agent_effort"]
|
|
620
717
|
agent_config_name = agent_name&.downcase&.gsub(/[^a-z0-9-]/, "-")
|
|
621
718
|
|
|
719
|
+
stored_session = cli_session_id_for(agent_cli: resolved["agent_cli"], worktree: chdir)
|
|
720
|
+
work_item_id = find_work_item_by_worktree(chdir)&.first
|
|
721
|
+
|
|
622
722
|
# Auto-resume: only if the provider supports it AND a prior session exists for this CLI here.
|
|
623
|
-
should_resume = resolve_resume(resume, resolved, chdir)
|
|
723
|
+
should_resume = resolve_resume(resume, resolved, chdir, session_id: stored_session)
|
|
724
|
+
minted_session_id = mint_cli_session_id(resolved, stored_session: stored_session, resuming: should_resume)
|
|
624
725
|
|
|
625
726
|
# Pre-dispatch hook — plugins can prep the working directory (e.g., copy config files, clean up)
|
|
626
727
|
Brainiac.emit(:pre_dispatch, chdir: chdir, project_config: project_config, agent_name: agent_name)
|
|
@@ -634,14 +735,15 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
|
|
|
634
735
|
|
|
635
736
|
cmd = build_agent_cmd(resolved, agent_config_name: agent_config_name, model: model, effort: effort,
|
|
636
737
|
prompt_file: prompt_file, resume: should_resume,
|
|
637
|
-
output_file: output_file, chdir: chdir
|
|
738
|
+
output_file: output_file, chdir: chdir, title: work_item_id,
|
|
739
|
+
new_session_id: minted_session_id)
|
|
638
740
|
prompt_mode = resolved["prompt_mode"] || "stdin"
|
|
639
741
|
|
|
640
742
|
spawn_env = agent_env_for(agent_name).merge(env)
|
|
641
743
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
744
|
+
log_agent_launch(resolved: resolved, chdir: chdir, log_file: log_file, prompt_file: prompt_file,
|
|
745
|
+
output_file: output_file, cmd: cmd, should_resume: should_resume,
|
|
746
|
+
spawn_env: spawn_env, agent_name: agent_name)
|
|
645
747
|
|
|
646
748
|
project_key_for_restart = PROJECTS.find { |_k, v| v == project_config }&.first
|
|
647
749
|
head_before, status_before = capture_git_state(chdir) if project_key_for_restart == "brainiac"
|
|
@@ -652,19 +754,16 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
|
|
|
652
754
|
out: [log_file, "w"],
|
|
653
755
|
err: %i[child out])
|
|
654
756
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
project_key_for_restart: project_key_for_restart
|
|
666
|
-
)
|
|
667
|
-
end
|
|
757
|
+
spawn_completion_watcher(
|
|
758
|
+
pid: pid, agent_cli: resolved["agent_cli"], agent_config_name: agent_config_name,
|
|
759
|
+
agent_name: agent_name, log_file: log_file, log_name: log_name,
|
|
760
|
+
prompt_file: prompt_file, chdir: chdir, source: source,
|
|
761
|
+
source_context: source_context, project_config: project_config,
|
|
762
|
+
card_number: card_number, skip_column_move: skip_column_move,
|
|
763
|
+
output_file: output_file, resolved: resolved, minted_session_id: minted_session_id,
|
|
764
|
+
head_before: head_before, status_before: status_before,
|
|
765
|
+
project_key_for_restart: project_key_for_restart
|
|
766
|
+
)
|
|
668
767
|
|
|
669
768
|
LOG.info "#{resolved["agent_cli"]} started (pid: #{pid}, agent: #{agent_config_name || "default"}, " \
|
|
670
769
|
"model: #{model || "default"}), tail -f #{log_file}"
|
|
@@ -672,6 +771,23 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
|
|
|
672
771
|
[pid, log_file]
|
|
673
772
|
end
|
|
674
773
|
|
|
774
|
+
# Spawn a background thread that waits for the agent process to finish and runs completion handling.
|
|
775
|
+
def spawn_completion_watcher(**ctx)
|
|
776
|
+
Thread.new do
|
|
777
|
+
Process.wait(ctx[:pid])
|
|
778
|
+
handle_agent_completion(**ctx)
|
|
779
|
+
end
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
# Log the details of an agent launch (command, prompt/output files, injected env).
|
|
783
|
+
def log_agent_launch(resolved:, chdir:, log_file:, prompt_file:, output_file:, cmd:, should_resume:, spawn_env:, agent_name:)
|
|
784
|
+
LOG.info "Running #{resolved["agent_cli"]} in #{chdir}, logging to #{log_file}"
|
|
785
|
+
LOG.info "Prompt: #{prompt_file} | Output: #{output_file || "none"} | Command: #{cmd.join(" ")}#{" (resuming session)" if should_resume}"
|
|
786
|
+
return if spawn_env.empty?
|
|
787
|
+
|
|
788
|
+
LOG.info "Injecting #{spawn_env.size} env var(s) for agent #{agent_name}: #{spawn_env.keys.join(", ")}"
|
|
789
|
+
end
|
|
790
|
+
|
|
675
791
|
# Write agent prompt to a temp file, return path.
|
|
676
792
|
def write_agent_prompt_file(prompt, log_name, timestamp)
|
|
677
793
|
prompt_dir = File.join(BRAINIAC_DIR, "tmp")
|
|
@@ -717,7 +833,8 @@ end
|
|
|
717
833
|
# When chdir is provided and the provider has a cwd_flag, appends it so the CLI
|
|
718
834
|
# itself switches to the working directory (e.g. `codex -C /path/to/project`).
|
|
719
835
|
# 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,
|
|
836
|
+
def build_agent_cmd(resolved, agent_config_name: nil, model: nil, effort: nil, prompt_file: nil, resume: false,
|
|
837
|
+
output_file: nil, chdir: nil, title: nil, new_session_id: nil)
|
|
721
838
|
cmd = [resolved["agent_cli"]]
|
|
722
839
|
# cwd_flag: pass the working directory as a CLI argument (e.g. -C for Codex CLI).
|
|
723
840
|
# This is added early so it appears before subcommands/args (global option).
|
|
@@ -730,25 +847,44 @@ def build_agent_cmd(resolved, agent_config_name: nil, model: nil, effort: nil, p
|
|
|
730
847
|
# e.g. "exec --full-auto" becomes "exec resume --last --full-auto"
|
|
731
848
|
args = resume == :resume_args && resolved["resume_args"] ? resolved["resume_args"] : resolved["agent_cli_args"]
|
|
732
849
|
cmd.concat(args.split)
|
|
733
|
-
|
|
734
|
-
# "auto" means "let the CLI choose" — skip passing it unless the provider explicitly maps it.
|
|
735
|
-
if model && resolved["agent_model_flag"] && !resolved["agent_model_flag"].empty?
|
|
736
|
-
allowed = resolved["allowed_models"] || {}
|
|
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
|
|
742
|
-
end
|
|
850
|
+
append_model_to_cmd(cmd, model, resolved)
|
|
743
851
|
append_effort_to_cmd(cmd, effort, resolved) if effort
|
|
744
|
-
|
|
745
|
-
|
|
852
|
+
cmd.push(resolved["title_flag"], title) if title && resolved["title_flag"]
|
|
853
|
+
if new_session_id && resolved["new_session_id_flag"] && !(resume.is_a?(Hash) || resume.is_a?(String) || resume == :resume_args)
|
|
854
|
+
cmd.push(resolved["new_session_id_flag"], new_session_id)
|
|
855
|
+
end
|
|
856
|
+
append_resume_to_cmd(cmd, resume)
|
|
746
857
|
# prompt_mode: "flag" passes the prompt file path via the configured prompt_flag (e.g. --prompt-file).
|
|
747
858
|
cmd.push(resolved["prompt_flag"], prompt_file) if prompt_file && resolved["prompt_mode"] == "flag" && resolved["prompt_flag"]
|
|
748
859
|
# output_last_message_flag: capture the agent's final message to a file (e.g. codex exec -o <path>).
|
|
749
860
|
cmd.push(resolved["output_last_message_flag"], output_file) if output_file && resolved["output_last_message_flag"]
|
|
750
861
|
cmd
|
|
751
862
|
end
|
|
863
|
+
|
|
864
|
+
# Append the --model flag when a valid model ID is resolved for this provider.
|
|
865
|
+
# "auto" means "let the CLI choose" — skip passing it unless the provider maps it.
|
|
866
|
+
def append_model_to_cmd(cmd, model, resolved)
|
|
867
|
+
return unless model && resolved["agent_model_flag"] && !resolved["agent_model_flag"].empty?
|
|
868
|
+
|
|
869
|
+
allowed = resolved["allowed_models"] || {}
|
|
870
|
+
# If the model is a key in allowed_models, use the mapped value (e.g. "auto" -> "o4-mini").
|
|
871
|
+
# This handles cases where different projects use "auto" but each CLI provider maps it differently.
|
|
872
|
+
effective_model = allowed.key?(model) ? allowed[model] : model
|
|
873
|
+
is_known = allowed.value?(effective_model) || allowed.key?(effective_model)
|
|
874
|
+
cmd.push(resolved["agent_model_flag"], effective_model) if is_known
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
# Resume via --session <id> (OpenCode), a bare flag (grok -c), or resume_args (already applied).
|
|
878
|
+
def append_resume_to_cmd(cmd, resume)
|
|
879
|
+
case resume
|
|
880
|
+
when Hash
|
|
881
|
+
flag = resume["flag"] || resume[:flag]
|
|
882
|
+
id = resume["id"] || resume[:id]
|
|
883
|
+
cmd.push(flag, id) if flag && id
|
|
884
|
+
when String
|
|
885
|
+
cmd.push(resume)
|
|
886
|
+
end
|
|
887
|
+
end
|
|
752
888
|
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
753
889
|
|
|
754
890
|
# Map a Brainiac effort level through the provider's effort_map (if any).
|
|
@@ -778,23 +914,130 @@ def append_effort_to_cmd(cmd, effort, resolved)
|
|
|
778
914
|
end
|
|
779
915
|
end
|
|
780
916
|
|
|
781
|
-
#
|
|
917
|
+
# Parse a CLI session list as JSON, ignoring non-JSON prefixes (e.g. mise).
|
|
918
|
+
# Normalizes OpenCode `{id, directory, updated}` and kiro `{sessionId, cwd, updatedAt}`
|
|
919
|
+
# (including kiro's `[{cwd, sessions: [...]}]` envelopes) into a flat array of
|
|
920
|
+
# `{ "id", "directory", "updated" }` hashes. Provider JSON can override field names.
|
|
921
|
+
def parse_session_list_output(output, id_field: nil, directory_field: nil, updated_field: nil, list_path: nil)
|
|
922
|
+
return nil if output.nil? || output.strip.empty?
|
|
923
|
+
|
|
924
|
+
json_start = output.index("[") || output.index("{")
|
|
925
|
+
return nil unless json_start
|
|
926
|
+
|
|
927
|
+
data = JSON.parse(output[json_start..])
|
|
928
|
+
rows = data.is_a?(Array) ? data : Array(data["sessions"] || data["data"])
|
|
929
|
+
return nil unless rows.is_a?(Array)
|
|
930
|
+
|
|
931
|
+
flatten_session_rows(rows, id_field: id_field, directory_field: directory_field,
|
|
932
|
+
updated_field: updated_field, list_path: list_path)
|
|
933
|
+
rescue JSON::ParserError
|
|
934
|
+
nil
|
|
935
|
+
end
|
|
936
|
+
|
|
937
|
+
def flatten_session_rows(rows, id_field: nil, directory_field: nil, updated_field: nil, list_path: nil)
|
|
938
|
+
nested_key = list_path.to_s.empty? ? "sessions" : list_path
|
|
939
|
+
fields = { id_field: id_field, directory_field: directory_field, updated_field: updated_field }
|
|
940
|
+
rows.flat_map do |row|
|
|
941
|
+
next [] unless row.is_a?(Hash)
|
|
942
|
+
|
|
943
|
+
nested = row[nested_key]
|
|
944
|
+
if nested.is_a?(Array)
|
|
945
|
+
parent_dir = session_value(row, directory_field, %w[directory dir cwd])
|
|
946
|
+
nested.filter_map { |item| normalize_session_row(item, parent_dir: parent_dir, **fields) }
|
|
947
|
+
else
|
|
948
|
+
[normalize_session_row(row, **fields)].compact
|
|
949
|
+
end
|
|
950
|
+
end
|
|
951
|
+
end
|
|
952
|
+
|
|
953
|
+
def normalize_session_row(row, id_field: nil, directory_field: nil, updated_field: nil, parent_dir: nil)
|
|
954
|
+
return nil unless row.is_a?(Hash)
|
|
955
|
+
|
|
956
|
+
id = session_value(row, id_field, %w[id sessionId session_id])
|
|
957
|
+
return nil if id.to_s.empty?
|
|
958
|
+
|
|
959
|
+
{
|
|
960
|
+
"id" => id.to_s,
|
|
961
|
+
"directory" => session_value(row, directory_field, %w[directory dir cwd]) || parent_dir,
|
|
962
|
+
"updated" => session_value(row, updated_field, %w[updated time_updated updatedAt updated_at]) || 0
|
|
963
|
+
}
|
|
964
|
+
end
|
|
965
|
+
|
|
966
|
+
def session_value(row, explicit_field, fallbacks)
|
|
967
|
+
return row[explicit_field] if explicit_field && !explicit_field.to_s.empty? && row.key?(explicit_field)
|
|
968
|
+
|
|
969
|
+
fallbacks.each { |key| return row[key] if row.key?(key) }
|
|
970
|
+
nil
|
|
971
|
+
end
|
|
972
|
+
|
|
973
|
+
def session_list_fields(resolved)
|
|
974
|
+
{
|
|
975
|
+
id_field: resolved["session_id_field"],
|
|
976
|
+
directory_field: resolved["session_directory_field"],
|
|
977
|
+
updated_field: resolved["session_updated_field"],
|
|
978
|
+
list_path: resolved["session_list_path"]
|
|
979
|
+
}
|
|
980
|
+
end
|
|
981
|
+
|
|
982
|
+
def list_cli_sessions(command, **fields)
|
|
983
|
+
stdout, stderr, status = Open3.capture3(command)
|
|
984
|
+
unless status.success?
|
|
985
|
+
LOG.warn "[Session] session_list_command failed (exit #{status.exitstatus}): #{stderr.strip}"
|
|
986
|
+
return nil
|
|
987
|
+
end
|
|
988
|
+
|
|
989
|
+
parse_session_list_output(stdout, **fields)
|
|
990
|
+
end
|
|
991
|
+
|
|
992
|
+
# Most recently updated session whose directory matches chdir.
|
|
993
|
+
# If no session has a directory (CLI already scoped the list to cwd), pick the newest overall.
|
|
994
|
+
def select_session_id_for_directory(sessions, chdir)
|
|
995
|
+
return nil if sessions.nil? || sessions.empty?
|
|
996
|
+
|
|
997
|
+
with_ids = sessions.select { |session| session.is_a?(Hash) && !session["id"].to_s.empty? }
|
|
998
|
+
return nil if with_ids.empty?
|
|
999
|
+
|
|
1000
|
+
if chdir
|
|
1001
|
+
target = path_for_compare(chdir)
|
|
1002
|
+
matching = with_ids.select { |session| session["directory"] && path_for_compare(session["directory"]) == target }
|
|
1003
|
+
with_ids = matching unless matching.empty?
|
|
1004
|
+
end
|
|
1005
|
+
|
|
1006
|
+
newest = with_ids.max_by { |session| session["updated"] || 0 }
|
|
1007
|
+
newest && newest["id"]
|
|
1008
|
+
end
|
|
1009
|
+
|
|
1010
|
+
# After a run, remember the CLI session id on the work item so later dispatches
|
|
1011
|
+
# can pass --session/--resume-id <id> instead of continuing whichever session was last globally.
|
|
1012
|
+
def capture_cli_session_id(resolved:, chdir:, minted_session_id: nil)
|
|
1013
|
+
if minted_session_id
|
|
1014
|
+
update_work_item_cli_session(agent_cli: resolved["agent_cli"], session_id: minted_session_id, worktree: chdir)
|
|
1015
|
+
return
|
|
1016
|
+
end
|
|
1017
|
+
return unless resolved["session_list_command"] && resolved["resume_id_flag"]
|
|
1018
|
+
|
|
1019
|
+
sessions = list_cli_sessions(resolved["session_list_command"], **session_list_fields(resolved))
|
|
1020
|
+
session_id = select_session_id_for_directory(sessions, chdir)
|
|
1021
|
+
unless session_id
|
|
1022
|
+
LOG.info "[Session] No CLI session found for #{chdir}"
|
|
1023
|
+
return
|
|
1024
|
+
end
|
|
1025
|
+
|
|
1026
|
+
update_work_item_cli_session(agent_cli: resolved["agent_cli"], session_id: session_id, worktree: chdir)
|
|
1027
|
+
end
|
|
1028
|
+
|
|
782
1029
|
def handle_agent_completion(**ctx)
|
|
783
1030
|
agent_exit_status = $CHILD_STATUS.exitstatus
|
|
784
1031
|
agent_signaled = $CHILD_STATUS.signaled?
|
|
785
1032
|
LOG.info "#{ctx[:agent_cli]} finished (pid: #{ctx[:pid]}, exit: #{agent_exit_status})"
|
|
786
1033
|
|
|
787
|
-
|
|
788
|
-
notify_agent_crash(
|
|
789
|
-
exit_status: agent_exit_status, log_file: ctx[:log_file],
|
|
790
|
-
agent_name: ctx[:agent_name], source: ctx[:source], source_context: ctx[:source_context],
|
|
791
|
-
project_config: ctx[:project_config]
|
|
792
|
-
)
|
|
793
|
-
end
|
|
1034
|
+
notify_crash_if_needed(ctx, agent_exit_status, agent_signaled)
|
|
794
1035
|
|
|
795
1036
|
# Read structured output if the provider wrote to an output file (--output-last-message).
|
|
796
1037
|
output_content = read_output_file(ctx[:output_file])
|
|
797
1038
|
|
|
1039
|
+
capture_session_id_if_possible(ctx)
|
|
1040
|
+
|
|
798
1041
|
# Emit lifecycle hook — plugins handle post-session actions (e.g., plugin moves card, appends footer)
|
|
799
1042
|
Brainiac.emit(:agent_completed,
|
|
800
1043
|
card_number: ctx[:card_number] || ctx[:source_context]&.dig(:card_number),
|
|
@@ -813,22 +1056,49 @@ def handle_agent_completion(**ctx)
|
|
|
813
1056
|
# Clean up the output file after hook emission (content already captured above).
|
|
814
1057
|
FileUtils.rm_f(ctx[:output_file]) if ctx[:output_file]
|
|
815
1058
|
|
|
1059
|
+
run_qmd_update(ctx[:agent_config_name])
|
|
1060
|
+
log_skill_candidate(ctx[:log_file])
|
|
1061
|
+
|
|
1062
|
+
brain_push(message: "#{ctx[:agent_config_name] || "agent"}: #{ctx[:log_name]}")
|
|
1063
|
+
# check_brainiac_restart(ctx[:head_before], ctx[:status_before], ctx[:chdir], ctx[:project_key_for_restart], ctx[:agent_config_name])
|
|
1064
|
+
end
|
|
1065
|
+
|
|
1066
|
+
# Capture the CLI session id after a run, if the provider and working dir are known.
|
|
1067
|
+
def capture_session_id_if_possible(ctx)
|
|
1068
|
+
return unless ctx[:resolved] && ctx[:chdir]
|
|
1069
|
+
|
|
1070
|
+
capture_cli_session_id(resolved: ctx[:resolved], chdir: ctx[:chdir], minted_session_id: ctx[:minted_session_id])
|
|
1071
|
+
end
|
|
1072
|
+
|
|
1073
|
+
# Notify plugins of a crashed agent session (non-zero exit, not signaled).
|
|
1074
|
+
def notify_crash_if_needed(ctx, agent_exit_status, agent_signaled)
|
|
1075
|
+
return unless ctx[:source] && agent_exit_status && agent_exit_status != 0 && !agent_signaled
|
|
1076
|
+
|
|
1077
|
+
notify_agent_crash(
|
|
1078
|
+
exit_status: agent_exit_status, log_file: ctx[:log_file],
|
|
1079
|
+
agent_name: ctx[:agent_name], source: ctx[:source], source_context: ctx[:source_context],
|
|
1080
|
+
project_config: ctx[:project_config]
|
|
1081
|
+
)
|
|
1082
|
+
end
|
|
1083
|
+
|
|
1084
|
+
# Run `qmd update` to reindex the brain after a session, logging the outcome.
|
|
1085
|
+
def run_qmd_update(agent_config_name)
|
|
816
1086
|
qmd_out, qmd_status = Open3.capture2e("qmd", "update")
|
|
817
1087
|
if qmd_status.success?
|
|
818
|
-
LOG.info "[Brain] qmd update completed after #{
|
|
1088
|
+
LOG.info "[Brain] qmd update completed after #{agent_config_name || "agent"} session"
|
|
819
1089
|
else
|
|
820
1090
|
LOG.warn "[Brain] qmd update failed: #{qmd_out.strip}"
|
|
821
1091
|
end
|
|
1092
|
+
end
|
|
822
1093
|
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
"— agent was nudged via reflection prompt"
|
|
828
|
-
end
|
|
1094
|
+
# Log whether the session qualifies for skill extraction (agent nudged via reflection prompt).
|
|
1095
|
+
def log_skill_candidate(log_file)
|
|
1096
|
+
skill_candidate = detect_skill_candidate(log_file)
|
|
1097
|
+
return unless skill_candidate[:extract]
|
|
829
1098
|
|
|
830
|
-
|
|
831
|
-
|
|
1099
|
+
LOG.info "[Skills] Session qualifies for skill extraction " \
|
|
1100
|
+
"(#{skill_candidate[:tool_calls]} tool calls, #{skill_candidate[:error_patterns]} error patterns) " \
|
|
1101
|
+
"— agent was nudged via reflection prompt"
|
|
832
1102
|
end
|
|
833
1103
|
|
|
834
1104
|
def check_brainiac_restart(head_before, status_before, chdir, project_key_for_restart, agent_config_name)
|
data/lib/brainiac/routes/api.rb
CHANGED
|
@@ -47,6 +47,54 @@ def format_recent_sessions
|
|
|
47
47
|
end
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
# Whether a log file path is safe to serve over /api/logs.
|
|
51
|
+
#
|
|
52
|
+
# Agent sessions log into `<repo>/tmp` for the registered project, but most
|
|
53
|
+
# real work (Fizzy cards, PR reviews, Discord agents) runs in a *worktree*
|
|
54
|
+
# sibling directory named `<repo>--<suffix>` (e.g. `feature_parity--fizzy-1377-…`),
|
|
55
|
+
# which is NOT under the registered repo_path. We therefore allow a log file
|
|
56
|
+
# when it lives under any registered repo's `tmp/`, under any worktree sibling
|
|
57
|
+
# of a registered repo (`<repo>--*/tmp/`), or under Brainiac's own `tmp/`.
|
|
58
|
+
#
|
|
59
|
+
# The worktree check is purely path-based (no filesystem globbing): the tmp
|
|
60
|
+
# dir's grandparent directory must be a registered repo and the worktree name
|
|
61
|
+
# must start with `<repo_basename>--`. This is cheap enough for the 2s log
|
|
62
|
+
# poller and still matches even if the worktree was removed after the log was
|
|
63
|
+
# written.
|
|
64
|
+
def log_file_allowed?(log_file)
|
|
65
|
+
return true if log_allowed_repo_dirs.any? { |dir| log_file.start_with?(dir) }
|
|
66
|
+
|
|
67
|
+
log_file_in_worktree_tmp?(log_file)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def log_allowed_repo_dirs
|
|
71
|
+
dirs = PROJECTS.values.filter_map do |project|
|
|
72
|
+
repo_path = project["repo_path"]
|
|
73
|
+
(File.join(repo_path, "tmp") + File::SEPARATOR) if repo_path && !repo_path.empty?
|
|
74
|
+
end
|
|
75
|
+
dirs << (File.join(BRAINIAC_DIR, "tmp") + File::SEPARATOR)
|
|
76
|
+
dirs
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# True when `log_file` sits in a `tmp/` dir of a worktree sibling of a
|
|
80
|
+
# registered repo: `<parent>/<repo_basename>--<suffix>/tmp/...`.
|
|
81
|
+
def log_file_in_worktree_tmp?(log_file)
|
|
82
|
+
tmp_dir = File.dirname(log_file)
|
|
83
|
+
return false unless File.basename(tmp_dir) == "tmp"
|
|
84
|
+
|
|
85
|
+
worktree = File.dirname(tmp_dir)
|
|
86
|
+
worktree_name = File.basename(worktree)
|
|
87
|
+
worktree_parent = File.dirname(worktree)
|
|
88
|
+
|
|
89
|
+
PROJECTS.values.any? do |project|
|
|
90
|
+
repo_path = project["repo_path"]
|
|
91
|
+
next false unless repo_path && !repo_path.empty?
|
|
92
|
+
|
|
93
|
+
File.dirname(repo_path) == worktree_parent &&
|
|
94
|
+
worktree_name.start_with?("#{File.basename(repo_path)}--")
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
50
98
|
def kill_child_process(target_pid)
|
|
51
99
|
Process.kill("TERM", target_pid)
|
|
52
100
|
Thread.new do
|
|
@@ -255,9 +303,7 @@ get "/api/logs" do
|
|
|
255
303
|
halt 400, "Invalid path" if log_file.include?("..") || !log_file.start_with?("/")
|
|
256
304
|
halt 404, "File not found" unless File.exist?(log_file)
|
|
257
305
|
|
|
258
|
-
|
|
259
|
-
allowed << File.join(BRAINIAC_DIR, "tmp")
|
|
260
|
-
halt 403, "Forbidden" unless allowed.any? { |dir| log_file.start_with?(dir) }
|
|
306
|
+
halt 403, "Forbidden" unless log_file_allowed?(log_file)
|
|
261
307
|
|
|
262
308
|
all_lines = File.readlines(log_file).last(lines)
|
|
263
309
|
all_lines.join.gsub(/\e\[[\d;]*[a-zA-Z]/, "").gsub(/\e\[\?[\d;]*[a-zA-Z]/, "")
|
data/lib/brainiac/version.rb
CHANGED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"binary": "opencode",
|
|
3
|
+
"default_args": "run --auto",
|
|
4
|
+
"agent_flag": null,
|
|
5
|
+
"model_flag": "--model",
|
|
6
|
+
"prompt_mode": "stdin",
|
|
7
|
+
"resume_id_flag": "--session",
|
|
8
|
+
"session_list_command": "opencode session list --format json",
|
|
9
|
+
"title_flag": "--title",
|
|
10
|
+
"list_models_command": "opencode models ollama",
|
|
11
|
+
"models": {
|
|
12
|
+
"qwen": "ollama/qwen3.6:latest",
|
|
13
|
+
"qwen36": "ollama/qwen3.6:latest",
|
|
14
|
+
"local": "ollama/qwen3.6:latest",
|
|
15
|
+
"gemma": "ollama/gemma3:12b",
|
|
16
|
+
"fast": "ollama/qwen3:4b",
|
|
17
|
+
"auto": "ollama/qwen3.6:latest"
|
|
18
|
+
}
|
|
19
|
+
}
|
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.33
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andy Davis
|
|
@@ -166,6 +166,7 @@ files:
|
|
|
166
166
|
- templates/cli-providers/gemini.json.example
|
|
167
167
|
- templates/cli-providers/grok.json.example
|
|
168
168
|
- templates/cli-providers/kiro.json.example
|
|
169
|
+
- templates/cli-providers/opencode.json.example
|
|
169
170
|
- templates/hooks/pre-commit
|
|
170
171
|
- templates/plugins.json.example
|
|
171
172
|
- templates/roles/code-reviewer.md.example
|