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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 39e25cd04a3d01fdacbb0382c2c367a1e72e8d2be88408e7fb29f804b3af1ba6
4
- data.tar.gz: 492ca66bcfb55a6cfc3f2cf38f171ce983f142a7a4b0f8655e5aafa317b79a69
3
+ metadata.gz: afc12c94c2b8b7580ca948625cc6c106004bbf385f341c783e36e1be9d93fd82
4
+ data.tar.gz: 95508d829f02270b3fce4849b21e29b6766a46d9c663d47e37df817aed456da5
5
5
  SHA512:
6
- metadata.gz: 014eeb8227bcc4cd94104a1da3bb2877083a1c70c4baaaf408233eec57ef684cbc2bcbac632ca52a771e2f1a8f436f2a09d89b697a165f1147891cabfe3708a0
7
- data.tar.gz: cc54f77d960bfd2db73906b713a84d0da6465fc18c65d9ec3ceb75d250bf426adaf4d9ba42c71900beab889bb6acf6a6472fa3843420fec8bbd3460a13f00088
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
- # Count existing compressed_summary messages in history to determine the next chunk index.
159
- # Using @compressed_summaries.size would reset to 0 on process restart and overwrite existing
160
- # chunk files, creating circular chunk references. Counting from history is always accurate.
161
- existing_chunk_count = original_messages.count { |m| m[:compressed_summary] }
162
- chunk_index = existing_chunk_count + 1
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
- # Collect previous chunk references so the new summary carries a complete
177
- # index of all older archives. Without this, each new compression would
178
- # lose all prior chunk references leaving only the newest chunk reachable
179
- # via replay_history. The AI can still access older chunks via file_reader
180
- # using the embedded basenames and topics.
181
- previous_chunks = original_messages
182
- .select { |m| m[:compressed_summary] && m[:chunk_path] }
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
- # Save the messages being compressed to a chunk MD file for future recall
352
- # File path: ~/.clacky/sessions/{datetime}-{short_id}-chunk-{n}.md
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
- sessions_dir = Clacky::SessionManager::SESSIONS_DIR
377
- datetime = Time.parse(@created_at).strftime("%Y-%m-%d-%H-%M-%S")
378
- short_id = @session_id[0..7]
379
- base_name = "#{datetime}-#{short_id}"
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
- chunk_path
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
- # Scenario 2: Reflect on executed skill (if one just ran)
19
- maybe_reflect_on_skill if @skill_execution_context
20
-
21
- # Scenario 1: Auto-create new skill from complex task
22
- maybe_create_skill_from_task
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
- match = input.match(%r{^/(\S+)(?:\s+(.*))?$})
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}.json"
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.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.0.0.beta.5"
4
+ VERSION = "1.0.0.beta.6"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openclacky
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0.beta.5
4
+ version: 1.0.0.beta.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy