brainiac 0.1.9 → 0.2.0

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: cedcfe4e4dfa26034cf0ded9746d8ff5bade886ec3d83774580d8334777d4d3c
4
- data.tar.gz: 96d1702d994f06f0a4f41870de6652258fc700a9f9df1b6193c347b2cce3b365
3
+ metadata.gz: 3d90a30fad5756179777b90413b87bbd41550783c75c2e9a38f726e1a2e1ef5c
4
+ data.tar.gz: 32607dc0510acec6449bdb16e8a4acbec358caa94a0d8592d7a6d06e4f73e9a2
5
5
  SHA512:
6
- metadata.gz: 16a5c9937e67afe78bcba468dea8a4b75c73864c4a320b8d2da930a966bd618797b6285f765f772a99394d8925ba9438fe2b9012ccb94730908e97642148cdbb
7
- data.tar.gz: 95cd9275779731da352b0356e1da275f12511b960037512e829614a6b5e2a4a97e3b00ba0ceeefbdbcc8eff71a3f0717b9cc43fb3ee4d21b2b706361e4dfccc0
6
+ metadata.gz: 1a16401ff3896e546d5411785418228ef5ef2a360dc10fd3633a798723969d90a7d5668d8f8e908856a08bdac35e939519f94bcb79ad6f2f259d0f55dac9c525
7
+ data.tar.gz: 29947e9e2dacd7007a630a06be63e7e8dbc913f9e677b28ccf92a5dcf4c3659c189291c7d07971b42e06c6c0d08880d266854aa759721d701308a415dfdbb79f
data/README.md CHANGED
@@ -202,7 +202,7 @@ After setup, `~/.brainiac/` looks like:
202
202
 
203
203
  ```
204
204
  ~/.brainiac/
205
- ├── brainiac.json # Core config (default_agent)
205
+ ├── brainiac.json # Core config (default_agent, default_cli_provider)
206
206
  ├── agents.json # Agent registry (tokens, display names, local flag)
207
207
  ├── projects.json # Registered projects
208
208
  ├── users.json # Cross-platform user identity registry
@@ -262,6 +262,31 @@ The `agent_flag` field controls how agent identity is passed to the CLI. Differe
262
262
 
263
263
  Set `agent_flag` to `null` to suppress passing the agent name entirely (for CLIs that don't support persona switching).
264
264
 
265
+ `readonly_args` is optional. It replaces `default_args` when a plugin asks an agent something with `readonly: true` (see [Asking an agent from a plugin](#asking-an-agent-from-a-plugin)), so it must only allow tools that read:
266
+
267
+ | CLI | `readonly_args` |
268
+ |-----|-----------------|
269
+ | Kiro CLI | `chat --no-interactive --trust-tools=fs_read` |
270
+ | Claude Code | `-p --restricted --strict-mcp-config --no-session-persistence --tools Read,Grep,Glob` |
271
+
272
+ A provider without `readonly_args` can't be used read-only; the call fails instead of falling back to `default_args`.
273
+
274
+ Which provider runs is decided in this order: a `[cli:X]` tag, the agent's `cli_provider` in `agents.json`, the project's `cli_provider`, then `default_cli_provider` in `brainiac.json`. With none of those set, brainiac uses the `AGENT_CLI` env var, or kiro-cli.
275
+
276
+ #### Asking an agent from a plugin
277
+
278
+ `run_agent` starts a session and returns straight away. When a plugin needs the agent's answer back (for example to decide whether an incoming report is a bug), it calls `ask_agent`, which waits and returns the reply text:
279
+
280
+ ```ruby
281
+ reply = ask_agent(prompt, project_config: PROJECTS["my-app"], project_key: "my-app",
282
+ readonly: true, topic: "bug triage", timeout: 300)
283
+ ```
284
+
285
+ - The agent and CLI resolve the same way as `run_agent`: `agent_name:`, else the project's `agent_name`, else the default agent.
286
+ - `brain: true` (the default) puts the agent's role, persona and brain knowledge ahead of the prompt. `topic:` seeds the knowledge search.
287
+ - `readonly: true` runs the provider's `readonly_args`. Use it whenever the prompt contains text from outside (webhooks, emails, public forms).
288
+ - It raises `AskAgentError` on a timeout, a CLI failure, or a read-only request to a provider without `readonly_args`.
289
+
265
290
  Brainiac ships with example providers during setup. Your project config references which provider to use.
266
291
 
267
292
  #### 3. Environment Variables
@@ -269,10 +294,12 @@ Brainiac ships with example providers during setup. Your project config referenc
269
294
  Most config lives in JSON files now. The default agent is set in `brainiac.json`:
270
295
 
271
296
  ```json
272
- { "default_agent": "Sherlock" }
297
+ { "default_agent": "Sherlock", "default_cli_provider": "kiro" }
273
298
  ```
274
299
 
275
- Or via environment variable (takes precedence):
300
+ `default_cli_provider` is the CLI used when neither the agent nor the project names one (see [CLI Providers](#2-cli-providers-brainiaccli-providers)).
301
+
302
+ The default agent can also be set via environment variable (takes precedence):
276
303
 
277
304
  ```bash
278
305
  export AI_AGENT_NAME="Sherlock"
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ask_agent: a synchronous, one-off agent call for plugins that need the answer back
4
+ # (e.g. deciding whether an incoming report is a bug). run_agent spawns a session and
5
+ # returns before there's any output, so it can't do this.
6
+
7
+ class AskAgentError < StandardError; end
8
+
9
+ ANSI_ESCAPE = %r{\e\[[0-9;?]*[ -/]*[@-~]}
10
+
11
+ # Ask an agent a question and return its reply as text.
12
+ #
13
+ # The agent and CLI resolve the same way run_agent's do: agent_name, else the project's
14
+ # agent_name, else the default agent; cli_provider, else the agent's, the project's, then
15
+ # brainiac.json's default_cli_provider.
16
+ #
17
+ # brain: true puts the agent's role, persona and brain knowledge ahead of the prompt, so the
18
+ # whole agent answers rather than a bare model. topic seeds the knowledge search.
19
+ # readonly: true runs the provider's readonly_args instead of default_args. Raises
20
+ # AskAgentError when the provider has none: text from strangers must never reach an agent
21
+ # that can write files or run commands, so there's no falling back to default_args.
22
+ #
23
+ # Raises AskAgentError when the CLI times out or exits non-zero without a reply.
24
+ def ask_agent(prompt, project_config:, agent_name: nil, chdir: nil, readonly: false, brain: true, topic: "",
25
+ project_key: nil, model: nil, cli_provider: nil, timeout: 300, log_name: "ask")
26
+ agent_name ||= project_config["agent_name"] || AI_AGENT_NAME
27
+ resolved = ask_agent_cli_config(project_config, cli_provider: cli_provider, agent_name: agent_name, readonly: readonly)
28
+ chdir ||= resolved["repo_path"]
29
+ raise AskAgentError, "working directory #{chdir.inspect} doesn't exist" unless chdir && Dir.exist?(chdir)
30
+
31
+ if brain
32
+ context = build_brain_context(agent_name: agent_name, card_title: topic, project_key: project_key)
33
+ prompt = "#{context}\n## Task\n\n#{prompt}" unless context.empty?
34
+ end
35
+
36
+ timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
37
+ prompt_file = write_agent_prompt_file(prompt, log_name, timestamp)
38
+ output_file = prepare_output_file(resolved, log_name, timestamp)
39
+ cmd = build_agent_cmd(resolved, agent_config_name: agent_name.downcase.gsub(/[^a-z0-9-]/, "-"),
40
+ model: model || resolved["agent_model"], prompt_file: prompt_file,
41
+ output_file: output_file, chdir: chdir)
42
+ LOG.info "[Ask] #{agent_name} via #{resolved["agent_cli"]}#{" (read-only)" if readonly} in #{chdir}: #{cmd.join(" ")}"
43
+
44
+ stdout = run_ask_command(cmd, prompt_file, resolved, chdir: chdir, env: profile_spawn_env(agent_env_for(agent_name), nil),
45
+ timeout: timeout)
46
+ (read_output_file(output_file) || stdout).gsub(ANSI_ESCAPE, "").strip
47
+ ensure
48
+ FileUtils.rm_f(prompt_file) if prompt_file
49
+ end
50
+
51
+ # The resolved CLI config, with readonly_args swapped in for default_args when asked.
52
+ def ask_agent_cli_config(project_config, cli_provider:, agent_name:, readonly:)
53
+ resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider, agent_name: agent_name)
54
+ return resolved unless readonly
55
+
56
+ args = resolved["readonly_args"]
57
+ if args.to_s.strip.empty?
58
+ raise AskAgentError, "#{resolved["agent_cli"]} has no readonly_args in its cli-providers config, " \
59
+ "so it can't be run read-only"
60
+ end
61
+
62
+ resolved.merge("agent_cli_args" => args)
63
+ end
64
+
65
+ # Run the CLI and return its stdout. Kills the whole process group on timeout so the
66
+ # CLI's own children (tool calls, MCP servers) go with it.
67
+ def run_ask_command(cmd, prompt_file, resolved, chdir:, env:, timeout:)
68
+ stdin_data = resolved["prompt_mode"] == "flag" ? "" : File.read(prompt_file)
69
+ Open3.popen3(env, *cmd, chdir: chdir, pgroup: true) do |stdin, stdout, stderr, wait|
70
+ readers = [Thread.new { stdout.read }, Thread.new { stderr.read }]
71
+ begin
72
+ stdin.write(stdin_data)
73
+ rescue Errno::EPIPE
74
+ nil # CLI exited before reading the prompt; its exit status says why.
75
+ end
76
+ stdin.close
77
+
78
+ unless wait.join(timeout)
79
+ begin
80
+ Process.kill("TERM", -wait.pid)
81
+ rescue Errno::ESRCH
82
+ nil # Finished just as the clock ran out.
83
+ end
84
+ readers.each(&:join) # The kill closes the pipes, so these return.
85
+ raise AskAgentError, "#{resolved["agent_cli"]} timed out after #{timeout}s"
86
+ end
87
+
88
+ out, err = readers.map(&:value)
89
+ if !wait.value.success? && out.strip.empty?
90
+ raise AskAgentError, "#{resolved["agent_cli"]} exited #{wait.value.exitstatus}: #{err.strip.lines.last(5).join}"
91
+ end
92
+
93
+ out
94
+ end
95
+ end
@@ -80,6 +80,13 @@ end
80
80
 
