openclacky 1.5.23 → 1.5.24

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -0
  3. data/lib/clacky/agent/cost_tracker.rb +55 -36
  4. data/lib/clacky/agent/hook_manager.rb +4 -1
  5. data/lib/clacky/agent/llm_caller.rb +46 -12
  6. data/lib/clacky/agent/memory_injector.rb +209 -0
  7. data/lib/clacky/agent/memory_updater.rb +16 -0
  8. data/lib/clacky/agent/skill_manager.rb +30 -8
  9. data/lib/clacky/agent.rb +66 -2
  10. data/lib/clacky/agent_config.rb +4 -2
  11. data/lib/clacky/anthropic_stream_aggregator.rb +11 -1
  12. data/lib/clacky/bedrock_stream_aggregator.rb +11 -1
  13. data/lib/clacky/billing/platform_billing.rb +1 -0
  14. data/lib/clacky/cli.rb +4 -3
  15. data/lib/clacky/client.rb +30 -25
  16. data/lib/clacky/default_extensions/advisor/panels/advisor/view.js +7 -1
  17. data/lib/clacky/default_extensions/git/panels/git/view.js +35 -4
  18. data/lib/clacky/default_extensions/secretary/README.md +69 -0
  19. data/lib/clacky/default_extensions/secretary/README.zh.md +69 -0
  20. data/lib/clacky/default_extensions/secretary/ext.yml +3 -0
  21. data/lib/clacky/default_extensions/secretary/icon.svg +17 -0
  22. data/lib/clacky/default_skills/channel-manager/SKILL.md +24 -5
  23. data/lib/clacky/default_skills/channel-manager/dingtalk_setup.rb +4 -2
  24. data/lib/clacky/default_skills/channel-manager/discord_setup.rb +4 -1
  25. data/lib/clacky/default_skills/channel-manager/weixin_setup.rb +2 -0
  26. data/lib/clacky/default_skills/media-gen/SKILL.md +14 -6
  27. data/lib/clacky/default_skills/onboard/SKILL.md +5 -7
  28. data/lib/clacky/default_skills/persist-memory/SKILL.md +15 -1
  29. data/lib/clacky/extension/api_extension.rb +13 -0
  30. data/lib/clacky/json_ui_controller.rb +14 -1
  31. data/lib/clacky/locales/i18n.rb +25 -3
  32. data/lib/clacky/message_history.rb +15 -2
  33. data/lib/clacky/openai_responses_stream_aggregator.rb +11 -1
  34. data/lib/clacky/openai_stream_aggregator.rb +11 -1
  35. data/lib/clacky/prompts/base.md +1 -1
  36. data/lib/clacky/providers.rb +7 -3
  37. data/lib/clacky/server/backup_manager.rb +98 -2
  38. data/lib/clacky/server/browser_manager.rb +10 -5
  39. data/lib/clacky/server/channel/channel_manager.rb +69 -45
  40. data/lib/clacky/server/http_server.rb +133 -42
  41. data/lib/clacky/server/preview.rb +26 -5
  42. data/lib/clacky/server/web_ui_controller.rb +12 -0
  43. data/lib/clacky/skill.rb +1 -1
  44. data/lib/clacky/ui_interface.rb +5 -0
  45. data/lib/clacky/utils/file_processor.rb +4 -2
  46. data/lib/clacky/utils/model_pricing.rb +19 -0
  47. data/lib/clacky/version.rb +1 -1
  48. data/lib/clacky/web/app.css +220 -6
  49. data/lib/clacky/web/app.js +18 -6
  50. data/lib/clacky/web/components/custom-select.js +7 -18
  51. data/lib/clacky/web/components/hint-tip.js +172 -0
  52. data/lib/clacky/web/components/sidebar.js +70 -0
  53. data/lib/clacky/web/features/backup/store.js +64 -27
  54. data/lib/clacky/web/features/backup/view.js +42 -11
  55. data/lib/clacky/web/features/channels/view.js +7 -7
  56. data/lib/clacky/web/features/new-session/view.js +10 -2
  57. data/lib/clacky/web/features/profile/view.js +4 -4
  58. data/lib/clacky/web/features/tasks/view.js +1 -0
  59. data/lib/clacky/web/features/trash/view.js +4 -4
  60. data/lib/clacky/web/i18n.js +61 -5
  61. data/lib/clacky/web/index.html +140 -43
  62. data/lib/clacky/web/projects.js +15 -2
  63. data/lib/clacky/web/sessions.js +234 -45
  64. data/lib/clacky/web/settings.js +93 -34
  65. data/lib/clacky/web/utils.js +44 -0
  66. data/lib/clacky/web/ws-dispatcher.js +17 -0
  67. data/lib/clacky/web/ws.js +16 -4
  68. data/lib/clacky/work/service.rb +2 -0
  69. data/lib/clacky.rb +1 -0
  70. metadata +6 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5b5f420c625379fad464ab7f88912b46d3c8bfd17dbb14c58274ed0841d0039e
