brainiac 0.1.8 → 0.1.10

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: ab0a2720abc8e0ea7c76d7a0c4f8ec6303a744dc8bdf7dfd6cd7d41b5d0671e2
4
- data.tar.gz: beefb425e88f3418babaf504b5d0a4841c7d7cda03ef189951adb1fd42c747fd
3
+ metadata.gz: e63254b890e44045e1c05872417234f86e34fdcd36a737c7565c4ee7efc74426
4
+ data.tar.gz: 630399ccb91dc4ef99b6f14619710c5893b6194354dc3a89f3d491cc4b226bdb
5
5
  SHA512:
6
- metadata.gz: 30ed5d2c72453ef0bf624c1bf34df92111bd1e5cab30c492fc483ad9ab241794ff177452b2bbc533e42d39b9199882382eda6930acc89a83f8f3a371020cd166
7
- data.tar.gz: b5d1b984a1bf7e17e094da496b831f34c822c9368a1c8966b6dc3d30057101c913d69629cf94f9358b6ee196d4a84dd58fb744fe8d652f5280dbf344911efc2f
6
+ metadata.gz: d0a1f9cc0ca39aa87aec48c3b45132b7777e6dc6ca7f71006a78e6ebe00879c188d8cea9104822d2e32a3d1b65970d09d5271c558d5af10faa8d29a24bd182a5
7
+ data.tar.gz: '02884d9354a9b5946a28f58f0e0c70d4f98893554b773f1738aa6e2a9383850d4f7577c8d691c9ef11867f71a81c5327a5ac28922fc7b90a95311bb1ceb536c4'
@@ -107,9 +107,8 @@ end
107
107
  # Returns the provider name (e.g. "grok") or nil.
108
108
  def detect_cli_provider(text: "", tags: [])
109
109
  # Inline tag: [cli:grok] — works in any channel
110
- if (match = text.match(/\[cli:(\w+)\]/i))
111
- return match[1].downcase
112
- end
110
+ inline = inline_cli_provider_tag(text)
111
+ return inline if inline
113
112
 
114
113
  # Plugin hook: let plugins detect from their own metadata (e.g., card tags)
115
114
  results = Brainiac.emit(:detect_cli_provider, text: text, tags: tags)
@@ -119,6 +118,11 @@ def detect_cli_provider(text: "", tags: [])
119
118
  nil
120
119
  end
121
120
 
121
+ # The provider named by an inline [cli:X] tag in text, or nil. Text only — no plugin hooks.
122
+ def inline_cli_provider_tag(text)
123
+ text.to_s.match(/\[cli:(\w+)\]/i)&.[](1)&.downcase
124
+ end
125
+
122
126
  def default_project_key
123
127
  # Find the project marked as default
124
128
  default = PROJECTS.find { |_key, config| config["default"] == true }