81
81
  AI_AGENT_NAME = resolve_default_agent
82
82
 
83
+ # --- Default CLI provider ---
84
+ # Last fallback for which CLI runs an agent, after the agent's and the project's
85
+ # cli_provider. Unset → DEFAULT_PROJECT's AGENT_CLI env var / kiro-cli.
86
+ def default_cli_provider
87
+ BRAINIAC_CONFIG["default_cli_provider"]
88
+ end
89
+
83
90
  LOG_LEVEL = ENV.fetch("LOG_LEVEL", "info").downcase
84
91
  LOG = Logger.new($stdout)
85
92
  LOG.level = case LOG_LEVEL
@@ -32,7 +32,7 @@ def load_cli_provider(provider_name)
32
32
  config["prompt_mode"] = raw["prompt_mode"] || "stdin"
33
33
  # Copy optional fields from raw config when present.
34
34
  # Each field controls a specific CLI behavior — see comments in the template.
35
- %w[prompt_flag list_models_command resume_flag resume_args resume_id_flag new_session_id_flag
35
+ %w[prompt_flag readonly_args 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
38
  effort_config_key effort_map title_flag heaviness_probe settings_model_cmd].each do |key|
@@ -76,12 +76,14 @@ end
76
76
  require_relative "model_parser"
77
77
 
78
78
  # Resolve CLI config for a project by merging provider defaults with project overrides.
79
- # Priority: cli_provider_override > agent-level cli_provider > project-level cli_provider > DEFAULT_PROJECT
79
+ # Priority: cli_provider_override > agent-level cli_provider > project-level cli_provider >
80
+ # brainiac.json default_cli_provider > DEFAULT_PROJECT
80
81
  def resolve_project_cli_config(project_config, cli_provider_override: nil, agent_name: nil)
81
- # Determine which CLI provider to use (priority: override > agent > project)
82
+ # Determine which CLI provider to use (priority: override > agent > project > default)
82
83
  provider_name = cli_provider_override
83
84
  provider_name ||= agent_cli_provider_for(agent_name) if agent_name
84
85
  provider_name ||= project_config["cli_provider"]
86
+ provider_name ||= default_cli_provider
85
87
 
86
88
  provider_config = load_cli_provider(provider_name)
87
89
 
@@ -704,9 +706,11 @@ def prior_session_exists?(chdir, agent_cli, session_dir: nil)
704
706
  return true if File.directory?(session_dir_local)
705
707
 
706
708
  # Fallback: look for recent session logs in tmp/ that suggest this CLI ran here before.
707
- # This covers CLIs that don't leave a dotdir but do leave logs via brainiac.
709
+ # This covers CLIs that don't leave a dotdir but do leave logs via brainiac. The logs
710
+ # don't say which CLI wrote them, so only trust them when the last-CLI marker says it was this one.
708
711
  tmp_dir = File.join(chdir, "tmp")
709
712
  return false unless File.directory?(tmp_dir)
713
+ return false unless last_cli_for(chdir) == cli_name
710
714
 
711
715
  Dir.glob(File.join(tmp_dir, "agent-*.log")).any? do |log|
712
716
  # Only count logs from the last 24 hours as valid "resumable" sessions
@@ -716,6 +720,40 @@ rescue StandardError
716
720
  false
717
721
  end
718
722
 
723
+ # Marker recording which CLI binary last ran in a directory (written by run_agent).
724
+ def last_cli_marker_path(chdir)
725
+ File.join(chdir, "tmp", ".brainiac-last-cli")
726
+ end
727
+
728
+ def last_cli_for(chdir)
729
+ return nil unless chdir
730
+
731
+ name = File.read(last_cli_marker_path(chdir)).strip
732
+ name.empty? ? nil : name
733
+ rescue StandardError
734
+ nil
735
+ end
736
+
737
+ def record_last_cli(chdir, agent_cli)
738
+ key = cli_session_key(agent_cli)
739
+ return unless chdir && key
740
+
741
+ FileUtils.mkdir_p(File.join(chdir, "tmp"))
742
+ File.write(last_cli_marker_path(chdir), key)
743
+ rescue StandardError => e
744
+ LOG.warn "[Dispatch] Could not record last CLI for #{chdir}: #{e.message}"
745
+ end
746
+
747
+ # True when a different CLI ran in this directory more recently than agent_cli.
748
+ # A switched-to CLI never saw the other provider's turns, so resuming its own (older)
749
+ # session would hand it a lean prompt with a stale or empty history. Start fresh instead
750
+ # and let the full prompt (memory file + source context) carry the conversation.
751
+ def cli_switched_since_last_run?(chdir, agent_cli)
752
+ last = last_cli_for(chdir)
753
+ key = cli_session_key(agent_cli)
754
+ !last.nil? && !key.nil? && last != key
755
+ end
756
+
719
757
  # Check if a centralized session directory has sessions matching the given cwd.
720
758
  # Supports CLIs that store sessions as .jsonl files in date-partitioned directories (YYYY/MM/DD/)
721
759
  # with a first-line JSON metadata object containing a "payload.cwd" field.
@@ -777,6 +815,7 @@ end
777
815
  def resume_viable?(project_config:, cli_provider: nil, agent_name: nil, chdir: nil)
778
816
  resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider, agent_name: agent_name)
