brainiac 0.0.33 → 0.1.1
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/handlers/shared/inline_tags.rb +10 -1
- data/lib/brainiac/helpers.rb +134 -15
- data/lib/brainiac/profiles.rb +116 -0
- data/lib/brainiac/routes/api.rb +9 -1
- data/lib/brainiac/session_history.rb +202 -0
- data/lib/brainiac/version.rb +1 -1
- data/lib/brainiac.rb +2 -0
- data/receiver.rb +12 -1
- data/templates/cli-providers/kiro.json.example +6 -1
- data/templates/profiles.json.example +13 -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: af2bf14f62db5780228f9c8f175abe703dcf0d654c1a35534446117bfced7034
|
|
4
|
+
data.tar.gz: 431b72de572f425911109cc0ced248859456ee7564dd028e5cb502654baf0d7b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 87da5739c961603be5884be7dfae1e02c312b8a5649ce612185b19149e0d1ffecb40a97ab0619787ff08c98003d333024565d4e4c7cfa4158631d5db0ea00b00
|
|
7
|
+
data.tar.gz: 70ce18db1d7520f129d286425f07af9a5e58b7f796a462d3af932bf636793db5054a1b2aac90e6b6565189d8b7e83e5f04ee0c7e1d5a56a33b36f9ec5dcde391
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
brainiac (0.
|
|
4
|
+
brainiac (0.1.1)
|
|
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.
|
|
88
|
-
json (2.
|
|
89
|
-
language_server-protocol (3.17.0.
|
|
87
|
+
brainiac (0.1.1)
|
|
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
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
#
|
|
5
5
|
# Messages from any channel can contain inline tags like:
|
|
6
6
|
# [project:my-project], [opus], [effort:high], [cli:grok], [chat], [plan],
|
|
7
|
-
# [fresh], [branch:feature-xyz], [workitem:wi-abc123]
|
|
7
|
+
# [fresh], [branch:feature-xyz], [workitem:wi-abc123], [profile:k+] ([p:k+])
|
|
8
8
|
#
|
|
9
9
|
# This module provides a single parser that extracts all tags and returns
|
|
10
10
|
# a structured result with the cleaned text.
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
# model_tag: "opus" or nil (raw tag, not resolved model ID),
|
|
17
17
|
# effort: "high" or nil,
|
|
18
18
|
# cli_provider: "grok" or nil,
|
|
19
|
+
# profile: "k+" or nil,
|
|
19
20
|
# chat_mode: true/false,
|
|
20
21
|
# planning: true/false,
|
|
21
22
|
# fresh: true/false,
|
|
@@ -29,6 +30,7 @@ def parse_inline_tags(text)
|
|
|
29
30
|
model_tag: nil,
|
|
30
31
|
effort: nil,
|
|
31
32
|
cli_provider: nil,
|
|
33
|
+
profile: nil,
|
|
32
34
|
chat_mode: false,
|
|
33
35
|
planning: false,
|
|
34
36
|
fresh: false,
|
|
@@ -67,6 +69,13 @@ def parse_value_tags(result)
|
|
|
67
69
|
result[:clean_text].sub!(match[0], "")
|
|
68
70
|
end
|
|
69
71
|
|
|
72
|
+
# [profile:k+] or short alias [p:k+] — a named env bundle (e.g. an alternate
|
|
73
|
+
# kiro-cli account via XDG_DATA_HOME). Values allow non-word chars like '+'.
|
|
74
|
+
if (match = result[:clean_text].match(/\[(?:profile|p):([^\]]+)\]/i))
|
|
75
|
+
result[:profile] = match[1].strip.downcase
|
|
76
|
+
result[:clean_text].sub!(match[0], "")
|
|
77
|
+
end
|
|
78
|
+
|
|
70
79
|
# [deploy] or [deploy:dev01]
|
|
71
80
|
if (match = result[:clean_text].match(/\[deploy(?::([^\]]+))?\]/i))
|
|
72
81
|
result[:deploy_intent] = match[1]&.strip&.downcase || :auto
|
data/lib/brainiac/helpers.rb
CHANGED
|
@@ -35,7 +35,7 @@ def load_cli_provider(provider_name)
|
|
|
35
35
|
%w[prompt_flag list_models_command resume_flag resume_args resume_id_flag new_session_id_flag
|
|
36
36
|
session_list_command session_id_field session_directory_field session_updated_field
|
|
37
37
|
session_list_path session_dir output_last_message_flag cwd_flag config_override_flag
|
|
38
|
-
effort_config_key effort_map title_flag].each do |key|
|
|
38
|
+
effort_config_key effort_map title_flag heaviness_probe].each do |key|
|
|
39
39
|
next unless raw[key]
|
|
40
40
|
next if raw[key].respond_to?(:empty?) && raw[key].empty?
|
|
41
41
|
|
|
@@ -507,6 +507,74 @@ rescue StandardError => e
|
|
|
507
507
|
nil
|
|
508
508
|
end
|
|
509
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
|
+
|
|
510
578
|
# Notify the originating channel that an agent crashed.
|
|
511
579
|
# source: :github, :discord, or plugin-registered sources
|
|
512
580
|
# source_context: hash with channel-specific info needed to post the notification
|
|
@@ -706,7 +774,7 @@ end
|
|
|
706
774
|
# Plugins should NOT build their own resume logic; pass `resume: true` and let core handle it.
|
|
707
775
|
def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil, effort: nil, agent_name: nil, card_number: nil, comment_id: nil,
|
|
708
776
|
source: nil, source_context: {}, skip_column_move: false, cli_provider: nil, resume: false,
|
|
709
|
-
message: nil, channel: nil, context: nil, env: {})
|
|
777
|
+
message: nil, channel: nil, context: nil, env: {}, profile: nil)
|
|
710
778
|
# Intent gate: if a raw message is provided, check whether the agent should respond.
|
|
711
779
|
return nil if intent_skip?(message, agent_name: agent_name, source: source, channel: channel, context: context)
|
|
712
780
|
|
|
@@ -737,9 +805,10 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
|
|
|
737
805
|
prompt_file: prompt_file, resume: should_resume,
|
|
738
806
|
output_file: output_file, chdir: chdir, title: work_item_id,
|
|
739
807
|
new_session_id: minted_session_id)
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
808
|
+
# Profile env (named bundle, e.g. an alternate kiro-cli account) layers between
|
|
809
|
+
# agent env and the explicit `env:` passed in. An explicitly-requested profile
|
|
810
|
+
# wins over agent env; the default profile is only a fallback. See profiles.rb.
|
|
811
|
+
spawn_env = profile_spawn_env(agent_env_for(agent_name), profile).merge(env)
|
|
743
812
|
|
|
744
813
|
log_agent_launch(resolved: resolved, chdir: chdir, log_file: log_file, prompt_file: prompt_file,
|
|
745
814
|
output_file: output_file, cmd: cmd, should_resume: should_resume,
|
|
@@ -748,11 +817,9 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
|
|
|
748
817
|
project_key_for_restart = PROJECTS.find { |_k, v| v == project_config }&.first
|
|
749
818
|
head_before, status_before = capture_git_state(chdir) if project_key_for_restart == "brainiac"
|
|
750
819
|
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
out: [log_file, "w"],
|
|
755
|
-
err: %i[child out])
|
|
820
|
+
started_at = Time.now
|
|
821
|
+
stdin_redirect = (resolved["prompt_mode"] || "stdin") == "stdin" ? { in: prompt_file } : {}
|
|
822
|
+
pid = spawn(spawn_env, *cmd, chdir: chdir, **stdin_redirect, out: [log_file, "w"], err: %i[child out])
|
|
756
823
|
|
|
757
824
|
spawn_completion_watcher(
|
|
758
825
|
pid: pid, agent_cli: resolved["agent_cli"], agent_config_name: agent_config_name,
|
|
@@ -762,7 +829,7 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
|
|
|
762
829
|
card_number: card_number, skip_column_move: skip_column_move,
|
|
763
830
|
output_file: output_file, resolved: resolved, minted_session_id: minted_session_id,
|
|
764
831
|
head_before: head_before, status_before: status_before,
|
|
765
|
-
project_key_for_restart: project_key_for_restart
|
|
832
|
+
project_key_for_restart: project_key_for_restart, model: model, started_at: started_at
|
|
766
833
|
)
|
|
767
834
|
|
|
768
835
|
LOG.info "#{resolved["agent_cli"]} started (pid: #{pid}, agent: #{agent_config_name || "default"}, " \
|
|
@@ -773,10 +840,15 @@ end
|
|
|
773
840
|
|
|
774
841
|
# Spawn a background thread that waits for the agent process to finish and runs completion handling.
|
|
775
842
|
def spawn_completion_watcher(**ctx)
|
|
776
|
-
Thread.new do
|
|
843
|
+
thread = Thread.new do
|
|
777
844
|
Process.wait(ctx[:pid])
|
|
778
845
|
handle_agent_completion(**ctx)
|
|
779
846
|
end
|
|
847
|
+
# Without this, an unrescued exception anywhere in handle_agent_completion silently
|
|
848
|
+
# kills the thread and is never logged — which previously hid completion-path failures
|
|
849
|
+
# (e.g. a step raising before session history was archived).
|
|
850
|
+
thread.report_on_exception = true
|
|
851
|
+
thread
|
|
780
852
|
end
|
|
781
853
|
|
|
782
854
|
# Log the details of an agent launch (command, prompt/output files, injected env).
|
|
@@ -1056,13 +1128,60 @@ def handle_agent_completion(**ctx)
|
|
|
1056
1128
|
# Clean up the output file after hook emission (content already captured above).
|
|
1057
1129
|
FileUtils.rm_f(ctx[:output_file]) if ctx[:output_file]
|
|
1058
1130
|
|
|
1059
|
-
|
|
1060
|
-
|
|
1131
|
+
run_post_completion_steps(ctx, exit_status: agent_exit_status, signaled: agent_signaled, output_content: output_content)
|
|
1132
|
+
end
|
|
1061
1133
|
|
|
1062
|
-
|
|
1134
|
+
# Independent, best-effort steps that run after the lifecycle hook. Each is isolated via
|
|
1135
|
+
# completion_step so a failure in one (e.g. a raising notify path) can't abort the rest —
|
|
1136
|
+
# notably session-history archival, which must always run regardless of earlier steps.
|
|
1137
|
+
def run_post_completion_steps(ctx, exit_status:, signaled:, output_content:)
|
|
1138
|
+
# Clean exit (0) but no usable response — e.g. the CLI hit a request/usage limit and
|
|
1139
|
+
# bailed without doing work. The crash path only fires on non-zero exits, so this would
|
|
1140
|
+
# otherwise be silent. Surface it to the originating channel.
|
|
1141
|
+
completion_step("notify_no_output") do
|
|
1142
|
+
notify_no_output_if_needed(ctx, exit_status: exit_status, signaled: signaled, output_content: output_content)
|
|
1143
|
+
end
|
|
1144
|
+
|
|
1145
|
+
# Durable session history — record every finished session to disk (survives restarts),
|
|
1146
|
+
# including provider-specific "heaviness" (context window usage + credits). Best-effort.
|
|
1147
|
+
completion_step("archive_session_history") do
|
|
1148
|
+
archive_session_history(ctx: ctx, exit_status: exit_status, signaled: signaled)
|
|
1149
|
+
end
|
|
1150
|
+
|
|
1151
|
+
completion_step("qmd_update") { run_qmd_update(ctx[:agent_config_name]) }
|
|
1152
|
+
completion_step("skill_candidate") { log_skill_candidate(ctx[:log_file]) }
|
|
1153
|
+
completion_step("brain_push") { brain_push(message: "#{ctx[:agent_config_name] || "agent"}: #{ctx[:log_name]}") }
|
|
1063
1154
|
# check_brainiac_restart(ctx[:head_before], ctx[:status_before], ctx[:chdir], ctx[:project_key_for_restart], ctx[:agent_config_name])
|
|
1064
1155
|
end
|
|
1065
1156
|
|
|
1157
|
+
# Run an independent post-completion step, logging and swallowing any failure so one
|
|
1158
|
+
# broken step (e.g. a plugin-facing call raising) can't abort the remaining steps —
|
|
1159
|
+
# notably session-history archival, which must not depend on earlier steps succeeding.
|
|
1160
|
+
def completion_step(name)
|
|
1161
|
+
yield
|
|
1162
|
+
rescue StandardError => e
|
|
1163
|
+
LOG.error "[Completion] step '#{name}' failed: #{e.class}: #{e.message}"
|
|
1164
|
+
LOG.error "[Completion] #{e.backtrace.first(3).join("\n ")}" if e.backtrace
|
|
1165
|
+
nil
|
|
1166
|
+
end
|
|
1167
|
+
|
|
1168
|
+
# Surface a clean-exit session that produced no response, pulling fields from the
|
|
1169
|
+
# completion context. Only fires for exit 0 (non-zero already goes through the crash
|
|
1170
|
+
# path) with a source to notify. Extracted from handle_agent_completion.
|
|
1171
|
+
def notify_no_output_if_needed(ctx, exit_status:, signaled:, output_content:)
|
|
1172
|
+
return unless ctx[:source]
|
|
1173
|
+
return if signaled
|
|
1174
|
+
return unless exit_status&.zero?
|
|
1175
|
+
|
|
1176
|
+
reason = detect_no_output_reason(ctx[:log_file], output_content)
|
|
1177
|
+
return unless reason
|
|
1178
|
+
|
|
1179
|
+
notify_agent_no_output(
|
|
1180
|
+
reason: reason, log_file: ctx[:log_file], agent_name: ctx[:agent_name],
|
|
1181
|
+
source: ctx[:source], source_context: ctx[:source_context], project_config: ctx[:project_config]
|
|
1182
|
+
)
|
|
1183
|
+
end
|
|
1184
|
+
|
|
1066
1185
|
# Capture the CLI session id after a run, if the provider and working dir are known.
|
|
1067
1186
|
def capture_session_id_if_possible(ctx)
|
|
1068
1187
|
return unless ctx[:resolved] && ctx[:chdir]
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Profiles: named bundles of environment variables that can be injected into an
|
|
4
|
+
# agent dispatch without standing up a separate agent.
|
|
5
|
+
#
|
|
6
|
+
# The canonical use case is kiro-cli multi-account: each AWS account gets its own
|
|
7
|
+
# XDG_DATA_HOME (separate auth store), and a profile lets you flip which account a
|
|
8
|
+
# dispatch runs against via an inline tag ([profile:X] / [p:X]) — same CLI, same
|
|
9
|
+
# agent, different credentials.
|
|
10
|
+
#
|
|
11
|
+
# Config lives at ~/.brainiac/profiles.json:
|
|
12
|
+
#
|
|
13
|
+
# {
|
|
14
|
+
# "q": { "env": { "XDG_DATA_HOME": "/home/andy/.local/share" } },
|
|
15
|
+
# "k+": { "env": { "XDG_DATA_HOME": "/home/andy/.brainiac/kiro-accounts/kiro-pro" }, "default": true }
|
|
16
|
+
# }
|
|
17
|
+
#
|
|
18
|
+
# A profile may also optionally pin a model and/or cli_provider:
|
|
19
|
+
#
|
|
20
|
+
# "k+": { "env": {...}, "model": "opus", "cli_provider": "kiro-pro" }
|
|
21
|
+
#
|
|
22
|
+
# Exactly one profile may be marked "default": true — it applies when no [profile:X]
|
|
23
|
+
# tag is present. Because it's only a fallback, an agent's own env (from agents.json)
|
|
24
|
+
# takes precedence over the default profile. An *explicitly requested* profile, by
|
|
25
|
+
# contrast, takes precedence over agent env — that's the whole point of asking for it.
|
|
26
|
+
#
|
|
27
|
+
# Precedence at the spawn point (highest wins):
|
|
28
|
+
# explicit env passed to run_agent
|
|
29
|
+
# > explicitly-requested profile env
|
|
30
|
+
# > agent env (agents.json)
|
|
31
|
+
# > default profile env
|
|
32
|
+
# > DEFAULT_AGENT_ENV
|
|
33
|
+
|
|
34
|
+
PROFILES_FILE = File.join(BRAINIAC_DIR, "profiles.json")
|
|
35
|
+
|
|
36
|
+
def load_profiles_config
|
|
37
|
+
return {} unless File.exist?(PROFILES_FILE)
|
|
38
|
+
|
|
39
|
+
raw = JSON.parse(File.read(PROFILES_FILE))
|
|
40
|
+
LOG.info "Loaded #{raw.size} profile(s) from #{PROFILES_FILE}" if defined?(LOG)
|
|
41
|
+
|
|
42
|
+
# Normalize keys to lowercase for case-insensitive lookup, but preserve the
|
|
43
|
+
# profile body as-is. Profile keys can contain '+' etc. (e.g. "k+").
|
|
44
|
+
normalized = {}
|
|
45
|
+
raw.each { |key, entry| normalized[key.to_s.downcase] = entry }
|
|
46
|
+
normalized
|
|
47
|
+
rescue JSON::ParserError => e
|
|
48
|
+
LOG.error "Failed to parse profiles.json: #{e.message}" if defined?(LOG)
|
|
49
|
+
{}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
PROFILES = load_profiles_config
|
|
53
|
+
|
|
54
|
+
def reload_profiles!(force: false)
|
|
55
|
+
return unless file_changed?(PROFILES_FILE, force: force)
|
|
56
|
+
|
|
57
|
+
PROFILES.replace(load_profiles_config)
|
|
58
|
+
LOG.info "Reloaded profiles: #{PROFILES.keys.join(", ")}" if defined?(LOG)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Look up a profile entry by name (case-insensitive). Returns the raw hash or nil.
|
|
62
|
+
def profile_entry(profile_name)
|
|
63
|
+
return nil unless profile_name
|
|
64
|
+
|
|
65
|
+
PROFILES[profile_name.to_s.downcase]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Find the profile marked "default": true. Returns [name, entry] or nil.
|
|
69
|
+
def default_profile
|
|
70
|
+
PROFILES.find { |_name, entry| entry.is_a?(Hash) && entry["default"] }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Resolve the env hash contributed by a profile.
|
|
74
|
+
#
|
|
75
|
+
# When profile_name is given and matches a profile, returns that profile's env.
|
|
76
|
+
# When profile_name is nil, returns the default profile's env (or {}).
|
|
77
|
+
# When profile_name is given but unknown, returns {} (never silently falls back
|
|
78
|
+
# to the default — a typo shouldn't route a dispatch to the wrong account).
|
|
79
|
+
#
|
|
80
|
+
# The caller decides precedence relative to agent env — see profile_spawn_env.
|
|
81
|
+
def profile_env(profile_name)
|
|
82
|
+
entry = profile_name ? profile_entry(profile_name) : default_profile&.last
|
|
83
|
+
return {} unless entry.is_a?(Hash)
|
|
84
|
+
|
|
85
|
+
(entry["env"] || {}).each_with_object({}) { |(k, v), h| h[k.to_s] = v.to_s }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Compute the profile contribution to a spawn env, honoring precedence relative
|
|
89
|
+
# to agent env.
|
|
90
|
+
#
|
|
91
|
+
# - An explicitly-requested profile (profile_name present and known) layers ON TOP
|
|
92
|
+
# of agent env — the request wins, so you can flip accounts for one dispatch.
|
|
93
|
+
# - The default profile (no profile_name given) layers UNDER agent env — it's only
|
|
94
|
+
# a fallback for dispatches that didn't ask for anything.
|
|
95
|
+
# - An unknown profile name is ignored (agent env only) and logged, so a typo never
|
|
96
|
+
# silently swaps accounts.
|
|
97
|
+
#
|
|
98
|
+
# Returns a merged env hash ready to be used as the base for further merges
|
|
99
|
+
# (e.g. run_agent's explicit `env:` still wins by merging afterward).
|
|
100
|
+
def profile_spawn_env(agent_env, profile_name)
|
|
101
|
+
if profile_name
|
|
102
|
+
if profile_entry(profile_name)
|
|
103
|
+
# Explicit request: profile wins over agent env.
|
|
104
|
+
agent_env.merge(profile_env(profile_name))
|
|
105
|
+
else
|
|
106
|
+
LOG.warn "Unknown profile '#{profile_name}' — ignoring (using agent env)" if defined?(LOG)
|
|
107
|
+
agent_env
|
|
108
|
+
end
|
|
109
|
+
else
|
|
110
|
+
# No profile requested: default profile is a baseline agent env overrides.
|
|
111
|
+
default_env = profile_env(nil)
|
|
112
|
+
return agent_env if default_env.empty?
|
|
113
|
+
|
|
114
|
+
default_env.merge(agent_env)
|
|
115
|
+
end
|
|
116
|
+
end
|
data/lib/brainiac/routes/api.rb
CHANGED
|
@@ -137,9 +137,10 @@ post "/api/reload" do
|
|
|
137
137
|
reload_projects!(force: true)
|
|
138
138
|
reload_agent_registry!(force: true)
|
|
139
139
|
reload_user_registry!(force: true)
|
|
140
|
+
reload_profiles!(force: true)
|
|
140
141
|
ReloadHooks.run_all!
|
|
141
142
|
{ status: "reloaded", projects: PROJECTS.keys, agents: all_agent_names.to_a, registry: AGENT_REGISTRY.keys,
|
|
142
|
-
users: USER_REGISTRY["users"].size }.to_json
|
|
143
|
+
users: USER_REGISTRY["users"].size, profiles: PROFILES.keys }.to_json
|
|
143
144
|
end
|
|
144
145
|
|
|
145
146
|
# --- Agents & Roles ---
|
|
@@ -265,6 +266,13 @@ get "/api/status" do
|
|
|
265
266
|
end
|
|
266
267
|
end
|
|
267
268
|
|
|
269
|
+
get "/api/sessions/history" do
|
|
270
|
+
content_type :json
|
|
271
|
+
limit = (params["limit"] || 50).to_i.clamp(1, SESSION_HISTORY_MAX)
|
|
272
|
+
history = read_session_history(limit: limit)
|
|
273
|
+
{ history: history, count: history.size }.to_json
|
|
274
|
+
end
|
|
275
|
+
|
|
268
276
|
post "/api/sessions/kill/:card_key" do
|
|
269
277
|
content_type :json
|
|
270
278
|
card_key = params[:card_key]
|
|
@@ -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
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
require_relative "brainiac/hooks"
|
|
2
2
|
require_relative "brainiac/config"
|
|
3
|
+
require_relative "brainiac/profiles"
|
|
3
4
|
require_relative "brainiac/users"
|
|
4
5
|
require_relative "brainiac/agents"
|
|
5
6
|
require_relative "brainiac/brain"
|
|
6
7
|
require_relative "brainiac/skills"
|
|
7
8
|
require_relative "brainiac/sessions"
|
|
9
|
+
require_relative "brainiac/session_history"
|
|
8
10
|
require_relative "brainiac/prompts"
|
|
9
11
|
require_relative "brainiac/helpers"
|
|
10
12
|
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"
|
|
@@ -83,7 +84,7 @@ set :quiet, true
|
|
|
83
84
|
set :server_settings, { Silent: true }
|
|
84
85
|
|
|
85
86
|
# Custom logger that filters polling endpoints unless LOG_LEVEL=debug
|
|
86
|
-
SILENT_POLL_PATHS = %w[/api/status /api/deployments].freeze
|
|
87
|
+
SILENT_POLL_PATHS = %w[/api/status /api/deployments /api/sessions/history].freeze
|
|
87
88
|
|
|
88
89
|
class SelectiveLogger < Rack::CommonLogger
|
|
89
90
|
def call(env)
|
|
@@ -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
|
}
|
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.
|
|
4
|
+
version: 0.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andy Davis
|
|
@@ -139,9 +139,11 @@ files:
|
|
|
139
139
|
- lib/brainiac/model_parser.rb
|
|
140
140
|
- lib/brainiac/notifications.rb
|
|
141
141
|
- lib/brainiac/plugins.rb
|
|
142
|
+
- lib/brainiac/profiles.rb
|
|
142
143
|
- lib/brainiac/prompts.rb
|
|
143
144
|
- lib/brainiac/restart.rb
|
|
144
145
|
- lib/brainiac/routes/api.rb
|
|
146
|
+
- lib/brainiac/session_history.rb
|
|
145
147
|
- lib/brainiac/sessions.rb
|
|
146
148
|
- lib/brainiac/skills.rb
|
|
147
149
|
- lib/brainiac/users.rb
|
|
@@ -169,6 +171,7 @@ files:
|
|
|
169
171
|
- templates/cli-providers/opencode.json.example
|
|
170
172
|
- templates/hooks/pre-commit
|
|
171
173
|
- templates/plugins.json.example
|
|
174
|
+
- templates/profiles.json.example
|
|
172
175
|
- templates/roles/code-reviewer.md.example
|
|
173
176
|
- templates/roles/general-engineer.md.example
|
|
174
177
|
- templates/roles/test-engineer.md.example
|