openclacky 0.9.38 → 1.0.0.beta.2

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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +67 -13
  3. data/CHANGELOG.md +40 -0
  4. data/lib/clacky/agent/llm_caller.rb +48 -2
  5. data/lib/clacky/agent/memory_updater.rb +131 -35
  6. data/lib/clacky/agent/message_compressor.rb +30 -3
  7. data/lib/clacky/agent/message_compressor_helper.rb +53 -19
  8. data/lib/clacky/agent/time_machine.rb +12 -3
  9. data/lib/clacky/agent/tool_executor.rb +0 -3
  10. data/lib/clacky/agent.rb +190 -61
  11. data/lib/clacky/agent_config.rb +201 -47
  12. data/lib/clacky/brand_config.rb +77 -5
  13. data/lib/clacky/cli.rb +101 -45
  14. data/lib/clacky/message_format/bedrock.rb +4 -0
  15. data/lib/clacky/message_history.rb +79 -4
  16. data/lib/clacky/platform_http_client.rb +7 -7
  17. data/lib/clacky/providers.rb +170 -8
  18. data/lib/clacky/server/http_server.rb +138 -21
  19. data/lib/clacky/telemetry.rb +111 -0
  20. data/lib/clacky/tools/terminal.rb +27 -0
  21. data/lib/clacky/tools/todo_manager.rb +11 -2
  22. data/lib/clacky/ui2/layout_manager.rb +22 -1
  23. data/lib/clacky/ui2/progress_handle.rb +291 -0
  24. data/lib/clacky/ui2/ui_controller.rb +261 -185
  25. data/lib/clacky/ui_interface.rb +69 -0
  26. data/lib/clacky/version.rb +1 -1
  27. data/lib/clacky/web/app.css +53 -0
  28. data/lib/clacky/web/app.js +1 -1
  29. data/lib/clacky/web/brand.js +112 -1
  30. data/lib/clacky/web/i18n.js +24 -16
  31. data/lib/clacky/web/index.html +15 -2
  32. data/lib/clacky/web/sessions.js +23 -6
  33. data/lib/clacky/web/settings.js +34 -0
  34. data/lib/clacky/web/ws.js +3 -2
  35. data/lib/clacky.rb +1 -0
  36. data/scripts/install.ps1 +20 -5
  37. metadata +3 -2
  38. data/lib/clacky/ui2/README.md +0 -214
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 43106e90c922f80d2bb342d051d68a6cb4efa045143c97b08c26bf776a952a6d
4
- data.tar.gz: 1faee045587d15517d25ab68df5003c5a210db1d009b9b8363d6c27f0b53157d
3
+ metadata.gz: a6a51d00de51f04f142d6be7e3e726561384752ca02f44a6971eeaad9c2cdb28
4
+ data.tar.gz: 185e635750793082206332377649095313b62297a2bd2f9f0b825a523a499afb
5
5
  SHA512:
6
- metadata.gz: 8e696b00d7e79c968851c1f5a194fe352d8d9f7971ca37f56321705dd83ec8a71a3cae1836468df94b3877d5ac6a25cf31c991f30e10d0997ec0a6b06b2c9825
7
- data.tar.gz: 92628aced329eb6e7c9567034873a276a15a64692e3796cf995cfbe1303132d6720fc69115332ab69371d07e5f4963377c80a5bad568797619c58ecc48fca04c
6
+ metadata.gz: 66e14242f2d4b0e049fd45283c77c71919614d5f2ef8558b8635318f443afd41ab3ebf941a3b10a22c987805569082f699683af5eae23f5ef54e6dbd993e4931
7
+ data.tar.gz: 4734d321c296e168f505185e67bddf8967687fd4889871a9a148f8bc003a7bb866774eb4f8c15ab4aba8b27384ae28e1c436477b3dd65e898c90743b393dbfb9
@@ -3,7 +3,9 @@
3
3
  name: gem-release
4
4
  description: >-
5
5
  Automates the complete process of releasing a new version of the openclacky Ruby
6
- gem
6
+ gem. Supports both stable releases (auto-increment) and pre-release versions
7
+ (user-specified, e.g., 1.0.0.beta.1). Handles version bumping, testing, building,
8
+ RubyGems publishing, GitHub Releases, and OSS CDN mirroring.
7
9
  disable-model-invocation: false
8
10
  user-invocable: true
9
11
  ---
@@ -21,6 +23,7 @@ This skill handles the entire gem release workflow from version bumping to publi
21
23
  To use this skill, simply say:
22
24
  - "Release a new version"
23
25
  - "Publish a new gem version"
26
+ - "Release version 1.0.0.beta.1" (pre-release with explicit version)
24
27
  - Use the command: `/gem-release`