779
817
  chdir ||= resolved["repo_path"]
818
+ return false if cli_switched_since_last_run?(chdir, resolved["agent_cli"])
780
819
  return stored_cli_session_viable?(resolved, chdir) if resolved["resume_id_flag"]
781
820
  return false unless resolved["resume_flag"] || resolved["resume_args"]
782
821
 
@@ -805,6 +844,11 @@ end
805
844
  def resolve_resume(resume, resolved, chdir, session_id: nil)
806
845
  return false unless resume
807
846
 
847
+ if cli_switched_since_last_run?(chdir, resolved["agent_cli"])
848
+ LOG.info "[Dispatch] CLI switched from #{last_cli_for(chdir)} to #{resolved["agent_cli"]} in #{chdir} — starting fresh session"
849
+ return false
850
+ end
851
+
808
852
  if resolved["resume_id_flag"]
809
853
  id = session_id.to_s.empty? ? cli_session_id_for(agent_cli: resolved["agent_cli"], worktree: chdir) : session_id
810
854
  if id.to_s.empty?
@@ -824,16 +868,27 @@ def resolve_resume(resume, resolved, chdir, session_id: nil)
824
868
  false
825
869
  end
826
870
 
827
- # Some CLIs (e.g. grok --session-id) let the caller name a new session up front.
828
- # Mint a UUID only on the first run, when we are not resuming an existing id.
829
- def mint_cli_session_id(resolved, stored_session:, resuming:)
871
+ # Some CLIs (e.g. claude/grok --session-id) let the caller name a new session up front.
872
+ # Mint a UUID whenever we start fresh — including after a provider switch, when an older
873
+ # stored id exists — so the stored id always points at the latest session for this CLI.
874
+ def mint_cli_session_id(resolved, resuming:)
830
875
  return nil if resuming
831
876
  return nil unless resolved["new_session_id_flag"]
832
- return nil unless stored_session.to_s.empty?
833
877
 
834
878
  SecureRandom.uuid
835
879
  end
836
880
 
881
+ # Decide resume vs. fresh start for this dispatch, then record this CLI as the last
882
+ # one to run here. Returns [should_resume, minted_session_id].
883
+ def prepare_cli_session(resume, resolved, chdir)
884
+ stored_session = cli_session_id_for(agent_cli: resolved["agent_cli"], worktree: chdir)
885
+ # Auto-resume: only if the provider supports it AND a prior session exists for this CLI here.
886
+ should_resume = resolve_resume(resume, resolved, chdir, session_id: stored_session)
887
+ minted_session_id = mint_cli_session_id(resolved, resuming: should_resume)
888
+ record_last_cli(chdir, resolved["agent_cli"])
889
+ [should_resume, minted_session_id]
890
+ end
891
+
837
892
  # Check if intent detection says to skip dispatching the agent.
838
893
  # Returns true if the message should be skipped, false otherwise.
839
894
  # Only runs if a raw message is provided, an agent is named, and intent is enabled.
@@ -870,12 +925,8 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
870
925
  effort ||= resolved["agent_effort"]
871
926
  agent_config_name = agent_name&.downcase&.gsub(/[^a-z0-9-]/, "-")
872
927
 
873
- stored_session = cli_session_id_for(agent_cli: resolved["agent_cli"], worktree: chdir)
874
928
  work_item_id = find_work_item_by_worktree(chdir)&.first
875
-
876
- # Auto-resume: only if the provider supports it AND a prior session exists for this CLI here.
877
- should_resume = resolve_resume(resume, resolved, chdir, session_id: stored_session)
878
- minted_session_id = mint_cli_session_id(resolved, stored_session: stored_session, resuming: should_resume)
929
+ should_resume, minted_session_id = prepare_cli_session(resume, resolved, chdir)
879
930
 
880
931
  # Pre-dispatch hook — plugins can prep the working directory (e.g., copy config files, clean up)
881
932
  Brainiac.emit(:pre_dispatch, chdir: chdir, project_config: project_config, agent_name: agent_name)
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Brainiac
4
4
  # @return [String] the current gem version
5
- VERSION = "0.1.9"
5
+ VERSION = "0.2.0"
6
6
  end
data/lib/brainiac.rb CHANGED
@@ -9,6 +9,7 @@ require_relative "brainiac/sessions"
9
9
  require_relative "brainiac/session_history"
10
10
  require_relative "brainiac/prompts"
11
11
  require_relative "brainiac/helpers"
12
+ require_relative "brainiac/ask"
12
13
  require_relative "brainiac/intent"
13
14
  require_relative "brainiac/cron"
14
15
  require_relative "brainiac/plugins"
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "default_agent": "Sherlock",
3
+ "default_cli_provider": "kiro",
3
4
  "intent": {
4
5
  "enabled": false,
5
6
  "endpoint": "http://localhost:11434/api/generate",
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "binary": "kiro-cli",
3
3
  "default_args": "chat --trust-all-tools --no-interactive",
4
+ "readonly_args": "chat --no-interactive --trust-tools=fs_read",
4
5
  "agent_flag": "--agent",
5
6
  "model_flag": "--model",
6
7
  "effort_flag": "--effort",
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.9
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis
@@ -126,6 +126,7 @@ files:
126
126
  - docs/waybar-config.md
127
127
  - lib/brainiac.rb
128
128
  - lib/brainiac/agents.rb
129
+ - lib/brainiac/ask.rb
129
130
  - lib/brainiac/brain.rb
130
131
  - lib/brainiac/config.rb
131
132
  - lib/brainiac/cron.rb