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
|
@@ -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
|