zillacore 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +12 -0
  4. data/Gemfile +3 -0
  5. data/Gemfile.lock +126 -0
  6. data/README.md +1166 -0
  7. data/Rakefile +12 -0
  8. data/bin/zillacore +1521 -0
  9. data/certs/stowzilla.pem +26 -0
  10. data/docs/waybar-config.md +96 -0
  11. data/lib/user_registry.rb +159 -0
  12. data/lib/zillacore/agents.rb +203 -0
  13. data/lib/zillacore/brain.rb +197 -0
  14. data/lib/zillacore/card_index.rb +389 -0
  15. data/lib/zillacore/config.rb +263 -0
  16. data/lib/zillacore/cron.rb +629 -0
  17. data/lib/zillacore/deployments.rb +258 -0
  18. data/lib/zillacore/handlers/discord.rb +1643 -0
  19. data/lib/zillacore/handlers/fizzy.rb +1249 -0
  20. data/lib/zillacore/handlers/github.rb +598 -0
  21. data/lib/zillacore/handlers/zoho.rb +487 -0
  22. data/lib/zillacore/helpers.rb +760 -0
  23. data/lib/zillacore/planning.rb +237 -0
  24. data/lib/zillacore/prompts.rb +620 -0
  25. data/lib/zillacore/sessions.rb +282 -0
  26. data/lib/zillacore/skills.rb +276 -0
  27. data/lib/zillacore/users.rb +76 -0
  28. data/lib/zillacore/version.rb +6 -0
  29. data/lib/zillacore/zoho_mail_api.rb +109 -0
  30. data/lib/zillacore.rb +10 -0
  31. data/monitor/daemon.rb +99 -0
  32. data/monitor/deploy-env-macos.rb +131 -0
  33. data/monitor/menubar.rb +295 -0
  34. data/monitor/open-action.sh +15 -0
  35. data/monitor/setup-menubar.rb +78 -0
  36. data/monitor/setup-waybar-deploy-envs.rb +121 -0
  37. data/monitor/setup-waybar-deployments.rb +96 -0
  38. data/monitor/setup-waybar-module.rb +113 -0
  39. data/monitor/setup-xbar-plugin.rb +35 -0
  40. data/monitor/view-logs-macos.rb +210 -0
  41. data/monitor/view-logs-rofi.rb +194 -0
  42. data/monitor/view-logs.rb +119 -0
  43. data/monitor/waybar-config-updater.rb +56 -0
  44. data/monitor/waybar-deploy-env.rb +206 -0
  45. data/monitor/waybar-deployments.rb +239 -0
  46. data/monitor/waybar.rb +146 -0
  47. data/monitor/xbar.3s.rb +179 -0
  48. data/receiver.rb +956 -0
  49. data/templates/agents.json.example +10 -0
  50. data/templates/discord.json.example +17 -0
  51. data/templates/fizzy.json.example +24 -0
  52. data/templates/github.json.example +4 -0
  53. data/templates/testflight.json.example +8 -0
  54. data/templates/users.json.example +121 -0
  55. data/templates/zoho.json.example +27 -0
  56. data/views/dashboard.erb +437 -0
  57. data/zillacore.gemspec +30 -0
  58. data.tar.gz.sig +2 -0
  59. metadata +235 -0
  60. metadata.gz.sig +0 -0