@@ -354,9 +358,11 @@ def register_work_item(branch:, worktree: nil, project: nil, agent: nil, source:
354
358
  end
355
359
 
356
360
  # Update dispatch overrides on a work item (cli_provider, model, effort, profile).
357
- # Only non-nil values are stored; nil values are removed from existing overrides.
361
+ # Non-nil values are stored; nil values leave the existing override untouched.
362
+ # Keys listed in clear: (e.g. [:model]) are removed from the stored overrides.
358
363
  # Returns true if the work item was found and updated, false otherwise.
359
- def update_work_item_overrides(cli_provider: nil, model: nil, effort: nil, profile: nil, work_item_id: nil, branch: nil) # rubocop:disable Naming/PredicateMethod
364
+ def update_work_item_overrides(cli_provider: nil, model: nil, effort: nil, profile: nil, work_item_id: nil, branch: nil, # rubocop:disable Naming/PredicateMethod
365
+ clear: [])
360
366
  map = load_work_item_map
361
367
 
362
368
  target_id = work_item_id
@@ -376,6 +382,7 @@ def update_work_item_overrides(cli_provider: nil, model: nil, effort: nil, profi
376
382
  overrides["model"] = model if model
377
383
  overrides["effort"] = effort if effort
378
384
  overrides["profile"] = profile if profile
385
+ Array(clear).each { |key| overrides.delete(key.to_s) }
379
386
  overrides.compact!
380
387
 
381
388
  if overrides.empty?
@@ -425,33 +432,78 @@ end
425
432
  # Unknown profile names are never persisted, so a typo can't pin a work item to a
426
433
  # profile that doesn't exist (profile_spawn_env ignores it for this dispatch too).
427
434
  #
435
+ # A stored model is a provider-specific ID (kiro's "opus" is "claude-opus-4.5", claude's is
436
+ # "opus"), so switching providers with [cli:X] drops it and the new provider's default
437
+ # applies, unless the same message also carries a model tag. default_cli_provider: is the
438
+ # provider a work item with no stored cli_provider was running on; without it, any inline
439
+ # [cli:X] on such a work item counts as a switch.
440
+ #
428
441
  # Returns: { cli_provider:, model:, effort:, profile: } (each String or nil)
429
442
  def resolve_work_item_overrides(work_item_id: nil, branch: nil, inline_cli_provider: nil, inline_model: nil, inline_effort: nil,
430
- inline_profile: nil)
443
+ inline_profile: nil, default_cli_provider: nil)
431
444
  stored = work_item_overrides_for(work_item_id: work_item_id, branch: branch)
432
445
  inline_profile = inline_profile&.to_s&.downcase
433
446
  persist_profile = inline_profile && profile_entry(inline_profile) ? inline_profile : nil
447
+ switched = cli_provider_switched?(inline_cli_provider, stored["cli_provider"] || default_cli_provider)
448
+ stored_model = switched ? nil : stored["model"]
434
449
 
435
450
  # Inline tags override stored values
436
451
  resolved = {
437
452
  cli_provider: inline_cli_provider || stored["cli_provider"],
438
- model: inline_model || stored["model"],
453
+ model: inline_model || stored_model,
439
454
  effort: inline_effort || stored["effort"],
440
455
  profile: inline_profile || stored["profile"]
441
456
  }
442
457
 
443
458
  # Persist any new inline overrides to the work item for future dispatches
444
- new_overrides = {}
445
- new_overrides[:cli_provider] = inline_cli_provider if inline_cli_provider
446
- new_overrides[:model] = inline_model if inline_model
447
- new_overrides[:effort] = inline_effort if inline_effort
448
- new_overrides[:profile] = persist_profile if persist_profile
459
+ new_overrides = { cli_provider: inline_cli_provider, model: inline_model, effort: inline_effort,
460
+ profile: persist_profile }.compact
461
+ new_overrides[:clear] = [:model] if switched && !inline_model && stored["model"]
449
462
 
450
463
  update_work_item_overrides(work_item_id: work_item_id, branch: branch, **new_overrides) if new_overrides.any? && (work_item_id || branch)
451
464
 
452
465
  resolved
453
466
  end
454
467
 
468
+ # True when an inline [cli:X] names a different provider than the one previously in use.
469
+ def cli_provider_switched?(inline_cli_provider, previous_cli_provider)
470
+ !inline_cli_provider.nil? && inline_cli_provider != previous_cli_provider
471
+ end
472
+
473
+ # One-stop override resolution for a dispatch: parses [cli:X]/[model]/[effort:X]/[p:X] from
474
+ # the message text (and plugin card tags), merges them with the work item's stored overrides,
475
+ # and persists any new inline values. The model and effort are resolved against the provider
476
+ # that will actually run (inline > stored > agent/project default), so a [sonnet] tag can't be
477
+ # looked up in the wrong provider's model map. Plugins should prefer this over calling
478
+ # detect_model/detect_effort and resolve_work_item_overrides by hand.
479
+ #
480
+ # Returns: { cli_provider:, model:, explicit_model:, effort:, profile: }
481
+ # cli_provider is nil when nothing overrides the agent/project default.
482
+ # explicit_model is true when the model came from a tag (see detect_model_explicit).
483
+ def resolve_dispatch_overrides(project_config, text: "", tags: [], agent_name: nil, work_item_id: nil, branch: nil)
484
+ stored = work_item_overrides_for(work_item_id: work_item_id, branch: branch)
485
+ inline_cli = detect_cli_provider(text: text, tags: tags)
486
+ default_cli = agent_cli_provider_for(agent_name) || project_config["cli_provider"]
487
+ effective_cli = inline_cli || stored["cli_provider"]
488
+
489
+ model, explicit_model = detect_model_explicit(project_config, tags: tags, text: text,
490
+ cli_provider_override: effective_cli, agent_name: agent_name)
491
+ effort, explicit_effort = detect_effort_explicit(project_config, tags: tags, text: text,
492
+ cli_provider_override: effective_cli, agent_name: agent_name)
493
+
494
+ resolved = resolve_work_item_overrides(work_item_id: work_item_id, branch: branch, inline_cli_provider: inline_cli,
495
+ inline_model: explicit_model ? model : nil,
496
+ inline_effort: explicit_effort ? effort : nil,
497
+ inline_profile: detect_profile(text: text, tags: tags),
498
+ default_cli_provider: default_cli)
499
+ # Nothing tagged or stored (or the stored model was dropped by a switch): use the
500
+ # effective provider's defaults, already resolved above.
501
+ resolved[:model] ||= model
502
+ resolved[:effort] ||= effort
503
+ resolved[:explicit_model] = explicit_model
504
+ resolved
505
+ end
506
+
455
507
  # Add or update a source on an existing work item.
456
508
  # Returns true if the work item was found and updated, false otherwise.
457
509
  def register_work_item_source(source:, source_data:, work_item_id: nil, branch: nil) # rubocop:disable Naming/PredicateMethod
@@ -652,9 +704,11 @@ def prior_session_exists?(chdir, agent_cli, session_dir: nil)
652
704
  return true if File.directory?(session_dir_local)
653
705
 
654
706
  # Fallback: look for recent session logs in tmp/ that suggest this CLI ran here before.
655
- # This covers CLIs that don't leave a dotdir but do leave logs via brainiac.
707
+ # This covers CLIs that don't leave a dotdir but do leave logs via brainiac. The logs
708
+ # don't say which CLI wrote them, so only trust them when the last-CLI marker says it was this one.
656
709
  tmp_dir = File.join(chdir, "tmp")
657
710
  return false unless File.directory?(tmp_dir)
711
+ return false unless last_cli_for(chdir) == cli_name
658
712
 
659
713
  Dir.glob(File.join(tmp_dir, "agent-*.log")).any? do |log|
660
714
  # Only count logs from the last 24 hours as valid "resumable" sessions
@@ -664,6 +718,40 @@ rescue StandardError
664
718
  false
665
719
  end
666
720
 
721
+ # Marker recording which CLI binary last ran in a directory (written by run_agent).
722
+ def last_cli_marker_path(chdir)
723
+ File.join(chdir, "tmp", ".brainiac-last-cli")
724
+ end
725
+
726
+ def last_cli_for(chdir)
727
+ return nil unless chdir
728
+
729
+ name = File.read(last_cli_marker_path(chdir)).strip
730
+ name.empty? ? nil : name
731
+ rescue StandardError
732
+ nil
733
+ end
734
+
735
+ def record_last_cli(chdir, agent_cli)
736
+ key = cli_session_key(agent_cli)
737
+ return unless chdir && key
738
+
739
+ FileUtils.mkdir_p(File.join(chdir, "tmp"))
740
+ File.write(last_cli_marker_path(chdir), key)
741
+ rescue StandardError => e
742
+ LOG.warn "[Dispatch] Could not record last CLI for #{chdir}: #{e.message}"
743
+ end
744
+
745
+ # True when a different CLI ran in this directory more recently than agent_cli.
746
+ # A switched-to CLI never saw the other provider's turns, so resuming its own (older)
747
+ # session would hand it a lean prompt with a stale or empty history. Start fresh instead
748
+ # and let the full prompt (memory file + source context) carry the conversation.
749
+ def cli_switched_since_last_run?(chdir, agent_cli)
750
+ last = last_cli_for(chdir)
751
+ key = cli_session_key(agent_cli)
752
+ !last.nil? && !key.nil? && last != key
753
+ end
754
+
667
755
  # Check if a centralized session directory has sessions matching the given cwd.
668
756
  # Supports CLIs that store sessions as .jsonl files in date-partitioned directories (YYYY/MM/DD/)
669
757
  # with a first-line JSON metadata object containing a "payload.cwd" field.
@@ -725,6 +813,7 @@ end
725
813
  def resume_viable?(project_config:, cli_provider: nil, agent_name: nil, chdir: nil)
726
814
  resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider, agent_name: agent_name)
