openclacky 0.9.35 → 0.9.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/lib/clacky/agent/skill_manager.rb +20 -8
- data/lib/clacky/agent.rb +22 -17
- data/lib/clacky/agent_config.rb +166 -40
- data/lib/clacky/cli.rb +32 -13
- data/lib/clacky/server/http_server.rb +141 -59
- data/lib/clacky/tools/terminal.rb +89 -15
- data/lib/clacky/ui2/ui_controller.rb +16 -7
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +75 -149
- data/lib/clacky/web/app.js +23 -17
- data/lib/clacky/web/i18n.js +10 -0
- data/lib/clacky/web/index.html +8 -18
- data/lib/clacky/web/sessions.js +85 -56
- metadata +1 -1
|
@@ -606,6 +606,52 @@ module Clacky
|
|
|
606
606
|
|
|
607
607
|
# ── Brand API ─────────────────────────────────────────────────────────────
|
|
608
608
|
|
|
609
|
+
# Process-wide mutex guarding heartbeat trigger state.
|
|
610
|
+
# Used by #trigger_async_heartbeat! to ensure only one heartbeat Thread is
|
|
611
|
+
# in flight at a time, no matter how many concurrent /api/brand/status
|
|
612
|
+
# requests arrive from the Web UI poller.
|
|
613
|
+
BRAND_HEARTBEAT_MUTEX = Mutex.new
|
|
614
|
+
# Tracks whether a heartbeat Thread is currently running.
|
|
615
|
+
@@brand_heartbeat_inflight = false
|
|
616
|
+
|
|
617
|
+
# Fire a heartbeat in a background Thread without blocking the caller.
|
|
618
|
+
#
|
|
619
|
+
# Contract:
|
|
620
|
+
# * Only one heartbeat Thread may be running at any moment across the
|
|
621
|
+
# whole process. If one is already in flight, this call is a no-op.
|
|
622
|
+
# * The caller never waits: it returns immediately after (at most)
|
|
623
|
+
# spawning the Thread.
|
|
624
|
+
# * The Thread rescues everything so a network failure cannot kill the
|
|
625
|
+
# server or leak an exception through the web stack.
|
|
626
|
+
def trigger_async_heartbeat!
|
|
627
|
+
BRAND_HEARTBEAT_MUTEX.synchronize do
|
|
628
|
+
if @@brand_heartbeat_inflight
|
|
629
|
+
Clacky::Logger.debug("[Brand] heartbeat already in flight, skipping")
|
|
630
|
+
return
|
|
631
|
+
end
|
|
632
|
+
@@brand_heartbeat_inflight = true
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
Thread.new do
|
|
636
|
+
Clacky::Logger.info("[Brand] async heartbeat starting...")
|
|
637
|
+
begin
|
|
638
|
+
brand = Clacky::BrandConfig.load
|
|
639
|
+
result = brand.heartbeat!
|
|
640
|
+
if result[:success]
|
|
641
|
+
Clacky::Logger.info("[Brand] async heartbeat OK")
|
|
642
|
+
else
|
|
643
|
+
Clacky::Logger.warn("[Brand] async heartbeat failed — #{result[:message]}")
|
|
644
|
+
end
|
|
645
|
+
rescue StandardError => e
|
|
646
|
+
Clacky::Logger.warn("[Brand] async heartbeat raised: #{e.class}: #{e.message}")
|
|
647
|
+
ensure
|
|
648
|
+
BRAND_HEARTBEAT_MUTEX.synchronize do
|
|
649
|
+
@@brand_heartbeat_inflight = false
|
|
650
|
+
end
|
|
651
|
+
end
|
|
652
|
+
end
|
|
653
|
+
end
|
|
654
|
+
|
|
609
655
|
# GET /api/brand/status
|
|
610
656
|
# Returns whether brand activation is needed.
|
|
611
657
|
# Mirrors the onboard/status pattern so the frontend can gate on it.
|
|
@@ -634,17 +680,15 @@ module Clacky
|
|
|
634
680
|
return
|
|
635
681
|
end
|
|
636
682
|
|
|
637
|
-
# Send heartbeat if interval has elapsed (once per day)
|
|
683
|
+
# Send heartbeat asynchronously if interval has elapsed (once per day).
|
|
684
|
+
#
|
|
685
|
+
# We must NOT block this HTTP response on the heartbeat call: a slow or
|
|
686
|
+
# unreachable license server would otherwise stall the Web UI's first
|
|
687
|
+
# paint for up to ~92s (2 hosts × 2 attempts × 23s timeout). The fresh
|
|
688
|
+
# expires_at / last_heartbeat will be picked up on the next /api/brand/status
|
|
689
|
+
# poll, which is sufficient for a once-per-day check.
|
|
638
690
|
if brand.heartbeat_due?
|
|
639
|
-
|
|
640
|
-
result = brand.heartbeat!
|
|
641
|
-
if result[:success]
|
|
642
|
-
Clacky::Logger.info("[Brand] api_brand_status: heartbeat OK")
|
|
643
|
-
else
|
|
644
|
-
Clacky::Logger.warn("[Brand] api_brand_status: heartbeat failed — #{result[:message]}")
|
|
645
|
-
end
|
|
646
|
-
# Reload after heartbeat to pick up updated expires_at / last_heartbeat
|
|
647
|
-
brand = Clacky::BrandConfig.load
|
|
691
|
+
trigger_async_heartbeat!
|
|
648
692
|
else
|
|
649
693
|
Clacky::Logger.debug("[Brand] api_brand_status: heartbeat not due yet")
|
|
650
694
|
end
|
|
@@ -1828,6 +1872,7 @@ module Clacky
|
|
|
1828
1872
|
def api_get_config(res)
|
|
1829
1873
|
models = @agent_config.models.map.with_index do |m, i|
|
|
1830
1874
|
{
|
|
1875
|
+
id: m["id"], # Stable runtime id — use this for switching
|
|
1831
1876
|
index: i,
|
|
1832
1877
|
model: m["model"],
|
|
1833
1878
|
base_url: m["base_url"],
|
|
@@ -1838,12 +1883,19 @@ module Clacky
|
|
|
1838
1883
|
end
|
|
1839
1884
|
# Filter out auto-injected models (like lite) from UI display
|
|
1840
1885
|
models.reject! { |m| @agent_config.models[m[:index]]["auto_injected"] }
|
|
1841
|
-
json_response(res, 200, {
|
|
1886
|
+
json_response(res, 200, {
|
|
1887
|
+
models: models,
|
|
1888
|
+
current_index: @agent_config.current_model_index,
|
|
1889
|
+
current_id: @agent_config.current_model&.dig("id")
|
|
1890
|
+
})
|
|
1842
1891
|
end
|
|
1843
1892
|
|
|
1844
1893
|
# POST /api/config — save updated model list
|
|
1845
|
-
# Body: { models: [ { index, model, base_url, api_key, anthropic_format, type } ] }
|
|
1846
|
-
#
|
|
1894
|
+
# Body: { models: [ { id?, index, model, base_url, api_key, anthropic_format, type } ] }
|
|
1895
|
+
# - id may be present for existing models (preserved) or absent for newly added
|
|
1896
|
+
# models (a new id is generated). Ids are runtime-only and stripped before
|
|
1897
|
+
# writing to config.yml (see AgentConfig#to_yaml).
|
|
1898
|
+
# - api_key may be masked ("sk-ab12****...5678") — keep existing key in that case
|
|
1847
1899
|
def api_save_config(req, res)
|
|
1848
1900
|
body = parse_json_body(req)
|
|
1849
1901
|
return json_response(res, 400, { error: "Invalid JSON" }) unless body
|
|
@@ -1851,8 +1903,18 @@ module Clacky
|
|
|
1851
1903
|
incoming = body["models"]
|
|
1852
1904
|
return json_response(res, 400, { error: "models array required" }) unless incoming.is_a?(Array)
|
|
1853
1905
|
|
|
1854
|
-
|
|
1855
|
-
|
|
1906
|
+
# Build a quick id→existing-model lookup. Ids are the single source
|
|
1907
|
+
# of identity for models across save/reload cycles — no index-based
|
|
1908
|
+
# fallback (ids are stable; indexes are not). Live sessions' stored
|
|
1909
|
+
# @current_model_id stays valid as long as the id is still present
|
|
1910
|
+
# in the list after save.
|
|
1911
|
+
existing_by_id = {}
|
|
1912
|
+
@agent_config.models.each { |m| existing_by_id[m["id"]] = m if m["id"] }
|
|
1913
|
+
|
|
1914
|
+
new_models = incoming.map do |m|
|
|
1915
|
+
# Lookup by id only. No id means a brand-new model — we mint one.
|
|
1916
|
+
existing = m["id"] && existing_by_id[m["id"]]
|
|
1917
|
+
|
|
1856
1918
|
# Resolve api_key: if masked placeholder, keep the stored key
|
|
1857
1919
|
api_key = if m["api_key"].to_s.include?("****")
|
|
1858
1920
|
existing&.dig("api_key")
|
|
@@ -1860,26 +1922,26 @@ module Clacky
|
|
|
1860
1922
|
m["api_key"]
|
|
1861
1923
|
end
|
|
1862
1924
|
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
model: m["model"].to_s,
|
|
1872
|
-
api_key: api_key.to_s,
|
|
1873
|
-
base_url: m["base_url"].to_s,
|
|
1874
|
-
anthropic_format: m["anthropic_format"] || false,
|
|
1875
|
-
type: m["type"]
|
|
1876
|
-
)
|
|
1877
|
-
end
|
|
1925
|
+
{
|
|
1926
|
+
"id" => (existing && existing["id"]) || SecureRandom.uuid,
|
|
1927
|
+
"model" => m["model"].to_s,
|
|
1928
|
+
"base_url" => m["base_url"].to_s,
|
|
1929
|
+
"api_key" => api_key.to_s,
|
|
1930
|
+
"anthropic_format" => m["anthropic_format"] || false,
|
|
1931
|
+
"type" => m["type"]
|
|
1932
|
+
}.tap { |h| h.delete("type") if h["type"].nil? || h["type"].to_s.empty? }
|
|
1878
1933
|
end
|
|
1879
1934
|
|
|
1880
|
-
#
|
|
1881
|
-
|
|
1882
|
-
|
|
1935
|
+
# Replace @models in place — do NOT reassign the array, because every
|
|
1936
|
+
# live session holds a reference to the same array (Plan B shared
|
|
1937
|
+
# models). `replace` mutates in place so all sessions see the new list.
|
|
1938
|
+
@agent_config.models.replace(new_models)
|
|
1939
|
+
|
|
1940
|
+
# Re-anchor current_model_index to the model still holding type: default
|
|
1941
|
+
if (new_default_idx = new_models.find_index { |m| m["type"] == "default" })
|
|
1942
|
+
@agent_config.current_model_index = new_default_idx
|
|
1943
|
+
elsif @agent_config.current_model_index >= new_models.length
|
|
1944
|
+
@agent_config.current_model_index = [new_models.length - 1, 0].max
|
|
1883
1945
|
end
|
|
1884
1946
|
|
|
1885
1947
|
@agent_config.save
|
|
@@ -2004,36 +2066,38 @@ module Clacky
|
|
|
2004
2066
|
|
|
2005
2067
|
def api_switch_session_model(session_id, req, res)
|
|
2006
2068
|
body = parse_json_body(req)
|
|
2007
|
-
|
|
2069
|
+
model_id = body["model_id"].to_s.strip
|
|
2008
2070
|
|
|
2009
|
-
return json_response(res, 400, { error: "
|
|
2071
|
+
return json_response(res, 400, { error: "model_id is required" }) if model_id.empty?
|
|
2010
2072
|
return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
|
|
2011
2073
|
|
|
2012
2074
|
agent = nil
|
|
2013
2075
|
@registry.with_session(session_id) { |s| agent = s[:agent] }
|
|
2014
|
-
|
|
2015
|
-
#
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2076
|
+
|
|
2077
|
+
# With Plan B (shared @models reference), every session's AgentConfig
|
|
2078
|
+
# points at the same @models array as the global @agent_config. So
|
|
2079
|
+
# resolving the model by stable id here and in agent.switch_model_by_id
|
|
2080
|
+
# will always agree — no more index divergence after add/delete.
|
|
2081
|
+
target_model = @agent_config.models.find { |m| m["id"] == model_id }
|
|
2082
|
+
if target_model.nil?
|
|
2083
|
+
return json_response(res, 400, { error: "Model not found in configuration" })
|
|
2020
2084
|
end
|
|
2021
|
-
|
|
2022
|
-
# Switch to the model by
|
|
2023
|
-
#
|
|
2024
|
-
success = agent.
|
|
2025
|
-
|
|
2085
|
+
|
|
2086
|
+
# Switch to the model by id (unified interface with CLI)
|
|
2087
|
+
# Handles: config.switch_model_by_id + client rebuild + message_compressor rebuild
|
|
2088
|
+
success = agent.switch_model_by_id(model_id)
|
|
2089
|
+
|
|
2026
2090
|
unless success
|
|
2027
2091
|
return json_response(res, 500, { error: "Failed to switch model" })
|
|
2028
2092
|
end
|
|
2029
|
-
|
|
2093
|
+
|
|
2030
2094
|
# Persist the change (saves to session file, NOT global config.yml)
|
|
2031
2095
|
@session_manager.save(agent.to_session_data)
|
|
2032
|
-
|
|
2096
|
+
|
|
2033
2097
|
# Broadcast update to all clients
|
|
2034
2098
|
broadcast_session_update(session_id)
|
|
2035
|
-
|
|
2036
|
-
json_response(res, 200, { ok: true, model:
|
|
2099
|
+
|
|
2100
|
+
json_response(res, 200, { ok: true, model_id: model_id, model: target_model["model"] })
|
|
2037
2101
|
rescue => e
|
|
2038
2102
|
json_response(res, 500, { error: e.message })
|
|
2039
2103
|
end
|
|
@@ -2071,16 +2135,34 @@ module Clacky
|
|
|
2071
2135
|
end
|
|
2072
2136
|
|
|
2073
2137
|
def api_delete_session(session_id, res)
|
|
2074
|
-
if
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2138
|
+
# A session exists if it's either in the runtime registry OR on disk.
|
|
2139
|
+
# Old sessions that were never restored into memory this server run
|
|
2140
|
+
# (e.g. shown via "load more" in the WebUI list) are disk-only — we
|
|
2141
|
+
# must still be able to delete them. Previously this endpoint only
|
|
2142
|
+
# consulted @registry and returned 404 for disk-only sessions,
|
|
2143
|
+
# causing the "can't delete old sessions" bug.
|
|
2144
|
+
in_registry = @registry.exist?(session_id)
|
|
2145
|
+
on_disk = !@session_manager.load(session_id).nil?
|
|
2146
|
+
|
|
2147
|
+
unless in_registry || on_disk
|
|
2148
|
+
return json_response(res, 404, { error: "Session not found" })
|
|
2083
2149
|
end
|
|
2150
|
+
|
|
2151
|
+
# Registry delete is best-effort — only meaningful when the session
|
|
2152
|
+
# is actually live (cancels idle timer, interrupts the agent thread).
|
|
2153
|
+
# For disk-only sessions this is a no-op and returns false, which is
|
|
2154
|
+
# fine and no longer blocks the disk cleanup below.
|
|
2155
|
+
@registry.delete(session_id) if in_registry
|
|
2156
|
+
|
|
2157
|
+
# Always physically remove the persisted session file (+ chunks).
|
|
2158
|
+
@session_manager.delete(session_id) if on_disk
|
|
2159
|
+
|
|
2160
|
+
# Notify any still-connected clients (mainly matters when the
|
|
2161
|
+
# session was live, but harmless otherwise).
|
|
2162
|
+
broadcast(session_id, { type: "session_deleted", session_id: session_id })
|
|
2163
|
+
unsubscribe_all(session_id)
|
|
2164
|
+
|
|
2165
|
+
json_response(res, 200, { ok: true })
|
|
2084
2166
|
end
|
|
2085
2167
|
|
|
2086
2168
|
# Export a session bundle as a .zip download containing:
|
|
@@ -274,7 +274,7 @@ module Clacky
|
|
|
274
274
|
raw = read_log_slice(session.log_file, start_offset, new_offset)
|
|
275
275
|
cleaned = OutputCleaner.clean(raw)
|
|
276
276
|
cleaned = cleaned.sub(session.marker_regex, "").rstrip if session.marker_regex
|
|
277
|
-
cleaned = strip_command_echo(cleaned)
|
|
277
|
+
cleaned = strip_command_echo(cleaned, marker_token: session.marker_token)
|
|
278
278
|
truncated = false
|
|
279
279
|
if cleaned.bytesize > MAX_LLM_OUTPUT_CHARS
|
|
280
280
|
cleaned = cleaned.byteslice(0, MAX_LLM_OUTPUT_CHARS) + "\n...[output truncated]"
|
|
@@ -336,23 +336,97 @@ module Clacky
|
|
|
336
336
|
# The shell may echo the wrapper line we injected (`{ USER_CMD; }; ...;
|
|
337
337
|
# printf "__CLACKY_DONE_..."`) before running it. When stty -echo is
|
|
338
338
|
# honoured (bash/fresh pty) this is a no-op; when it isn't (zsh ZLE
|
|
339
|
-
# sometimes re-enables echo on reuse
|
|
339
|
+
# sometimes re-enables echo on reuse, or the user sent input to a
|
|
340
|
+
# running session) we strip the wrapper echo wherever it appears.
|
|
340
341
|
#
|
|
341
|
-
#
|
|
342
|
-
#
|
|
343
|
-
#
|
|
344
|
-
#
|
|
345
|
-
#
|
|
346
|
-
|
|
342
|
+
# Observed variants of the echoed wrapper:
|
|
343
|
+
#
|
|
344
|
+
# 1) Multi-line, starting the buffer (PTY in cooked mode, expanded
|
|
345
|
+
# \n escapes inside printf's double-quoted format string):
|
|
346
|
+
# { USER_CMD
|
|
347
|
+
# }; __clacky_ec=$?; printf "
|
|
348
|
+
# __CLACKY_DONE_<token>_%s__
|
|
349
|
+
# " "$__clacky_ec"
|
|
350
|
+
#
|
|
351
|
+
# 2) Single-line / partially-truncated (PTY width wrap or partial
|
|
352
|
+
# char drop ate the leading `{` or first chars of the command):
|
|
353
|
+
# ails runner foo.rb ... }; __clacky_ec=$?; printf " __CLACKY_DONE_<token>_%s__ " "$__clacky_ec"
|
|
354
|
+
#
|
|
355
|
+
# 3) Embedded mid-stream when re-echoed (e.g. after session re-use
|
|
356
|
+
# or after a user input: call landed in a shell that re-enabled
|
|
357
|
+
# echo). Same shape as (1) or (2) but not anchored to the start.
|
|
358
|
+
#
|
|
359
|
+
# We handle all three by running two passes:
|
|
360
|
+
# * an anchored multi-line strip (keeps the legacy behaviour and is
|
|
361
|
+
# cheapest when stty -echo silently failed);
|
|
362
|
+
# * a token-aware global strip that removes any remaining echoed
|
|
363
|
+
# wrapper fragment anywhere in the buffer. The token makes this
|
|
364
|
+
# safe: the real completion marker was already removed via
|
|
365
|
+
# session.marker_regex above, so any surviving occurrence of
|
|
366
|
+
# __CLACKY_DONE_<token>_ is by definition an echoed wrapper.
|
|
367
|
+
private def strip_command_echo(text, marker_token: nil)
|
|
347
368
|
return text if text.nil? || text.empty?
|
|
348
|
-
|
|
349
|
-
#
|
|
350
|
-
#
|
|
351
|
-
# }; __clacky_ec=$?; printf "
|
|
352
|
-
# __CLACKY_DONE_<token>_%s__
|
|
353
|
-
# " "$__clacky_ec"
|
|
354
|
-
# Anchored at the start; non-greedy across newlines via /m.
|
|
369
|
+
|
|
370
|
+
# Pass 1: anchored strip — the full wrapper echoed at the start,
|
|
371
|
+
# possibly spanning multiple real newlines.
|
|
355
372
|
text = text.sub(/\A\{.*?"\$__clacky_ec"\s*\n?/m, "")
|
|
373
|
+
|
|
374
|
+
# Pass 2: token-aware global strip — remove any leftover wrapper
|
|
375
|
+
# echo fragment, wherever it sits. Requires the session token so
|
|
376
|
+
# we never touch unrelated user output that happens to mention
|
|
377
|
+
# `__clacky_ec`.
|
|
378
|
+
if marker_token && !marker_token.empty?
|
|
379
|
+
token_re = Regexp.escape(marker_token)
|
|
380
|
+
|
|
381
|
+
# 2a. Multi-line shape: walk back from __CLACKY_DONE_<token> to
|
|
382
|
+
# the opening `{` of the wrapper (start of line or start of
|
|
383
|
+
# buffer) and forward to the closing `"$__clacky_ec"`.
|
|
384
|
+
text = text.gsub(
|
|
385
|
+
/(?:^|(?<=\n))\{[^\n]*\n(?:[^\n]*\n)*?[^\n]*__CLACKY_DONE_#{token_re}_[^\n]*\n[^\n]*"\$__clacky_ec"[^\n]*\n?/,
|
|
386
|
+
""
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
# 2b. Single-line shape: everything collapsed onto one line.
|
|
390
|
+
# Strip from the wrapper's `}; __clacky_ec=$?` pivot (or the
|
|
391
|
+
# opening `{` if still present on that line) through the end of
|
|
392
|
+
# the printf invocation (`"$__clacky_ec"`).
|
|
393
|
+
text = text.gsub(
|
|
394
|
+
/[^\n]*\}; *__clacky_ec=\$\?; *printf[^\n]*__CLACKY_DONE_#{token_re}_[^\n]*"\$__clacky_ec"[^\n]*\n?/,
|
|
395
|
+
""
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
# 2c. Last-resort: a bare marker-format fragment on its own,
|
|
399
|
+
# without the `}; printf ...` prefix (e.g. terminal wrapped the
|
|
400
|
+
# echo such that only the tail survived). Drop lines that
|
|
401
|
+
# contain the literal `__CLACKY_DONE_<token>_%s__` format —
|
|
402
|
+
# the real marker has `\d+` in place of `%s` so this only hits
|
|
403
|
+
# echoed wrappers.
|
|
404
|
+
text = text.gsub(/^.*__CLACKY_DONE_#{token_re}_%s__.*\n?/, "")
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# Pass 3: token-INDEPENDENT fingerprint strip — PTY width-wrap
|
|
408
|
+
# can chop the `__CLACKY_DONE_<token>_%s__` format string out of
|
|
409
|
+
# printf entirely, leaving e.g. `}; __clacky_ec=$?; printf " " "$__clacky_ec"`.
|
|
410
|
+
# None of the token-aware patterns above catch that. The pair
|
|
411
|
+
# `}; __clacky_ec=$?` (opening pivot) and `"$__clacky_ec"` (printf
|
|
412
|
+
# tail) are our wrapper's unique fingerprints — `__clacky_ec` is a
|
|
413
|
+
# private double-underscore var name that user code effectively
|
|
414
|
+
# never emits — so we strip anything between them (non-greedy,
|
|
415
|
+
# multiline-aware) to also handle width-wrap that inserted
|
|
416
|
+
# real \n breaks inside the echo.
|
|
417
|
+
text = text.gsub(
|
|
418
|
+
/[^\n]*\}; *__clacky_ec=\$\?.*?"\$__clacky_ec"[^\n]*\n?/m,
|
|
419
|
+
""
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
# Pass 4: bare pivot with no printf tail at all (extreme
|
|
423
|
+
# truncation cut off everything after `__clacky_ec=$?`). Still a
|
|
424
|
+
# reliable fingerprint thanks to the `__clacky_ec` var name.
|
|
425
|
+
text = text.gsub(
|
|
426
|
+
/[^\n]*\}; *__clacky_ec=\$\?;?[^\n]*\n?/,
|
|
427
|
+
""
|
|
428
|
+
)
|
|
429
|
+
|
|
356
430
|
text
|
|
357
431
|
end
|
|
358
432
|
|
|
@@ -1188,7 +1188,7 @@ module Clacky
|
|
|
1188
1188
|
display_name = "#{type_badge}#{model_name} (#{masked_key})"
|
|
1189
1189
|
choices << {
|
|
1190
1190
|
name: display_name,
|
|
1191
|
-
value: { action: :switch,
|
|
1191
|
+
value: { action: :switch, model_id: model["id"] }
|
|
1192
1192
|
}
|
|
1193
1193
|
end
|
|
1194
1194
|
|
|
@@ -1210,8 +1210,15 @@ module Clacky
|
|
|
1210
1210
|
|
|
1211
1211
|
case result[:action]
|
|
1212
1212
|
when :switch
|
|
1213
|
-
|
|
1214
|
-
#
|
|
1213
|
+
# CLI is a single-session context: when the user picks a model
|
|
1214
|
+
# we treat it as "make this my default from now on". So:
|
|
1215
|
+
# 1. switch this session's current model
|
|
1216
|
+
# 2. move the global `type: "default"` marker to it
|
|
1217
|
+
# 3. persist to config.yml so next CLI launch uses it
|
|
1218
|
+
# (Web UI's per-session switch is different — it must NOT do
|
|
1219
|
+
# steps 2 and 3, and uses switch_model_by_id directly.)
|
|
1220
|
+
current_config.switch_model_by_id(result[:model_id])
|
|
1221
|
+
current_config.set_default_model_by_id(result[:model_id])
|
|
1215
1222
|
current_config.save
|
|
1216
1223
|
# Return to indicate config changed (need to update client)
|
|
1217
1224
|
return { action: :switch }
|
|
@@ -1228,10 +1235,12 @@ module Clacky
|
|
|
1228
1235
|
base_url: new_model[:base_url],
|
|
1229
1236
|
anthropic_format: anthropic_format
|
|
1230
1237
|
)
|
|
1231
|
-
#
|
|
1232
|
-
|
|
1233
|
-
#
|
|
1234
|
-
current_config.
|
|
1238
|
+
# CLI: adding a model implies the user wants to use it now and
|
|
1239
|
+
# next launch. Switch this session to it AND set it as the
|
|
1240
|
+
# global default, then persist.
|
|
1241
|
+
new_id = current_config.models.last["id"]
|
|
1242
|
+
current_config.switch_model_by_id(new_id)
|
|
1243
|
+
current_config.set_default_model_by_id(new_id)
|
|
1235
1244
|
current_config.save
|
|
1236
1245
|
# Return to exit the menu
|
|
1237
1246
|
return { action: :switch }
|
data/lib/clacky/version.rb
CHANGED