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 +4 -4
- data/CHANGELOG.md +12 -0
- data/lib/clacky/agent/message_compressor.rb +32 -8
- data/lib/clacky/agent/message_compressor_helper.rb +113 -12
- data/lib/clacky/agent.rb +0 -3
- data/lib/clacky/cli.rb +0 -1
- data/lib/clacky/server/http_server.rb +122 -119
- data/lib/clacky/server/session_registry.rb +50 -1
- data/lib/clacky/session_manager.rb +35 -4
- data/lib/clacky/ui2/layout_manager.rb +0 -5
- data/lib/clacky/ui2/progress_handle.rb +0 -3
- data/lib/clacky/ui2/ui_controller.rb +0 -9
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +54 -1
- data/lib/clacky/web/components/sidebar.js +1 -3
- data/lib/clacky/web/features/backup/store.js +23 -0
- data/lib/clacky/web/features/backup/view.js +49 -22
- data/lib/clacky/web/features/tasks/view.js +77 -28
- data/lib/clacky/web/i18n.js +22 -2
- data/lib/clacky/web/index.html +52 -26
- data/lib/clacky/web/sessions.js +36 -36
- data/lib/clacky/web/ws-dispatcher.js +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: 45a64960de249e34ee18e67f0ce38888b510a886988d545c58aca558d942bbdb
|
|
4
|
+
data.tar.gz: '008d7b7bec8fd7edf43cb9848949637d23783d2ad3a8a2b6199010f9b8ed660d'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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>
|
|
175
|
-
|
|
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(
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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 =
|
|
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
|
|