brainiac 0.0.12 → 0.0.14

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: 7890616cf0466e9c991c0e1209ff482eae548c205d151746c3f3065d2c239033
4
- data.tar.gz: 76f04a3697a1070932a9754281828fb6d49869c3efe1b0efc9a89c98f3ee9bba
3
+ metadata.gz: '04080c41c24921ed739b0d28ad15ce61b8eb5b5c12935d0cb0b1d72c6b0deeab'
4
+ data.tar.gz: bbb1dceb84ecaba924ada9189e8b63320828c035da4a6ff466ba8b47c4c30708
5
5
  SHA512:
6
- metadata.gz: 975a3cff959457bd2bf6f622010d3f016643f36aea981874e9424fb278666eda22441fd9f0e5938806849eaa4165f62c2f1c5fd1be10ad32b501afcecfacfb1a
7
- data.tar.gz: 2c94aeefd3190ad1c8f074665486988d1b0440b4cb104a5dae39d670bb5559db0d5abeb64a4d4da132c6ff8e2d385e004ee26d11568a6eaf7313a822f854065d
6
+ metadata.gz: '0699a2277c78aa2c33e57b6ee4394ac7be8d27bc483a9f3866dd27e644e40fbdea4b85168b3452c6cbbd4fce42da6e282a9609e93541802b2c751f9845c55ea2'
7
+ data.tar.gz: adcd7eab6269a33b278499e3b873b072f488f7b06b18378507fba456f883b1c525b7dd8d8ce82b13e6464ab5184007e0887dfa9a82a69910f8f8d0164660ff9b
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- brainiac (0.0.12)
4
+ brainiac (0.0.14)
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.0.12)
87
+ brainiac (0.0.14)
88
88
  json (2.19.9) sha256=9b9025b7cdddafa38d316eca0b2358488e42d417045c1b90d216a9fefe46b79a
89
89
  language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
90
90
  lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
data/README.md CHANGED
@@ -43,7 +43,9 @@ All four channels support inline tags in message/comment text. Tags are stripped
43
43
  | ----------------------------------------------------------------------------- | ----- | ------- | ------ | ----------------------------------------------------------------------------------------------------- |
44
44
  | `[project:XYZ]` | — | ✓ | — | Override which project the agent works in (Discord only — Fizzy uses card tags, GitHub uses the repo) |
45
45
  | `[opus]` `[sonnet]` `[haiku]` `[deepseek]` `[minimax]` `[minimax21]` `[qwen]` | ✓ | ✓ | ✓ | Override the model for this dispatch |
46
- | `[worktree:branch-name]` | ✓ | | — | Direct the agent to a specific worktree instead of the card's default |
46
+ | `[worktree:branch-name]` | ✓ | | — | Direct the agent to a specific worktree/branch (legacy syntax) |
47
+ | `[branch:branch-name]` | ✓ | ✓ | ✓ | Target an existing branch — reuses its worktree if a work item exists |
48
+ | `[workitem:wi-abc123]` | ✓ | ✓ | ✓ | Target a specific work item by ID — agent works in its worktree |
47
49
  | `[plan]` | ✓ | ✓ | — | Activate planning mode — agent gathers requirements before coding |
48
50
 
49
51
  Model keys come from the project's `allowed_models` config. Fizzy also supports model selection via card tags (e.g. adding an `opus` tag to the card).
@@ -868,6 +870,50 @@ curl http://localhost:4567/api/users/fladamd # Find by identifier
868
870
 
869
871
  The registry reloads automatically on every webhook and via `POST /api/reload`.
870
872
 
873
+ ## Work Items
874
+
875
+ Work items are the universal tracking unit in Brainiac — they connect a branch/worktree to one or more communication channels. A single work item can span Discord, Fizzy, and GitHub simultaneously.
876
+
877
+ ### Structure
878
+
879
+ Work items are stored in `~/.brainiac/work_items.json`:
880
+
881
+ ```json
882
+ {
883
+ "wi-a1b2c3d4": {
884
+ "id": "wi-a1b2c3d4",
885
+ "branch": "fizzy-42-fix-login",
886
+ "worktree": "/home/you/Code/marketplace--fizzy-42-fix-login",
887
+ "project": "marketplace",
888
+ "agent": "Sherlock",
889
+ "sources": {
890
+ "fizzy": { "card_internal_id": "uuid-here", "card_number": 42 },
891
+ "github": { "prs": [{"number": 8, "url": "https://github.com/org/repo/pull/8"}] },
892
+ "discord": { "thread_id": "1234567890" }
893
+ }
894
+ }
895
+ }
896
+ ```
897
+
898
+ The **branch** is the universal join key. When any channel needs to find a work item, it looks up by branch name. This means:
899
+
900
+ - A Fizzy card assignment creates a work item with `sources.fizzy`
901
+ - When a PR is opened for that branch, `sources.github` is attached automatically
902
+ - A Discord `[branch:fizzy-42-fix-login]` message targets the same worktree
903
+
904
+ ### Targeting Work Items
905
+
906
+ From Discord or any channel, use inline tags to target existing work items:
907
+
908
+ - **`[branch:branch-name]`** — find the work item for this branch, work in its worktree
909
+ - **`[workitem:wi-abc123]`** — target a work item by its ID directly
910
+
911
+ This enables cross-channel workflows without creating duplicate worktrees.
912
+
913
+ ### Migration
914
+
915
+ The old format (keyed by Fizzy card internal ID) is automatically migrated on first read. No manual intervention needed — existing `work_items.json` files are transparently upgraded to the new source-agnostic format.
916
+
871
917
  ## Worktree Management
872
918
 
873
919
  When a card is assigned, Brainiac creates a git worktree for the agent to work in. Two config files in the project root control how gitignored files are handled:
data/bin/brainiac CHANGED
@@ -655,7 +655,8 @@ def list_projects
655
655
  default_marker = config["default"] ? " (default)" : ""
656
656
  puts " #{key}#{default_marker}"
657
657
  puts " Path: #{config["repo_path"]}"
658
- puts " Tags: #{config["tags"] || config["project_tags"].join(", ")}"
658
+ tags = config["tags"] || config["project_tags"]&.join(", ")
659
+ puts " Tags: #{tags}" if tags
659
660
  puts " GitHub: #{config["github_repo"]}" if config["github_repo"]
660
661
  puts " Agent: #{config["agent_cli"]} (model: #{config["agent_model"]})"
661
662
  puts
@@ -2295,54 +2296,44 @@ when "update", "upgrade"
2295
2296
  end
2296
2297
 
2297
2298
  puts "Updating #{gem_name}..."
2298
- system("gem install #{gem_name}")
2299
- unless $CHILD_STATUS.success?
2299
+ output, status = Open3.capture2e("gem install #{gem_name}")
2300
+ puts output
2301
+ unless status.success?
2300
2302
  puts ""
2301
2303
  puts "Failed to update #{gem_name}."
2302
2304
  exit 1
2303
2305
  end
2304
2306
 
2305
- # Show version change
2306
- Gem::Specification.reset
2307
- new_version = begin
2308
- spec = Gem::Specification.find_by_name(gem_name)
2309
- spec.version.to_s
2310
- rescue Gem::MissingSpecError
2311
- "unknown"
2312
- end
2307
+ # Parse installed version from gem install output
2308
+ new_version = output[/Successfully installed #{Regexp.escape(gem_name)}-(\S+)/, 1]
2313
2309
 
2314
- if current_version && current_version == new_version
2315
- puts "✓ #{gem_name} is already at the latest version (#{new_version})"
2316
- else
2310
+ if new_version
2317
2311
  puts "✓ Updated #{gem_name}: #{current_version || "new"} → #{new_version}"
2318
2312
  puts " Restart the server to activate: brainiac restart"
2313
+ else
2314
+ puts "✓ #{gem_name} is already at the latest version (#{current_version})"
2319
2315
  end
2320
2316
  else
2321
2317
  # Update brainiac itself: brainiac update
2322
2318
  current_version = BRAINIAC_VERSION
2323
2319
 
2324
2320
  puts "Updating brainiac gem..."
2325
- system("gem install brainiac")
2326
- unless $CHILD_STATUS.success?
2321
+ output, status = Open3.capture2e("gem install brainiac")
2322
+ puts output
2323
+ unless status.success?
2327
2324
  puts ""
2328
2325
  puts "Failed to update brainiac."
2329
2326
  exit 1
2330
2327
  end
2331
2328
 
2332
- # Show version change
2333
- Gem::Specification.reset
2334
- new_version = begin
2335
- spec = Gem::Specification.find_by_name("brainiac")
2336
- spec.version.to_s
2337
- rescue Gem::MissingSpecError
2338
- "unknown"
2339
- end
2329
+ # Parse installed version from gem install output
2330
+ new_version = output[/Successfully installed brainiac-(\S+)/, 1]
2340
2331
 
2341
- if current_version == new_version
2342
- puts "✓ brainiac is already at the latest version (#{new_version})"
2343
- else
2332
+ if new_version
2344
2333
  puts "✓ Updated brainiac: #{current_version} → #{new_version}"
2345
2334
  puts " Restart the server to apply: brainiac restart"
2335
+ else
2336
+ puts "✓ brainiac is already at the latest version (#{current_version})"
2346
2337
  end
2347
2338
  end
2348
2339
 
@@ -5,6 +5,7 @@ require "openssl"
5
5
  require "open3"
6
6
  require "fileutils"
7
7
  require "logger"
8
+ require "securerandom"
8
9
  require "net/http"
9
10
  require "uri"
10
11
 
@@ -37,15 +38,48 @@ end
37
38
  BRAINIAC_CONFIG = load_brainiac_config
38
39
 
39
40
  # --- Default agent name ---
40
- # Priority: AI_AGENT_NAME env var → brainiac.json "default_agent" → error.
41
- AI_AGENT_NAME = ENV.fetch("AI_AGENT_NAME", nil) || BRAINIAC_CONFIG["default_agent"] || begin
41
+ # Priority: AI_AGENT_NAME env var → brainiac.json "default_agent" → first agent in agents.json → error.
42
+ def resolve_default_agent
43
+ # 1. Env var
44
+ name = ENV.fetch("AI_AGENT_NAME", nil)
45
+ return name if name
46
+
47
+ # 2. brainiac.json
48
+ name = BRAINIAC_CONFIG["default_agent"]
49
+ return name if name
50
+
51
+ # 3. First agent in agents.json
52
+ if File.exist?(AGENT_REGISTRY_FILE)
53
+ agents = begin
54
+ JSON.parse(File.read(AGENT_REGISTRY_FILE))
55
+ rescue StandardError
56
+ {}
57
+ end
58
+ first_agent = agents.values.first
59
+ if first_agent
60
+ agent_name = first_agent["fizzy_name"] || first_agent["display_name"] || agents.keys.first.capitalize
61
+ warn <<~MSG
62
+ [Brainiac] No default agent configured — using "#{agent_name}" (first agent in agents.json).
63
+ To set explicitly, either:
64
+ export AI_AGENT_NAME="#{agent_name}"
65
+ Or add to ~/.brainiac/brainiac.json:
66
+ { "default_agent": "#{agent_name}" }
67
+ MSG
68
+ return agent_name
69
+ end
70
+ end
71
+
72
+ # 4. Nothing found
42
73
  raise <<~MSG
43
- No default agent configured. Set one of:
74
+ No default agent configured and no agents found in #{AGENT_REGISTRY_FILE}.
75
+ Set one of:
44
76
  1. Environment variable: export AI_AGENT_NAME="YourAgent"
45
77
  2. In ~/.brainiac/brainiac.json: { "default_agent": "YourAgent" }
46
78
  MSG
47
79
  end
48
80
 
81
+ AI_AGENT_NAME = resolve_default_agent
82
+
49
83
  LOG_LEVEL = ENV.fetch("LOG_LEVEL", "info").downcase
50
84
  LOG = Logger.new($stdout)
51
85
  LOG.level = case LOG_LEVEL
@@ -3,7 +3,8 @@
3
3
  # Shared inline tag parsing for handler messages.
4
4
  #
5
5
  # Messages from any channel can contain inline tags like:
6
- # [project:my-project], [opus], [effort:high], [cli:grok], [chat], [plan]
6
+ # [project:my-project], [opus], [effort:high], [cli:grok], [chat], [plan],
7
+ # [branch:feature-xyz], [workitem:wi-abc123]
7
8
  #
8
9
  # This module provides a single parser that extracts all tags and returns
9
10
  # a structured result with the cleaned text.
@@ -31,9 +32,21 @@ def parse_inline_tags(text)
31
32
  planning: false,
32
33
  deploy_intent: nil,
33
34
  worktree_override: nil,
35
+ work_item: nil,
36
+ branch_override: nil,
34
37
  clean_text: text.dup
35
38
  }
