brainiac 0.1.2 → 0.1.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 228229305cb7f5653387878ac57194d1d955e6958621a47f5fadada2fb43a61e
4
- data.tar.gz: 372e7ee3d059a96b636bb52dfe9dfb0f9e479ca141a12c02ddb1a52817097034
3
+ metadata.gz: d73484edd8f42558e933347b45b50f5aa27065a86798ae4d764e44b0d07534f9
4
+ data.tar.gz: bcc9a1a5bf6094ef8138cf345e2e517004c412d983c4475c80b89cb005bbf6aa
5
5
  SHA512:
6
- metadata.gz: a762edbf02c2ea120afa4f1da7d242a53e1f0c894f7b1706d570d38604d50e92a12efdb858f1bec86fcb2fef1e9223a5eeb96b43226fd2f9b1d0a4190367939f
7
- data.tar.gz: 8dc270f4ca6e1278b9b0607bba0b3691ea319d0390cb214880a3c581fb6d2232aabfb9934b2bfadcc50d866f7c0b49ddd4201336cacd56913c38bbac8111f0be
6
+ metadata.gz: 1ecd465ba660b6a145c15a9fa7fd17a13dad35bf2dff802b18dcba92949964b5df0be02e26ebc643a74582f010cecf520fc7d04cd32b4acda83891768cd0c6c1
7
+ data.tar.gz: ca830bd810461ff79c474bf5a2744f187a29ee8040d7af0b14c7143dd76c92dbb6e1117f942e51610ecb8e3bdb9d48739a48f7862006f8f84a4d7a66bb40481a
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- brainiac (0.1.2)
4
+ brainiac (0.1.4)
5
5
  puma (~> 7.2)
6
6
  rackup (~> 2.3)
7
7
  sinatra (~> 4.1)
@@ -84,7 +84,7 @@ DEPENDENCIES
84
84
  CHECKSUMS
85
85
  ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
86
86
  base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
87
- brainiac (0.1.2)
87
+ brainiac (0.1.4)
88
88
  json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
89
89
  language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0
90
90
  lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
data/README.md CHANGED
@@ -661,6 +661,69 @@ brainiac discord config # Show current Discord config
661
661
  brainiac discord status # Check bot status via server API