25
28
 
26
29
  ## Process Steps
@@ -31,10 +34,30 @@ To use this skill, simply say:
31
34
  - Ensure the repository is in a clean state
32
35
 
33
36
  ### 2. Version Management
37
+
38
+ **Stable releases (default):**
34
39
  - Read current version from `lib/clacky/version.rb`
35
40
  - Increment version number (typically patch version: x.y.z → x.y.z+1)
36
41
  - Update the VERSION constant in the version file
37
42
 
43
+ **Pre-release versions (when user specifies a version like `1.0.0.beta.1`):**
44
+ - Accept the user-provided version string directly — do NOT auto-increment
45
+ - The version must follow semver pre-release format: `X.Y.Z-<identifier>` or `X.Y.Z.<identifier>` (e.g., `1.0.0.beta.1`, `2.0.0-alpha`, `1.5.0-rc1`)
46
+ - Before proceeding, warn the user about pre-release caveats (see Pre-Release Caveats below)
47
+
48
+ ### 2a. Pre-Release Caveats
49
+
50
+ When releasing a pre-release version, inform the user of these known behaviors in the Clacky ecosystem:
51
+
52
+ | Concern | Behavior | Impact |
53
+ |---------|----------|--------|
54
+ | **Version check notification** | RubyGems API returns the highest version number, including prereleases. `Gem::Version("0.9.38") < Gem::Version("1.0.0.beta.1")` → `true`. | ✅ The upgrade dot WILL appear in the Web UI for most users. |
55
+ | **`gem update` (official source)** | `gem update openclacky --no-document` does NOT install prereleases without `--pre`. | ❌ Users on official RubyGems source who click "Upgrade" will see the notification but the upgrade will silently do nothing. |
56
+ | **OSS CDN upgrade (mirror users)** | `upgrade_via_oss_cdn` downloads the exact `.gem` from `latest.txt` on OSS. | ⚠️ If you update `latest.txt` to point to the prerelease, mirror users WILL get the beta. |
57
+ | **OSS `latest.txt`** | Stable users fetching `latest.txt` for fresh installs would get the beta. | ⚠️ By default, do NOT update `latest.txt` for pre-releases. Only update if this is intentional (e.g., a release candidate for broad testing). |
58
+
59
+ **Action**: Ask the user whether to update `latest.txt` on OSS before proceeding. For internal testing, the answer is usually "no".
60
+
38
61
  ### 3. Quality Assurance
39
62
  - Run the full test suite with `bundle exec rspec`
40
63
  - Ensure all 167+ tests pass
@@ -93,15 +116,26 @@ To use this skill, simply say:
93
116
 
94
117
  4. **Create GitHub Release and Upload gem**
95
118
 
96
- Extract the release notes for this version from CHANGELOG.md, then create a GitHub Release with the .gem file attached:
119
+ Extract the release notes for this version from CHANGELOG.md, then create a GitHub Release with the .gem file attached.
120
+
121
+ **For stable releases:**
97
122
  ```bash
98
123
  gh release create v{version} \
99
124
  --title "v{version}" \
100
- --notes-file /tmp/release_notes.md \
125
+ --notes-file /tmp/release_notes_{version}.md \
101
126
  --latest \
102
127
  openclacky-{version}.gem
103
128
  ```
104
129
 
130
+ **For pre-release versions (e.g., `1.0.0.beta.1`):** use `--prerelease` instead of `--latest`:
131
+ ```bash
132
+ gh release create v{version} \
133
+ --title "v{version}" \
134
+ --notes-file /tmp/release_notes_{version}.md \
135
+ --prerelease \
136
+ openclacky-{version}.gem
137
+ ```
138
+
105
139
  Steps:
106
140
  - Parse the CHANGELOG.md section for `[{version}]`
107
141
  - Write it to a temp file (e.g., `/tmp/release_notes_{version}.md`) to avoid shell escaping issues
@@ -112,22 +146,28 @@ To use this skill, simply say:
112
146
 
113
147
  5. **Sync to Tencent Cloud OSS (CN mirror)**
114
148
 
115
- After GitHub Release is created, upload the .gem file and update `latest.txt` on OSS so Chinese users can install without hitting GitHub directly:
149
+ After GitHub Release is created, upload the .gem file to OSS so Chinese users can install without hitting GitHub directly.
116
150
 
117
151
  ```bash
118
- # Upload .gem file
152
+ # Upload .gem file (always do this for any release)
119
153
  coscli cp openclacky-{version}.gem cos://clackyai-1258723534/openclacky/openclacky-{version}.gem
154
+ ```
120
155
 