727
815
  chdir ||= resolved["repo_path"]
816
+ return false if cli_switched_since_last_run?(chdir, resolved["agent_cli"])
728
817
  return stored_cli_session_viable?(resolved, chdir) if resolved["resume_id_flag"]
729
818
  return false unless resolved["resume_flag"] || resolved["resume_args"]
730
819
 
@@ -753,6 +842,11 @@ end
753
842
  def resolve_resume(resume, resolved, chdir, session_id: nil)
754
843
  return false unless resume
755
844
 
845
+ if cli_switched_since_last_run?(chdir, resolved["agent_cli"])
846
+ LOG.info "[Dispatch] CLI switched from #{last_cli_for(chdir)} to #{resolved["agent_cli"]} in #{chdir} — starting fresh session"
847
+ return false
848
+ end
849
+
756
850
  if resolved["resume_id_flag"]
757
851
  id = session_id.to_s.empty? ? cli_session_id_for(agent_cli: resolved["agent_cli"], worktree: chdir) : session_id
758
852
  if id.to_s.empty?
@@ -772,16 +866,27 @@ def resolve_resume(resume, resolved, chdir, session_id: nil)
772
866
  false
773
867
  end
774
868
 
775
- # Some CLIs (e.g. grok --session-id) let the caller name a new session up front.
776
- # Mint a UUID only on the first run, when we are not resuming an existing id.
777
- def mint_cli_session_id(resolved, stored_session:, resuming:)
869
+ # Some CLIs (e.g. claude/grok --session-id) let the caller name a new session up front.
870
+ # Mint a UUID whenever we start fresh — including after a provider switch, when an older
871
+ # stored id exists — so the stored id always points at the latest session for this CLI.
872
+ def mint_cli_session_id(resolved, resuming:)
778
873
  return nil if resuming
779
874
  return nil unless resolved["new_session_id_flag"]
780
- return nil unless stored_session.to_s.empty?
781
875
 
782
876
  SecureRandom.uuid
783
877
  end
784
878
 
879
+ # Decide resume vs. fresh start for this dispatch, then record this CLI as the last
880
+ # one to run here. Returns [should_resume, minted_session_id].
881
+ def prepare_cli_session(resume, resolved, chdir)
882
+ stored_session = cli_session_id_for(agent_cli: resolved["agent_cli"], worktree: chdir)
883
+ # Auto-resume: only if the provider supports it AND a prior session exists for this CLI here.
884
+ should_resume = resolve_resume(resume, resolved, chdir, session_id: stored_session)
885
+ minted_session_id = mint_cli_session_id(resolved, resuming: should_resume)
886
+ record_last_cli(chdir, resolved["agent_cli"])
887
+ [should_resume, minted_session_id]
888
+ end
889
+
785
890
  # Check if intent detection says to skip dispatching the agent.
786
891
  # Returns true if the message should be skipped, false otherwise.
787
892
  # Only runs if a raw message is provided, an agent is named, and intent is enabled.
@@ -818,12 +923,8 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
818
923
  effort ||= resolved["agent_effort"]
819
924
  agent_config_name = agent_name&.downcase&.gsub(/[^a-z0-9-]/, "-")
820
925
 
821
- stored_session = cli_session_id_for(agent_cli: resolved["agent_cli"], worktree: chdir)
822
926
  work_item_id = find_work_item_by_worktree(chdir)&.first
823
-
824
- # Auto-resume: only if the provider supports it AND a prior session exists for this CLI here.
825
- should_resume = resolve_resume(resume, resolved, chdir, session_id: stored_session)
826
- minted_session_id = mint_cli_session_id(resolved, stored_session: stored_session, resuming: should_resume)
927
+ should_resume, minted_session_id = prepare_cli_session(resume, resolved, chdir)
827
928
 
828
929
  # Pre-dispatch hook — plugins can prep the working directory (e.g., copy config files, clean up)
829
930
  Brainiac.emit(:pre_dispatch, chdir: chdir, project_config: project_config, agent_name: agent_name)
@@ -984,7 +1085,11 @@ def append_model_to_cmd(cmd, model, resolved)
984
1085
  # This handles cases where different projects use "auto" but each CLI provider maps it differently.