4
- data.tar.gz: 6f8df699a2a7097348bba112445687429b17aa2617f1265ad83107a1aa7778fd
3
+ metadata.gz: 0de5997e8f1b998b5e57c588e232c0bbb6f96664f9dcd2c36ff2577d566b445b
4
+ data.tar.gz: 25108838302b0137589a14b320ee7374202a1601c897333fc1cb632226366462
5
5
  SHA512:
6
- metadata.gz: 462d32d168319ebc8d1366636ffe7cbcd5df1d748334180e12e688928f9abd72a8c7e8887ee8d3fb71c269a835fdd014a7bf8387fc6aeff5b2b98ca40833d217
7
- data.tar.gz: e74fdb9dfdc042b922544f6eb80f0a7b6c029d25f6e5e15a841ee8277aa32830c5dcd733a049cc9225851689d18d66665e9301480ffb3c0875dc6e644b582484
6
+ metadata.gz: ecaa16a7b3a69d5932b0f878abc9fa0d0a6fe0c10898eb509a3ad92959a90f5bfb78e2610b55608bd5687128de48b587f11e0b2aff29b4c00629093704020afe
7
+ data.tar.gz: 54c69db9fcbe7aa5e34eca12a5c1171f893fff2d8833a0c99814eb7a50dcdc91388f04758f81cfb1041facf2bb7698d85ce7e5245a33911d26c6117aeb03b2af
data/CHANGELOG.md CHANGED
@@ -4,6 +4,52 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
 
7
+ ## [1.5.24] - 2026-11-13
8
+
9
+ ### Added
10
+ - Stream assistant replies as they are written
11
+ - Point first-time users at Settings with a hint
12
+ - Add a first-run hint on the model picker in the session info bar
13
+ - Recall relevant memories automatically before each message (opt-in)
14
+ - Collapse the Config and My Data sidebar sections
15
+ - Preview files an AI turn leaves in the system temp directory
16
+ - Dismiss the OpenClacky AI Keys promo and keep it dismissed
17
+ - Show progress while packing and downloading a backup
18
+ - Add model hints for audio, speech-to-text and video understanding
19
+ - Add Claude Fable 5.2 to the hosted model picker
20
+ - Give the secretary extension an icon and a detail page
21
+ - Let extensions follow WebSocket events and run before the first think
22
+
23
+ ### Improved
24
+ - Unify the settings section headings and their icons
25
+ - Tighten the long-term memory write rules so stored facts stay worth keeping
26
+ - Keep the memories listing compact and flag the files it leaves out
27
+ - Move Web Search above Network in the general tab
28
+ - Keep memories timestamped by file mtime
29
+ - Use the shared tooltip on memory and recycle-bin row actions
30
+ - Stream backup restore uploads instead of buffering them
31
+ - Reveal overlong project names on hover
32
+ - Rename auto recall to plainer wording
33
+
34
+ ### Fixed
35
+ - Switch sessions with one click in Safari
36
+ - Keep backend text in the client's language
37
+ - Rewrite hosted-platform file references to local links
38
+ - Render non-media image references as plain links instead of broken thumbnails
39
+ - Show media with a single inline reference
40
+ - Keep dropdown lists inside the viewport
41
+ - Keep the flipped new-session popover glued to its chip
42
+ - Localise the channel platform names
43
+ - Open channel setup in the session that started it
44
+ - Keep the git panel diffs rendered when switching layout
45
+ - Pin the git panel file header while scrolling a long diff
46
+ - Order the git panel diff stream like the file tree
47
+ - Show actionable browser toggle state and errors in settings
48
+ - Stop the scheduled tasks view from opening with no active menu item
49
+
50
+ ### More
51
+ - Move GLM 5.3 and Kimi K3 to the end of the model picker
52
+
7
53
  ## [1.5.23] - 2026-11-06