36
39
 
40
+ parse_value_tags(result)
41
+ parse_flag_tags(result)
42
+ parse_work_item_tags(result)
43
+ parse_model_tag(result)
44
+
45
+ result[:clean_text].strip!
46
+ result
47
+ end
48
+
49
+ def parse_value_tags(result)
37
50
  # [project:my-project]
38
51
  if (match = result[:clean_text].match(/\[project:(\S+)\]/i))
39
52
  result[:project] = match[1]
@@ -52,6 +65,14 @@ def parse_inline_tags(text)
52
65
  result[:clean_text].sub!(match[0], "")
53
66
  end
54
67
 
68
+ # [deploy] or [deploy:dev01]
69
+ if (match = result[:clean_text].match(/\[deploy(?::([^\]]+))?\]/i))
70
+ result[:deploy_intent] = match[1]&.strip&.downcase || :auto
71
+ result[:clean_text].sub!(match[0], "")
72
+ end
73
+ end
74
+
75
+ def parse_flag_tags(result)
55
76
  # [chat], [question], [?]
56
77
  if result[:clean_text].match?(/\[(chat|question|\?)\]/i)
57
78
  result[:chat_mode] = true
@@ -59,23 +80,35 @@ def parse_inline_tags(text)
59
80
  end
60
81
 
61
82
  # [plan]
62
- if result[:clean_text].match?(/\[plan\]/i)
63
- result[:planning] = true
64
- result[:clean_text].sub!(/\[plan\]/i, "")
83
+ return unless result[:clean_text].match?(/\[plan\]/i)
84
+
85
+ result[:planning] = true
86
+ result[:clean_text].sub!(/\[plan\]/i, "")
87
+ end
88
+
89
+ def parse_work_item_tags(result)
90
+ # [worktree:branch-name] — legacy syntax, still supported
91
+ if (match = result[:clean_text].match(/\[worktree:([^\]]+)\]/))
92
+ result[:worktree_override] = match[1].strip
93
+ result[:clean_text].sub!(match[0], "")
65
94
  end
66
95
 
67
- # [deploy] or [deploy:dev01]
68
- if (match = result[:clean_text].match(/\[deploy(?::([^\]]+))?\]/i))
69
- result[:deploy_intent] = match[1]&.strip&.downcase || :auto
96
+ # [branch:branch-name] preferred syntax for targeting a branch/worktree
97
+ if (match = result[:clean_text].match(/\[branch:([^\]]+)\]/i))
98
+ result[:branch_override] = match[1].strip
99
+ # Also set worktree_override for backward compat with plugins that read it
100
+ result[:worktree_override] ||= result[:branch_override]
70
101
  result[:clean_text].sub!(match[0], "")
71
102
  end
72
103
 
73
- # [worktree:branch-name]
74
- if (match = result[:clean_text].match(/\[worktree:([^\]]+)\]/))
75
- result[:worktree_override] = match[1].strip
104
+ # [workitem:wi-abc123] — target a specific work item by ID
105
+ if (match = result[:clean_text].match(/\[workitem:([^\]]+)\]/i))
106
+ result[:work_item] = match[1].strip
76
107
  result[:clean_text].sub!(match[0], "")
77
108
  end
109
+ end
78
110
 
111
+ def parse_model_tag(result)
79
112
  # Model tag: any remaining [word] that isn't a known tag — detected separately
80
113
  # because it depends on the project's allowed_models config. We just capture
81
114
  # the raw match here for the caller to resolve.
@@ -83,7 +116,4 @@ def parse_inline_tags(text)
83
116
  result[:model_tag] = match[1].downcase
84
117
  result[:clean_text].sub!(match[0], "")
85
118
  end
86
-
87
- result[:clean_text].strip!
88
- result
89
119
  end
@@ -112,7 +112,8 @@ end
112
112
  def load_work_item_map
113
113
  return {} unless File.exist?(WORK_ITEM_MAP_FILE)
114
114
 
115
- JSON.parse(File.read(WORK_ITEM_MAP_FILE))
115
+ raw = JSON.parse(File.read(WORK_ITEM_MAP_FILE))
116
+ migrate_work_item_map(raw)
116
117
  rescue JSON::ParserError
117
118
  {}
118
119
  end
@@ -121,6 +122,162 @@ def save_work_item_map(map)
121
122
  File.write(WORK_ITEM_MAP_FILE, JSON.pretty_generate(map))
122
123
  end
123
124
 
125
+ # Migrate old-format work item maps (keyed by Fizzy card internal ID with flat structure)
126
+ # to the new source-agnostic format (keyed by work item ID with sources hash).
127
+ # Old format: { "fizzy-uuid" => { "number" => 42, "branch" => "...", "worktree" => "...", "project" => "...", "agent" => "..." } }
128
+ # New format: { "wi-abc123" => { "id" => "wi-...", "branch" => "...", "worktree" => "...",
129
+ # "project" => "...", "agent" => "...", "sources" => { "fizzy" => { ... } } } }
130
+ def migrate_work_item_map(raw)
131
+ return raw if raw.empty?
132
+
133
+ # Detect: if any entry has a "sources" key, it's already new format (or mixed)
134
+ # If none have "sources", it's entirely old format
135
+ needs_migration = raw.values.any? { |v| v.is_a?(Hash) && !v.key?("sources") }
136
+ return raw unless needs_migration
137
+
138
+ migrated = {}
139
+ raw.each do |key, entry|
140
+ next unless entry.is_a?(Hash)
141
+
142
+ if entry.key?("sources")
143
+ # Already new format
144
+ migrated[key] = entry
145
+ else
146
+ # Old format — migrate. Generate a work item ID from the branch or card number.
147
+ work_item_id = generate_work_item_id(branch: entry["branch"], card_number: entry["number"])
148
+ migrated[work_item_id] = {
149
+ "id" => work_item_id,
150
+ "branch" => entry["branch"],
151
+ "worktree" => entry["worktree"],
152
+ "project" => entry["project"],
153
+ "agent" => entry["agent"],
154
+ "sources" => {
155
+ "fizzy" => {
156
+ "card_internal_id" => key,
157
+ "card_number" => entry["number"]
158
+ }
159
+ }
160
+ }
161
+ # Preserve PR tracking if it exists
162
+ migrated[work_item_id]["sources"]["github"] = { "prs" => entry["prs"] } if entry["prs"]
163
+ end
164
+ end
165
+ migrated
166
+ end
167
+
168
+ # Generate a deterministic work item ID from available identifiers.
169
+ # Priority: branch name (universal join key), then card number fallback.
170
+ def generate_work_item_id(branch: nil, card_number: nil)
171
+ if branch
172
+ "wi-#{Digest::SHA256.hexdigest(branch)[0..7]}"
173
+ elsif card_number
174
+ "wi-card-#{card_number}"
175
+ else
176
+ "wi-#{SecureRandom.hex(4)}"
177
+ end
178
+ end
179
+
180
+ # Find a work item by its branch name. Returns [work_item_id, info] or nil.
181
+ # This is the primary lookup method — branch is the universal join key.
182
+ def find_work_item_by_branch(branch)
183
+ return nil unless branch
184
+
185
+ map = load_work_item_map
186
+ map.each do |work_item_id, info|
187
+ next unless info.is_a?(Hash) && info["branch"] == branch
188
+
189
+ return [work_item_id, info]
190
+ end
191
+ nil
192
+ end
193
+
194
+ # Find a work item by its ID. Returns the info hash or nil.
195
+ def find_work_item_by_id(work_item_id)
196
+ return nil unless work_item_id
197
+
198
+ map = load_work_item_map
199
+ map[work_item_id]
200
+ end
201
+
202
+ # Find a work item by a Fizzy card internal ID (for backward compat with Fizzy plugin).
203
+ # Returns [work_item_id, info] or nil.
204
+ def find_work_item_by_card(card_internal_id)
205
+ return nil unless card_internal_id
206
+
207
+ map = load_work_item_map
208
+ map.each do |work_item_id, info|
209
+ next unless info.is_a?(Hash)
210
+
211
+ fizzy_source = info.dig("sources", "fizzy")
212
+ next unless fizzy_source && fizzy_source["card_internal_id"] == card_internal_id
213
+
214
+ return [work_item_id, info]
215
+ end
216
+ nil
217
+ end
218
+
219
+ # Register a new work item or update an existing one.
220
+ # Returns the work item ID.
221
+ def register_work_item(branch:, worktree: nil, project: nil, agent: nil, source: nil, source_data: {})
222
+ map = load_work_item_map
223
+
224
+ # Check if a work item already exists for this branch
225
+ existing_id = nil
226
+ map.each do |wid, info|
227
+ if info.is_a?(Hash) && info["branch"] == branch
228
+ existing_id = wid
229
+ break
230
+ end
231
+ end
232
+
233
+ work_item_id = existing_id || generate_work_item_id(branch: branch)
234
+
235
+ if existing_id
236
+ # Update existing entry — merge in new source, update worktree/agent if provided
237
+ map[work_item_id]["worktree"] = worktree if worktree
238
+ map[work_item_id]["agent"] = agent if agent
239
+ map[work_item_id]["sources"] ||= {}
240
+ map[work_item_id]["sources"][source] = source_data if source
241
+ else
242
+ # Create new entry
243
+ map[work_item_id] = {
244
+ "id" => work_item_id,
245
+ "branch" => branch,
246
+ "worktree" => worktree,
247
+ "project" => project,
248
+ "agent" => agent,
249
+ "sources" => source ? { source => source_data } : {}
250
+ }
251
+ end
252
+
253
+ save_work_item_map(map)
254
+ work_item_id
255
+ end
256
+
257
+ # Add or update a source on an existing work item.
258
+ # Returns true if the work item was found and updated, false otherwise.
259
+ def register_work_item_source(source:, source_data:, work_item_id: nil, branch: nil) # rubocop:disable Naming/PredicateMethod
260
+ map = load_work_item_map
261
+
262
+ # Find by ID or branch
263
+ target_id = work_item_id
264
+ unless target_id
265
+ map.each do |wid, info|
266
+ if info.is_a?(Hash) && info["branch"] == branch
267
+ target_id = wid
268
+ break
269
+ end
270
+ end
271
+ end
272
+
273
+ return false unless target_id && map[target_id]
274
+
275
+ map[target_id]["sources"] ||= {}
276
+ map[target_id]["sources"][source] = source_data
277
+ save_work_item_map(map)
278
+ true
279
+ end
280
+
124
281
  def slugify(title, max_length: 40)
125
282
  title.downcase.gsub(/[^a-z0-9\s-]/, "").strip.gsub(/\s+/, "-").slice(0, max_length).chomp("-")
126
283
  end
@@ -242,8 +399,27 @@ def resolve_resume(resume, resolved, chdir)
242
399
  false
243
400
  end
244
401
 
402
+ # Check if intent detection says to skip dispatching the agent.
403
+ # Returns true if the message should be skipped, false otherwise.
404
+ # Only runs if a raw message is provided, an agent is named, and intent is enabled.
405
+ def intent_skip?(message, agent_name:, source: nil, channel: nil)
406
+ return false unless message && agent_name && intent_config["enabled"]
407
+
408
+ intent_channel = channel || source&.to_s || "conversation"
409
+ unless check_intent(message, agent_name: agent_name, channel: intent_channel)
410
+ LOG.info "[Intent] Skipping dispatch for #{agent_name} — message classified as not requiring response"
411
+ return true
412
+ end
413
+
414
+ false
415
+ end
416
+
245
417
  def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil, effort: nil, agent_name: nil, card_number: nil, comment_id: nil,
246
- source: nil, source_context: {}, skip_column_move: false, cli_provider: nil, resume: false)
418
+ source: nil, source_context: {}, skip_column_move: false, cli_provider: nil, resume: false,
419
+ message: nil, channel: nil)
420
+ # Intent gate: if a raw message is provided, check whether the agent should respond.
421
+ return nil if intent_skip?(message, agent_name: agent_name, source: source, channel: channel)
422
+
247
423
  resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider, agent_name: agent_name)
248
424
  chdir ||= resolved["repo_path"]
249
425
  model ||= resolved["agent_model"]
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Local LLM-based intent detection.
4
+ #
5
+ # Uses a lightweight local model (via Ollama) to classify whether an incoming
6
+ # message requires an agent to respond. This saves expensive API tokens by
7
+ # filtering out messages that are just humans chatting, reactions, or noise.
8
+ #
9
+ # Also detects when an agent's own response implies pending work — if the agent
10
+ # said "I'll do X" but the session ended without completing the work, this
11
+ # signals that the agent should be re-dispatched.
12
+ #
13
+ # Configuration (in ~/.brainiac/brainiac.json):
14
+ #
15
+ # "intent": {
16
+ # "enabled": true,
17
+ # "endpoint": "http://localhost:11434/api/generate",
18
+ # "model": "gemma3:4b",
19
+ # "timeout": 10,
20
+ # "temperature": 0.1
21
+ # }
22
+ #
23
+ # Plugins use this via:
24
+ # requires_response = check_intent(message, agent_name: "Galen", context: "Discord thread")
25
+ # return unless requires_response
26
+ #
27
+ # Returns true (agent should respond), false (skip), or true on any failure
28
+ # (fail-open so we never accidentally ignore a real request).
29
+
30
+ INTENT_CONFIG_DEFAULTS = {
31
+ "enabled" => false,
32
+ "endpoint" => "http://localhost:11434/api/generate",
33
+ "model" => "gemma3:4b",
34
+ "timeout" => 10,
35
+ "temperature" => 0.1
36
+ }.freeze
37
+
38
+ INTENT_PROMPT_TEMPLATE = <<~PROMPT
39
+ You are a message router for a {{CHANNEL}}. An AI agent named {{AGENT_NAME}} is participating in this conversation. Your job: determine if the latest message requires {{AGENT_NAME}} to take action or respond.
40
+
41
+ Rules:
42
+ - If the message is giving {{AGENT_NAME}} instructions, asking {{AGENT_NAME}} a question, or continuing a conversation WITH {{AGENT_NAME}} → yes
43
+ - If the message is humans talking to each other and {{AGENT_NAME}} is not being addressed → no
44
+ - If the message is a simple acknowledgment (like "thanks", "ok", "got it") directed at {{AGENT_NAME}}'s previous work → no
45
+ - If the message is asking a question to another person or agent → no
46
+ - If uncertain, lean toward yes (better to respond unnecessarily than miss a request)
47
+
48
+ Respond with ONLY "yes" or "no" — nothing else.
49
+
50
+ Latest message:
51
+ {{MESSAGE}}
52
+ PROMPT
53
+
54
+ PENDING_WORK_PROMPT_TEMPLATE = <<~PROMPT
55
+ You are analyzing a message posted by an AI agent named {{AGENT_NAME}}. Your job: determine if this message indicates the agent intends to do more work that hasn't been completed yet.
56
+
57
+ Rules:
58
+ - If the agent says it will do something, is about to start work, or promises a follow-up action → yes
59
+ - If the agent is reporting completed work, summarizing what was done, or delivering a final answer → no
60
+ - If the agent is asking a clarifying question and waiting for a human response → no
61
+ - If the agent says things like "give me a sec", "I'll implement this", "let me do that", "working on it" → yes
62
+ - If uncertain, lean toward no (avoid unnecessary re-dispatches)
63
+
64
+ Respond with ONLY "yes" or "no" — nothing else.
65
+
66
+ Agent's message:
67
+ {{MESSAGE}}
68
+ PROMPT
69
+
70
+ # Load intent config from brainiac.json, merging with defaults.
71
+ def intent_config
72
+ raw = BRAINIAC_CONFIG["intent"] || {}
73
+ INTENT_CONFIG_DEFAULTS.merge(raw)
74
+ end
75
+
76
+ # Check whether a message requires an agent to respond.
77
+ #
78
+ # @param message [String] The message text to classify
79
+ # @param agent_name [String] The agent being addressed
80
+ # @param channel [String] Context description (e.g., "Discord thread", "Fizzy card comment")
81
+ # @return [Boolean] true if the agent should respond, false if it can be skipped
82
+ def check_intent(message, agent_name:, channel: "conversation")
83
+ config = intent_config
84
+ return true unless config["enabled"]
85
+ return true if message.nil? || message.strip.empty?
86
+
87
+ prompt = INTENT_PROMPT_TEMPLATE
88
+ .gsub("{{AGENT_NAME}}", agent_name)
89
+ .gsub("{{CHANNEL}}", channel)
90
+ .gsub("{{MESSAGE}}", message.strip)
91
+
92
+ LOG.info "[Intent] Checking intent for #{agent_name} (#{channel}): #{message.strip.slice(0, 80)}..."
93
+ response = query_local_llm(prompt, config)
94
+ result = positive_intent?(response)
95
+ LOG.info "[Intent] Result: #{result ? "RESPOND" : "SKIP"} (model: #{config["model"]})"
96
+ result
97
+ rescue StandardError => e
98
+ LOG.warn "[Intent] Classification failed (fail-open): #{e.message}"
99
+ true
100
+ end
101
+
102
+ # Query the local LLM via Ollama's HTTP API.
103
+ #
104
+ # @param prompt [String] The classification prompt
105
+ # @param config [Hash] Intent configuration
106
+ # @return [String] Raw response text from the model
107
+ def query_local_llm(prompt, config)
108
+ uri = URI(config["endpoint"])
109
+ payload = {
110
+ model: config["model"],
111
+ prompt: prompt,
112
+ stream: false,
113
+ options: { temperature: config["temperature"] }
114
+ }
115
+
116
+ http = Net::HTTP.new(uri.host, uri.port)
117
+ http.open_timeout = config["timeout"]
118
+ http.read_timeout = config["timeout"]
119
+
120
+ request = Net::HTTP::Post.new(uri.path, "Content-Type" => "application/json")
121
+ request.body = JSON.generate(payload)
122
+
123
+ response = http.request(request)
124
+ raise "Ollama returned #{response.code}: #{response.body&.slice(0, 200)}" unless response.is_a?(Net::HTTPSuccess)
125
+
126
+ body = JSON.parse(response.body)
127
+ body["response"] || ""
128
+ rescue Errno::ECONNREFUSED
129
+ raise "Ollama not running at #{config["endpoint"]}"
130
+ rescue Net::OpenTimeout, Net::ReadTimeout
131
+ raise "Ollama timed out after #{config["timeout"]}s"
132
+ end
133
+
134
+ # Parse the LLM's yes/no response into a boolean.
135
+ # Fail-open: anything that isn't clearly "no" returns true.
136
+ #
137
+ # @param response [String] Raw response from the model
138
+ # @return [Boolean]
139
+ def positive_intent?(response)
140
+ cleaned = response.to_s.strip.downcase.gsub(/[^a-z]/, "")
141
+ cleaned != "no"
142
+ end
143
+
144
+ # Check whether an agent's own message implies pending work that wasn't completed.
145
+ # Used to detect when an agent posted "I'll do X" but the session ended without
146
+ # actually doing the work — signaling the agent should be re-dispatched.
147
+ #
148
+ # Fail-closed: returns false on any error (don't re-dispatch on classification failure).
149
+ #
150
+ # @param message [String] The agent's posted message
151
+ # @param agent_name [String] The agent who posted it
152
+ # @return [Boolean] true if the message implies uncommitted work
153
+ def check_pending_work(message, agent_name:)
154
+ config = intent_config
155
+ return false unless config["enabled"]
156
+ return false if message.nil? || message.strip.empty?
157
+
158
+ prompt = PENDING_WORK_PROMPT_TEMPLATE
159
+ .gsub("{{AGENT_NAME}}", agent_name)
160
+ .gsub("{{MESSAGE}}", message.strip)
161
+
162
+ response = query_local_llm(prompt, config)
163
+ result = pending_work_detected?(response)
164
+ LOG.info "[Intent] Pending work detected in #{agent_name}'s message" if result
165
+ result
166
+ rescue StandardError => e
167
+ LOG.warn "[Intent] Pending work check failed (fail-closed): #{e.message}"
168
+ false
169
+ end
170
+
171
+ # Parse the LLM's yes/no response for pending work detection.
172
+ # Fail-closed: anything that isn't clearly "yes" returns false.
173
+ #
174
+ # @param response [String] Raw response from the model
175
+ # @return [Boolean]
176
+ def pending_work_detected?(response)
177
+ cleaned = response.to_s.strip.downcase.gsub(/[^a-z]/, "")
178
+ cleaned == "yes"
179
+ end
@@ -354,6 +354,67 @@ post "/api/cron/reload" do
354
354
  { status: "reloaded", jobs: CRON_JOBS.size }.to_json
355
355
  end
356
356
 
357
+ # --- Intent ---
358
+
359
+ get "/api/intent/config" do
360
+ content_type :json
361
+ config = intent_config
362
+ status = if config["enabled"]
363
+ begin
364
+ uri = URI(config["endpoint"].sub("/api/generate", "/api/tags"))
365
+ http = Net::HTTP.new(uri.host, uri.port)
366
+ http.open_timeout = 3
367
+ http.read_timeout = 3
368
+ response = http.get(uri.path)
369
+ if response.is_a?(Net::HTTPSuccess)
370
+ models = JSON.parse(response.body).fetch("models", []).map { |m| m["name"] }
371
+ model_available = models.any? { |m| m.start_with?(config["model"].split(":").first) }
372
+ model_available ? "ok" : "model_not_found"
373
+ else
374
+ "ollama_error"
375
+ end
376
+ rescue Errno::ECONNREFUSED
377
+ "ollama_not_running"
378
+ rescue StandardError => e
379
+ "error: #{e.message}"
380
+ end
381
+ else
382
+ "disabled"
383
+ end
384
+ config.merge("status" => status).to_json
385
+ end
386
+
387
+ post "/api/intent/check" do
388
+ content_type :json
389
+ request.body.rewind
390
+ payload = JSON.parse(request.body.read)
391
+
392
+ message = payload["message"]
393
+ agent = payload["agent"] || AI_AGENT_NAME
394
+ channel = payload["channel"] || "conversation"
395
+
396
+ halt 400, { error: "Missing 'message' field" }.to_json unless message && !message.empty?
397
+
398
+ result = check_intent(message, agent_name: agent, channel: channel)
399
+ { should_respond: result, agent: agent, channel: channel }.to_json
400
+ end
401
+
402
+ post "/api/intent/pending-work" do
403
+ content_type :json
404
+ request.body.rewind
405
+ payload = JSON.parse(request.body.read)
406
+
407
+ message = payload["message"]
408
+ agent = payload["agent"] || AI_AGENT_NAME
409
+
410
+ halt 400, { error: "Missing 'message' field" }.to_json unless message && !message.empty?
411
+
412
+ result = check_pending_work(message, agent_name: agent)
413
+ { pending_work: result, agent: agent }.to_json
414
+ end
415
+
416
+ # --- Cron Logs ---
417
+
357
418
  get "/api/cron/logs" do
358
419
  content_type :json
359
420
  job_id = params["id"]
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Brainiac
4
4
  # @return [String] the current gem version
5
- VERSION = "0.0.12"
5
+ VERSION = "0.0.14"
6
6
  end
data/lib/brainiac.rb CHANGED
@@ -7,6 +7,7 @@ require_relative "brainiac/skills"
7
7
  require_relative "brainiac/sessions"
8
8
  require_relative "brainiac/prompts"
9
9
  require_relative "brainiac/helpers"
10
+ require_relative "brainiac/intent"
10
11
  require_relative "brainiac/cron"
11
12
  require_relative "brainiac/plugins"
12
13
 
data/receiver.rb CHANGED
@@ -24,6 +24,7 @@ require_relative "lib/brainiac/helpers"
24
24
  require_relative "lib/brainiac/notifications"
25
25
  require_relative "lib/brainiac/cron"
26
26
  require_relative "lib/brainiac/restart"
27
+ require_relative "lib/brainiac/intent"
27
28
  require_relative "lib/brainiac/plugins"
28
29
  require_relative "lib/brainiac/handlers/shared/git"
29
30
  require_relative "lib/brainiac/handlers/shared/inline_tags"
@@ -98,6 +99,11 @@ configure do
98
99
  end
99
100
 
100
101
  LOG.info "[Brainiac] Starting v#{BRAINIAC_VERSION} on port #{settings.port} (#{settings.environment})"
102
+ if intent_config["enabled"]
103
+ LOG.info "[Intent] Enabled — model: #{intent_config["model"]}, endpoint: #{intent_config["endpoint"]}"
104
+ else
105
+ LOG.info "[Intent] Disabled (enable in brainiac.json → intent.enabled: true)"
106
+ end
101
107
 
102
108
  # --- Dashboard authentication ---
103
109
 
@@ -1,3 +1,10 @@
1
1
  {
2
- "default_agent": "Sherlock"
2
+ "default_agent": "Sherlock",
3
+ "intent": {
4
+ "enabled": false,
5
+ "endpoint": "http://localhost:11434/api/generate",
6
+ "model": "gemma3:4b",
7
+ "timeout": 10,
8
+ "temperature": 0.1
9
+ }
3
10
  }
@@ -0,0 +1,14 @@
1
+ #!/bin/bash
2
+ # Pre-commit hook: auto-run `bundle` when version.rb changes
3
+ # This ensures Gemfile.lock stays in sync with gemspec version bumps.
4
+
5
+ if git diff --cached --name-only | grep -q "version\.rb$"; then
6
+ echo "🔒 version.rb changed — running bundle to sync Gemfile.lock..."
7
+ bundle install --quiet
8
+ if [ $? -ne 0 ]; then
9
+ echo "❌ bundle install failed. Fix the issue and try again."
10
+ exit 1
11
+ fi
12
+ git add Gemfile.lock
13
+ echo "✅ Gemfile.lock updated and staged."
14
+ fi
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.0.12
4
+ version: 0.0.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis
@@ -133,6 +133,7 @@ files:
133
133
  - lib/brainiac/handlers/shared/inline_tags.rb
134
134
  - lib/brainiac/helpers.rb
135
135
  - lib/brainiac/hooks.rb
136
+ - lib/brainiac/intent.rb
136
137
  - lib/brainiac/notifications.rb
137
138
  - lib/brainiac/plugins.rb
138
139
  - lib/brainiac/prompts.rb
@@ -159,6 +160,7 @@ files:
159
160
  - templates/brainiac.json.example
160
161
  - templates/cli-providers/grok.json.example
161
162
  - templates/cli-providers/kiro.json.example
163
+ - templates/hooks/pre-commit
162
164
  - templates/plugins.json.example
163
165
  - templates/roles/code-reviewer.md.example
164
166
  - templates/roles/general-engineer.md.example