121
- # Update latest.txt
156
+ **For stable releases only** — update `latest.txt` so fresh installs and mirror users pick up the new version:
157
+ ```bash
122
158
  echo "{version}" > /tmp/latest.txt
123
159
  coscli cp /tmp/latest.txt cos://clackyai-1258723534/openclacky/latest.txt
124
160
 
125
161
  # Verify
126
162
  curl -fsSL https://oss.1024code.com/openclacky/latest.txt
127
163
  ```
128
-
129
164
  Expected output of verify: `{version}`
130
165
 
166
+ **For pre-release versions** — do NOT update `latest.txt` unless the user explicitly requested it. Updating `latest.txt` to a prerelease would cause:
167
+ - Mirror users clicking "Upgrade" to get the beta via `upgrade_via_oss_cdn`
168
+ - Fresh installs via the install script to get the beta
169
+ - Only skip this if the user explicitly wants broad beta distribution
170
+
131
171
  > **Prerequisite**: `coscli` installed at `/usr/local/bin/coscli` and configured at `~/.cos.yaml`
132
172
 
133
173
  6. **Sync scripts/ to OSS**
@@ -325,22 +365,34 @@ git tag vX.Y.Z
325
365
  git push origin main
326
366
  git push origin --tags
327
367
 
328
- # Create GitHub Release with .gem asset (requires gh CLI)
329
- # 1. Extract release notes from CHANGELOG.md for this version
330
- # 2. Write to temp file to avoid shell escaping issues
331
- # 3. Create the release and attach .gem file
368
+ # ── GitHub Release ──────────────────────────────────────────────────────
369
+
370
+ # Stable release:
332
371
  gh release create vX.Y.Z \
333
372
  --title "vX.Y.Z" \
334
373
  --notes-file /tmp/release_notes_X.Y.Z.md \
335
374
  --latest \
336
375
  openclacky-X.Y.Z.gem
337
376
 
338
- # Sync to Tencent Cloud OSS (CN mirror)
377
+ # Pre-release (use --prerelease instead of --latest):
378
+ gh release create vX.Y.Z-beta.1 \
379
+ --title "vX.Y.Z-beta.1" \
380
+ --notes-file /tmp/release_notes_X.Y.Z-beta.1.md \
381
+ --prerelease \
382
+ openclacky-X.Y.Z.beta.1.gem
383
+
384
+ # ── OSS CDN (CN mirror) ─────────────────────────────────────────────────
385
+
386
+ # Always upload the .gem file:
339
387
  coscli cp openclacky-X.Y.Z.gem cos://clackyai-1258723534/openclacky/openclacky-X.Y.Z.gem
388
+
389
+ # Stable releases ONLY — update latest.txt:
340
390
  echo "X.Y.Z" > /tmp/latest.txt
341
391
  coscli cp /tmp/latest.txt cos://clackyai-1258723534/openclacky/latest.txt
342
392
  curl -fsSL https://oss.1024code.com/openclacky/latest.txt # verify
343
393
 
394
+ # Pre-releases — skip latest.txt update unless user explicitly requests it
395
+
344
396
  # Sync scripts/ to OSS (build from templates first)
345
397
  bash scripts/build/build.sh
