brainiac 0.0.27 → 0.0.28
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/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/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 +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: add3489f7ad183254936da1957e45221696ba277bc1119fd243cdde74c8114a8
|
|
4
|
+
data.tar.gz: 9fbc0a541c50978c549327528f4f64383735b386dd6688a9d38dae4353a04542
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 063130f40cb32c62a13e3fb5cf1c036d88e17f25e76243c3724f56e49776ffbaed5f025efc8a03b6792da24917d615665f5169147dfa3e28ed4ac9da5c63b1c7
|
|
7
|
+
data.tar.gz: a57864221e958f731ce29d77a9a2322eda834f4efd63bd83109dd326bcafbe749592a47fe29b1bc76ada0e51dc74380f76079802c3881b9465e66d2aea4cc653
|
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.28)
|
|
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.28)
|
|
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/README.md
CHANGED
|
@@ -226,12 +226,23 @@ Configure which AI CLI to use for dispatching agents. Each provider is a JSON fi
|
|
|
226
226
|
|
|
227
227
|
```json
|
|
228
228
|
{
|
|
229
|
-
"
|
|
230
|
-
"
|
|
231
|
-
"
|
|
229
|
+
"binary": "kiro-cli",
|
|
230
|
+
"default_args": "chat --trust-all-tools --no-interactive",
|
|
231
|
+
"agent_flag": "--agent",
|
|
232
|
+
"model_flag": "--model"
|
|
232
233
|
}
|
|
233
234
|
```
|
|
234
235
|
|
|
236
|
+
The `agent_flag` field controls how agent identity is passed to the CLI. Different CLIs use different flags:
|
|
237
|
+
|
|
238
|
+
| CLI | `agent_flag` | Example command |
|
|
239
|
+
|-----|-------------|-----------------|
|
|
240
|
+
| Kiro CLI | `"--agent"` | `kiro-cli --agent sherlock chat --no-interactive` |
|
|
241
|
+
| Codex CLI | `"--profile"` | `codex --profile sherlock exec --sandbox workspace-write -` |
|
|
242
|
+
| Grok CLI | `null` | `grok --always-approve` (no agent identity passed) |
|
|
243
|
+
|
|
244
|
+
Set `agent_flag` to `null` to suppress passing the agent name entirely (for CLIs that don't support persona switching).
|
|
245
|
+
|
|
235
246
|
Brainiac ships with example providers during setup. Your project config references which provider to use.
|
|
236
247
|
|
|
237
248
|
#### 3. Environment Variables
|
data/bin/brainiac
CHANGED
|
@@ -205,6 +205,7 @@ def wait_for_agents_to_finish
|
|
|
205
205
|
end
|
|
206
206
|
end
|
|
207
207
|
|
|
208
|
+
# rubocop:disable Naming/PredicateMethod
|
|
208
209
|
def stop_server
|
|
209
210
|
pid = find_server_pid
|
|
210
211
|
|
|
@@ -245,6 +246,7 @@ def stop_server
|
|
|
245
246
|
false
|
|
246
247
|
end
|
|
247
248
|
end
|
|
249
|
+
# rubocop:enable Naming/PredicateMethod
|
|
248
250
|
|
|
249
251
|
# Kill a single process: TERM first, KILL if it doesn't exit within 5s.
|
|
250
252
|
def kill_pid(pid, quiet: false)
|
|
@@ -1025,6 +1027,9 @@ def brain_list
|
|
|
1025
1027
|
puts "Run 'brainiac brain init <agent-name>' to set up a new agent."
|
|
1026
1028
|
end
|
|
1027
1029
|
|
|
1030
|
+
# Model output parsing (shared with lib/brainiac/helpers.rb)
|
|
1031
|
+
require_relative "../lib/brainiac/model_parser"
|
|
1032
|
+
|
|
1028
1033
|
# Main CLI
|
|
1029
1034
|
options = {}
|
|
1030
1035
|
subcommand = ARGV.shift
|
|
@@ -1940,6 +1945,150 @@ when "provider"
|
|
|
1940
1945
|
File.write(file, JSON.pretty_generate(scaffold))
|
|
1941
1946
|
puts "Created #{file} — edit it to configure the provider."
|
|
1942
1947
|
|
|
1948
|
+
when "list-models"
|
|
1949
|
+
name = ARGV[0]
|
|
1950
|
+
unless name
|
|
1951
|
+
puts "Usage: brainiac provider list-models <name>"
|
|
1952
|
+
exit 1
|
|
1953
|
+
end
|
|
1954
|
+
file = File.join(providers_dir, "#{name}.json")
|
|
1955
|
+
unless File.exist?(file)
|
|
1956
|
+
puts "Provider '#{name}' not found."
|
|
1957
|
+
exit 1
|
|
1958
|
+
end
|
|
1959
|
+
data = JSON.parse(File.read(file))
|
|
1960
|
+
command = data["list_models_command"]
|
|
1961
|
+
if command.nil? || command.empty?
|
|
1962
|
+
puts "Provider '#{name}' has no list_models_command configured."
|
|
1963
|
+
puts "Edit #{file} and set \"list_models_command\" to a shell command that lists models."
|
|
1964
|
+
puts "Example: \"kiro-cli chat --list-models --format json\""
|
|
1965
|
+
exit 1
|
|
1966
|
+
end
|
|
1967
|
+
|
|
1968
|
+
puts "Querying models from '#{name}' (#{command})..."
|
|
1969
|
+
puts ""
|
|
1970
|
+
stdout, stderr, status = Open3.capture3(command)
|
|
1971
|
+
unless status.success?
|
|
1972
|
+
puts "Command failed (exit #{status.exitstatus}):"
|
|
1973
|
+
puts stderr.strip unless stderr.empty?
|
|
1974
|
+
exit 1
|
|
1975
|
+
end
|
|
1976
|
+
|
|
1977
|
+
models = parse_list_models_output(stdout)
|
|
1978
|
+
if models.nil? || models.empty?
|
|
1979
|
+
puts "No models found in command output."
|
|
1980
|
+
puts "Raw output:"
|
|
1981
|
+
puts stdout
|
|
1982
|
+
exit 1
|
|
1983
|
+
end
|
|
1984
|
+
|
|
1985
|
+
# Filter hidden models (e.g. codex "visibility": "hide") unless none are visible
|
|
1986
|
+
visible_models = models.select { |m| m["visibility"].nil? || m["visibility"] != "hide" }
|
|
1987
|
+
visible_models = models if visible_models.empty?
|
|
1988
|
+
|
|
1989
|
+
# Display models in a table
|
|
1990
|
+
default_model = data["default_model"]
|
|
1991
|
+
# Determine default: explicit default_model field > parsed "default" flag > first model
|
|
1992
|
+
has_explicit_default = default_model && visible_models.any? { |m| m["model_id"] == default_model }
|
|
1993
|
+
has_parsed_default = !has_explicit_default && visible_models.any? { |m| m["default"] }
|
|
1994
|
+
puts "Available models for '#{name}' (#{visible_models.size} visible, #{models.size} total):"
|
|
1995
|
+
puts ""
|
|
1996
|
+
visible_models.each do |m|
|
|
1997
|
+
model_id = m["model_id"] || "unknown"
|
|
1998
|
+
desc = m["description"] || m["display_name"] || ""
|
|
1999
|
+
rate = m["rate_multiplier"] ? "#{m["rate_multiplier"]}x" : (m["rate"] || "")
|
|
2000
|
+
marker = if has_explicit_default
|
|
2001
|
+
m["model_id"] == default_model ? "*" : " "
|
|
2002
|
+
elsif has_parsed_default
|
|
2003
|
+
m["default"] ? "*" : " "
|
|
2004
|
+
else
|
|
2005
|
+
m == visible_models.first ? "*" : " "
|
|
2006
|
+
end
|
|
2007
|
+
if desc.empty?
|
|
2008
|
+
puts " #{marker} #{model_id}"
|
|
2009
|
+
else
|
|
2010
|
+
puts format(" %<marker>s %-25<model>s %-15<rate>s %<desc>s", marker: marker, model: model_id, rate: rate, desc: desc)
|
|
2011
|
+
end
|
|
2012
|
+
end
|
|
2013
|
+
|
|
2014
|
+
# Show which are mapped vs unmapped
|
|
2015
|
+
configured = data["models"] || {}
|
|
2016
|
+
configured_ids = configured.values
|
|
2017
|
+
unmapped = visible_models.select do |m|
|
|
2018
|
+
id = m["model_id"]
|
|
2019
|
+
!configured_ids.include?(id) && !configured.key?(id)
|
|
2020
|
+
end
|
|
2021
|
+
unless unmapped.empty?
|
|
2022
|
+
puts ""
|
|
2023
|
+
puts "#{unmapped.size} model(s) not in your provider's 'models' map:"
|
|
2024
|
+
unmapped.each { |m| puts " - #{m["model_id"]}" }
|
|
2025
|
+
puts ""
|
|
2026
|
+
puts "Run 'brainiac provider sync-models #{name}' to add them."
|
|
2027
|
+
end
|
|
2028
|
+
|
|
2029
|
+
when "sync-models"
|
|
2030
|
+
name = ARGV[0]
|
|
2031
|
+
unless name
|
|
2032
|
+
puts "Usage: brainiac provider sync-models <name>"
|
|
2033
|
+
exit 1
|
|
2034
|
+
end
|
|
2035
|
+
file = File.join(providers_dir, "#{name}.json")
|
|
2036
|
+
unless File.exist?(file)
|
|
2037
|
+
puts "Provider '#{name}' not found."
|
|
2038
|
+
exit 1
|
|
2039
|
+
end
|
|
2040
|
+
data = JSON.parse(File.read(file))
|
|
2041
|
+
command = data["list_models_command"]
|
|
2042
|
+
if command.nil? || command.empty?
|
|
2043
|
+
puts "Provider '#{name}' has no list_models_command configured."
|
|
2044
|
+
exit 1
|
|
2045
|
+
end
|
|
2046
|
+
|
|
2047
|
+
stdout, stderr, status = Open3.capture3(command)
|
|
2048
|
+
unless status.success?
|
|
2049
|
+
puts "Command failed (exit #{status.exitstatus}): #{stderr.strip}"
|
|
2050
|
+
exit 1
|
|
2051
|
+
end
|
|
2052
|
+
|
|
2053
|
+
models = parse_list_models_output(stdout)
|
|
2054
|
+
if models.nil? || models.empty?
|
|
2055
|
+
puts "No models found in command output."
|
|
2056
|
+
exit 1
|
|
2057
|
+
end
|
|
2058
|
+
|
|
2059
|
+
# Build a models map from discovered models: key = short name, value = model_id
|
|
2060
|
+
existing_models = data["models"] || {}
|
|
2061
|
+
new_models = existing_models.dup
|
|
2062
|
+
added = []
|
|
2063
|
+
|
|
2064
|
+
models.each do |m|
|
|
2065
|
+
model_id = m["model_id"]
|
|
2066
|
+
next unless model_id
|
|
2067
|
+
|
|
2068
|
+
# Skip hidden models (e.g. codex internal models)
|
|
2069
|
+
next if m["visibility"] == "hide"
|
|
2070
|
+
|
|
2071
|
+
# Skip if already mapped (as key or value)
|
|
2072
|
+
next if new_models.value?(model_id) || new_models.key?(model_id)
|
|
2073
|
+
|
|
2074
|
+
# Generate a short key: strip common prefixes, use the base name
|
|
2075
|
+
short_key = generate_short_model_key(model_id)
|
|
2076
|
+
# If the short key already exists, use the full model_id as the key
|
|
2077
|
+
short_key = model_id if new_models.key?(short_key)
|
|
2078
|
+
new_models[short_key] = model_id
|
|
2079
|
+
added << "#{short_key} => #{model_id}"
|
|
2080
|
+
end
|
|
2081
|
+
|
|
2082
|
+
if added.empty?
|
|
2083
|
+
puts "All discovered models are already mapped. Nothing to sync."
|
|
2084
|
+
exit 0
|
|
2085
|
+
end
|
|
2086
|
+
|
|
2087
|
+
data["models"] = new_models
|
|
2088
|
+
File.write(file, JSON.pretty_generate(data))
|
|
2089
|
+
puts "Synced #{added.size} new model(s) to #{file}:"
|
|
2090
|
+
added.each { |a| puts " + #{a}" }
|
|
2091
|
+
|
|
1943
2092
|
else
|
|
1944
2093
|
puts <<~HELP
|
|
1945
2094
|
Usage: brainiac provider <command>
|
|
@@ -1948,6 +2097,8 @@ when "provider"
|
|
|
1948
2097
|
list List configured CLI providers
|
|
1949
2098
|
show <name> Show provider configuration
|
|
1950
2099
|
add <name> Create a new provider config
|
|
2100
|
+
list-models <name> Query available models from the CLI provider
|
|
2101
|
+
sync-models <name> Add discovered models to provider config
|
|
1951
2102
|
HELP
|
|
1952
2103
|
end
|
|
1953
2104
|
|
|
@@ -3474,7 +3625,7 @@ when "plugin"
|
|
|
3474
3625
|
gem_name = "brainiac-#{plugin_name}"
|
|
3475
3626
|
|
|
3476
3627
|
# Determine the base repo path (strip worktree suffix like --branch-name)
|
|
3477
|
-
base_path = current_path.sub(
|
|
3628
|
+
base_path = current_path.sub(%r{--[^/]+$}, "")
|
|
3478
3629
|
unless Dir.exist?(base_path)
|
|
3479
3630
|
puts "Error: Base repo not found at #{base_path}"
|
|
3480
3631
|
exit 1
|
|
@@ -3503,9 +3654,7 @@ when "plugin"
|
|
|
3503
3654
|
# Fetch latest to make sure the branch is available
|
|
3504
3655
|
puts "Fetching latest from origin..."
|
|
3505
3656
|
_out, _err, fetch_status = Open3.capture3("git", "fetch", "origin", chdir: base_path)
|
|
3506
|
-
unless fetch_status.success?
|
|
3507
|
-
puts "Warning: git fetch failed — continuing with local state."
|
|
3508
|
-
end
|
|
3657
|
+
puts "Warning: git fetch failed — continuing with local state." unless fetch_status.success?
|
|
3509
3658
|
|
|
3510
3659
|
# Check if a worktree already exists for this branch
|
|
3511
3660
|
worktree_list, _, wt_status = Open3.capture3("git", "worktree", "list", "--porcelain", chdir: base_path)
|
|
@@ -3513,18 +3662,19 @@ when "plugin"
|
|
|
3513
3662
|
|
|
3514
3663
|
if wt_status.success?
|
|
3515
3664
|
# Parse porcelain output to find worktrees on the target branch
|
|
3516
|
-
|
|
3665
|
+
worktree_blocks = worktree_list.split("\n\n").map do |block|
|
|
3517
3666
|
wt = {}
|
|
3518
3667
|
block.each_line do |line|
|
|
3519
3668
|
case line
|
|
3520
3669
|
when /^worktree (.+)/
|
|
3521
3670
|
wt[:path] = Regexp.last_match(1).strip
|
|
3522
|
-
when
|
|
3671
|
+
when %r{^branch refs/heads/(.+)}
|
|
3523
3672
|
wt[:branch] = Regexp.last_match(1).strip
|
|
3524
3673
|
end
|
|
3525
3674
|
end
|
|
3526
3675
|
wt
|
|
3527
|
-
end
|
|
3676
|
+
end
|
|
3677
|
+
worktrees = worktree_blocks.select { |wt| wt[:path] && wt[:branch] }
|
|
3528
3678
|
|
|
3529
3679
|
target_worktree = worktrees.find { |wt| wt[:branch] == branch_name }
|
|
3530
3680
|
end
|
|
@@ -3541,11 +3691,9 @@ when "plugin"
|
|
|
3541
3691
|
File.write(plugins_file, JSON.pretty_generate(plugins_config))
|
|
3542
3692
|
puts "✓ Switched plugin '#{plugin_name}' to branch '#{branch_name}'"
|
|
3543
3693
|
puts " Path: #{new_path}"
|
|
3544
|
-
puts " Restart the server to apply: brainiac restart"
|
|
3545
3694
|
else
|
|
3546
3695
|
# Check if branch exists remotely or locally
|
|
3547
|
-
|
|
3548
|
-
check_cmd, _, check_status = Open3.capture3("git", "rev-parse", "--verify", "origin/#{branch_name}", chdir: base_path)
|
|
3696
|
+
_, _, check_status = Open3.capture3("git", "rev-parse", "--verify", "origin/#{branch_name}", chdir: base_path)
|
|
3549
3697
|
branch_exists = check_status.success?
|
|
3550
3698
|
|
|
3551
3699
|
unless branch_exists
|
|
@@ -3576,7 +3724,10 @@ when "plugin"
|
|
|
3576
3724
|
_, _, local_check = Open3.capture3("git", "rev-parse", "--verify", branch_name, chdir: base_path)
|
|
3577
3725
|
if !local_check.success? && check_status.success?
|
|
3578
3726
|
# Remote branch exists but not local — create tracking branch in worktree
|
|
3579
|
-
_, stderr, wt_status = Open3.capture3(
|
|
3727
|
+
_, stderr, wt_status = Open3.capture3(
|
|
3728
|
+
"git", "worktree", "add", "--track", "-b", branch_name, worktree_dir,
|
|
3729
|
+
"origin/#{branch_name}", chdir: base_path
|
|
3730
|
+
)
|
|
3580
3731
|
else
|
|
3581
3732
|
# Local branch exists — use it directly
|
|
3582
3733
|
_, stderr, wt_status = Open3.capture3("git", "worktree", "add", worktree_dir, branch_name, chdir: base_path)
|
|
@@ -3592,8 +3743,8 @@ when "plugin"
|
|
|
3592
3743
|
File.write(plugins_file, JSON.pretty_generate(plugins_config))
|
|
3593
3744
|
puts "✓ Switched plugin '#{plugin_name}' to branch '#{branch_name}'"
|
|
3594
3745
|
puts " Worktree: #{worktree_dir}"
|
|
3595
|
-
puts " Restart the server to apply: brainiac restart"
|
|
3596
3746
|
end
|
|
3747
|
+
puts " Restart the server to apply: brainiac restart"
|
|
3597
3748
|
|
|
3598
3749
|
else
|
|
3599
3750
|
puts "Usage: brainiac plugin <command>"
|
data/docs/resume.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Resume Session Integration
|
|
2
|
+
|
|
3
|
+
Brainiac supports resuming prior agent sessions instead of starting fresh. Two patterns exist:
|
|
4
|
+
|
|
5
|
+
## Flag-Based Resume (e.g. Kiro `--resume`, Grok `-c`)
|
|
6
|
+
|
|
7
|
+
The CLI appends a flag to the existing command. The base command structure stays the same.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
kiro-cli --agent sherlock chat --trust-all-tools --no-interactive --resume
|
|
11
|
+
grok --always-approve -c
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Provider config:
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{
|
|
18
|
+
"resume_flag": "--resume"
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Subcommand-Based Resume (e.g. Codex `exec resume --last`)
|
|
23
|
+
|
|
24
|
+
The CLI changes the subcommand entirely. The base command structure is replaced.
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
codex exec --full-auto → codex exec resume --last --full-auto
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Provider config:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"resume_flag": null,
|
|
35
|
+
"resume_args": "exec resume --last --full-auto",
|
|
36
|
+
"session_dir": "~/.codex/sessions"
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
When `resume_args` is set, it replaces `default_args` entirely during resume. The `session_dir` field tells Brainiac where to look for prior sessions (for CLIs that store session state centrally rather than in the project directory).
|
|
41
|
+
|
|
42
|
+
## Plugin Integration Contract
|
|
43
|
+
|
|
44
|
+
Plugins that want to support resume MUST:
|
|
45
|
+
|
|
46
|
+
1. **Use `resume_viable?`** to check whether a session can be resumed:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
can_resume = resume_viable?(project_config: config, chdir: worktree_path)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
2. **Pass `resume: true` to `run_agent`** — core handles the rest:
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
run_agent(prompt, project_config: config, chdir: worktree_path, resume: can_resume)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Plugins MUST NOT:
|
|
59
|
+
- Check `resolved["resume_flag"]` directly (misses `resume_args` providers)
|
|
60
|
+
- Call `resolve_resume` directly (internal to `run_agent`)
|
|
61
|
+
- Build their own resume command logic
|
|
62
|
+
|
|
63
|
+
## How It Works Internally
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
Plugin calls run_agent(..., resume: true)
|
|
67
|
+
→ run_agent calls resolve_resume(true, resolved, chdir)
|
|
68
|
+
→ resolve_resume checks resume_flag OR resume_args is configured
|
|
69
|
+
→ resolve_resume calls prior_session_exists?(chdir, cli, session_dir:)
|
|
70
|
+
→ For centralized session dirs: scans .jsonl files for matching cwd
|
|
71
|
+
→ For local session dirs: checks for .grok/, .kiro-cli/, or recent logs
|
|
72
|
+
→ Returns :resume_args, flag string, or false
|
|
73
|
+
→ run_agent passes result to build_agent_cmd
|
|
74
|
+
→ :resume_args → replace default_args with resume_args
|
|
75
|
+
→ String flag → append to command
|
|
76
|
+
→ false → normal command (no resume)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Session Detection
|
|
80
|
+
|
|
81
|
+
### Local (flag-based CLIs)
|
|
82
|
+
|
|
83
|
+
Checks for CLI-specific dotdirs (`.grok/`, `.kiro-cli/`) or recent agent logs in `tmp/` (last 24 hours).
|
|
84
|
+
|
|
85
|
+
### Centralized (subcommand-based CLIs)
|
|
86
|
+
|
|
87
|
+
Scans `session_dir` for `.jsonl` files modified in the last 24 hours whose first-line JSON metadata contains a `payload.cwd` matching the target working directory. Handles symlink resolution (e.g. `/var/folders` vs `/private/var/folders` on macOS).
|
data/lib/brainiac/cron.rb
CHANGED
|
@@ -596,10 +596,12 @@ def build_cron_agent_cmd(job, project, prompt_file: nil)
|
|
|
596
596
|
resolved = resolve_project_cli_config(project, agent_name: job[:agent])
|
|
597
597
|
agent_flag = resolved.key?("agent_flag") ? resolved["agent_flag"] : "--agent"
|
|
598
598
|
cmd = [resolved["agent_cli"]]
|
|
599
|
+
# cwd_flag: pass the working directory as a CLI argument (e.g. -C for Codex CLI).
|
|
600
|
+
cmd.push(resolved["cwd_flag"], project["repo_path"]) if resolved["cwd_flag"] && project["repo_path"]
|
|
599
601
|
cmd.push(agent_flag, agent_config_name) if agent_flag
|
|
600
602
|
cmd.concat(resolved["agent_cli_args"].split)
|
|
601
603
|
cmd.push(resolved["agent_model_flag"], job[:model]) if resolved["agent_model_flag"]&.length&.positive? && job[:model]
|
|
602
|
-
cmd
|
|
604
|
+
append_effort_to_cmd(cmd, job[:effort], resolved)
|
|
603
605
|
cmd.push(resolved["prompt_flag"], prompt_file) if prompt_file && resolved["prompt_mode"] == "flag" && resolved["prompt_flag"]
|
|
604
606
|
cmd
|
|
605
607
|
end
|
|
@@ -172,37 +172,12 @@ def create_or_reuse_worktree(repo_path:, branch:, base_ref: nil, worktree_path:
|
|
|
172
172
|
base_ref ||= "origin/#{get_default_branch(repo_path)}"
|
|
173
173
|
|
|
174
174
|
worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path)
|
|
175
|
-
|
|
176
|
-
if File.directory?(worktree_path)
|
|
177
|
-
is_tracked = worktree_list.include?(worktree_path)
|
|
178
|
-
|
|
179
|
-
if is_tracked
|
|
180
|
-
LOG.info "Worktree directory #{worktree_path} is tracked by git"
|
|
181
|
-
else
|
|
182
|
-
LOG.warn "Orphaned worktree directory found at #{worktree_path}, removing it"
|
|
183
|
-
begin
|
|
184
|
-
FileUtils.rm_rf(worktree_path)
|
|
185
|
-
LOG.info "Successfully removed orphaned directory"
|
|
186
|
-
rescue StandardError => e
|
|
187
|
-
LOG.error "Failed to remove orphaned directory: #{e.message}"
|
|
188
|
-
raise
|
|
189
|
-
end
|
|
190
|
-
end
|
|
191
|
-
end
|
|
175
|
+
cleanup_orphaned_worktree(worktree_path, worktree_list) if File.directory?(worktree_path)
|
|
192
176
|
|
|
193
177
|
branch_exists = system("git", "rev-parse", "--verify", branch, chdir: repo_path, out: File::NULL, err: File::NULL)
|
|
194
178
|
|
|
195
179
|
if branch_exists
|
|
196
|
-
|
|
197
|
-
worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path)
|
|
198
|
-
has_worktree = worktree_list.lines.any? { |line| line.strip == "worktree #{worktree_path}" }
|
|
199
|
-
|
|
200
|
-
if has_worktree && File.directory?(worktree_path)
|
|
201
|
-
LOG.info "Reusing existing worktree at #{worktree_path}"
|
|
202
|
-
else
|
|
203
|
-
LOG.info "Creating worktree from existing branch #{branch}"
|
|
204
|
-
run_cmd("git", "worktree", "add", worktree_path, branch, chdir: repo_path)
|
|
205
|
-
end
|
|
180
|
+
attach_or_create_worktree_for_branch(repo_path, branch, worktree_path)
|
|
206
181
|
else
|
|
207
182
|
LOG.info "Creating new branch #{branch} and worktree"
|
|
208
183
|
run_cmd("git", "worktree", "add", "-b", branch, worktree_path, base_ref, chdir: repo_path)
|
|
@@ -215,6 +190,56 @@ def create_or_reuse_worktree(repo_path:, branch:, base_ref: nil, worktree_path:
|
|
|
215
190
|
worktree_path
|
|
216
191
|
end
|
|
217
192
|
|
|
193
|
+
# Remove an orphaned worktree directory (exists on disk but not tracked by git).
|
|
194
|
+
# If the directory is tracked, logs and leaves it alone.
|
|
195
|
+
def cleanup_orphaned_worktree(worktree_path, worktree_list)
|
|
196
|
+
resolved_path = begin
|
|
197
|
+
File.realpath(worktree_path)
|
|
198
|
+
rescue StandardError
|
|
199
|
+
worktree_path
|
|
200
|
+
end
|
|
201
|
+
is_tracked = worktree_list.include?(worktree_path) || worktree_list.include?(resolved_path)
|
|
202
|
+
|
|
203
|
+
if is_tracked
|
|
204
|
+
LOG.info "Worktree directory #{worktree_path} is tracked by git"
|
|
205
|
+
else
|
|
206
|
+
LOG.warn "Orphaned worktree directory found at #{worktree_path}, removing it"
|
|
207
|
+
begin
|
|
208
|
+
FileUtils.rm_rf(worktree_path)
|
|
209
|
+
LOG.info "Successfully removed orphaned directory"
|
|
210
|
+
rescue StandardError => e
|
|
211
|
+
LOG.error "Failed to remove orphaned directory: #{e.message}"
|
|
212
|
+
raise
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Attach an existing branch to a worktree, reusing it if already present.
|
|
218
|
+
def attach_or_create_worktree_for_branch(repo_path, branch, worktree_path)
|
|
219
|
+
LOG.info "Branch #{branch} already exists, checking for existing worktree"
|
|
220
|
+
worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path)
|
|
221
|
+
# Resolve symlinks for comparison (macOS /var/folders vs /private/var/folders)
|
|
222
|
+
resolved_wt = begin
|
|
223
|
+
File.realpath(worktree_path)
|
|
224
|
+
rescue StandardError
|
|
225
|
+
worktree_path
|
|
226
|
+
end
|
|
227
|
+
has_worktree = worktree_list.lines.any? do |line|
|
|
228
|
+
stripped = line.strip
|
|
229
|
+
next false unless stripped.start_with?("worktree ")
|
|
230
|
+
|
|
231
|
+
listed_path = stripped.sub("worktree ", "")
|
|
232
|
+
listed_path == worktree_path || listed_path == resolved_wt
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
if has_worktree && File.directory?(worktree_path)
|
|
236
|
+
LOG.info "Reusing existing worktree at #{worktree_path}"
|
|
237
|
+
else
|
|
238
|
+
LOG.info "Creating worktree from existing branch #{branch}"
|
|
239
|
+
run_cmd("git", "worktree", "add", worktree_path, branch, chdir: repo_path)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
218
243
|
# Find an existing worktree for a card by scanning the filesystem.
|
|
219
244
|
def find_worktree_for_card(card_number, repo_path:)
|
|
220
245
|
return nil unless card_number
|
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)
|
|
@@ -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.28
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andy Davis
|
|
@@ -123,6 +123,7 @@ 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
|
|
@@ -134,6 +135,7 @@ files:
|
|
|
134
135
|
- lib/brainiac/helpers.rb
|
|
135
136
|
- lib/brainiac/hooks.rb
|
|
136
137
|
- lib/brainiac/intent.rb
|
|
138
|
+
- lib/brainiac/model_parser.rb
|
|
137
139
|
- lib/brainiac/notifications.rb
|
|
138
140
|
- lib/brainiac/plugins.rb
|
|
139
141
|
- lib/brainiac/prompts.rb
|
|
@@ -159,6 +161,7 @@ files:
|
|
|
159
161
|
- skills/brainiac-plugins/SKILL.md
|
|
160
162
|
- templates/agents.json.example
|
|
161
163
|
- templates/brainiac.json.example
|
|
164
|
+
- templates/cli-providers/codex.json.example
|
|
162
165
|
- templates/cli-providers/grok.json.example
|
|
163
166
|
- templates/cli-providers/kiro.json.example
|
|
164
167
|
- templates/hooks/pre-commit
|