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 +4 -4
- data/lib/brainiac/helpers.rb +142 -29
- data/lib/brainiac/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e63254b890e44045e1c05872417234f86e34fdcd36a737c7565c4ee7efc74426
|
|
4
|
+
data.tar.gz: 630399ccb91dc4ef99b6f14619710c5893b6194354dc3a89f3d491cc4b226bdb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d0a1f9cc0ca39aa87aec48c3b45132b7777e6dc6ca7f71006a78e6ebe00879c188d8cea9104822d2e32a3d1b65970d09d5271c558d5af10faa8d29a24bd182a5
|
|
7
|
+
data.tar.gz: '02884d9354a9b5946a28f58f0e0c70d4f98893554b773f1738aa6e2a9383850d4f7577c8d691c9ef11867f71a81c5327a5ac28922fc7b90a95311bb1ceb536c4'
|
data/lib/brainiac/helpers.rb
CHANGED
|
@@ -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
|
-
|
|
111
|
-
|
|
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
|
-
#
|
|
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
|
|
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 ||
|
|
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
|
-
|
|
446
|
-
new_overrides[:
|
|
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
|
|
777
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
1348
|
-
|
|
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
|
-
|
|
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.
|
data/lib/brainiac/version.rb
CHANGED