brainiac 0.1.7 → 0.1.9

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: ad5658a53a073cc5742607142f389a1330fc44a3600ecb01d945a207c03b74e5
4
- data.tar.gz: ae9b2d130fad6a9af04ab08572f1f12cef0578e303543dc565b0d8f6c3606bab
3
+ metadata.gz: cedcfe4e4dfa26034cf0ded9746d8ff5bade886ec3d83774580d8334777d4d3c
4
+ data.tar.gz: 96d1702d994f06f0a4f41870de6652258fc700a9f9df1b6193c347b2cce3b365
5
5
  SHA512:
6
- metadata.gz: 8110a8ecae79f591c43b8bd7ea5730886491c8db09d4d3e7ee3950d4d4a19680fa054705ea7c4d53a333c431542b2c25a2c2658dd08630c4532a64c387328dbe
7
- data.tar.gz: 20954f902702102b9b91e761295cb4de86b9b3e153df613f34225cf1b36f4d7a337be7f8dbe521a3f525d9156e6b4e9d806a879f6810b96a4b02bde9582d2ce9
6
+ metadata.gz: 16a5c9937e67afe78bcba468dea8a4b75c73864c4a320b8d2da930a966bd618797b6285f765f772a99394d8925ba9438fe2b9012ccb94730908e97642148cdbb
7
+ data.tar.gz: 95cd9275779731da352b0356e1da275f12511b960037512e829614a6b5e2a4a97e3b00ba0ceeefbdbcc8eff71a3f0717b9cc43fb3ee4d21b2b706361e4dfccc0
data/bin/brainiac CHANGED
@@ -263,7 +263,10 @@ def wait_for_agents_to_finish
263
263
  end
264
264
  end
265
265
 
266
- # rubocop:disable Naming/PredicateMethod
266
+ # stop_server is a command (it stops the server) that happens to return a
267
+ # boolean success flag; renaming it to a predicate (stop_server?) would be
268
+ # semantically wrong, so suppress the predicate-naming cop for this definition.
269
+ # rubocop:disable-next Naming/PredicateMethod
267
270
  def stop_server
268
271
  pid = find_server_pid
269
272
 
@@ -304,7 +307,6 @@ def stop_server
304
307
  false
305
308
  end
306
309
  end
307
- # rubocop:enable Naming/PredicateMethod
308
310
 
309
311
  # Kill a single process: TERM first, KILL if it doesn't exit within 5s.
310
312
  def kill_pid(pid, quiet: false)
@@ -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
@@ -984,7 +1036,11 @@ def append_model_to_cmd(cmd, model, resolved)
984
1036
  # This handles cases where different projects use "auto" but each CLI provider maps it differently.
985
1037
  effective_model = allowed.key?(model) ? allowed[model] : model
986
1038
  is_known = allowed.value?(effective_model) || allowed.key?(effective_model)
987
- cmd.push(resolved["agent_model_flag"], effective_model) if is_known
1039
+ if is_known
1040
+ cmd.push(resolved["agent_model_flag"], effective_model)
1041
+ else
1042
+ LOG.warn "[Model] Dropping model '#{model}': not in #{resolved["agent_cli"]}'s model map; using the CLI's default"
1043
+ end
988
1044
  end
989
1045
 
990
1046
  # A model is only "explicit" if the caller actually supplied one (i.e. it came from a
@@ -1344,8 +1400,9 @@ end
1344
1400
  # while a fell-back-to-default value must NOT write — otherwise every no-tag dispatch
1345
1401
  # would clobber each account's per-account default back to the project default.
1346
1402
  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)
1403
+ # Without an explicit override, a [cli:X] in the same text picks the model map (so
1404
+ # "[cli:claude] [sonnet]" resolves through claude's map), then the agent's provider.
1405
+ effective_cli_provider = cli_provider_override || inline_cli_provider_tag(text) || agent_cli_provider_for(agent_name)
1349
1406
  resolved = resolve_project_cli_config(project_config, cli_provider_override: effective_cli_provider, agent_name: agent_name)
1350
1407
  allowed_models = resolved["allowed_models"] || {}
1351
1408
  return [resolved["agent_model"], false] if allowed_models.empty?
@@ -1368,22 +1425,29 @@ end
1368
1425
  # If the requested level isn't supported by the current model, returns the closest
1369
1426
  # lower level from allowed_efforts.
1370
1427
  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)
1428
+ detect_effort_explicit(project_config, tags: tags, text: text,
1429
+ cli_provider_override: cli_provider_override, agent_name: agent_name).first
1430
+ end
1431
+
1432
+ # Like detect_effort, but returns [level, explicit_bool] where explicit is true only when
1433
+ # the level came from an [effort:X] tag or a plugin's card tag (not the provider default).
1434
+ def detect_effort_explicit(project_config, tags: [], text: "", cli_provider_override: nil, agent_name: nil)
1435
+ effective_cli_provider = cli_provider_override || inline_cli_provider_tag(text) || agent_cli_provider_for(agent_name)
1372
1436
  resolved = resolve_project_cli_config(project_config, cli_provider_override: effective_cli_provider, agent_name: agent_name)