985
1086
  effective_model = allowed.key?(model) ? allowed[model] : model
986
1087
  is_known = allowed.value?(effective_model) || allowed.key?(effective_model)
987
- cmd.push(resolved["agent_model_flag"], effective_model) if is_known
1088
+ if is_known
1089
+ cmd.push(resolved["agent_model_flag"], effective_model)
1090
+ else
1091
+ LOG.warn "[Model] Dropping model '#{model}': not in #{resolved["agent_cli"]}'s model map; using the CLI's default"
1092
+ end
988
1093
  end
989
1094
 
990
1095
  # A model is only "explicit" if the caller actually supplied one (i.e. it came from a
@@ -1344,8 +1449,9 @@ end
1344
1449
  # while a fell-back-to-default value must NOT write — otherwise every no-tag dispatch
1345
1450
  # would clobber each account's per-account default back to the project default.
1346
1451
  def detect_model_explicit(project_config, tags: [], text: "", cli_provider_override: nil, agent_name: nil)
1347
- # If no explicit CLI provider override, check if the agent has one configured
1348
- effective_cli_provider = cli_provider_override || agent_cli_provider_for(agent_name)
1452
+ # Without an explicit override, a [cli:X] in the same text picks the model map (so
1453
+ # "[cli:claude] [sonnet]" resolves through claude's map), then the agent's provider.
1454
+ effective_cli_provider = cli_provider_override || inline_cli_provider_tag(text) || agent_cli_provider_for(agent_name)
1349
1455
  resolved = resolve_project_cli_config(project_config, cli_provider_override: effective_cli_provider, agent_name: agent_name)
1350
1456
  allowed_models = resolved["allowed_models"] || {}
1351
1457
  return [resolved["agent_model"], false] if allowed_models.empty?
@@ -1368,22 +1474,29 @@ end
1368
1474
  # If the requested level isn't supported by the current model, returns the closest
1369
1475
  # lower level from allowed_efforts.
1370
1476
  def detect_effort(project_config, tags: [], text: "", cli_provider_override: nil, agent_name: nil)
1371
- effective_cli_provider = cli_provider_override || agent_cli_provider_for(agent_name)
1477
+ detect_effort_explicit(project_config, tags: tags, text: text,
1478
+ cli_provider_override: cli_provider_override, agent_name: agent_name).first
1479
+ end
1480
+
1481
+ # Like detect_effort, but returns [level, explicit_bool] where explicit is true only when
1482
+ # the level came from an [effort:X] tag or a plugin's card tag (not the provider default).
1483
+ def detect_effort_explicit(project_config, tags: [], text: "", cli_provider_override: nil, agent_name: nil)
1484
+ effective_cli_provider = cli_provider_override || inline_cli_provider_tag(text) || agent_cli_provider_for(agent_name)
1372
1485
  resolved = resolve_project_cli_config(project_config, cli_provider_override: effective_cli_provider, agent_name: agent_name)
1373
1486
  allowed = resolved["allowed_efforts"] || %w[low medium high xhigh max]
1374
1487
 
1375
1488
  # Inline tag: [effort:high] — works in any channel
1376
1489
  if (match = text.match(/\[effort:(\w+)\]/i))
1377
1490
  level = match[1].downcase
1378
- return resolve_effort_level(level, allowed) if allowed.include?(level)
1491
+ return [resolve_effort_level(level, allowed), true] if allowed.include?(level)
1379
1492
  end
1380
1493
 
1381
1494
  # Plugin hook: let plugins detect from their own metadata (e.g., card tags)
1382
1495
  results = Brainiac.emit(:detect_effort, tags: tags, allowed: allowed)
1383
1496
  plugin_result = results.compact.first
1384
- return resolve_effort_level(plugin_result, allowed) if plugin_result
1497
+ return [resolve_effort_level(plugin_result, allowed), true] if plugin_result
1385
1498
 
1386
- resolved["agent_effort"]
1499
+ [resolved["agent_effort"], false]
1387
1500
  end
1388
1501
 
1389
1502
  # Detect a profile ([profile:X]/[p:X]) from inline text OR card tags.
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Brainiac
4
4
  # @return [String] the current gem version
5
- VERSION = "0.1.8"
5
+ VERSION = "0.1.10"
6
6
  end
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.8
4
+ version: 0.1.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis