openclacky 1.3.5 → 1.3.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: 5a7474760c07220891bc62795e95ab0f5b9f83387b4881e1cc9eec4133545222
4
- data.tar.gz: '099b846d3a8b44af563403c05f5bc8c855b0925122aabeb3f01b77f8f0c8d18f'
3
+ metadata.gz: 45a64960de249e34ee18e67f0ce38888b510a886988d545c58aca558d942bbdb
4
+ data.tar.gz: '008d7b7bec8fd7edf43cb9848949637d23783d2ad3a8a2b6199010f9b8ed660d'
5
5
  SHA512:
6
- metadata.gz: e494c9032f35cf631a91dbbff72c89004c1aa8991a6f630dc21c65a9d97b45bceb2afa9c69b2f25b0d4c0e909d317b64eff5ed7b972fd475edc2f19e81a2f779
7
- data.tar.gz: 7fb32e090e6cfd780cd0b4218bbfb00a0f939ce77089cc124e35d03a8870abdbb27f09de976304526b8e905d14107fc861914ba08f80d447a72e4d24a6711c8f
6
+ metadata.gz: 2f0e9c3fb21691cf9bbfdd6820faaaed86086bebb820278b6104356f92fec4c0e53584a8c3ca40bfd42fb7f82e1f3a484ed404c2b693a47467e9c43d517ed9e8
7
+ data.tar.gz: 83cb1fa9ac5a3ae4834bf5c02c344da9db09a5863a4970857f2d233656c66d9b20fde131f0af4b04d23e204a09a978968ae7d2310ae1c3c3ceb8bcb6d721e3c2
data/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.3.6] - 2026-06-30
9
+
10
+ ### Added
11
+ - Cron task session isolation with independent pagination and stable task count
12
+ - Backup/restore UI in settings with WebSocket reconnect refresh and WSL open-folder support
13
+
14
+ ### Fixed
15
+ - Rewrite human-readable cron parser to handle comma-separated lists, hour ranges, and `*/1` normalization
16
+ - Setup submit button hover color follows accent theme variable
17
+ - Settings button no longer navigates away when already on settings page
18
+ - Use Unix epoch for old thread timestamps to avoid date parsing issues
19
+
8
20
  ## [1.3.5] - 2026-06-29
9
21
 
10
22
  ### Added
@@ -39,10 +39,14 @@ module Clacky
39
39
 
40
40
  REQUIRED RESPONSE FORMAT:
41
41
  First output a <topics> line listing 3-6 key topic phrases (comma-separated, concise).
42
+ Then output a <continues_previous> line: "true" if this conversation is a direct
43
+ continuation of the SAME task/topic as the PREVIOUS chunk shown below, "false" if it
44
+ has moved on to a different task or topic. If there is no previous chunk, output "false".
42
45
  Then output the full summary wrapped in <summary> tags.
43
46
 
44
47
  Example format:
45
48
  <topics>Rails setup, database config, deploy pipeline, Tailwind CSS</topics>
49
+ <continues_previous>false</continues_previous>
46
50
  <summary>
47
51
  ...full summary text...
48
52
  </summary>
@@ -54,7 +58,8 @@ module Clacky
54
58
  - Errors encountered and fixes applied
55
59
  - Current work status and pending tasks
56
60
 
57
- Begin your response NOW. Remember: PURE TEXT only, starting with <topics> then <summary>.
61
+ Begin your response NOW. Remember: PURE TEXT only, starting with <topics> then
62
+ <continues_previous> then <summary>.
58
63
  PROMPT
59
64
 
60
65
  def initialize(client, model: nil)
@@ -72,18 +77,25 @@ module Clacky
72
77
  #
73
78
  # @param messages [Array<Hash>] Original conversation messages
74
79
  # @param recent_messages [Array<Hash>] Recent messages to keep uncompressed (optional)
80
+ # @param previous_topics [String, nil] Topics of the most recent chunk on disk,
81
+ # shown to the LLM so it can decide whether the current conversation is a
82
+ # continuation (drives the <continues_previous> output for chunk merging).
75
83
  # @return [Hash] Compression instruction message to insert, or nil if nothing to compress
76
- def build_compression_message(messages, recent_messages: [])
84
+ def build_compression_message(messages, recent_messages: [], previous_topics: nil)
77
85
  # Get messages to compress (exclude system message and recent messages)
78
86
  messages_to_compress = messages.reject { |m| m[:role] == "system" || recent_messages.include?(m) }
79
87
 
80
88
  # If nothing to compress, return nil
81
89
  return nil if messages_to_compress.empty?
82
90
 
83
- # Simple compression instruction - LLM can see the history already
84
- {
85
- role: "user",
86
- content: COMPRESSION_PROMPT,
91
+ content = COMPRESSION_PROMPT
92
+ if previous_topics && !previous_topics.strip.empty?
93
+ content = "#{COMPRESSION_PROMPT}\n\nPREVIOUS CHUNK TOPICS (for <continues_previous> judgement): #{previous_topics}"
94
+ end
95
+
96
+ {
97
+ role: "user",
98
+ content: content,
87
99
  system_injected: true
88
100
  }
89
101
  end
@@ -142,6 +154,15 @@ module Clacky
142
154
  m ? m[1].strip : nil
143
155
  end
144
156
 
157
+ # Parse the <continues_previous> tag. Returns true only when the LLM
158
+ # explicitly says "true"; missing tag or any other value → false.
159
+ # This conservative default ensures we never merge unless the model is sure.
160
+ def parse_continues_previous(content)
161
+ return false if content.nil? || content.to_s.empty?
162
+ m = content.to_s.match(/<continues_previous>(.*?)<\/continues_previous>/m)
163
+ m ? m[1].strip.downcase == "true" : false
164
+ end
165
+
145
166
  def parse_compressed_result(result, chunk_path: nil, topics: nil, previous_chunks: [])
146
167
  # Return the compressed result as a single user message (role: "user").
147
168
  #
@@ -171,8 +192,11 @@ module Clacky
171
192
  if content.empty?
172
193
  []
173
194
  else
174
- # Strip out the <topics> blockit's metadata for the chunk file, not for AI context
175
- content_without_topics = content.gsub(/<topics>.*?<\/topics>\n*/m, "").strip
195
+ # Strip out the <topics> and <continues_previous> blocks they're
196
+ # metadata for chunk handling, not for AI context.
197
+ content_without_topics = content.gsub(/<topics>.*?<\/topics>\n*/m, "")
198
+ .gsub(/<continues_previous>.*?<\/continues_previous>\n*/m, "")
199
+ .strip
176
200
 
177
201
  # Build previous chunks index section — links to older chunk files so the AI
178
202
  # can find earlier conversations without keeping all prior compressed_summary
@@ -193,8 +193,18 @@ module Clacky
193
193
  recent_messages = get_recent_messages_with_tool_pairs(all_messages, target_recent_count)
194
194
  recent_messages = [] if recent_messages.nil?
195
195
 
196
+ # Surface the most recent chunk's topics so the compression LLM can judge
197
+ # whether this conversation continues the same task (drives chunk merging).
198
+ previous_topics = nil
199
+ if @session_id && @created_at
200
+ latest = session_manager.chunks_for_current(@session_id, @created_at).last
201
+ previous_topics = latest && latest[:topics]
202
+ end
203
+
196
204
  # Build compression instruction message (to be inserted into conversation)
197
- compression_message = @message_compressor.build_compression_message(all_messages, recent_messages: recent_messages)
205
+ compression_message = @message_compressor.build_compression_message(
206
+ all_messages, recent_messages: recent_messages, previous_topics: previous_topics
207
+ )
198
208
 
199
209
  return nil if compression_message.nil?
200
210
 
@@ -242,25 +252,52 @@ module Clacky
242
252
  # all chunk file I/O (naming, writing, discovery) — we just ask it.
243
253
  sm = session_manager
244
254
  existing_chunks = sm.chunks_for_current(@session_id, @created_at)
245
- chunk_index = sm.next_chunk_index(@session_id, @created_at)
246
255
 
247
256
  # Extract topics from the LLM response to store in both the chunk MD front
248
257
  # matter and the compressed_summary message hash (for future chunk indexing).
249
258
  topics = @message_compressor.parse_topics(compressed_content)
250
259
 
251
- chunk_path = save_compressed_chunk(
252
- original_messages,
253
- compression_context[:recent_messages],
254
- chunk_index: chunk_index,
255
- compression_level: compression_context[:compression_level],
256
- topics: topics
257
- )
260
+ # Decide whether to MERGE into the previous chunk or create a NEW one.
261
+ # The LLM judges (via <continues_previous>) whether this conversation is a
262
+ # direct continuation of the previous chunk's task. Merging avoids tiny
263
+ # fragmented chunks (e.g. a long task compressed mid-flight into 2-message
264
+ # chunks) that pollute the topics index and degrade recall.
265
+ latest_chunk = existing_chunks.last
266
+ continues = latest_chunk && @message_compressor.parse_continues_previous(compressed_content)
267
+
268
+ if continues
269
+ chunk_path = merge_into_previous_chunk(
270
+ latest_chunk,
271
+ original_messages,
272
+ compression_context[:recent_messages],
273
+ compression_level: compression_context[:compression_level],
274
+ topics: topics
275
+ )
276
+ # Fallback to new chunk if the merge could not be performed.
277
+ chunk_path ||= save_compressed_chunk(
278
+ original_messages, compression_context[:recent_messages],
279
+ chunk_index: sm.next_chunk_index(@session_id, @created_at),
280
+ compression_level: compression_context[:compression_level], topics: topics
281
+ )
282
+ # The merged chunk is the current chunk — exclude it from previous_chunks.
283
+ index_chunks = existing_chunks.reject { |c| c[:index] == latest_chunk[:index] }
284
+ else
285
+ chunk_index = sm.next_chunk_index(@session_id, @created_at)
286
+ chunk_path = save_compressed_chunk(
287
+ original_messages,
288
+ compression_context[:recent_messages],
289
+ chunk_index: chunk_index,
290
+ compression_level: compression_context[:compression_level],
291
+ topics: topics
292
+ )
293
+ index_chunks = existing_chunks
294
+ end
258
295
 
259
296
  # Build previous_chunks index from the disk-discovered chunks (already
260
297
  # sorted by index ascending). This gives the new summary a complete
261
298
  # chronological index of all older archives so the AI can recall any
262
299
  # past chunk via file_reader, not just the most recent one.
263
- previous_chunks = existing_chunks.map do |c|
300
+ previous_chunks = index_chunks.map do |c|
264
301
  { basename: c[:basename], path: c[:path], topics: c[:topics] }
265
302
  end
266
303
 
@@ -483,6 +520,62 @@ module Clacky
483
520
  nil
484
521
  end
485
522
 
523
+ # Merge the current batch of compressed messages INTO an existing chunk
524
+ # (overwrite-in-place, same chunk index). Used when the LLM judged this
525
+ # conversation as a continuation of the previous chunk's task. Keeps the
526
+ # archive on a single, growing, well-formed chunk instead of fragmenting
527
+ # into tiny standalone files that pollute the topics index.
528
+ #
529
+ # Every write hits disk immediately, so a crash never loses archived
530
+ # messages — there is no in-memory buffering.
531
+ #
532
+ # @param prev_chunk [Hash] disk-discovered chunk hash ({ index:, path:, topics: })
533
+ # @return [String, nil] the chunk path on success, nil if merge not possible
534
+ def merge_into_previous_chunk(prev_chunk, original_messages, recent_messages, compression_level:, topics: nil)
535
+ return nil unless @session_id && @created_at
536
+
537
+ recent_set = recent_messages.to_a
538
+ messages_to_archive = original_messages.reject do |m|
539
+ m[:role] == "system" || m[:system_injected] || m[:compressed_summary] || recent_set.include?(m)
540
+ end
541
+ return nil if messages_to_archive.empty?
542
+
543
+ sm = session_manager
544
+ raw = sm.read_chunk(prev_chunk[:path])
545
+ return nil unless raw
546
+
547
+ fm, body = sm.split_chunk_md(raw)
548
+ return nil unless fm
549
+
550
+ new_sections = render_message_sections(messages_to_archive)
551
+
552
+ fm["compression_level"] = compression_level.to_s
553
+ fm["archived_at"] = Time.now.iso8601
554
+ fm["message_count"] = (fm["message_count"].to_i + messages_to_archive.size).to_s
555
+ fm["merged_count"] = (fm.fetch("merged_count", "1").to_i + 1).to_s
556
+ fm["topics"] = merge_topics(fm["topics"], topics)
557
+
558
+ lines = ["---"]
559
+ fm.each { |k, v| lines << "#{k}: #{v}" }
560
+ lines << "---"
561
+ lines << body.rstrip
562
+ lines << ""
563
+ lines.concat(new_sections)
564
+
565
+ sm.write_chunk(@session_id, @created_at, prev_chunk[:index], lines.join("\n"))
566
+ rescue => e
567
+ @ui&.log("Failed to merge chunk MD: #{e.message}", level: :warn)
568
+ nil
569
+ end
570
+
571
+ # Union two comma-separated topic strings, preserving order, dropping dups.
572
+ private def merge_topics(existing, incoming)
573
+ a = (existing || "").split(/\s*,\s*/).map(&:strip).reject(&:empty?)
574
+ b = (incoming || "").split(/\s*,\s*/).map(&:strip).reject(&:empty?)
575
+ merged = (a + b).uniq
576
+ merged.empty? ? nil : merged.join(", ")
577
+ end
578
+
486
579
  # Build markdown content from a list of messages
487
580
  # @param messages [Array<Hash>] Messages to render
488
581
  # @param chunk_index [Integer] Chunk number for metadata
@@ -508,6 +601,15 @@ module Clacky
508
601
  lines << "> Use `file_reader` to recall specific details from this conversation."
509
602
  lines << ""
510
603
 
604
+ lines.concat(render_message_sections(messages))
605
+
606
+ lines.join("\n")
607
+ end
608
+
609
+ # Render messages into chunk MD body sections (no front matter / header).
610
+ # Shared by build_chunk_md and the chunk-merge path.
611
+ def render_message_sections(messages)
612
+ lines = []
511
613
  messages.each do |msg|
512
614
  role = msg[:role]
513
615
  content = msg[:content]
@@ -560,8 +662,7 @@ module Clacky
560
662
  lines << ""
561
663
  end
562
664
  end
563
-
564
- lines.join("\n")
665
+ lines
565
666
  end
566
667
 
567
668
  # Format message content (handles string or array of content blocks)
data/lib/clacky/agent.rb CHANGED
@@ -683,9 +683,6 @@ module Clacky
683
683
  raise
684
684
  ensure
685
685
  # Safety net: ensure any lingering progress spinner is stopped.
686
- # Normal paths close their own spinners; this guards against exceptions
687
- # raised between a progress slot's active/done pair.
688
- Clacky::Logger.warn("[ph_debug] agent_run_ensure")
689
686
  @ui&.show_progress(phase: "done")
690
687
 
691
688
  # Fire-and-forget telemetry after every agent run.
data/lib/clacky/cli.rb CHANGED
@@ -654,7 +654,6 @@ module Clacky
654
654
 
655
655
  # Handle agent error/interrupt with cleanup
656
656
  def handle_agent_exception(ui_controller, agent, session_manager, exception)
657
- Clacky::Logger.warn("[ph_debug] handle_agent_exception", klass: exception.class.name, msg: exception.message.to_s[0, 200])
658
657
  ui_controller.show_progress(phase: "done")
659
658
  ui_controller.set_idle_status
660
659