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.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +12 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +126 -0
- data/README.md +1166 -0
- data/Rakefile +12 -0
- data/bin/zillacore +1521 -0
- data/certs/stowzilla.pem +26 -0
- data/docs/waybar-config.md +96 -0
- data/lib/user_registry.rb +159 -0
- data/lib/zillacore/agents.rb +203 -0
- data/lib/zillacore/brain.rb +197 -0
- data/lib/zillacore/card_index.rb +389 -0
- data/lib/zillacore/config.rb +263 -0
- data/lib/zillacore/cron.rb +629 -0
- data/lib/zillacore/deployments.rb +258 -0
- data/lib/zillacore/handlers/discord.rb +1643 -0
- data/lib/zillacore/handlers/fizzy.rb +1249 -0
- data/lib/zillacore/handlers/github.rb +598 -0
- data/lib/zillacore/handlers/zoho.rb +487 -0
- data/lib/zillacore/helpers.rb +760 -0
- data/lib/zillacore/planning.rb +237 -0
- data/lib/zillacore/prompts.rb +620 -0
- data/lib/zillacore/sessions.rb +282 -0
- data/lib/zillacore/skills.rb +276 -0
- data/lib/zillacore/users.rb +76 -0
- data/lib/zillacore/version.rb +6 -0
- data/lib/zillacore/zoho_mail_api.rb +109 -0
- data/lib/zillacore.rb +10 -0
- data/monitor/daemon.rb +99 -0
- data/monitor/deploy-env-macos.rb +131 -0
- data/monitor/menubar.rb +295 -0
- data/monitor/open-action.sh +15 -0
- data/monitor/setup-menubar.rb +78 -0
- data/monitor/setup-waybar-deploy-envs.rb +121 -0
- data/monitor/setup-waybar-deployments.rb +96 -0
- data/monitor/setup-waybar-module.rb +113 -0
- data/monitor/setup-xbar-plugin.rb +35 -0
- data/monitor/view-logs-macos.rb +210 -0
- data/monitor/view-logs-rofi.rb +194 -0
- data/monitor/view-logs.rb +119 -0
- data/monitor/waybar-config-updater.rb +56 -0
- data/monitor/waybar-deploy-env.rb +206 -0
- data/monitor/waybar-deployments.rb +239 -0
- data/monitor/waybar.rb +146 -0
- data/monitor/xbar.3s.rb +179 -0
- data/receiver.rb +956 -0
- data/templates/agents.json.example +10 -0
- data/templates/discord.json.example +17 -0
- data/templates/fizzy.json.example +24 -0
- data/templates/github.json.example +4 -0
- data/templates/testflight.json.example +8 -0
- data/templates/users.json.example +121 -0
- data/templates/zoho.json.example +27 -0
- data/views/dashboard.erb +437 -0
- data/zillacore.gemspec +30 -0
- data.tar.gz.sig +2 -0
- metadata +235 -0
- metadata.gz.sig +0 -0
data/certs/stowzilla.pem
ADDED
|
@@ -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
|