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,389 @@
1
+ # frozen_string_literal: true
2
+
3
+ # CardIndex — duplicate card detection via trigram string similarity + qmd semantic search.
4
+ #
5
+ # Two detection layers run in parallel:
6
+ # 1. Trigram similarity — fast, catches near-identical titles ("Fix login bug" ≈ "Fix login bug on mobile")
7
+ # 2. qmd vsearch — semantic embeddings, catches same-meaning different-words
8
+ # ("Login page broken on mobile" ≈ "Users can't sign in from phones")
9
+ #
10
+ # Results are merged by card number, keeping the higher score from either method.
11
+ #
12
+ # Only the local machine's creator cards are checked for duplicates (creator-based
13
+ # routing prevents multi-machine races).
14
+
15
+ require "json"
16
+ require "open3"
17
+ require "fileutils"
18
+ require "yaml"
19
+
20
+ class CardIndex
21
+ SIMILARITY_THRESHOLD = 0.65
22
+ SEMANTIC_THRESHOLD = 0.65
23
+ SEMANTIC_COLLECTION = "card-titles"
24
+ QMD_DEBOUNCE = 30 # seconds
25
+
26
+ attr_reader :index_file, :titles_dir
27
+
28
+ def initialize(index_file:, titles_dir:)
29
+ @index_file = index_file
30
+ @titles_dir = titles_dir
31
+ @data = {}
32
+ @mutex = Mutex.new
33
+ @qmd_mutex = Mutex.new
34
+ @qmd_last_run = nil
35
+ @qmd_pending = false
36
+ end
37
+
38
+ # --- Hash-like access (thread-safe) ---
39
+
40
+ def [](key)
41
+ @mutex.synchronize { @data[key] }
42
+ end
43
+
44
+ def []=(key, value)
45
+ @mutex.synchronize { @data[key] = value }
46
+ end
47
+
48
+ def delete(key)
49
+ @mutex.synchronize { @data.delete(key) }
50
+ end
51
+
52
+ def size
53
+ @mutex.synchronize { @data.size }
54
+ end
55
+
56
+ def key?(key)
57
+ @mutex.synchronize { @data.key?(key) }
58
+ end
59
+
60
+ def each(&)
61
+ @mutex.synchronize { @data.each(&) }
62
+ end
63
+
64
+ def dig(*keys)
65
+ @mutex.synchronize { @data.dig(*keys) }
66
+ end
67
+
68
+ def to_json(...)
69
+ @mutex.synchronize { @data.to_json(...) }
70
+ end
71
+
72
+ def to_h
73
+ @mutex.synchronize { @data.dup }
74
+ end
75
+
76
+ # --- Trigram similarity ---
77
+
78
+ def trigrams(str)
79
+ normalized = str.downcase.gsub(/[^a-z0-9\s]/, "").strip
80
+ return Set.new if normalized.length < 3
81
+
82
+ Set.new((0..(normalized.length - 3)).map { |i| normalized[i, 3] })
83
+ end
84
+
85
+ def trigram_similarity(str_a, str_b)
86
+ ta = trigrams(str_a)
87
+ tb = trigrams(str_b)
88
+ return 0.0 if ta.empty? || tb.empty?
89
+
90
+ intersection = (ta & tb).size.to_f
91
+ union = (ta | tb).size.to_f
92
+ intersection / union
93
+ end
94
+
95
+ # --- Card title files for qmd collection ---
96
+
97
+ def sync_card_title_file(number, title, closed: false)
98
+ FileUtils.mkdir_p(@titles_dir)
99
+ path = File.join(@titles_dir, "#{number}.md")
100
+ if closed
101
+ FileUtils.rm_f(path)
102
+ else
103
+ File.write(path, title)
104
+ end
105
+ end
106
+
107
+ def remove_card_title_file(number)
108
+ FileUtils.rm_f(File.join(@titles_dir, "#{number}.md"))
109
+ end
110
+
111
+ # Ensure the qmd collection exists, create if not
112
+ def ensure_card_titles_collection
113
+ FileUtils.mkdir_p(@titles_dir)
114
+ output, _, status = Open3.capture3("qmd", "collection", "list")
115
+ return if status.success? && output.include?(SEMANTIC_COLLECTION)
116
+
117
+ LOG.info "[CardIndex] Creating qmd collection '#{SEMANTIC_COLLECTION}'"
118
+ _, stderr, s = Open3.capture3("qmd", "collection", "add", @titles_dir,
119
+ "--name", SEMANTIC_COLLECTION, "--mask", "*.md")
120
+ LOG.warn "[CardIndex] Failed to create qmd collection: #{stderr}" unless s.success?
121
+ end
122
+
123
+ # Debounced qmd update + embed. Runs in background thread.
124
+ def schedule_qmd_reindex
125
+ @qmd_mutex.synchronize do
126
+ @qmd_pending = true
127
+ return if @qmd_last_run && (Time.now - @qmd_last_run) < QMD_DEBOUNCE
128
+
129
+ @qmd_last_run = Time.now
130
+ @qmd_pending = false
131
+ end
132
+
133
+ Thread.new do
134
+ LOG.info "[CardIndex] Running qmd update for card titles..."
135
+ _, stderr, s = Open3.capture3("qmd", "update")
136
+ LOG.warn "[CardIndex] qmd update failed: #{stderr}" unless s.success?
137
+
138
+ LOG.info "[CardIndex] Running qmd embed for card titles..."
139
+ _, stderr, s = Open3.capture3("qmd", "embed")
140
+ LOG.warn "[CardIndex] qmd embed failed: #{stderr}" unless s.success?
141
+
142
+ LOG.info "[CardIndex] qmd reindex complete"
143
+
144
+ needs_rerun = @qmd_mutex.synchronize do
145
+ if @qmd_pending
146
+ @qmd_pending = false
147
+ @qmd_last_run = Time.now
148
+ true
149
+ else
150
+ false
151
+ end
152
+ end
153
+ schedule_qmd_reindex if needs_rerun
154
+ rescue StandardError => e
155
+ LOG.warn "[CardIndex] qmd reindex failed: #{e.message}"
156
+ end
157
+ end
158
+
159
+ # --- Index operations ---
160
+
161
+ def load
162
+ data = if File.exist?(@index_file)
163
+ JSON.parse(File.read(@index_file))
164
+ else
165
+ {}
166
+ end
167
+ @mutex.synchronize { @data.replace(data) }
168
+ LOG.info "[CardIndex] Loaded #{size} cards from disk"
169
+ rescue JSON::ParserError => e
170
+ LOG.error "Failed to parse card index: #{e.message}"
171
+ @mutex.synchronize { @data.replace({}) }
172
+ end
173
+
174
+ def save
175
+ @mutex.synchronize do
176
+ File.write(@index_file, JSON.generate(@data))
177
+ end
178
+ end
179
+
180
+ def index_card(number:, title:, creator_name: nil, creator_id: nil, tags: [], closed: false)
181
+ @mutex.synchronize do
182
+ @data[number.to_s] = {
183
+ "title" => title,
184
+ "creator_name" => creator_name,
185
+ "creator_id" => creator_id,
186
+ "tags" => tags.map { |t| t.is_a?(Hash) ? t["name"] : t.to_s },
187
+ "closed" => closed,
188
+ "indexed_at" => Time.now.iso8601
189
+ }
190
+ end
191
+ sync_card_title_file(number, title, closed: closed)
192
+ end
193
+
194
+ def evict_card(number)
195
+ delete(number.to_s)
196
+ remove_card_title_file(number)
197
+ end
198
+
199
+ # --- Scope extraction for cross-project duplicate filtering ---
200
+
201
+ def build_scope_map
202
+ return if @scope_map_built
203
+
204
+ @scope_map ||= {}
205
+ PROJECTS.each do |key, cfg|
206
+ (cfg["fizzy_tags"] || []).each { |t| @scope_map[t.downcase] = key }
207
+ (cfg["scope_tags"] || {}).each { |tag, scope| @scope_map[tag.downcase] = scope }
208
+ end
209
+ @scope_map_built = true
210
+ end
211
+
212
+ def card_scopes(tags)
213
+ return Set.new if tags.nil? || tags.empty?
214
+
215
+ build_scope_map
216
+ tag_names = tags.map { |t| (t.is_a?(Hash) ? t["name"] : t).to_s.downcase }
217
+ scopes = Set.new
218
+ tag_names.each { |t| scopes << @scope_map[t] if @scope_map[t] }
219
+ scopes
220
+ end
221
+
222
+ def different_scopes?(tags_a, tags_b)
223
+ scopes_a = card_scopes(tags_a)
224
+ scopes_b = card_scopes(tags_b)
225
+ scopes_a.any? && scopes_b.any? && !scopes_a.intersect?(scopes_b)
226
+ end
227
+
228
+ # --- Trigram search ---
229
+
230
+ def find_trigram_similar_cards(title, exclude_number: nil)
231
+ matches = []
232
+ each do |num, entry|
233
+ next if num == exclude_number.to_s
234
+ next if entry["closed"]
235
+
236
+ score = trigram_similarity(title, entry["title"])
237
+ matches << { number: num.to_i, title: entry["title"], score: score, method: :trigram } if score >= SIMILARITY_THRESHOLD
238
+ end
239
+ matches
240
+ end
241
+
242
+ # --- Semantic search via qmd vsearch ---
243
+
244
+ def find_semantic_similar_cards(title, exclude_number: nil)
245
+ output, stderr, status = Open3.capture3("qmd", "vsearch", title, "-c", SEMANTIC_COLLECTION,
246
+ "--json", "--min-score", SEMANTIC_THRESHOLD.to_s, "--all")
247
+ unless status.success?
248
+ LOG.warn "[CardIndex] qmd vsearch failed: #{stderr.lines.last&.strip}"
249
+ return []
250
+ end
251
+
252
+ clean = output.lines.reject { |l| l.start_with?("[node-llama-cpp]") }.join
253
+ json_start = clean.index("[")
254
+ return [] unless json_start
255
+
256
+ results = JSON.parse(clean[json_start..])
257
+ results.filter_map do |r|
258
+ num = r["file"]&.match(%r{/(\d+)\.md$})&.[](1)
259
+ next unless num
260
+ next if num == exclude_number.to_s
261
+
262
+ entry = self[num]
263
+ next if entry&.dig("closed")
264
+
265
+ { number: num.to_i, title: entry&.dig("title") || r["snippet"]&.strip || "", score: r["score"], method: :semantic }
266
+ end
267
+ rescue JSON::ParserError => e
268
+ LOG.warn "[CardIndex] Failed to parse qmd vsearch output: #{e.message}"
269
+ []
270
+ end
271
+
272
+ # --- Merged search: trigram + semantic in parallel ---
273
+
274
+ def find_similar_cards(title, exclude_number: nil, tags: nil)
275
+ trigram_thread = Thread.new { find_trigram_similar_cards(title, exclude_number: exclude_number) }
276
+ semantic_thread = Thread.new { find_semantic_similar_cards(title, exclude_number: exclude_number) }
277
+
278
+ trigram_results = trigram_thread.value
279
+ semantic_results = semantic_thread.value
280
+
281
+ merged = {}
282
+ (trigram_results + semantic_results).each do |match|
283
+ key = match[:number]
284
+ existing = merged[key]
285
+ if existing.nil? || match[:score] > existing[:score]
286
+ merged[key] = match
287
+ elsif match[:score] == existing[:score] && existing[:method] != match[:method]
288
+ merged[key] = existing.merge(method: :both)
289
+ end
290
+ end
291
+
292
+ if tags && card_scopes(tags).any?
293
+ merged.reject! do |num, _match|
294
+ match_tags = dig(num.to_s, "tags")
295
+ different_scopes?(tags, match_tags)
296
+ end
297
+ end
298
+
299
+ merged.values.sort_by { |m| -m[:score] }
300
+ end
301
+
302
+ # --- Backfill from Fizzy API on startup ---
303
+
304
+ def backfill
305
+ Thread.new do
306
+ LOG.info "[CardIndex] Starting backfill from Fizzy API..."
307
+ backfilled = 0
308
+ seen_boards = Set.new
309
+
310
+ PROJECTS.each do |project_key, config|
311
+ result = backfill_project(project_key, config, seen_boards)
312
+ backfilled += result if result
313
+ end
314
+
315
+ save
316
+ LOG.info "[CardIndex] Backfill complete: #{backfilled} new cards indexed (#{size} total)"
317
+
318
+ ensure_card_titles_collection
319
+ schedule_qmd_reindex
320
+ end
321
+ end
322
+
323
+ # Backfill cards for a single project. Returns count of new cards indexed, or nil if skipped.
324
+ def backfill_project(project_key, config, seen_boards)
325
+ repo_path = config["repo_path"]
326
+ return nil unless repo_path && File.directory?(repo_path)
327
+
328
+ fizzy_yaml = File.join(repo_path, ".fizzy.yaml")
329
+ unless File.exist?(fizzy_yaml)
330
+ LOG.debug "[CardIndex] Skipping '#{project_key}' — no .fizzy.yaml"
331
+ return nil
332
+ end
333
+
334
+ begin
335
+ board_id = YAML.safe_load_file(fizzy_yaml)["board"]
336
+ rescue StandardError => e
337
+ LOG.warn "[CardIndex] Could not read .fizzy.yaml for '#{project_key}': #{e.message}"
338
+ return nil
339
+ end
340
+
341
+ if seen_boards.include?(board_id)
342
+ LOG.debug "[CardIndex] Skipping '#{project_key}' — board #{board_id} already fetched"
343
+ return nil
344
+ end
345
+ seen_boards << board_id
346
+
347
+ count = 0
348
+ output = run_cmd("fizzy", "card", "list", "--all", chdir: repo_path, env: default_fizzy_env)
349
+ cards = JSON.parse(output)["data"] || []
350
+ cards.each do |card|
351
+ num = card["number"]
352
+ next unless num
353
+ next if key?(num.to_s)
354
+
355
+ index_card(
356
+ number: num,
357
+ title: card["title"] || card["description"]&.slice(0, 80) || "untitled",
358
+ creator_name: card.dig("creator", "name"),
359
+ creator_id: card.dig("creator", "id"),
360
+ tags: card["tags"] || [],
361
+ closed: card["closed"] || false
362
+ )
363
+ count += 1
364
+ end
365
+ count
366
+ rescue StandardError => e
367
+ LOG.warn "[CardIndex] Backfill failed for project '#{project_key}': #{e.message}"
368
+ 0
369
+ end
370
+
371
+ # --- Startup ---
372
+
373
+ def sync_title_files
374
+ FileUtils.mkdir_p(@titles_dir)
375
+ each do |num, entry|
376
+ sync_card_title_file(num, entry["title"], closed: entry["closed"])
377
+ end
378
+ end
379
+ end
380
+
381
+ # --- Create singleton instance ---
382
+
383
+ CARD_INDEX = CardIndex.new(
384
+ index_file: File.join(ZILLACORE_DIR, "card_index.json"),
385
+ titles_dir: File.join(ZILLACORE_DIR, "card_titles")
386
+ )
387
+
388
+ CARD_INDEX.load
389
+ CARD_INDEX.sync_title_files
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+ require "open3"
6
+ require "fileutils"
7
+ require "logger"
8
+ require "net/http"
9
+ require "uri"
10
+
11
+ # --- Version ---
12
+
13
+ require_relative "version"
14
+ ZILLACORE_VERSION = ZillaCore::VERSION
15
+
16
+ # --- Environment & paths ---
17
+
18
+ FIZZY_WEBHOOK_SECRET = ENV.fetch("FIZZY_WEBHOOK_SECRET", nil)
19
+ AI_AGENT_NAME = ENV.fetch("AI_AGENT_NAME") do
20
+ case RbConfig::CONFIG["host_os"]
21
+ when /darwin/i then "Kaylee"
22
+ else "Galen"
23
+ end
24
+ end
25
+
26
+ ZILLACORE_DIR = ENV.fetch("ZILLACORE_DIR", File.join(Dir.home, ".zillacore"))
27
+ PROJECTS_FILE = File.join(ZILLACORE_DIR, "projects.json")
28
+ KIRO_AGENTS_DIR = File.join(Dir.home, ".kiro", "agents")
29
+ CARD_MAP_FILE = File.join(ZILLACORE_DIR, "card_map.json")
30
+ AGENT_TOKENS_FILE = File.join(ZILLACORE_DIR, "agent_tokens.json")
31
+ AGENT_REGISTRY_FILE = File.join(ZILLACORE_DIR, "agents.json")
32
+
33
+ LOG_LEVEL = ENV.fetch("LOG_LEVEL", "info").downcase
34
+ LOG = Logger.new($stdout)
35
+ LOG.level = case LOG_LEVEL
36
+ when "debug" then Logger::DEBUG
37
+ when "info" then Logger::INFO
38
+ when "warn" then Logger::WARN
39
+ when "error" then Logger::ERROR
40
+ else Logger::INFO # rubocop:disable Lint/DuplicateBranch
41
+ end
42
+
43
+ # --- Brain paths ---
44
+
45
+ BRAIN_BASE_DIR = File.join(ZILLACORE_DIR, "brain")
46
+ KNOWLEDGE_DIR = File.join(BRAIN_BASE_DIR, "knowledge")
47
+ PERSONA_BASE_DIR = File.join(BRAIN_BASE_DIR, "persona")
48
+ MEMORY_BASE_DIR = File.join(ZILLACORE_DIR, "brain", "memory")
49
+ MEMORY_FILE_TEMPLATE = "card-{{CARD_ID}}.md"
50
+ KNOWLEDGE_COLLECTION = "zillacore-knowledge"
51
+
52
+ # --- Fizzy auth ---
53
+
54
+ FIZZY_CONFIG_FILE = File.join(ZILLACORE_DIR, "fizzy.json")
55
+
56
+ def load_fizzy_config
57
+ return {} unless File.exist?(FIZZY_CONFIG_FILE)
58
+
59
+ JSON.parse(File.read(FIZZY_CONFIG_FILE))
60
+ rescue JSON::ParserError => e
61
+ LOG.error "Failed to parse Fizzy config: #{e.message}"
62
+ {}
63
+ end
64
+
65
+ FIZZY_CONFIG = load_fizzy_config
66
+
67
+ # --- GitHub auth ---
68
+
69
+ GITHUB_CONFIG_FILE = File.join(ZILLACORE_DIR, "github.json")
70
+
71
+ def load_github_config
72
+ return {} unless File.exist?(GITHUB_CONFIG_FILE)
73
+
74
+ JSON.parse(File.read(GITHUB_CONFIG_FILE))
75
+ rescue JSON::ParserError => e
76
+ LOG.error "Failed to parse GitHub config: #{e.message}"
77
+ {}
78
+ end
79
+
80
+ GITHUB_CONFIG = load_github_config
81
+
82
+ def github_webhook_secret
83
+ # Fallback to env var for backwards compatibility
84
+ GITHUB_CONFIG["webhook_secret"] || ENV.fetch("GITHUB_WEBHOOK_SECRET", nil)
85
+ end
86
+
87
+ # --- Board config ---
88
+
89
+ FIZZY_BOARDS = FIZZY_CONFIG["boards"] || {}
90
+
91
+ def board_config(board_key)
92
+ FIZZY_BOARDS[board_key.to_s]
93
+ end
94
+
95
+ def board_webhook_secret(board_key)
96
+ config = board_config(board_key)
97
+ config&.dig("webhook_secret") || FIZZY_WEBHOOK_SECRET
98
+ end
99
+
100
+ def board_column_id(board_key, column_name)
101
+ config = board_config(board_key)
102
+ config&.dig("columns", column_name.to_s)
103
+ end
104
+
105
+ # Find board_key by board_id (from .fizzy.yaml or payload)
106
+ def board_key_for_id(board_id)
107
+ FIZZY_BOARDS.each do |key, config|
108
+ return key if config["board_id"] == board_id
109
+ end
110
+ nil
111
+ end
112
+
113
+ # Determine board_key for a project by reading its .fizzy.yaml
114
+ def board_key_for_project(project_config)
115
+ fizzy_yaml = File.join(project_config["repo_path"], ".fizzy.yaml")
116
+ return nil unless File.exist?(fizzy_yaml)
117
+
118
+ require "yaml"
119
+ data = YAML.safe_load_file(fizzy_yaml)
120
+ board_id = data["board"]
121
+ board_key_for_id(board_id)
122
+ rescue StandardError => e
123
+ LOG.warn "Could not read .fizzy.yaml for board lookup: #{e.message}"
124
+ nil
125
+ end
126
+
127
+ # Build authorized user IDs from config or env var (env var overrides)
128
+ AUTHORIZED_USER_IDS = if ENV["AUTHORIZED_USER_IDS"] && !ENV["AUTHORIZED_USER_IDS"].empty?
129
+ ENV["AUTHORIZED_USER_IDS"].split(",").map(&:strip)
130
+ else
131
+ (FIZZY_CONFIG["authorized_users"] || []).map { |u| u["id"] }
132
+ end
133
+
134
+ NOTIFICATION_COMMAND = ENV.fetch("NOTIFICATION_COMMAND", nil)
135
+
136
+ # --- Projects ---
137
+
138
+ def load_projects_config
139
+ return {} unless File.exist?(PROJECTS_FILE)
140
+
141
+ projects = JSON.parse(File.read(PROJECTS_FILE))
142
+ LOG.info "Loaded #{projects.size} project(s) from #{PROJECTS_FILE}"
143
+ projects
144
+ rescue JSON::ParserError => e
145
+ LOG.error "Failed to parse projects config: #{e.message}"
146
+ {}
147
+ end
148
+
149
+ # Track file mtimes to avoid unnecessary reloads
150
+ CONFIG_MTIMES = {}
151
+
152
+ def file_changed?(path, force: false)
153
+ return true if force
154
+ return true unless File.exist?(path)
155
+
156
+ current_mtime = File.mtime(path)
157
+ last_mtime = CONFIG_MTIMES[path]
158
+ if last_mtime == current_mtime
159
+ false
160
+ else
161
+ CONFIG_MTIMES[path] = current_mtime
162
+ true
163
+ end
164
+ end
165
+
166
+ def reload_projects!(force: false)
167
+ return unless file_changed?(PROJECTS_FILE, force: force)
168
+
169
+ PROJECTS.replace(load_projects_config)
170
+ LOG.info "Reloaded projects configuration: #{PROJECTS.keys.join(", ")}"
171
+ end
172
+
173
+ def reload_github_config!(force: false)
174
+ return unless file_changed?(GITHUB_CONFIG_FILE, force: force)
175
+
176
+ GITHUB_CONFIG.replace(load_github_config)
177
+ LOG.info "Reloaded GitHub configuration"
178
+ end
179
+
180
+ PROJECTS = load_projects_config
181
+
182
+ DEFAULT_PROJECT = {
183
+ "repo_path" => ENV.fetch("REPO_PATH", Dir.pwd),
184
+ "fizzy_tags" => [],
185
+ "github_repo" => ENV.fetch("GITHUB_REPO", nil),
186
+ "agent_cli" => ENV.fetch("AGENT_CLI", "kiro-cli"),
187
+ "agent_cli_args" => ENV.fetch("AGENT_CLI_ARGS", "chat --trust-all-tools --no-interactive"),
188
+ "agent_model_flag" => ENV["AGENT_MODEL_FLAG"] || "--model",
189
+ "agent_model" => ENV.fetch("AGENT_MODEL", nil),
190
+ "agent_effort_flag" => ENV["AGENT_EFFORT_FLAG"] || "--effort",
191
+ "agent_effort" => ENV.fetch("AGENT_EFFORT", nil),
192
+ "allowed_models" => {
193
+ "opus" => "claude-opus-4.6",
194
+ "sonnet" => "claude-sonnet-4.6",
195
+ "haiku" => "claude-haiku-4.5",
196
+ "deepseek" => "deepseek-3.2",
197
+ "minimax" => "minimax-m2.5",
198
+ "minimax25" => "minimax-m2.5",
199
+ "minimax21" => "minimax-m2.1",
200
+ "qwen" => "qwen3-coder-next",
201
+ "auto" => "auto"
202
+ },
203
+ "allowed_efforts" => %w[low medium high xhigh max]
204
+ }.freeze
205
+
206
+ # --- Discord (optional) ---
207
+ # Discord is enabled when any agent in the registry has a discord_bot_token,
208
+ # OR when the legacy DISCORD_BOT_TOKEN env var is set.
209
+ # Requires the websocket-client-simple gem.
210
+
211
+ DISCORD_ENABLED = begin
212
+ require "websocket-client-simple"
213
+ true
214
+ rescue LoadError
215
+ warn "WARNING: websocket-client-simple gem not found. Discord bot disabled."
216
+ warn "Install with: gem install websocket-client-simple"
217
+ false
218
+ end
219
+
220
+ # --- Version check ---
221
+
222
+ # Check if local zillacore is behind origin/master.
223
+ # Returns { behind: true, local_sha:, remote_sha:, commits_behind: } or { behind: false }
224
+ def check_zillacore_version
225
+ zillacore_dir = File.join(__dir__, "..", "..")
226
+
227
+ # Fetch latest from origin (quiet, don't fail if offline)
228
+ _, _, status = Open3.capture3("git", "fetch", "origin", "master", "--quiet", chdir: zillacore_dir)
229
+ unless status.success?
230
+ LOG.warn "[Version] Could not fetch origin/master — skipping version check"
231
+ return { behind: false }
232
+ end
233
+
234
+ local_sha, = Open3.capture3("git", "rev-parse", "HEAD", chdir: zillacore_dir)
235
+ remote_sha, = Open3.capture3("git", "rev-parse", "origin/master", chdir: zillacore_dir)
236
+ local_sha = local_sha.strip
237
+ remote_sha = remote_sha.strip
238
+
239
+ return { behind: false } if local_sha == remote_sha
240
+
241
+ count, = Open3.capture3("git", "rev-list", "--count", "HEAD..origin/master", chdir: zillacore_dir)
242
+ { behind: true, local_sha: local_sha[0..6], remote_sha: remote_sha[0..6], commits_behind: count.strip.to_i }
243
+ end
244
+
245
+ # Discord user ID of the machine owner (for version-outdated notifications).
246
+ # Reads from discord.json (Discord-scoped config).
247
+ def owner_discord_id
248
+ discord_file = File.join(ZILLACORE_DIR, "discord.json")
249
+ return nil unless File.exist?(discord_file)
250
+
251
+ JSON.parse(File.read(discord_file))["owner_discord_id"]
252
+ rescue JSON::ParserError
253
+ nil
254
+ end
255
+
256
+ # --- Dashboard auth ---
257
+
258
+ DASHBOARD_TOKEN = begin
259
+ discord_file = File.join(ZILLACORE_DIR, "discord.json")
260
+ JSON.parse(File.read(discord_file))["dashboard_token"] if File.exist?(discord_file)
261
+ rescue JSON::ParserError
262
+ nil
263
+ end