@@ -0,0 +1,26 @@
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIEdDCCAtygAwIBAgIBATANBgkqhkiG9w0BAQsFADBAMQ4wDAYDVQQDDAVhZ2Vu
3
+ dDEZMBcGCgmSJomT8ixkARkWCXN0b3d6aWxsYTETMBEGCgmSJomT8ixkARkWA2Nv
4
+ bTAeFw0yNjA2MDgxOTExNTlaFw0yNzA2MDgxOTExNTlaMEAxDjAMBgNVBAMMBWFn
5
+ ZW50MRkwFwYKCZImiZPyLGQBGRYJc3Rvd3ppbGxhMRMwEQYKCZImiZPyLGQBGRYD
6
+ Y29tMIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAupBquKI/4WvXOgND
7
+ pXyqH2GllZs1wG4TWWdn/DoMg45UoCwD+AWEuGrIdInBCpPN8vEJNJWPoM/RrU+b
8
+ xRBZT4uUk00bnZRW2SYh5GJSqBoBR+rWc2DGkXyGfdRU2sQvkB0+is6ChgQ61WMM
9
+ 33LE9+loBlVsZ6EVtrc18Uh2OW0mJpe0hN2nmBrxZqqOZigxC4DKRMFHvpRkxSb6
10
+ mD4kit1AcwX9NEWJsXxrPaetL/SB/VbXaEZX93XAvp6USaXvCWt4slkDS2mIvqtn
11
+ 9DtGC43LFC7SDGbnsG9PVenQgVCi8UWFPUAab0PqZSlmi3Qlbhw8qTGPp5Cbv4vz
12
+ qjC2UGPOQigA/7lbbGRhCohMrjOVHMAQwkcgiIqtolUoYlnvPMIy+m3pdvgDv/PH
13
+ bsZGvXQ7i0458xsmp1vaKthZocVAR+GboHbuIiYPUnO45ccXUQ00x6365tTe7mZi
14
+ NvmUYdAGbQmVvFqyxF7IYA6sF74L2Lstu0knSfss557bAe1HAgMBAAGjeTB3MAkG
15
+ A1UdEwQCMAAwCwYDVR0PBAQDAgSwMB0GA1UdDgQWBBSnxTL/lNBCeLqpeVIX6AUY
16
+ kel4zjAeBgNVHREEFzAVgRNhZ2VudEBzdG93emlsbGEuY29tMB4GA1UdEgQXMBWB
17
+ E2FnZW50QHN0b3d6aWxsYS5jb20wDQYJKoZIhvcNAQELBQADggGBACm9Fjit/UCv
18
+ FxlKqeiCTIG94cIx+QrWAOJSx9knKydwUec1u04D/DbfZjTn3C2Bj227QgxeUn+6
19
+ if3e2v7zAk1896hLmGYzML0+nxQPb0vmtdLR7HETUlSKTVabcv1fbwLyjsuGrBvk
20
+ y51vOEzUEZ508a9yepLYqrQu1kOju4d57c9oA5l3H0mMKWz7av9tFj0B+STvuaWk
21
+ HRYDWc5HgOEVTyV+w0uFt2Kw4OCb8C42uSvC5RfYYtw78MSP+5Ru+LXJ7XOtmuN0
22
+ E6GVmofQ17ig9O3rgfFbMendSInrRmvPIGswvM1yivq9NOllFbdck2OJKPx6FCJF
23
+ 7SJIkXQfc9P4B5iASIV1d1FsE0YX+g3jHXPJK/4mGL5bAyBKzpMfQB/mg6vQBzkh
24
+ aOKPwcreFj7TznBl89R5tNS9wZQfPVR98zgPyocddWhK18eQNMSBUnv4eeJ8PPbk
25
+ DovL+G8ajHDZ9fjH/+GVYHEMuiVdLarXrKJpHC1VfGTTUAp4NSEpUQ==
26
+ -----END CERTIFICATE-----
@@ -0,0 +1,96 @@
1
+ # Agent Display Configuration (Waybar / xbar)
2
+
3
+ This file configures how agents appear in the status bar — waybar on Linux, xbar on macOS.
4
+
5
+ **Location:** `~/.zillacore/waybar.json`
6
+
7
+ ## Example Configuration
8
+
9
+ ```json
10
+ {
11
+ "agents": [
12
+ {
13
+ "name": "GLaDOS",
14
+ "emoji": "🤖",
15
+ "color": "blue"
16
+ },
17
+ {
18
+ "name": "Galen",
19
+ "emoji": "🛠️",
20
+ "color": "green"
21
+ },
22
+ {
23
+ "name": "Threepio",
24
+ "emoji": "📝",
25
+ "color": "yellow"
26
+ },
27
+ {
28
+ "name": "Sheogorath",
29
+ "emoji": "🎭",
30
+ "color": "purple"
31
+ },
32
+ {
33
+ "name": "Kaylee",
34
+ "emoji": "🔧",
35
+ "color": "pink"
36
+ },
37
+ {
38
+ "name": "Avon",
39
+ "emoji": "🔐",
40
+ "color": "red"
41
+ },
42
+ {
43
+ "name": "Sleeper Service",
44
+ "emoji": "💤",
45
+ "color": "cyan"
46
+ }
47
+ ],
48
+ "default_emoji": "❓",
49
+ "schema_version": "1.0"
50
+ }
51
+ ```
52
+
53
+ ## Fields
54
+
55
+ - **name**: Agent name (must match agent registry)
56
+ - **emoji**: Display emoji for waybar
57
+ - **color**: Terminal color for logs (red, green, blue, yellow, cyan, magenta, white)
58
+ - **default_emoji**: Fallback emoji when agent is unknown
59
+ - **schema_version**: Config format version
60
+
61
+ ## Usage
62
+
63
+ Monitor scripts automatically load this file:
64
+ - `monitor/waybar.rb` - Waybar status display (Linux)
65
+ - `monitor/xbar.3s.rb` - xbar menu bar plugin (macOS)
66
+ - `monitor/daemon.rb` - Background monitor daemon
67
+ - `monitor/view-logs.rb` - Log viewer
68
+ - `monitor/view-logs-rofi.rb` - Rofi log selector
69
+
70
+ ### Linux (Waybar)
71
+
72
+ ```bash
73
+ ruby monitor/setup-waybar-module.rb # One-time setup
74
+ omarchy restart waybar # Restart waybar
75
+ ```
76
+
77
+ ### macOS (xbar)
78
+
79
+ Requires [xbar](https://xbarapp.com) (free, formerly BitBar).
80
+
81
+ ```bash
82
+ ruby monitor/setup-xbar-plugin.rb # One-time setup (symlinks plugin)
83
+ # Restart xbar to activate
84
+ ```
85
+
86
+ The xbar plugin reads from the same daemon socket as waybar. Make sure the monitor daemon is running:
87
+
88
+ ```bash
89
+ ruby monitor/daemon.rb &
90
+ ```
91
+
92
+ After editing agent config, restart zillacore:
93
+ ```bash
94
+ zillacore restart
95
+ ```
96
+
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+
6
+ # UserRegistry - Centralized user identity tracking
7
+ #
8
+ # Resolves user identities across platforms (Discord, GitHub, Fizzy)
9
+ # and provides canonical names, aliases, and relationships.
10
+ #
11
+ # Usage:
12
+ # registry = UserRegistry.new
13
+ # user = registry.find_by_discord_id('832331260088287242')
14
+ # puts user['canonical_name'] # => "Adam Dalton"
15
+ # puts user['identities']['github']['username'] # => "dalton"
16
+ #
17
+ class UserRegistry
18
+ USERS_FILE = File.expand_path("~/.zillacore/users.json")
19
+
20
+ def initialize
21
+ @data = load_data
22
+ end
23
+
24
+ # Find user by Discord user ID
25
+ def find_by_discord_id(user_id)
26
+ @data["users"].find { |u| u.dig("identities", "discord", "user_id") == user_id.to_s }
27
+ end
28
+
29
+ # Find user by Discord username
30
+ def find_by_discord_username(username)
31
+ @data["users"].find { |u| u.dig("identities", "discord", "username") == username.to_s }
32
+ end
33
+
34
+ # Find user by GitHub username
35
+ def find_by_github_username(username)
36
+ @data["users"].find { |u| u.dig("identities", "github", "username") == username.to_s }
37
+ end
38
+
39
+ # Find user by Fizzy username
40
+ def find_by_fizzy_username(username)
41
+ @data["users"].find { |u| u.dig("identities", "fizzy", "username") == username.to_s }
42
+ end
43
+
44
+ # Find user by canonical name
45
+ def find_by_canonical_name(name)
46
+ @data["users"].find { |u| u["canonical_name"].downcase == name.downcase }
47
+ end
48
+
49
+ # Find user by any identifier (tries all platforms)
50
+ def find(identifier)
51
+ find_by_discord_id(identifier) ||
52
+ find_by_discord_username(identifier) ||
53
+ find_by_github_username(identifier) ||
54
+ find_by_fizzy_username(identifier) ||
55
+ find_by_canonical_name(identifier)
56
+ end
57
+
58
+ # Get all users
59
+ def all
60
+ @data["users"]
61
+ end
62
+
63
+ # Get all human users (exclude AI agents)
64
+ def humans
65
+ @data["users"].reject { |u| u["notes"]&.include?("AI agent") }
66
+ end
67
+
68
+ # Get all AI agents
69
+ def agents
70
+ @data["users"].select { |u| u["notes"]&.include?("AI agent") }
71
+ end
72
+
73
+ # Reload data from disk
74
+ def reload!
75
+ @data = load_data
76
+ end
77
+
78
+ private
79
+
80
+ def load_data
81
+ return { "users" => [] } unless File.exist?(USERS_FILE)
82
+
83
+ JSON.parse(File.read(USERS_FILE))
84
+ rescue JSON::ParserError => e
85
+ warn "Failed to parse #{USERS_FILE}: #{e.message}"
86
+ { "users" => [] }
87
+ end
88
+ end
89
+
90
+ # CLI interface when run directly
91
+ if __FILE__ == $PROGRAM_NAME
92
+ require "optparse"
93
+
94
+ options = {}
95
+ OptionParser.new do |opts|
96
+ opts.banner = "Usage: user_registry.rb [options] [identifier]"
97
+
98
+ opts.on("-d", "--discord-id ID", "Find by Discord user ID") do |id|
99
+ options[:discord_id] = id
100
+ end
101
+
102
+ opts.on("-u", "--discord-username USERNAME", "Find by Discord username") do |username|
103
+ options[:discord_username] = username
104
+ end
105
+
106
+ opts.on("-g", "--github USERNAME", "Find by GitHub username") do |username|
107
+ options[:github] = username
108
+ end
109
+
110
+ opts.on("-f", "--fizzy USERNAME", "Find by Fizzy username") do |username|
111
+ options[:fizzy] = username
112
+ end
113
+
114
+ opts.on("-l", "--list", "List all users") do
115
+ options[:list] = true
116
+ end
117
+
118
+ opts.on("--humans", "List only human users") do
119
+ options[:humans] = true
120
+ end
121
+
122
+ opts.on("--agents", "List only AI agents") do
123
+ options[:agents] = true
124
+ end
125
+
126
+ opts.on("-h", "--help", "Show this help") do
127
+ puts opts
128
+ exit
129
+ end
130
+ end.parse!
131
+
132
+ registry = UserRegistry.new
133
+
134
+ if options[:list]
135
+ puts JSON.pretty_generate(registry.all)
136
+ elsif options[:humans]
137
+ puts JSON.pretty_generate(registry.humans)
138
+ elsif options[:agents]
139
+ puts JSON.pretty_generate(registry.agents)
140
+ elsif options[:discord_id]
141
+ user = registry.find_by_discord_id(options[:discord_id])
142
+ puts user ? JSON.pretty_generate(user) : "User not found"
143
+ elsif options[:discord_username]
144
+ user = registry.find_by_discord_username(options[:discord_username])
145
+ puts user ? JSON.pretty_generate(user) : "User not found"
146
+ elsif options[:github]
147
+ user = registry.find_by_github_username(options[:github])
148
+ puts user ? JSON.pretty_generate(user) : "User not found"
149
+ elsif options[:fizzy]
150
+ user = registry.find_by_fizzy_username(options[:fizzy])
151
+ puts user ? JSON.pretty_generate(user) : "User not found"
152
+ elsif ARGV[0]
153
+ user = registry.find(ARGV[0])
154
+ puts user ? JSON.pretty_generate(user) : "User not found"
155
+ else
156
+ puts "No search criteria provided. Use --help for usage."
157
+ exit 1
158
+ end
159
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Agent registry, discovery, identity, mention detection, and env injection.
4
+ #
5
+ # The registry at ~/.zillacore/agents.json uses a generic `env` hash so any
6
+ # environment variable can be set per-agent:
7
+ #
8
+ # {
9
+ # "galen": {
10
+ # "fizzy_name": "Galen",
11
+ # "local": true,
12
+ # "env": {
13
+ # "FIZZY_TOKEN": "fizzy_abc...",
14
+ # "DISCORD_BOT_TOKEN": "Bot_abc..."
15
+ # }
16
+ # }
17
+ # }
18
+ #
19
+ # The "local" flag marks agents that this machine should dispatch work for
20
+ # (card assignments). Agents without "local": true are still known for
21
+ # mention detection, display names, tokens, and cross-agent interactions —
22
+ # they just won't pick up card assignments on this machine.
23
+ #
24
+ # Legacy format with top-level `fizzy_token` / `discord_bot_token` keys is
25
+ # auto-migrated into the `env` hash at load time.
26
+
27
+ def load_agent_registry
28
+ if File.exist?(AGENT_REGISTRY_FILE)
29
+ raw_registry = JSON.parse(File.read(AGENT_REGISTRY_FILE))
30
+ LOG.info "Loaded agent registry (#{raw_registry.size} agents) from #{AGENT_REGISTRY_FILE}"
31
+
32
+ # Normalize keys: convert to lowercase, replace non-alphanumeric with hyphens
33
+ registry = {}
34
+ raw_registry.each do |key, entry|
35
+ normalized_key = key.downcase.gsub(/[^a-z0-9-]/, "-")
36
+ if registry.key?(normalized_key) && registry[normalized_key] != entry
37
+ LOG.warn "Duplicate agent key after normalization: '#{key}' → '#{normalized_key}' (already exists)"
38
+ end
39
+ registry[normalized_key] = entry
40
+ end
41
+
42
+ # Migrate legacy keys into env hash
43
+ registry.each_value do |entry|
44
+ next unless entry.is_a?(Hash)
45
+
46
+ entry["env"] ||= {}
47
+ # Migrate fizzy_token → FIZZY_TOKEN
48
+ if (ft = entry.delete("fizzy_token"))
49
+ entry["env"]["FIZZY_TOKEN"] ||= ft
50
+ end
51
+ # Migrate discord_bot_token → DISCORD_BOT_TOKEN
52
+ if (dt = entry.delete("discord_bot_token"))
53
+ entry["env"]["DISCORD_BOT_TOKEN"] ||= dt
54
+ end
55
+ end
56
+ return registry
57
+ end
58
+
59
+ if File.exist?(AGENT_TOKENS_FILE)
60
+ tokens = JSON.parse(File.read(AGENT_TOKENS_FILE))
61
+ LOG.info "Loaded legacy agent tokens (#{tokens.size} agents) from #{AGENT_TOKENS_FILE}"
62
+ return tokens.transform_values { |token| { "env" => { "FIZZY_TOKEN" => token } } }
63
+ end
64
+
65
+ {}
66
+ rescue JSON::ParserError => e
67
+ LOG.error "Failed to parse agent registry: #{e.message}"
68
+ {}
69
+ end
70
+
71
+ AGENT_REGISTRY = load_agent_registry
72
+
73
+ def reload_agent_registry!(force: false)
74
+ return unless file_changed?(AGENT_REGISTRY_FILE, force: force)
75
+
76
+ AGENT_REGISTRY.replace(load_agent_registry)
77
+ LOG.info "Reloaded agent registry: #{AGENT_REGISTRY.keys.join(", ")}"
78
+ end
79
+
80
+ # Get the env hash for an agent. Returns {} if none configured.
81
+ def agent_env_for(agent_name)
82
+ return {} unless agent_name
83
+
84
+ key = agent_name.downcase.gsub(/[^a-z0-9-]/, "-")
85
+ entry = AGENT_REGISTRY[key]
86
+ return {} unless entry.is_a?(Hash)
87
+
88
+ entry["env"] || {}
89
+ end
90
+
91
+ # Get a specific env var for an agent. Returns nil if not set.
92
+ def agent_env_var(agent_name, var_name)
93
+ agent_env_for(agent_name)[var_name]
94
+ end
95
+
96
+ # Convenience: get the Fizzy token for an agent.
97
+ def fizzy_token_for(agent_name)
98
+ agent_env_var(agent_name, "FIZZY_TOKEN")
99
+ end
100
+
101
+ # Convenience: build env hash for fizzy CLI calls (backward compat).
102
+ # Falls back to default agent token when the given agent has no token.
103
+ def fizzy_env_for(agent_name)
104
+ token = fizzy_token_for(agent_name) || fizzy_token_for(AI_AGENT_NAME)
105
+ token ? { "FIZZY_TOKEN" => token } : {}
106
+ end
107
+
108
+ def default_fizzy_env
109
+ fizzy_env_for(AI_AGENT_NAME)
110
+ end
111
+
112
+ def fizzy_display_name(agent_name)
113
+ return agent_name unless agent_name
114
+
115
+ key = agent_name.downcase.gsub(/[^a-z0-9-]/, "-")
116
+ entry = AGENT_REGISTRY[key]
117
+ return agent_name unless entry.is_a?(Hash)
118
+
119
+ entry["fizzy_name"] || agent_name
120
+ end
121
+
122
+ def agent_roster
123
+ roster = {}
124
+ all_agent_names.each { |name| roster[name.downcase] = fizzy_display_name(name) }
125
+ roster
126
+ end
127
+
128
+ def discover_kiro_agents
129
+ return [] unless File.directory?(KIRO_AGENTS_DIR)
130
+
131
+ Dir.glob(File.join(KIRO_AGENTS_DIR, "*.json")).map { |path| File.basename(path, ".json") }
132
+ rescue StandardError => e
133
+ LOG.error "Failed to scan kiro agents directory: #{e.message}"
134
+ []
135
+ end
136
+
137
+ def agent_name_for(project_config)
138
+ project_config["agent_name"] || AI_AGENT_NAME
139
+ end
140
+
141
+ def all_agent_names
142
+ names = Set.new([AI_AGENT_NAME])
143
+ PROJECTS.each_value { |config| names << config["agent_name"] if config["agent_name"] }
144
+ discover_kiro_agents.each { |name| names << name.capitalize }
145
+ # Include agents from the registry (with their fizzy_name if specified)
146
+ AGENT_REGISTRY.each do |key, entry|
147
+ names << (entry["fizzy_name"] || key.capitalize)
148
+ end
149
+ names
150
+ end
151
+
152
+ # Agents marked "local": true in the registry — only these should pick up
153
+ # card assignments on this machine. All other agents are still "known" for
154
+ # mention detection, tokens, and display names.
155
+ def local_agent_names
156
+ names = Set.new
157
+ # The default AI_AGENT_NAME is always local (it's this machine's primary agent)
158
+ names << AI_AGENT_NAME
159
+ # Project-configured agents are local by definition
160
+ PROJECTS.each_value { |config| names << config["agent_name"] if config["agent_name"] }
161
+ # kiro-cli agent configs on disk are local
162
+ discover_kiro_agents.each { |name| names << name.capitalize }
163
+ # Registry agents only if explicitly marked local
164
+ AGENT_REGISTRY.each do |key, entry|
165
+ next unless entry.is_a?(Hash) && entry["local"]
166
+
167
+ names << (entry["fizzy_name"] || key.capitalize)
168
+ end
169
+ names
170
+ end
171
+
172
+ def detect_mentioned_agent(text)
173
+ downcased = text.downcase
174
+ # Exact full-name match first (highest priority)
175
+ all_agent_names.each do |name|
176
+ return name if downcased.include?("@#{name.downcase}")
177
+
178
+ # Fizzy renders mentions using first name only (e.g. "@Sleeper" not "@Sleeper Service").
179
+ # Fall back to matching the first word of multi-word agent names.
180
+ first_word = name.split.first.downcase
181
+ next if first_word == name.downcase # already checked above
182
+ return name if downcased.include?("@#{first_word}")
183
+ end
184
+ nil
185
+ end
186
+
187
+ def detect_mentioned_user_ids(text)
188
+ return [] unless FIZZY_CONFIG["authorized_users"]
189
+
190
+ mentioned_ids = []
191
+ FIZZY_CONFIG["authorized_users"].each do |user|
192
+ name = user["name"]
193
+ mentioned_ids << user["id"] if text.downcase.include?("@#{name.downcase}")
194
+ end
195
+ mentioned_ids
196
+ end
197
+
198
+ def comment_from_agent?(name)
199
+ return false unless name
200
+
201
+ downcased = name.downcase
202
+ all_agent_names.any? { |agent| agent.downcase == downcased }
203
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Brain (long-term memory via qmd) — query, context building, and git sync.
4
+
5
+ BRAIN_SYNC_MUTEX = Mutex.new
6
+ BRAIN_LAST_PULL = { at: nil }
7
+
8
+ def memory_dir_for(agent_name)
9
+ File.join(MEMORY_BASE_DIR, agent_name.downcase.gsub(/[^a-z0-9-]/, "-"))
10
+ end
11
+
12
+ def persona_dir_for(agent_name)
13
+ File.join(PERSONA_BASE_DIR, agent_name.downcase.gsub(/[^a-z0-9-]/, "-"))
14
+ end
15
+
16
+ def persona_collection_for(agent_name)
17
+ "#{agent_name.downcase.gsub(/[^a-z0-9-]/, "-")}-persona"
18
+ end
19
+
20
+ # --- Brain git sync ---
21
+
22
+ def brain_git_repo?
23
+ File.directory?(File.join(BRAIN_BASE_DIR, ".git"))
24
+ end
25
+
26
+ # Internal pull logic without mutex (for use inside synchronized blocks)
27
+ def brain_pull_internal(force: false)
28
+ return unless brain_git_repo?
29
+
30
+ # Skip if we pulled within the last 30 seconds (avoid hammering on rapid-fire sessions)
31
+ unless force
32
+ last = BRAIN_LAST_PULL[:at]
33
+ return if last && (Time.now - last) < 30
34
+ end
35
+
36
+ # Stash any uncommitted changes, pull, then pop
37
+ status, = Open3.capture2("git", "status", "--porcelain", chdir: BRAIN_BASE_DIR)
38
+ has_changes = !status.strip.empty?
39
+
40
+ if has_changes
41
+ Open3.capture2("git", "add", "-A", chdir: BRAIN_BASE_DIR)
42
+ Open3.capture2("git", "stash", chdir: BRAIN_BASE_DIR)
43
+ end
44
+
45
+ output, pull_status = Open3.capture2e("git", "pull", "--rebase", "--autostash", chdir: BRAIN_BASE_DIR)
46
+ if pull_status.success?
47
+ LOG.info "[Brain] Pulled latest changes"
48
+ else
49
+ LOG.warn "[Brain] Pull failed: #{output.strip}"
50
+ # Abort rebase if it got stuck
51
+ Open3.capture2("git", "rebase", "--abort", chdir: BRAIN_BASE_DIR)
52
+ end
53
+
54
+ Open3.capture2("git", "stash", "pop", chdir: BRAIN_BASE_DIR) if has_changes
55
+
56
+ BRAIN_LAST_PULL[:at] = Time.now
57
+ end
58
+
59
+ # Pull latest brain changes. Safe to call frequently — skips if pulled recently.
60
+ # Uses rebase to keep history clean and auto-resolves conflicts by keeping both sides.
61
+ def brain_pull(force: false)
62
+ return unless brain_git_repo?
63
+
64
+ BRAIN_SYNC_MUTEX.synchronize do
65
+ brain_pull_internal(force: force)
66
+ end
67
+ rescue StandardError => e
68
+ LOG.warn "[Brain] Pull error: #{e.message}"
69
+ end
70
+
71
+ # Commit and push any brain changes. Called after agent sessions complete.
72
+ def brain_push(message: "brain update", retries: 3)
73
+ return unless brain_git_repo?
74
+
75
+ BRAIN_SYNC_MUTEX.synchronize do
76
+ # Check for changes
77
+ status, = Open3.capture2("git", "status", "--porcelain", chdir: BRAIN_BASE_DIR)
78
+ return if status.strip.empty?
79
+
80
+ Open3.capture2("git", "add", "-A", chdir: BRAIN_BASE_DIR)
81
+ Open3.capture2("git", "commit", "-m", message, chdir: BRAIN_BASE_DIR)
82
+
83
+ retries.times do |attempt|
84
+ brain_pull_internal(force: true) if attempt.positive?
85
+
86
+ _, push_status = Open3.capture2e("git", "push", chdir: BRAIN_BASE_DIR)
87
+ if push_status.success?
88
+ LOG.info "[Brain] Pushed changes#{" (retry #{attempt})" if attempt.positive?}"
89
+ break
90
+ end
91
+
92
+ sleep(2**attempt) if attempt < retries - 1
93
+ end
94
+
95
+ LOG.warn "[Brain] Push failed after #{retries} attempts"
96
+ end
97
+ rescue StandardError => e
98
+ LOG.warn "[Brain] Push error: #{e.message}"
99
+ end
100
+
101
+ def query_brain(search_terms, agent_name: AI_AGENT_NAME, scope: :knowledge, max_results: 5)
102
+ return "" unless system("which qmd > /dev/null 2>&1")
103
+
104
+ collection = case scope
105
+ when :persona then persona_collection_for(agent_name)
106
+ else KNOWLEDGE_COLLECTION
107
+ end
108
+
109
+ output, status = Open3.capture2("qmd", "search", search_terms, "-c", collection, "-n", max_results.to_s, "--md")
110
+ return "" unless status.success? && !output.strip.empty?
111
+
112
+ output.strip
113
+ rescue StandardError => e
114
+ LOG.warn "Brain query failed (#{scope}, #{agent_name}): #{e.message}"
115
+ ""
116
+ end
117
+
118
+ def extract_topics(card_title, comment_body, project_key)
119
+ text = [card_title, comment_body].compact.join(" ")
120
+ # Strip common noise words, extract meaningful terms
121
+ stopwords = %w[the a an is are was were be been being have has had do does did will would shall should
122
+ may might can could this that these those it its i me my we our you your he she they them
123
+ to of in for on with at by from as into through during before after above below between
124
+ and or but not no nor so yet both either neither each every all any few more most other
125
+ some such only own same than too very just don doesn didn won wasn weren isn aren hasn
126
+ haven hadn couldn shouldn wouldn about also back even still already again further then
127
+ once here there when where why how what which who whom whose if because since while
128
+ please thanks thank need want like make sure get got going go let know think see look
129
+ work try use find give tell ask seem feel become leave call keep put run move live
130
+ update fix add create new change set up check out]
131
+ words = text.downcase.gsub(/[^a-z0-9\s_-]/, " ").split.uniq - stopwords
132
+ topics = words.select { |w| w.length > 2 }.first(8)
133
+ topics << project_key if project_key && !project_key.empty?
134
+ topics.compact.uniq
135
+ end
136
+
137
+ def build_brain_context(agent_name: AI_AGENT_NAME, card_title: "", card_number: nil, project_key: nil, comment_body: "", source: nil)
138
+ Thread.new { brain_pull }
139
+
140
+ topics = extract_topics(card_title, comment_body, project_key)
141
+ primary_query = topics.first(5).join(" ")
142
+ primary_query = "project conventions" if primary_query.empty?
143
+
144
+ fizzy_mentioned = [card_title, comment_body].any? { |s| s&.match?(/fizzy/i) }
145
+ fizzy_originated = source == :fizzy
146
+
147
+ search_queries = [primary_query]
148
+
149
+ knowledge_threads = [
150
+ Thread.new { query_brain(primary_query, scope: :knowledge, max_results: 3) },
151
+ Thread.new { query_brain(agent_name, scope: :knowledge, max_results: 2) }
152
+ ]
153
+ search_queries << agent_name
154
+
155
+ if fizzy_mentioned || fizzy_originated
156
+ knowledge_threads << Thread.new { query_brain("fizzy CLI commands", scope: :knowledge, max_results: 2) }
157
+ search_queries << "fizzy CLI commands"
158
+ end
159
+
160
+ persona_thread = Thread.new { query_brain("personality tone voice communication style", agent_name: agent_name, scope: :persona, max_results: 5) }
161
+
162
+ all_knowledge = knowledge_threads.map(&:value).reject(&:empty?)
163
+ persona_result = persona_thread.value
164
+
165
+ sections = []
166
+
167
+ unless persona_result.empty?
168
+ sections << <<~PERSONA
169
+ ## Brain — Persona (auto-retrieved, CRITICAL)
170
+ The following is YOUR personality, communication style, and voice.
171
+ You MUST use this to shape every response you write — tone, word choice, humor, attitude.
172
+ This is who you ARE. Do not respond in a generic or neutral voice.
173
+
174
+ #{persona_result}
175
+ PERSONA
176
+ end
177
+
178
+ unless all_knowledge.empty?
179
+ knowledge_text = all_knowledge.join("\n\n")
180
+ sections << <<~BRAIN
181
+ ## Brain — Knowledge (auto-retrieved for: #{search_queries.map { |q| %("#{q}") }.join(", ")})
182
+ The following is relevant technical knowledge from your long-term memory.
183
+ These are project conventions, coding patterns, lessons learned, and decisions
184
+ that past-you saved for exactly this kind of work. Use it to inform your implementation.
185
+ If these results don't look relevant to your current task, search manually with better terms.
186
+
187
+ #{knowledge_text}
188
+ BRAIN
189
+ end
190
+
191
+ # Auto-inject skills: semantically match skills against current task context
192
+ skill_search_context = [card_title, comment_body, primary_query].compact.reject(&:empty?).join(" ")
193
+ skill_section = auto_inject_skills(skill_search_context)
194
+ sections << skill_section unless skill_section.empty?
195
+
196
+ sections.join("\n")
197
+ end