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,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Session tracking, deduplication, cooldowns, and agent dispatch depth.
4
+
5
+ # --- Deduplication ---
6
+
7
+ PROCESSED_EVENTS = {}
8
+ PROCESSED_EVENTS_MAX = 1000
9
+
10
+ def already_processed?(event_id)
11
+ return false unless event_id
12
+ return true if PROCESSED_EVENTS[event_id]
13
+
14
+ PROCESSED_EVENTS[event_id] = Time.now
15
+ if PROCESSED_EVENTS.size > PROCESSED_EVENTS_MAX
16
+ oldest = PROCESSED_EVENTS.keys.first(PROCESSED_EVENTS.size - PROCESSED_EVENTS_MAX)
17
+ oldest.each { |k| PROCESSED_EVENTS.delete(k) }
18
+ end
19
+ false
20
+ end
21
+
22
+ # --- Active sessions ---
23
+
24
+ ACTIVE_SESSIONS = {}
25
+ ACTIVE_SESSIONS_MUTEX = Mutex.new
26
+ RECENT_SESSIONS = []
27
+ RECENT_SESSIONS_MAX = 10
28
+
29
+ # --- Self-move tracking (suppress webhook echoes from our own column moves) ---
30
+
31
+ SELF_MOVES = {}
32
+ SELF_MOVES_MUTEX = Mutex.new
33
+
34
+ def record_self_move(card_number)
35
+ SELF_MOVES_MUTEX.synchronize { SELF_MOVES[card_number.to_s] = Time.now }
36
+ end
37
+
38
+ def self_move_recent?(card_number, window: 120)
39
+ SELF_MOVES_MUTEX.synchronize do
40
+ t = SELF_MOVES[card_number.to_s]
41
+ t && (Time.now - t) < window
42
+ end
43
+ end
44
+
45
+ # Archive a completed session for menu bar display. Call inside ACTIVE_SESSIONS_MUTEX.
46
+ def archive_session(card_key, info)
47
+ RECENT_SESSIONS.unshift({
48
+ card_key: card_key, agent_name: info[:agent_name],
49
+ log_file: info[:log_file], started_at: info[:started_at],
50
+ finished_at: Time.now
51
+ })
52
+ RECENT_SESSIONS.pop while RECENT_SESSIONS.size > RECENT_SESSIONS_MAX
53
+ end
54
+
55
+ def recently_completed?(card_key, window: 120)
56
+ ACTIVE_SESSIONS_MUTEX.synchronize do
57
+ RECENT_SESSIONS.any? do |s|
58
+ s[:card_key] == card_key && (Time.now - s[:finished_at]) < window
59
+ end
60
+ end
61
+ end
62
+
63
+ def session_active?(card_key)
64
+ ACTIVE_SESSIONS_MUTEX.synchronize do
65
+ info = ACTIVE_SESSIONS[card_key]
66
+ return false unless info
67
+
68
+ begin
69
+ Process.kill(0, info[:pid])
70
+ true
71
+ rescue Errno::ESRCH, Errno::EPERM
72
+ archive_session(card_key, info)
73
+ ACTIVE_SESSIONS.delete(card_key)
74
+ false
75
+ end
76
+ end
77
+ end
78
+
79
+ SESSION_WAIT_INTERVAL = 15
80
+ SESSION_WAIT_MAX = 600
81
+
82
+ def wait_for_session?(card_key)
83
+ return true unless session_active?(card_key)
84
+
85
+ LOG.info "Waiting for active session on #{card_key} to finish..."
86
+ elapsed = 0
87
+ while session_active?(card_key) && elapsed < SESSION_WAIT_MAX
88
+ sleep SESSION_WAIT_INTERVAL
89
+ elapsed += SESSION_WAIT_INTERVAL
90
+ LOG.info "Still waiting on #{card_key} (#{elapsed}s elapsed)"
91
+ end
92
+
93
+ if session_active?(card_key)
94
+ LOG.warn "Timed out waiting for session on #{card_key} after #{SESSION_WAIT_MAX}s"
95
+ false
96
+ else
97
+ LOG.info "Session on #{card_key} finished after ~#{elapsed}s, proceeding"
98
+ true
99
+ end
100
+ end
101
+
102
+ # Recursively collect all descendant processes of a given PID via /proc.
103
+ # Returns array of hashes: { pid:, ppid:, cmd:, elapsed_seconds: }
104
+ def child_processes_for(pid)
105
+ children_map = build_proc_children_map
106
+ descendants = []
107
+ queue = [pid]
108
+
109
+ while (current = queue.shift)
110
+ (children_map[current] || []).each do |child_pid|
111
+ queue << child_pid
112
+ cmdline = read_proc_cmdline(child_pid)
113
+ elapsed = read_proc_elapsed(child_pid)
114
+ descendants << { pid: child_pid, ppid: current, cmd: cmdline, elapsed_seconds: elapsed }
115
+ end
116
+ end
117
+ descendants
118
+ rescue StandardError => e
119
+ LOG.warn "Failed to enumerate child processes for PID #{pid}: #{e.message}"
120
+ []
121
+ end
122
+
123
+ # Build a ppid→children map from /proc in one pass.
124
+ def build_proc_children_map
125
+ children_map = Hash.new { |h, k| h[k] = [] }
126
+ Dir.glob("/proc/[0-9]*/stat").each do |stat_path|
127
+ content = begin
128
+ File.read(stat_path)
129
+ rescue StandardError
130
+ next
131
+ end
132
+ close_paren = content.rindex(")")
133
+ next unless close_paren
134
+
135
+ prefix_pid = content[0...content.index("(")].strip.to_i
136
+ fields_after = content[(close_paren + 2)..].split
137
+ ppid = fields_after[1].to_i
138
+ children_map[ppid] << prefix_pid
139
+ end
140
+ children_map
141
+ end
142
+
143
+ # Read command line for a given PID from /proc.
144
+ def read_proc_cmdline(pid)
145
+ File.read("/proc/#{pid}/cmdline").tr("\0", " ").strip
146
+ rescue StandardError
147
+ "(unknown)"
148
+ end
149
+
150
+ # Calculate elapsed seconds since process start from /proc/stat.
151
+ def read_proc_elapsed(pid)
152
+ stat_content = File.read("/proc/#{pid}/stat")
153
+ rescue StandardError
154
+ 0
155
+ else
156
+ cp = stat_content.rindex(")")
157
+ starttime_ticks = begin
158
+ stat_content[(cp + 2)..].split[19].to_i
159
+ rescue StandardError
160
+ 0
161
+ end
162
+ clk_tck = 100
163
+ uptime = begin
164
+ File.read("/proc/uptime").split[0].to_f
165
+ rescue StandardError
166
+ 0
167
+ end
168
+ start_seconds = starttime_ticks.to_f / clk_tck
169
+ (uptime - start_seconds).to_i.clamp(0, Float::INFINITY).to_i
170
+ end
171
+
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)
173
+ ACTIVE_SESSIONS_MUTEX.synchronize do
174
+ ACTIVE_SESSIONS[card_key] = {
175
+ pid: pid, started_at: Time.now, log_file: log_file,
176
+ message_id: message_id, channel_id: channel_id, supersede_key: supersede_key,
177
+ draft_files: draft_files, agent_name: agent_name
178
+ }
179
+ end
180
+ end
181
+
182
+ # --- Session supersede (Discord follow-up within window kills previous run) ---
183
+
184
+ SUPERSEDE_WINDOW = 60 # seconds
185
+
186
+ # Find an active session for the same supersede key (agent+channel) started within the window.
187
+ # Returns the session info hash (with :session_key added) or nil.
188
+ def find_supersedable_session(supersede_key)
189
+ ACTIVE_SESSIONS_MUTEX.synchronize do
190
+ ACTIVE_SESSIONS.each do |key, info|
191
+ next unless info[:supersede_key] == supersede_key
192
+ next if (Time.now - info[:started_at]) > SUPERSEDE_WINDOW
193
+
194
+ begin
195
+ Process.kill(0, info[:pid])
196
+ return info.merge(session_key: key)
197
+ rescue Errno::ESRCH, Errno::EPERM
198
+ next
199
+ end
200
+ end
201
+ end
202
+ nil
203
+ end
204
+
205
+ # Kill a session's process. Returns true if killed.
206
+ def kill_session(session_key)
207
+ ACTIVE_SESSIONS_MUTEX.synchronize do
208
+ info = ACTIVE_SESSIONS[session_key]
209
+ return false unless info
210
+
211
+ # Kill child processes first (bottom-up), then the parent
212
+ children = child_processes_for(info[:pid])
213
+ children.reverse_each do |child|
214
+ Process.kill("KILL", child[:pid])
215
+ rescue StandardError
216
+ nil
217
+ end
218
+ begin
219
+ Process.kill("KILL", info[:pid])
220
+ rescue Errno::ESRCH, Errno::EPERM
221
+ # already gone
222
+ end
223
+ archive_session(session_key, info)
224
+ ACTIVE_SESSIONS.delete(session_key)
225
+ true
226
+ end
227
+ end
228
+
229
+ # --- Comment cooldown ---
230
+
231
+ COMMENT_COOLDOWN = 60
232
+ LAST_COMMENT_TIMES = {}
233
+
234
+ def on_comment_cooldown?(card_key)
235
+ last = LAST_COMMENT_TIMES[card_key]
236
+ last && (Time.now - last) < COMMENT_COOLDOWN
237
+ end
238
+
239
+ def touch_comment_cooldown(card_key)
240
+ LAST_COMMENT_TIMES[card_key] = Time.now
241
+ end
242
+
243
+ # --- Deploy cooldown (debounce rapid PR pushes) ---
244
+
245
+ DEPLOY_COOLDOWN = 30
246
+ LAST_DEPLOY_TIMES = {}
247
+
248
+ def on_deploy_cooldown?(env_key)
249
+ last = LAST_DEPLOY_TIMES[env_key]
250
+ last && (Time.now - last) < DEPLOY_COOLDOWN
251
+ end
252
+
253
+ def touch_deploy_cooldown(env_key)
254
+ LAST_DEPLOY_TIMES[env_key] = Time.now
255
+ end
256
+
257
+ # --- Agent dispatch depth (loop prevention) ---
258
+
259
+ AGENT_DISPATCH_DEPTH = {}
260
+ AGENT_DISPATCH_MAX_DEPTH = 10
261
+ AGENT_DISPATCH_WINDOW = 3600
262
+
263
+ def record_human_comment(card_internal_id)
264
+ AGENT_DISPATCH_DEPTH[card_internal_id] = { count: 0, last_human_at: Time.now }
265
+ end
266
+
267
+ def agent_dispatch_allowed?(card_internal_id)
268
+ info = AGENT_DISPATCH_DEPTH[card_internal_id]
269
+ return false unless info
270
+ return false if (Time.now - info[:last_human_at]) > AGENT_DISPATCH_WINDOW
271
+
272
+ info[:count] < AGENT_DISPATCH_MAX_DEPTH
273
+ end
274
+
275
+ def record_agent_dispatch(card_internal_id)
276
+ info = AGENT_DISPATCH_DEPTH[card_internal_id]
277
+ if info
278
+ info[:count] += 1
279
+ else
280
+ AGENT_DISPATCH_DEPTH[card_internal_id] = { count: 1, last_human_at: Time.now }
281
+ end
282
+ end
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Auto-skills: procedural skill extraction, progressive loading, usage tracking, and curation.
4
+ #
5
+ # Skills are SKILL.md files with YAML frontmatter stored in brain/knowledge/skills/.
6
+ # They are auto-extracted after complex sessions and curated over time.
7
+
8
+ require "yaml"
9
+ require "json"
10
+ require "time"
11
+
12
+ SKILLS_DIR = File.join(KNOWLEDGE_DIR, "skills")
13
+ FileUtils.mkdir_p(SKILLS_DIR)
14
+
15
+ # --- SKILL.md format ---
16
+ # ---
17
+ # name: skill-name-slug
18
+ # description: When to use this skill (one line)
19
+ # tags: [ruby, testing, deployment]
20
+ # ---
21
+ # Procedural content...
22
+
23
+ # --- Skill detection ---
24
+
25
+ # Analyze an agent session log to determine if a skill should be extracted.
26
+ # Triggers when: 5+ tool calls AND at least one error-recovery pattern detected.
27
+ # Returns: { extract: true, topic: '...', summary: '...' } or { extract: false }
28
+ def detect_skill_candidate(log_file)
29
+ return { extract: false } unless File.exist?(log_file)
30
+
31
+ content = File.read(log_file, encoding: "utf-8", invalid: :replace)
32
+
33
+ # Count tool invocations (kiro-cli logs tool calls as "Tool:" or "antml:invoke")
34
+ tool_calls = content.scan(/(?:^Tool:|<invoke|execute_bash|fs_write|fs_read|code.*operation)/).size
35
+ return { extract: false } if tool_calls < 5
36
+
37
+ # Detect error-recovery patterns: retry, fix, error followed by success
38
+ error_patterns = content.scan(/(?:error|failed|fix|retry|correcting|let me try)/i).size
39
+ recovery_patterns = content.scan(/(?:that worked|fixed|resolved|now passing|success)/i).size
40
+ has_recovery = error_patterns >= 1 && recovery_patterns >= 1
41
+
42
+ return { extract: false } unless has_recovery
43
+
44
+ { extract: true, tool_calls: tool_calls, error_patterns: error_patterns }
45
+ end
46
+
47
+ # --- Skill index (lightweight manifest for prompt injection) ---
48
+
49
+ # Build a compact skill index for prompt injection.
50
+ # Returns array of { name:, description:, path: } from all SKILL.md files.
51
+ def build_skill_index
52
+ skills = []
53
+ Dir.glob(File.join(SKILLS_DIR, "**", "SKILL.md")).each do |path|
54
+ frontmatter = parse_skill_frontmatter(path)
55
+ next unless frontmatter
56
+
57
+ skills << {
58
+ name: frontmatter["name"],
59
+ description: frontmatter["description"],
60
+ tags: frontmatter["tags"] || [],
61
+ path: path
62
+ }
63
+ end
64
+ skills
65
+ end
66
+
67
+ # Parse YAML frontmatter from a SKILL.md file.
68
+ def parse_skill_frontmatter(path)
69
+ content = File.read(path)
70
+ return nil unless content.start_with?("---")
71
+
72
+ parts = content.split("---", 3)
73
+ return nil if parts.size < 3
74
+
75
+ YAML.safe_load(parts[1])
76
+ rescue StandardError => e
77
+ LOG.warn "[Skills] Failed to parse frontmatter in #{path}: #{e.message}"
78
+ nil
79
+ end
80
+
81
+ # --- Progressive skill loading ---
82
+
83
+ # Maximum tokens (approx chars / 4) to spend on auto-injected skill content.
84
+ SKILL_AUTO_INJECT_MAX_CHARS = 8000
85
+
86
+ # Build the skill index section for prompt injection.
87
+ # Only includes name + description (not full content) to keep tokens bounded.
88
+ def skill_index_for_prompt
89
+ skills = build_skill_index
90
+ return "" if skills.empty?
91
+
92
+ lines = skills.map { |s| "- **#{s[:name]}**: #{s[:description]}" }
93
+ <<~SECTION
94
+ ## Available Skills
95
+ The following procedural skills are available. To use one, read the full file at its path.
96
+ #{lines.join("\n")}
97
+ SECTION
98
+ end
99
+
100
+ # Semantically match skills against the current task context and auto-inject their full content.
101
+ # This is the skill:// equivalent — skills are loaded automatically when relevant, not manually.
102
+ # Returns a prompt section with full skill content for top matches, plus an index of remaining skills.
103
+ def auto_inject_skills(search_context)
104
+ skills = build_skill_index
105
+ return "" if skills.empty?
106
+ return "" unless system("which qmd > /dev/null 2>&1")
107
+
108
+ # Semantic search against skill descriptions to find relevant ones
109
+ matched_paths = match_skills_semantically(search_context, skills)
110
+
111
+ # Split into auto-injected (matched) and index-only (rest)
112
+ injected = []
113
+ chars_used = 0
114
+
115
+ matched_paths.each do |path|
116
+ content = File.read(path)
117
+ break if chars_used + content.size > SKILL_AUTO_INJECT_MAX_CHARS
118
+
119
+ skill = skills.find { |s| s[:path] == path }
120
+ next unless skill
121
+
122
+ injected << { name: skill[:name], description: skill[:description], content: content, path: path }
123
+ chars_used += content.size
124
+ end
125
+
126
+ remaining = skills.reject { |s| injected.any? { |i| i[:path] == s[:path] } }
127
+
128
+ sections = []
129
+
130
+ unless injected.empty?
131
+ sections << "## Auto-Loaded Skills (matched to your current task)"
132
+ sections << "These skills were automatically loaded because they're relevant to what you're working on.\n"
133
+ injected.each do |skill|
134
+ sections << "### Skill: #{skill[:name]}"
135
+ sections << skill[:content]
136
+ sections << ""
137
+ record_skill_usage(skill[:path], type: :use)
138
+ end
139
+ end
140
+
141
+ unless remaining.empty?
142
+ sections << "## Other Available Skills"
143
+ sections << "Additional skills not auto-loaded. Read the file if needed.\n"
144
+ remaining.each { |s| sections << "- **#{s[:name]}**: #{s[:description]} (`#{s[:path]}`)" }
145
+ sections << ""
146
+ end
147
+
148
+ sections.join("\n")
149
+ end
150
+
151
+ # Use qmd semantic search to find skills whose descriptions match the current context.
152
+ # Returns an ordered array of SKILL.md paths (most relevant first).
153
+ def match_skills_semantically(search_context, skills)
154
+ return [] if search_context.strip.empty?
155
+
156
+ # Search the knowledge collection — skills are indexed there since they're in knowledge/skills/
157
+ output, status = Open3.capture2("qmd", "search", search_context, "-c", KNOWLEDGE_COLLECTION, "-n", "10", "--md")
158
+ return [] unless status.success? && !output.strip.empty?
159
+
160
+ # Extract paths from qmd results that point to SKILL.md files
161
+ skill_paths = skills.map { |s| s[:path] }
162
+ matched = []
163
+
164
+ # qmd --md output includes file paths in results — match against known skill paths
165
+ skill_paths.each do |path|
166
+ # Check if the skill's directory name or file appears in search results
167
+ skill_dir_name = File.basename(File.dirname(path))
168
+ matched << path if output.include?(skill_dir_name) || output.include?(path)
169
+ end
170
+
171
+ matched
172
+ rescue StandardError => e
173
+ LOG.warn "[Skills] Semantic matching failed: #{e.message}"
174
+ []
175
+ end
176
+
177
+ # --- Usage tracking ---
178
+
179
+ SKILL_USAGE_SUFFIX = ".usage.json"
180
+
181
+ def skill_usage_path(skill_path)
182
+ skill_path.sub(/SKILL\.md$/, "SKILL.usage.json")
183
+ end
184
+
185
+ # Record a view (skill index shown in prompt) or use (agent read the full skill).
186
+ def record_skill_usage(skill_path, type: :view)
187
+ usage_file = skill_usage_path(skill_path)
188
+ data = if File.exist?(usage_file)
189
+ JSON.parse(File.read(usage_file))
190
+ else
191
+ { "views" => 0, "uses" => 0, "last_viewed" => nil, "last_used" => nil, "created_at" => Time.now.iso8601 }
192
+ end
193
+
194
+ now = Time.now.iso8601
195
+ case type
196
+ when :view
197
+ data["views"] = (data["views"] || 0) + 1
198
+ data["last_viewed"] = now
199
+ when :use
200
+ data["uses"] = (data["uses"] || 0) + 1
201
+ data["last_used"] = now
202
+ end
203
+
204
+ File.write(usage_file, JSON.pretty_generate(data))
205
+ rescue StandardError => e
206
+ LOG.warn "[Skills] Failed to record usage for #{skill_path}: #{e.message}"
207
+ end
208
+
209
+ # Batch-record views for all skills in the index (called when prompt is built).
210
+ def record_skill_index_views
211
+ build_skill_index.each { |s| record_skill_usage(s[:path], type: :view) }
212
+ end
213
+
214
+ # --- Curator ---
215
+
216
+ SKILL_STALE_DAYS = 90 # Archive skills unused for this many days
217
+ SKILL_ARCHIVE_DIR = File.join(SKILLS_DIR, "_archived")
218
+
219
+ # Run the curator: archive stale skills, log consolidation candidates.
220
+ # Never auto-deletes — only moves to _archived/.
221
+ def curate_skills
222
+ FileUtils.mkdir_p(SKILL_ARCHIVE_DIR)
223
+ now = Time.now
224
+ archived = 0
225
+ consolidation_candidates = []
226
+
227
+ skills = build_skill_index
228
+ skills_by_tag = Hash.new { |h, k| h[k] = [] }
229
+
230
+ skills.each do |skill|
231
+ usage_file = skill_usage_path(skill[:path])
232
+ usage = if File.exist?(usage_file)
233
+ JSON.parse(File.read(usage_file))
234
+ else
235
+ { "views" => 0, "uses" => 0, "last_viewed" => nil, "last_used" => nil }
236
+ end
237
+
238
+ # Check staleness
239
+ last_activity = [usage["last_viewed"], usage["last_used"]].compact.max
240
+ if last_activity.nil? || (now - Time.parse(last_activity)) > (SKILL_STALE_DAYS * 86_400)
241
+ archive_skill(skill[:path])
242
+ archived += 1
243
+ next
244
+ end
245
+
246
+ # Track tags for consolidation detection
247
+ (skill[:tags] || []).each { |tag| skills_by_tag[tag] << skill }
248
+ end
249
+
250
+ # Detect consolidation candidates: 3+ skills sharing the same tag
251
+ skills_by_tag.each do |tag, tag_skills|
252
+ consolidation_candidates << { tag: tag, skills: tag_skills.map { |s| s[:name] } } if tag_skills.size >= 3
253
+ end
254
+
255
+ LOG.info "[Curator] Archived #{archived} stale skill(s)" if archived.positive?
256
+ LOG.info "[Curator] Consolidation candidates: #{consolidation_candidates.map { |c| c[:tag] }.join(", ")}" unless consolidation_candidates.empty?
257
+
258
+ { archived: archived, consolidation_candidates: consolidation_candidates }
259
+ rescue StandardError => e
260
+ LOG.warn "[Curator] Error during curation: #{e.message}"
261
+ { archived: 0, consolidation_candidates: [], error: e.message }
262
+ end
263
+
264
+ def archive_skill(skill_path)
265
+ skill_dir = File.dirname(skill_path)
266
+ skill_name = File.basename(skill_dir)
267
+ archive_dest = File.join(SKILL_ARCHIVE_DIR, skill_name)
268
+
269
+ FileUtils.mkdir_p(archive_dest)
270
+ FileUtils.mv(Dir.glob(File.join(skill_dir, "*")), archive_dest)
271
+ FileUtils.rmdir(skill_dir) if Dir.empty?(skill_dir)
272
+
273
+ LOG.info "[Curator] Archived skill: #{skill_name}"
274
+ rescue StandardError => e
275
+ LOG.warn "[Curator] Failed to archive #{skill_path}: #{e.message}"
276
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ # User identity registry - resolves identities across platforms
4
+ # (Discord, GitHub, Fizzy)
5
+
6
+ USERS_FILE = File.join(ZILLACORE_DIR, "users.json")
7
+
8
+ def load_user_registry
9
+ return { "users" => [] } unless File.exist?(USERS_FILE)
10
+
11
+ data = JSON.parse(File.read(USERS_FILE))
12
+ LOG.info "Loaded #{data["users"].size} user(s) from #{USERS_FILE}"
13
+ data
14
+ rescue JSON::ParserError => e
15
+ LOG.error "Failed to parse user registry: #{e.message}"
16
+ { "users" => [] }
17
+ end
18
+
19
+ def reload_user_registry!(force: false)
20
+ return unless file_changed?(USERS_FILE, force: force)
21
+
22
+ USER_REGISTRY.replace(load_user_registry)
23
+ LOG.info "Reloaded user registry: #{USER_REGISTRY["users"].size} users"
24
+ end
25
+
26
+ USER_REGISTRY = load_user_registry
27
+
28
+ # Find user by Discord user ID
29
+ def find_user_by_discord_id(user_id)
30
+ USER_REGISTRY["users"].find { |u| u.dig("identities", "discord", "user_id") == user_id.to_s }
31
+ end
32
+
33
+ # Find user by Discord username
34
+ def find_user_by_discord_username(username)
35
+ USER_REGISTRY["users"].find { |u| u.dig("identities", "discord", "username") == username.to_s }
36
+ end
37
+
38
+ # Find user by GitHub username
39
+ def find_user_by_github_username(username)
40
+ USER_REGISTRY["users"].find { |u| u.dig("identities", "github", "username") == username.to_s }
41
+ end
42
+
43
+ # Find user by Fizzy username
44
+ def find_user_by_fizzy_username(username)
45
+ USER_REGISTRY["users"].find { |u| u.dig("identities", "fizzy", "username") == username.to_s }
46
+ end
47
+
48
+ # Find user by canonical name
49
+ def find_user_by_canonical_name(name)
50
+ USER_REGISTRY["users"].find { |u| u["canonical_name"].downcase == name.downcase }
51
+ end
52
+
53
+ # Find user by any identifier (tries all platforms)
54
+ def find_user(identifier)
55
+ find_user_by_discord_id(identifier) ||
56
+ find_user_by_discord_username(identifier) ||
57
+ find_user_by_github_username(identifier) ||
58
+ find_user_by_fizzy_username(identifier) ||
59
+ find_user_by_canonical_name(identifier)
60
+ end
61
+
62
+ # Get canonical name for a platform-specific identifier
63
+ def canonical_name_for(identifier)
64
+ user = find_user(identifier)
65
+ user ? user["canonical_name"] : identifier
66
+ end
67
+
68
+ # Get all human users (exclude AI agents)
69
+ def human_users
70
+ USER_REGISTRY["users"].reject { |u| u["notes"]&.include?("AI agent") }
71
+ end
72
+
73
+ # Get all AI agents
74
+ def ai_agents
75
+ USER_REGISTRY["users"].select { |u| u["notes"]&.include?("AI agent") }
76
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZillaCore
4
+ # @return [String] the current gem version
5
+ VERSION = "0.0.1"
6
+ end