openclacky 1.0.0.beta.5 → 1.0.0.beta.6
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 +4 -4
- data/CHANGELOG.md +9 -0
- data/lib/clacky/agent/message_compressor_helper.rb +46 -28
- data/lib/clacky/agent/skill_evolution.rb +21 -6
- data/lib/clacky/agent/skill_manager.rb +35 -1
- data/lib/clacky/session_manager.rb +105 -1
- data/lib/clacky/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: afc12c94c2b8b7580ca948625cc6c106004bbf385f341c783e36e1be9d93fd82
|
|
4
|
+
data.tar.gz: 95508d829f02270b3fce4849b21e29b6766a46d9c663d47e37df817aed456da5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8f44be2b9d9bf26f97490f5ddf2525a6cad937c5152b8486bb2840a263ab104cacfa5838600236b3a38a6806e69cd717fbce982838f2c2a65664158b0b4ed238
|
|
7
|
+
data.tar.gz: aecb14f4b6f345d190e52de0c0816f380b4e6c3213453c9e69a04b78944f757115e8a1ac042b0a78398e79d27de65190f4c0cb61d1efe3c224416b6a2f55f6c6
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.0.0.beta.6] - 2026-04-30
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Compression chunk indexing now uses disk-based discovery.** Chunk files are no longer incorrectly overwritten after the second compression. Previously, chunk index was counted from compressed_summary messages in history — which caps at 1 after rebuild — causing chunk-2.md to be overwritten on every subsequent compression. Now uses durable disk-based chunk discovery via SessionManager, ensuring all compressed chunks are preserved.
|
|
14
|
+
- **Skill evolution no longer creates duplicate skills.** The reflect and auto-create scenarios in skill evolution are now mutually exclusive: when a skill was just used, only reflection runs; when no skill was used, only auto-creation is considered. This prevents near-duplicate "auto-*" skills from being extracted from tasks already served by an existing skill.
|
|
15
|
+
|
|
16
|
+
### Improved
|
|
17
|
+
- **Slash commands no longer misinterpret filesystem paths.** Pasted paths like `/Users/alice/foo` or `/tmp/bar` are no longer mistaken for slash commands, avoiding confusing "skill not found" notices.
|
|
18
|
+
|
|
10
19
|
## [1.0.0.beta.5] - 2026-04-29
|
|
11
20
|
|
|
12
21
|
### Added
|
|
@@ -154,12 +154,22 @@ module Clacky
|
|
|
154
154
|
# Note: we need to remove the compression instruction message we just added
|
|
155
155
|
original_messages = @history.to_a[0..-2] # All except the last (compression instruction)
|
|
156
156
|
|
|
157
|
-
# Archive compressed messages to a chunk MD file before discarding them
|
|
158
|
-
#
|
|
159
|
-
#
|
|
160
|
-
#
|
|
161
|
-
|
|
162
|
-
|
|
157
|
+
# Archive compressed messages to a chunk MD file before discarding them.
|
|
158
|
+
#
|
|
159
|
+
# IMPORTANT: chunk_index and previous_chunks MUST come from disk, not from
|
|
160
|
+
# message history. Each compression's rebuild_with_compression keeps only
|
|
161
|
+
# ONE compressed_summary message (the new one), dropping older summaries
|
|
162
|
+
# and embedding their references into the new summary's content. So
|
|
163
|
+
# counting compressed_summary messages in history caps at 1 from the
|
|
164
|
+
# second compression onward — causing chunk-2.md to be overwritten on
|
|
165
|
+
# every subsequent compression, and losing references to chunk-1.md.
|
|
166
|
+
#
|
|
167
|
+
# Disk is the only durable source of truth: chunk files survive process
|
|
168
|
+
# restarts, session reloads, and message rebuilds. SessionManager owns
|
|
169
|
+
# all chunk file I/O (naming, writing, discovery) — we just ask it.
|
|
170
|
+
sm = session_manager
|
|
171
|
+
existing_chunks = sm.chunks_for_current(@session_id, @created_at)
|
|
172
|
+
chunk_index = sm.next_chunk_index(@session_id, @created_at)
|
|
163
173
|
|
|
164
174
|
# Extract topics from the LLM response to store in both the chunk MD front
|
|
165
175
|
# matter and the compressed_summary message hash (for future chunk indexing).
|
|
@@ -173,14 +183,13 @@ module Clacky
|
|
|
173
183
|
topics: topics
|
|
174
184
|
)
|
|
175
185
|
|
|
176
|
-
#
|
|
177
|
-
#
|
|
178
|
-
#
|
|
179
|
-
#
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
.map { |m| { basename: File.basename(m[:chunk_path]), path: m[:chunk_path], topics: m[:topics] } }
|
|
186
|
+
# Build previous_chunks index from the disk-discovered chunks (already
|
|
187
|
+
# sorted by index ascending). This gives the new summary a complete
|
|
188
|
+
# chronological index of all older archives so the AI can recall any
|
|
189
|
+
# past chunk via file_reader, not just the most recent one.
|
|
190
|
+
previous_chunks = existing_chunks.map do |c|
|
|
191
|
+
{ basename: c[:basename], path: c[:path], topics: c[:topics] }
|
|
192
|
+
end
|
|
184
193
|
|
|
185
194
|
@history.replace_all(@message_compressor.rebuild_with_compression(
|
|
186
195
|
compressed_content,
|
|
@@ -348,8 +357,22 @@ module Clacky
|
|
|
348
357
|
end
|
|
349
358
|
end
|
|
350
359
|
|
|
351
|
-
#
|
|
352
|
-
#
|
|
360
|
+
# Lazy accessor for a SessionManager instance used by compression chunk I/O.
|
|
361
|
+
# We keep this local to the helper rather than threading a manager instance
|
|
362
|
+
# through the Agent constructor — Agent itself doesn't persist sessions
|
|
363
|
+
# (CLI / HTTP server do that), but the compression archive lives in the
|
|
364
|
+
# same directory under SessionManager's ownership.
|
|
365
|
+
#
|
|
366
|
+
# NOTE: Uses Clacky::SessionManager::SESSIONS_DIR by default. Tests can
|
|
367
|
+
# stub that constant to point at a tmpdir.
|
|
368
|
+
private def session_manager
|
|
369
|
+
@session_manager ||= Clacky::SessionManager.new
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Save the messages being compressed to a chunk MD file for future recall.
|
|
373
|
+
# The filesystem concerns (path, write, chmod) are delegated to SessionManager;
|
|
374
|
+
# this method is responsible only for the business rules of WHAT gets archived.
|
|
375
|
+
#
|
|
353
376
|
# @param original_messages [Array<Hash>] All messages before compression (excluding compression instruction)
|
|
354
377
|
# @param recent_messages [Array<Hash>] Recent messages being kept (to exclude from chunk)
|
|
355
378
|
# @param chunk_index [Integer] Sequential chunk number
|
|
@@ -373,19 +396,14 @@ module Clacky
|
|
|
373
396
|
|
|
374
397
|
return nil if messages_to_archive.empty?
|
|
375
398
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
chunk_filename = "#{base_name}-chunk-#{chunk_index}.md"
|
|
381
|
-
chunk_path = File.join(sessions_dir, chunk_filename)
|
|
382
|
-
|
|
383
|
-
md_content = build_chunk_md(messages_to_archive, chunk_index: chunk_index, compression_level: compression_level, topics: topics)
|
|
384
|
-
|
|
385
|
-
File.write(chunk_path, md_content)
|
|
386
|
-
FileUtils.chmod(0o600, chunk_path)
|
|
399
|
+
md_content = build_chunk_md(messages_to_archive,
|
|
400
|
+
chunk_index: chunk_index,
|
|
401
|
+
compression_level: compression_level,
|
|
402
|
+
topics: topics)
|
|
387
403
|
|
|
388
|
-
|
|
404
|
+
# Delegate filesystem concerns (path assembly, write, chmod) to SessionManager —
|
|
405
|
+
# it owns the on-disk layout for sessions and their chunk archives.
|
|
406
|
+
session_manager.write_chunk(@session_id, @created_at, chunk_index, md_content)
|
|
389
407
|
rescue => e
|
|
390
408
|
@ui&.log("Failed to save chunk MD: #{e.message}", level: :warn)
|
|
391
409
|
nil
|
|
@@ -10,16 +10,31 @@ module Clacky
|
|
|
10
10
|
# Triggered at the end of Agent#run (post-run hooks), only for main agents.
|
|
11
11
|
module SkillEvolution
|
|
12
12
|
# Main entry point - runs all skill evolution checks
|
|
13
|
-
# Called from Agent#run after the main loop completes
|
|
13
|
+
# Called from Agent#run after the main loop completes.
|
|
14
|
+
#
|
|
15
|
+
# The two scenarios are mutually exclusive by design:
|
|
16
|
+
#
|
|
17
|
+
# * If a skill just ran (@skill_execution_context is set), the user's
|
|
18
|
+
# need was already served by an existing skill. Run Scenario 2
|
|
19
|
+
# (reflect + possibly improve that skill) and skip Scenario 1 —
|
|
20
|
+
# otherwise we would auto-extract a near-duplicate "auto-*" skill
|
|
21
|
+
# from the same task, polluting the skills directory.
|
|
22
|
+
#
|
|
23
|
+
# * If no skill ran, the task was solved with raw tools. That is the
|
|
24
|
+
# signal for Scenario 1: if the pattern is complex/repeatable enough,
|
|
25
|
+
# consider extracting it into a new skill.
|
|
14
26
|
def run_skill_evolution_hooks
|
|
15
27
|
return unless skill_evolution_enabled?
|
|
16
28
|
return if @is_subagent
|
|
17
29
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
30
|
+
if @skill_execution_context
|
|
31
|
+
# Scenario 2: Reflect on executed skill (may invoke skill-creator
|
|
32
|
+
# to UPDATE the existing skill, but will not create a new one).
|
|
33
|
+
maybe_reflect_on_skill
|
|
34
|
+
else
|
|
35
|
+
# Scenario 1: Auto-create new skill from complex task.
|
|
36
|
+
maybe_create_skill_from_task
|
|
37
|
+
end
|
|
23
38
|
end
|
|
24
39
|
|
|
25
40
|
# Check if skill evolution is enabled in config
|
|
@@ -33,12 +33,46 @@ module Clacky
|
|
|
33
33
|
def parse_skill_command(input)
|
|
34
34
|
return { matched: false } unless input.start_with?("/")
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
# Split off the first whitespace-delimited token after the leading "/".
|
|
37
|
+
# Shape of a slash command:
|
|
38
|
+
# /<command>
|
|
39
|
+
# /<command> <arguments...>
|
|
40
|
+
#
|
|
41
|
+
# The key distinction we need to make is "slash command" vs. "filesystem
|
|
42
|
+
# path starting with /". Paths look like "/xxx/yyy", "/Users/alice/foo",
|
|
43
|
+
# "/tmp/bar" — what they all share is a *second* "/" inside the first
|
|
44
|
+
# token. Slash commands, on the other hand, may legitimately contain
|
|
45
|
+
# non-slug characters like ':' or '.' (e.g. "/guizang-ppt-skill:create"),
|
|
46
|
+
# so we deliberately DO NOT require the command to be a clean slug here —
|
|
47
|
+
# find_by_command handles the lookup, and a pilot-error like "/foo.bar"
|
|
48
|
+
# should still surface a friendly "skill not found" notice.
|
|
49
|
+
#
|
|
50
|
+
# Rejected as slash commands (treated as plain user messages):
|
|
51
|
+
# - "/", "//", "/*.rb" — token is empty or begins with a separator/glob
|
|
52
|
+
# - "/ leading space" — whitespace immediately after /
|
|
53
|
+
# - "/Users/alice/foo" — second "/" inside the first token ⇒ a path
|
|
54
|
+
# - "/xxxx/zzzz/" — same
|
|
55
|
+
#
|
|
56
|
+
# Accepted (routed to find_by_command, may yield :not_found notice):
|
|
57
|
+
# - "/commit"
|
|
58
|
+
# - "/skill-add https://…" — "/" appears only in arguments, fine
|
|
59
|
+
# - "/guizang-ppt-skill:create", "/foo.bar" — non-slug but no path shape
|
|
60
|
+
match = input.match(%r{^/(\S+?)(?:\s+(.*))?$})
|
|
37
61
|
return { matched: false } unless match
|
|
38
62
|
|
|
39
63
|
skill_name = match[1]
|
|
40
64
|
arguments = match[2] || ""
|
|
41
65
|
|
|
66
|
+
# Reject path-like first tokens: anything containing a "/" after the
|
|
67
|
+
# leading one belongs to the filesystem, not the command namespace.
|
|
68
|
+
# This also naturally rejects "" (from "/" alone) and "*…" / ".…" style
|
|
69
|
+
# tokens because they won't be registered as a command — but those edge
|
|
70
|
+
# cases fall through to :not_found which is acceptable. The main goal is
|
|
71
|
+
# to stop pasted paths like "/Users/foo/bar" from producing a bogus
|
|
72
|
+
# "skill /Users/foo/bar not found" reply.
|
|
73
|
+
return { matched: false } if skill_name.include?("/")
|
|
74
|
+
return { matched: false } if skill_name.empty?
|
|
75
|
+
|
|
42
76
|
skill = @skill_loader.find_by_command("/#{skill_name}")
|
|
43
77
|
return { matched: true, found: false, skill_name: skill_name, reason: :not_found } unless skill
|
|
44
78
|
|
|
@@ -84,6 +84,67 @@ module Clacky
|
|
|
84
84
|
{ session: session, json_path: json_path, chunks: chunks }
|
|
85
85
|
end
|
|
86
86
|
|
|
87
|
+
# ── Chunk file I/O (for conversation compression archives) ────────────────
|
|
88
|
+
#
|
|
89
|
+
# The SessionManager is the single owner of sessions/{base}-chunk-N.md
|
|
90
|
+
# file naming, writing, discovery, and deletion. Everything else in the
|
|
91
|
+
# codebase (MessageCompressorHelper, SessionSerializer) should go through
|
|
92
|
+
# these methods rather than building paths or scanning the directory
|
|
93
|
+
# directly — this keeps the on-disk layout under one roof and makes it
|
|
94
|
+
# easy to evolve (e.g. add encryption, switch to a DB).
|
|
95
|
+
|
|
96
|
+
# Discover all chunk MD files on disk for a given session.
|
|
97
|
+
# Returns them sorted by chunk index ascending (oldest first).
|
|
98
|
+
#
|
|
99
|
+
# @param session_id [String] full session id (or at least first 8 chars)
|
|
100
|
+
# @param created_at [String] ISO-8601 timestamp used in the base filename
|
|
101
|
+
# @return [Array<Hash>] each with :index, :path, :basename, :topics
|
|
102
|
+
def chunks_for_current(session_id, created_at)
|
|
103
|
+
return [] unless session_id && created_at
|
|
104
|
+
|
|
105
|
+
base = chunk_base_name(session_id, created_at)
|
|
106
|
+
pattern = File.join(@sessions_dir, "#{base}-chunk-*.md")
|
|
107
|
+
|
|
108
|
+
Dir.glob(pattern).filter_map do |path|
|
|
109
|
+
basename = File.basename(path)
|
|
110
|
+
# Extract integer index from "<base>-chunk-<N>.md"
|
|
111
|
+
m = basename.match(/-chunk-(\d+)\.md\z/)
|
|
112
|
+
next nil unless m
|
|
113
|
+
|
|
114
|
+
{
|
|
115
|
+
index: m[1].to_i,
|
|
116
|
+
path: path,
|
|
117
|
+
basename: basename,
|
|
118
|
+
topics: read_chunk_topics(path)
|
|
119
|
+
}
|
|
120
|
+
end.sort_by { |c| c[:index] }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Next unused chunk index for a session, derived from disk.
|
|
124
|
+
# This is the ONLY correct way to compute the next chunk index —
|
|
125
|
+
# counting compressed_summary messages in history caps at 1 after the
|
|
126
|
+
# second compression (rebuild keeps only the latest summary) and
|
|
127
|
+
# in-memory counters reset on process restart.
|
|
128
|
+
def next_chunk_index(session_id, created_at)
|
|
129
|
+
existing = chunks_for_current(session_id, created_at)
|
|
130
|
+
(existing.map { |c| c[:index] }.max || 0) + 1
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Write a chunk MD file to disk. Returns the absolute path.
|
|
134
|
+
# Caller is responsible for generating the MD content — this method
|
|
135
|
+
# only handles filesystem concerns (path assembly, write, chmod).
|
|
136
|
+
def write_chunk(session_id, created_at, chunk_index, md_content)
|
|
137
|
+
return nil unless session_id && created_at
|
|
138
|
+
|
|
139
|
+
base = chunk_base_name(session_id, created_at)
|
|
140
|
+
chunk_path = File.join(@sessions_dir, "#{base}-chunk-#{chunk_index}.md")
|
|
141
|
+
|
|
142
|
+
File.write(chunk_path, md_content)
|
|
143
|
+
FileUtils.chmod(0o600, chunk_path)
|
|
144
|
+
|
|
145
|
+
chunk_path
|
|
146
|
+
end
|
|
147
|
+
|
|
87
148
|
# All sessions from disk, newest-first (sorted by created_at).
|
|
88
149
|
# Optional filters:
|
|
89
150
|
# current_dir: (String) if given, sessions matching working_dir come first
|
|
@@ -141,9 +202,52 @@ module Clacky
|
|
|
141
202
|
end
|
|
142
203
|
|
|
143
204
|
def generate_filename(session_id, created_at)
|
|
205
|
+
"#{chunk_base_name(session_id, created_at)}.json"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Base name (without extension) shared by a session's .json file and its
|
|
209
|
+
# chunk-N.md archive files. Kept as a single source of truth so chunk
|
|
210
|
+
# I/O stays consistent with the session filename.
|
|
211
|
+
private def chunk_base_name(session_id, created_at)
|
|
144
212
|
datetime = Time.parse(created_at).strftime("%Y-%m-%d-%H-%M-%S")
|
|
145
213
|
short_id = session_id[0..7]
|
|
146
|
-
"#{datetime}-#{short_id}
|
|
214
|
+
"#{datetime}-#{short_id}"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Read the `topics:` field from a chunk MD file's YAML-like front matter.
|
|
218
|
+
# Only scans the first ~20 lines — front matter is tiny and we don't
|
|
219
|
+
# want to read megabytes of archived conversation just to grab one line.
|
|
220
|
+
# Returns nil if the file is missing, unreadable, or has no topics.
|
|
221
|
+
private def read_chunk_topics(path)
|
|
222
|
+
return nil unless File.exist?(path)
|
|
223
|
+
|
|
224
|
+
lines = []
|
|
225
|
+
File.open(path, "r") do |f|
|
|
226
|
+
20.times do
|
|
227
|
+
line = f.gets
|
|
228
|
+
break if line.nil?
|
|
229
|
+
lines << line
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
in_front_matter = false
|
|
234
|
+
lines.each do |line|
|
|
235
|
+
stripped = line.strip
|
|
236
|
+
if stripped == "---"
|
|
237
|
+
break if in_front_matter
|
|
238
|
+
in_front_matter = true
|
|
239
|
+
next
|
|
240
|
+
end
|
|
241
|
+
next unless in_front_matter
|
|
242
|
+
|
|
243
|
+
if (m = stripped.match(/\Atopics:\s*(.+)\z/))
|
|
244
|
+
topics = m[1].strip
|
|
245
|
+
return topics.empty? ? nil : topics
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
nil
|
|
249
|
+
rescue
|
|
250
|
+
nil
|
|
147
251
|
end
|
|
148
252
|
|
|
149
253
|
# Delete a session JSON file and all its associated chunk MD files.
|
data/lib/clacky/version.rb
CHANGED