346
398
  for script in scripts/*; do
@@ -365,8 +417,10 @@ curl -fsSL https://oss.1024code.com/clacky-ai/openclacky/main/scripts/install.sh
365
417
  - Git repository updated with version tag
366
418
  - CHANGELOG.md updated with release notes
367
419
  - GitHub Release created with .gem file attached at https://github.com/clacky-ai/openclacky/releases
420
+ - Use `--latest` for stable releases, `--prerelease` for pre-releases
368
421
  - .gem file uploaded to OSS: https://oss.1024code.com/openclacky/openclacky-{version}.gem
369
- - latest.txt updated on OSS: https://oss.1024code.com/openclacky/latest.txt returns the new version
422
+ - For stable releases: `latest.txt` updated on OSS: https://oss.1024code.com/openclacky/latest.txt returns the new version
423
+ - For pre-releases: `latest.txt` NOT updated (unless user explicitly opts in)
370
424
  - No build or deployment errors
371
425
  - User-facing release summary presented at the end
372
426
 
data/CHANGELOG.md CHANGED
@@ -7,6 +7,46 @@ 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.2] - 2026-04-27
11
+
12
+ ### Added
13
+ - **New session creation supports model & working-directory options.** The Web UI "new session" dialog now lets you pick the model and starting directory up front, instead of having to adjust them after the session opens.
14
+
15
+ ### Fixed
16
+ - **System prompt now refreshes when you switch models.** Previously the system prompt captured at session start stuck around even after `/model` or `/provider` switches, which could leave model-specific instructions out of sync. The agent now re-injects the correct system prompt on every model change.
17
+ - **Port 7070 properly released when the terminal tool exits.** A lingering listener on port 7070 could block subsequent runs; the terminal tool now cleans it up on shutdown.
18
+ - **Windows installer uses `[IO.Path]::GetTempPath()` for the temp directory** (#58) — more reliable than `$env:TEMP` on systems where the env var is unset or points to a non-ASCII path.
19
+
20
+ ## [1.0.0.beta.1] - 2026-04-26
21
+
22
+ ### Added
23
+ - **Vision support — agents can now "see" images.** When you attach image files (PNG, JPG, GIF, WebP), the agent can analyze them visually with vision-capable models. Non-vision models automatically fall back to disk references instead of breaking.
24
+ - **DeepSeek V4 (Clacky-DS) provider.** New `deepseekv4` provider preset with native DeepSeek API endpoint, supporting `deepseek-v4-pro` and `deepseek-v4-flash` models with accurate pricing.
25
+ - **Memory subagent.** Long-term memory management now runs as a dedicated background subagent — writes memories when the task reaches meaningful completion, instead of on every turn.
26
+ - **Usage telemetry.** Anonymous usage data collection helps us understand how the product is used and prioritize improvements. No personal or conversation data is collected.
27
+ - **Brand configuration auto-refresh.** White-label brand settings now refresh automatically when the WebUI starts up, no manual restart needed.
28
+
29
+ ### Improved
30
+ - **Progress handles revamped.** Nested progress handles now hide/show automatically, ticker threads keep animations smooth, and fast-completing tasks no longer flash a pointless "done" message.
31
+ - **Todo manager tool upgrades.** Batch add/remove multiple todos at once, and completed todos auto-clear when you add new ones.
32
+ - **Model switching more robust.** CLI slash commands (`/model`, `/provider`) now work seamlessly, server-side routing handles dynamic endpoints correctly, and switching between all provider types is more reliable.
33
+
34
+ ### Fixed
35
+ - **Access key now persists via cookies.** The WebUI login key was stored only in `localStorage`, causing WebSocket connections to lose authentication. Now also written to a `clacky_access_key` cookie for consistent auth across all connection types.
36
+ - **MiniMax → DeepSeek switch error.** Switching models from MiniMax to DeepSeek no longer fails due to mismatched message format handling.
37
+ - **Bedrock truncated tool call recovery.** When AWS Bedrock truncates a tool call mid-argument, the agent now detects the error, sends feedback, and successfully retries on the next turn.
38
+ - **Sidebar "Load More" scroll jump.** Clicking "Load More" at the bottom of the session list no longer jerks the sidebar back to the active session — scroll position is now preserved.
39
+ - **Double-render regression.** An output buffer lifecycle bug that occasionally caused duplicate content in the terminal UI has been fixed.
40
+ - **DeepSeek V4 message content extraction.** Compression no longer mishandles DeepSeek V4's user message content format.
41
+
42
+ ## [0.9.38] - 2026-04-24
43
+
44
+ ### Fixed
45
+ - **Access key now persists correctly via cookie**. When the Web UI server was configured with `--access-key`, the key entered at login was stored only in `localStorage` — but WebSocket connections and some API requests read the key from cookies. This mismatch caused authenticated sessions to sporadically lose access (e.g. WebSocket falling back to unauthorized). The auth flow now writes the key to both `localStorage` _and_ a `clacky_access_key` cookie, and probes the server using the cookie. Incorrect keys are cleared from both stores before retry. Up to 3 attempts are allowed before giving up.
46
+
47
+ ### More
48
+ - Auth prompt input field now uses `type="password"` while the user is typing (reverts to text after), preventing shoulder-surfing
49
+
10
50
  ## [0.9.37] - 2026-04-24
11
51
 
12
52
  ### Fixed
@@ -54,14 +54,25 @@ module Clacky
54
54
  max_retries = 10
55
55
  retry_delay = 5
56
56
  retries = 0
57
+ # One-shot flag set by the BadRequestError rescue below when the server
58
+ # complained about missing reasoning_content. The subsequent retry will
59
+ # pad every assistant message's reasoning_content, which satisfies
60
+ # DeepSeek / Kimi thinking-mode providers even when the earlier turns
61
+ # were produced by a different provider (e.g. MiniMax keeps thinking
62
+ # inline in content and never emits a reasoning_content field, so the
63
+ # history-evidence heuristic in MessageHistory can't infer thinking
64
+ # mode on its own). We retry at most once — if padding doesn't fix it,
65
+ # the error is something else and we let it propagate.
66
+ force_reasoning_content_pad = false
67
+ thinking_retry_attempted = false
57
68
 
58
69
  begin
59
70
  # Use active_messages (Time Machine) when undone, otherwise send full history.
60
71
  # to_api strips internal fields and handles orphaned tool_calls.
61
72
  messages_to_send = if respond_to?(:active_messages)
62
- active_messages
73
+ active_messages(force_reasoning_content_pad: force_reasoning_content_pad)
63
74
  else
64
- @history.to_api
75
+ @history.to_api(force_reasoning_content_pad: force_reasoning_content_pad)
65
76
  end
66
77
 
67
78
  response = @client.send_messages_with_tools(
@@ -137,6 +148,25 @@ module Clacky
137
148
  # Progress cleanup is the caller's responsibility (via its own ensure block).
138
149
  raise AgentError, "[LLM] Service unavailable after #{current_max} retries"
139
150
  end
151
+
152
+ rescue Clacky::BadRequestError => e
153
+ # One-shot recovery for thinking-mode providers (DeepSeek V4, Kimi K2)
154
+ # that require every assistant message in the history to carry a
155
+ # reasoning_content field. The history-evidence heuristic in
156
+ # MessageHistory#to_api can miss this when the preceding turns came
157
+ # from a different thinking style (e.g. MiniMax keeps <think>...</think>
158
+ # inline in content and never emits reasoning_content) — so we detect
159
+ # the error here and retry once with forced padding.
160
+ if !thinking_retry_attempted && reasoning_content_missing_error?(e)
161
+ thinking_retry_attempted = true
162
+ force_reasoning_content_pad = true
163
+ Clacky::Logger.info(
164
+ "[thinking-mode] retrying with forced reasoning_content padding " \
165
+ "(model=#{@config.model_name.inspect} base_url=#{@config.base_url.inspect})"
166
+ )
167
+ retry
168
+ end
169
+ raise
140
170
  end
141
171
 
142
172
  # Track cost and collect token usage data.
@@ -183,6 +213,22 @@ module Clacky
183
213
  "Continuing with fallback model: #{fallback}"
184
214
  )
185
215
  end
216
+
217
+ # True when a 400 BadRequestError is specifically about a missing
218
+ # reasoning_content field in thinking mode (DeepSeek V4, Kimi K2 thinking).
219
+ # We require TWO distinct substrings to avoid false positives — a generic
220
+ # 400 that happens to mention "reasoning_content" in passing (e.g. a
221
+ # validation hint in some unrelated provider) must NOT trigger the pad
222
+ # retry, which would silently add an empty field to every assistant
223
+ # message in the history.
224
+ private def reasoning_content_missing_error?(err)
225
+ return false unless err.is_a?(Clacky::BadRequestError)
226
+
227
+ msg = err.message.to_s.downcase
228
+ msg.include?("reasoning_content") &&
229
+ (msg.include?("thinking") || msg.include?("must be passed back") ||
230
+ msg.include?("must be provided"))
231
+ end
186
232
  end
187
233
  end
188
234
  end
@@ -2,17 +2,34 @@
2
2
 
3
3
  module Clacky
4
4
  class Agent
5
- # Long-term memory update functionality
6
- # Triggered at the end of a session to persist important knowledge.
5
+ # Long-term memory update functionality.
7
6
  #
8
- # The LLM decides:
7
+ # Runs at the end of a qualifying task to persist important knowledge
8
+ # into ~/.clacky/memories/. The LLM decides:
9
9
  # - Which topics were discussed
10
10
  # - Which memory files to update or create
11
11
  # - How to merge new info with existing content
12
12
  # - What to drop to stay within the per-file token limit
13
13
  #
14
+ # Architecture:
15
+ # Memory update runs as a **forked subagent**, NOT inline in the
16
+ # main agent's loop. The subagent inherits the main agent's history
17
+ # (so it can see what happened) via +fork_subagent+'s standard
18
+ # deep-clone, and inherits the same model/tools so prompt-cache is
19
+ # reused maximally. The subagent runs synchronously; when it returns,
20
+ # the main agent prints +show_complete+.
21
+ #
22
+ # This gives us, structurally:
23
+ # - Clean main-agent history (no memory_update messages to clean up)
24
+ # - Correct visual ordering ([OK] Task Complete is the LAST thing
25
+ # printed — the memory-update progress finishes before it)
26
+ # - Independent cost accounting (task cost vs. memory update cost)
27
+ # - Natural recursion guard (+@is_subagent+ blocks re-entry)
28
+ #
14
29
  # Trigger condition:
15
- # - Iteration count >= MEMORY_UPDATE_MIN_ITERATIONS (avoids trivial tasks like commits)
30
+ # - Iteration count >= MEMORY_UPDATE_MIN_ITERATIONS (skip trivial tasks)
31
+ # - Not already a subagent (no recursion)
32
+ # - Memory update is enabled in config
16
33
  module MemoryUpdater
17
34
  # Minimum LLM iterations for this task before triggering memory update.
18
35
  # Set high enough to skip short utility tasks (commit, deploy, etc.)
@@ -32,37 +49,79 @@ module Clacky
32
49
  task_iterations >= MEMORY_UPDATE_MIN_ITERATIONS
33
50
  end
34
51
 
35
- # Inject memory update prompt into @messages so the main agent loop handles it.
36
- # Builds the prompt dynamically, injecting the current memory file list so the
37
- # LLM doesn't need to scan the directory itself.
38
- # Returns true if prompt was injected, false otherwise.
39
- def inject_memory_prompt!
40
- return false unless should_update_memory?
41
- return false if @memory_prompt_injected
42
-
43
- @memory_prompt_injected = true
44
- @memory_updating = true
45
- @ui&.show_progress("Updating long-term memory…")
46
-
47
- @history.append({
48
- role: "user",
49
- content: build_memory_update_prompt,
50
- system_injected: true,
51
- memory_update: true
52
- })
53
-
54
- true
55
- end
56
-
57
- # Clean up memory update messages from conversation history after loop ends.
58
- # Call this once after the main loop finishes.
59
- def cleanup_memory_messages
60
- return unless @memory_prompt_injected
61
-
62
- @history.delete_where { |m| m[:memory_update] }
63
- @memory_prompt_injected = false
64
- @memory_updating = false
65
- @ui&.show_progress(phase: "done")
52
+ # Run memory update as a forked subagent.
53
+ #
54
+ # This is called by +Agent#run+ on the success path, AFTER the main
55
+ # loop exits and BEFORE +show_complete+ is printed. It blocks until
56
+ # the subagent finishes, so the visual order is structurally correct:
57
+ #
58
+ # ... task output ...
59
+ # [progress] Updating long-term memory… (spinner)
60
+ # [progress finishes]
61
+ # [OK] Task Complete
62
+ #
63
+ # Safe to call unconditionally; returns early if preconditions fail.
64
+ # Never raises for "no update needed" — only propagates genuine errors
65
+ # (+Clacky::AgentInterrupted+ for Ctrl+C, other exceptions are caught
66
+ # and logged so memory-update failures never mask the parent task's
67
+ # result).
68
+ def run_memory_update_subagent
69
+ return unless should_update_memory?
70
+
71
+ handle = @ui&.start_progress(message: "Updating long-term memory…", style: :primary)
72
+
73
+ # Fork subagent inheriting main agent's model, tools, and history.
74
+ # Maximizes prompt-cache reuse: same model, same tool set, same
75
+ # cloned history only the +system_prompt_suffix+ (the memory
76
+ # update instructions) and the final "Please proceed." user turn
77
+ # are new, landing on top of a warm cache.
78
+ subagent = fork_subagent(system_prompt_suffix: build_memory_update_prompt)
79
+
80
+ # Memory update is a background consolidation task — never prompt
81
+ # the user for confirmation on memory file writes. The subagent
82
+ # has its own config copy (fork_subagent does deep_copy), so this
83
+ # doesn't affect the parent.
84
+ sub_config = subagent.instance_variable_get(:@config)
85
+ sub_config.permission_mode = :auto_approve if sub_config.respond_to?(:permission_mode=)
86
+
87
+ begin
88
+ result = subagent.run("Please proceed.")
89
+ rescue Clacky::AgentInterrupted
90
+ # User pressed Ctrl+C during memory update. Propagate so the
91
+ # parent agent's interrupt handler runs.
92
+ raise
93
+ rescue StandardError => e
94
+ # Memory update failures are NEVER fatal to the parent task.
95
+ # Log and move on — the user's actual work is already done.
96
+ @debug_logs << {
97
+ timestamp: Time.now.iso8601,
98
+ event: "memory_update_error",
99
+ error_class: e.class.name,
100
+ error_message: e.message,
101
+ backtrace: e.backtrace&.first(10)
102
+ }
103
+ Clacky::Logger.error("memory_update_error", error: e)
104
+ return
105
+ ensure
106
+ handle&.finish
107
+ end
108
+
109
+ return unless result
110
+
111
+ # Merge subagent cost into parent's cumulative session spend so the
112
+ # sessionbar shows the real total. The parent's task-complete cost
113
+ # (result[:total_cost_usd] in Agent#run) stays unaffected — it
114
+ # still reflects ONLY the user's task, not the memory update.
115
+ subagent_cost = result[:total_cost_usd] || 0.0
116
+ @total_cost += subagent_cost
117
+ @ui&.update_sessionbar(cost: @total_cost, cost_source: @cost_source)
118
+
119
+ # Only surface a completion info line if the subagent actually
120
+ # wrote something to memory. The common "No memory updates needed."
121
+ # path stays silent to avoid visual noise.
122
+ if subagent_wrote_memory?(subagent)
123
+ @ui&.show_info("Memory updated: #{result[:iterations]} iterations, $#{subagent_cost.round(4)}")
124
+ end
66
125
  end
67
126
 
68
127
  private def memory_update_enabled?
@@ -72,6 +131,43 @@ module Clacky
72
131
  @config.memory_update_enabled != false
73
132
  end
74
133
 
134
+ # Inspect the subagent's history for a successful write/edit tool
135
+ # call targeting a memory file. Used to decide whether to surface a
136
+ # "Memory updated" info line (option C — silent when nothing changed).
137
+ # @param subagent [Clacky::Agent]
138
+ # @return [Boolean]
139
+ private def subagent_wrote_memory?(subagent)
140
+ return false unless subagent.respond_to?(:history) && subagent.history
141
+
142
+ subagent.history.to_a.any? do |msg|
143
+ next false unless msg.is_a?(Hash)
144
+
145
+ # Match OpenAI-style tool_calls on assistant messages …
146
+ tool_calls = msg[:tool_calls] || msg["tool_calls"]
147
+ if tool_calls.is_a?(Array) && tool_calls.any?
148
+ next true if tool_calls.any? do |tc|
149
+ name = tc.dig(:function, :name) || tc.dig("function", "name") || tc[:name] || tc["name"]
150
+ %w[write edit].include?(name.to_s)
151
+ end
152
+ end
153
+
154
+ # … and Anthropic-style content blocks with type=tool_use.
155
+ content = msg[:content] || msg["content"]
156
+ if content.is_a?(Array)
157
+ next true if content.any? do |block|
158
+ block.is_a?(Hash) &&
159
+ (block[:type] == "tool_use" || block["type"] == "tool_use") &&
160
+ %w[write edit].include?((block[:name] || block["name"]).to_s)
161
+ end
162
+ end
163
+
164
+ false
165
+ end
166
+ rescue StandardError
167
+ # Defensive: never let introspection errors break memory update.
168
+ false
169
+ end
170
+
75
171
  # Build the memory update prompt with the current memory file list injected.
76
172
  # Uses a whitelist approach: default is NO write, only write if explicit criteria are met.
77
173
  # @return [String]
@@ -125,8 +125,25 @@ module Clacky
125
125
  end
126
126
 
127
127
  def parse_compressed_result(result, chunk_path: nil)
128
- # Return the compressed result as a single assistant message
129
- # Keep the <summary> tags as they provide semantic context
128
+ # Return the compressed result as a single user message (role: "user").
129
+ #
130
+ # Why role:"user" instead of "assistant":
131
+ # When all original user messages get archived into the chunk during compression
132
+ # (e.g. a long single-turn `/slash` task), the rebuilt history can end up as
133
+ # `system → assistant(summary) → assistant(tool_calls) → tool → …` with NO user
134
+ # message anywhere. Strict providers (notably DeepSeek V4 thinking mode) reject
135
+ # this as a malformed turn structure with a misleading
136
+ # "reasoning_content must be passed back" 400 error.
137
+ #
138
+ # Marking it as a user message gives the conversation a valid turn boundary.
139
+ # `system_injected: true` ensures the UI's replay_history still hides it from
140
+ # the chat panel (the real-user filter excludes system_injected messages), while
141
+ # INTERNAL_FIELDS in MessageHistory strips the marker before the API payload is
142
+ # built — so DeepSeek/OpenAI/Anthropic only see a plain `{role:"user", content:…}`.
143
+ #
144
+ # The `compressed_summary: true` flag is preserved so that replay_history still
145
+ # routes this message through the chunk-expansion path (which keys off that flag,
146
+ # not the role).
130
147
  content = result.to_s.strip
131
148
 
132
149
  if content.empty?
@@ -142,7 +159,17 @@ module Clacky
142
159
  content_without_topics = content_without_topics + anchor
143
160
  end
144
161
 
145
- [{ role: "assistant", content: content_without_topics, compressed_summary: true, chunk_path: chunk_path }]
162
+ # Prefix lets the model recognise this is injected context, not a user utterance.
163
+ framed_content = "[Compressed conversation summary — previous turns archived]\n\n" \
164
+ "#{content_without_topics}"
165
+
166
+ [{
167
+ role: "user",
168
+ content: framed_content,
169
+ compressed_summary: true,
170
+ chunk_path: chunk_path,
171
+ system_injected: true
172
+ }]
146
173
  end
147
174
  end
148
175
  end
@@ -15,11 +15,10 @@ module Clacky
15
15
  # Trigger compression during idle time (user-friendly, interruptible)
16
16
  # Returns true if compression was performed, false otherwise
17
17
  def trigger_idle_compression
18
- # Check if we should compress (force mode)
18
+ # Check if we should compress (force mode) BEFORE opening any UI, so
19
+ # "skipped" doesn't flash a spinner on screen.
19
20
  compression_context = compress_messages_if_needed(force: true)
20
- @ui&.show_progress("Idle detected. Compressing conversation to optimize costs...", progress_type: "idle_compress", phase: "active")
21
21
  if compression_context.nil?
22
- @ui&.show_progress("Idle skipped.", progress_type: "idle_compress", phase: "done")
23
22
  Clacky::Logger.info(
24
23
  "Idle compression skipped",
25
24
  enable_compression: @config.enable_compression,
@@ -31,23 +30,44 @@ module Clacky
31
30
  return false
32
31
  end
33
32
 
34
- # Insert compression message
33
+ # Own the progress indicator through +with_progress+: the ensure
34
+ # block guarantees the spinner/ticker is released even when the
35
+ # user interrupts mid-way (AgentInterrupted from current thread)
36
+ # or the LLM call fails. No more orphan gray tickers.
37
+ #
38
+ # When @ui is nil (tests / headless) we still need to run the
39
+ # compression work — safe-navigation with a block would silently
40
+ # skip it, so branch explicitly.
35
41
  compression_message = compression_context[:compression_message]
36
42
  @history.append(compression_message)
37
43
 
38
- begin
39
- # Execute compression using shared LLM call logic
40
- response = call_llm
41
- handle_compression_response(response, compression_context)
42
- true
43
- rescue Clacky::AgentInterrupted => e
44
- @ui&.log("Idle compression canceled: #{e.message}", level: :info)
45
- @history.rollback_before(compression_message)
46
- false
47
- rescue => e
48
- @ui&.log("Idle compression failed: #{e.message}", level: :error)
49
- @history.rollback_before(compression_message)
50
- false
44
+ run_compression = lambda do |handle|
45
+ begin
46
+ response = call_llm
47
+ handle_compression_response(response, compression_context, progress: handle)
48
+ true
49
+ rescue Clacky::AgentInterrupted => e
50
+ @ui&.log("Idle compression canceled: #{e.message}", level: :info)
51
+ @history.rollback_before(compression_message)
52
+ false
53
+ rescue => e
54
+ @ui&.log("Idle compression failed: #{e.message}", level: :error)
55
+ @history.rollback_before(compression_message)
56
+ false
57
+ end
58
+ end
59
+
60
+ if @ui
61
+ result = nil
62
+ @ui.with_progress(
63
+ message: "Idle detected. Compressing conversation to optimize costs...",
64
+ style: :quiet
65
+ ) do |handle|
66
+ result = run_compression.call(handle)
67
+ end
68
+ result
69
+ else
70
+ run_compression.call(nil)
51
71
  end
52
72
  end
53
73
 
@@ -117,7 +137,14 @@ module Clacky
117
137
  end
118
138
 
119
139
  # Handle compression response and rebuild message list
120
- def handle_compression_response(response, compression_context)
140
+ # @param response [Hash] LLM response
141
+ # @param compression_context [Hash] context returned by +compress_messages_if_needed+
142
+ # @param progress [#finish, nil] Owned progress handle from the caller's
143
+ # with_progress block. When provided, the final summary message is
144
+ # delivered via +progress.finish(final_message: ...)+ instead of the
145
+ # legacy +show_progress(phase: "done")+ — this lets +ensure+ in the
146
+ # caller guarantee cleanup even if this method raises mid-way.
147
+ def handle_compression_response(response, compression_context, progress: nil)
121
148
  # Extract compressed content from response
122
149
  compressed_content = response[:content]
123
150
 
@@ -168,7 +195,14 @@ module Clacky
168
195
  # Show compression info (use estimated tokens from rebuilt history)
169
196
  compression_summary = "History compressed (~#{compression_context[:original_token_count]} -> ~#{@history.estimate_tokens} tokens, " \
170
197
  "level #{compression_context[:compression_level]})"
171
- @ui&.show_progress(compression_summary, progress_type: "idle_compress", phase: "done")
198
+ if progress
199
+ # Owned-handle path: the caller's ensure block will still call
200
+ # handle.finish; finishing here with a final_message means that
201
+ # later finish (with no final_message) is a no-op (idempotent).
202
+ progress.finish(final_message: compression_summary)
203
+ else
204
+ @ui&.show_progress(compression_summary, progress_type: "idle_compress", phase: "done")
205
+ end
172
206
  end
173
207
 
174
208
  # Get recent messages while preserving tool_calls/tool_results pairs.