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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -0
- data/lib/clacky/agent/cost_tracker.rb +55 -36
- data/lib/clacky/agent/hook_manager.rb +4 -1
- data/lib/clacky/agent/llm_caller.rb +46 -12
- data/lib/clacky/agent/memory_injector.rb +209 -0
- data/lib/clacky/agent/memory_updater.rb +16 -0
- data/lib/clacky/agent/skill_manager.rb +30 -8
- data/lib/clacky/agent.rb +66 -2
- data/lib/clacky/agent_config.rb +4 -2
- data/lib/clacky/anthropic_stream_aggregator.rb +11 -1
- data/lib/clacky/bedrock_stream_aggregator.rb +11 -1
- data/lib/clacky/billing/platform_billing.rb +1 -0
- data/lib/clacky/cli.rb +4 -3
- data/lib/clacky/client.rb +30 -25
- data/lib/clacky/default_extensions/advisor/panels/advisor/view.js +7 -1
- data/lib/clacky/default_extensions/git/panels/git/view.js +35 -4
- data/lib/clacky/default_extensions/secretary/README.md +69 -0
- data/lib/clacky/default_extensions/secretary/README.zh.md +69 -0
- data/lib/clacky/default_extensions/secretary/ext.yml +3 -0
- data/lib/clacky/default_extensions/secretary/icon.svg +17 -0
- data/lib/clacky/default_skills/channel-manager/SKILL.md +24 -5
- data/lib/clacky/default_skills/channel-manager/dingtalk_setup.rb +4 -2
- data/lib/clacky/default_skills/channel-manager/discord_setup.rb +4 -1
- data/lib/clacky/default_skills/channel-manager/weixin_setup.rb +2 -0
- data/lib/clacky/default_skills/media-gen/SKILL.md +14 -6
- data/lib/clacky/default_skills/onboard/SKILL.md +5 -7
- data/lib/clacky/default_skills/persist-memory/SKILL.md +15 -1
- data/lib/clacky/extension/api_extension.rb +13 -0
- data/lib/clacky/json_ui_controller.rb +14 -1
- data/lib/clacky/locales/i18n.rb +25 -3
- data/lib/clacky/message_history.rb +15 -2
- data/lib/clacky/openai_responses_stream_aggregator.rb +11 -1
- data/lib/clacky/openai_stream_aggregator.rb +11 -1
- data/lib/clacky/prompts/base.md +1 -1
- data/lib/clacky/providers.rb +7 -3
- data/lib/clacky/server/backup_manager.rb +98 -2
- data/lib/clacky/server/browser_manager.rb +10 -5
- data/lib/clacky/server/channel/channel_manager.rb +69 -45
- data/lib/clacky/server/http_server.rb +133 -42
- data/lib/clacky/server/preview.rb +26 -5
- data/lib/clacky/server/web_ui_controller.rb +12 -0
- data/lib/clacky/skill.rb +1 -1
- data/lib/clacky/ui_interface.rb +5 -0
- data/lib/clacky/utils/file_processor.rb +4 -2
- data/lib/clacky/utils/model_pricing.rb +19 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +220 -6
- data/lib/clacky/web/app.js +18 -6
- data/lib/clacky/web/components/custom-select.js +7 -18
- data/lib/clacky/web/components/hint-tip.js +172 -0
- data/lib/clacky/web/components/sidebar.js +70 -0
- data/lib/clacky/web/features/backup/store.js +64 -27
- data/lib/clacky/web/features/backup/view.js +42 -11
- data/lib/clacky/web/features/channels/view.js +7 -7
- data/lib/clacky/web/features/new-session/view.js +10 -2
- data/lib/clacky/web/features/profile/view.js +4 -4
- data/lib/clacky/web/features/tasks/view.js +1 -0
- data/lib/clacky/web/features/trash/view.js +4 -4
- data/lib/clacky/web/i18n.js +61 -5
- data/lib/clacky/web/index.html +140 -43
- data/lib/clacky/web/projects.js +15 -2
- data/lib/clacky/web/sessions.js +234 -45
- data/lib/clacky/web/settings.js +93 -34
- data/lib/clacky/web/utils.js +44 -0
- data/lib/clacky/web/ws-dispatcher.js +17 -0
- data/lib/clacky/web/ws.js +16 -4
- data/lib/clacky/work/service.rb +2 -0
- data/lib/clacky.rb +1 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0de5997e8f1b998b5e57c588e232c0bbb6f96664f9dcd2c36ff2577d566b445b
|
|
4
|
+
data.tar.gz: 25108838302b0137589a14b320ee7374202a1601c897333fc1cb632226366462
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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"]
|
|
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
|