1373
1437
  allowed = resolved["allowed_efforts"] || %w[low medium high xhigh max]
1374
1438
 
1375
1439
  # Inline tag: [effort:high] — works in any channel
1376
1440
  if (match = text.match(/\[effort:(\w+)\]/i))
1377
1441
  level = match[1].downcase
1378
- return resolve_effort_level(level, allowed) if allowed.include?(level)
1442
+ return [resolve_effort_level(level, allowed), true] if allowed.include?(level)
1379
1443
  end
1380
1444
 
1381
1445
  # Plugin hook: let plugins detect from their own metadata (e.g., card tags)
1382
1446
  results = Brainiac.emit(:detect_effort, tags: tags, allowed: allowed)
1383
1447
  plugin_result = results.compact.first
1384
- return resolve_effort_level(plugin_result, allowed) if plugin_result
1448
+ return [resolve_effort_level(plugin_result, allowed), true] if plugin_result
1385
1449
 
1386
- resolved["agent_effort"]
1450
+ [resolved["agent_effort"], false]
1387
1451
  end
1388
1452
 
1389
1453
  # Detect a profile ([profile:X]/[p:X]) from inline text OR card tags.
@@ -40,45 +40,49 @@ end
40
40
  # Load all installed plugin gems and call their register hooks.
41
41
  # Called once during server startup, after core handlers are loaded.
42
42
  def load_plugins!(app)
43
- # rubocop:disable Metrics/BlockLength
44
- installed_plugins.each do |name|
45
- gem_name = "brainiac-#{name}"
46
- entry = plugin_entry(name)
47
- begin
48
- # If plugin has a local path, add its lib/ to load path before requiring
49
- if entry.is_a?(Hash) && entry["path"]
50
- lib_path = File.join(entry["path"], "lib")
51
- $LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path)
52
- LOG.info "[Plugins] Loading #{gem_name} from local path: #{entry["path"]}"
53
- end
54
-
55
- # Try both naming conventions: brainiac-fizzy and brainiac_fizzy
56
- begin
57
- require gem_name
58
- rescue LoadError
59
- require gem_name.tr("-", "_")
60
- end
61
-
62
- plugin_module = resolve_plugin_module(name)
63
- if plugin_module.respond_to?(:register)
64
- plugin_module.register(app)
65
- LOG.info "[Plugins] Loaded #{gem_name}"
66
- else
67
- LOG.warn "[Plugins] #{gem_name} loaded but no register method found"
68
- end
69
- rescue LoadError => e
70
- LOG.error "[Plugins] Could not load #{gem_name}: #{e.message}"
71
- if entry.is_a?(Hash) && entry["path"]
72
- LOG.error "[Plugins] Local path: #{entry["path"]}"
73
- else
74
- LOG.error "[Plugins] Is the gem installed? Run: gem install #{gem_name}"
75
- end
76
- rescue StandardError => e
77
- LOG.error "[Plugins] Error registering #{gem_name}: #{e.message}"
78
- LOG.error "[Plugins] #{e.backtrace.first(3).join("\n ")}"
79
- end
43
+ installed_plugins.each { |name| load_plugin!(app, name) }
44
+ end
45
+
46
+ # Require a single plugin gem and call its register hook.
47
+ # Isolated per-plugin so one failing plugin doesn't abort the rest.
48
+ def load_plugin!(app, name)
49
+ gem_name = "brainiac-#{name}"
50
+ entry = plugin_entry(name)
51
+
52
+ require_plugin_gem(gem_name, entry)
53
+
54
+ plugin_module = resolve_plugin_module(name)
55
+ if plugin_module.respond_to?(:register)
56
+ plugin_module.register(app)
57
+ LOG.info "[Plugins] Loaded #{gem_name}"
58
+ else
59
+ LOG.warn "[Plugins] #{gem_name} loaded but no register method found"
80
60
  end
81
- # rubocop:enable Metrics/BlockLength
61
+ rescue LoadError => e
62
+ LOG.error "[Plugins] Could not load #{gem_name}: #{e.message}"
63
+ if entry.is_a?(Hash) && entry["path"]
64
+ LOG.error "[Plugins] Local path: #{entry["path"]}"
65
+ else
66
+ LOG.error "[Plugins] Is the gem installed? Run: gem install #{gem_name}"
67
+ end
68
+ rescue StandardError => e
69
+ LOG.error "[Plugins] Error registering #{gem_name}: #{e.message}"
70
+ LOG.error "[Plugins] #{e.backtrace.first(3).join("\n ")}"
71
+ end
72
+
73
+ # Require a plugin gem, honoring a local path override and both the
74
+ # hyphen (brainiac-fizzy) and underscore (brainiac_fizzy) naming conventions.
75
+ def require_plugin_gem(gem_name, entry)
76
+ # If plugin has a local path, add its lib/ to load path before requiring
77
+ if entry.is_a?(Hash) && entry["path"]
78
+ lib_path = File.join(entry["path"], "lib")
79
+ $LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path)
80
+ LOG.info "[Plugins] Loading #{gem_name} from local path: #{entry["path"]}"
81
+ end
82
+
83
+ require gem_name
84
+ rescue LoadError
85
+ require gem_name.tr("-", "_")
82
86
  end
