brainiac-basecamp 0.0.16 → 0.0.18

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.
@@ -0,0 +1,380 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Brainiac
6
+ module Plugins
7
+ module Basecamp
8
+ # Handles inbound Basecamp comments on epic todolists/todos.
9
+ #
10
+ # Routing:
11
+ # 1. If a bot account is @mentioned in the comment → dispatch that agent
12
+ # 2. If no mention → dispatch the last agent who responded on this epic
13
+ # 3. If no prior responder → dispatch the epic's default agent
14
+ #
15
+ # The dispatched agent receives the comment content as a prompt with epic context,
16
+ # and posts its reply back via Client.add_comment.
17
+ module CommentResponder
18
+ MENTION_TAG_OPEN = "<bc-attachment"
19
+ MENTION_TAG_CLOSE = "</bc-attachment>"
20
+
21
+ class << self
22
+ # Process a comment_created webhook and dispatch the appropriate agent.
23
+ #
24
+ # @param payload [Hash] Full webhook payload
25
+ # @param recording [Hash] The comment recording from the payload
26
+ # @return [Array(Integer, String)] HTTP status code and response body
27
+ def handle(payload, recording)
28
+ content = recording["content"] || ""
29
+ creator = payload["creator"] || {}
30
+ creator_id = creator["id"]&.to_s
31
+ parent = recording["parent"] || {}
32
+ parent_type = parent["type"]
33
+ parent_id = parent["id"]
34
+ parent_title = parent["title"] || ""
35
+ project_id = recording.dig("bucket", "id")&.to_s
36
+
37
+ # Ignore comments posted by our own bot accounts (prevent loops)
38
+ if Config.bot_account_for_person(creator_id)
39
+ LOG.debug "[Basecamp:Comment] Ignoring comment from our own bot (person #{creator_id})" if defined?(LOG)
40
+ return [200, { status: "ignored", reason: "self_comment" }.to_json]
41
+ end
42
+
43
+ # Determine if this comment is on an epic todolist or a todo within one
44
+ epic = resolve_epic_for_comment(parent_type, parent_id, parent_title, project_id)
45
+ unless epic
46
+ LOG.debug "[Basecamp:Comment] Comment not on an epic recording — ignoring" if defined?(LOG)
47
+ return [200, { status: "ignored", reason: "not_epic" }.to_json]
48
+ end
49
+
50
+ # Determine which agent to dispatch
51
+ agent_name = resolve_target_agent(content, epic)
52
+
53
+ LOG.info "[Basecamp:Comment] Dispatching #{agent_name} to respond to comment on '#{epic['title']}'" if defined?(LOG)
54
+
55
+ # Strip HTML tags for a clean text prompt, preserve @mentions as names
56
+ clean_content = strip_html_preserve_mentions(content)
57
+ commenter_name = creator["name"] || "Someone"
58
+
59
+ # Dispatch the agent in a background thread
60
+ Thread.new do
61
+ dispatch_comment_response(
62
+ epic: epic,
63
+ agent_name: agent_name,
64
+ comment_text: clean_content,
65
+ commenter_name: commenter_name,
66
+ recording_id: parent_id,
67
+ project_id: project_id
68
+ )
69
+ rescue StandardError => e
70
+ LOG.error "[Basecamp:Comment] Dispatch failed: #{e.message}\n#{e.backtrace.first(3).join("\n")}" if defined?(LOG)
71
+ end
72
+
73
+ # Track last responding agent on the epic
74
+ epic["last_responding_agent"] = agent_name
75
+ epic["updated_at"] = Time.now.iso8601
76
+ Hooks.send(:save_epic_state, epic)
77
+
78
+ [200, { status: "dispatched", agent: agent_name, epic_id: epic["id"] }.to_json]
79
+ end
80
+
81
+ # Resolve Basecamp person IDs for known agent names using the basecamp CLI.
82
+ # Used during setup to auto-map bot accounts.
83
+ #
84
+ # @param agent_names [Array<String>] Agent names to look up (e.g. ["Galen", "Kaylee"])
85
+ # @param project_id [String, nil] Optional project/bucket ID for scoping
86
+ # @return [Hash<String, String>] agent_name => person_id mapping
87
+ def resolve_person_ids(agent_names)
88
+ results = {}
89
+
90
+ agent_names.each do |name|
91
+ # Use jq to filter people by name
92
+ output, status = Open3.capture2(
93
+ "basecamp", "people", "list", "--jq",
94
+ ".data[] | select(.name | ascii_downcase | contains(\"#{name.downcase}\")) | {id, name}"
95
+ )
96
+
97
+ next unless status.success?
98
+
99
+ # Parse each JSON line (could be multiple matches)
100
+ output.each_line do |line|
101
+ person = JSON.parse(line.strip)
102
+ # Exact match preferred, otherwise first contains-match
103
+ if person["name"]&.downcase == name.downcase
104
+ results[name] = person["id"].to_s
105
+ break
106
+ elsif !results.key?(name)
107
+ results[name] = person["id"].to_s
108
+ end
109
+ rescue JSON::ParserError
110
+ next
111
+ end
112
+ end
113
+
114
+ results
115
+ end
116
+
117
+ private
118
+
119
+ # Find the active epic that this comment belongs to.
120
+ #
121
+ # @param parent_type [String] "Todolist" or "Todo"
122
+ # @param parent_id [Integer, String] ID of the parent recording
123
+ # @param parent_title [String] Title of the parent
124
+ # @param project_id [String] Basecamp bucket/project ID
125
+ # @return [Hash, nil] Epic state or nil
126
+ def resolve_epic_for_comment(parent_type, parent_id, _parent_title, _project_id)
127
+ active_epics = Orchestrator.active_epics
128
+
129
+ case parent_type
130
+ when "Todolist"
131
+ # Comment directly on the epic todolist
132
+ active_epics.find { |e| e["todolist_id"].to_s == parent_id.to_s }
133
+ when "Todo"
134
+ # Comment on a specific todo within an epic
135
+ active_epics.find do |e|
136
+ e["tasks"]&.any? { |t| t["todo_id"].to_s == parent_id.to_s }
137
+ end
138
+ end
139
+ end
140
+
141
+ # Determine which agent should respond to this comment.
142
+ #
143
+ # Priority:
144
+ # 1. Explicit @mention of a bot account in the comment HTML
145
+ # 2. Last agent who responded on this epic
146
+ # 3. Epic's default agent
147
+ #
148
+ # @param content [String] Comment HTML content
149
+ # @param epic [Hash] Epic state
150
+ # @return [String] Agent name
151
+ def resolve_target_agent(content, epic)
152
+ # 1. Check for @mentions of bot accounts
153
+ mentioned_agent = detect_mentioned_agent(content)
154
+ return mentioned_agent if mentioned_agent
155
+
156
+ # 2. Fall back to last responding agent
157
+ return epic["last_responding_agent"] if epic["last_responding_agent"]
158
+
159
+ # 3. Fall back to epic's default agent
160
+ epic["agent"] || "Galen"
161
+ end
162
+
163
+ # Parse Basecamp rich text HTML to find @mentions of bot accounts.
164
+ #
165
+ # Basecamp mentions look like:
166
+ # <bc-attachment sgid="..." content-type="application/vnd.basecamp.mention">@Name</bc-attachment>
167
+ #
168
+ # We also check for plain-text @AgentName patterns as a fallback.
169
+ #
170
+ # @param content [String] HTML content of the comment
171
+ # @return [String, nil] Agent name if a bot was mentioned, nil otherwise
172
+ def detect_mentioned_agent(content)
173
+ bot_accounts = Config.current["bot_accounts"] || {}
174
+
175
+ # Strategy 1: Parse bc-attachment mentions (Basecamp's native format)
176
+ # The sgid encodes the person — but we can match by the visible name text
177
+ each_basecamp_mention(content) do |mention_content|
178
+ mention_name = strip_html_tags(mention_content).delete_prefix("@").strip
179
+ bot_accounts.each_value do |account|
180
+ agent = account["default_agent"]
181
+ # Match if the mention text contains the agent name (case insensitive)
182
+ return agent if mention_name.downcase.include?(agent.downcase)
183
+ end
184
+ end
185
+
186
+ # Strategy 2: Plain text @AgentName pattern (fallback for simple comments)
187
+ bot_accounts.each_value do |account|
188
+ agent = account["default_agent"]
189
+ return agent if content.match?(/(?:^|\s)@#{Regexp.escape(agent)}\b/i)
190
+ end
191
+
192
+ nil
193
+ end
194
+
195
+ # Strip HTML tags but preserve mention names as readable text.
196
+ # Uses bounded atomic groups to prevent polynomial regex backtracking,
197
+ # and loops until stable to prevent incomplete sanitization (e.g. nested tags
198
+ # that reconstruct dangerous elements after a single pass).
199
+ #
200
+ # @param html [String] HTML content
201
+ # @return [String] Clean text
202
+ def strip_html_preserve_mentions(html)
203
+ text = +""
204
+ cursor = 0
205
+
206
+ while (tag_start = html.index("<", cursor))
207
+ text << html[cursor...tag_start]
208
+ tag_end = html.index(">", tag_start + 1)
209
+ break unless tag_end
210
+
211
+ tag = html[tag_start..tag_end]
212
+ if basecamp_mention_tag?(tag)
213
+ closing_start = html.index(MENTION_TAG_CLOSE, tag_end + 1)
214
+ break unless closing_start
215
+
216
+ mention_content = html[(tag_end + 1)...closing_start]
217
+ text << "@#{strip_html_tags(mention_content).delete_prefix('@').strip}"
218
+ cursor = closing_start + MENTION_TAG_CLOSE.size
219
+ next
220
+ end
221
+
222
+ cursor = tag_end + 1
223
+ end
224
+
225
+ text << html[cursor..] unless cursor >= html.length || tag_start
226
+ text.split.join(" ")
227
+ end
228
+
229
+ # Iterate over the text from native Basecamp mention attachments. This uses
230
+ # bounded String operations rather than a backtracking regular expression,
231
+ # because comment content comes from an untrusted webhook payload.
232
+ def each_basecamp_mention(html)
233
+ cursor = 0
234
+
235
+ while (tag_start = html.index(MENTION_TAG_OPEN, cursor))
236
+ tag_end = html.index(">", tag_start + 1)
237
+ break unless tag_end
238
+
239
+ closing_start = html.index(MENTION_TAG_CLOSE, tag_end + 1)
240
+ break unless closing_start
241
+
242
+ tag = html[tag_start..tag_end]
243
+ yield html[(tag_end + 1)...closing_start] if basecamp_mention_tag?(tag)
244
+ cursor = closing_start + MENTION_TAG_CLOSE.size
245
+ end
246
+ end
247
+
248
+ # Return whether an attachment tag has a content-type value containing
249
+ # "mention". Basecamp uses application/vnd.basecamp.mention.
250
+ def basecamp_mention_tag?(tag)
251
+ marker = "content-type="
252
+ marker_start = tag.downcase.index(marker)
253
+ return false unless marker_start
254
+
255
+ value_start = marker_start + marker.length
256
+ quote = tag[value_start]
257
+ return false unless ['"', "'"].include?(quote)
258
+
259
+ value_end = tag.index(quote, value_start + 1)
260
+ return false unless value_end
261
+
262
+ tag[(value_start + 1)...value_end].downcase.include?("mention")
263
+ end
264
+
265
+ # Remove markup without returning a dangling '<' sequence. An unclosed tag
266
+ # is discarded with the remainder of the input, keeping the result safe as
267
+ # plain text if it is ever rendered by a downstream consumer.
268
+ def strip_html_tags(html)
269
+ text = +""
270
+ cursor = 0
271
+
272
+ while (tag_start = html.index("<", cursor))
273
+ text << html[cursor...tag_start]
274
+ tag_end = html.index(">", tag_start + 1)
275
+ return text if tag_end.nil?
276
+
277
+ cursor = tag_end + 1
278
+ end
279
+
280
+ text << html[cursor..]
281
+ text
282
+ end
283
+
284
+ # Dispatch an agent to respond to the Basecamp comment.
285
+ #
286
+ # @param epic [Hash] Epic state
287
+ # @param agent_name [String] Agent to dispatch
288
+ # @param comment_text [String] Clean text of the comment
289
+ # @param commenter_name [String] Name of the person who commented
290
+ # @param recording_id [String, Integer] The recording to reply to
291
+ # @param project_id [String] Basecamp project/bucket ID
292
+ def dispatch_comment_response(epic:, agent_name:, comment_text:, commenter_name:, recording_id:, project_id:)
293
+ # Build context about the epic state
294
+ tasks_summary = (epic["tasks"] || []).map do |t|
295
+ status_icon = case t["status"]
296
+ when "complete" then "✅"
297
+ when "in_flight" then "🚀"
298
+ when "in_review" then "👀"
299
+ when "final_decision" then "⚖️"
300
+ else "⏳"
301
+ end
302
+ "#{status_icon} ##{t['fizzy_card']} — #{t['title'] || 'Untitled'} (#{t['status']})"
303
+ end.join("\n")
304
+
305
+ prompt = <<~PROMPT
306
+ ## Basecamp Comment — Reply Required
307
+
308
+ **#{commenter_name}** commented on the epic "#{epic['title']}":
309
+
310
+ > #{comment_text}
311
+
312
+ ### Epic Status
313
+ #{tasks_summary}
314
+
315
+ ### Instructions
316
+ You're responding to a comment on a Basecamp epic todolist. Reply conversationally
317
+ and helpfully. If they're asking about status, give specifics from the task list.
318
+ If they're asking you to do something (pause, skip, adjust), explain what you can do.
319
+
320
+ **Reply format:** Write your response as plain text (Basecamp supports basic Markdown).
321
+ Keep it concise but informative.
322
+
323
+ When you're done composing your reply, post it using:
324
+ ```
325
+ basecamp comments create #{recording_id} "<your reply>" --in #{project_id}
326
+ ```
327
+ PROMPT
328
+
329
+ # Resolve project config for the agent
330
+ project_key = Config.brainiac_project_for(project_id)
331
+ projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
332
+ projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
333
+ project_config = projects[project_key]
334
+
335
+ repo_path = project_config&.dig("repo_path") || Dir.home
336
+
337
+ # Spawn the agent
338
+ pid = nil
339
+ log_file = nil
340
+ card_key = "basecamp-comment-#{epic['id']}"
341
+
342
+ begin
343
+ pid, log_file = Hooks.send(:run_agent,
344
+ prompt,
345
+ project_config: project_config,
346
+ chdir: repo_path,
347
+ log_name: "basecamp-comment-#{epic['id']}-#{Time.now.strftime('%Y%m%d-%H%M%S')}",
348
+ agent_name: agent_name,
349
+ source: :basecamp,
350
+ env: {})
351
+ rescue NameError
352
+ if Object.respond_to?(:run_agent, true)
353
+ pid, log_file = Object.send(:run_agent,
354
+ prompt,
355
+ project_config: project_config,
356
+ chdir: repo_path,
357
+ log_name: "basecamp-comment-#{epic['id']}-#{Time.now.strftime('%Y%m%d-%H%M%S')}",
358
+ agent_name: agent_name,
359
+ source: :basecamp,
360
+ env: {})
361
+ else
362
+ LOG.warn "[Basecamp:Comment] run_agent not available — comment response skipped" if defined?(LOG)
363
+ return
364
+ end
365
+ end
366
+
367
+ return unless pid
368
+
369
+ if defined?(register_session)
370
+ register_session(card_key, pid, log_file: log_file, agent_name: agent_name)
371
+ elsif Object.respond_to?(:register_session, true)
372
+ Object.send(:register_session, card_key, pid, log_file: log_file, agent_name: agent_name)
373
+ end
374
+ LOG.info "[Basecamp:Comment] Spawned #{agent_name} (pid #{pid}) to respond on epic '#{epic['title']}'" if defined?(LOG)
375
+ end
376
+ end
377
+ end
378
+ end
379
+ end
380
+ end
@@ -60,6 +60,16 @@ module Brainiac
60
60
  File.exist?(path_for(todolist_id))
61
61
  end
62
62
 
63
+ # Ensure epic memory exists for an epic (creates if missing).
64
+ # Called on resume for epics that started before the feature existed.
65
+ #
66
+ # @param epic [Hash] Epic state
67
+ def ensure_exists_for(epic)
68
+ return if exists?(epic["basecamp_todolist_id"])
69
+
70
+ initialize_for(epic)
71
+ end
72
+
63
73
  # Initialize epic memory with the epic title and initial context.
64
74
  # Called when an epic starts.
65
75
  #