8
54
 
9
55
  ### Added
@@ -34,42 +34,7 @@ module Clacky
34
34
  # @param raw_api_usage [Hash, nil] Raw API usage data for debugging
35
35
  # @param model [String, nil] Model name to use for billing (defaults to current_model)
36
36
  def track_cost(usage, raw_api_usage: nil, model: nil)
37
- # Priority 1: Use API-provided cost if available (OpenRouter, LiteLLM, etc.)
38
- iteration_cost = nil
39
- if usage[:api_cost]
40
- add_cost(usage[:api_cost])
41
- @cost_source = :api
42
- @task_cost_source = :api
43
- iteration_cost = usage[:api_cost]
44
- @ui&.log("Using API-provided cost: $#{usage[:api_cost]}", level: :debug) if @config.verbose
45
- else
46
- # Priority 2: Calculate from tokens using ModelPricing
47
- # Use provided model name (from API call time) to ensure accurate billing
48
- # even if the user switches models during the API call
49
- billing_model = model || current_model
50
- result = ModelPricing.calculate_cost(model: billing_model, usage: usage)
51
- cost = result[:cost]
52
- pricing_source = result[:source]
53
-
54
- # Only accumulate cost when the model has known pricing.
55
- # Unknown models return nil — display N/A, don't add to total.
56
- if cost
57
- add_cost(cost)
58
- iteration_cost = cost
59
- @cost_source = pricing_source
60
- @task_cost_source = pricing_source
61
- end
62
-
63
- if @config.verbose
64
- if cost
65
- source_label = pricing_source == :price ? "model pricing" : "default pricing"
66
- @ui&.log("Calculated cost for #{@config.model_name} using #{source_label}: $#{cost.round(6)}", level: :debug)
67
- else
68
- @ui&.log("No pricing data available for #{@config.model_name} — cost is unknown", level: :debug)
69
- end
70
- @ui&.log("Usage breakdown: prompt=#{usage[:prompt_tokens]}, completion=#{usage[:completion_tokens]}, cache_write=#{usage[:cache_creation_input_tokens] || 0}, cache_read=#{usage[:cache_read_input_tokens] || 0}", level: :debug)
71
- end
72
- end
37
+ iteration_cost = accumulate_cost(usage, model)
73
38
 
74
39
  # Collect token usage data for this iteration (returned to caller for deferred display)
75
40
  token_data = collect_iteration_tokens(usage, iteration_cost)
@@ -127,6 +92,60 @@ module Clacky
127
92
  token_data
128
93
  end
129
94
 
95
+ # Bill a side LLM call (e.g. the memory picker) into the session total.
96
+ # Unlike track_cost it leaves @previous_total_tokens and the cache stats
97
+ # alone: those describe the main conversation and drive compression.
98
+ # @return [Float, nil] cost of the call, nil when pricing is unknown
99
+ def track_auxiliary_cost(usage, model:)
100
+ cost = accumulate_cost(usage, model)
101
+ unless @is_subagent
102
+ @ui&.update_sessionbar(cost: @total_cost, cost_source: @cost_source)
103
+ persist_billing_record(usage, cost, model: model)
104
+ end
105
+ cost
106
+ end
107
+
108
+ # Add one call's cost to the running total.
109
+ # @return [Float, nil] the call's cost, nil when pricing is unknown
110
+ private def accumulate_cost(usage, model)
111
+ # Priority 1: Use API-provided cost if available (OpenRouter, LiteLLM, etc.)
112
+ if usage[:api_cost]
113
+ add_cost(usage[:api_cost])
114
+ @cost_source = :api
115
+ @task_cost_source = :api
116
+ @ui&.log("Using API-provided cost: $#{usage[:api_cost]}", level: :debug) if @config.verbose
117
+ return usage[:api_cost]
118
+ end
119
+
120
+ # Priority 2: Calculate from tokens using ModelPricing
121
+ # Use provided model name (from API call time) to ensure accurate billing
122
+ # even if the user switches models during the API call
123
+ billing_model = model || current_model
124
+ result = ModelPricing.calculate_cost(model: billing_model, usage: usage)
125
+ cost = result[:cost]
126
+ pricing_source = result[:source]
127
+
128
+ # Only accumulate cost when the model has known pricing.
129
+ # Unknown models return nil — display N/A, don't add to total.
130
+ if cost
131
+ add_cost(cost)
132
+ @cost_source = pricing_source
133
+ @task_cost_source = pricing_source
134
+ end
135
+
136
+ if @config.verbose
137
+ if cost
138
+ source_label = pricing_source == :price ? "model pricing" : "default pricing"
139
+ @ui&.log("Calculated cost for #{billing_model} using #{source_label}: $#{cost.round(6)}", level: :debug)
140
+ else
141
+ @ui&.log("No pricing data available for #{billing_model} — cost is unknown", level: :debug)
142
+ end
143
+ @ui&.log("Usage breakdown: prompt=#{usage[:prompt_tokens]}, completion=#{usage[:completion_tokens]}, cache_write=#{usage[:cache_creation_input_tokens] || 0}, cache_read=#{usage[:cache_read_input_tokens] || 0}", level: :debug)
144
+ end
145
+
146
+ cost
147
+ end
148
+
130
149
  # Persist a billing record to the billing store
131
150
  # @param usage [Hash] Usage data from API
132
151
  # @param cost [Float, nil] Calculated cost for this iteration
@@ -10,6 +10,7 @@ module Clacky
10
10
  :on_start,
11
11
  :on_complete,
12
12
  :on_iteration,
13
+ :before_think,
13
14
  :session_rollback
14
15
  ].freeze
15
16
 
@@ -39,6 +40,8 @@ module Clacky
39
40
  # display/countdown. It may extend questions, display context and model-only
40
41
  # answer_context in place; answer_context is never sent to UI subscribers.
41
42
  # return values are ignored. Keep work bounded; no separate UI events.
43
+ # before_think receives (agent) after steering, before model reasoning.
44
+ # Its return value is ignored, like other observation/preparation hooks.
42
45
  def trigger(event, *args)
43
46
  validate_event!(event)
44
47
  # Keep payload hashes positional: Ruby 2.6 otherwise promotes their keys
@@ -51,7 +54,7 @@ module Clacky
51
54
  callback_args = [*args, @agent]
52
55
  callback_args << context.dup if context
53
56
  hook_result = hook.call(*callback_args)
54
- next if [:on_complete, :prepare_feedback].include?(event)
57
+ next if [:on_complete, :prepare_feedback, :before_think].include?(event)
55
58
  next unless hook_result.is_a?(Hash)
56
59
  # First deny wins and stops the chain: a weaker later verdict must
57
60
  # never clobber a stronger earlier one, and the first deny's reason
@@ -45,7 +45,8 @@ module Clacky
45
45
  # Inside call_llm we only *update in place* during retries, so the
46
46
  # already-live progress slot shows meaningful transient status
47
47
  # ("Network failed… attempt 2/10", etc.).
48
- private def call_llm(agent_upstream_fails: nil, agent_upgrade_fails: nil)
48
+ # Only user-facing replies opt in; compression calls stay silent.
49
+ private def call_llm(agent_upstream_fails: nil, agent_upgrade_fails: nil, stream_reply: false)
49
50
  # Transition :fallback_active → :probing if cooling-off has expired.
50
51
  @config.maybe_start_probing
51
52
  refresh_client_if_card_changed!
@@ -118,17 +119,20 @@ module Clacky
118
119
  @history.to_api(force_reasoning_content_pad: force_reasoning_content_pad)
119
120
  end
120
121
 
121
- response = @client.send_messages_with_tools(
122
- messages_to_send,
123
- model: api_call_model,
124
- tools: tools_to_send,
125
- max_tokens: @config.max_tokens,
126
- enable_caching: @config.enable_prompt_caching,
127
- reasoning_effort: @reasoning_effort,
128
- on_chunk: build_progress_on_chunk,
129
- agent_upstream_fails: (agent_upstream_fails || 0) + floor_fails,
130
- agent_upgrade_fails: (agent_upgrade_fails || 0) + upgrade_fails
131
- )
122
+ response = with_assistant_text_stream(enabled: stream_reply) do |on_text_delta|
123
+ @client.send_messages_with_tools(
124
+ messages_to_send,
125
+ model: api_call_model,
126
+ tools: tools_to_send,
127
+ max_tokens: @config.max_tokens,
128
+ enable_caching: @config.enable_prompt_caching,
129
+ reasoning_effort: @reasoning_effort,
130
+ on_chunk: build_progress_on_chunk,
131
+ **(on_text_delta ? { on_text_delta: on_text_delta } : {}),
132
+ agent_upstream_fails: (agent_upstream_fails || 0) + floor_fails,
133
+ agent_upgrade_fails: (agent_upgrade_fails || 0) + upgrade_fails
134
+ )
135
+ end
132
136
 
133
137
  # Successful response — if we were probing, confirm primary is healthy.
134
138
  handle_probe_success if @config.probing?
@@ -841,6 +845,36 @@ module Clacky
841
845
  )
842
846
  end
843
847
 
848
+ # Each attempt owns a preview. Always close it, including cancellation and retry.
849
+ private def with_assistant_text_stream(enabled: true)
850
+ return yield(nil) unless enabled
851
+ return yield(nil) unless @ui && @ui.respond_to?(:stream_assistant_text?) && @ui.stream_assistant_text?
852
+
853
+ message_id = SecureRandom.uuid
854
+ seq = 0
855
+ pending = +""
856
+ last_emit = 0.0
857
+ flush = lambda do
858
+ unless pending.empty?
859
+ seq += 1
860
+ @ui.show_assistant_delta(message_id: message_id, seq: seq, delta: pending.dup)
861
+ pending.clear
862
+ last_emit = Process.clock_gettime(Process::CLOCK_MONOTONIC)
863
+ end
864
+ end
865
+ callback = lambda do |text|
866
+ pending << text
867
+ flush.call if Process.clock_gettime(Process::CLOCK_MONOTONIC) - last_emit >= 0.05
868
+ end
869
+ begin
870
+ result = yield(callback)
871
+ flush.call
872
+ result
873
+ ensure
874
+ @ui.end_assistant_stream(message_id: message_id)
875
+ end
876
+ end
877
+
844
878
  # Build a streaming progress callback for Client#send_messages_with_tools.
845
879
  # Returns nil when no UI is attached, so the client skips the streaming
846
880
  # plumbing entirely. Callback throttles UI updates to avoid flooding the
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Clacky
6
+ class Agent
7
+ # Automatic long-term memory injection (opt-in: memory_inject_enabled).
8
+ #
9
+ # Before each user message a picker model (the provider's lite model, else
10
+ # the primary) selects relevant files from a wide memories listing
11
+ # (+load_memories_meta+); their bodies are appended as a system_injected
12
+ # user message. Append-only keeps the prompt-cache prefix intact. Files
13
+ # already injected on the active task chain are skipped; compression drops
14
+ # those messages, which naturally allows re-injection later.
15
+ #
16
+ # Best-effort: any failure is logged and the turn proceeds without memories.
17
+ module MemoryInjector
18
+ MEMORY_INJECT_MAX_FILES = 3
19
+ MEMORY_INJECT_FILE_BYTES = 4096
20
+ MEMORY_INJECT_STALE_DAYS = 30
21
+ MEMORY_PICKER_PREV_REPLY_CHARS = 600
22
+ MEMORY_PICKER_USER_CHARS = 2000
23
+ # Thinking models spend part of this budget on reasoning; 200 truncated the JSON.
24
+ MEMORY_PICKER_MAX_TOKENS = 1024
25
+ MEMORY_PICKER_READ_TIMEOUT = 20
26
+ # The picker can't grep, so it needs a far wider view than the
27
+ # 20-file LRU shortlist the recall/persist skills get.
28
+ MEMORY_PICKER_LISTING_LIMIT = 200
29
+
30
+ MEMORY_PICKER_SYSTEM_PROMPT = <<~PROMPT
31
+ You select long-term memory files relevant to the user's latest message.
32
+ A memory is relevant only if it would change how the assistant handles this message
33
+ (preferences, conventions, project facts, prior decisions). Skip loosely related ones.
34
+ Reply with JSON only: {"files": ["<filename>.md"]} using at most #{MEMORY_INJECT_MAX_FILES} filenames from the list.
35
+ Reply {"files": []} when nothing is relevant (greetings, acknowledgements, small talk).
36
+ PROMPT
37
+
38
+ private def inject_relevant_memories(user_input, task_id:)
39
+ return unless @config.memory_inject_enabled
40
+ return if @is_subagent
41
+
42
+ message = user_input.to_s.strip
43
+ return if message.empty?
44
+ return if Dir.glob(File.join(memories_base_dir, "*.md")).empty?
45
+
46
+ visible = memory_visible_messages
47
+ picked = pick_relevant_memories(message, visible) - injected_memory_files(visible)
48
+ return if picked.empty?
49
+
50
+ @history.append({
51
+ role: "user",
52
+ content: build_memory_reminder(picked),
53
+ system_injected: true,
54
+ memory_recall: true,
55
+ memory_files: picked,
56
+ task_id: task_id
57
+ })
58
+ rescue StandardError => e
59
+ Clacky::Logger.warn("memory_inject.failed", error: e)
60
+ end
61
+
62
+ # Messages on the active task chain, so undone branches neither count as
63
+ # injected nor feed the picker.
64
+ private def memory_visible_messages
65
+ chain = active_task_chain
66
+ @history.to_a.select { |m| !m[:task_id] || chain.include?(m[:task_id]) }
67
+ end
68
+
69
+ private def injected_memory_files(messages)
70
+ messages.select { |m| m[:memory_recall] }.flat_map { |m| Array(m[:memory_files]) }.uniq
71
+ end
72
+
73
+ private def pick_relevant_memories(message, visible)
74
+ model = memory_picker_model
75
+ # send_messages_with_tools rather than send_messages: only it returns usage.
76
+ response = memory_picker_client(model).send_messages_with_tools(
77
+ [
78
+ { role: "system", content: MEMORY_PICKER_SYSTEM_PROMPT },
79
+ { role: "user", content: memory_picker_prompt(message, visible) }
80
+ ],
81
+ model: model,
82
+ tools: [],
83
+ max_tokens: MEMORY_PICKER_MAX_TOKENS
84
+ )
85
+ track_auxiliary_cost(response[:usage], model: response[:routed_model] || model) if response[:usage]
86
+ if response[:finish_reason] == "length"
87
+ Clacky::Logger.warn("memory_inject.picker_truncated", model: model, max_tokens: MEMORY_PICKER_MAX_TOKENS)
88
+ end
89
+ parse_memory_picker_reply(response[:content].to_s)
90
+ .select { |name| memory_file_name?(name) }
91
+ .uniq
92
+ .first(MEMORY_INJECT_MAX_FILES)
93
+ end
94
+
95
+ private def memory_picker_model
96
+ lite = @config.lite_model_config_for_current
97
+ (lite && lite["model"]) || current_model
98
+ end
99
+
100
+ # A dedicated client so a slow picker gives up quickly instead of holding
101
+ # the turn for the main client's 300s read timeout.
102
+ private def memory_picker_client(model)
103
+ lite = @config.lite_model_config_for_current
104
+ if lite
105
+ Clacky::Client.new(
106
+ lite["api_key"],
107
+ base_url: lite["base_url"],
108
+ model: model,
109
+ anthropic_format: lite["anthropic_format"] == true,
110
+ read_timeout: MEMORY_PICKER_READ_TIMEOUT
111
+ )
112
+ else
113
+ Clacky::Client.new(
114
+ @config.api_key,
115
+ base_url: @config.base_url,
116
+ model: model,
117
+ anthropic_format: @config.anthropic_format?,
118
+ api_format: @config.api_format,
119
+ read_timeout: MEMORY_PICKER_READ_TIMEOUT
120
+ )
121
+ end
122
+ end
123
+
124
+ private def memory_picker_prompt(message, visible)
125
+ <<~PROMPT
126
+ #{load_memories_meta(limit: MEMORY_PICKER_LISTING_LIMIT)}
127
+
128
+ Previous assistant reply (excerpt):
129
+ #{previous_reply_excerpt(last_assistant_reply_text(visible))}
130
+
131
+ User message:
132
+ #{message[0, MEMORY_PICKER_USER_CHARS]}
133
+ PROMPT
134
+ end
135
+
136
+ # Long replies open with the conclusion but close with the question the
137
+ # user is answering, so keep both ends.
138
+ private def previous_reply_excerpt(text)
139
+ return "(none)" if text.empty?
140
+
141
+ limit = MEMORY_PICKER_PREV_REPLY_CHARS
142
+ return text if text.length <= limit
143
+
144
+ half = limit / 2
145
+ "#{text[0, half]}\n...\n#{text[-half, half]}"
146
+ end
147
+
148
+ private def parse_memory_picker_reply(text)
149
+ json = begin
150
+ JSON.parse(text)
151
+ rescue JSON::ParserError
152
+ match = text.match(/\{.*\}/m)
153
+ match ? (JSON.parse(match[0]) rescue nil) : nil
154
+ end
155
+ files = json.is_a?(Hash) ? json["files"] : nil
156
+ files.is_a?(Array) ? files.map(&:to_s) : []
157
+ end
158
+
159
+ # Bare filename that exists in the memories dir; rejects hallucinated
160
+ # names and any path components.
161
+ private def memory_file_name?(name)
162
+ name.end_with?(".md") && File.basename(name) == name &&
163
+ File.file?(File.join(memories_base_dir, name))
164
+ end
165
+
166
+ private def last_assistant_reply_text(visible)
167
+ msg = visible.reverse.find { |m| m[:role] == "assistant" && !m[:content].to_s.strip.empty? }
168
+ return "" unless msg
169
+
170
+ content = msg[:content]
171
+ text = if content.is_a?(Array)
172
+ content.map { |b| b.is_a?(Hash) ? (b[:text] || b["text"]).to_s : "" }.join("\n")
173
+ else
174
+ content.to_s
175
+ end
176
+ text.strip
177
+ end
178
+
179
+ private def build_memory_reminder(names)
180
+ parts = [
181
+ "<system-reminder>",
182
+ "Relevant long-term memories (auto-selected from ~/.clacky/memories/). " \
183
+ "Apply them where they fit; the user's current instructions take precedence.",
184
+ ""
185
+ ]
186
+ names.each do |name|
187
+ path = File.join(memories_base_dir, name)
188
+ mtime = File.mtime(path)
189
+ parts << "## #{name} (last updated #{mtime.strftime("%Y-%m-%d")})"
190
+ parts << memory_inject_body(path)
191
+ age_days = ((Time.now - mtime) / 86_400).floor
192
+ if age_days > MEMORY_INJECT_STALE_DAYS
193
+ parts << "(This memory is #{age_days} days old; verify it against the current state before relying on it.)"
194
+ end
195
+ parts << ""
196
+ end
197
+ parts << "</system-reminder>"
198
+ parts.join("\n")
199
+ end
200
+
201
+ private def memory_inject_body(path)
202
+ body = File.read(path, encoding: "UTF-8").sub(/\A---\n.*?\n---\n*/m, "").strip
203
+ return body if body.bytesize <= MEMORY_INJECT_FILE_BYTES
204
+
205
+ "#{body.byteslice(0, MEMORY_INJECT_FILE_BYTES).scrub("")}\n...(truncated; read #{path} for the rest)"
206
+ end
207
+ end
208
+ end
209
+ end
@@ -188,6 +188,22 @@ module Clacky
188
188
  - Any task that produced no lasting decisions or preferences
189
189
  - Repeating or slightly rephrasing what is already in memory
190
190
 
191
+ ## Never persist what can be re-derived
192
+
193
+ Code and git are the source of truth; a memory that copies them goes stale
194
+ silently and later misleads. Do NOT persist:
195
+
196
+ - Code structure, architecture, file paths, class/method names, line numbers
197
+ - Implementation details of a feature that was just built or changed
198
+ - Git history, change logs, PR contents, or "what was modified"
199
+ - Debugging steps, root-cause narratives, or how a bug was fixed
200
+ - Content already in project rules or docs (.clackyrules, README, AGENTS.md, etc.)
201
+ - Temporary task state, progress, or TODOs of the current conversation
202
+
203
+ If a session's only takeaways fall into this list, write nothing. When a
204
+ whitelisted item involves code, persist only the decision and its reason,
205
+ not the implementation.
206
+
191
207
  ═══════════════════════════════════════════════════════════════
192
208
  EXECUTOR MANUAL (from persist-memory skill)
193
209
  ═══════════════════════════════════════════════════════════════
@@ -98,6 +98,14 @@ module Clacky
98
98
  # so excessive mcp.json entries don't quietly bloat the system prompt.
99
99
  MAX_CONTEXT_MCP_SERVERS = 10
100
100
 
101
+ # Bounds on the memories listing injected into recall/persist-memory skills.
102
+ # The file cap is an LRU shortlist, not a hard limit: older files stay
103
+ # reachable via grep. Per-field caps stop one oversized frontmatter from
104
+ # bloating the whole list.
105
+ MAX_MEMORIES_META_FILES = 20
106
+ MAX_MEMORY_TOPIC_CHARS = 100
107
+ MAX_MEMORY_DESCRIPTION_CHARS = 150
108
+
101
109
  # Process-wide deduper for the "skill context limit" warning so that
102
110
  # every newly constructed Agent (sub-agents, retries, web turns…) doesn't
103
111
  # re-emit the same line.
@@ -489,16 +497,18 @@ module Clacky
489
497
 
490
498
  # Scan ~/.clacky/memories/ and return a formatted summary of all memory files.
491
499
  # Parses YAML frontmatter (same pattern as Skill#parse_frontmatter) for each file.
500
+ # @param limit [Integer] max files listed, newest first
492
501
  # @return [String] Formatted list of memory topics and descriptions
493
- def load_memories_meta
502
+ def load_memories_meta(limit: MAX_MEMORIES_META_FILES)
494
503
  memories_dir = memories_base_dir
495
504
  return "(No long-term memories found.)" unless Dir.exist?(memories_dir)
496
505
 
497
- files = Dir.glob(File.join(memories_dir, "*.md"))
498
- .sort_by { |f| File.mtime(f) }
499
- .reverse
500
- .first(20)
501
- return "(No long-term memories found.)" if files.empty?
506
+ all_files = Dir.glob(File.join(memories_dir, "*.md"))
507
+ .sort_by { |f| File.mtime(f) }
508
+ .reverse
509
+ return "(No long-term memories found.)" if all_files.empty?
510
+
511
+ files = all_files.first(limit)
502
512
 
503
513
  lines = ["Available memory files in ~/.clacky/memories/:"]
504
514
  lines << ""
@@ -506,8 +516,8 @@ module Clacky
506
516
  files.each do |path|
507
517
  filename = File.basename(path)
508
518
  fm = parse_memory_frontmatter(path)
509
- topic = fm["topic"] || filename.sub(/\.md$/, "")
510
- description = fm["description"] || "(no description)"
519
+ topic = truncate_memory_field(fm["topic"] || filename.sub(/\.md$/, ""), MAX_MEMORY_TOPIC_CHARS)
520
+ description = truncate_memory_field(fm["description"] || "(no description)", MAX_MEMORY_DESCRIPTION_CHARS)
511
521
  # Use file mtime as the "last seen" signal (covers both writes and
512
522
  # touch-on-recall LRU bumps). Authoritative — no longer relies on
513
523
  # an LLM-maintained `updated_at` frontmatter field.
@@ -518,9 +528,21 @@ module Clacky
518
528
  lines << entry
519
529
  end
520
530
 
531
+ omitted = all_files.size - files.size
532
+ if omitted > 0
533
+ lines << ""
534
+ lines << "(#{omitted} older memories not listed — search ~/.clacky/memories/ with grep if needed)"
535
+ end
536
+
521
537
  lines.join("\n")
522
538
  end
523
539
 
540
+ # Collapse to a single line so each memory occupies exactly one entry in the listing.
541
+ private def truncate_memory_field(value, max_chars)
542
+ text = value.to_s.gsub(/\s+/, " ").strip
543
+ text.length > max_chars ? "#{text[0, max_chars]}…" : text
544
+ end
545
+
524
546
  # Base directory for long-term memories. Override in tests for isolation.
525
547
  # @return [String]
526
548
  def memories_base_dir