83
87
 
84
88
  # Resolve the plugin module for a given name.
@@ -18,6 +18,13 @@ PROMPT_CORE = <<~PROMPT
18
18
  Getting the casing wrong means the mention won't link or notify properly.
19
19
  {{AGENT_ROSTER}}
20
20
 
21
+ ## Conversation Focus (CRITICAL)
22
+ Your PRIMARY task is ALWAYS the most recent message/comment you've received.
23
+ - Context and history are reference material — they inform your response but don't define your task
24
+ - If the user asks a question, answer THAT question — don't summarize previous work
25
+ - If the user requests a change, make THAT change — don't rehash what you've already done
26
+ - Previous sessions and memory help you understand context, but the current request is what you're here to do
27
+
21
28
  ## Memory (CRITICAL — read this first)
22
29
  You have no persistent memory between sessions. Every time you are invoked, you start completely fresh.
23
30
  Memory files MAY exist at `{{MEMORY_DIR}}/` — this is inside the brain, so they survive worktree deletion.
@@ -174,8 +181,8 @@ CHANNEL_PROMPTS = {}.freeze
174
181
  # channel: :discord, :github, or plugin-registered channels (e.g., :fizzy, :linear)
175
182
  # ---------------------------------------------------------------------------
176
183
 
177
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
178
- def render_prompt(template, vars = {}, brain_context: "", card_context: "", agent_name: AI_AGENT_NAME, channel: nil, board_key: nil)
184
+ def render_prompt(template, vars = {}, brain_context: "", card_context: "", comment_history: "", agent_name: AI_AGENT_NAME, channel: nil,
185
+ board_key: nil)
179
186
  result = ""
180
187
  result += "#{brain_context}\n" unless brain_context.empty?
181
188
  result += card_context unless card_context.empty?
@@ -187,6 +194,10 @@ def render_prompt(template, vars = {}, brain_context: "", card_context: "", agen
187
194
 
188
195
  result += template
189
196
 
197
+ # Comment history (for follow-up scenarios): placed AFTER the triggering comment
198
+ # so the agent sees the current request first, then can reference history as needed
199
+ result += comment_history unless comment_history.empty?
200
+
190
201
  # Pre-post comment check: plugin-registered
191
202
  plugin_pre_post = Brainiac.channel_pre_post_checks[channel]
192
203
  result += plugin_pre_post if plugin_pre_post
@@ -194,6 +205,15 @@ def render_prompt(template, vars = {}, brain_context: "", card_context: "", agen
194
205
  # Reflection prompt — skip for now
195
206
  # result += PROMPT_REFLECTION
196
207
 
208
+ populate_prompt_vars(vars, agent_name: agent_name, board_key: board_key)
209
+
210
+ vars.each { |key, val| result.gsub!("{{#{key}}}", val.to_s) }
211
+ result
212
+ end
213
+
214
+ # Populate the default template variables (dirs, column IDs, roster) and touch
215
+ # the agent's memory file. Extracted from render_prompt to keep complexity down.
216
+ def populate_prompt_vars(vars, agent_name:, board_key:)
197
217
  vars["KNOWLEDGE_DIR"] ||= KNOWLEDGE_DIR
198
218
  vars["MEMORY_DIR"] ||= memory_dir_for(agent_name)
199
219
  vars["PERSONA_DIR"] ||= persona_dir_for(agent_name)
@@ -215,14 +235,9 @@ def render_prompt(template, vars = {}, brain_context: "", card_context: "", agen
215
235
  FileUtils.touch(memory_file)
216
236
  end
217
237
 
218
- roster = agent_roster
219
- roster_lines = roster.map { |_key, display| " - @#{display}" }.join("\n")
238
+ roster_lines = agent_roster.map { |_key, display| " - @#{display}" }.join("\n")
220
239
  vars["AGENT_ROSTER"] ||= roster_lines
221
-
222
- vars.each { |key, val| result.gsub!("{{#{key}}}", val.to_s) }
223
- result
224
240
  end
225
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
226
241
 
227
242
  # Lean prompt for resumed sessions. The previous session already has the full context
228
243
  # (role, persona, knowledge, core instructions, channel prompts). We only send the new
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Brainiac
4
4
  # @return [String] the current gem version
5
- VERSION = "0.1.7"
5
+ VERSION = "0.1.9"
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.7
4
+ version: 0.1.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis