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