brainiac 0.0.32 → 0.1.0
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 +16 -16
- data/README.md +26 -0
- data/lib/brainiac/helpers.rb +446 -60
- data/lib/brainiac/routes/api.rb +56 -3
- data/lib/brainiac/session_history.rb +202 -0
- data/lib/brainiac/version.rb +1 -1
- data/lib/brainiac.rb +1 -0
- data/receiver.rb +11 -0
- data/templates/cli-providers/kiro.json.example +6 -1
- data/templates/cli-providers/opencode.json.example +19 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 82f144b517fd0197684d1912858134c0a6b440263cf2e6bb43d1c4e2651aec3a
|
|
4
|
+
data.tar.gz: 415ae3b9fad9b4d407e13147ce0c28286b8292897175f45c37d39b4dfbf2cccd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 567146d407026c606a08a9c3d592059a43f195f9785318d3763ad368aab002d16153a2b442cf778a06513b231327552201f71b1d7bb2680036214f0cf842e5cc
|
|
7
|
+
data.tar.gz: a41c6857915e2e9493f45fa0d49755e4958d6c4de6175a7171d7e25a8013ac3b3f498a7ab1933021c09b0bf3a5768bd1542176d59a0e4ad680e186ce7af74545
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
brainiac (0.0
|
|
4
|
+
brainiac (0.1.0)
|
|
5
5
|
puma (~> 7.2)
|
|
6
6
|
rackup (~> 2.3)
|
|
7
7
|
sinatra (~> 4.1)
|
|
@@ -11,22 +11,22 @@ GEM
|
|
|
11
11
|
specs:
|
|
12
12
|
ast (2.4.3)
|
|
13
13
|
base64 (0.3.0)
|
|
14
|
-
json (2.
|
|
15
|
-
language_server-protocol (3.17.0.
|
|
14
|
+
json (2.21.2)
|
|
15
|
+
language_server-protocol (3.17.0.6)
|
|
16
16
|
lint_roller (1.1.0)
|
|
17
17
|
logger (1.7.0)
|
|
18
18
|
minitest (5.27.0)
|
|
19
19
|
mustermann (3.1.1)
|
|
20
20
|
nio4r (2.7.5)
|
|
21
|
-
parallel (2.
|
|
22
|
-
parser (3.3.
|
|
21
|
+
parallel (2.2.0)
|
|
22
|
+
parser (3.3.12.0)
|
|
23
23
|
ast (~> 2.4.1)
|
|
24
24
|
racc
|
|
25
25
|
prism (1.9.0)
|
|
26
26
|
puma (7.2.1)
|
|
27
27
|
nio4r (~> 2.0)
|
|
28
28
|
racc (1.8.1)
|
|
29
|
-
rack (3.2.
|
|
29
|
+
rack (3.2.7)
|
|
30
30
|
rack-protection (4.2.1)
|
|
31
31
|
base64 (>= 0.1.0)
|
|
32
32
|
logger (>= 1.6.0)
|
|
@@ -50,7 +50,7 @@ GEM
|
|
|
50
50
|
rubocop-ast (>= 1.49.0, < 2.0)
|
|
51
51
|
ruby-progressbar (~> 1.7)
|
|
52
52
|
unicode-display_width (>= 2.4.0, < 4.0)
|
|
53
|
-
rubocop-ast (1.
|
|
53
|
+
rubocop-ast (1.50.0)
|
|
54
54
|
parser (>= 3.3.7.2)
|
|
55
55
|
prism (~> 1.7)
|
|
56
56
|
rubocop-performance (1.26.1)
|
|
@@ -65,7 +65,7 @@ GEM
|
|
|
65
65
|
rack-protection (= 4.2.1)
|
|
66
66
|
rack-session (>= 2.0.0, < 3)
|
|
67
67
|
tilt (~> 2.0)
|
|
68
|
-
tilt (2.
|
|
68
|
+
tilt (2.9.0)
|
|
69
69
|
unicode-display_width (3.2.0)
|
|
70
70
|
unicode-emoji (~> 4.1)
|
|
71
71
|
unicode-emoji (4.2.0)
|
|
@@ -84,20 +84,20 @@ DEPENDENCIES
|
|
|
84
84
|
CHECKSUMS
|
|
85
85
|
ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
|
|
86
86
|
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
|
|
87
|
-
brainiac (0.0
|
|
88
|
-
json (2.
|
|
89
|
-
language_server-protocol (3.17.0.
|
|
87
|
+
brainiac (0.1.0)
|
|
88
|
+
json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
|
|
89
|
+
language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0
|
|
90
90
|
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
|
|
91
91
|
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
|
|
92
92
|
minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5
|
|
93
93
|
mustermann (3.1.1) sha256=4c6170c7234d5499c345562ba7c7dfe73e1754286dcc1abb053064d66a127198
|
|
94
94
|
nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
|
|
95
|
-
parallel (2.
|
|
96
|
-
parser (3.3.
|
|
95
|
+
parallel (2.2.0) sha256=e1059c5fd7b649558a0aec38a769f06a42942bdb40503d005a59c352fe011cd8
|
|
96
|
+
parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828
|
|
97
97
|
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
|
|
98
98
|
puma (7.2.1) sha256=d7bf0e9cabd532e0d401e142cd94e3ac531e993610e2d80e6fbf9c26961414b0
|
|
99
99
|
racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
|
|
100
|
-
rack (3.2.
|
|
100
|
+
rack (3.2.7) sha256=93e13e1c24f93556671d85d2d79fa228c3485815c50d7e2f265b5330c6528fb7
|
|
101
101
|
rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac
|
|
102
102
|
rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8
|
|
103
103
|
rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868
|
|
@@ -105,11 +105,11 @@ CHECKSUMS
|
|
|
105
105
|
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
|
|
106
106
|
regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb
|
|
107
107
|
rubocop (1.88.0) sha256=e420ddf1662d0ef34bc8a2910ac4b396a7ddda0b51a708264405241734b08e0b
|
|
108
|
-
rubocop-ast (1.
|
|
108
|
+
rubocop-ast (1.50.0) sha256=b9ca88300da0803ee222ad20cdb30494c0a784eed06fdc35d254b06d662788db
|
|
109
109
|
rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834
|
|
110
110
|
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
|
|
111
111
|
sinatra (4.2.1) sha256=b7aeb9b11d046b552972ade834f1f9be98b185fa8444480688e3627625377080
|
|
112
|
-
tilt (2.
|
|
112
|
+
tilt (2.9.0) sha256=da5735d0280bba96e9a91041bb14aee435ccad5c17b0fa519249ae543d9aa3a5
|
|
113
113
|
unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
|
|
114
114
|
unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
|
|
115
115
|
|
data/README.md
CHANGED
|
@@ -1270,6 +1270,32 @@ curl "http://localhost:4567/api/gif?q=excited" # Search for GIFs (r
|
|
|
1270
1270
|
curl http://localhost:4567/api/cron # Cron jobs and thread status
|
|
1271
1271
|
curl http://localhost:4567/api/logs # Read log files
|
|
1272
1272
|
curl http://localhost:4567/api/status # Active agent sessions (used by monitor)
|
|
1273
|
+
curl "http://localhost:4567/api/sessions/history?limit=25" # Durable session history (survives restarts)
|
|
1274
|
+
```
|
|
1275
|
+
|
|
1276
|
+
### Session History
|
|
1277
|
+
|
|
1278
|
+
`/api/status` returns only *live* sessions plus the last 10 finished ones held in memory
|
|
1279
|
+
(wiped on every restart). For durable, restart-surviving history use
|
|
1280
|
+
`GET /api/sessions/history?limit=N`, backed by an append-only JSONL log at
|
|
1281
|
+
`~/.brainiac/session-history.jsonl`.
|
|
1282
|
+
|
|
1283
|
+
Every finished agent session is recorded with timing, identity (agent, CLI, model, source),
|
|
1284
|
+
exit status, and — where the provider exposes it — **session heaviness**: context window
|
|
1285
|
+
usage and credits spent. Heaviness is captured at completion time via a provider-specific
|
|
1286
|
+
probe that is **declared in the provider config**, not hardcoded in brainiac. Add a
|
|
1287
|
+
`heaviness_probe` block to `~/.brainiac/cli-providers/<name>.json`:
|
|
1288
|
+
|
|
1289
|
+
```json
|
|
1290
|
+
"heaviness_probe": { "type": "kiro_sqlite", "db_path": "~/.local/share/kiro-cli/data.sqlite3" }
|
|
1291
|
+
```
|
|
1292
|
+
|
|
1293
|
+
`type` selects the probe strategy and `db_path` points at the provider's local usage store,
|
|
1294
|
+
so enabling/disabling a provider or moving its data store is pure JSON. kiro-cli ships with a
|
|
1295
|
+
`kiro_sqlite` probe (reads its local conversation sqlite, read-only). A genuinely new storage
|
|
1296
|
+
mechanism needs a small probe implementation in `lib/brainiac/session_history.rb`; providers
|
|
1297
|
+
with no `heaviness_probe` simply record timing + identity. The file is trimmed to the most
|
|
1298
|
+
recent 500 records.
|
|
1273
1299
|
```
|
|
1274
1300
|
|
|
1275
1301
|
## Development
|
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 heaviness_probe].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: {})
|
|
@@ -444,6 +507,74 @@ rescue StandardError => e
|
|
|
444
507
|
nil
|
|
445
508
|
end
|
|
446
509
|
|
|
510
|
+
# Detect when an agent exited cleanly (exit 0) but produced no usable response.
|
|
511
|
+
# This covers cases the crash path misses because the exit code is 0 — most notably a
|
|
512
|
+
# CLI that prints a request/usage-limit notice and exits without doing any work.
|
|
513
|
+
#
|
|
514
|
+
# Returns a short human-readable reason string when the session yielded no response,
|
|
515
|
+
# or nil when the session did produce output (i.e. nothing to surface).
|
|
516
|
+
#
|
|
517
|
+
# @param log_file [String, nil] Path to the agent's log file
|
|
518
|
+
# @param output_content [String, nil] Structured output captured via --output-last-message, if any
|
|
519
|
+
NO_OUTPUT_LIMIT_PATTERNS = [
|
|
520
|
+
/\bmonthly request limit reached\b/i,
|
|
521
|
+
/\brequest limit reached\b/i,
|
|
522
|
+
/\busage limit\b/i,
|
|
523
|
+
/\brate limit(?:ed|ing)?\b/i,
|
|
524
|
+
/\bquota (?:exceeded|reached)\b/i,
|
|
525
|
+
/\btoo many requests\b/i
|
|
526
|
+
].freeze
|
|
527
|
+
|
|
528
|
+
def detect_no_output_reason(log_file, output_content)
|
|
529
|
+
# If we captured a structured response, the session clearly produced output.
|
|
530
|
+
return nil if output_content && !output_content.strip.empty?
|
|
531
|
+
|
|
532
|
+
tail = extract_crash_snippet(log_file, max_lines: 40)
|
|
533
|
+
return nil if tail.nil?
|
|
534
|
+
|
|
535
|
+
limit_line = tail.lines.map(&:strip).reverse.find do |line|
|
|
536
|
+
NO_OUTPUT_LIMIT_PATTERNS.any? { |re| line.match?(re) }
|
|
537
|
+
end
|
|
538
|
+
return "hit a request/usage limit: #{limit_line}" if limit_line
|
|
539
|
+
|
|
540
|
+
# No structured output and no recognizable limit notice. Treat a log that carries no
|
|
541
|
+
# assistant text as "produced no response" so the channel isn't left silent. We can't
|
|
542
|
+
# parse every CLI's transcript here, so this is best-effort: only flag when the log is
|
|
543
|
+
# effectively empty of content beyond startup/boilerplate lines.
|
|
544
|
+
meaningful = tail.lines.map(&:strip).reject do |line|
|
|
545
|
+
line.empty? ||
|
|
546
|
+
line.start_with?("---", "▸") ||
|
|
547
|
+
line.match?(/all tools are now trusted/i) ||
|
|
548
|
+
line.match?(/mcp server did not load/i) ||
|
|
549
|
+
line.match?(/agents can sometimes do unexpected things/i) ||
|
|
550
|
+
line.match?(%r{learn more at https?://}i)
|
|
551
|
+
end
|
|
552
|
+
return "produced no response (log contained no agent output)" if meaningful.empty?
|
|
553
|
+
|
|
554
|
+
nil
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
# Surface a clean-exit-but-no-response session to the originating channel. Reuses the
|
|
558
|
+
# crash notification delivery path (the :agent_crashed hook) so plugins deliver it the
|
|
559
|
+
# same way they deliver failures, but frames it as "no response" rather than a crash.
|
|
560
|
+
def notify_agent_no_output(reason:, log_file:, agent_name:, source:, source_context:, project_config:)
|
|
561
|
+
agent_display = agent_name || "Agent"
|
|
562
|
+
snippet = extract_crash_snippet(log_file)
|
|
563
|
+
|
|
564
|
+
handled = Brainiac.emit(:agent_crashed,
|
|
565
|
+
exit_status: 0, log_file: log_file, agent_name: agent_display,
|
|
566
|
+
source: source, source_context: source_context, project_config: project_config,
|
|
567
|
+
snippet: snippet, no_output: true, no_output_reason: reason)
|
|
568
|
+
|
|
569
|
+
if handled.any?
|
|
570
|
+
LOG.info "[NoOutput] #{agent_display} #{reason} — notified via #{source}"
|
|
571
|
+
else
|
|
572
|
+
LOG.warn "[NoOutput] #{agent_display} #{reason} but no plugin handled notification (source: #{source})"
|
|
573
|
+
end
|
|
574
|
+
rescue StandardError => e
|
|
575
|
+
LOG.error "[NoOutput] Unexpected error: #{e.message}"
|
|
576
|
+
end
|
|
577
|
+
|
|
447
578
|
# Notify the originating channel that an agent crashed.
|
|
448
579
|
# source: :github, :discord, or plugin-registered sources
|
|
449
580
|
# source_context: hash with channel-specific info needed to post the notification
|
|
@@ -561,11 +692,24 @@ end
|
|
|
561
692
|
def resume_viable?(project_config:, cli_provider: nil, agent_name: nil, chdir: nil)
|
|
562
693
|
resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider, agent_name: agent_name)
|
|
563
694
|
chdir ||= resolved["repo_path"]
|
|
695
|
+
return stored_cli_session_viable?(resolved, chdir) if resolved["resume_id_flag"]
|
|
564
696
|
return false unless resolved["resume_flag"] || resolved["resume_args"]
|
|
565
697
|
|
|
566
698
|
prior_session_exists?(chdir, resolved["agent_cli"], session_dir: resolved["session_dir"])
|
|
567
699
|
end
|
|
568
700
|
|
|
701
|
+
# True when this CLI resumes by session id AND the work item has a still-existing id.
|
|
702
|
+
def stored_cli_session_viable?(resolved, chdir)
|
|
703
|
+
session_id = cli_session_id_for(agent_cli: resolved["agent_cli"], worktree: chdir)
|
|
704
|
+
return false if session_id.to_s.empty?
|
|
705
|
+
return true unless resolved["session_list_command"]
|
|
706
|
+
|
|
707
|
+
sessions = list_cli_sessions(resolved["session_list_command"], **session_list_fields(resolved))
|
|
708
|
+
return false unless sessions
|
|
709
|
+
|
|
710
|
+
sessions.any? { |s| s["id"] == session_id }
|
|
711
|
+
end
|
|
712
|
+
|
|
569
713
|
# Determine whether a session resume should actually happen.
|
|
570
714
|
# Called by run_agent — plugins should NOT call this directly; pass `resume: true` to run_agent.
|
|
571
715
|
#
|
|
@@ -573,8 +717,19 @@ end
|
|
|
573
717
|
# - :resume_args — when the provider uses subcommand-based resume (build_agent_cmd replaces default_args)
|
|
574
718
|
# - String (the resume flag) — when the provider uses flag-based resume (appended to cmd)
|
|
575
719
|
# - false — when resume was not requested or not viable
|
|
576
|
-
def resolve_resume(resume, resolved, chdir)
|
|
577
|
-
return false unless resume
|
|
720
|
+
def resolve_resume(resume, resolved, chdir, session_id: nil)
|
|
721
|
+
return false unless resume
|
|
722
|
+
|
|
723
|
+
if resolved["resume_id_flag"]
|
|
724
|
+
id = session_id.to_s.empty? ? cli_session_id_for(agent_cli: resolved["agent_cli"], worktree: chdir) : session_id
|
|
725
|
+
if id.to_s.empty?
|
|
726
|
+
LOG.info "[Dispatch] Resume requested but no stored #{resolved["agent_cli"]} session for #{chdir} — starting fresh"
|
|
727
|
+
return false
|
|
728
|
+
end
|
|
729
|
+
return { "flag" => resolved["resume_id_flag"], "id" => id }
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
return false unless resolved["resume_flag"] || resolved["resume_args"]
|
|
578
733
|
if prior_session_exists?(chdir, resolved["agent_cli"], session_dir: resolved["session_dir"])
|
|
579
734
|
# Return :resume_args when the provider uses subcommand-based resume (e.g. Codex exec resume)
|
|
580
735
|
return resolved["resume_args"] ? :resume_args : resolved["resume_flag"]
|
|
@@ -584,6 +739,16 @@ def resolve_resume(resume, resolved, chdir)
|
|
|
584
739
|
false
|
|
585
740
|
end
|
|
586
741
|
|
|
742
|
+
# Some CLIs (e.g. grok --session-id) let the caller name a new session up front.
|
|
743
|
+
# Mint a UUID only on the first run, when we are not resuming an existing id.
|
|
744
|
+
def mint_cli_session_id(resolved, stored_session:, resuming:)
|
|
745
|
+
return nil if resuming
|
|
746
|
+
return nil unless resolved["new_session_id_flag"]
|
|
747
|
+
return nil unless stored_session.to_s.empty?
|
|
748
|
+
|
|
749
|
+
SecureRandom.uuid
|
|
750
|
+
end
|
|
751
|
+
|
|
587
752
|
# Check if intent detection says to skip dispatching the agent.
|
|
588
753
|
# Returns true if the message should be skipped, false otherwise.
|
|
589
754
|
# Only runs if a raw message is provided, an agent is named, and intent is enabled.
|
|
@@ -619,8 +784,12 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
|
|
|
619
784
|
effort ||= resolved["agent_effort"]
|
|
620
785
|
agent_config_name = agent_name&.downcase&.gsub(/[^a-z0-9-]/, "-")
|
|
621
786
|
|
|
787
|
+
stored_session = cli_session_id_for(agent_cli: resolved["agent_cli"], worktree: chdir)
|
|
788
|
+
work_item_id = find_work_item_by_worktree(chdir)&.first
|
|
789
|
+
|
|
622
790
|
# 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)
|
|
791
|
+
should_resume = resolve_resume(resume, resolved, chdir, session_id: stored_session)
|
|
792
|
+
minted_session_id = mint_cli_session_id(resolved, stored_session: stored_session, resuming: should_resume)
|
|
624
793
|
|
|
625
794
|
# Pre-dispatch hook — plugins can prep the working directory (e.g., copy config files, clean up)
|
|
626
795
|
Brainiac.emit(:pre_dispatch, chdir: chdir, project_config: project_config, agent_name: agent_name)
|
|
@@ -634,37 +803,31 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
|
|
|
634
803
|
|
|
635
804
|
cmd = build_agent_cmd(resolved, agent_config_name: agent_config_name, model: model, effort: effort,
|
|
636
805
|
prompt_file: prompt_file, resume: should_resume,
|
|
637
|
-
output_file: output_file, chdir: chdir
|
|
638
|
-
|
|
639
|
-
|
|
806
|
+
output_file: output_file, chdir: chdir, title: work_item_id,
|
|
807
|
+
new_session_id: minted_session_id)
|
|
640
808
|
spawn_env = agent_env_for(agent_name).merge(env)
|
|
641
809
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
810
|
+
log_agent_launch(resolved: resolved, chdir: chdir, log_file: log_file, prompt_file: prompt_file,
|
|
811
|
+
output_file: output_file, cmd: cmd, should_resume: should_resume,
|
|
812
|
+
spawn_env: spawn_env, agent_name: agent_name)
|
|
645
813
|
|
|
646
814
|
project_key_for_restart = PROJECTS.find { |_k, v| v == project_config }&.first
|
|
647
815
|
head_before, status_before = capture_git_state(chdir) if project_key_for_restart == "brainiac"
|
|
648
816
|
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
output_file: output_file,
|
|
664
|
-
head_before: head_before, status_before: status_before,
|
|
665
|
-
project_key_for_restart: project_key_for_restart
|
|
666
|
-
)
|
|
667
|
-
end
|
|
817
|
+
started_at = Time.now
|
|
818
|
+
stdin_redirect = (resolved["prompt_mode"] || "stdin") == "stdin" ? { in: prompt_file } : {}
|
|
819
|
+
pid = spawn(spawn_env, *cmd, chdir: chdir, **stdin_redirect, out: [log_file, "w"], err: %i[child out])
|
|
820
|
+
|
|
821
|
+
spawn_completion_watcher(
|
|
822
|
+
pid: pid, agent_cli: resolved["agent_cli"], agent_config_name: agent_config_name,
|
|
823
|
+
agent_name: agent_name, log_file: log_file, log_name: log_name,
|
|
824
|
+
prompt_file: prompt_file, chdir: chdir, source: source,
|
|
825
|
+
source_context: source_context, project_config: project_config,
|
|
826
|
+
card_number: card_number, skip_column_move: skip_column_move,
|
|
827
|
+
output_file: output_file, resolved: resolved, minted_session_id: minted_session_id,
|
|
828
|
+
head_before: head_before, status_before: status_before,
|
|
829
|
+
project_key_for_restart: project_key_for_restart, model: model, started_at: started_at
|
|
830
|
+
)
|
|
668
831
|
|
|
669
832
|
LOG.info "#{resolved["agent_cli"]} started (pid: #{pid}, agent: #{agent_config_name || "default"}, " \
|
|
670
833
|
"model: #{model || "default"}), tail -f #{log_file}"
|
|
@@ -672,6 +835,28 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
|
|
|
672
835
|
[pid, log_file]
|
|
673
836
|
end
|
|
674
837
|
|
|
838
|
+
# Spawn a background thread that waits for the agent process to finish and runs completion handling.
|
|
839
|
+
def spawn_completion_watcher(**ctx)
|
|
840
|
+
thread = Thread.new do
|
|
841
|
+
Process.wait(ctx[:pid])
|
|
842
|
+
handle_agent_completion(**ctx)
|
|
843
|
+
end
|
|
844
|
+
# Without this, an unrescued exception anywhere in handle_agent_completion silently
|
|
845
|
+
# kills the thread and is never logged — which previously hid completion-path failures
|
|
846
|
+
# (e.g. a step raising before session history was archived).
|
|
847
|
+
thread.report_on_exception = true
|
|
848
|
+
thread
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
# Log the details of an agent launch (command, prompt/output files, injected env).
|
|
852
|
+
def log_agent_launch(resolved:, chdir:, log_file:, prompt_file:, output_file:, cmd:, should_resume:, spawn_env:, agent_name:)
|
|
853
|
+
LOG.info "Running #{resolved["agent_cli"]} in #{chdir}, logging to #{log_file}"
|
|
854
|
+
LOG.info "Prompt: #{prompt_file} | Output: #{output_file || "none"} | Command: #{cmd.join(" ")}#{" (resuming session)" if should_resume}"
|
|
855
|
+
return if spawn_env.empty?
|
|
856
|
+
|
|
857
|
+
LOG.info "Injecting #{spawn_env.size} env var(s) for agent #{agent_name}: #{spawn_env.keys.join(", ")}"
|
|
858
|
+
end
|
|
859
|
+
|
|
675
860
|
# Write agent prompt to a temp file, return path.
|
|
676
861
|
def write_agent_prompt_file(prompt, log_name, timestamp)
|
|
677
862
|
prompt_dir = File.join(BRAINIAC_DIR, "tmp")
|
|
@@ -717,7 +902,8 @@ end
|
|
|
717
902
|
# When chdir is provided and the provider has a cwd_flag, appends it so the CLI
|
|
718
903
|
# itself switches to the working directory (e.g. `codex -C /path/to/project`).
|
|
719
904
|
# 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,
|
|
905
|
+
def build_agent_cmd(resolved, agent_config_name: nil, model: nil, effort: nil, prompt_file: nil, resume: false,
|
|
906
|
+
output_file: nil, chdir: nil, title: nil, new_session_id: nil)
|
|
721
907
|
cmd = [resolved["agent_cli"]]
|
|
722
908
|
# cwd_flag: pass the working directory as a CLI argument (e.g. -C for Codex CLI).
|
|
723
909
|
# This is added early so it appears before subcommands/args (global option).
|
|
@@ -730,25 +916,44 @@ def build_agent_cmd(resolved, agent_config_name: nil, model: nil, effort: nil, p
|
|
|
730
916
|
# e.g. "exec --full-auto" becomes "exec resume --last --full-auto"
|
|
731
917
|
args = resume == :resume_args && resolved["resume_args"] ? resolved["resume_args"] : resolved["agent_cli_args"]
|
|
732
918
|
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
|
|
919
|
+
append_model_to_cmd(cmd, model, resolved)
|
|
743
920
|
append_effort_to_cmd(cmd, effort, resolved) if effort
|
|
744
|
-
|
|
745
|
-
|
|
921
|
+
cmd.push(resolved["title_flag"], title) if title && resolved["title_flag"]
|
|
922
|
+
if new_session_id && resolved["new_session_id_flag"] && !(resume.is_a?(Hash) || resume.is_a?(String) || resume == :resume_args)
|
|
923
|
+
cmd.push(resolved["new_session_id_flag"], new_session_id)
|
|
924
|
+
end
|
|
925
|
+
append_resume_to_cmd(cmd, resume)
|
|
746
926
|
# prompt_mode: "flag" passes the prompt file path via the configured prompt_flag (e.g. --prompt-file).
|
|
747
927
|
cmd.push(resolved["prompt_flag"], prompt_file) if prompt_file && resolved["prompt_mode"] == "flag" && resolved["prompt_flag"]
|
|
748
928
|
# output_last_message_flag: capture the agent's final message to a file (e.g. codex exec -o <path>).
|
|
749
929
|
cmd.push(resolved["output_last_message_flag"], output_file) if output_file && resolved["output_last_message_flag"]
|
|
750
930
|
cmd
|
|
751
931
|
end
|
|
932
|
+
|
|
933
|
+
# Append the --model flag when a valid model ID is resolved for this provider.
|
|
934
|
+
# "auto" means "let the CLI choose" — skip passing it unless the provider maps it.
|
|
935
|
+
def append_model_to_cmd(cmd, model, resolved)
|
|
936
|
+
return unless model && resolved["agent_model_flag"] && !resolved["agent_model_flag"].empty?
|
|
937
|
+
|
|
938
|
+
allowed = resolved["allowed_models"] || {}
|
|
939
|
+
# If the model is a key in allowed_models, use the mapped value (e.g. "auto" -> "o4-mini").
|
|
940
|
+
# This handles cases where different projects use "auto" but each CLI provider maps it differently.
|
|
941
|
+
effective_model = allowed.key?(model) ? allowed[model] : model
|
|
942
|
+
is_known = allowed.value?(effective_model) || allowed.key?(effective_model)
|
|
943
|
+
cmd.push(resolved["agent_model_flag"], effective_model) if is_known
|
|
944
|
+
end
|
|
945
|
+
|
|
946
|
+
# Resume via --session <id> (OpenCode), a bare flag (grok -c), or resume_args (already applied).
|
|
947
|
+
def append_resume_to_cmd(cmd, resume)
|
|
948
|
+
case resume
|
|
949
|
+
when Hash
|
|
950
|
+
flag = resume["flag"] || resume[:flag]
|
|
951
|
+
id = resume["id"] || resume[:id]
|
|
952
|
+
cmd.push(flag, id) if flag && id
|
|
953
|
+
when String
|
|
954
|
+
cmd.push(resume)
|
|
955
|
+
end
|
|
956
|
+
end
|
|
752
957
|
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
753
958
|
|
|
754
959
|
# Map a Brainiac effort level through the provider's effort_map (if any).
|
|
@@ -778,23 +983,130 @@ def append_effort_to_cmd(cmd, effort, resolved)
|
|
|
778
983
|
end
|
|
779
984
|
end
|
|
780
985
|
|
|
781
|
-
#
|
|
986
|
+
# Parse a CLI session list as JSON, ignoring non-JSON prefixes (e.g. mise).
|
|
987
|
+
# Normalizes OpenCode `{id, directory, updated}` and kiro `{sessionId, cwd, updatedAt}`
|
|
988
|
+
# (including kiro's `[{cwd, sessions: [...]}]` envelopes) into a flat array of
|
|
989
|
+
# `{ "id", "directory", "updated" }` hashes. Provider JSON can override field names.
|
|
990
|
+
def parse_session_list_output(output, id_field: nil, directory_field: nil, updated_field: nil, list_path: nil)
|
|
991
|
+
return nil if output.nil? || output.strip.empty?
|
|
992
|
+
|
|
993
|
+
json_start = output.index("[") || output.index("{")
|
|
994
|
+
return nil unless json_start
|
|
995
|
+
|
|
996
|
+
data = JSON.parse(output[json_start..])
|
|
997
|
+
rows = data.is_a?(Array) ? data : Array(data["sessions"] || data["data"])
|
|
998
|
+
return nil unless rows.is_a?(Array)
|
|
999
|
+
|
|
1000
|
+
flatten_session_rows(rows, id_field: id_field, directory_field: directory_field,
|
|
1001
|
+
updated_field: updated_field, list_path: list_path)
|
|
1002
|
+
rescue JSON::ParserError
|
|
1003
|
+
nil
|
|
1004
|
+
end
|
|
1005
|
+
|
|
1006
|
+
def flatten_session_rows(rows, id_field: nil, directory_field: nil, updated_field: nil, list_path: nil)
|
|
1007
|
+
nested_key = list_path.to_s.empty? ? "sessions" : list_path
|
|
1008
|
+
fields = { id_field: id_field, directory_field: directory_field, updated_field: updated_field }
|
|
1009
|
+
rows.flat_map do |row|
|
|
1010
|
+
next [] unless row.is_a?(Hash)
|
|
1011
|
+
|
|
1012
|
+
nested = row[nested_key]
|
|
1013
|
+
if nested.is_a?(Array)
|
|
1014
|
+
parent_dir = session_value(row, directory_field, %w[directory dir cwd])
|
|
1015
|
+
nested.filter_map { |item| normalize_session_row(item, parent_dir: parent_dir, **fields) }
|
|
1016
|
+
else
|
|
1017
|
+
[normalize_session_row(row, **fields)].compact
|
|
1018
|
+
end
|
|
1019
|
+
end
|
|
1020
|
+
end
|
|
1021
|
+
|
|
1022
|
+
def normalize_session_row(row, id_field: nil, directory_field: nil, updated_field: nil, parent_dir: nil)
|
|
1023
|
+
return nil unless row.is_a?(Hash)
|
|
1024
|
+
|
|
1025
|
+
id = session_value(row, id_field, %w[id sessionId session_id])
|
|
1026
|
+
return nil if id.to_s.empty?
|
|
1027
|
+
|
|
1028
|
+
{
|
|
1029
|
+
"id" => id.to_s,
|
|
1030
|
+
"directory" => session_value(row, directory_field, %w[directory dir cwd]) || parent_dir,
|
|
1031
|
+
"updated" => session_value(row, updated_field, %w[updated time_updated updatedAt updated_at]) || 0
|
|
1032
|
+
}
|
|
1033
|
+
end
|
|
1034
|
+
|
|
1035
|
+
def session_value(row, explicit_field, fallbacks)
|
|
1036
|
+
return row[explicit_field] if explicit_field && !explicit_field.to_s.empty? && row.key?(explicit_field)
|
|
1037
|
+
|
|
1038
|
+
fallbacks.each { |key| return row[key] if row.key?(key) }
|
|
1039
|
+
nil
|
|
1040
|
+
end
|
|
1041
|
+
|
|
1042
|
+
def session_list_fields(resolved)
|
|
1043
|
+
{
|
|
1044
|
+
id_field: resolved["session_id_field"],
|
|
1045
|
+
directory_field: resolved["session_directory_field"],
|
|
1046
|
+
updated_field: resolved["session_updated_field"],
|
|
1047
|
+
list_path: resolved["session_list_path"]
|
|
1048
|
+
}
|
|
1049
|
+
end
|
|
1050
|
+
|
|
1051
|
+
def list_cli_sessions(command, **fields)
|
|
1052
|
+
stdout, stderr, status = Open3.capture3(command)
|
|
1053
|
+
unless status.success?
|
|
1054
|
+
LOG.warn "[Session] session_list_command failed (exit #{status.exitstatus}): #{stderr.strip}"
|
|
1055
|
+
return nil
|
|
1056
|
+
end
|
|
1057
|
+
|
|
1058
|
+
parse_session_list_output(stdout, **fields)
|
|
1059
|
+
end
|
|
1060
|
+
|
|
1061
|
+
# Most recently updated session whose directory matches chdir.
|
|
1062
|
+
# If no session has a directory (CLI already scoped the list to cwd), pick the newest overall.
|
|
1063
|
+
def select_session_id_for_directory(sessions, chdir)
|
|
1064
|
+
return nil if sessions.nil? || sessions.empty?
|
|
1065
|
+
|
|
1066
|
+
with_ids = sessions.select { |session| session.is_a?(Hash) && !session["id"].to_s.empty? }
|
|
1067
|
+
return nil if with_ids.empty?
|
|
1068
|
+
|
|
1069
|
+
if chdir
|
|
1070
|
+
target = path_for_compare(chdir)
|
|
1071
|
+
matching = with_ids.select { |session| session["directory"] && path_for_compare(session["directory"]) == target }
|
|
1072
|
+
with_ids = matching unless matching.empty?
|
|
1073
|
+
end
|
|
1074
|
+
|
|
1075
|
+
newest = with_ids.max_by { |session| session["updated"] || 0 }
|
|
1076
|
+
newest && newest["id"]
|
|
1077
|
+
end
|
|
1078
|
+
|
|
1079
|
+
# After a run, remember the CLI session id on the work item so later dispatches
|
|
1080
|
+
# can pass --session/--resume-id <id> instead of continuing whichever session was last globally.
|
|
1081
|
+
def capture_cli_session_id(resolved:, chdir:, minted_session_id: nil)
|
|
1082
|
+
if minted_session_id
|
|
1083
|
+
update_work_item_cli_session(agent_cli: resolved["agent_cli"], session_id: minted_session_id, worktree: chdir)
|
|
1084
|
+
return
|
|
1085
|
+
end
|
|
1086
|
+
return unless resolved["session_list_command"] && resolved["resume_id_flag"]
|
|
1087
|
+
|
|
1088
|
+
sessions = list_cli_sessions(resolved["session_list_command"], **session_list_fields(resolved))
|
|
1089
|
+
session_id = select_session_id_for_directory(sessions, chdir)
|
|
1090
|
+
unless session_id
|
|
1091
|
+
LOG.info "[Session] No CLI session found for #{chdir}"
|
|
1092
|
+
return
|
|
1093
|
+
end
|
|
1094
|
+
|
|
1095
|
+
update_work_item_cli_session(agent_cli: resolved["agent_cli"], session_id: session_id, worktree: chdir)
|
|
1096
|
+
end
|
|
1097
|
+
|
|
782
1098
|
def handle_agent_completion(**ctx)
|
|
783
1099
|
agent_exit_status = $CHILD_STATUS.exitstatus
|
|
784
1100
|
agent_signaled = $CHILD_STATUS.signaled?
|
|
785
1101
|
LOG.info "#{ctx[:agent_cli]} finished (pid: #{ctx[:pid]}, exit: #{agent_exit_status})"
|
|
786
1102
|
|
|
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
|
|
1103
|
+
notify_crash_if_needed(ctx, agent_exit_status, agent_signaled)
|
|
794
1104
|
|
|
795
1105
|
# Read structured output if the provider wrote to an output file (--output-last-message).
|
|
796
1106
|
output_content = read_output_file(ctx[:output_file])
|
|
797
1107
|
|
|
1108
|
+
capture_session_id_if_possible(ctx)
|
|
1109
|
+
|
|
798
1110
|
# Emit lifecycle hook — plugins handle post-session actions (e.g., plugin moves card, appends footer)
|
|
799
1111
|
Brainiac.emit(:agent_completed,
|
|
800
1112
|
card_number: ctx[:card_number] || ctx[:source_context]&.dig(:card_number),
|
|
@@ -813,22 +1125,96 @@ def handle_agent_completion(**ctx)
|
|
|
813
1125
|
# Clean up the output file after hook emission (content already captured above).
|
|
814
1126
|
FileUtils.rm_f(ctx[:output_file]) if ctx[:output_file]
|
|
815
1127
|
|
|
1128
|
+
run_post_completion_steps(ctx, exit_status: agent_exit_status, signaled: agent_signaled, output_content: output_content)
|
|
1129
|
+
end
|
|
1130
|
+
|
|
1131
|
+
# Independent, best-effort steps that run after the lifecycle hook. Each is isolated via
|
|
1132
|
+
# completion_step so a failure in one (e.g. a raising notify path) can't abort the rest —
|
|
1133
|
+
# notably session-history archival, which must always run regardless of earlier steps.
|
|
1134
|
+
def run_post_completion_steps(ctx, exit_status:, signaled:, output_content:)
|
|
1135
|
+
# Clean exit (0) but no usable response — e.g. the CLI hit a request/usage limit and
|
|
1136
|
+
# bailed without doing work. The crash path only fires on non-zero exits, so this would
|
|
1137
|
+
# otherwise be silent. Surface it to the originating channel.
|
|
1138
|
+
completion_step("notify_no_output") do
|
|
1139
|
+
notify_no_output_if_needed(ctx, exit_status: exit_status, signaled: signaled, output_content: output_content)
|
|
1140
|
+
end
|
|
1141
|
+
|
|
1142
|
+
# Durable session history — record every finished session to disk (survives restarts),
|
|
1143
|
+
# including provider-specific "heaviness" (context window usage + credits). Best-effort.
|
|
1144
|
+
completion_step("archive_session_history") do
|
|
1145
|
+
archive_session_history(ctx: ctx, exit_status: exit_status, signaled: signaled)
|
|
1146
|
+
end
|
|
1147
|
+
|
|
1148
|
+
completion_step("qmd_update") { run_qmd_update(ctx[:agent_config_name]) }
|
|
1149
|
+
completion_step("skill_candidate") { log_skill_candidate(ctx[:log_file]) }
|
|
1150
|
+
completion_step("brain_push") { brain_push(message: "#{ctx[:agent_config_name] || "agent"}: #{ctx[:log_name]}") }
|
|
1151
|
+
# check_brainiac_restart(ctx[:head_before], ctx[:status_before], ctx[:chdir], ctx[:project_key_for_restart], ctx[:agent_config_name])
|
|
1152
|
+
end
|
|
1153
|
+
|
|
1154
|
+
# Run an independent post-completion step, logging and swallowing any failure so one
|
|
1155
|
+
# broken step (e.g. a plugin-facing call raising) can't abort the remaining steps —
|
|
1156
|
+
# notably session-history archival, which must not depend on earlier steps succeeding.
|
|
1157
|
+
def completion_step(name)
|
|
1158
|
+
yield
|
|
1159
|
+
rescue StandardError => e
|
|
1160
|
+
LOG.error "[Completion] step '#{name}' failed: #{e.class}: #{e.message}"
|
|
1161
|
+
LOG.error "[Completion] #{e.backtrace.first(3).join("\n ")}" if e.backtrace
|
|
1162
|
+
nil
|
|
1163
|
+
end
|
|
1164
|
+
|
|
1165
|
+
# Surface a clean-exit session that produced no response, pulling fields from the
|
|
1166
|
+
# completion context. Only fires for exit 0 (non-zero already goes through the crash
|
|
1167
|
+
# path) with a source to notify. Extracted from handle_agent_completion.
|
|
1168
|
+
def notify_no_output_if_needed(ctx, exit_status:, signaled:, output_content:)
|
|
1169
|
+
return unless ctx[:source]
|
|
1170
|
+
return if signaled
|
|
1171
|
+
return unless exit_status&.zero?
|
|
1172
|
+
|
|
1173
|
+
reason = detect_no_output_reason(ctx[:log_file], output_content)
|
|
1174
|
+
return unless reason
|
|
1175
|
+
|
|
1176
|
+
notify_agent_no_output(
|
|
1177
|
+
reason: reason, log_file: ctx[:log_file], agent_name: ctx[:agent_name],
|
|
1178
|
+
source: ctx[:source], source_context: ctx[:source_context], project_config: ctx[:project_config]
|
|
1179
|
+
)
|
|
1180
|
+
end
|
|
1181
|
+
|
|
1182
|
+
# Capture the CLI session id after a run, if the provider and working dir are known.
|
|
1183
|
+
def capture_session_id_if_possible(ctx)
|
|
1184
|
+
return unless ctx[:resolved] && ctx[:chdir]
|
|
1185
|
+
|
|
1186
|
+
capture_cli_session_id(resolved: ctx[:resolved], chdir: ctx[:chdir], minted_session_id: ctx[:minted_session_id])
|
|
1187
|
+
end
|
|
1188
|
+
|
|
1189
|
+
# Notify plugins of a crashed agent session (non-zero exit, not signaled).
|
|
1190
|
+
def notify_crash_if_needed(ctx, agent_exit_status, agent_signaled)
|
|
1191
|
+
return unless ctx[:source] && agent_exit_status && agent_exit_status != 0 && !agent_signaled
|
|
1192
|
+
|
|
1193
|
+
notify_agent_crash(
|
|
1194
|
+
exit_status: agent_exit_status, log_file: ctx[:log_file],
|
|
1195
|
+
agent_name: ctx[:agent_name], source: ctx[:source], source_context: ctx[:source_context],
|
|
1196
|
+
project_config: ctx[:project_config]
|
|
1197
|
+
)
|
|
1198
|
+
end
|
|
1199
|
+
|
|
1200
|
+
# Run `qmd update` to reindex the brain after a session, logging the outcome.
|
|
1201
|
+
def run_qmd_update(agent_config_name)
|
|
816
1202
|
qmd_out, qmd_status = Open3.capture2e("qmd", "update")
|
|
817
1203
|
if qmd_status.success?
|
|
818
|
-
LOG.info "[Brain] qmd update completed after #{
|
|
1204
|
+
LOG.info "[Brain] qmd update completed after #{agent_config_name || "agent"} session"
|
|
819
1205
|
else
|
|
820
1206
|
LOG.warn "[Brain] qmd update failed: #{qmd_out.strip}"
|
|
821
1207
|
end
|
|
1208
|
+
end
|
|
822
1209
|
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
"— agent was nudged via reflection prompt"
|
|
828
|
-
end
|
|
1210
|
+
# Log whether the session qualifies for skill extraction (agent nudged via reflection prompt).
|
|
1211
|
+
def log_skill_candidate(log_file)
|
|
1212
|
+
skill_candidate = detect_skill_candidate(log_file)
|
|
1213
|
+
return unless skill_candidate[:extract]
|
|
829
1214
|
|
|
830
|
-
|
|
831
|
-
|
|
1215
|
+
LOG.info "[Skills] Session qualifies for skill extraction " \
|
|
1216
|
+
"(#{skill_candidate[:tool_calls]} tool calls, #{skill_candidate[:error_patterns]} error patterns) " \
|
|
1217
|
+
"— agent was nudged via reflection prompt"
|
|
832
1218
|
end
|
|
833
1219
|
|
|
834
1220
|
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
|
|
@@ -217,6 +265,13 @@ get "/api/status" do
|
|
|
217
265
|
end
|
|
218
266
|
end
|
|
219
267
|
|
|
268
|
+
get "/api/sessions/history" do
|
|
269
|
+
content_type :json
|
|
270
|
+
limit = (params["limit"] || 50).to_i.clamp(1, SESSION_HISTORY_MAX)
|
|
271
|
+
history = read_session_history(limit: limit)
|
|
272
|
+
{ history: history, count: history.size }.to_json
|
|
273
|
+
end
|
|
274
|
+
|
|
220
275
|
post "/api/sessions/kill/:card_key" do
|
|
221
276
|
content_type :json
|
|
222
277
|
card_key = params[:card_key]
|
|
@@ -255,9 +310,7 @@ get "/api/logs" do
|
|
|
255
310
|
halt 400, "Invalid path" if log_file.include?("..") || !log_file.start_with?("/")
|
|
256
311
|
halt 404, "File not found" unless File.exist?(log_file)
|
|
257
312
|
|
|
258
|
-
|
|
259
|
-
allowed << File.join(BRAINIAC_DIR, "tmp")
|
|
260
|
-
halt 403, "Forbidden" unless allowed.any? { |dir| log_file.start_with?(dir) }
|
|
313
|
+
halt 403, "Forbidden" unless log_file_allowed?(log_file)
|
|
261
314
|
|
|
262
315
|
all_lines = File.readlines(log_file).last(lines)
|
|
263
316
|
all_lines.join.gsub(/\e\[[\d;]*[a-zA-Z]/, "").gsub(/\e\[\?[\d;]*[a-zA-Z]/, "")
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
# Durable session history + "session heaviness" tracking.
|
|
6
|
+
#
|
|
7
|
+
# The in-memory RECENT_SESSIONS (see sessions.rb) only holds the last 10 finished
|
|
8
|
+
# sessions and is wiped on every brainiac restart. This module persists a durable,
|
|
9
|
+
# append-only record of every completed agent session to disk so the monitor (and
|
|
10
|
+
# anything else) can review real history: how long a session ran, which CLI/model it
|
|
11
|
+
# used, where it came from, and — where the provider exposes it — how "heavy" the
|
|
12
|
+
# session was (context window usage + credits spent).
|
|
13
|
+
#
|
|
14
|
+
# Storage: append-only JSONL at ~/.brainiac/session-history.jsonl (one record per line).
|
|
15
|
+
# JSONL is chosen deliberately: appends are atomic-ish and cheap, the file survives
|
|
16
|
+
# restarts, it's trivially tailable, and a corrupt line never poisons the whole file.
|
|
17
|
+
# The file is trimmed to SESSION_HISTORY_MAX records to bound growth.
|
|
18
|
+
|
|
19
|
+
SESSION_HISTORY_FILE = File.join(BRAINIAC_DIR, "session-history.jsonl")
|
|
20
|
+
SESSION_HISTORY_MAX = 500
|
|
21
|
+
SESSION_HISTORY_MUTEX = Mutex.new
|
|
22
|
+
|
|
23
|
+
# Append a completed-session record to the durable history file.
|
|
24
|
+
# `record` is a plain Hash; symbol keys are fine (serialized to JSON).
|
|
25
|
+
# Best-effort: any failure is logged and swallowed — history is never allowed to
|
|
26
|
+
# break the completion path.
|
|
27
|
+
def record_session_history(record)
|
|
28
|
+
SESSION_HISTORY_MUTEX.synchronize do
|
|
29
|
+
FileUtils.mkdir_p(File.dirname(SESSION_HISTORY_FILE))
|
|
30
|
+
File.open(SESSION_HISTORY_FILE, "a") { |f| f.puts(JSON.generate(record)) }
|
|
31
|
+
trim_session_history!
|
|
32
|
+
end
|
|
33
|
+
LOG.info "[SessionHistory] Recorded session for #{record["agent"] || "?"} → #{SESSION_HISTORY_FILE}"
|
|
34
|
+
true
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
LOG.warn "[SessionHistory] Failed to record session: #{e.class}: #{e.message}"
|
|
37
|
+
false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Read the most recent `limit` session records, newest first.
|
|
41
|
+
def read_session_history(limit: 50)
|
|
42
|
+
return [] unless File.exist?(SESSION_HISTORY_FILE)
|
|
43
|
+
|
|
44
|
+
lines = SESSION_HISTORY_MUTEX.synchronize { File.readlines(SESSION_HISTORY_FILE) }
|
|
45
|
+
records = lines.filter_map do |line|
|
|
46
|
+
line = line.strip
|
|
47
|
+
next if line.empty?
|
|
48
|
+
|
|
49
|
+
begin
|
|
50
|
+
JSON.parse(line)
|
|
51
|
+
rescue JSON::ParserError
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
records.last(limit).reverse
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
LOG.warn "[SessionHistory] Failed to read history: #{e.message}"
|
|
58
|
+
[]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Keep the history file bounded. Call inside SESSION_HISTORY_MUTEX.
|
|
62
|
+
def trim_session_history!
|
|
63
|
+
lines = File.readlines(SESSION_HISTORY_FILE)
|
|
64
|
+
return if lines.size <= SESSION_HISTORY_MAX
|
|
65
|
+
|
|
66
|
+
File.write(SESSION_HISTORY_FILE, lines.last(SESSION_HISTORY_MAX).join)
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
LOG.warn "[SessionHistory] Failed to trim history: #{e.message}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Build the durable history record for a finished session and append it.
|
|
72
|
+
# Called from handle_agent_completion. Gathers timing, identity, and — via the
|
|
73
|
+
# provider-specific heaviness probe — context/credit usage. Never raises.
|
|
74
|
+
def archive_session_history(ctx:, exit_status:, signaled:)
|
|
75
|
+
started_at = ctx[:started_at]
|
|
76
|
+
finished_at = Time.now
|
|
77
|
+
heaviness = session_heaviness(resolved: ctx[:resolved], chdir: ctx[:chdir])
|
|
78
|
+
source_context = ctx[:source_context] || {}
|
|
79
|
+
card_key = ctx[:card_key] || source_context[:card_key]
|
|
80
|
+
channel_id = ctx[:channel_id] || source_context[:channel_id]
|
|
81
|
+
|
|
82
|
+
record = {
|
|
83
|
+
"recorded_at" => finished_at.utc.iso8601,
|
|
84
|
+
"agent" => ctx[:agent_name] || ctx[:agent_config_name] || "Unknown",
|
|
85
|
+
"card_key" => card_key,
|
|
86
|
+
"card_number" => ctx[:card_number] || source_context[:card_number],
|
|
87
|
+
"source" => ctx[:source]&.to_s,
|
|
88
|
+
"channel_id" => channel_id,
|
|
89
|
+
"log_file" => ctx[:log_file],
|
|
90
|
+
"cli" => ctx[:agent_cli],
|
|
91
|
+
"model" => ctx[:model],
|
|
92
|
+
"started_at" => started_at&.utc&.iso8601,
|
|
93
|
+
"finished_at" => finished_at.utc.iso8601,
|
|
94
|
+
"duration_seconds" => started_at ? (finished_at - started_at).to_i : nil,
|
|
95
|
+
"exit_status" => exit_status,
|
|
96
|
+
"signaled" => signaled
|
|
97
|
+
}
|
|
98
|
+
record.merge!(heaviness) if heaviness
|
|
99
|
+
record.compact!
|
|
100
|
+
|
|
101
|
+
record_session_history(record)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# --- Provider-generic session heaviness probe ---
|
|
105
|
+
#
|
|
106
|
+
# "How heavy was this session?" — context window usage + credits spent.
|
|
107
|
+
# This is inherently provider-specific: each CLI stores (or doesn't store) usage
|
|
108
|
+
# differently. Rather than hardcode any one CLI in this file, brainiac reads a
|
|
109
|
+
# `heaviness_probe` block from the provider's cli-providers/<name>.json. That config
|
|
110
|
+
# declares WHICH probe strategy to use and WHERE its data lives, e.g.:
|
|
111
|
+
#
|
|
112
|
+
# "heaviness_probe": { "type": "kiro_sqlite", "db_path": "~/.local/share/kiro-cli/data.sqlite3" }
|
|
113
|
+
#
|
|
114
|
+
# The config controls the "what and where" (enable/disable a provider, point it at a
|
|
115
|
+
# moved db) with zero code changes. The "how" for each probe TYPE lives here in Ruby —
|
|
116
|
+
# a genuinely new storage mechanism (a provider that logs usage to JSON, an HTTP
|
|
117
|
+
# endpoint, etc.) still needs a small probe implementation, but pointing kiro at a new
|
|
118
|
+
# path or turning a provider's probe on/off is pure JSON.
|
|
119
|
+
#
|
|
120
|
+
# Providers with no `heaviness_probe` (or an unknown type) simply yield nil and the
|
|
121
|
+
# history record carries timing/identity only.
|
|
122
|
+
#
|
|
123
|
+
# Returns a Hash of string-keyed heaviness fields, or nil if unavailable:
|
|
124
|
+
# context_window_tokens, context_usage_pct, context_used_tokens, credits_used
|
|
125
|
+
def session_heaviness(resolved:, chdir:)
|
|
126
|
+
return nil unless resolved && chdir
|
|
127
|
+
|
|
128
|
+
probe = resolved["heaviness_probe"]
|
|
129
|
+
return nil unless probe.is_a?(Hash)
|
|
130
|
+
|
|
131
|
+
case probe["type"]
|
|
132
|
+
when "kiro_sqlite"
|
|
133
|
+
kiro_session_heaviness(chdir, db_path: probe["db_path"])
|
|
134
|
+
end
|
|
135
|
+
rescue StandardError => e
|
|
136
|
+
LOG.warn "[SessionHistory] Heaviness probe failed for #{chdir}: #{e.message}"
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# kiro-cli persists every conversation in a local sqlite keyed by working directory.
|
|
141
|
+
# Each conversation's JSON carries the model's context window, the latest request's
|
|
142
|
+
# context-usage percentage, and per-request credit usage. We read it read-only via the
|
|
143
|
+
# sqlite3 CLI (no gem dependency) and derive the heaviness fields. Best-effort: any
|
|
144
|
+
# problem (missing db, no row for this cwd, malformed json) yields nil.
|
|
145
|
+
#
|
|
146
|
+
# The db location comes from the provider's heaviness_probe.db_path so it's not baked
|
|
147
|
+
# into brainiac; KIRO_DEFAULT_DB_PATH is only the fallback.
|
|
148
|
+
KIRO_DEFAULT_DB_PATH = File.join(Dir.home, ".local", "share", "kiro-cli", "data.sqlite3")
|
|
149
|
+
|
|
150
|
+
def kiro_session_heaviness(chdir, db_path: nil)
|
|
151
|
+
db = File.expand_path(db_path || KIRO_DEFAULT_DB_PATH)
|
|
152
|
+
return nil unless File.exist?(db)
|
|
153
|
+
|
|
154
|
+
key = File.expand_path(chdir)
|
|
155
|
+
# The CTE picks the most recently updated conversation for this working directory,
|
|
156
|
+
# then we pull the model's context window, the latest request's context-usage %, and
|
|
157
|
+
# the summed credit spend. `.timeout` (via -cmd, so it doesn't emit output) keeps us
|
|
158
|
+
# from hanging if kiro-cli is mid-write.
|
|
159
|
+
sql = <<~SQL.gsub(/\s+/, " ").strip
|
|
160
|
+
WITH latest AS (
|
|
161
|
+
SELECT value FROM conversations_v2
|
|
162
|
+
WHERE key = #{sqlite_quote(key)} ORDER BY updated_at DESC LIMIT 1
|
|
163
|
+
)
|
|
164
|
+
SELECT
|
|
165
|
+
json_extract(value, '$.model_info.context_window_tokens'),
|
|
166
|
+
(SELECT r.value ->> 'context_usage_percentage'
|
|
167
|
+
FROM latest, json_each(json_extract(latest.value, '$.user_turn_metadata.requests')) r
|
|
168
|
+
ORDER BY CAST(r.key AS INTEGER) DESC LIMIT 1),
|
|
169
|
+
(SELECT ROUND(SUM(CAST(u.value ->> 'value' AS REAL)), 4)
|
|
170
|
+
FROM latest, json_each(json_extract(latest.value, '$.user_turn_metadata.usage_info')) u
|
|
171
|
+
WHERE (u.value ->> 'unit') = 'credit')
|
|
172
|
+
FROM latest;
|
|
173
|
+
SQL
|
|
174
|
+
|
|
175
|
+
out = ""
|
|
176
|
+
status = nil
|
|
177
|
+
Timeout.timeout(6) do
|
|
178
|
+
out, status = Open3.capture2(
|
|
179
|
+
"sqlite3", "-separator", "\t", "-cmd", ".timeout 3000",
|
|
180
|
+
"file:#{db}?mode=ro", sql
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
return nil unless status&.success?
|
|
184
|
+
|
|
185
|
+
row = out.strip
|
|
186
|
+
return nil if row.empty?
|
|
187
|
+
|
|
188
|
+
window, pct, credits = row.split("\t", 3)
|
|
189
|
+
window = window.to_i
|
|
190
|
+
return nil if window.zero?
|
|
191
|
+
|
|
192
|
+
pct = pct.to_f
|
|
193
|
+
fields = { "context_window_tokens" => window, "context_usage_pct" => pct.round(2) }
|
|
194
|
+
fields["context_used_tokens"] = (window * pct / 100.0).round if pct.positive?
|
|
195
|
+
fields["credits_used"] = credits.to_f.round(4) if credits && !credits.empty?
|
|
196
|
+
fields
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Single-quote a string for safe literal interpolation into SQLite SQL.
|
|
200
|
+
def sqlite_quote(str)
|
|
201
|
+
"'#{str.to_s.gsub("'", "''")}'"
|
|
202
|
+
end
|
data/lib/brainiac/version.rb
CHANGED
data/lib/brainiac.rb
CHANGED
|
@@ -5,6 +5,7 @@ require_relative "brainiac/agents"
|
|
|
5
5
|
require_relative "brainiac/brain"
|
|
6
6
|
require_relative "brainiac/skills"
|
|
7
7
|
require_relative "brainiac/sessions"
|
|
8
|
+
require_relative "brainiac/session_history"
|
|
8
9
|
require_relative "brainiac/prompts"
|
|
9
10
|
require_relative "brainiac/helpers"
|
|
10
11
|
require_relative "brainiac/intent"
|
data/receiver.rb
CHANGED
|
@@ -19,6 +19,7 @@ require_relative "lib/brainiac/agents"
|
|
|
19
19
|
require_relative "lib/brainiac/brain"
|
|
20
20
|
require_relative "lib/brainiac/skills"
|
|
21
21
|
require_relative "lib/brainiac/sessions"
|
|
22
|
+
require_relative "lib/brainiac/session_history"
|
|
22
23
|
require_relative "lib/brainiac/prompts"
|
|
23
24
|
require_relative "lib/brainiac/helpers"
|
|
24
25
|
require_relative "lib/brainiac/notifications"
|
|
@@ -100,6 +101,16 @@ configure do
|
|
|
100
101
|
end
|
|
101
102
|
|
|
102
103
|
LOG.info "[Brainiac] Starting v#{BRAINIAC_VERSION} on port #{settings.port} (#{settings.environment})"
|
|
104
|
+
LOG.info "[Brainiac] Server root: #{SERVER_ROOT} (pid #{Process.pid})"
|
|
105
|
+
|
|
106
|
+
# Warn loudly when the server is running from an installed gem rather than a git
|
|
107
|
+
# checkout. During development this is almost always a mistake: `brainiac` on PATH
|
|
108
|
+
# resolves to the gem's bin, so edits to a working checkout never take effect. Surfacing
|
|
109
|
+
# it here turns a long "why won't my change apply" hunt into a one-line startup notice.
|
|
110
|
+
if SERVER_ROOT.include?("/gems/")
|
|
111
|
+
LOG.warn "[Brainiac] Running from an installed GEM (#{SERVER_ROOT}) — not a git checkout. " \
|
|
112
|
+
"Local code changes will NOT be loaded. Run `ruby ./bin/brainiac server` from your checkout to test edits."
|
|
113
|
+
end
|
|
103
114
|
if intent_config["enabled"]
|
|
104
115
|
LOG.info "[Intent] Enabled — model: #{intent_config["model"]}, endpoint: #{intent_config["endpoint"]}"
|
|
105
116
|
begin
|
|
@@ -18,5 +18,10 @@
|
|
|
18
18
|
"qwen": "qwen3-coder-next",
|
|
19
19
|
"auto": "auto"
|
|
20
20
|
},
|
|
21
|
-
"efforts": ["low", "medium", "high", "xhigh", "max"]
|
|
21
|
+
"efforts": ["low", "medium", "high", "xhigh", "max"],
|
|
22
|
+
"heaviness_probe": {
|
|
23
|
+
"comment": "Optional. Lets brainiac record how 'heavy' a finished session was (context window usage + credits) in the durable session history. type selects the probe strategy; db_path points at the provider's local usage store. Omit this block to record timing/identity only.",
|
|
24
|
+
"type": "kiro_sqlite",
|
|
25
|
+
"db_path": "~/.local/share/kiro-cli/data.sqlite3"
|
|
26
|
+
}
|
|
22
27
|
}
|
|
@@ -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.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andy Davis
|
|
@@ -142,6 +142,7 @@ files:
|
|
|
142
142
|
- lib/brainiac/prompts.rb
|
|
143
143
|
- lib/brainiac/restart.rb
|
|
144
144
|
- lib/brainiac/routes/api.rb
|
|
145
|
+
- lib/brainiac/session_history.rb
|
|
145
146
|
- lib/brainiac/sessions.rb
|
|
146
147
|
- lib/brainiac/skills.rb
|
|
147
148
|
- lib/brainiac/users.rb
|
|
@@ -166,6 +167,7 @@ files:
|
|
|
166
167
|
- templates/cli-providers/gemini.json.example
|
|
167
168
|
- templates/cli-providers/grok.json.example
|
|
168
169
|
- templates/cli-providers/kiro.json.example
|
|
170
|
+
- templates/cli-providers/opencode.json.example
|
|
169
171
|
- templates/hooks/pre-commit
|
|
170
172
|
- templates/plugins.json.example
|
|
171
173
|
- templates/roles/code-reviewer.md.example
|