662
662
  ```
663
663
 
664
+ ## Profiles (Multi-Account)
665
+
666
+ Profiles let you inject a named set of environment variables into an agent dispatch without standing up a separate agent. The canonical use-case is running different CLI accounts (e.g. two AWS accounts) from the same brainiac instance.
667
+
668
+ ### How it works
669
+
670
+ Add a `[profile:name]` or `[p:name]` inline tag to any Discord message, Fizzy comment, or GitHub message. The matching profile's env is merged into the dispatch — same CLI, same agent, different credentials.
671
+
672
+ ### kiro-cli: two env vars, two concerns
673
+
674
+ kiro-cli splits state across two independent dirs:
675
+
676
+ | Env var | What it controls | File |
677
+ |---------|-----------------|------|
678
+ | `XDG_DATA_HOME` | Auth store — tokens + which account you're logged into | `kiro-cli/data.sqlite3` |
679
+ | `KIRO_HOME` | Settings — `chat.defaultModel`, agents, MCP, permissions, steering | `settings/cli.json` |
680
+
681
+ Give each profile its own `KIRO_HOME` and each account gets its own `chat.defaultModel`. That's the clean way to run different accounts on different models — set each account's model once and let it drive.
682
+
683
+ ### Configuration (`~/.brainiac/profiles.json`)
684
+
685
+ ```json
686
+ {
687
+ "q": {
688
+ "env": {
689
+ "XDG_DATA_HOME": "/home/you/.local/share",
690
+ "KIRO_HOME": "/home/you/.kiro"
691
+ },
692
+ "default": true
693
+ },
694
+ "k+": {
695
+ "env": {
696
+ "XDG_DATA_HOME": "/home/you/.local/share/brainiac/k+",
697
+ "KIRO_HOME": "/home/you/.kiro-accounts/k+"
698
+ }
699
+ }
700
+ }
701
+ ```
702
+
703
+ Exactly one profile should be marked `"default": true` — it applies when no `[profile:X]` tag is present.
704
+
705
+ ### Model selection
706
+
707
+ Model selection is a **cli-provider concern**, not a profile concern. kiro-cli accepts `--model <id>` per run, so the kiro-cli provider uses `"model_flag": "--model"`. A model tag (`[opus]`, `[sol]`, etc.) only affects that dispatch (and the thread/work item it was sent in). It never changes the account's saved `chat.defaultModel`, so other threads are unaffected.
708
+
709
+ Each account's `chat.defaultModel` (in its own `KIRO_HOME`) only applies when no model is resolved at all. Keep it set to a model that account can actually use.
710
+
711
+ **`settings_model_cmd` (legacy, avoid for kiro-cli):** for CLIs with no runtime model flag, a provider can set `"model_flag": ""` plus `"settings_model_cmd": [...]`, and brainiac runs that command before each tagged dispatch. That write is *persistent*: it becomes the account's default for every later dispatch, including unrelated threads. If a tag writes a model the default account doesn't have (e.g. `[sol]` landing on `q`), every untagged dispatch on that account fails with `The model '...' is not available`.
712
+
713
+ ### Seeding a new KIRO_HOME
714
+
715
+ A fresh `KIRO_HOME` starts empty. brainiac dispatches with `--agent <name>`, so each home needs agent definitions or dispatches will fail. Symlink the shared bits from your real `~/.kiro` and keep only `settings/cli.json` per-account:
716
+
717
+ ```bash
718
+ mkdir -p ~/.kiro-accounts/k+/settings
719
+ ln -s ~/.kiro/agents ~/.kiro-accounts/k+/agents
720
+ ln -s ~/.kiro/mcp.json ~/.kiro-accounts/k+/mcp.json
721
+ ln -s ~/.kiro/settings/permissions.yaml ~/.kiro-accounts/k+/settings/permissions.yaml
722
+ KIRO_HOME=~/.kiro-accounts/k+ kiro-cli settings chat.defaultModel claude-opus-5
723
+ ```
724
+
725
+ Everything stays in sync with `~/.kiro` via the symlinks — only the model differs.
726
+
664
727
  ## Cron (Scheduled Tasks)
665
728
 
666
729
  Agents can be dispatched on a schedule — daily standups, weekly summaries, periodic code reviews, whatever you want. Jobs are stored in `~/.brainiac/cron.json` and run in a background thread inside `brainiac server`.
@@ -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 heaviness_probe].each do |key|
38
+ effort_config_key effort_map title_flag heaviness_probe settings_model_cmd].each do |key|
39
39
  next unless raw[key]
40
40
  next if raw[key].respond_to?(:empty?) && raw[key].empty?
41
41
 
@@ -315,6 +315,11 @@ end
315
315
  def register_work_item(branch:, worktree: nil, project: nil, agent: nil, source: nil, source_data: {})
316
316
  map = load_work_item_map
317
317
 
318
+ # Normalize source to a string key — callers may pass a symbol (e.g. :fizzy).
319
+ # The sources hash is JSON-serialized with string keys, so mixing symbol and
320
+ # string keys produces a "duplicate key" error in JSON.pretty_generate.
321
+ source = source.to_s if source
322
+
318
323
  # Check if a work item already exists for this branch
319
324
  existing_id = nil
320
325
  map.each do |wid, info|
@@ -330,7 +335,7 @@ def register_work_item(branch:, worktree: nil, project: nil, agent: nil, source:
330
335
  # Update existing entry — merge in new source, update worktree/agent if provided
331
336
  map[work_item_id]["worktree"] = worktree if worktree
332
337
  map[work_item_id]["agent"] = agent if agent
333
- map[work_item_id]["sources"] ||= {}
338
+ map[work_item_id]["sources"] = normalize_source_keys(map[work_item_id]["sources"])
334
339
  map[work_item_id]["sources"][source] = source_data if source
335
340
  else
336
341
  # Create new entry
@@ -440,6 +445,10 @@ end
440
445
  def register_work_item_source(source:, source_data:, work_item_id: nil, branch: nil) # rubocop:disable Naming/PredicateMethod
441
446
  map = load_work_item_map
442
447
 
448
+ # Normalize source to a string key — callers may pass a symbol (e.g. :fizzy).
449
+ # See register_work_item for why mixing symbol/string keys breaks JSON serialization.
450
+ source = source.to_s if source
451
+
443
452
  # Find by ID or branch
444
453
  target_id = work_item_id
445
454
  unless target_id
@@ -453,12 +462,24 @@ def register_work_item_source(source:, source_data:, work_item_id: nil, branch:
453
462
 
454
463
  return false unless target_id && map[target_id]
455
464
 
456
- map[target_id]["sources"] ||= {}
465
+ map[target_id]["sources"] = normalize_source_keys(map[target_id]["sources"])
457
466
  map[target_id]["sources"][source] = source_data
458
467
  save_work_item_map(map)
459
468
  true
460
469
  end
461
470
 
471
+ # Normalize a work item's sources hash so all keys are strings. Guards against
472
+ # a previously-corrupted in-memory hash that mixed symbol and string keys
473
+ # (e.g. both :fizzy and "fizzy"), which would otherwise crash JSON serialization.
474
+ # A later entry (symbol source just written) wins over an existing string entry.
475
+ def normalize_source_keys(sources)
476
+ return {} unless sources.is_a?(Hash)
477
+
478
+ sources.each_with_object({}) do |(key, value), acc|
479
+ acc[key.to_s] = value
480
+ end
481
+ end
482
+
462
483
  def slugify(title, max_length: 40)
463
484
  title.downcase.gsub(/[^a-z0-9\s-]/, "").strip.gsub(/\s+/, "-").slice(0, max_length).chomp("-")
464
485
  end
@@ -774,12 +795,13 @@ end
774
795
  # Plugins should NOT build their own resume logic; pass `resume: true` and let core handle it.
775
796
  def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil, effort: nil, agent_name: nil, card_number: nil, comment_id: nil,
776
797
  source: nil, source_context: {}, skip_column_move: false, cli_provider: nil, resume: false,
777
- message: nil, channel: nil, context: nil, env: {}, profile: nil)
798
+ message: nil, channel: nil, context: nil, env: {}, profile: nil, explicit_model: nil)
778
799
  # Intent gate: if a raw message is provided, check whether the agent should respond.
779
800
  return nil if intent_skip?(message, agent_name: agent_name, source: source, channel: channel, context: context)
780
801
 
781
802
  resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider, agent_name: agent_name)
782
803
  chdir ||= resolved["repo_path"]
804
+ explicit_model = model_explicit?(model, explicit_model)
783
805
  model ||= resolved["agent_model"]
784
806
  effort ||= resolved["agent_effort"]
785
807
  agent_config_name = agent_name&.downcase&.gsub(/[^a-z0-9-]/, "-")
@@ -810,6 +832,12 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
810
832
  # wins over agent env; the default profile is only a fallback. See profiles.rb.
811
833
  spawn_env = profile_spawn_env(agent_env_for(agent_name), profile).merge(env)
812
834
 
835
+ # For providers that use settings_model_cmd instead of a --model flag (e.g. kiro-cli,
836
+ # which requires `kiro-cli settings chat.defaultModel <name>`), apply the model to
837
+ # the provider's settings store before spawning. The write runs under spawn_env so it
838
+ # targets the correct account's KIRO_HOME. No-op when model_flag is non-empty.
839
+ apply_settings_model(model, resolved, spawn_env, explicit: explicit_model)
840
+
813
841
  log_agent_launch(resolved: resolved, chdir: chdir, log_file: log_file, prompt_file: prompt_file,
814
842
  output_file: output_file, cmd: cmd, should_resume: should_resume,
815
843
  spawn_env: spawn_env, agent_name: agent_name)
@@ -829,7 +857,8 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
829
857
  card_number: card_number, skip_column_move: skip_column_move,
830
858
  output_file: output_file, resolved: resolved, minted_session_id: minted_session_id,
831
859
  head_before: head_before, status_before: status_before,
832
- project_key_for_restart: project_key_for_restart, model: model, started_at: started_at
860
+ project_key_for_restart: project_key_for_restart, model: model, started_at: started_at,
861
+ profile: effective_profile_name(profile)
833
862
  )
834
863
 
835
864
  LOG.info "#{resolved["agent_cli"]} started (pid: #{pid}, agent: #{agent_config_name || "default"}, " \
@@ -946,6 +975,61 @@ def append_model_to_cmd(cmd, model, resolved)
946
975
  cmd.push(resolved["agent_model_flag"], effective_model) if is_known
947
976
  end
948
977
 
978
+ # A model is only "explicit" if the caller actually supplied one (i.e. it came from a
979
+ # tag) AND flagged it explicit. A nil model means we'll fall back to the project default,
980
+ # which must never be treated as explicit — otherwise no-tag dispatches would write it.
981
+ def model_explicit?(model, explicit_flag)
982
+ explicit_flag && !model.nil?
983
+ end
984
+
985
+ # When a provider uses settings_model_cmd instead of a runtime --model flag,
986
+ # run that command synchronously before spawning the agent so the correct model
987
+ # is already persisted in the provider's settings dir (e.g. KIRO_HOME/settings/cli.json).
988
+ #
989
+ # WARNING: this write is sticky. It changes the account's default for every later
990
+ # dispatch, not just this one, so a tag in one thread leaks into unrelated threads.
991
+ # Only use it for CLIs with no per-run model flag. kiro-cli supports --model and
992
+ # should use "model_flag": "--model" instead.
993
+ #
994
+ # Only fires when:
995
+ # - model_flag is absent or empty (the provider doesn't support runtime model selection)
996
+ # - settings_model_cmd is configured on the provider
997
+ # - a model was resolved (nil model = let the account default stand)
998
+ #
999
+ # The `explicit` flag decides whether "auto" gets written:
1000
+ # - explicit == true → the model came from an inline/card tag ([opus], [auto], ...).
1001
+ # ALWAYS write it, including "auto" — an explicit [auto] is a deliberate reset that
1002
+ # clears a polluted default back to the account's no-preference state.
1003
+ # - explicit == false → the model is the project's default fallback (no tag given).
1004
+ # Skip "auto" (writing it would clobber each account's per-account default on every
1005
+ # no-tag dispatch). A non-auto default still writes, preserving prior behavior.
1006
+ # - explicit == nil → caller didn't specify (older/other call sites). Treat as
1007
+ # not-explicit for safety — same as the pre-existing "skip auto" behavior.
1008
+ #
1009
+ # spawn_env is passed in so the command runs under the same env as the agent
1010
+ # (picks up KIRO_HOME, XDG_DATA_HOME, etc.) — ensuring the write goes to the right
1011
+ # account's settings dir. With per-profile KIRO_HOME the write is isolated to that
1012
+ # account, so concurrent dispatches on different profiles never race.
1013
+ def apply_settings_model(model, resolved, spawn_env, explicit: nil)
1014
+ return unless model
1015
+ return if resolved["agent_model_flag"] && !resolved["agent_model_flag"].empty?
1016
+
1017
+ settings_cmd = resolved["settings_model_cmd"]
1018
+ return unless settings_cmd.is_a?(Array) && settings_cmd.length >= 2
1019
+
1020
+ allowed = resolved["allowed_models"] || {}
1021
+ effective_model = allowed.key?(model) ? allowed[model] : model
1022
+ is_known = allowed.value?(effective_model) || allowed.key?(effective_model)
1023
+ return unless is_known
1024
+ # "auto" only writes when it was an explicit tag (used as a reset). A defaulted
1025
+ # "auto" is skipped so no-tag dispatches never overwrite an account's default.
1026
+ return if effective_model == "auto" && !explicit
1027
+
1028
+ full_cmd = settings_cmd + [effective_model]
1029
+ LOG.info "Pre-dispatch: setting model via #{full_cmd.join(" ")} (env: #{spawn_env.keys.join(", ")})"
1030
+ system(spawn_env, *full_cmd)
1031
+ end
1032
+
949
1033
  # Resume via --session <id> (OpenCode), a bare flag (grok -c), or resume_args (already applied).
950
1034
  def append_resume_to_cmd(cmd, resume)
951
1035
  case resume
@@ -1232,23 +1316,39 @@ def check_brainiac_restart(head_before, status_before, chdir, project_key_for_re
1232
1316
  end
1233
1317
 
1234
1318
  def detect_model(project_config, tags: [], text: "", cli_provider_override: nil, agent_name: nil)
1319
+ detect_model_explicit(project_config, tags: tags, text: text,
1320
+ cli_provider_override: cli_provider_override, agent_name: agent_name).first
1321
+ end
1322
+
1323
+ # Like detect_model, but also reports whether the model came from an EXPLICIT source
1324
+ # (an inline [model] tag or a card tag) versus the project's default fallback.
1325
+ #
1326
+ # Returns [model_string, explicit_bool].
1327
+ # explicit == true → the user asked for this model via a tag ([opus], [auto], etc.)
1328
+ # explicit == false → no tag matched; the value is the project's agent_model default
1329
+ #
1330
+ # This distinction is load-bearing for apply_settings_model: an explicit tag should
1331
+ # always write the provider's default-model setting (even "auto", used as a reset),
1332
+ # while a fell-back-to-default value must NOT write — otherwise every no-tag dispatch
1333
+ # would clobber each account's per-account default back to the project default.
1334
+ def detect_model_explicit(project_config, tags: [], text: "", cli_provider_override: nil, agent_name: nil)
1235
1335
  # If no explicit CLI provider override, check if the agent has one configured
1236
1336
  effective_cli_provider = cli_provider_override || agent_cli_provider_for(agent_name)
1237
1337
  resolved = resolve_project_cli_config(project_config, cli_provider_override: effective_cli_provider, agent_name: agent_name)
1238
1338
  allowed_models = resolved["allowed_models"] || {}
1239
- return resolved["agent_model"] if allowed_models.empty?
1339
+ return [resolved["agent_model"], false] if allowed_models.empty?
1240
1340
 
1241
1341
  if (match = text.match(/\[(\w+)\]/))
1242
1342
  key = match[1].downcase
1243
- return allowed_models[key] if allowed_models.key?(key)
1343
+ return [allowed_models[key], true] if allowed_models.key?(key)
1244
1344
  end
1245
1345
 
1246
1346
  tags.each do |tag|
1247
1347
  key = (tag.is_a?(Hash) ? tag["name"] : tag).to_s.downcase
1248
- return allowed_models[key] if allowed_models.key?(key)
1348
+ return [allowed_models[key], true] if allowed_models.key?(key)
1249
1349
  end
1250
1350
 
1251
- resolved["agent_model"]
1351
+ [resolved["agent_model"], false]
1252
1352
  end
1253
1353
 
1254
1354
  # Detect effort level from inline tags [effort:high] or card tags (effort-high).
@@ -1274,6 +1374,40 @@ def detect_effort(project_config, tags: [], text: "", cli_provider_override: nil
1274
1374
  resolved["agent_effort"]
1275
1375
  end
1276
1376
 
1377
+ # Detect a profile ([profile:X]/[p:X]) from inline text OR card tags.
1378
+ #
1379
+ # A profile is a named env bundle (e.g. an alternate kiro-cli account via
1380
+ # XDG_DATA_HOME/KIRO_HOME) — see profiles.rb. This mirrors detect_model/detect_effort:
1381
+ # it checks inline text first, then falls back to card tags. Without the tag path,
1382
+ # adding a `p:k+` chip to a Fizzy card was silently ignored (only inline title text
1383
+ # was honored), so dispatches ran on the default profile.
1384
+ #
1385
+ # Card tags can be named either "profile:X"/"p:X" (colon form, matches inline syntax)
1386
+ # or a bare "X" that happens to be a known profile name.
1387
+ # Returns the lowercased profile name or nil.
1388
+ def detect_profile(text: "", tags: [])
1389
+ # Inline tag: [profile:k+] / [p:k+] — works in any channel
1390
+ inline = parse_inline_tags(text.to_s)[:profile]
1391
+ return inline if inline
1392
+
1393
+ known = defined?(PROFILES) ? PROFILES : {}
1394
+
1395
+ tags.each do |tag|
1396
+ name = (tag.is_a?(Hash) ? tag["name"] : tag).to_s.strip.downcase
1397
+ next if name.empty?
1398
+
1399
+ # "profile:k+" or "p:k+" colon form
1400
+ if (m = name.match(/\A(?:profile|p):(.+)\z/))
1401
+ return m[1].strip
1402
+ end
1403
+
1404
+ # bare tag that matches a configured profile name (e.g. a "k+" chip)
1405
+ return name if known.key?(name)
1406
+ end
1407
+
1408
+ nil
1409
+ end
1410
+
1277
1411
  # If a level isn't in allowed_efforts, return the closest lower level.
1278
1412
  def resolve_effort_level(level, allowed)
1279
1413
  all_levels = %w[low medium high xhigh max]
@@ -8,17 +8,40 @@
8
8
  # dispatch runs against via an inline tag ([profile:X] / [p:X]) — same CLI, same
9
9
  # agent, different credentials.
10
10
  #
11
+ # kiro-cli splits its state across two independent env vars — a profile should set both:
12
+ #
13
+ # XDG_DATA_HOME → auth store (kiro-cli/data.sqlite3): tokens + which account you're
14
+ # logged into. This selects the account.
15
+ # KIRO_HOME → settings dir (~/.kiro, incl. settings/cli.json): chat.defaultModel,
16
+ # agents, MCP, permissions, steering. This is where chat.defaultModel
17
+ # lives — giving each profile its own KIRO_HOME means each account has
18
+ # its own model setting.
19
+ #
20
+ # Model selection is a cli-provider concern, not a profile concern. kiro-cli takes
21
+ # --model per run ("model_flag": "--model"), so an inline model tag only affects that
22
+ # dispatch. Each account's chat.defaultModel (in its own KIRO_HOME) is just the fallback
23
+ # when no model resolves — keep it set to a model that account actually has.
24
+ #
25
+ # NOTE: a fresh KIRO_HOME starts empty. brainiac dispatches with --agent <name>, so each
26
+ # KIRO_HOME needs agent definitions (and any MCP/permissions you rely on) or dispatches
27
+ # break. Seed a new home by symlinking the shared bits from ~/.kiro and keeping only
28
+ # settings/cli.json per-account:
29
+ #
30
+ # mkdir -p ~/.kiro-accounts/k+/settings
31
+ # ln -s ~/.kiro/agents ~/.kiro-accounts/k+/agents
32
+ # ln -s ~/.kiro/mcp.json ~/.kiro-accounts/k+/mcp.json
33
+ # ln -s ~/.kiro/settings/permissions.yaml ~/.kiro-accounts/k+/settings/permissions.yaml
34
+ # KIRO_HOME=~/.kiro-accounts/k+ kiro-cli settings chat.defaultModel claude-opus-5
35
+ #
11
36
  # Config lives at ~/.brainiac/profiles.json:
12
37
  #
13
38
  # {
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 }
39
+ # "q": { "env": { "XDG_DATA_HOME": "/home/andy/.local/share",
40
+ # "KIRO_HOME": "/home/andy/.kiro" }, "default": true },
41
+ # "k+": { "env": { "XDG_DATA_HOME": "/home/andy/.local/share/brainiac/k+",
42
+ # "KIRO_HOME": "/home/andy/.kiro-accounts/k+" } }
16
43
  # }
17
44
  #
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
45
  # Exactly one profile may be marked "default": true — it applies when no [profile:X]
23
46
  # tag is present. Because it's only a fallback, an agent's own env (from agents.json)
24
47
  # takes precedence over the default profile. An *explicitly requested* profile, by
@@ -70,6 +93,23 @@ def default_profile
70
93
  PROFILES.find { |_name, entry| entry.is_a?(Hash) && entry["default"] }
71
94
  end
72
95
 
96
+ # Resolve the *effective* profile name that a dispatch actually ran under, mirroring
97
+ # profile_spawn_env's fallback logic. This is what should be recorded in durable
98
+ # session history and shown in the monitor so you can tell whether a session used the
99
+ # default account (e.g. "q") or an explicitly-requested one (e.g. "k+").
100
+ #
101
+ # - An explicitly-requested, known profile → that name (normalized lowercase).
102
+ # - An explicitly-requested but UNKNOWN profile → nil (it was ignored at spawn, so it
103
+ # didn't actually run under any profile — matches profile_spawn_env dropping it).
104
+ # - No profile requested → the default profile's name, or nil if there's no default.
105
+ def effective_profile_name(profile_name)
106
+ if profile_name
107
+ profile_entry(profile_name) ? profile_name.to_s.downcase : nil
108
+ else
109
+ default_profile&.first
110
+ end
111
+ end
112
+
73
113
  # Resolve the env hash contributed by a profile.
74
114
  #
75
115
  # When profile_name is given and matches a profile, returns that profile's env.
@@ -89,6 +89,7 @@ def archive_session_history(ctx:, exit_status:, signaled:)
89
89
  "log_file" => ctx[:log_file],
90
90
  "cli" => ctx[:agent_cli],
91
91
  "model" => ctx[:model],
92
+ "profile" => ctx[:profile],
92
93
  "started_at" => started_at&.utc&.iso8601,
93
94
  "finished_at" => finished_at.utc.iso8601,
94
95
  "duration_seconds" => started_at ? (finished_at - started_at).to_i : nil,
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Brainiac
4
4
  # @return [String] the current gem version
5
- VERSION = "0.1.2"
5
+ VERSION = "0.1.4"
6
6
  end
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "q": {
3
3
  "env": {
4
- "XDG_DATA_HOME": "/home/you/.local/share"
5
- }
4
+ "XDG_DATA_HOME": "/home/you/.local/share",
5
+ "KIRO_HOME": "/home/you/.kiro"
6
+ },
7
+ "default": true
6
8
  },
7
9
  "k+": {
8
10
  "env": {
9
- "XDG_DATA_HOME": "/home/you/.brainiac/kiro-accounts/kiro-pro"
10
- },
11
- "default": true
11
+ "XDG_DATA_HOME": "/home/you/.local/share/brainiac/k+",
12
+ "KIRO_HOME": "/home/you/.kiro-accounts/k+"
13
+ }
12
14
  }
13
15
  }
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.1.2
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis