Top Level Namespace
Defined Under Namespace
Modules: Brainiac
Constant Summary collapse
- CRON_CONFIG_FILE =
File.join(BRAINIAC_DIR, "cron.json")
- CRON_JOBS =
{}
- CRON_JOBS_MUTEX =
Mutex.new
- CRON_THREAD =
{ ref: nil }
- BRAIN_SYNC_MUTEX =
Brain (long-term memory via qmd) — query, context building, and git sync.
Mutex.new
- BRAIN_LAST_PULL =
{ at: nil }
- USERS_FILE =
User identity registry - resolves identities across platforms (Discord, GitHub)
File.join(BRAINIAC_DIR, "users.json")
- USER_REGISTRY =
load_user_registry- AGENT_REGISTRY =
load_agent_registry- BRAINIAC_VERSION =
Brainiac::VERSION
- BRAINIAC_DIR =
--- Environment & paths ---
ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
- PROJECTS_FILE =
File.join(BRAINIAC_DIR, "projects.json")
- KIRO_AGENTS_DIR =
File.join(Dir.home, ".kiro", "agents")
- WORK_ITEM_MAP_FILE =
File.join(BRAINIAC_DIR, "work_items.json")
- AGENT_REGISTRY_FILE =
File.join(BRAINIAC_DIR, "agents.json")
- BRAINIAC_CONFIG_FILE =
--- Master config (handler toggles, global settings) ---
File.join(BRAINIAC_DIR, "brainiac.json")
- BRAINIAC_CONFIG =
load_brainiac_config- AI_AGENT_NAME =
--- Default agent name --- Priority: AI_AGENT_NAME env var → brainiac.json "default_agent" → error.
ENV.fetch("AI_AGENT_NAME", nil) || BRAINIAC_CONFIG["default_agent"] || begin raise <<~MSG No default agent configured. Set one of: 1. Environment variable: export AI_AGENT_NAME="YourAgent" 2. In ~/.brainiac/brainiac.json: { "default_agent": "YourAgent" } MSG end
- LOG_LEVEL =
ENV.fetch("LOG_LEVEL", "info").downcase
- LOG =
Logger.new($stdout)
- BRAIN_BASE_DIR =
--- Brain paths ---
File.join(BRAINIAC_DIR, "brain")
- KNOWLEDGE_DIR =
File.join(BRAIN_BASE_DIR, "knowledge")
- PERSONA_BASE_DIR =
File.join(BRAIN_BASE_DIR, "persona")
- MEMORY_BASE_DIR =
File.join(BRAINIAC_DIR, "brain", "memory")
- MEMORY_FILE_TEMPLATE =
"card-{{CARD_ID}}.md"- KNOWLEDGE_COLLECTION =
"brainiac-knowledge"- ROLES_DIR =
--- Roles ---
File.join(BRAINIAC_DIR, "roles")
- NOTIFICATION_COMMAND =
ENV.fetch("NOTIFICATION_COMMAND", nil)
- CONFIG_MTIMES =
Track file mtimes to avoid unnecessary reloads
{}
- PROJECTS =
load_projects_config- DEFAULT_PROJECT =
{ "repo_path" => ENV.fetch("REPO_PATH", Dir.pwd), "github_repo" => ENV.fetch("GITHUB_REPO", nil), # CLI defaults below are overridden by ~/.brainiac/cli-providers/*.json when a # cli_provider is configured on the project or agent. These only apply as a # last-resort fallback when no provider config exists. "agent_cli" => ENV.fetch("AGENT_CLI", "kiro-cli"), "agent_cli_args" => ENV.fetch("AGENT_CLI_ARGS", "chat --trust-all-tools --no-interactive"), "agent_model_flag" => ENV["AGENT_MODEL_FLAG"] || "--model", "agent_model" => ENV.fetch("AGENT_MODEL", nil), "agent_effort_flag" => ENV["AGENT_EFFORT_FLAG"] || "--effort", "agent_effort" => ENV.fetch("AGENT_EFFORT", nil), "allowed_models" => { "opus" => "claude-opus-4.6", "sonnet" => "claude-sonnet-4.6", "haiku" => "claude-haiku-4.5", "deepseek" => "deepseek-3.2", "minimax" => "minimax-m2.5", "minimax25" => "minimax-m2.5", "minimax21" => "minimax-m2.1", "qwen" => "qwen3-coder-next", "auto" => "auto" }, "allowed_efforts" => %w[low medium high xhigh max] }.freeze
- DASHBOARD_TOKEN =
--- Dashboard auth ---
begin brainiac_config_file = File.join(BRAINIAC_DIR, "brainiac.json") config = File.exist?(brainiac_config_file) ? JSON.parse(File.read(brainiac_config_file)) : {} config["dashboard_token"] rescue JSON::ParserError nil end
- SKILLS_DIR =
File.join(KNOWLEDGE_DIR, "skills")
- SKILL_AUTO_INJECT_MAX_CHARS =
Maximum tokens (approx chars / 4) to spend on auto-injected skill content.
8000- SKILL_USAGE_SUFFIX =
--- Usage tracking ---
".usage.json"- SKILL_STALE_DAYS =
--- Curator ---
90- SKILL_ARCHIVE_DIR =
Archive skills unused for this many days
File.join(SKILLS_DIR, "_archived")
- CLI_PROVIDERS_DIR =
File.join(BRAINIAC_DIR, "cli-providers")
- MERGED_CARDS =
Cards that have been merged to main — skip Needs Review moves for these. Keyed by card number (string), value is Time. Entries expire after 10 minutes.
{}
- MERGED_CARDS_MUTEX =
Mutex.new
- PREFETCH_COMMENT_LIMIT =
Returns a formatted string suitable for injection into the prompt, or '' if the fetch fails (agent can still fetch manually as a fallback).
15- COMMENT_BODY_TRUNCATE_LENGTH =
500- CARD_CONTEXT_CACHE =
{}
- CARD_CONTEXT_CACHE_TTL =
seconds
60- PLUGINS_FILE =
Plugin system for Brainiac.
Plugins are distributed as gems named
brainiac-<name>(e.g. brainiac-whatsapp). Each gem exposes a module atBrainiac::Plugins::<Name>that responds to.register(app)whereappis the Sinatra application instance.Installed plugins are tracked in ~/.brainiac/plugins.json so the receiver knows which gems to require at startup.
File.join(BRAINIAC_DIR, "plugins.json")
- PLUGINS_CONFIG =
load_plugins_config- PROMPT_CORE =
PROMPT_CORE — included in EVERY session regardless of channel
<<~PROMPT ## Agent Roster When @mentioning other agents, use the EXACT spelling below. Getting the casing wrong means the mention won't link or notify properly. {{AGENT_ROSTER}} ## Memory (CRITICAL — read this first) You have no persistent memory between sessions. Every time you are invoked, you start completely fresh. Memory files MAY exist at `{{MEMORY_DIR}}/` — this is inside the brain, so they survive worktree deletion. **At the very start of every session:** 1. Read `{{MEMORY_DIR}}/card-{{CARD_ID}}.md`. If it contains content, it has context from your previous sessions. If the file is empty (first session on this card), just proceed without prior context. **Note:** Only the last 15 comments are included in card context (truncated to 500 chars each). Your memory file is the authoritative record of prior discussions. **Before you finish every session (even if you didn't complete the task):** 2. Update your memory file at `{{MEMORY_DIR}}/card-{{CARD_ID}}.md`. Write what future-you needs to pick up where you left off. Use your judgement on what's important — status, decisions, open questions, file paths, PR URLs, timeline of sessions. ## Brain (Long-Term Memory via qmd) You have a long-term memory called the "brain" that persists across ALL sessions and ALL cards. It's split into two parts with very different purposes: ### Knowledge (`{{KNOWLEDGE_DIR}}/`) — shared across all agents Technical knowledge: project conventions, coding patterns, architecture decisions, lessons learned, debugging tips, deployment procedures. **This is for doing work.** Relevant knowledge is automatically retrieved and included above in this prompt when available. You can also search manually: `qmd search "<query>" -c brainiac-knowledge` **MANDATORY: Before running any non-standard CLI tool (qmd, gh, project scripts) you haven't used in this session, search the brain first:** ``` qmd search "<tool-name>" -c brainiac-knowledge ``` Examples: `qmd search "qmd" -c brainiac-knowledge`, `qmd search "gh" -c brainiac-knowledge` Standard unix commands (cd, ls, grep, cat, git, curl, etc.) don't need a brain search. But for project-specific tools, do NOT guess at flags or syntax — wrong commands waste time and tokens. Look it up first. **When to save knowledge:** Be selective — only save significant architecture decisions, non-obvious gotchas, major workflow changes, or things the user explicitly asks you to remember. Routine card work and things already documented in the codebase don't need brain entries. Organize files like: - `{{KNOWLEDGE_DIR}}/projects/marketplace.md` - `{{KNOWLEDGE_DIR}}/conventions/ruby-style.md` - `{{KNOWLEDGE_DIR}}/lessons/testing-patterns.md` ### Persona (`{{PERSONA_DIR}}/`) — unique to you Communication style, tone, personality, how to interact with specific people. **This is for all external communication, such as writing comments on cards, Discord chat, and GitHub PRs.** Do NOT manually read persona files during coding/debugging — the auto-retrieved persona above already shapes your communication style. Focus on implementation during work phases, but always write comments and responses in your unique voice. Organize files like: - `{{PERSONA_DIR}}/style.md` - `{{PERSONA_DIR}}/people/andy.md` ### Writing to the brain Just write or update the file — re-indexing and git sync happen automatically when your session ends. ### Brain vs Memory - Memory (`{{MEMORY_DIR}}/`) = per-card session context, unique to YOU (other agents can't see it) - Brain knowledge (`{{KNOWLEDGE_DIR}}/`) = permanent technical knowledge (shared across all agents) - Brain persona (`{{PERSONA_DIR}}/`) = permanent communication style (yours only) ## Communication Rules Post only **once per session** — combine all updates into a single message at the end of your work. Do not post incremental status updates. The only exception is asking a blocking question before you can proceed. Before posting: 1. Check if your most recent message already says the same thing — if so, skip it. 2. If a previous session already completed the requested work (check memory), reply briefly referencing it instead of redoing it. ## Clarifying Questions (MANDATORY when uncertain) If the task is ambiguous or you're uncertain about requirements, ask before starting. If you're 90% sure, proceed. If you're 60% sure, ask. ## Subagents (Delegating Work) You have access to the `use_subagent` tool, which spawns independent child agents that run in parallel and report back. Use them to preserve your context window for implementation. **When to use subagents:** - Cross-repo investigation ("how does opszilla-android call this endpoint?") - Heavy codebase research before implementation (reading many files you won't need later) - Parallel tasks that don't depend on each other - When your context is getting heavy and you need to offload research **When NOT to use subagents:** - Simple, directed lookups (one file, one function, one grep) - Tasks that require your brain context, persona, or memory - Posting comments or external communication (only you can do that) **How to use them effectively:** - Be specific in your query — tell the subagent exactly what to find and where to look - Include relevant file paths and repo locations in the query - Use `relevant_context` to pass information the subagent needs - You can specify `agent_name` to use a specialized agent (e.g., "robin" for Android research) - Run `ListAgents` first if you want to see available specialized agents - Up to 4 subagents can run in parallel - To discover project locations for cross-repo work, run: `brainiac list` **Limitations:** Subagents don't get your brain context, persona, or memory. They can read files and run commands, but cannot post to Discord, GitHub, or other channels. They're excellent researchers — use them as such. ## Image Reading Limits Read at most 4-5 images per tool call. Summarize what you saw before reading more. Loading too many images at once can exceed the API request size limit and crash your session. PROMPT
- PROMPT_REFLECTION =
PROMPT_REFLECTION — appended AFTER the situation template so the agent sees its task first and reflects only after completing it.
<<~PROMPT ## Post-Session Reflection (after posting your response and updating memory) ### Step 1: Check persona relevance `qmd search "{{COMMENT_CREATOR}}" -c {{PERSONA_COLLECTION}}` If no results, this might be someone new worth noting. ### Step 2: Decide what to update Consider the interaction and ask: **Persona** — Did the user give feedback (explicit or implicit) on your tone or style? Is this someone new? Did they seem frustrated or pleased? Update persona files if so. Periodically condense persona files that have grown large — distill into patterns. **Knowledge** — High bar. Only save if: - User explicitly asked you to remember something - A significant architecture decision or convention was established - You discovered a non-obvious gotcha - A major workflow changed **Skills** — Did this session involve a multi-step procedure (5+ tool calls) that you or another agent might repeat? If so, save it at `{{KNOWLEDGE_DIR}}/skills/<name>/SKILL.md` with YAML frontmatter (name, description, tags). ### Step 3: Update the brain or move on Write/update relevant files if needed. If nothing warrants saving, move on. PROMPT
- CHANNEL_PROMPTS =
Channel constant mapping for render_prompt NOTE: GitHub prompts have been extracted to brainiac-github plugin. The plugin registers via Brainiac.register_channel_prompt(:github, ...)
{}.freeze
- BRAINIAC_RESTART_STATE =
Brainiac self-restart logic.
When an agent works on brainiac itself (modifies code), a restart is queued. A background thread checks every 30s and only restarts when no other agents are running, preventing mid-session kills.
{ queued: false, triggered_by: nil }
- BRAINIAC_RESTART_MUTEX =
Mutex.new
- PROCESSED_EVENTS =
--- Deduplication ---
{}
- PROCESSED_EVENTS_MAX =
1000- ACTIVE_SESSIONS =
--- Active sessions ---
{}
- ACTIVE_SESSIONS_MUTEX =
Mutex.new
- RECENT_SESSIONS =
[]
- RECENT_SESSIONS_MAX =
10- SELF_MOVES =
--- Self-move tracking (suppress webhook echoes from our own column moves) ---
{}
- SELF_MOVES_MUTEX =
Mutex.new
- SESSION_WAIT_INTERVAL =
15- SESSION_WAIT_MAX =
600- SUPERSEDE_WINDOW =
--- Session supersede (Channel follow-up within window kills previous run) ---
60- COMMENT_COOLDOWN =
--- Comment cooldown ---
60- LAST_COMMENT_TIMES =
{}
- DEPLOY_COOLDOWN =
--- Deploy cooldown (debounce rapid PR pushes) ---
30- LAST_DEPLOY_TIMES =
{}
- AGENT_DISPATCH_DEPTH =
--- Agent dispatch depth (loop prevention) ---
{}
- AGENT_DISPATCH_MAX_DEPTH =
10- AGENT_DISPATCH_WINDOW =
3600- NOTIFICATIONS_CONFIG_KEY =
Generic notification system for Brainiac.
Provides a channel-agnostic way to send messages. Plugins register as notification providers for their channel. Core code emits notifications without knowing which plugin will deliver them.
Configuration in ~/.brainiac/brainiac.json:
"notifications": { "deploy": { "channel": "discord", "target": "channel-id-123" }, "cron": { "channel": "discord", "target": "channel-id-456" }, "restart": { "channel": "discord", "target": "channel-id-789" } }Plugins register handlers:
Brainiac.on(:notify) do |ctx| if ctx[:channel].to_s == "discord" send_to_discord(ctx[:target], ctx[:message], agent: ctx[:agent]) end end "notifications"- REPO_LAST_FETCH =
Debounced repo git fetch — avoids fetching the same repo multiple times within a short window.
{}
- REPO_FETCH_DEBOUNCE =
5 minutes
300
Instance Method Summary collapse
-
#add_cron_job(id:, schedule:, agent:, project:, prompt: nil, script: nil, enabled: true, model: nil, effort: nil, notify_channel: nil, notify_target: nil, forum_title: nil, forum_reply_to_latest: false, repeat_count: nil) ⇒ Object
Add a new cron job.
-
#agent_cli_provider_for(agent_name) ⇒ Object
Get the cli_provider configured at the agent level in agents.json.
- #agent_dispatch_allowed?(card_internal_id) ⇒ Boolean
-
#agent_display_name(agent_name) ⇒ Object
Get the display name for an agent (from agents.json registry).
-
#agent_env_for(agent_name) ⇒ Object
Get the env hash for an agent.
-
#agent_env_var(agent_name, var_name) ⇒ Object
Get a specific env var for an agent.
- #agent_name_for(project_config) ⇒ Object
-
#agent_roles_for(agent_name) ⇒ Object
Get the role name(s) configured for an agent in agents.json.
- #agent_roster ⇒ Object
-
#ai_agents ⇒ Object
Get all AI agents.
- #all_agent_names ⇒ Object
- #already_processed?(event_id) ⇒ Boolean
- #any_agents_running? ⇒ Boolean
-
#apply_worktree_includes(repo_path, worktree_path) ⇒ Object
Copy gitignored files matching .worktreeinclude patterns from repo to worktree.
-
#archive_session(card_key, info) ⇒ Object
Archive a completed session for menu bar display.
- #archive_skill(skill_path) ⇒ Object
-
#auto_inject_skills(search_context) ⇒ Object
Semantically match skills against the current task context and auto-inject their full content.
-
#brain_git_repo? ⇒ Boolean
--- Brain git sync ---.
-
#brain_pull(force: false) ⇒ Object
Pull latest brain changes.
-
#brain_pull_internal(force: false) ⇒ Object
Internal pull logic without mutex (for use inside synchronized blocks).
-
#brain_push(message: "brain update", retries: 3) ⇒ Object
Commit and push any brain changes.
-
#build_agent_cmd(resolved, agent_config_name: nil, model: nil, effort: nil, prompt_file: nil, resume: false) ⇒ Object
Build the CLI command array for an agent invocation.
- #build_brain_context(agent_name: AI_AGENT_NAME, card_title: "", card_number: nil, project_key: nil, comment_body: "", source: nil) ⇒ Object
-
#build_cron_agent_cmd(job, project, prompt_file: nil) ⇒ Object
Build the CLI command array for a cron agent invocation.
-
#build_cron_prompt(job, project) ⇒ Object
Build the prompt content and meta files for a cron job.
-
#build_proc_children_map ⇒ Object
Build a ppid→children map from /proc in one pass.
- #build_role_section(agent_name) ⇒ Object
-
#build_skill_index ⇒ Object
Build a compact skill index for prompt injection.
-
#canonical_name_for(identifier) ⇒ Object
Get canonical name for a platform-specific identifier.
-
#capture_git_state(chdir) ⇒ Object
Capture git HEAD and working tree status for a directory.
- #check_brainiac_restart(head_before, status_before, chdir, project_key_for_restart, agent_config_name) ⇒ Object
-
#check_brainiac_version ⇒ Object
Check if local brainiac is behind origin/master.
-
#child_processes_for(pid) ⇒ Object
Recursively collect all descendant processes of a given PID via /proc.
-
#cleanup_work_item_worktrees(card_number, repo_path:, primary_worktree: nil, primary_branch: nil) ⇒ Object
Clean up all worktrees associated with a work item (primary + cross-agent review).
- #comment_from_agent?(name) ⇒ Boolean
-
#convert_meridiem_hour(hour, meridiem) ⇒ Object
Convert 12-hour format hour + meridiem to 24-hour format.
-
#create_or_reuse_worktree(repo_path:, branch:, base_ref: nil, worktree_path: nil) ⇒ Object
Create or reuse a git worktree for a given branch.
-
#cron_loop ⇒ Object
Cron loop — runs every minute, checks all jobs.
-
#cron_matches?(cron_hash, time = Time.now) ⇒ Boolean
Check if current time matches cron expression.
-
#curate_skills ⇒ Object
Run the curator: archive stale skills, log consolidation candidates.
- #debounced_repo_fetch(repo_path) ⇒ Object
- #default_project_key ⇒ Object
-
#deliver_script_output(job, log_file, draft_file) ⇒ Object
Read script output and write to draft file or log.
-
#detect_cli_provider(text: "", tags: []) ⇒ Object
Detect CLI provider override from inline [cli:X] tag or card tags.
-
#detect_effort(project_config, tags: [], text: "") ⇒ Object
Detect effort level from inline tags [effort:high] or card tags (effort-high).
- #detect_mentioned_agent(text) ⇒ Object
- #detect_model(project_config, tags: [], text: "") ⇒ Object
-
#detect_skill_candidate(log_file) ⇒ Object
Analyze an agent session log to determine if a skill should be extracted.
- #discover_kiro_agents ⇒ Object
-
#execute_cron_job(job) ⇒ Object
Execute a cron job (dispatch agent).
- #execute_restart ⇒ Object
-
#execute_script_job(job, project) ⇒ Object
Execute a script-based cron job (no agent, direct script execution).
-
#extract_crash_snippet(log_file, max_lines: 20) ⇒ Object
Extract the last N meaningful lines from an agent log for crash reporting.
-
#extract_cron_response_from_log(job, agent_config_name, log_file, response_file, meta_file) ⇒ Object
Extract agent response from log if the response file wasn't written directly.
- #extract_topics(card_title, comment_body, project_key) ⇒ Object
- #file_changed?(path, force: false) ⇒ Boolean
-
#find_supersedable_session(supersede_key) ⇒ Object
Find an active session for the same supersede key (agent+channel) started within the window.
-
#find_user(identifier) ⇒ Object
Find user by any identifier (tries all platforms).
-
#find_user_by_canonical_name(name) ⇒ Object
Find user by canonical name.
-
#find_user_by_discord_id(user_id) ⇒ Object
Find user by Discord user ID.
-
#find_user_by_discord_username(username) ⇒ Object
Find user by Discord username.
-
#find_user_by_github_username(username) ⇒ Object
Find user by GitHub username.
-
#find_work_item_by_branch(branch) ⇒ Object
Find a work item by its branch name.
-
#find_work_item_by_card(card_internal_id) ⇒ Object
Find a work item by a Fizzy card internal ID (for backward compat with Fizzy plugin).
-
#find_work_item_by_id(work_item_id) ⇒ Object
Find a work item by its ID.
- #format_active_sessions ⇒ Object
- #format_recent_sessions ⇒ Object
-
#generate_work_item_id(branch: nil, card_number: nil) ⇒ Object
Generate a deterministic work item ID from available identifiers.
- #get_default_branch(repo_path) ⇒ Object
- #handle_agent_completion(**ctx) ⇒ Object
-
#handle_cron_completion(job, project, agent_name, agent_config_name, log_file, response_file, meta_file) ⇒ Object
Handle post-execution: extract response from log, update job state.
-
#human_users ⇒ Object
Get all human users (exclude AI agents).
- #identify_project_by_repo(repo_full_name) ⇒ Object
-
#install_plugin(name, version: nil) ⇒ Object
Install a plugin gem and register it in plugins.json.
-
#installed_plugins ⇒ Object
Returns the list of installed plugin names (e.g. ["whatsapp", "slack"]).
- #kill_child_process(target_pid) ⇒ Object
-
#kill_session(session_key) ⇒ Object
Kill a session's process.
-
#load_agent_registry ⇒ Object
Agent registry, discovery, identity, mention detection, and env injection.
- #load_brainiac_config ⇒ Object
-
#load_cli_provider(provider_name) ⇒ Object
Load a CLI provider config from ~/.brainiac/cli-providers/
.json. -
#load_cron_jobs ⇒ Object
Load cron jobs from config.
-
#load_plugins!(app) ⇒ Object
Load all installed plugin gems and call their register hooks.
- #load_plugins_config ⇒ Object
-
#load_projects_config ⇒ Object
--- Projects ---.
-
#load_role(role_name) ⇒ Object
Load a role definition from ~/.brainiac/roles/
.md. - #load_user_registry ⇒ Object
- #load_work_item_map ⇒ Object
-
#local_agent_names ⇒ Object
Agents marked "local": true in the registry — only these should pick up card assignments on this machine.
- #mark_work_item_merged(card_number) ⇒ Object
- #match_field?(pattern, value) ⇒ Boolean
-
#match_skills_semantically(search_context, skills) ⇒ Object
Use qmd semantic search to find skills whose descriptions match the current context.
- #memory_dir_for(agent_name) ⇒ Object
-
#migrate_work_item_map(raw) ⇒ Object
Migrate old-format work item maps (keyed by Fizzy card internal ID with flat structure) to the new source-agnostic format (keyed by work item ID with sources hash).
-
#notification_config_for(event) ⇒ Object
Get the notification config for a specific event type.
-
#notify_agent_crash(exit_status:, log_file:, agent_name:, source:, source_context:, project_config:) ⇒ Object
Notify the originating channel that an agent crashed.
-
#notify_cron_output(job, message, agent_name: nil) ⇒ Object
Convenience: send a notification for cron job output.
-
#notify_deploy(project_key, message) ⇒ Object
Convenience: send a deploy notification.
-
#notify_restart(message) ⇒ Object
Convenience: send a restart notification.
- #notify_unauthorized(action, creator_name, card_info) ⇒ Object
- #on_comment_cooldown?(card_key) ⇒ Boolean
- #on_deploy_cooldown?(env_key) ⇒ Boolean
-
#owner_id ⇒ Object
Owner identifier (for version-outdated notifications).
-
#parse_cron_expression(expr) ⇒ Object
Parse cron expression (simplified: supports minute, hour, day, month, weekday) Format: "minute hour day month weekday" (e.g., "0 9 * * 1-5" = 9am weekdays) Also supports special strings: @hourly, @daily, @weekly, @monthly Also supports one-time timestamps: ISO8601 format (e.g., "2026-02-27T09:00:00-05:00") Also supports natural language: "tomorrow at 9am", "in 2 hours", "next monday at 3pm".
- #parse_flag_tags(result) ⇒ Object
-
#parse_inline_tags(text) ⇒ Object
Parse inline tags from message text.
- #parse_model_tag(result) ⇒ Object
-
#parse_natural_time(expr) ⇒ Object
Parse natural language time expressions into absolute timestamps.
-
#parse_skill_frontmatter(path) ⇒ Object
Parse YAML frontmatter from a SKILL.md file.
-
#parse_time_of_day(time_str, date) ⇒ Object
Parse time of day (e.g., "9am", "3:30pm", "14:00") and combine with a date.
- #parse_value_tags(result) ⇒ Object
- #parse_work_item_tags(result) ⇒ Object
- #persona_collection_for(agent_name) ⇒ Object
- #persona_dir_for(agent_name) ⇒ Object
-
#plugin_entry(name) ⇒ Object
Returns the full plugin entry (Hash) for a given name, or nil.
-
#prepare_script_notification_draft(job, timestamp) ⇒ Object
Prepare a notification draft file for a script job.
-
#prior_session_exists?(chdir, agent_cli) ⇒ Boolean
Check if a prior CLI session exists in the given directory for the specified CLI binary.
- #query_brain(search_terms, agent_name: AI_AGENT_NAME, scope: :knowledge, max_results: 5) ⇒ Object
- #queue_brainiac_restart(agent_name) ⇒ Object
-
#read_proc_cmdline(pid) ⇒ Object
Read command line for a given PID from /proc.
-
#read_proc_elapsed(pid) ⇒ Object
Calculate elapsed seconds since process start from /proc/stat.
-
#reap_dead_sessions ⇒ Object
--- Session Helpers ---.
- #recently_completed?(card_key, window: 120) ⇒ Boolean
- #record_agent_dispatch(card_internal_id) ⇒ Object
- #record_human_comment(card_internal_id) ⇒ Object
- #record_self_move(card_number) ⇒ Object
-
#record_skill_index_views ⇒ Object
Batch-record views for all skills in the index (called when prompt is built).
-
#record_skill_usage(skill_path, type: :view) ⇒ Object
Record a view (skill index shown in prompt) or use (agent read the full skill).
- #register_session(card_key, pid, log_file: nil, message_id: nil, channel_id: nil, supersede_key: nil, draft_files: nil, agent_name: nil) ⇒ Object
-
#register_work_item(branch:, worktree: nil, project: nil, agent: nil, source: nil, source_data: {}) ⇒ Object
Register a new work item or update an existing one.
-
#register_work_item_source(source:, source_data:, work_item_id: nil, branch: nil) ⇒ Object
Add or update a source on an existing work item.
- #reload_agent_registry!(force: false) ⇒ Object
-
#reload_cron_jobs!(force: false) ⇒ Object
Reload cron jobs from disk.
- #reload_projects!(force: false) ⇒ Object
- #reload_user_registry!(force: false) ⇒ Object
-
#remove_cron_job(id) ⇒ Object
Remove a cron job.
-
#render_prompt(template, vars = {}, brain_context: "", card_context: "", agent_name: AI_AGENT_NAME, channel: nil, board_key: nil) ⇒ Object
rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity.
-
#render_resume_prompt(comment_body:, comment_creator:, comment_id:, card_number: nil, agent_name: AI_AGENT_NAME) ⇒ Object
Lean prompt for resumed sessions.
-
#resolve_effort_level(level, allowed) ⇒ Object
If a level isn't in allowed_efforts, return the closest lower level.
-
#resolve_plugin_module(name) ⇒ Object
Resolve the plugin module for a given name.
-
#resolve_project_cli_config(project_config, cli_provider_override: nil, agent_name: nil) ⇒ Object
Resolve CLI config for a project by merging provider defaults with project overrides.
-
#resolve_resume(resume, resolved, chdir) ⇒ Object
Determine whether a session resume should actually happen.
- #resolve_session_agent_name(_card_key, info) ⇒ Object
-
#resume_viable?(project_config:, cli_provider: nil, agent_name: nil, chdir: nil) ⇒ Boolean
Public helper: check if resume is viable for a given project + CLI provider combo.
- #run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil, effort: nil, agent_name: nil, card_number: nil, comment_id: nil, source: nil, source_context: {}, skip_column_move: false, cli_provider: nil, resume: false) ⇒ Object
- #run_cmd(*cmd, chdir:, env: {}) ⇒ Object
-
#run_project_hook(repo_path, hook_name, extra_env: {}) ⇒ Object
Run a project-level hook script from .brainiac/<hook_name> if it exists.
-
#save_cron_jobs(jobs) ⇒ Object
Save cron jobs to config.
- #save_plugins_config(config) ⇒ Object
- #save_work_item_map(map) ⇒ Object
- #self_move_recent?(card_number, window: 120) ⇒ Boolean
-
#send_notification(event, message, target: nil, channel: nil, agent: nil, **metadata) ⇒ Object
Send a notification via the configured channel.
-
#send_restart_notification(message) ⇒ Object
Send a notification about brainiac restart/startup.
- #session_active?(card_key) ⇒ Boolean
-
#skill_index_for_prompt ⇒ Object
Build the skill index section for prompt injection.
- #skill_usage_path(skill_path) ⇒ Object
- #slugify(title, max_length: 40) ⇒ Object
- #start_brainiac_restart_monitor ⇒ Object
-
#start_cron_thread ⇒ Object
Start cron background thread.
-
#toggle_cron_job(id, enabled) ⇒ Object
Enable/disable a cron job.
- #touch_comment_cooldown(card_key) ⇒ Object
- #touch_deploy_cooldown(env_key) ⇒ Object
-
#trust_version_manager(path, chdir:) ⇒ Object
Trust the version manager config in a directory (supports mise and asdf).
-
#uninstall_plugin(name) ⇒ Object
Uninstall a plugin gem and remove from plugins.json.
-
#update_cron_job(id, schedule: nil, notify_target: nil, notify_channel: nil, forum_title: nil, forum_reply_to_latest: nil) ⇒ Object
Update a cron job's schedule, notification target, and/or forum title.
-
#update_cron_job_state(job) ⇒ Object
Update cron job state after execution (last_run, execution_count, auto-disable).
- #wait_for_session?(card_key) ⇒ Boolean
- #work_item_merged?(card_number) ⇒ Boolean
-
#write_agent_prompt_file(prompt, log_name, timestamp) ⇒ Object
Write agent prompt to a temp file, return path.
-
#write_cron_prompt_file(job, prompt_content, timestamp) ⇒ Object
Write cron prompt to a temp file, return path.
Instance Method Details
#add_cron_job(id:, schedule:, agent:, project:, prompt: nil, script: nil, enabled: true, model: nil, effort: nil, notify_channel: nil, notify_target: nil, forum_title: nil, forum_reply_to_latest: false, repeat_count: nil) ⇒ Object
Add a new cron job
202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 |
# File 'lib/brainiac/cron.rb', line 202 def add_cron_job(id:, schedule:, agent:, project:, prompt: nil, script: nil, enabled: true, model: nil, effort: nil, notify_channel: nil, notify_target: nil, forum_title: nil, forum_reply_to_latest: false, repeat_count: nil) parsed = parse_cron_expression(schedule) return { error: "Invalid cron expression" } unless parsed return { error: "Must provide either prompt or script, not both" } if prompt && script return { error: "Must provide either prompt or script" } unless prompt || script return { error: "notify_target requires notify_channel (e.g. -c discord)" } if notify_target && !notify_channel job = { id: id, schedule: schedule, parsed: parsed, agent: agent, project: project, model: model, effort: effort, prompt: prompt, script: script, enabled: enabled, notify_channel: notify_channel&.to_s, notify_target: notify_target, forum_title: forum_title, forum_reply_to_latest: forum_reply_to_latest, repeat_count: repeat_count, execution_count: 0, created_at: Time.now.iso8601, last_run: nil } CRON_JOBS_MUTEX.synchronize do jobs = load_cron_jobs jobs[id.to_sym] = job save_cron_jobs(jobs) CRON_JOBS[id.to_sym] = job end { success: true, job: job } end |
#agent_cli_provider_for(agent_name) ⇒ Object
Get the cli_provider configured at the agent level in agents.json.
63 64 65 66 67 68 69 70 71 |
# File 'lib/brainiac/helpers.rb', line 63 def agent_cli_provider_for(agent_name) return nil unless agent_name key = agent_name.downcase.gsub(/[^a-z0-9-]/, "-") entry = AGENT_REGISTRY[key] return nil unless entry.is_a?(Hash) entry["cli_provider"] end |
#agent_dispatch_allowed?(card_internal_id) ⇒ Boolean
266 267 268 269 270 271 272 |
# File 'lib/brainiac/sessions.rb', line 266 def agent_dispatch_allowed?(card_internal_id) info = AGENT_DISPATCH_DEPTH[card_internal_id] return false unless info return false if (Time.now - info[:last_human_at]) > AGENT_DISPATCH_WINDOW info[:count] < AGENT_DISPATCH_MAX_DEPTH end |
#agent_display_name(agent_name) ⇒ Object
Get the display name for an agent (from agents.json registry). This is the human-readable canonical name (e.g., "Robin" not "robin"). Core owns this — it's the identity used across all channels and plugins.
76 77 78 79 80 81 82 83 84 |
# File 'lib/brainiac/agents.rb', line 76 def agent_display_name(agent_name) return agent_name unless agent_name key = agent_name.downcase.gsub(/[^a-z0-9-]/, "-") entry = AGENT_REGISTRY[key] return agent_name unless entry.is_a?(Hash) entry["display_name"] || agent_name end |
#agent_env_for(agent_name) ⇒ Object
Get the env hash for an agent. Returns {} if none configured.
58 59 60 61 62 63 64 65 66 |
# File 'lib/brainiac/agents.rb', line 58 def agent_env_for(agent_name) return {} unless agent_name key = agent_name.downcase.gsub(/[^a-z0-9-]/, "-") entry = AGENT_REGISTRY[key] return {} unless entry.is_a?(Hash) entry["env"] || {} end |
#agent_env_var(agent_name, var_name) ⇒ Object
Get a specific env var for an agent. Returns nil if not set.
69 70 71 |
# File 'lib/brainiac/agents.rb', line 69 def agent_env_var(agent_name, var_name) agent_env_for(agent_name)[var_name] end |
#agent_name_for(project_config) ⇒ Object
135 136 137 |
# File 'lib/brainiac/agents.rb', line 135 def agent_name_for(project_config) project_config["agent_name"] || AI_AGENT_NAME end |
#agent_roles_for(agent_name) ⇒ Object
Get the role name(s) configured for an agent in agents.json. Returns an array of role names (may be empty).
88 89 90 91 92 93 94 95 96 97 98 99 100 101 |
# File 'lib/brainiac/agents.rb', line 88 def agent_roles_for(agent_name) return [] unless agent_name key = agent_name.downcase.gsub(/[^a-z0-9-]/, "-") entry = AGENT_REGISTRY[key] return [] unless entry.is_a?(Hash) roles = entry["role"] case roles when Array then roles when String then [roles] else [] end end |
#agent_roster ⇒ Object
120 121 122 123 124 |
# File 'lib/brainiac/agents.rb', line 120 def agent_roster roster = {} all_agent_names.each { |name| roster[name.downcase] = agent_display_name(name) } roster end |
#ai_agents ⇒ Object
Get all AI agents
68 69 70 |
# File 'lib/brainiac/users.rb', line 68 def ai_agents USER_REGISTRY["users"].select { |u| u["notes"]&.include?("AI agent") } end |
#all_agent_names ⇒ Object
139 140 141 142 143 144 145 146 147 148 |
# File 'lib/brainiac/agents.rb', line 139 def all_agent_names names = Set.new([AI_AGENT_NAME]) PROJECTS.each_value { |config| names << config["agent_name"] if config["agent_name"] } discover_kiro_agents.each { |name| names << name.capitalize } # Include agents from the registry AGENT_REGISTRY.each do |key, entry| names << (entry["display_name"] || key.capitalize) end names end |
#already_processed?(event_id) ⇒ Boolean
10 11 12 13 14 15 16 17 18 19 20 |
# File 'lib/brainiac/sessions.rb', line 10 def already_processed?(event_id) return false unless event_id return true if PROCESSED_EVENTS[event_id] PROCESSED_EVENTS[event_id] = Time.now if PROCESSED_EVENTS.size > PROCESSED_EVENTS_MAX oldest = PROCESSED_EVENTS.keys.first(PROCESSED_EVENTS.size - PROCESSED_EVENTS_MAX) oldest.each { |k| PROCESSED_EVENTS.delete(k) } end false end |
#any_agents_running? ⇒ Boolean
29 30 31 32 33 34 35 36 37 38 |
# File 'lib/brainiac/restart.rb', line 29 def any_agents_running? ACTIVE_SESSIONS_MUTEX.synchronize do ACTIVE_SESSIONS.any? do |_key, info| Process.kill(0, info[:pid]) true rescue Errno::ESRCH, Errno::EPERM false end end end |
#apply_worktree_includes(repo_path, worktree_path) ⇒ Object
Copy gitignored files matching .worktreeinclude patterns from repo to worktree. Symlink directories matching .worktreelink patterns instead of copying.
56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 |
# File 'lib/brainiac/handlers/shared/git.rb', line 56 def apply_worktree_includes(repo_path, worktree_path) copied = 0 linked = 0 [".worktreeinclude", ".worktreelink"].each do |filename| config_file = File.join(repo_path, filename) next unless File.exist?(config_file) symlink_mode = filename == ".worktreelink" patterns = File.readlines(config_file).map(&:strip).reject { |l| l.empty? || l.start_with?("#") } next if patterns.empty? patterns.each do |pattern| Dir.glob(pattern, File::FNM_DOTMATCH, base: repo_path).each do |match| src = File.join(repo_path, match) dest = File.join(worktree_path, match) next if File.exist?(dest) || File.symlink?(dest) _, _, st = Open3.capture3("git", "check-ignore", "-q", match, chdir: repo_path) next unless st.success? FileUtils.mkdir_p(File.dirname(dest)) if symlink_mode && File.directory?(src) FileUtils.ln_s(src, dest) linked += 1 LOG.info "Symlinked #{match} from main repo" elsif File.file?(src) FileUtils.cp(src, dest) copied += 1 end end end end LOG.info "Worktree include: copied #{copied} file(s), symlinked #{linked} dir(s) for #{worktree_path}" if copied.positive? || linked.positive? end |
#archive_session(card_key, info) ⇒ Object
Archive a completed session for menu bar display. Call inside ACTIVE_SESSIONS_MUTEX.
46 47 48 49 50 51 52 53 |
# File 'lib/brainiac/sessions.rb', line 46 def archive_session(card_key, info) RECENT_SESSIONS.unshift({ card_key: card_key, agent_name: info[:agent_name], log_file: info[:log_file], started_at: info[:started_at], finished_at: Time.now }) RECENT_SESSIONS.pop while RECENT_SESSIONS.size > RECENT_SESSIONS_MAX end |
#archive_skill(skill_path) ⇒ Object
264 265 266 267 268 269 270 271 272 273 274 275 276 |
# File 'lib/brainiac/skills.rb', line 264 def archive_skill(skill_path) skill_dir = File.dirname(skill_path) skill_name = File.basename(skill_dir) archive_dest = File.join(SKILL_ARCHIVE_DIR, skill_name) FileUtils.mkdir_p(archive_dest) FileUtils.mv(Dir.glob(File.join(skill_dir, "*")), archive_dest) FileUtils.rmdir(skill_dir) if Dir.empty?(skill_dir) LOG.info "[Curator] Archived skill: #{skill_name}" rescue StandardError => e LOG.warn "[Curator] Failed to archive #{skill_path}: #{e.}" end |
#auto_inject_skills(search_context) ⇒ Object
Semantically match skills against the current task context and auto-inject their full content. This is the skill:// equivalent — skills are loaded automatically when relevant, not manually. Returns a prompt section with full skill content for top matches, plus an index of remaining skills.
103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 |
# File 'lib/brainiac/skills.rb', line 103 def auto_inject_skills(search_context) skills = build_skill_index return "" if skills.empty? return "" unless system("which qmd > /dev/null 2>&1") # Semantic search against skill descriptions to find relevant ones matched_paths = match_skills_semantically(search_context, skills) # Split into auto-injected (matched) and index-only (rest) injected = [] chars_used = 0 matched_paths.each do |path| content = File.read(path) break if chars_used + content.size > SKILL_AUTO_INJECT_MAX_CHARS skill = skills.find { |s| s[:path] == path } next unless skill injected << { name: skill[:name], description: skill[:description], content: content, path: path } chars_used += content.size end remaining = skills.reject { |s| injected.any? { |i| i[:path] == s[:path] } } sections = [] unless injected.empty? sections << "## Auto-Loaded Skills (matched to your current task)" sections << "These skills were automatically loaded because they're relevant to what you're working on.\n" injected.each do |skill| sections << "### Skill: #{skill[:name]}" sections << skill[:content] sections << "" record_skill_usage(skill[:path], type: :use) end end unless remaining.empty? sections << "## Other Available Skills" sections << "Additional skills not auto-loaded. Read the file if needed.\n" remaining.each { |s| sections << "- **#{s[:name]}**: #{s[:description]} (`#{s[:path]}`)" } sections << "" end sections.join("\n") end |
#brain_git_repo? ⇒ Boolean
--- Brain git sync ---
22 23 24 |
# File 'lib/brainiac/brain.rb', line 22 def brain_git_repo? File.directory?(File.join(BRAIN_BASE_DIR, ".git")) end |
#brain_pull(force: false) ⇒ Object
Pull latest brain changes. Safe to call frequently — skips if pulled recently. Uses rebase to keep history clean and auto-resolves conflicts by keeping both sides.
61 62 63 64 65 66 67 68 69 |
# File 'lib/brainiac/brain.rb', line 61 def brain_pull(force: false) return unless brain_git_repo? BRAIN_SYNC_MUTEX.synchronize do brain_pull_internal(force: force) end rescue StandardError => e LOG.warn "[Brain] Pull error: #{e.}" end |
#brain_pull_internal(force: false) ⇒ Object
Internal pull logic without mutex (for use inside synchronized blocks)
27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
# File 'lib/brainiac/brain.rb', line 27 def brain_pull_internal(force: false) return unless brain_git_repo? # Skip if we pulled within the last 30 seconds (avoid hammering on rapid-fire sessions) unless force last = BRAIN_LAST_PULL[:at] return if last && (Time.now - last) < 30 end # Stash any uncommitted changes, pull, then pop status, = Open3.capture2("git", "status", "--porcelain", chdir: BRAIN_BASE_DIR) has_changes = !status.strip.empty? if has_changes Open3.capture2("git", "add", "-A", chdir: BRAIN_BASE_DIR) Open3.capture2("git", "stash", chdir: BRAIN_BASE_DIR) end output, pull_status = Open3.capture2e("git", "pull", "--rebase", "--autostash", chdir: BRAIN_BASE_DIR) if pull_status.success? LOG.info "[Brain] Pulled latest changes" else LOG.warn "[Brain] Pull failed: #{output.strip}" # Abort rebase if it got stuck Open3.capture2("git", "rebase", "--abort", chdir: BRAIN_BASE_DIR) end Open3.capture2("git", "stash", "pop", chdir: BRAIN_BASE_DIR) if has_changes BRAIN_LAST_PULL[:at] = Time.now end |
#brain_push(message: "brain update", retries: 3) ⇒ Object
Commit and push any brain changes. Called after agent sessions complete.
72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 |
# File 'lib/brainiac/brain.rb', line 72 def brain_push(message: "brain update", retries: 3) return unless brain_git_repo? BRAIN_SYNC_MUTEX.synchronize do # Check for changes status, = Open3.capture2("git", "status", "--porcelain", chdir: BRAIN_BASE_DIR) return if status.strip.empty? Open3.capture2("git", "add", "-A", chdir: BRAIN_BASE_DIR) Open3.capture2("git", "commit", "-m", , chdir: BRAIN_BASE_DIR) retries.times do |attempt| brain_pull_internal(force: true) if attempt.positive? _, push_status = Open3.capture2e("git", "push", chdir: BRAIN_BASE_DIR) if push_status.success? LOG.info "[Brain] Pushed changes#{" (retry #{attempt})" if attempt.positive?}" break end sleep(2**attempt) if attempt < retries - 1 end LOG.warn "[Brain] Push failed after #{retries} attempts" end rescue StandardError => e LOG.warn "[Brain] Push error: #{e.}" end |
#build_agent_cmd(resolved, agent_config_name: nil, model: nil, effort: nil, prompt_file: nil, resume: false) ⇒ Object
Build the CLI command array for an agent invocation. When prompt_file is provided and prompt_mode is "flag", appends the prompt as a CLI argument. When resume is true and the provider has a resume_flag, adds it to continue the last session.
471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 |
# File 'lib/brainiac/helpers.rb', line 471 def build_agent_cmd(resolved, agent_config_name: nil, model: nil, effort: nil, prompt_file: nil, resume: false) cmd = [resolved["agent_cli"]] # agent_flag controls how the agent identity is passed. Defaults to "--agent". # Provider configs can set it to a different flag or null to suppress entirely. agent_flag = resolved.key?("agent_flag") ? resolved["agent_flag"] : "--agent" cmd.push(agent_flag, agent_config_name) if agent_flag && agent_config_name cmd.concat(resolved["agent_cli_args"].split) # Only pass --model if the model is a valid ID for this provider. # "auto" means "let the CLI choose" — skip passing it unless the provider explicitly maps it. if model && resolved["agent_model_flag"] && !resolved["agent_model_flag"].empty? allowed = resolved["allowed_models"] || {} # Pass the model if it's a mapped value (e.g. "claude-opus-4.6") or the key itself is mapped is_known = allowed.value?(model) || allowed.key?(model) cmd.push(resolved["agent_model_flag"], model) if is_known end cmd.push(resolved["agent_effort_flag"], effort) if resolved["agent_effort_flag"] && !resolved["agent_effort_flag"].empty? && effort # Resume the most recent session in the working directory (for multi-turn CLIs like grok) cmd.push(resolved["resume_flag"]) if resume && resolved["resume_flag"] # prompt_mode: "flag" passes the prompt file path via the configured prompt_flag (e.g. --prompt-file). cmd.push(resolved["prompt_flag"], prompt_file) if prompt_file && resolved["prompt_mode"] == "flag" && resolved["prompt_flag"] cmd end |
#build_brain_context(agent_name: AI_AGENT_NAME, card_title: "", card_number: nil, project_key: nil, comment_body: "", source: nil) ⇒ Object
158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 |
# File 'lib/brainiac/brain.rb', line 158 def build_brain_context(agent_name: AI_AGENT_NAME, card_title: "", card_number: nil, project_key: nil, comment_body: "", source: nil) Thread.new { brain_pull } topics = extract_topics(card_title, comment_body, project_key) primary_query = topics.first(5).join(" ") primary_query = "project conventions" if primary_query.empty? search_queries = [primary_query] knowledge_threads = [ Thread.new { query_brain(primary_query, scope: :knowledge, max_results: 3) }, Thread.new { query_brain(agent_name, scope: :knowledge, max_results: 2) } ] search_queries << agent_name # Plugin hook: source-specific brain queries (e.g., plugins inject source-specific knowledge) plugin_queries = Brainiac.emit(:build_brain_context, source: source, card_title: card_title, comment_body: comment_body) plugin_queries.flatten.compact.each do |query| knowledge_threads << Thread.new { query_brain(query, scope: :knowledge, max_results: 2) } search_queries << query end persona_thread = Thread.new { query_brain("personality tone voice communication style", agent_name: agent_name, scope: :persona, max_results: 5) } all_knowledge = knowledge_threads.map(&:value).reject(&:empty?) persona_result = persona_thread.value sections = [] # Role: CLI-agnostic agent role definition from ~/.brainiac/roles/ role_section = build_role_section(agent_name) sections << role_section if role_section unless persona_result.empty? sections << <<~PERSONA ## Brain — Persona (auto-retrieved, CRITICAL) The following is YOUR personality, communication style, and voice. You MUST use this to shape every response you write — tone, word choice, humor, attitude. This is who you ARE. Do not respond in a generic or neutral voice. #{persona_result} PERSONA end unless all_knowledge.empty? knowledge_text = all_knowledge.join("\n\n") sections << <<~BRAIN ## Brain — Knowledge (auto-retrieved for: #{search_queries.map { |q| %("#{q}") }.join(", ")}) The following is relevant technical knowledge from your long-term memory. These are project conventions, coding patterns, lessons learned, and decisions that past-you saved for exactly this kind of work. Use it to inform your implementation. If these results don't look relevant to your current task, search manually with better terms. #{knowledge_text} BRAIN end # Auto-inject skills: semantically match skills against current task context skill_search_context = [card_title, comment_body, primary_query].compact.reject(&:empty?).join(" ") skill_section = auto_inject_skills(skill_search_context) sections << skill_section unless skill_section.empty? sections.join("\n") end |
#build_cron_agent_cmd(job, project, prompt_file: nil) ⇒ Object
Build the CLI command array for a cron agent invocation.
591 592 593 594 595 596 597 598 599 600 601 602 |
# File 'lib/brainiac/cron.rb', line 591 def build_cron_agent_cmd(job, project, prompt_file: nil) agent_config_name = job[:agent].downcase.gsub(/[^a-z0-9-]/, "-") resolved = resolve_project_cli_config(project, agent_name: job[:agent]) agent_flag = resolved.key?("agent_flag") ? resolved["agent_flag"] : "--agent" cmd = [resolved["agent_cli"]] cmd.push(agent_flag, agent_config_name) if agent_flag cmd.concat(resolved["agent_cli_args"].split) cmd.push(resolved["agent_model_flag"], job[:model]) if resolved["agent_model_flag"]&.length&.positive? && job[:model] cmd.push(resolved["agent_effort_flag"], job[:effort]) if resolved["agent_effort_flag"]&.length&.positive? && job[:effort] cmd.push(resolved["prompt_flag"], prompt_file) if prompt_file && resolved["prompt_mode"] == "flag" && resolved["prompt_flag"] cmd end |
#build_cron_prompt(job, project) ⇒ Object
Build the prompt content and meta files for a cron job
377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 |
# File 'lib/brainiac/cron.rb', line 377 def build_cron_prompt(job, project) prompt = job[:prompt] agent_name = job[:agent] = Time.now.strftime("%Y%m%d-%H%M%S") has_notify = job[:notify_target] if has_notify notify_dir = File.join(BRAINIAC_DIR, "tmp", "notify", "draft") FileUtils.mkdir_p(notify_dir) draft_file = File.join(notify_dir, "cron-#{}-#{agent_name}-#{job[:id]}.md") = "#{draft_file}.meta.json" agent_key = agent_name.downcase.gsub(/[^a-z0-9-]/, "-") notify_channel = job[:notify_channel]&.to_s notify_target = job[:notify_target] = { notify_channel: notify_channel, notify_target: notify_target, channel_id: notify_target, agent_key: agent_key, agent_name: agent_name, cron_job_id: job[:id], forum_title: job[:forum_title], forum_reply_to_latest: job[:forum_reply_to_latest], created_at: Time.now.iso8601 } File.write(, JSON.pretty_generate()) full_prompt = <<~PROMPT ## Scheduled Task (Notification Posting) This is a scheduled cron job. Output will be posted to #{notify_channel} (#{notify_target}). You were asked to: "#{prompt}" Project: #{job[:project]} Source directory: #{project["repo_path"]} **IMPORTANT: Write your response to #{draft_file}. Do NOT reply via stdout.** Your response will be automatically posted. #{prompt} PROMPT { response_file: draft_file, meta_file: , full_prompt: full_prompt } else response_file = File.join(BRAINIAC_DIR, "tmp", "cron", "cron-#{job[:id]}-#{Time.now.to_i}.md") FileUtils.mkdir_p(File.dirname(response_file)) full_prompt = <<~PROMPT ## Scheduled Task This is a scheduled cron job. You were asked to: "#{prompt}" Project: #{job[:project]} Source directory: #{project["repo_path"]} Write your response to: #{response_file} #{prompt} PROMPT { response_file: response_file, meta_file: nil, full_prompt: full_prompt } end end |
#build_proc_children_map ⇒ Object
Build a ppid→children map from /proc in one pass.
124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 |
# File 'lib/brainiac/sessions.rb', line 124 def build_proc_children_map children_map = Hash.new { |h, k| h[k] = [] } Dir.glob("/proc/[0-9]*/stat").each do |stat_path| content = begin File.read(stat_path) rescue StandardError next end close_paren = content.rindex(")") next unless close_paren prefix_pid = content[0...content.index("(")].strip.to_i fields_after = content[(close_paren + 2)..].split ppid = fields_after[1].to_i children_map[ppid] << prefix_pid end children_map end |
#build_role_section(agent_name) ⇒ Object
137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 |
# File 'lib/brainiac/brain.rb', line 137 def build_role_section(agent_name) roles = agent_roles_for(agent_name) return nil if roles.empty? sections = roles.filter_map do |role_name| content = load_role(role_name) next unless content "### #{role_name}\n\n#{content}" end return nil if sections.empty? <<~ROLE ## Role#{"s" if roles.size > 1} (#{roles.join(", ")}) The following defines your role and responsibilities for this session. Follow these instructions for how you approach work. #{sections.join("\n\n")} ROLE end |
#build_skill_index ⇒ Object
Build a compact skill index for prompt injection. Returns array of { name:, description:, path: } from all SKILL.md files.
51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
# File 'lib/brainiac/skills.rb', line 51 def build_skill_index skills = [] Dir.glob(File.join(SKILLS_DIR, "**", "SKILL.md")).each do |path| frontmatter = parse_skill_frontmatter(path) next unless frontmatter skills << { name: frontmatter["name"], description: frontmatter["description"], tags: frontmatter["tags"] || [], path: path } end skills end |
#canonical_name_for(identifier) ⇒ Object
Get canonical name for a platform-specific identifier
57 58 59 60 |
# File 'lib/brainiac/users.rb', line 57 def canonical_name_for(identifier) user = find_user(identifier) user ? user["canonical_name"] : identifier end |
#capture_git_state(chdir) ⇒ Object
Capture git HEAD and working tree status for a directory.
33 34 35 36 37 38 39 |
# File 'lib/brainiac/handlers/shared/git.rb', line 33 def capture_git_state(chdir) head, = Open3.capture2("git", "rev-parse", "HEAD", chdir: chdir) status, = Open3.capture2("git", "status", "--porcelain", chdir: chdir) [head.strip, status.strip] rescue StandardError [nil, nil] end |
#check_brainiac_restart(head_before, status_before, chdir, project_key_for_restart, agent_config_name) ⇒ Object
538 539 540 541 542 543 544 545 546 547 |
# File 'lib/brainiac/helpers.rb', line 538 def check_brainiac_restart(head_before, status_before, chdir, project_key_for_restart, agent_config_name) return unless project_key_for_restart == "brainiac" && head_before head_after, status_after = capture_git_state(chdir) if head_after != head_before || status_after != (status_before || "") queue_brainiac_restart(agent_config_name || "agent") else LOG.info "[Brainiac] #{agent_config_name || "agent"} session on brainiac had no changes — skipping restart" end end |
#check_brainiac_version ⇒ Object
Check if local brainiac is behind origin/master. Returns { behind: true, local_sha:, remote_sha:, commits_behind: } or { behind: false }
144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 |
# File 'lib/brainiac/config.rb', line 144 def check_brainiac_version brainiac_dir = File.join(__dir__, "..", "..") # Fetch latest from origin (quiet, don't fail if offline) _, _, status = Open3.capture3("git", "fetch", "origin", "master", "--quiet", chdir: brainiac_dir) unless status.success? LOG.warn "[Version] Could not fetch origin/master — skipping version check" return { behind: false } end local_sha, = Open3.capture3("git", "rev-parse", "HEAD", chdir: brainiac_dir) remote_sha, = Open3.capture3("git", "rev-parse", "origin/master", chdir: brainiac_dir) local_sha = local_sha.strip remote_sha = remote_sha.strip return { behind: false } if local_sha == remote_sha count, = Open3.capture3("git", "rev-list", "--count", "HEAD..origin/master", chdir: brainiac_dir) { behind: true, local_sha: local_sha[0..6], remote_sha: remote_sha[0..6], commits_behind: count.strip.to_i } end |
#child_processes_for(pid) ⇒ Object
Recursively collect all descendant processes of a given PID via /proc. Returns array of hashes: { pid:, ppid:, cmd:, elapsed_seconds: }
104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 |
# File 'lib/brainiac/sessions.rb', line 104 def child_processes_for(pid) children_map = build_proc_children_map descendants = [] queue = [pid] while (current = queue.shift) (children_map[current] || []).each do |child_pid| queue << child_pid cmdline = read_proc_cmdline(child_pid) elapsed = read_proc_elapsed(child_pid) descendants << { pid: child_pid, ppid: current, cmd: cmdline, elapsed_seconds: elapsed } end end descendants rescue StandardError => e LOG.warn "Failed to enumerate child processes for PID #{pid}: #{e.}" [] end |
#cleanup_work_item_worktrees(card_number, repo_path:, primary_worktree: nil, primary_branch: nil) ⇒ Object
Clean up all worktrees associated with a work item (primary + cross-agent review). Safe: skips worktrees with uncommitted changes.
161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 |
# File 'lib/brainiac/handlers/shared/git.rb', line 161 def cleanup_work_item_worktrees(card_number, repo_path:, primary_worktree: nil, primary_branch: nil) return unless card_number repo_dir = File.dirname(repo_path) repo_base = File.basename(repo_path) cleaned = 0 # Find worktrees that contain the work item number in their name (any naming convention) candidates = Dir.glob(File.join(repo_dir, "#{repo_base}--*#{card_number}*")).select { |d| File.directory?(d) } candidates << primary_worktree if primary_worktree && File.directory?(primary_worktree) && !candidates.include?(primary_worktree) candidates.uniq.each do |wt_path| status_output, = Open3.capture3("git", "status", "--porcelain", chdir: wt_path) if status_output.strip.empty? branch_name = File.basename(wt_path).sub("#{repo_base}--", "") begin run_cmd("git", "worktree", "remove", wt_path, "--force", chdir: repo_path) run_cmd("git", "branch", "-D", branch_name, chdir: repo_path) cleaned += 1 LOG.info "Cleaned up worktree #{wt_path} (branch: #{branch_name})" rescue StandardError => e LOG.warn "Failed to clean up worktree #{wt_path}: #{e.}" end else LOG.warn "Worktree #{wt_path} has uncommitted changes — skipping cleanup" end end LOG.info "Card ##{card_number}: cleaned up #{cleaned} worktree(s)" if cleaned.positive? end |
#comment_from_agent?(name) ⇒ Boolean
185 186 187 188 189 190 |
# File 'lib/brainiac/agents.rb', line 185 def comment_from_agent?(name) return false unless name downcased = name.downcase all_agent_names.any? { |agent| agent.downcase == downcased } end |
#convert_meridiem_hour(hour, meridiem) ⇒ Object
Convert 12-hour format hour + meridiem to 24-hour format.
112 113 114 115 116 |
# File 'lib/brainiac/cron.rb', line 112 def convert_meridiem_hour(hour, meridiem) hour = 0 if hour == 12 && meridiem == "am" hour += 12 if meridiem == "pm" && hour < 12 hour end |
#create_or_reuse_worktree(repo_path:, branch:, base_ref: nil, worktree_path: nil) ⇒ Object
Create or reuse a git worktree for a given branch. Returns the worktree path on success.
111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 |
# File 'lib/brainiac/handlers/shared/git.rb', line 111 def create_or_reuse_worktree(repo_path:, branch:, base_ref: nil, worktree_path: nil) worktree_path ||= File.join(File.dirname(repo_path), "#{File.basename(repo_path)}--#{branch}") base_ref ||= "origin/#{get_default_branch(repo_path)}" worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path) if File.directory?(worktree_path) is_tracked = worktree_list.include?(worktree_path) if is_tracked LOG.info "Worktree directory #{worktree_path} is tracked by git" else LOG.warn "Orphaned worktree directory found at #{worktree_path}, removing it" begin FileUtils.rm_rf(worktree_path) LOG.info "Successfully removed orphaned directory" rescue StandardError => e LOG.error "Failed to remove orphaned directory: #{e.}" raise end end end branch_exists = system("git", "rev-parse", "--verify", branch, chdir: repo_path, out: File::NULL, err: File::NULL) if branch_exists LOG.info "Branch #{branch} already exists, checking for existing worktree" worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path) has_worktree = worktree_list.lines.any? { |line| line.strip == "worktree #{worktree_path}" } if has_worktree && File.directory?(worktree_path) LOG.info "Reusing existing worktree at #{worktree_path}" else LOG.info "Creating worktree from existing branch #{branch}" run_cmd("git", "worktree", "add", worktree_path, branch, chdir: repo_path) end else LOG.info "Creating new branch #{branch} and worktree" run_cmd("git", "worktree", "add", "-b", branch, worktree_path, base_ref, chdir: repo_path) end trust_version_manager(worktree_path, chdir: worktree_path) apply_worktree_includes(repo_path, worktree_path) run_project_hook(repo_path, "worktree-setup", extra_env: { "WORKTREE_PATH" => worktree_path }) worktree_path end |
#cron_loop ⇒ Object
Cron loop — runs every minute, checks all jobs
605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 |
# File 'lib/brainiac/cron.rb', line 605 def cron_loop loop do now = Time.now # Calculate sleep time to wake up at the start of the next minute seconds_until_next_minute = 60 - now.sec sleep seconds_until_next_minute now = Time.now CRON_JOBS_MUTEX.synchronize do CRON_JOBS.each_value do |job| next unless job[:enabled] next unless cron_matches?(job[:parsed], now) # Prevent duplicate runs within the same minute if job[:last_run] last_run_time = Time.parse(job[:last_run]) next if (now - last_run_time) < 60 end execute_cron_job(job) end end rescue StandardError => e LOG.error "[Cron] Loop error: #{e.}\n#{e.backtrace.first(3).join("\n")}" sleep 60 end end |
#cron_matches?(cron_hash, time = Time.now) ⇒ Boolean
Check if current time matches cron expression
119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 |
# File 'lib/brainiac/cron.rb', line 119 def cron_matches?(cron_hash, time = Time.now) return false unless cron_hash # Handle one-time scheduled tasks if cron_hash[:one_time] target = cron_hash[:timestamp] # Match if we're within the same minute as the target time return time.year == target.year && time.month == target.month && time.day == target.day && time.hour == target.hour && time.min == target.min end minute_match = match_field?(cron_hash[:minute], time.min) hour_match = match_field?(cron_hash[:hour], time.hour) day_match = match_field?(cron_hash[:day], time.day) month_match = match_field?(cron_hash[:month], time.month) weekday_match = match_field?(cron_hash[:weekday], time.wday) minute_match && hour_match && day_match && month_match && weekday_match end |
#curate_skills ⇒ Object
Run the curator: archive stale skills, log consolidation candidates. Never auto-deletes — only moves to _archived/.
221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 |
# File 'lib/brainiac/skills.rb', line 221 def curate_skills FileUtils.mkdir_p(SKILL_ARCHIVE_DIR) now = Time.now archived = 0 consolidation_candidates = [] skills = build_skill_index skills_by_tag = Hash.new { |h, k| h[k] = [] } skills.each do |skill| usage_file = skill_usage_path(skill[:path]) usage = if File.exist?(usage_file) JSON.parse(File.read(usage_file)) else { "views" => 0, "uses" => 0, "last_viewed" => nil, "last_used" => nil } end # Check staleness last_activity = [usage["last_viewed"], usage["last_used"]].compact.max if last_activity.nil? || (now - Time.parse(last_activity)) > (SKILL_STALE_DAYS * 86_400) archive_skill(skill[:path]) archived += 1 next end # Track tags for consolidation detection (skill[:tags] || []).each { |tag| skills_by_tag[tag] << skill } end # Detect consolidation candidates: 3+ skills sharing the same tag skills_by_tag.each do |tag, tag_skills| consolidation_candidates << { tag: tag, skills: tag_skills.map { |s| s[:name] } } if tag_skills.size >= 3 end LOG.info "[Curator] Archived #{archived} stale skill(s)" if archived.positive? LOG.info "[Curator] Consolidation candidates: #{consolidation_candidates.map { |c| c[:tag] }.join(", ")}" unless consolidation_candidates.empty? { archived: archived, consolidation_candidates: consolidation_candidates } rescue StandardError => e LOG.warn "[Curator] Error during curation: #{e.}" { archived: 0, consolidation_candidates: [], error: e. } end |
#debounced_repo_fetch(repo_path) ⇒ Object
12 13 14 15 16 17 18 19 20 21 |
# File 'lib/brainiac/handlers/shared/git.rb', line 12 def debounced_repo_fetch(repo_path) last = REPO_LAST_FETCH[repo_path] if last && (Time.now - last) < REPO_FETCH_DEBOUNCE LOG.info "Skipping git fetch for #{repo_path} — fetched #{(Time.now - last).to_i}s ago" return end run_cmd("git", "fetch", "origin", chdir: repo_path) REPO_LAST_FETCH[repo_path] = Time.now end |
#default_project_key ⇒ Object
89 90 91 92 93 |
# File 'lib/brainiac/helpers.rb', line 89 def default_project_key # Find the project marked as default default = PROJECTS.find { |_key, config| config["default"] == true } default ? default[0] : nil end |
#deliver_script_output(job, log_file, draft_file) ⇒ Object
Read script output and write to draft file or log.
357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 |
# File 'lib/brainiac/cron.rb', line 357 def deliver_script_output(job, log_file, draft_file) return unless File.exist?(log_file) output = File.read(log_file).strip has_notify = job[:notify_target] if has_notify && draft_file && !output.empty? LOG.warn "[Cron] Job #{job[:id]} has notify_target but no notify_channel — notification may not be delivered" unless job[:notify_channel] File.write(draft_file, output) # Deliver via notification system notify_cron_output(job, output, agent_name: job[:agent]) LOG.info "[Cron] Script output delivered via notification (#{output.length} chars)" elsif !output.empty? LOG.info "[Cron] Script output: #{output[0..200]}..." else LOG.warn "[Cron] Script produced no output" end end |
#detect_cli_provider(text: "", tags: []) ⇒ Object
Detect CLI provider override from inline [cli:X] tag or card tags. Returns the provider name (e.g. "grok") or nil.
75 76 77 78 79 80 81 82 83 84 85 86 87 |
# File 'lib/brainiac/helpers.rb', line 75 def detect_cli_provider(text: "", tags: []) # Inline tag: [cli:grok] — works in any channel if (match = text.match(/\[cli:(\w+)\]/i)) return match[1].downcase end # Plugin hook: let plugins detect from their own metadata (e.g., card tags) results = Brainiac.emit(:detect_cli_provider, text: text, tags: ) plugin_result = results.compact.first return plugin_result if plugin_result nil end |
#detect_effort(project_config, tags: [], text: "") ⇒ Object
Detect effort level from inline tags [effort:high] or card tags (effort-high). Returns the effort level string (e.g. "high") or nil. If the requested level isn't supported by the current model, returns the closest lower level from allowed_efforts.
571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 |
# File 'lib/brainiac/helpers.rb', line 571 def detect_effort(project_config, tags: [], text: "") resolved = resolve_project_cli_config(project_config) allowed = resolved["allowed_efforts"] || %w[low medium high xhigh max] # Inline tag: [effort:high] — works in any channel if (match = text.match(/\[effort:(\w+)\]/i)) level = match[1].downcase return resolve_effort_level(level, allowed) if allowed.include?(level) end # Plugin hook: let plugins detect from their own metadata (e.g., card tags) results = Brainiac.emit(:detect_effort, tags: , allowed: allowed) plugin_result = results.compact.first return resolve_effort_level(plugin_result, allowed) if plugin_result resolved["agent_effort"] end |
#detect_mentioned_agent(text) ⇒ Object
170 171 172 173 174 175 176 177 178 179 180 181 182 183 |
# File 'lib/brainiac/agents.rb', line 170 def detect_mentioned_agent(text) downcased = text.downcase # Exact full-name match first (highest priority) all_agent_names.each do |name| return name if downcased.include?("@#{name.downcase}") # Some systems render mentions using first name only (e.g. "@Robin" not "@Robin Hood"). # Fall back to matching the first word of multi-word agent names. first_word = name.split.first.downcase next if first_word == name.downcase # already checked above return name if downcased.include?("@#{first_word}") end nil end |
#detect_model(project_config, tags: [], text: "") ⇒ Object
549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 |
# File 'lib/brainiac/helpers.rb', line 549 def detect_model(project_config, tags: [], text: "") resolved = resolve_project_cli_config(project_config) allowed_models = resolved["allowed_models"] || {} return resolved["agent_model"] if allowed_models.empty? if (match = text.match(/\[(\w+)\]/)) key = match[1].downcase return allowed_models[key] if allowed_models.key?(key) end .each do |tag| key = (tag.is_a?(Hash) ? tag["name"] : tag).to_s.downcase return allowed_models[key] if allowed_models.key?(key) end resolved["agent_model"] end |
#detect_skill_candidate(log_file) ⇒ Object
Analyze an agent session log to determine if a skill should be extracted. Triggers when: 5+ tool calls AND at least one error-recovery pattern detected. Returns: { extract: true, topic: '...', summary: '...' } or { extract: false }
28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
# File 'lib/brainiac/skills.rb', line 28 def detect_skill_candidate(log_file) return { extract: false } unless File.exist?(log_file) content = File.read(log_file, encoding: "utf-8", invalid: :replace) # Count tool invocations (kiro-cli logs tool calls as "Tool:" or "antml:invoke") tool_calls = content.scan(/(?:^Tool:|<invoke|execute_bash|fs_write|fs_read|code.*operation)/).size return { extract: false } if tool_calls < 5 # Detect error-recovery patterns: retry, fix, error followed by success error_patterns = content.scan(/(?:error|failed|fix|retry|correcting|let me try)/i).size recovery_patterns = content.scan(/(?:that worked|fixed|resolved|now passing|success)/i).size has_recovery = error_patterns >= 1 && recovery_patterns >= 1 return { extract: false } unless has_recovery { extract: true, tool_calls: tool_calls, error_patterns: error_patterns } end |
#discover_kiro_agents ⇒ Object
126 127 128 129 130 131 132 133 |
# File 'lib/brainiac/agents.rb', line 126 def discover_kiro_agents return [] unless File.directory?(KIRO_AGENTS_DIR) Dir.glob(File.join(KIRO_AGENTS_DIR, "*.json")).map { |path| File.basename(path, ".json") } rescue StandardError => e LOG.error "Failed to scan kiro agents directory: #{e.}" [] end |
#execute_cron_job(job) ⇒ Object
Execute a cron job (dispatch agent)
533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 |
# File 'lib/brainiac/cron.rb', line 533 def execute_cron_job(job) return unless job[:enabled] LOG.info "[Cron] Executing job #{job[:id]}: #{job[:prompt] || job[:script]}..." project = PROJECTS[job[:project]] unless project LOG.error "[Cron] Project #{job[:project]} not found for job #{job[:id]}" return end if job[:script] execute_script_job(job, project) return end agent_name = job[:agent] agent_config_name = agent_name.downcase.gsub(/[^a-z0-9-]/, "-") prompt_data = build_cron_prompt(job, project) = Time.now.strftime("%Y%m%d-%H%M%S") log_file = File.join(project["repo_path"], "tmp/agent-cron-#{job[:id]}-#{}.log") FileUtils.mkdir_p(File.dirname(log_file)) prompt_file = write_cron_prompt_file(job, prompt_data[:full_prompt], ) resolved = resolve_project_cli_config(project, agent_name: agent_name) cmd = build_cron_agent_cmd(job, project, prompt_file: prompt_file) prompt_mode = resolved["prompt_mode"] || "stdin" LOG.info "[Cron] Dispatching job #{job[:id]} with #{agent_name}, tail -f #{log_file}" spawn_env = agent_env_for(agent_name) LOG.info "[Cron] Injecting #{spawn_env.size} env var(s) for agent #{agent_name}" unless spawn_env.empty? pid = spawn(spawn_env, *cmd, chdir: project["repo_path"], **(prompt_mode == "stdin" ? { in: prompt_file } : {}), out: [log_file, "w"], err: %i[child out]) Thread.new do Process.wait(pid) handle_cron_completion(job, project, agent_name, agent_config_name, log_file, prompt_data[:response_file], prompt_data[:meta_file]) rescue StandardError => e LOG.error "[Cron] Job #{job[:id]} failed: #{e.}\n#{e.backtrace.first(3).join("\n")}" end end |
#execute_restart ⇒ Object
40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 |
# File 'lib/brainiac/restart.rb', line 40 def execute_restart triggered_by = BRAINIAC_RESTART_MUTEX.synchronize { BRAINIAC_RESTART_STATE[:triggered_by] } LOG.info "[Brainiac] All agents finished, executing restart..." BRAINIAC_RESTART_MUTEX.synchronize { BRAINIAC_RESTART_STATE[:queued] = false } send_restart_notification("🔄 Restarting brainiac (triggered by #{triggered_by || "unknown"})...") Thread.new do sleep 1 source_dir = defined?(SERVER_ROOT) ? SERVER_ROOT : File.("../..", __dir__) receiver_path = File.join(source_dir, "receiver.rb") # Determine if we're running in foreground mode. # If stdin is a TTY or we weren't launched as a daemon, use exec to replace the process. # This keeps the server in the foreground terminal. foreground = $stdout.tty? || !File.exist?(File.join(BRAINIAC_DIR, "server.pid")) || File.read(File.join(BRAINIAC_DIR, "server.pid")).strip.to_i == Process.pid if foreground LOG.info "[Brainiac] Restarting in foreground mode (exec)..." # Write PID file for the new process (same PID after exec) File.write(File.join(BRAINIAC_DIR, "server.pid"), Process.pid.to_s) # exec replaces this process — the terminal stays attached Dir.chdir(source_dir) exec("ruby", receiver_path) else # Daemon mode — spawn a new background process log_file = File.join(source_dir, "tmp", "brainiac-server.log") FileUtils.mkdir_p(File.dirname(log_file)) pid = spawn({ "PATH" => ENV.fetch("PATH", nil) }, "ruby", receiver_path, chdir: source_dir, out: [log_file, "a"], err: %i[child out]) Process.detach(pid) File.write(File.join(BRAINIAC_DIR, "server.pid"), pid.to_s) LOG.info "[Brainiac] Stopping server, new instance started (PID: #{pid}) from #{source_dir}" sleep 0.5 Sinatra::Application.quit! sleep 0.5 exit! end end end |
#execute_script_job(job, project) ⇒ Object
Execute a script-based cron job (no agent, direct script execution)
298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 |
# File 'lib/brainiac/cron.rb', line 298 def execute_script_job(job, project) script_path = File.(job[:script]) unless File.exist?(script_path) LOG.error "[Cron] Script not found: #{script_path}" return end unless File.executable?(script_path) LOG.error "[Cron] Script not executable: #{script_path}" return end = Time.now.strftime("%Y%m%d-%H%M%S") log_file = File.join(project["repo_path"], "tmp/cron-script-#{job[:id]}-#{}.log") FileUtils.mkdir_p(File.dirname(log_file)) draft_file = prepare_script_notification_draft(job, ) if job[:notify_target] LOG.info "[Cron] Running script #{script_path} for job #{job[:id]}, tail -f #{log_file}" pid = spawn(script_path, chdir: project["repo_path"], out: [log_file, "w"], err: %i[child out]) Thread.new do Process.wait(pid) LOG.info "[Cron] Script job #{job[:id]} finished (exit: #{$CHILD_STATUS.exitstatus})" deliver_script_output(job, log_file, draft_file) update_cron_job_state(job) rescue StandardError => e LOG.error "[Cron] Script job #{job[:id]} failed: #{e.}\n#{e.backtrace.first(3).join("\n")}" end end |
#extract_crash_snippet(log_file, max_lines: 20) ⇒ Object
Extract the last N meaningful lines from an agent log for crash reporting.
319 320 321 322 323 324 325 326 327 |
# File 'lib/brainiac/helpers.rb', line 319 def extract_crash_snippet(log_file, max_lines: 20) return nil unless log_file && File.exist?(log_file) lines = File.readlines(log_file).map { |l| l.gsub(/\e\[[0-9;]*[a-zA-Z]/, "").rstrip }.reject(&:empty?).last(max_lines) lines&.join("\n") rescue StandardError => e LOG.warn "[CrashNotify] Could not read log: #{e.}" nil end |
#extract_cron_response_from_log(job, agent_config_name, log_file, response_file, meta_file) ⇒ Object
Extract agent response from log if the response file wasn't written directly
476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 |
# File 'lib/brainiac/cron.rb', line 476 def extract_cron_response_from_log(job, agent_config_name, log_file, response_file, ) return if File.exist?(response_file) return unless File.exist?(log_file) log_content = File.read(log_file) if log_content.match?(/Opening browser\.\.\.|Press \(\^\) \+ C to cancel/) LOG.error "[Cron] Auth failure detected for job #{job[:id]} — " \ "re-authenticate with: kiro-cli --agent #{agent_config_name} chat" File.delete() if && File.exist?() return end clean_output = log_content .gsub(/\e\[[0-9;]*[a-zA-Z]|\e\[\?[0-9;]*[a-zA-Z]/, "") .gsub(/\e\][^\a]*\a/, "") .delete("\r") .gsub(/^.*?(using tool:.*?)$/m, "") .gsub(/^.*?✓.*?$/m, "") .gsub(/^.*?▸.*?$/m, "") .gsub(/^.*?Loading\.\.\..*?$/m, "") .gsub(/^.*?Completed in.*?$/m, "") .strip return unless !clean_output.empty? && clean_output.length > 20 File.write(response_file, clean_output) LOG.info "[Cron] Extracted response from log (#{clean_output.length} chars)" end |
#extract_topics(card_title, comment_body, project_key) ⇒ Object
118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 |
# File 'lib/brainiac/brain.rb', line 118 def extract_topics(card_title, comment_body, project_key) text = [card_title, comment_body].compact.join(" ") # Strip common noise words, extract meaningful terms stopwords = %w[the a an is are was were be been being have has had do does did will would shall should may might can could this that these those it its i me my we our you your he she they them to of in for on with at by from as into through during before after above below between and or but not no nor so yet both either neither each every all any few more most other some such only own same than too very just don doesn didn won wasn weren isn aren hasn haven hadn couldn shouldn wouldn about also back even still already again further then once here there when where why how what which who whom whose if because since while please thanks thank need want like make sure get got going go let know think see look work try use find give tell ask seem feel become leave call keep put run move live update fix add create new change set up check out] words = text.downcase.gsub(/[^a-z0-9\s_-]/, " ").split.uniq - stopwords topics = words.select { |w| w.length > 2 }.first(8) topics << project_key if project_key && !project_key.empty? topics.compact.uniq end |
#file_changed?(path, force: false) ⇒ Boolean
91 92 93 94 95 96 97 98 99 100 101 102 103 |
# File 'lib/brainiac/config.rb', line 91 def file_changed?(path, force: false) return true if force return true unless File.exist?(path) current_mtime = File.mtime(path) last_mtime = CONFIG_MTIMES[path] if last_mtime == current_mtime false else CONFIG_MTIMES[path] = current_mtime true end end |
#find_supersedable_session(supersede_key) ⇒ Object
Find an active session for the same supersede key (agent+channel) started within the window. Returns the session info hash (with :session_key added) or nil.
187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 |
# File 'lib/brainiac/sessions.rb', line 187 def find_supersedable_session(supersede_key) ACTIVE_SESSIONS_MUTEX.synchronize do ACTIVE_SESSIONS.each do |key, info| next unless info[:supersede_key] == supersede_key next if (Time.now - info[:started_at]) > SUPERSEDE_WINDOW begin Process.kill(0, info[:pid]) return info.merge(session_key: key) rescue Errno::ESRCH, Errno::EPERM next end end end nil end |
#find_user(identifier) ⇒ Object
Find user by any identifier (tries all platforms)
49 50 51 52 53 54 |
# File 'lib/brainiac/users.rb', line 49 def find_user(identifier) find_user_by_discord_id(identifier) || find_user_by_discord_username(identifier) || find_user_by_github_username(identifier) || find_user_by_canonical_name(identifier) end |
#find_user_by_canonical_name(name) ⇒ Object
Find user by canonical name
44 45 46 |
# File 'lib/brainiac/users.rb', line 44 def find_user_by_canonical_name(name) USER_REGISTRY["users"].find { |u| u["canonical_name"].downcase == name.downcase } end |
#find_user_by_discord_id(user_id) ⇒ Object
Find user by Discord user ID
29 30 31 |
# File 'lib/brainiac/users.rb', line 29 def find_user_by_discord_id(user_id) USER_REGISTRY["users"].find { |u| u.dig("identities", "discord", "user_id") == user_id.to_s } end |
#find_user_by_discord_username(username) ⇒ Object
Find user by Discord username
34 35 36 |
# File 'lib/brainiac/users.rb', line 34 def find_user_by_discord_username(username) USER_REGISTRY["users"].find { |u| u.dig("identities", "discord", "username") == username.to_s } end |
#find_user_by_github_username(username) ⇒ Object
Find user by GitHub username
39 40 41 |
# File 'lib/brainiac/users.rb', line 39 def find_user_by_github_username(username) USER_REGISTRY["users"].find { |u| u.dig("identities", "github", "username") == username.to_s } end |
#find_work_item_by_branch(branch) ⇒ Object
Find a work item by its branch name. Returns [work_item_id, info] or nil. This is the primary lookup method — branch is the universal join key.
182 183 184 185 186 187 188 189 190 191 192 |
# File 'lib/brainiac/helpers.rb', line 182 def find_work_item_by_branch(branch) return nil unless branch map = load_work_item_map map.each do |work_item_id, info| next unless info.is_a?(Hash) && info["branch"] == branch return [work_item_id, info] end nil end |
#find_work_item_by_card(card_internal_id) ⇒ Object
Find a work item by a Fizzy card internal ID (for backward compat with Fizzy plugin). Returns [work_item_id, info] or nil.
204 205 206 207 208 209 210 211 212 213 214 215 216 217 |
# File 'lib/brainiac/helpers.rb', line 204 def find_work_item_by_card(card_internal_id) return nil unless card_internal_id map = load_work_item_map map.each do |work_item_id, info| next unless info.is_a?(Hash) fizzy_source = info.dig("sources", "fizzy") next unless fizzy_source && fizzy_source["card_internal_id"] == card_internal_id return [work_item_id, info] end nil end |
#find_work_item_by_id(work_item_id) ⇒ Object
Find a work item by its ID. Returns the info hash or nil.
195 196 197 198 199 200 |
# File 'lib/brainiac/helpers.rb', line 195 def find_work_item_by_id(work_item_id) return nil unless work_item_id map = load_work_item_map map[work_item_id] end |
#format_active_sessions ⇒ Object
20 21 22 23 24 25 26 27 28 29 30 31 |
# File 'lib/brainiac/routes/api.rb', line 20 def format_active_sessions ACTIVE_SESSIONS.map do |card_key, info| agent_name = resolve_session_agent_name(card_key, info) { card_key: card_key, agent: agent_name, pid: info[:pid], started_at: info[:started_at].iso8601, elapsed_seconds: (Time.now - info[:started_at]).to_i, log_file: info[:log_file], alive: true, children: child_processes_for(info[:pid]) } end end |
#format_recent_sessions ⇒ Object
39 40 41 42 43 44 45 46 47 |
# File 'lib/brainiac/routes/api.rb', line 39 def format_recent_sessions RECENT_SESSIONS.map do |s| { card_key: s[:card_key], agent: s[:agent_name] || "Unknown", log_file: s[:log_file], started_at: s[:started_at]&.iso8601, finished_at: s[:finished_at]&.iso8601 } end end |
#generate_work_item_id(branch: nil, card_number: nil) ⇒ Object
Generate a deterministic work item ID from available identifiers. Priority: branch name (universal join key), then card number fallback.
170 171 172 173 174 175 176 177 178 |
# File 'lib/brainiac/helpers.rb', line 170 def generate_work_item_id(branch: nil, card_number: nil) if branch "wi-#{Digest::SHA256.hexdigest(branch)[0..7]}" elsif card_number "wi-card-#{card_number}" else "wi-#{SecureRandom.hex(4)}" end end |
#get_default_branch(repo_path) ⇒ Object
23 24 25 26 27 28 29 30 |
# File 'lib/brainiac/handlers/shared/git.rb', line 23 def get_default_branch(repo_path) default_branch = run_cmd("git", "rev-parse", "--abbrev-ref", "HEAD", chdir: repo_path).strip begin run_cmd("git", "symbolic-ref", "--short", "refs/remotes/origin/HEAD", chdir: repo_path).strip.sub("origin/", "") rescue StandardError default_branch end end |
#handle_agent_completion(**ctx) ⇒ Object
494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 |
# File 'lib/brainiac/helpers.rb', line 494 def handle_agent_completion(**ctx) agent_exit_status = $CHILD_STATUS.exitstatus agent_signaled = $CHILD_STATUS.signaled? LOG.info "#{ctx[:agent_cli]} finished (pid: #{ctx[:pid]}, exit: #{agent_exit_status})" if ctx[:source] && agent_exit_status && agent_exit_status != 0 && !agent_signaled notify_agent_crash( exit_status: agent_exit_status, log_file: ctx[:log_file], agent_name: ctx[:agent_name], source: ctx[:source], source_context: ctx[:source_context], project_config: ctx[:project_config] ) end # Emit lifecycle hook — plugins handle post-session actions (e.g., plugin moves card, appends footer) Brainiac.emit(:agent_completed, card_number: ctx[:card_number] || ctx[:source_context]&.dig(:card_number), exit_status: agent_exit_status, signaled: agent_signaled, agent_name: ctx[:agent_name], chdir: ctx[:chdir], source: ctx[:source], source_context: ctx[:source_context], project_config: ctx[:project_config], skip_column_move: ctx[:skip_column_move], prompt_file: ctx[:prompt_file]) qmd_out, qmd_status = Open3.capture2e("qmd", "update") if qmd_status.success? LOG.info "[Brain] qmd update completed after #{ctx[:agent_config_name] || "agent"} session" else LOG.warn "[Brain] qmd update failed: #{qmd_out.strip}" end skill_candidate = detect_skill_candidate(ctx[:log_file]) if skill_candidate[:extract] LOG.info "[Skills] Session qualifies for skill extraction " \ "(#{skill_candidate[:tool_calls]} tool calls, #{skill_candidate[:error_patterns]} error patterns) " \ "— agent was nudged via reflection prompt" end brain_push(message: "#{ctx[:agent_config_name] || "agent"}: #{ctx[:log_name]}") # check_brainiac_restart(ctx[:head_before], ctx[:status_before], ctx[:chdir], ctx[:project_key_for_restart], ctx[:agent_config_name]) end |
#handle_cron_completion(job, project, agent_name, agent_config_name, log_file, response_file, meta_file) ⇒ Object
Handle post-execution: extract response from log, update job state
443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 |
# File 'lib/brainiac/cron.rb', line 443 def handle_cron_completion(job, project, agent_name, agent_config_name, log_file, response_file, ) cron_exit_status = $CHILD_STATUS.exitstatus LOG.info "[Cron] Job #{job[:id]} finished (exit: #{cron_exit_status})" if cron_exit_status && cron_exit_status != 0 && job[:notify_target] notify_agent_crash( exit_status: cron_exit_status, log_file: log_file, agent_name: agent_name, source: :cron, source_context: { job: job }, project_config: project ) end extract_cron_response_from_log(job, agent_config_name, log_file, response_file, ) qmd_out, qmd_status = Open3.capture2e("qmd", "update") if qmd_status.success? LOG.info "[Brain] qmd update completed after cron job #{job[:id]}" else LOG.warn "[Brain] qmd update failed: #{qmd_out.strip}" end brain_push(message: "#{agent_config_name}: cron-#{job[:id]}") update_cron_job_state(job) if File.exist?(response_file) LOG.info "[Cron] Job #{job[:id]} completed. Response: #{File.read(response_file)[0..100]}..." else LOG.warn "[Cron] Job #{job[:id]} produced no response" end end |
#human_users ⇒ Object
Get all human users (exclude AI agents)
63 64 65 |
# File 'lib/brainiac/users.rb', line 63 def human_users USER_REGISTRY["users"].reject { |u| u["notes"]&.include?("AI agent") } end |
#identify_project_by_repo(repo_full_name) ⇒ Object
95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 |
# File 'lib/brainiac/helpers.rb', line 95 def identify_project_by_repo(repo_full_name) return nil if PROJECTS.empty? PROJECTS.each do |project_key, config| return [project_key, config] if config["github_repo"] == repo_full_name end # Fall back to default project if configured default_key = default_project_key if default_key LOG.info "No project matched GitHub repo '#{repo_full_name}', falling back to default project '#{default_key}'" return [default_key, PROJECTS[default_key]] end nil end |
#install_plugin(name, version: nil) ⇒ Object
Install a plugin gem and register it in plugins.json. rubocop:disable Naming/PredicateMethod
103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 |
# File 'lib/brainiac/plugins.rb', line 103 def install_plugin(name, version: nil) gem_name = "brainiac-#{name}" if installed_plugins.include?(name) puts "Plugin '#{name}' is already installed." return false end puts "Installing #{gem_name}..." install_cmd = ["gem", "install", gem_name] install_cmd.push("--version", version) if version stdout, stderr, status = Open3.capture3(*install_cmd) unless status.success? puts "Failed to install #{gem_name}:" puts stderr.empty? ? stdout : stderr return false end puts stdout unless stdout.strip.empty? config = load_plugins_config config["plugins"] ||= [] entry = { "name" => name, "gem" => gem_name, "installed_at" => Time.now.iso8601 } entry["version"] = version if version config["plugins"] << entry save_plugins_config(config) puts "✓ Installed plugin '#{name}' (#{gem_name})" puts " Restart the server to activate: brainiac restart" true end |
#installed_plugins ⇒ Object
Returns the list of installed plugin names (e.g. ["whatsapp", "slack"])
31 32 33 |
# File 'lib/brainiac/plugins.rb', line 31 def installed_plugins (PLUGINS_CONFIG["plugins"] || []).map { |p| p.is_a?(Hash) ? p["name"] : p.to_s } end |
#kill_child_process(target_pid) ⇒ Object
49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
# File 'lib/brainiac/routes/api.rb', line 49 def kill_child_process(target_pid) Process.kill("TERM", target_pid) Thread.new do sleep 3 begin Process.kill(0, target_pid) Process.kill("KILL", target_pid) rescue Errno::ESRCH, Errno::EPERM # rubocop:disable Lint/SuppressedException end end LOG.info "Killed child process #{target_pid} (SIGTERM)" { killed: target_pid }.to_json rescue Errno::ESRCH halt 404, { error: "process not found" }.to_json rescue Errno::EPERM halt 403, { error: "permission denied" }.to_json end |
#kill_session(session_key) ⇒ Object
Kill a session's process. Returns true if killed.
205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 |
# File 'lib/brainiac/sessions.rb', line 205 def kill_session(session_key) ACTIVE_SESSIONS_MUTEX.synchronize do info = ACTIVE_SESSIONS[session_key] return false unless info # Kill child processes first (bottom-up), then the parent children = child_processes_for(info[:pid]) children.reverse_each do |child| Process.kill("KILL", child[:pid]) rescue StandardError nil end begin Process.kill("KILL", info[:pid]) rescue Errno::ESRCH, Errno::EPERM # already gone end archive_session(session_key, info) ACTIVE_SESSIONS.delete(session_key) true end end |
#load_agent_registry ⇒ Object
Agent registry, discovery, identity, mention detection, and env injection.
The registry at ~/.brainiac/agents.json uses a generic env hash so any
environment variable can be set per-agent:
{
"sherlock": {
"display_name": "Sherlock",
"local": true,
"env": {
"SOME_TOKEN": "token_abc...",
"OTHER_TOKEN": "Bot_abc..."
}
}
}
The "local" flag marks agents that this machine should dispatch work for (card assignments). Agents without "local": true are still known for mention detection, display names, tokens, and cross-agent interactions — they just won't pick up card assignments on this machine.
23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
# File 'lib/brainiac/agents.rb', line 23 def load_agent_registry unless File.exist?(AGENT_REGISTRY_FILE) LOG.info "No agent registry found at #{AGENT_REGISTRY_FILE}" return {} end raw_registry = JSON.parse(File.read(AGENT_REGISTRY_FILE)) LOG.info "Loaded agent registry (#{raw_registry.size} agents) from #{AGENT_REGISTRY_FILE}" # Normalize keys: convert to lowercase, replace non-alphanumeric with hyphens registry = {} raw_registry.each do |key, entry| normalized_key = key.downcase.gsub(/[^a-z0-9-]/, "-") if registry.key?(normalized_key) && registry[normalized_key] != entry LOG.warn "Duplicate agent key after normalization: '#{key}' → '#{normalized_key}' (already exists)" end registry[normalized_key] = entry end registry rescue JSON::ParserError => e LOG.error "Failed to parse agent registry: #{e.}" {} end |
#load_brainiac_config ⇒ Object
29 30 31 32 33 34 35 36 |
# File 'lib/brainiac/config.rb', line 29 def load_brainiac_config return {} unless File.exist?(BRAINIAC_CONFIG_FILE) JSON.parse(File.read(BRAINIAC_CONFIG_FILE)) rescue JSON::ParserError => e LOG.error "Failed to parse brainiac.json: #{e.}" if defined?(LOG) {} end |
#load_cli_provider(provider_name) ⇒ Object
Load a CLI provider config from ~/.brainiac/cli-providers/
10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
# File 'lib/brainiac/helpers.rb', line 10 def load_cli_provider(provider_name) return {} unless provider_name provider_file = File.join(CLI_PROVIDERS_DIR, "#{provider_name}.json") return {} unless File.exist?(provider_file) raw = JSON.parse(File.read(provider_file)) config = { "agent_cli" => raw["binary"], "agent_cli_args" => raw["default_args"], "agent_model_flag" => raw["model_flag"], "agent_effort_flag" => raw["effort_flag"], "allowed_models" => raw["models"], "allowed_efforts" => raw["efforts"] } # agent_flag: how the agent identity is passed (default: "--agent"). # Set to null/false in provider JSON to suppress passing agent name entirely. # We must preserve the key even when nil so merges don't lose the "no agent flag" intent. config["agent_flag"] = raw.key?("agent_flag") ? raw["agent_flag"] : "--agent" # prompt_mode: "stdin" (default) or "flag" — how the prompt is delivered. config["prompt_mode"] = raw["prompt_mode"] || "stdin" config["prompt_flag"] = raw["prompt_flag"] if raw["prompt_flag"] # resume_flag: when set, follow-up dispatches use this flag to continue the # most recent session in the working directory (e.g. "-c" or "--continue"). config["resume_flag"] = raw["resume_flag"] if raw["resume_flag"] # Compact nil values except agent_flag (which uses nil to mean "don't pass agent name") agent_flag_value = config["agent_flag"] config.compact! config["agent_flag"] = agent_flag_value if raw.key?("agent_flag") config rescue JSON::ParserError => e LOG.warn "Failed to parse CLI provider '#{provider_name}': #{e.}" {} end |
#load_cron_jobs ⇒ Object
Load cron jobs from config
166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 |
# File 'lib/brainiac/cron.rb', line 166 def load_cron_jobs return {} unless File.exist?(CRON_CONFIG_FILE) jobs = JSON.parse(File.read(CRON_CONFIG_FILE), symbolize_names: true) # Deserialize timestamp strings back to Time objects for one-time jobs jobs.each_value do |job| next unless job[:parsed] job[:parsed][:timestamp] = Time.parse(job[:parsed][:timestamp]) if job[:parsed][:one_time] && job[:parsed][:timestamp].is_a?(String) end jobs rescue JSON::ParserError => e LOG.error "[Cron] Failed to parse cron config: #{e.}" {} end |
#load_plugins!(app) ⇒ Object
Load all installed plugin gems and call their register hooks. Called once during server startup, after core handlers are loaded.
42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 |
# File 'lib/brainiac/plugins.rb', line 42 def load_plugins!(app) # rubocop:disable Metrics/BlockLength installed_plugins.each do |name| gem_name = "brainiac-#{name}" entry = plugin_entry(name) begin # If plugin has a local path, add its lib/ to load path before requiring if entry.is_a?(Hash) && entry["path"] lib_path = File.join(entry["path"], "lib") $LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path) LOG.info "[Plugins] Loading #{gem_name} from local path: #{entry["path"]}" end # Try both naming conventions: brainiac-fizzy and brainiac_fizzy begin require gem_name rescue LoadError require gem_name.tr("-", "_") end plugin_module = resolve_plugin_module(name) if plugin_module.respond_to?(:register) plugin_module.register(app) LOG.info "[Plugins] Loaded #{gem_name}" else LOG.warn "[Plugins] #{gem_name} loaded but no register method found" end rescue LoadError => e LOG.error "[Plugins] Could not load #{gem_name}: #{e.}" if entry.is_a?(Hash) && entry["path"] LOG.error "[Plugins] Local path: #{entry["path"]}" else LOG.error "[Plugins] Is the gem installed? Run: gem install #{gem_name}" end rescue StandardError => e LOG.error "[Plugins] Error registering #{gem_name}: #{e.}" LOG.error "[Plugins] #{e.backtrace.first(3).join("\n ")}" end end # rubocop:enable Metrics/BlockLength end |
#load_plugins_config ⇒ Object
14 15 16 17 18 19 20 21 |
# File 'lib/brainiac/plugins.rb', line 14 def load_plugins_config return { "plugins" => [] } unless File.exist?(PLUGINS_FILE) JSON.parse(File.read(PLUGINS_FILE)) rescue JSON::ParserError => e LOG.error "Failed to parse plugins.json: #{e.}" { "plugins" => [] } end |
#load_projects_config ⇒ Object
--- Projects ---
77 78 79 80 81 82 83 84 85 86 |
# File 'lib/brainiac/config.rb', line 77 def load_projects_config return {} unless File.exist?(PROJECTS_FILE) projects = JSON.parse(File.read(PROJECTS_FILE)) LOG.info "Loaded #{projects.size} project(s) from #{PROJECTS_FILE}" projects rescue JSON::ParserError => e LOG.error "Failed to parse projects config: #{e.}" {} end |
#load_role(role_name) ⇒ Object
Load a role definition from ~/.brainiac/roles/
105 106 107 108 109 110 111 112 113 114 115 116 117 118 |
# File 'lib/brainiac/agents.rb', line 105 def load_role(role_name) return nil unless role_name role_file = File.join(ROLES_DIR, "#{role_name}.md") return nil unless File.exist?(role_file) content = File.read(role_file).strip # Strip YAML front matter if present content = content.sub(/\A---\n.*?\n---\n*/m, "").strip content.empty? ? nil : content rescue StandardError => e LOG.warn "Failed to load role '#{role_name}': #{e.}" nil end |
#load_user_registry ⇒ Object
8 9 10 11 12 13 14 15 16 17 |
# File 'lib/brainiac/users.rb', line 8 def load_user_registry return { "users" => [] } unless File.exist?(USERS_FILE) data = JSON.parse(File.read(USERS_FILE)) LOG.info "Loaded #{data["users"].size} user(s) from #{USERS_FILE}" data rescue JSON::ParserError => e LOG.error "Failed to parse user registry: #{e.}" { "users" => [] } end |
#load_work_item_map ⇒ Object
112 113 114 115 116 117 118 119 |
# File 'lib/brainiac/helpers.rb', line 112 def load_work_item_map return {} unless File.exist?(WORK_ITEM_MAP_FILE) raw = JSON.parse(File.read(WORK_ITEM_MAP_FILE)) migrate_work_item_map(raw) rescue JSON::ParserError {} end |
#local_agent_names ⇒ Object
Agents marked "local": true in the registry — only these should pick up card assignments on this machine. All other agents are still "known" for mention detection, tokens, and display names.
153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 |
# File 'lib/brainiac/agents.rb', line 153 def local_agent_names names = Set.new # The default AI_AGENT_NAME is always local (it's this machine's primary agent) names << AI_AGENT_NAME # Project-configured agents are local by definition PROJECTS.each_value { |config| names << config["agent_name"] if config["agent_name"] } # kiro-cli agent configs on disk are local discover_kiro_agents.each { |name| names << name.capitalize } # Registry agents only if explicitly marked local AGENT_REGISTRY.each do |key, entry| next unless entry.is_a?(Hash) && entry["local"] names << (entry["display_name"] || key.capitalize) end names end |
#mark_work_item_merged(card_number) ⇒ Object
298 299 300 |
# File 'lib/brainiac/helpers.rb', line 298 def mark_work_item_merged(card_number) MERGED_CARDS_MUTEX.synchronize { MERGED_CARDS[card_number.to_s] = Time.now } end |
#match_field?(pattern, value) ⇒ Boolean
142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 |
# File 'lib/brainiac/cron.rb', line 142 def match_field?(pattern, value) return true if pattern == "*" # Handle ranges (e.g., "1-5") if pattern.include?("-") range_start, range_end = pattern.split("-").map(&:to_i) return value.between?(range_start, range_end) end # Handle lists (e.g., "1,3,5") return pattern.split(",").map(&:to_i).include?(value) if pattern.include?(",") # Handle step values (e.g., "*/5") if pattern.include?("/") base, step = pattern.split("/") step = step.to_i return (value % step).zero? if base == "*" end # Exact match pattern.to_i == value end |
#match_skills_semantically(search_context, skills) ⇒ Object
Use qmd semantic search to find skills whose descriptions match the current context. Returns an ordered array of SKILL.md paths (most relevant first).
153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 |
# File 'lib/brainiac/skills.rb', line 153 def match_skills_semantically(search_context, skills) return [] if search_context.strip.empty? # Search the knowledge collection — skills are indexed there since they're in knowledge/skills/ output, status = Open3.capture2("qmd", "search", search_context, "-c", KNOWLEDGE_COLLECTION, "-n", "10", "--md") return [] unless status.success? && !output.strip.empty? # Extract paths from qmd results that point to SKILL.md files skill_paths = skills.map { |s| s[:path] } matched = [] # qmd --md output includes file paths in results — match against known skill paths skill_paths.each do |path| # Check if the skill's directory name or file appears in search results skill_dir_name = File.basename(File.dirname(path)) matched << path if output.include?(skill_dir_name) || output.include?(path) end matched rescue StandardError => e LOG.warn "[Skills] Semantic matching failed: #{e.}" [] end |
#memory_dir_for(agent_name) ⇒ Object
8 9 10 |
# File 'lib/brainiac/brain.rb', line 8 def memory_dir_for(agent_name) File.join(MEMORY_BASE_DIR, agent_name.downcase.gsub(/[^a-z0-9-]/, "-")) end |
#migrate_work_item_map(raw) ⇒ Object
Migrate old-format work item maps (keyed by Fizzy card internal ID with flat structure) to the new source-agnostic format (keyed by work item ID with sources hash). Old format: { "fizzy-uuid" => { "number" => 42, "branch" => "...", "worktree" => "...", "project" => "...", "agent" => "..." } } New format: { "wi-abc123" => { "id" => "wi-...", "branch" => "...", "worktree" => "...", "project" => "...", "agent" => "...", "sources" => { "fizzy" => { ... } } } }
130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 |
# File 'lib/brainiac/helpers.rb', line 130 def migrate_work_item_map(raw) return raw if raw.empty? # Detect: if any entry has a "sources" key, it's already new format (or mixed) # If none have "sources", it's entirely old format needs_migration = raw.values.any? { |v| v.is_a?(Hash) && !v.key?("sources") } return raw unless needs_migration migrated = {} raw.each do |key, entry| next unless entry.is_a?(Hash) if entry.key?("sources") # Already new format migrated[key] = entry else # Old format — migrate. Generate a work item ID from the branch or card number. work_item_id = generate_work_item_id(branch: entry["branch"], card_number: entry["number"]) migrated[work_item_id] = { "id" => work_item_id, "branch" => entry["branch"], "worktree" => entry["worktree"], "project" => entry["project"], "agent" => entry["agent"], "sources" => { "fizzy" => { "card_internal_id" => key, "card_number" => entry["number"] } } } # Preserve PR tracking if it exists migrated[work_item_id]["sources"]["github"] = { "prs" => entry["prs"] } if entry["prs"] end end migrated end |
#notification_config_for(event) ⇒ Object
Get the notification config for a specific event type. Falls back to "default" config if no event-specific config exists.
65 66 67 68 69 70 71 72 73 74 75 |
# File 'lib/brainiac/notifications.rb', line 65 def notification_config_for(event) brainiac_config_file = File.join(BRAINIAC_DIR, "brainiac.json") return {} unless File.exist?(brainiac_config_file) config = JSON.parse(File.read(brainiac_config_file)) notifications = config[NOTIFICATIONS_CONFIG_KEY] || {} notifications[event.to_s] || notifications["default"] || {} rescue JSON::ParserError {} end |
#notify_agent_crash(exit_status:, log_file:, agent_name:, source:, source_context:, project_config:) ⇒ Object
Notify the originating channel that an agent crashed. source: :github, :discord, or plugin-registered sources source_context: hash with channel-specific info needed to post the notification
332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 |
# File 'lib/brainiac/helpers.rb', line 332 def notify_agent_crash(exit_status:, log_file:, agent_name:, source:, source_context:, project_config:) agent_display = agent_name || "Agent" snippet = extract_crash_snippet(log_file) # Emit to plugins — they handle their own channel-specific delivery handled = Brainiac.emit(:agent_crashed, exit_status: exit_status, log_file: log_file, agent_name: agent_display, source: source, source_context: source_context, project_config: project_config, snippet: snippet) # If no plugin handled it, log a warning LOG.warn "[CrashNotify] Agent crashed but no plugin handled notification (source: #{source})" unless handled.any? rescue StandardError => e LOG.error "[CrashNotify] Unexpected error: #{e.}" end |
#notify_cron_output(job, message, agent_name: nil) ⇒ Object
Convenience: send a notification for cron job output.
78 79 80 81 82 83 84 85 86 |
# File 'lib/brainiac/notifications.rb', line 78 def notify_cron_output(job, , agent_name: nil) send_notification(:cron, , target: job[:notify_target], channel: job[:notify_channel], agent: agent_name || job[:agent], job_id: job[:id], forum_title: job[:forum_title], forum_reply_to_latest: job[:forum_reply_to_latest]) end |
#notify_deploy(project_key, message) ⇒ Object
Convenience: send a deploy notification.
89 90 91 |
# File 'lib/brainiac/notifications.rb', line 89 def notify_deploy(project_key, ) send_notification(:deploy, , metadata_project: project_key) end |
#notify_restart(message) ⇒ Object
Convenience: send a restart notification.
94 95 96 |
# File 'lib/brainiac/notifications.rb', line 94 def notify_restart() send_notification(:restart, ) end |
#notify_unauthorized(action, creator_name, card_info) ⇒ Object
602 603 604 605 606 |
# File 'lib/brainiac/helpers.rb', line 602 def (action, creator_name, card_info) msg = "Unauthorized: #{creator_name} triggered #{action} on #{card_info}" LOG.warn msg system("#{NOTIFICATION_COMMAND} '#{msg}'") if NOTIFICATION_COMMAND end |
#on_comment_cooldown?(card_key) ⇒ Boolean
233 234 235 236 |
# File 'lib/brainiac/sessions.rb', line 233 def on_comment_cooldown?(card_key) last = LAST_COMMENT_TIMES[card_key] last && (Time.now - last) < COMMENT_COOLDOWN end |
#on_deploy_cooldown?(env_key) ⇒ Boolean
247 248 249 250 |
# File 'lib/brainiac/sessions.rb', line 247 def on_deploy_cooldown?(env_key) last = LAST_DEPLOY_TIMES[env_key] last && (Time.now - last) < DEPLOY_COOLDOWN end |
#owner_id ⇒ Object
Owner identifier (for version-outdated notifications). Reads from brainiac.json.
167 168 169 170 171 172 173 174 |
# File 'lib/brainiac/config.rb', line 167 def owner_id brainiac_config_file = File.join(BRAINIAC_DIR, "brainiac.json") return nil unless File.exist?(brainiac_config_file) JSON.parse(File.read(brainiac_config_file))["owner_id"] rescue JSON::ParserError nil end |
#parse_cron_expression(expr) ⇒ Object
Parse cron expression (simplified: supports minute, hour, day, month, weekday) Format: "minute hour day month weekday" (e.g., "0 9 * * 1-5" = 9am weekdays) Also supports special strings: @hourly, @daily, @weekly, @monthly Also supports one-time timestamps: ISO8601 format (e.g., "2026-02-27T09:00:00-05:00") Also supports natural language: "tomorrow at 9am", "in 2 hours", "next monday at 3pm"
22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
# File 'lib/brainiac/cron.rb', line 22 def parse_cron_expression(expr) case expr when "@hourly" then { minute: 0, hour: "*", day: "*", month: "*", weekday: "*" } when "@daily" then { minute: 0, hour: 0, day: "*", month: "*", weekday: "*" } when "@weekly" then { minute: 0, hour: 0, day: "*", month: "*", weekday: 0 } when "@monthly" then { minute: 0, hour: 0, day: 1, month: "*", weekday: "*" } else # Try parsing as natural language or ISO8601 timestamp for one-time execution = parse_natural_time(expr) return { one_time: true, timestamp: } if parts = expr.split return nil unless parts.size == 5 { minute: parts[0], hour: parts[1], day: parts[2], month: parts[3], weekday: parts[4] } end end |
#parse_flag_tags(result) ⇒ Object
75 76 77 78 79 80 81 82 83 84 85 86 87 |
# File 'lib/brainiac/handlers/shared/inline_tags.rb', line 75 def (result) # [chat], [question], [?] if result[:clean_text].match?(/\[(chat|question|\?)\]/i) result[:chat_mode] = true result[:clean_text].sub!(/\[(chat|question|\?)\]/i, "") end # [plan] return unless result[:clean_text].match?(/\[plan\]/i) result[:planning] = true result[:clean_text].sub!(/\[plan\]/i, "") end |
#parse_inline_tags(text) ⇒ Object
Parse inline tags from message text. Returns a hash:
{
project: "my-project" or nil,
model_tag: "opus" or nil (raw tag, not resolved model ID),
effort: "high" or nil,
cli_provider: "grok" or nil,
chat_mode: true/false,
planning: true/false,
deploy_intent: "dev01" / :auto / nil,
worktree_override: "branch-name" or nil,
clean_text: "the message with all tags stripped"
}
25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
# File 'lib/brainiac/handlers/shared/inline_tags.rb', line 25 def (text) result = { project: nil, model_tag: nil, effort: nil, cli_provider: nil, chat_mode: false, planning: false, deploy_intent: nil, worktree_override: nil, work_item: nil, branch_override: nil, clean_text: text.dup } (result) (result) (result) parse_model_tag(result) result[:clean_text].strip! result end |
#parse_model_tag(result) ⇒ Object
111 112 113 114 115 116 117 118 119 |
# File 'lib/brainiac/handlers/shared/inline_tags.rb', line 111 def parse_model_tag(result) # Model tag: any remaining [word] that isn't a known tag — detected separately # because it depends on the project's allowed_models config. We just capture # the raw match here for the caller to resolve. if (match = result[:clean_text].match(/\[(\w+)\]/)) result[:model_tag] = match[1].downcase result[:clean_text].sub!(match[0], "") end end |
#parse_natural_time(expr) ⇒ Object
Parse natural language time expressions into absolute timestamps
41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 |
# File 'lib/brainiac/cron.rb', line 41 def parse_natural_time(expr) now = Time.now # Try ISO8601 first begin return Time.parse(expr) rescue ArgumentError # Not ISO8601, try natural language end # "tomorrow at HH:MM" or "tomorrow at HHam/pm" if expr =~ /^tomorrow\s+at\s+(.+)$/i time_str = Regexp.last_match(1) tomorrow = now + 86_400 parsed_time = parse_time_of_day(time_str, tomorrow) return parsed_time if parsed_time end # "in X hours/minutes/days" if expr =~ /^in\s+(\d+)\s+(hour|minute|day)s?$/i amount = Regexp.last_match(1).to_i unit = Regexp.last_match(2).downcase case unit when "minute" then return now + (amount * 60) when "hour" then return now + (amount * 3600) when "day" then return now + (amount * 86_400) end end # "next monday/tuesday/etc at HH:MM" weekdays = { "sunday" => 0, "monday" => 1, "tuesday" => 2, "wednesday" => 3, "thursday" => 4, "friday" => 5, "saturday" => 6 } if expr =~ /^next\s+(#{weekdays.keys.join("|")})\s+at\s+(.+)$/i target_wday = weekdays[Regexp.last_match(1).downcase] time_str = Regexp.last_match(2) days_ahead = (target_wday - now.wday + 7) % 7 days_ahead = 7 if days_ahead.zero? # "next monday" means next week if today is monday target_date = now + (days_ahead * 86_400) parsed_time = parse_time_of_day(time_str, target_date) return parsed_time if parsed_time end nil end |
#parse_skill_frontmatter(path) ⇒ Object
Parse YAML frontmatter from a SKILL.md file.
68 69 70 71 72 73 74 75 76 77 78 79 |
# File 'lib/brainiac/skills.rb', line 68 def parse_skill_frontmatter(path) content = File.read(path) return nil unless content.start_with?("---") parts = content.split("---", 3) return nil if parts.size < 3 YAML.safe_load(parts[1]) rescue StandardError => e LOG.warn "[Skills] Failed to parse frontmatter in #{path}: #{e.}" nil end |
#parse_time_of_day(time_str, date) ⇒ Object
Parse time of day (e.g., "9am", "3:30pm", "14:00") and combine with a date
87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 |
# File 'lib/brainiac/cron.rb', line 87 def parse_time_of_day(time_str, date) # "9am" or "3pm" if time_str =~ /^(\d+)(am|pm)$/i hour = convert_meridiem_hour(Regexp.last_match(1).to_i, Regexp.last_match(2).downcase) return Time.new(date.year, date.month, date.day, hour, 0, 0, date.utc_offset) end # "9:30am" or "3:45pm" if time_str =~ /^(\d+):(\d+)(am|pm)$/i hour = convert_meridiem_hour(Regexp.last_match(1).to_i, Regexp.last_match(3).downcase) minute = Regexp.last_match(2).to_i return Time.new(date.year, date.month, date.day, hour, minute, 0, date.utc_offset) end # "14:00" (24-hour format) if time_str =~ /^(\d+):(\d+)$/ hour = Regexp.last_match(1).to_i minute = Regexp.last_match(2).to_i return Time.new(date.year, date.month, date.day, hour, minute, 0, date.utc_offset) end nil end |
#parse_value_tags(result) ⇒ Object
49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 |
# File 'lib/brainiac/handlers/shared/inline_tags.rb', line 49 def (result) # [project:my-project] if (match = result[:clean_text].match(/\[project:(\S+)\]/i)) result[:project] = match[1] result[:clean_text].sub!(match[0], "") end # [effort:high] if (match = result[:clean_text].match(/\[effort:(\w+)\]/i)) result[:effort] = match[1].downcase result[:clean_text].sub!(match[0], "") end # [cli:grok] if (match = result[:clean_text].match(/\[cli:(\w+)\]/i)) result[:cli_provider] = match[1].downcase result[:clean_text].sub!(match[0], "") end # [deploy] or [deploy:dev01] if (match = result[:clean_text].match(/\[deploy(?::([^\]]+))?\]/i)) result[:deploy_intent] = match[1]&.strip&.downcase || :auto result[:clean_text].sub!(match[0], "") end end |
#parse_work_item_tags(result) ⇒ Object
89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 |
# File 'lib/brainiac/handlers/shared/inline_tags.rb', line 89 def (result) # [worktree:branch-name] — legacy syntax, still supported if (match = result[:clean_text].match(/\[worktree:([^\]]+)\]/)) result[:worktree_override] = match[1].strip result[:clean_text].sub!(match[0], "") end # [branch:branch-name] — preferred syntax for targeting a branch/worktree if (match = result[:clean_text].match(/\[branch:([^\]]+)\]/i)) result[:branch_override] = match[1].strip # Also set worktree_override for backward compat with plugins that read it result[:worktree_override] ||= result[:branch_override] result[:clean_text].sub!(match[0], "") end # [workitem:wi-abc123] — target a specific work item by ID if (match = result[:clean_text].match(/\[workitem:([^\]]+)\]/i)) result[:work_item] = match[1].strip result[:clean_text].sub!(match[0], "") end end |
#persona_collection_for(agent_name) ⇒ Object
16 17 18 |
# File 'lib/brainiac/brain.rb', line 16 def persona_collection_for(agent_name) "#{agent_name.downcase.gsub(/[^a-z0-9-]/, "-")}-persona" end |
#persona_dir_for(agent_name) ⇒ Object
12 13 14 |
# File 'lib/brainiac/brain.rb', line 12 def persona_dir_for(agent_name) File.join(PERSONA_BASE_DIR, agent_name.downcase.gsub(/[^a-z0-9-]/, "-")) end |
#plugin_entry(name) ⇒ Object
Returns the full plugin entry (Hash) for a given name, or nil.
36 37 38 |
# File 'lib/brainiac/plugins.rb', line 36 def plugin_entry(name) (PLUGINS_CONFIG["plugins"] || []).find { |p| (p.is_a?(Hash) ? p["name"] : p.to_s) == name } end |
#prepare_script_notification_draft(job, timestamp) ⇒ Object
Prepare a notification draft file for a script job. Returns the draft file path.
335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 |
# File 'lib/brainiac/cron.rb', line 335 def prepare_script_notification_draft(job, ) notify_dir = File.join(BRAINIAC_DIR, "tmp", "notify", "draft") FileUtils.mkdir_p(notify_dir) draft_file = File.join(notify_dir, "cron-script-#{}-#{job[:id]}.md") = "#{draft_file}.meta.json" script_agent_key = job[:agent]&.downcase&.gsub(/[^a-z0-9-]/, "-") = { notify_channel: job[:notify_channel]&.to_s, notify_target: job[:notify_target], agent_key: script_agent_key, agent_name: job[:agent] || "Script", cron_job_id: job[:id], forum_title: job[:forum_title], forum_reply_to_latest: job[:forum_reply_to_latest], created_at: Time.now.iso8601 } File.write(, JSON.pretty_generate()) draft_file end |
#prior_session_exists?(chdir, agent_cli) ⇒ Boolean
Check if a prior CLI session exists in the given directory for the specified CLI binary. This prevents resume attempts when the CLI provider changed (e.g., [cli:grok] in a thread started by kiro-cli) or when the session was started on a different machine.
351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 |
# File 'lib/brainiac/helpers.rb', line 351 def prior_session_exists?(chdir, agent_cli) return false unless chdir && agent_cli cli_name = File.basename(agent_cli) # Check for CLI-specific session markers: # - grok uses .grok/ directory for session state # - kiro-cli uses .kiro-cli/ or similar # - Generic fallback: check tmp/ for agent logs from this CLI session_dir = File.join(chdir, ".#{cli_name}") return true if File.directory?(session_dir) # Fallback: look for recent session logs in tmp/ that suggest this CLI ran here before. # This covers CLIs that don't leave a dotdir but do leave logs via brainiac. tmp_dir = File.join(chdir, "tmp") return false unless File.directory?(tmp_dir) Dir.glob(File.join(tmp_dir, "agent-*.log")).any? do |log| # Only count logs from the last 24 hours as valid "resumable" sessions File.mtime(log) > Time.now - 86_400 end rescue StandardError false end |
#query_brain(search_terms, agent_name: AI_AGENT_NAME, scope: :knowledge, max_results: 5) ⇒ Object
101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 |
# File 'lib/brainiac/brain.rb', line 101 def query_brain(search_terms, agent_name: AI_AGENT_NAME, scope: :knowledge, max_results: 5) return "" unless system("which qmd > /dev/null 2>&1") collection = case scope when :persona then persona_collection_for(agent_name) else KNOWLEDGE_COLLECTION end output, status = Open3.capture2("qmd", "search", search_terms, "-c", collection, "-n", max_results.to_s, "--md") return "" unless status.success? && !output.strip.empty? output.strip rescue StandardError => e LOG.warn "Brain query failed (#{scope}, #{agent_name}): #{e.}" "" end |
#queue_brainiac_restart(agent_name) ⇒ Object
12 13 14 15 16 17 18 19 20 |
# File 'lib/brainiac/restart.rb', line 12 def queue_brainiac_restart(agent_name) BRAINIAC_RESTART_MUTEX.synchronize do unless BRAINIAC_RESTART_STATE[:queued] BRAINIAC_RESTART_STATE[:queued] = true BRAINIAC_RESTART_STATE[:triggered_by] = agent_name LOG.info "[Brainiac] #{agent_name} queued a restart — will execute when all agents finish" end end end |
#read_proc_cmdline(pid) ⇒ Object
Read command line for a given PID from /proc.
144 145 146 147 148 |
# File 'lib/brainiac/sessions.rb', line 144 def read_proc_cmdline(pid) File.read("/proc/#{pid}/cmdline").tr("\0", " ").strip rescue StandardError "(unknown)" end |
#read_proc_elapsed(pid) ⇒ Object
Calculate elapsed seconds since process start from /proc/stat.
151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 |
# File 'lib/brainiac/sessions.rb', line 151 def read_proc_elapsed(pid) stat_content = File.read("/proc/#{pid}/stat") rescue StandardError 0 else cp = stat_content.rindex(")") starttime_ticks = begin stat_content[(cp + 2)..].split[19].to_i rescue StandardError 0 end clk_tck = 100 uptime = begin File.read("/proc/uptime").split[0].to_f rescue StandardError 0 end start_seconds = starttime_ticks.to_f / clk_tck (uptime - start_seconds).to_i.clamp(0, Float::INFINITY).to_i end |
#reap_dead_sessions ⇒ Object
--- Session Helpers ---
10 11 12 13 14 15 16 17 18 |
# File 'lib/brainiac/routes/api.rb', line 10 def reap_dead_sessions ACTIVE_SESSIONS.delete_if do |card_key, info| Process.kill(0, info[:pid]) false rescue Errno::ESRCH, Errno::EPERM archive_session(card_key, info) true end end |
#recently_completed?(card_key, window: 120) ⇒ Boolean
55 56 57 58 59 60 61 |
# File 'lib/brainiac/sessions.rb', line 55 def recently_completed?(card_key, window: 120) ACTIVE_SESSIONS_MUTEX.synchronize do RECENT_SESSIONS.any? do |s| s[:card_key] == card_key && (Time.now - s[:finished_at]) < window end end end |
#record_agent_dispatch(card_internal_id) ⇒ Object
274 275 276 277 278 279 280 281 |
# File 'lib/brainiac/sessions.rb', line 274 def record_agent_dispatch(card_internal_id) info = AGENT_DISPATCH_DEPTH[card_internal_id] if info info[:count] += 1 else AGENT_DISPATCH_DEPTH[card_internal_id] = { count: 1, last_human_at: Time.now } end end |
#record_human_comment(card_internal_id) ⇒ Object
262 263 264 |
# File 'lib/brainiac/sessions.rb', line 262 def record_human_comment(card_internal_id) AGENT_DISPATCH_DEPTH[card_internal_id] = { count: 0, last_human_at: Time.now } end |
#record_self_move(card_number) ⇒ Object
34 35 36 |
# File 'lib/brainiac/sessions.rb', line 34 def record_self_move(card_number) SELF_MOVES_MUTEX.synchronize { SELF_MOVES[card_number.to_s] = Time.now } end |
#record_skill_index_views ⇒ Object
Batch-record views for all skills in the index (called when prompt is built).
210 211 212 |
# File 'lib/brainiac/skills.rb', line 210 def record_skill_index_views build_skill_index.each { |s| record_skill_usage(s[:path], type: :view) } end |
#record_skill_usage(skill_path, type: :view) ⇒ Object
Record a view (skill index shown in prompt) or use (agent read the full skill).
186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 |
# File 'lib/brainiac/skills.rb', line 186 def record_skill_usage(skill_path, type: :view) usage_file = skill_usage_path(skill_path) data = if File.exist?(usage_file) JSON.parse(File.read(usage_file)) else { "views" => 0, "uses" => 0, "last_viewed" => nil, "last_used" => nil, "created_at" => Time.now.iso8601 } end now = Time.now.iso8601 case type when :view data["views"] = (data["views"] || 0) + 1 data["last_viewed"] = now when :use data["uses"] = (data["uses"] || 0) + 1 data["last_used"] = now end File.write(usage_file, JSON.pretty_generate(data)) rescue StandardError => e LOG.warn "[Skills] Failed to record usage for #{skill_path}: #{e.}" end |
#register_session(card_key, pid, log_file: nil, message_id: nil, channel_id: nil, supersede_key: nil, draft_files: nil, agent_name: nil) ⇒ Object
172 173 174 175 176 177 178 179 180 |
# File 'lib/brainiac/sessions.rb', line 172 def register_session(card_key, pid, log_file: nil, message_id: nil, channel_id: nil, supersede_key: nil, draft_files: nil, agent_name: nil) ACTIVE_SESSIONS_MUTEX.synchronize do ACTIVE_SESSIONS[card_key] = { pid: pid, started_at: Time.now, log_file: log_file, message_id: , channel_id: channel_id, supersede_key: supersede_key, draft_files: draft_files, agent_name: agent_name } end end |
#register_work_item(branch:, worktree: nil, project: nil, agent: nil, source: nil, source_data: {}) ⇒ Object
Register a new work item or update an existing one. Returns the work item ID.
221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 |
# File 'lib/brainiac/helpers.rb', line 221 def register_work_item(branch:, worktree: nil, project: nil, agent: nil, source: nil, source_data: {}) map = load_work_item_map # Check if a work item already exists for this branch existing_id = nil map.each do |wid, info| if info.is_a?(Hash) && info["branch"] == branch existing_id = wid break end end work_item_id = existing_id || generate_work_item_id(branch: branch) if existing_id # Update existing entry — merge in new source, update worktree/agent if provided map[work_item_id]["worktree"] = worktree if worktree map[work_item_id]["agent"] = agent if agent map[work_item_id]["sources"] ||= {} map[work_item_id]["sources"][source] = source_data if source else # Create new entry map[work_item_id] = { "id" => work_item_id, "branch" => branch, "worktree" => worktree, "project" => project, "agent" => agent, "sources" => source ? { source => source_data } : {} } end save_work_item_map(map) work_item_id end |
#register_work_item_source(source:, source_data:, work_item_id: nil, branch: nil) ⇒ Object
Add or update a source on an existing work item. Returns true if the work item was found and updated, false otherwise.
259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 |
# File 'lib/brainiac/helpers.rb', line 259 def register_work_item_source(source:, source_data:, work_item_id: nil, branch: nil) # rubocop:disable Naming/PredicateMethod map = load_work_item_map # Find by ID or branch target_id = work_item_id unless target_id map.each do |wid, info| if info.is_a?(Hash) && info["branch"] == branch target_id = wid break end end end return false unless target_id && map[target_id] map[target_id]["sources"] ||= {} map[target_id]["sources"][source] = source_data save_work_item_map(map) true end |
#reload_agent_registry!(force: false) ⇒ Object
50 51 52 53 54 55 |
# File 'lib/brainiac/agents.rb', line 50 def reload_agent_registry!(force: false) return unless file_changed?(AGENT_REGISTRY_FILE, force: force) AGENT_REGISTRY.replace(load_agent_registry) LOG.info "Reloaded agent registry: #{AGENT_REGISTRY.keys.join(", ")}" end |
#reload_cron_jobs!(force: false) ⇒ Object
Reload cron jobs from disk
191 192 193 194 195 196 197 198 199 |
# File 'lib/brainiac/cron.rb', line 191 def reload_cron_jobs!(force: false) return unless file_changed?(CRON_CONFIG_FILE, force: force) CRON_JOBS_MUTEX.synchronize do CRON_JOBS.clear CRON_JOBS.merge!(load_cron_jobs) end LOG.info "[Cron] Reloaded #{CRON_JOBS.size} cron jobs" end |
#reload_projects!(force: false) ⇒ Object
105 106 107 108 109 110 |
# File 'lib/brainiac/config.rb', line 105 def reload_projects!(force: false) return unless file_changed?(PROJECTS_FILE, force: force) PROJECTS.replace(load_projects_config) LOG.info "Reloaded projects configuration: #{PROJECTS.keys.join(", ")}" end |
#reload_user_registry!(force: false) ⇒ Object
19 20 21 22 23 24 |
# File 'lib/brainiac/users.rb', line 19 def reload_user_registry!(force: false) return unless file_changed?(USERS_FILE, force: force) USER_REGISTRY.replace(load_user_registry) LOG.info "Reloaded user registry: #{USER_REGISTRY["users"].size} users" end |
#remove_cron_job(id) ⇒ Object
Remove a cron job
243 244 245 246 247 248 249 250 251 |
# File 'lib/brainiac/cron.rb', line 243 def remove_cron_job(id) CRON_JOBS_MUTEX.synchronize do jobs = load_cron_jobs removed = jobs.delete(id.to_sym) save_cron_jobs(jobs) CRON_JOBS.delete(id.to_sym) removed ? { success: true } : { error: "Job not found" } end end |
#render_prompt(template, vars = {}, brain_context: "", card_context: "", agent_name: AI_AGENT_NAME, channel: nil, board_key: nil) ⇒ Object
rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 |
# File 'lib/brainiac/prompts.rb', line 178 def render_prompt(template, vars = {}, brain_context: "", card_context: "", agent_name: AI_AGENT_NAME, channel: nil, board_key: nil) result = "" result += "#{brain_context}\n" unless brain_context.empty? result += card_context unless card_context.empty? result += PROMPT_CORE # Channel prompt: check plugin-registered prompts first, then built-in plugin_prompt = Brainiac.channel_prompts[channel] result += plugin_prompt || CHANNEL_PROMPTS.fetch(channel, "") result += template # Pre-post comment check: plugin-registered plugin_pre_post = Brainiac.channel_pre_post_checks[channel] result += plugin_pre_post if plugin_pre_post # Reflection prompt — skip for now # result += PROMPT_REFLECTION vars["KNOWLEDGE_DIR"] ||= KNOWLEDGE_DIR vars["MEMORY_DIR"] ||= memory_dir_for(agent_name) vars["PERSONA_DIR"] ||= persona_dir_for(agent_name) vars["PERSONA_COLLECTION"] ||= persona_collection_for(agent_name) vars["AGENT_NAME"] ||= agent_name # Populate column IDs from board config, falling back to defaults if defined?(DEFAULT_COLUMN_IDS) DEFAULT_COLUMN_IDS.each do |col_name, default_id| var_name = "#{col_name.upcase}_COLUMN_ID" vars[var_name] ||= (board_key && board_column_id(board_key, col_name)) || default_id end end # Touch memory file if CARD_ID is present — ensures file exists before agent tries to read it if vars["CARD_ID"] memory_file = File.join(vars["MEMORY_DIR"], "card-#{vars["CARD_ID"]}.md") FileUtils.mkdir_p(vars["MEMORY_DIR"]) FileUtils.touch(memory_file) end roster = agent_roster roster_lines = roster.map { |_key, display| " - @#{display}" }.join("\n") vars["AGENT_ROSTER"] ||= roster_lines vars.each { |key, val| result.gsub!("{{#{key}}}", val.to_s) } result end |
#render_resume_prompt(comment_body:, comment_creator:, comment_id:, card_number: nil, agent_name: AI_AGENT_NAME) ⇒ Object
Lean prompt for resumed sessions. The previous session already has the full context (role, persona, knowledge, core instructions, channel prompts). We only send the new comment and any fresh card context so the agent knows what changed.
230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 |
# File 'lib/brainiac/prompts.rb', line 230 def render_resume_prompt(comment_body:, comment_creator:, comment_id:, card_number: nil, agent_name: AI_AGENT_NAME) # Touch memory file (same as render_prompt does) memory_dir = memory_dir_for(agent_name) card_id = card_number || "unknown" memory_file = File.join(memory_dir, "card-#{card_id}.md") FileUtils.mkdir_p(memory_dir) FileUtils.touch(memory_file) lines = [] lines << "## Resumed Session — New Follow-up Comment" lines << "" lines << "This is a continuation of your previous session on this card." lines << "All prior context, instructions, and your previous work are still in this conversation." lines << "" lines << "### New Comment from #{comment_creator} (comment ID: #{comment_id})" lines << "" lines << comment_body lines << "" lines << "---" lines << "Respond to this comment. All your previous instructions still apply." lines.join("\n") end |
#resolve_effort_level(level, allowed) ⇒ Object
If a level isn't in allowed_efforts, return the closest lower level.
590 591 592 593 594 595 596 597 598 599 600 |
# File 'lib/brainiac/helpers.rb', line 590 def resolve_effort_level(level, allowed) all_levels = %w[low medium high xhigh max] return level if allowed.include?(level) idx = all_levels.index(level) return nil unless idx # Walk down to find closest supported lower level idx.downto(0) { |i| return all_levels[i] if allowed.include?(all_levels[i]) } nil end |
#resolve_plugin_module(name) ⇒ Object
Resolve the plugin module for a given name. Tries Brainiac::Plugins::Whatsapp, Brainiac::Plugins::WhatsApp, etc.
86 87 88 89 90 91 92 93 94 95 96 97 98 99 |
# File 'lib/brainiac/plugins.rb', line 86 def resolve_plugin_module(name) return nil unless defined?(Brainiac::Plugins) # Try PascalCase (e.g. "whatsapp" -> "Whatsapp", "test-widget" -> "TestWidget") pascal = name.split(/[-_]/).map(&:capitalize).join return Brainiac::Plugins.const_get(pascal) if Brainiac::Plugins.const_defined?(pascal) # Try case-insensitive match (e.g. "WhatsApp" if the gem defines it that way) Brainiac::Plugins.constants.each do |const| return Brainiac::Plugins.const_get(const) if const.to_s.downcase == name.downcase end nil end |
#resolve_project_cli_config(project_config, cli_provider_override: nil, agent_name: nil) ⇒ Object
Resolve CLI config for a project by merging provider defaults with project overrides. Priority: cli_provider_override > agent-level cli_provider > project-level cli_provider > DEFAULT_PROJECT
47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
# File 'lib/brainiac/helpers.rb', line 47 def resolve_project_cli_config(project_config, cli_provider_override: nil, agent_name: nil) # Determine which CLI provider to use (priority: override > agent > project) provider_name = cli_provider_override provider_name ||= agent_cli_provider_for(agent_name) if agent_name provider_name ||= project_config["cli_provider"] provider_config = load_cli_provider(provider_name) DEFAULT_PROJECT.merge(provider_config).merge(project_config).tap do |resolved| # If an override or agent-level provider was used, it should win over the # project-level cli_provider's config. Re-apply the override provider on top. resolved.merge!(provider_config) if provider_name && provider_name != project_config["cli_provider"] end end |
#resolve_resume(resume, resolved, chdir) ⇒ Object
Determine whether a session resume should actually happen. Returns truthy (the resume flag string) if viable, false otherwise. Logs a message when resume was requested but isn't possible.
394 395 396 397 398 399 400 |
# File 'lib/brainiac/helpers.rb', line 394 def resolve_resume(resume, resolved, chdir) return false unless resume && resolved["resume_flag"] return resolved["resume_flag"] if prior_session_exists?(chdir, resolved["agent_cli"]) LOG.info "[Dispatch] Resume requested but not viable for #{resolved["agent_cli"]} in #{chdir} — starting fresh session" false end |
#resolve_session_agent_name(_card_key, info) ⇒ Object
33 34 35 36 37 |
# File 'lib/brainiac/routes/api.rb', line 33 def resolve_session_agent_name(_card_key, info) return info[:agent_name] if info[:agent_name] agent_display_name("Unknown") end |
#resume_viable?(project_config:, cli_provider: nil, agent_name: nil, chdir: nil) ⇒ Boolean
Public helper: check if resume is viable for a given project + CLI provider combo. Plugins should call this BEFORE building the prompt to decide between render_resume_prompt (lean) and render_prompt (full context).
Returns true if the CLI supports resume AND a prior session exists in the working directory. When this returns false, plugins should use render_prompt with thread history as card_context so the agent gets full context even though this is a follow-up message.
383 384 385 386 387 388 389 |
# File 'lib/brainiac/helpers.rb', line 383 def resume_viable?(project_config:, cli_provider: nil, agent_name: nil, chdir: nil) resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider, agent_name: agent_name) chdir ||= resolved["repo_path"] return false unless resolved["resume_flag"] prior_session_exists?(chdir, resolved["agent_cli"]) end |
#run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil, effort: nil, agent_name: nil, card_number: nil, comment_id: nil, source: nil, source_context: {}, skip_column_move: false, cli_provider: nil, resume: false) ⇒ Object
402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 |
# File 'lib/brainiac/helpers.rb', line 402 def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil, effort: nil, agent_name: nil, card_number: nil, comment_id: nil, source: nil, source_context: {}, skip_column_move: false, cli_provider: nil, resume: false) resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider, agent_name: agent_name) chdir ||= resolved["repo_path"] model ||= resolved["agent_model"] effort ||= resolved["agent_effort"] agent_config_name = agent_name&.downcase&.gsub(/[^a-z0-9-]/, "-") # Auto-resume: only if the provider supports it AND a prior session exists for this CLI here. should_resume = resolve_resume(resume, resolved, chdir) # Pre-dispatch hook — plugins can prep the working directory (e.g., copy config files, clean up) Brainiac.emit(:pre_dispatch, chdir: chdir, project_config: project_config, agent_name: agent_name) = Time.now.strftime("%Y%m%d-%H%M%S") log_file = File.join(chdir, "tmp/agent-#{log_name}-#{}.log") FileUtils.mkdir_p(File.dirname(log_file)) prompt_file = write_agent_prompt_file(prompt, log_name, ) cmd = build_agent_cmd(resolved, agent_config_name: agent_config_name, model: model, effort: effort, prompt_file: prompt_file, resume: should_resume) prompt_mode = resolved["prompt_mode"] || "stdin" spawn_env = agent_env_for(agent_name) LOG.info "Running #{resolved["agent_cli"]} in #{chdir}, logging to #{log_file}" LOG.info "Prompt written to #{prompt_file}" LOG.info "Command: #{cmd.join(" ")}#{" (resuming session)" if should_resume}" LOG.info "Injecting #{spawn_env.size} env var(s) for agent #{agent_name}: #{spawn_env.keys.join(", ")}" unless spawn_env.empty? project_key_for_restart = PROJECTS.find { |_k, v| v == project_config }&.first head_before, status_before = capture_git_state(chdir) if project_key_for_restart == "brainiac" pid = spawn(spawn_env, *cmd, chdir: chdir, **(prompt_mode == "stdin" ? { in: prompt_file } : {}), out: [log_file, "w"], err: %i[child out]) Thread.new do Process.wait(pid) handle_agent_completion( pid: pid, agent_cli: resolved["agent_cli"], agent_config_name: agent_config_name, agent_name: agent_name, log_file: log_file, log_name: log_name, prompt_file: prompt_file, chdir: chdir, source: source, source_context: source_context, project_config: project_config, card_number: card_number, skip_column_move: skip_column_move, head_before: head_before, status_before: status_before, project_key_for_restart: project_key_for_restart ) end LOG.info "#{resolved["agent_cli"]} started (pid: #{pid}, agent: #{agent_config_name || "default"}, " \ "model: #{model || "default"}), tail -f #{log_file}" [pid, log_file] end |
#run_cmd(*cmd, chdir:, env: {}) ⇒ Object
285 286 287 288 289 290 291 |
# File 'lib/brainiac/helpers.rb', line 285 def run_cmd(*cmd, chdir:, env: {}) LOG.info "Running: #{cmd.join(" ")} (in #{chdir})" stdout, stderr, status = Open3.capture3(env, *cmd, chdir: chdir) raise "Command failed (#{cmd.first}): #{stderr}" unless status.success? stdout end |
#run_project_hook(repo_path, hook_name, extra_env: {}) ⇒ Object
Run a project-level hook script from .brainiac/<hook_name> if it exists.
95 96 97 98 99 100 101 102 103 104 105 106 107 |
# File 'lib/brainiac/handlers/shared/git.rb', line 95 def run_project_hook(repo_path, hook_name, extra_env: {}) hook = File.join(repo_path, ".brainiac", hook_name) return unless File.exist?(hook) env = { "REPO_PATH" => repo_path }.merge(extra_env) LOG.info "Running .brainiac/#{hook_name} hook for #{repo_path}" output, status = Open3.capture2e(env, "bash", hook, chdir: repo_path) if status.success? LOG.info ".brainiac/#{hook_name} completed successfully" else LOG.warn ".brainiac/#{hook_name} failed (exit #{status.exitstatus}): #{output.strip}" end end |
#save_cron_jobs(jobs) ⇒ Object
Save cron jobs to config
185 186 187 188 |
# File 'lib/brainiac/cron.rb', line 185 def save_cron_jobs(jobs) FileUtils.mkdir_p(BRAINIAC_DIR) File.write(CRON_CONFIG_FILE, JSON.pretty_generate(jobs)) end |
#save_plugins_config(config) ⇒ Object
23 24 25 26 |
# File 'lib/brainiac/plugins.rb', line 23 def save_plugins_config(config) FileUtils.mkdir_p(BRAINIAC_DIR) File.write(PLUGINS_FILE, JSON.pretty_generate(config)) end |
#save_work_item_map(map) ⇒ Object
121 122 123 |
# File 'lib/brainiac/helpers.rb', line 121 def save_work_item_map(map) File.write(WORK_ITEM_MAP_FILE, JSON.pretty_generate(map)) end |
#self_move_recent?(card_number, window: 120) ⇒ Boolean
38 39 40 41 42 43 |
# File 'lib/brainiac/sessions.rb', line 38 def self_move_recent?(card_number, window: 120) SELF_MOVES_MUTEX.synchronize do t = SELF_MOVES[card_number.to_s] t && (Time.now - t) < window end end |
#send_notification(event, message, target: nil, channel: nil, agent: nil, **metadata) ⇒ Object
Send a notification via the configured channel.
33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
# File 'lib/brainiac/notifications.rb', line 33 def send_notification(event, , target: nil, channel: nil, agent: nil, **) config = notification_config_for(event) # Explicit params override config channel = (channel || config["channel"])&.to_sym target ||= config["target"] unless channel && target LOG.debug "[Notify] No channel/target configured for '#{event}', skipping" if LOG.debug? return false end agent ||= config["agent"] results = Brainiac.emit(:notify, event: event.to_sym, channel: channel, target: target, message: , agent: agent, **) if results.any? LOG.info "[Notify] #{event} delivered via #{channel} to #{target}" true else LOG.warn "[Notify] No handler responded for channel '#{channel}' (is the plugin installed?)" false end rescue StandardError => e LOG.error "[Notify] Failed to send '#{event}': #{e.}" false end |
#send_restart_notification(message) ⇒ Object
Send a notification about brainiac restart/startup.
23 24 25 26 27 |
# File 'lib/brainiac/restart.rb', line 23 def send_restart_notification() notify_restart() rescue StandardError => e LOG.warn "[Brainiac] Failed to send restart notification: #{e.}" end |
#session_active?(card_key) ⇒ Boolean
63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 |
# File 'lib/brainiac/sessions.rb', line 63 def session_active?(card_key) ACTIVE_SESSIONS_MUTEX.synchronize do info = ACTIVE_SESSIONS[card_key] return false unless info begin Process.kill(0, info[:pid]) true rescue Errno::ESRCH, Errno::EPERM archive_session(card_key, info) ACTIVE_SESSIONS.delete(card_key) false end end end |
#skill_index_for_prompt ⇒ Object
Build the skill index section for prompt injection. Only includes name + description (not full content) to keep tokens bounded.
88 89 90 91 92 93 94 95 96 97 98 |
# File 'lib/brainiac/skills.rb', line 88 def skill_index_for_prompt skills = build_skill_index return "" if skills.empty? lines = skills.map { |s| "- **#{s[:name]}**: #{s[:description]}" } <<~SECTION ## Available Skills The following procedural skills are available. To use one, read the full file at its path. #{lines.join("\n")} SECTION end |
#skill_usage_path(skill_path) ⇒ Object
181 182 183 |
# File 'lib/brainiac/skills.rb', line 181 def skill_usage_path(skill_path) skill_path.sub(/SKILL\.md$/, "SKILL.usage.json") end |
#slugify(title, max_length: 40) ⇒ Object
281 282 283 |
# File 'lib/brainiac/helpers.rb', line 281 def slugify(title, max_length: 40) title.downcase.gsub(/[^a-z0-9\s-]/, "").strip.gsub(/\s+/, "-").slice(0, max_length).chomp("-") end |
#start_brainiac_restart_monitor ⇒ Object
86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 |
# File 'lib/brainiac/restart.rb', line 86 def start_brainiac_restart_monitor Thread.new do LOG.info "[Brainiac] Restart monitor started, checking every 30s" loop do sleep 30 restart_needed = BRAINIAC_RESTART_MUTEX.synchronize { BRAINIAC_RESTART_STATE[:queued] } if restart_needed && !any_agents_running? execute_restart elsif restart_needed active_count = ACTIVE_SESSIONS_MUTEX.synchronize { ACTIVE_SESSIONS.size } LOG.info "[Brainiac] Restart queued but #{active_count} agent(s) still running, waiting..." end end end end |
#start_cron_thread ⇒ Object
Start cron background thread
636 637 638 639 640 641 642 643 644 645 |
# File 'lib/brainiac/cron.rb', line 636 def start_cron_thread return if CRON_THREAD[:ref]&.alive? reload_cron_jobs! CRON_THREAD[:ref] = Thread.new do LOG.info "[Cron] Starting cron thread..." cron_loop end end |
#toggle_cron_job(id, enabled) ⇒ Object
Enable/disable a cron job
254 255 256 257 258 259 260 261 262 263 264 265 266 |
# File 'lib/brainiac/cron.rb', line 254 def toggle_cron_job(id, enabled) CRON_JOBS_MUTEX.synchronize do jobs = load_cron_jobs job = jobs[id.to_sym] return { error: "Job not found" } unless job job[:enabled] = enabled jobs[id.to_sym] = job save_cron_jobs(jobs) CRON_JOBS[id.to_sym] = job { success: true, job: job } end end |
#touch_comment_cooldown(card_key) ⇒ Object
238 239 240 |
# File 'lib/brainiac/sessions.rb', line 238 def touch_comment_cooldown(card_key) LAST_COMMENT_TIMES[card_key] = Time.now end |
#touch_deploy_cooldown(env_key) ⇒ Object
252 253 254 |
# File 'lib/brainiac/sessions.rb', line 252 def touch_deploy_cooldown(env_key) LAST_DEPLOY_TIMES[env_key] = Time.now end |
#trust_version_manager(path, chdir:) ⇒ Object
Trust the version manager config in a directory (supports mise and asdf)
42 43 44 45 46 47 48 49 50 51 52 |
# File 'lib/brainiac/handlers/shared/git.rb', line 42 def trust_version_manager(path, chdir:) if system("which mise >/dev/null 2>&1") run_cmd("mise", "trust", path, chdir: chdir) elsif system("which asdf >/dev/null 2>&1") LOG.info "asdf detected — no explicit trust needed for #{path}" else LOG.info "No version manager (mise/asdf) found — skipping trust for #{path}" end rescue StandardError => e LOG.warn "Could not trust version manager in #{path}: #{e.}" end |
#uninstall_plugin(name) ⇒ Object
Uninstall a plugin gem and remove from plugins.json
136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 |
# File 'lib/brainiac/plugins.rb', line 136 def uninstall_plugin(name) gem_name = "brainiac-#{name}" unless installed_plugins.include?(name) puts "Plugin '#{name}' is not installed." return false end config = load_plugins_config config["plugins"].reject! { |p| (p.is_a?(Hash) ? p["name"] : p.to_s) == name } save_plugins_config(config) puts "Removed plugin '#{name}' from Brainiac." puts " The gem #{gem_name} is still installed system-wide." puts " To fully remove: gem uninstall #{gem_name}" puts " Restart the server to apply: brainiac restart" true end |
#update_cron_job(id, schedule: nil, notify_target: nil, notify_channel: nil, forum_title: nil, forum_reply_to_latest: nil) ⇒ Object
Update a cron job's schedule, notification target, and/or forum title
269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 |
# File 'lib/brainiac/cron.rb', line 269 def update_cron_job(id, schedule: nil, notify_target: nil, notify_channel: nil, forum_title: nil, forum_reply_to_latest: nil) return { error: "No updates provided" } if schedule.nil? && notify_target.nil? && forum_title.nil? && forum_reply_to_latest.nil? if schedule parsed = parse_cron_expression(schedule) return { error: "Invalid cron expression" } unless parsed end CRON_JOBS_MUTEX.synchronize do jobs = load_cron_jobs job = jobs[id.to_sym] return { error: "Job not found" } unless job if schedule job[:schedule] = schedule job[:parsed] = parsed end job[:notify_target] = notify_target if notify_target job[:notify_channel] = notify_channel if notify_channel job[:forum_title] = forum_title if forum_title job[:forum_reply_to_latest] = forum_reply_to_latest unless forum_reply_to_latest.nil? jobs[id.to_sym] = job save_cron_jobs(jobs) CRON_JOBS[id.to_sym] = job { success: true, job: job } end end |
#update_cron_job_state(job) ⇒ Object
Update cron job state after execution (last_run, execution_count, auto-disable)
507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 |
# File 'lib/brainiac/cron.rb', line 507 def update_cron_job_state(job) CRON_JOBS_MUTEX.synchronize do jobs = load_cron_jobs job_data = jobs[job[:id].to_sym] return unless job_data job_data[:last_run] = Time.now.iso8601 job_data[:execution_count] = (job_data[:execution_count] || 0) + 1 if job[:parsed][:one_time] job_data[:enabled] = false CRON_JOBS[job[:id].to_sym][:enabled] = false LOG.info "[Cron] Auto-disabled one-time job: #{job[:id]}" elsif job[:repeat_count] && job_data[:execution_count] >= job[:repeat_count] job_data[:enabled] = false CRON_JOBS[job[:id].to_sym][:enabled] = false LOG.info "[Cron] Auto-disabled job #{job[:id]} after #{job[:repeat_count]} executions" end save_cron_jobs(jobs) CRON_JOBS[job[:id].to_sym][:last_run] = Time.now.iso8601 CRON_JOBS[job[:id].to_sym][:execution_count] = job_data[:execution_count] end end |
#wait_for_session?(card_key) ⇒ Boolean
82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 |
# File 'lib/brainiac/sessions.rb', line 82 def wait_for_session?(card_key) return true unless session_active?(card_key) LOG.info "Waiting for active session on #{card_key} to finish..." elapsed = 0 while session_active?(card_key) && elapsed < SESSION_WAIT_MAX sleep SESSION_WAIT_INTERVAL elapsed += SESSION_WAIT_INTERVAL LOG.info "Still waiting on #{card_key} (#{elapsed}s elapsed)" end if session_active?(card_key) LOG.warn "Timed out waiting for session on #{card_key} after #{SESSION_WAIT_MAX}s" false else LOG.info "Session on #{card_key} finished after ~#{elapsed}s, proceeding" true end end |
#work_item_merged?(card_number) ⇒ Boolean
302 303 304 305 306 307 |
# File 'lib/brainiac/helpers.rb', line 302 def work_item_merged?(card_number) MERGED_CARDS_MUTEX.synchronize do ts = MERGED_CARDS[card_number.to_s] ts && (Time.now - ts < 600) end end |
#write_agent_prompt_file(prompt, log_name, timestamp) ⇒ Object
Write agent prompt to a temp file, return path.
460 461 462 463 464 465 466 |
# File 'lib/brainiac/helpers.rb', line 460 def write_agent_prompt_file(prompt, log_name, ) prompt_dir = File.join(BRAINIAC_DIR, "tmp") FileUtils.mkdir_p(prompt_dir) prompt_file = File.join(prompt_dir, "prompt-#{log_name}-#{}.md") File.write(prompt_file, prompt) prompt_file end |
#write_cron_prompt_file(job, prompt_content, timestamp) ⇒ Object
Write cron prompt to a temp file, return path.
582 583 584 585 586 587 588 |
# File 'lib/brainiac/cron.rb', line 582 def write_cron_prompt_file(job, prompt_content, ) prompt_dir = File.join(BRAINIAC_DIR, "tmp") FileUtils.mkdir_p(prompt_dir) prompt_file = File.join(prompt_dir, "prompt-cron-#{job[:id]}-#{}.md") File.write(prompt_file, prompt_content) prompt_file end |