openclacky 1.2.12 → 1.2.13

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +1 -1
  3. data/.clacky/skills/gem-release/scripts/release.sh +4 -1
  4. data/CHANGELOG.md +23 -0
  5. data/lib/clacky/agent/llm_caller.rb +40 -25
  6. data/lib/clacky/agent/memory_updater.rb +12 -0
  7. data/lib/clacky/agent/skill_auto_creator.rb +7 -4
  8. data/lib/clacky/agent/skill_evolution.rb +23 -5
  9. data/lib/clacky/agent/skill_manager.rb +86 -1
  10. data/lib/clacky/agent/skill_reflector.rb +18 -23
  11. data/lib/clacky/agent.rb +9 -1
  12. data/lib/clacky/agent_config.rb +59 -15
  13. data/lib/clacky/cli.rb +55 -0
  14. data/lib/clacky/default_skills/persist-memory/SKILL.md +4 -3
  15. data/lib/clacky/default_skills/search-skills/SKILL.md +61 -0
  16. data/lib/clacky/idle_compression_timer.rb +1 -1
  17. data/lib/clacky/message_format/open_ai.rb +7 -1
  18. data/lib/clacky/openai_stream_aggregator.rb +4 -1
  19. data/lib/clacky/providers.rb +40 -12
  20. data/lib/clacky/server/http_server.rb +117 -3
  21. data/lib/clacky/server/session_registry.rb +30 -8
  22. data/lib/clacky/server/web_ui_controller.rb +24 -1
  23. data/lib/clacky/session_manager.rb +120 -0
  24. data/lib/clacky/tools/web_search.rb +59 -8
  25. data/lib/clacky/ui2/layout_manager.rb +15 -5
  26. data/lib/clacky/ui2/progress_handle.rb +7 -1
  27. data/lib/clacky/ui2/ui_controller.rb +27 -0
  28. data/lib/clacky/ui_interface.rb +22 -0
  29. data/lib/clacky/utils/model_pricing.rb +96 -0
  30. data/lib/clacky/version.rb +1 -1
  31. data/lib/clacky/web/app.css +209 -4
  32. data/lib/clacky/web/app.js +6 -5
  33. data/lib/clacky/web/i18n.js +18 -4
  34. data/lib/clacky/web/index.html +2 -1
  35. data/lib/clacky/web/sessions.js +408 -80
  36. data/lib/clacky/web/settings.js +213 -51
  37. data/lib/clacky/web/skills.js +5 -14
  38. data/lib/clacky/web/utils.js +57 -0
  39. data/lib/clacky/web/ws-dispatcher.js +136 -0
  40. metadata +4 -2
data/lib/clacky/cli.rb CHANGED
@@ -50,6 +50,7 @@ module Clacky
50
50
  option :verbose, type: :boolean, aliases: "-v", default: false, desc: "Show detailed output"
51
51
  option :path, type: :string, desc: "Project directory path (defaults to current directory)"
52
52
  option :continue, type: :boolean, aliases: "-c", desc: "Continue most recent session"
53
+ option :fork, type: :string, desc: "Fork a session by number or session ID prefix (creates a copy)"
53
54
  option :list, type: :boolean, aliases: "-l", desc: "List recent sessions"
54
55
  option :attach, type: :string, aliases: "-a", desc: "Attach to session by number or keyword"
55
56
  option :json, type: :boolean, default: false, desc: "Output NDJSON to stdout (for scripting/piping)"
@@ -140,6 +141,9 @@ module Clacky
140
141
  elsif options[:attach]
141
142
  agent = load_session_by_number(client_factory.call, agent_config, session_manager, working_dir, options[:attach], profile: agent_profile)
142
143
  is_session_load = !agent.nil?
144
+ elsif options[:fork]
145
+ agent = fork_session(client_factory.call, agent_config, session_manager, working_dir, options[:fork], profile: agent_profile)
146
+ is_session_load = !agent.nil?
143
147
  end
144
148
 
145
149
  # Create new agent if no session loaded
@@ -549,8 +553,59 @@ module Clacky
549
553
  Clacky::Agent.from_session(client, agent_config, session_data, profile: resolved_profile)
550
554
  end
551
555
 
556
+ def fork_session(client, agent_config, session_manager, working_dir, identifier, profile:)
557
+ # Get a larger list to search through (for ID prefix matching)
558
+ sessions = session_manager.all_sessions(current_dir: working_dir, limit: 100)
559
+
560
+ if sessions.empty?
561
+ say "No sessions found.", :yellow
562
+ return nil
563
+ end
564
+
565
+ session_data = nil
566
+
567
+ # Same resolution logic as load_session_by_number
568
+ if identifier.match?(/^\d+$/) && identifier.to_i <= 99
569
+ index = identifier.to_i - 1
570
+ if index < 0 || index >= sessions.size
571
+ say "Invalid session number. Use -l to list available sessions.", :red
572
+ exit 1
573
+ end
574
+ session_data = sessions[index]
575
+ else
576
+ matching_sessions = sessions.select { |s| s[:session_id].start_with?(identifier) }
577
+ if matching_sessions.empty?
578
+ say "No session found matching ID prefix: #{identifier}", :red
579
+ say "Use -l to list available sessions.", :yellow
580
+ exit 1
581
+ elsif matching_sessions.size > 1
582
+ say "Multiple sessions found matching '#{identifier}':", :yellow
583
+ matching_sessions.each_with_index do |session, idx|
584
+ created_at = Time.parse(session[:created_at]).strftime("%Y-%m-%d %H:%M")
585
+ s_id = session[:session_id][0..7]
586
+ name = session[:name].to_s.empty? ? "Unnamed session" : session[:name]
587
+ say " #{idx + 1}. [#{s_id}] #{created_at} - #{name}", :cyan
588
+ end
589
+ say "\nPlease use a more specific prefix.", :yellow
590
+ exit 1
591
+ else
592
+ session_data = matching_sessions.first
593
+ end
594
+ end
595
+
596
+ fork_data = session_manager.fork(session_data[:session_id])
597
+ return nil unless fork_data
598
+
599
+ # Fall back to CLI --agent flag for sessions that predate agent_profile
600
+ restored_profile = fork_data[:agent_profile].to_s
601
+ resolved_profile = restored_profile.empty? ? profile : restored_profile
602
+
603
+ Clacky::Agent.from_session(client, agent_config, fork_data, profile: resolved_profile)
604
+ end
605
+
552
606
  # Handle agent error/interrupt with cleanup
553
607
  def handle_agent_exception(ui_controller, agent, session_manager, exception)
608
+ Clacky::Logger.warn("[ph_debug] handle_agent_exception", klass: exception.class.name, msg: exception.message.to_s[0, 200])
554
609
  ui_controller.show_progress(phase: "done")
555
610
  ui_controller.set_idle_status
556
611
 
@@ -48,10 +48,11 @@ Scan the list above:
48
48
 
49
49
  Use the `write` tool. Always include the YAML frontmatter shown above.
50
50
 
51
- ## Hard constraints (CRITICAL)
51
+ ## Guidelines
52
52
 
53
- - Each file MUST stay under **4000 characters of content** (after the frontmatter).
54
- - If merging would exceed this limit, remove the least important information do NOT split into multiple files for the same topic.
53
+ - Aim for around 4000 characters of content (after the frontmatter). This is a soft target — moderate overshoot is fine, do NOT iterate writes just to shave characters.
54
+ - If a file grows much larger than that (say, well past 8000), trim the least important information rather than splitting one topic across multiple files.
55
+ - Prefer merging into an existing file over creating a new one. Only create a new file when no existing topic genuinely covers the area.
55
56
  - Write concise, factual Markdown — no fluff, no redundant headings.
56
57
  - One topic per file. Don't bundle unrelated facts together.
57
58
  - Do NOT use `terminal` or `file_reader` to list the memories directory — the list above is authoritative.
@@ -0,0 +1,61 @@
1
+ ---
2
+ name: search-skills
3
+ description: 'Search ALL installed skills (including ones not shown in AVAILABLE SKILLS) by keyword. Use this whenever you suspect a fitting skill might exist but is not listed in your system prompt — for example before building a new skill, when the user mentions a domain not covered by visible skills, or after seeing the (N more skills installed) hint. Triggers on phrases like search skills, find a skill for, is there a skill that, 查找skill, 有没有skill做.'
4
+ disable-model-invocation: false
5
+ user-invocable: true
6
+ fork_agent: true
7
+ auto_summarize: true
8
+ forbidden_tools:
9
+ - write
10
+ - edit
11
+ - terminal
12
+ - web_search
13
+ - web_fetch
14
+ - browser
15
+ ---
16
+
17
+ # Search Skills Subagent
18
+
19
+ You are a Skill Search Subagent. Given a keyword or topic from the parent agent, scan the complete list of installed skills below and return the best matches.
20
+
21
+ The AVAILABLE SKILLS section in the parent agent system prompt is capped (~30 entries). The list below is the FULL list — pre-loaded for you, no scanning required. Your job is to look beyond that cap so the parent does not redundantly create a new skill when one already exists.
22
+
23
+ ## Complete Skill Inventory
24
+
25
+ This list was pre-loaded — do NOT re-scan the filesystem or call any tools.
26
+
27
+ <%= all_skills_meta %>
28
+
29
+ ## Workflow
30
+
31
+ ### Step 1 — Extract keywords
32
+
33
+ Pull 2-4 keywords from the input task. Both English and Chinese terms are valid (skill descriptions are bilingual).
34
+
35
+ ### Step 2 — Match against the inventory above
36
+
37
+ For each skill in the inventory, judge relevance against the keywords:
38
+ - Strong match: keyword appears in the skill `name` or clearly in the `description`'s purpose statement
39
+ - Weak match: keyword appears only in the trigger examples or peripheral mentions
40
+
41
+ ### Step 3 — Return a ranked summary
42
+
43
+ Return at most 5 results, strongest matches first:
44
+
45
+ ```
46
+ Found N matching skill(s) for: <keywords>
47
+
48
+ 1. <name> (<source>)
49
+ <description trimmed to ~200 chars>
50
+
51
+ 2. ...
52
+ ```
53
+
54
+ If nothing genuinely matches, return exactly: `No installed skill matches: <task>`
55
+
56
+ ## Rules
57
+
58
+ - Do NOT invoke any tool. The inventory above is authoritative; just match and return.
59
+ - Do NOT recommend creating a new skill — that is the parent agent's call.
60
+ - If the task is vague, return what genuinely matched, do not invent relevance.
61
+ - Default skills (built-in) are part of the inventory but typically also visible to the parent — flagging them is still useful as a reminder.
@@ -17,7 +17,7 @@ module Clacky
17
17
  # Seconds of inactivity before idle compression is triggered.
18
18
  # Kept under the 5-minute prompt cache TTL so the compression call itself
19
19
  # still hits the existing prefix cache.
20
- IDLE_DELAY = 314
20
+ IDLE_DELAY = 266
21
21
 
22
22
  # @param agent [Clacky::Agent] the agent whose messages will be compressed
23
23
  # @param session_manager [Clacky::SessionManager, nil] used to persist session after compression
@@ -206,7 +206,13 @@ module Clacky
206
206
  # Skip malformed tool calls where name or arguments is nil (broken API response)
207
207
  next if name.nil? || arguments.nil?
208
208
 
209
- { id: call["id"], type: call["type"], name: name, arguments: arguments }
209
+ tc = { id: call["id"], type: call["type"], name: name, arguments: arguments }
210
+ # Vertex Gemini's OpenAI shim returns thought_signature inside
211
+ # tool_calls[i].extra_content.google and requires it echoed back on
212
+ # replay, otherwise the next turn 400s with "Function call is missing
213
+ # a thought_signature". Preserve it through the canonical layer.
214
+ tc[:extra_content] = call["extra_content"] if call["extra_content"]
215
+ tc
210
216
  end
211
217
  end
212
218
 
@@ -72,7 +72,7 @@ module Clacky
72
72
  def to_h
73
73
  tool_calls = @tool_calls.keys.sort.map do |idx|
74
74
  tc = @tool_calls[idx]
75
- {
75
+ out = {
76
76
  "id" => tc[:id],
77
77
  "type" => tc[:type] || "function",
78
78
  "function" => {
@@ -80,6 +80,8 @@ module Clacky
80
80
  "arguments" => tc[:arguments].to_s
81
81
  }
82
82
  }
83
+ out["extra_content"] = tc[:extra_content] if tc[:extra_content]
84
+ out
83
85
  end
84
86
 
85
87
  message = {
@@ -104,6 +106,7 @@ module Clacky
104
106
  slot[:name] ||= fn["name"] if fn["name"]
105
107
  slot[:arguments] << fn["arguments"].to_s if fn["arguments"]
106
108
  end
109
+ slot[:extra_content] = tc["extra_content"] if tc["extra_content"]
107
110
  end
108
111
 
109
112
  private def parse_or_nil(s)
@@ -39,18 +39,25 @@ module Clacky
39
39
  "abs-claude-haiku-4-5",
40
40
  "dsk-deepseek-v4-pro",
41
41
  "dsk-deepseek-v4-flash",
42
- "or-gemini-3-1-pro"
42
+ "or-gemini-3-1-pro",
43
+ "or-gemini-3-5-flash"
43
44
  ],
44
45
  # Image generation models served by the openclacky platform
45
46
  # gateway. The gateway exposes a standard OpenAI-compatible
46
47
  # /v1/images/generations endpoint, so the same OpenAICompat
47
- # provider class handles them. `or-` prefix mirrors the chat
48
- # model naming these are routed through the OpenRouter
49
- # backend by the platform.
48
+ # provider class handles them. `or-` prefix is a routing alias
49
+ # onlythe platform may dispatch to OpenRouter or Vertex AI
50
+ # (Gemini Nano Banana family) depending on the model.
50
51
  "image_models" => [
51
52
  "or-gemini-3-pro-image",
53
+ "or-gemini-3-1-flash-image",
52
54
  "or-gpt-image-2"
53
55
  ],
56
+ "image_model_aliases" => {
57
+ "or-gemini-3-pro-image" => "Nano Banana Pro",
58
+ "or-gemini-3-1-flash-image" => "Nano Banana 2",
59
+ "or-gpt-image-2" => "GPT Image 2"
60
+ },
54
61
  "default_image_model" => "or-gpt-image-2",
55
62
  # Provider-level default: the Claude family served here is vision-capable.
56
63
  "capabilities" => { "vision" => true }.freeze,
@@ -65,20 +72,17 @@ module Clacky
65
72
  # Per-primary lite pairing: keys are "strong" primary models, values
66
73
  # are the lite sidekick to auto-inject when that primary is the
67
74
  # default. Lite is consumed by some subagents for cheap/fast work;
68
- # weak models (haiku / v4-flash) ARE the lite tier themselves, so
69
- # they're intentionally not listed here no injection happens when
70
- # the default model is already lite-class.
71
- #
72
- # or-gemini-3-1-pro is intentionally absent: Gemini has no lite
73
- # sibling wired up (yet) on this provider; subagents using the
74
- # Gemini default will just reuse it for lite work until we add one.
75
+ # weak models (haiku / v4-flash / 3-5-flash) ARE the lite tier
76
+ # themselves, so they're intentionally not listed here as keys
77
+ # no injection happens when the default model is already lite-class.
75
78
  "lite_models" => {
76
79
  "abs-claude-opus-4-8" => "abs-claude-haiku-4-5",
77
80
  "abs-claude-opus-4-7" => "abs-claude-haiku-4-5",
78
81
  "abs-claude-opus-4-6" => "abs-claude-haiku-4-5",
79
82
  "abs-claude-sonnet-4-6" => "abs-claude-haiku-4-5",
80
83
  "abs-claude-sonnet-4-5" => "abs-claude-haiku-4-5",
81
- "dsk-deepseek-v4-pro" => "dsk-deepseek-v4-flash"
84
+ "dsk-deepseek-v4-pro" => "dsk-deepseek-v4-flash",
85
+ "or-gemini-3-1-pro" => "or-gemini-3-5-flash"
82
86
  },
83
87
  # Fallback chain: if a model is unavailable, try the next one in order.
84
88
  # Keys are primary model names; values are the fallback model to use instead.
@@ -487,6 +491,30 @@ module Clacky
487
491
  preset&.dig("image_models") || []
488
492
  end
489
493
 
494
+ def image_model_aliases(provider_id)
495
+ preset = PRESETS[provider_id]
496
+ preset&.dig("image_model_aliases") || {}
497
+ end
498
+
499
+ def video_model_aliases(provider_id)
500
+ preset = PRESETS[provider_id]
501
+ preset&.dig("video_model_aliases") || {}
502
+ end
503
+
504
+ def audio_model_aliases(provider_id)
505
+ preset = PRESETS[provider_id]
506
+ preset&.dig("audio_model_aliases") || {}
507
+ end
508
+
509
+ def media_model_aliases(provider_id, kind)
510
+ case kind.to_s
511
+ when "image" then image_model_aliases(provider_id)
512
+ when "video" then video_model_aliases(provider_id)
513
+ when "audio" then audio_model_aliases(provider_id)
514
+ else {}
515
+ end
516
+ end
517
+
490
518
  # Video generation models — placeholder. No provider supports video
491
519
  # via Clacky yet; once they do, declare "video_models" alongside
492
520
  # "image_models" in the relevant PRESETS entry and this returns it.
@@ -5,6 +5,7 @@ require "websocket"
5
5
  require "socket"
6
6
  require "json"
7
7
  require "net/http"
8
+ require "faraday"
8
9
  require "thread"
9
10
  require "fileutils"
10
11
  require "tmpdir"
@@ -437,6 +438,7 @@ module Clacky
437
438
  when ["PATCH", "/api/config/settings"] then api_update_settings(req, res)
438
439
  when ["POST", "/api/config/models"] then api_add_model(req, res)
439
440
  when ["POST", "/api/config/test"] then api_test_config(req, res)
441
+ when ["POST", "/api/config/media/test"] then api_test_media_config(req, res)
440
442
  when ["GET", "/api/config/media"] then api_get_media_config(res)
441
443
  when ["GET", "/api/providers"] then api_list_providers(res)
442
444
  when ["GET", "/api/onboard/status"] then api_onboard_status(res)
@@ -552,6 +554,9 @@ module Clacky
552
554
  elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/working_dir$})
553
555
  session_id = path.sub("/api/sessions/", "").sub("/working_dir", "")
554
556
  api_change_session_working_dir(session_id, req, res)
557
+ elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/fork$})
558
+ session_id = path.sub("/api/sessions/", "").sub("/fork", "")
559
+ api_fork_session(session_id, req, res)
555
560
  elsif method == "DELETE" && path.start_with?("/api/sessions/")
556
561
  session_id = path.sub("/api/sessions/", "")
557
562
  api_delete_session(session_id, res)
@@ -610,6 +615,7 @@ module Clacky
610
615
  limit = [query["limit"].to_i.then { |n| n > 0 ? n : 20 }, 50].min
611
616
  before = query["before"].to_s.strip.then { |v| v.empty? ? nil : v }
612
617
  q = query["q"].to_s.strip.then { |v| v.empty? ? nil : v }
618
+ q_scope = query["q_scope"].to_s.strip.then { |v| %w[name content].include?(v) ? v : "name" }
613
619
  date = query["date"].to_s.strip.then { |v| v.empty? ? nil : v }
614
620
  type = query["type"].to_s.strip.then { |v| v.empty? ? nil : v }
615
621
  # Backward-compat: ?source=<x> and ?profile=coding → type
@@ -620,7 +626,7 @@ module Clacky
620
626
  # `registry.list` always returns ALL matching pinned rows first (on the
621
627
  # first page; `before` == nil), followed by non-pinned rows up to `limit+1`.
622
628
  # So has_more is determined by whether the non-pinned section overflowed.
623
- sessions = @registry.list(limit: limit + 1, before: before, q: q, date: date, type: type)
629
+ sessions = @registry.list(limit: limit + 1, before: before, q: q, q_scope: q_scope, date: date, type: type)
624
630
 
625
631
  # Split pinned vs non-pinned to apply has_more only to the non-pinned tail.
626
632
  pinned_part, non_pinned_part = sessions.partition { |s| s[:pinned] }
@@ -935,6 +941,9 @@ module Clacky
935
941
  api_key_masked: entry ? mask_api_key(entry["api_key"]) : nil,
936
942
  provider: state["provider"],
937
943
  available: state["available"],
944
+ aliases: state["aliases"] || {},
945
+ stale: state["stale"] || false,
946
+ requested_model: state["requested_model"],
938
947
  configured: state["configured"]
939
948
  }
940
949
  end
@@ -952,7 +961,8 @@ module Clacky
952
961
  defaults[t] = {
953
962
  provider: provider_id,
954
963
  model: provider_id ? Clacky::Providers.default_media_model(provider_id, t) : nil,
955
- available: provider_id ? Clacky::Providers.media_models(provider_id, t) : []
964
+ available: provider_id ? Clacky::Providers.media_models(provider_id, t) : [],
965
+ aliases: provider_id ? Clacky::Providers.media_model_aliases(provider_id, t) : {}
956
966
  }
957
967
  end
958
968
 
@@ -965,6 +975,85 @@ module Clacky
965
975
  # off / auto — remove any custom entry; "auto" lets the virtual
966
976
  # derivation in AgentConfig#find_model_by_type take over.
967
977
  # custom — replace any existing custom entry with the supplied fields.
978
+ # POST /api/config/media/test
979
+ # Body: { kind, source, model, base_url, api_key }
980
+ # Lightweight preflight: GET <base_url>/models to verify connectivity,
981
+ # auth, and that the requested model is exposed by the endpoint.
982
+ # No image is generated — zero cost, sub-second.
983
+ def api_test_media_config(req, res)
984
+ body = parse_json_body(req) || {}
985
+ kind = body["kind"].to_s
986
+ return json_response(res, 422, { error: "invalid kind" }) unless %w[image video audio].include?(kind)
987
+ return json_response(res, 422, { error: "only image kind supported" }) unless kind == "image"
988
+
989
+ api_key = body["api_key"].to_s
990
+ if api_key.empty? || api_key.include?("****")
991
+ existing = @agent_config.find_model_by_type(kind) || {}
992
+ api_key = existing["api_key"].to_s
993
+ end
994
+
995
+ model = body["model"].to_s.strip
996
+ base_url = body["base_url"].to_s.strip
997
+
998
+ if model.empty? || base_url.empty? || api_key.empty?
999
+ return json_response(res, 200, { ok: false, message: "model, base_url, api_key are required" })
1000
+ end
1001
+
1002
+ result = preflight_media_endpoint(base_url: base_url, api_key: api_key, model: model)
1003
+ json_response(res, 200, result)
1004
+ rescue => e
1005
+ json_response(res, 200, { ok: false, message: e.message })
1006
+ end
1007
+
1008
+ private def preflight_media_endpoint(base_url:, api_key:, model:)
1009
+ url = "#{base_url.chomp("/")}/models"
1010
+ conn = Faraday.new(url: url) do |f|
1011
+ f.options.timeout = 10
1012
+ f.options.open_timeout = 5
1013
+ end
1014
+
1015
+ response =
1016
+ begin
1017
+ conn.get do |req|
1018
+ req.headers["Authorization"] = "Bearer #{api_key}"
1019
+ req.headers["Accept"] = "application/json"
1020
+ end
1021
+ rescue Faraday::Error => e
1022
+ return { ok: false, message: "Network error: #{e.message}" }
1023
+ end
1024
+
1025
+ case response.status
1026
+ when 401, 403
1027
+ return { ok: false, message: "Authentication failed (HTTP #{response.status}). Check API key." }
1028
+ when 404
1029
+ return { ok: false, message: "Endpoint not found at #{url}. Check Base URL." }
1030
+ end
1031
+
1032
+ unless response.success?
1033
+ return { ok: false, message: "HTTP #{response.status}: #{response.body.to_s[0, 200]}" }
1034
+ end
1035
+
1036
+ body = JSON.parse(response.body) rescue nil
1037
+ ids =
1038
+ if body.is_a?(Hash) && body["data"].is_a?(Array)
1039
+ body["data"].map { |m| m["id"].to_s }
1040
+ elsif body.is_a?(Array)
1041
+ body.map { |m| m["id"].to_s }
1042
+ else
1043
+ []
1044
+ end
1045
+
1046
+ if ids.empty?
1047
+ return { ok: true, message: "Connected (model list unavailable; cannot verify model id)" }
1048
+ end
1049
+
1050
+ if ids.include?(model)
1051
+ { ok: true, message: "Connected. Model '#{model}' is available." }
1052
+ else
1053
+ { ok: false, message: "Connected, but model '#{model}' not found on this endpoint." }
1054
+ end
1055
+ end
1056
+
968
1057
  def api_update_media_config(kind, req, res)
969
1058
  body = parse_json_body(req) || {}
970
1059
  source = body["source"].to_s
@@ -974,7 +1063,23 @@ module Clacky
974
1063
 
975
1064
  @agent_config.models.reject! { |m| m["type"] == kind }
976
1065
 
977
- if source == "custom"
1066
+ case source
1067
+ when "off"
1068
+ @agent_config.models << {
1069
+ "id" => SecureRandom.uuid,
1070
+ "type" => kind,
1071
+ "disabled" => true
1072
+ }
1073
+ when "auto"
1074
+ override = body["model"].to_s.strip
1075
+ unless override.empty?
1076
+ @agent_config.models << {
1077
+ "id" => SecureRandom.uuid,
1078
+ "type" => kind,
1079
+ "model" => override
1080
+ }
1081
+ end
1082
+ when "custom"
978
1083
  model = body["model"].to_s.strip
979
1084
  base_url = body["base_url"].to_s.strip
980
1085
  api_key = body["api_key"].to_s
@@ -4201,6 +4306,15 @@ module Clacky
4201
4306
  json_response(res, 500, { error: e.message })
4202
4307
  end
4203
4308
 
4309
+ def api_fork_session(session_id, req, res)
4310
+ fork_data = @session_manager.fork(session_id)
4311
+ return json_response(res, 404, { error: "Session not found" }) unless fork_data
4312
+
4313
+ fork_id = fork_data[:session_id]
4314
+ broadcast_session_update(fork_id)
4315
+ json_response(res, 201, { session: @registry.snapshot(fork_id) })
4316
+ end
4317
+
4204
4318
  def api_delete_session(session_id, res)
4205
4319
  # A session exists if it's either in the runtime registry OR on disk.
4206
4320
  # Old sessions that were never restored into memory this server run
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "set"
4
+
3
5
  module Clacky
4
6
  module Server
5
7
  # SessionRegistry is the single authoritative source for session state.
@@ -158,7 +160,7 @@ module Clacky
158
160
  # [ ...all_pinned_matching (newest-first), ...non_pinned (newest-first, limited) ]
159
161
  #
160
162
  # source and profile are orthogonal — either can be nil independently.
161
- def list(limit: nil, before: nil, q: nil, date: nil, type: nil, include_pinned: true)
163
+ def list(limit: nil, before: nil, q: nil, q_scope: "name", date: nil, type: nil, include_pinned: true)
162
164
  return [] unless @session_manager
163
165
 
164
166
  live = @mutex.synchronize do
@@ -195,13 +197,25 @@ module Clacky
195
197
  # ── date filter (YYYY-MM-DD, matches created_at prefix) ──────────────
196
198
  all = all.select { |s| s[:created_at].to_s.start_with?(date) } if date
197
199
 
198
- # ── name / id search ─────────────────────────────────────────────────
200
+ # ── name / id / content search ───────────────────────────────────────
201
+ content_snippets = nil
199
202
  if q && !q.empty?
200
- q_down = q.downcase
201
- all = all.select { |s|
202
- (s[:name] || "").downcase.include?(q_down) ||
203
- (s[:session_id] || "").downcase.include?(q_down)
204
- }
203
+ if q_scope == "content"
204
+ content_snippets = @session_manager.search_content(q)
205
+ if content_snippets.empty?
206
+ all = []
207
+ else
208
+ prefix_set = content_snippets.keys.to_set
209
+ all = all.select { |s| prefix_set.include?((s[:session_id] || "")[0, 8]) }
210
+ end
211
+ else
212
+ q_down = q.downcase
213
+ id_match_eligible = q_down.match?(/\A[0-9a-f]{6,}\z/)
214
+ all = all.select { |s|
215
+ (s[:name] || "").downcase.include?(q_down) ||
216
+ (id_match_eligible && (s[:session_id] || "").downcase.include?(q_down))
217
+ }
218
+ end
205
219
  end
206
220
 
207
221
  # ── Split pinned vs non-pinned BEFORE applying `before`/`limit`.
@@ -223,7 +237,15 @@ module Clacky
223
237
 
224
238
  ordered = pinned_section + non_pinned
225
239
 
226
- ordered.map { |s| build_enriched_row(s, live[s[:session_id]]) }
240
+ ordered.map do |s|
241
+ row = build_enriched_row(s, live[s[:session_id]])
242
+ if content_snippets
243
+ short = (s[:session_id] || "")[0, 8]
244
+ snip = content_snippets[short]
245
+ row[:search_snippet] = snip if snip
246
+ end
247
+ row
248
+ end
227
249
  end
228
250
 
229
251
  # Return the same enriched hash that a `list` row would produce, for a
@@ -171,7 +171,6 @@ module Clacky
171
171
 
172
172
  def show_diff(old_content, new_content, max_lines: 50)
173
173
  emit("diff", old_size: old_content.bytesize, new_size: new_content.bytesize)
174
- # Diffs are too verbose for IM — intentionally not forwarded
175
174
  end
176
175
 
177
176
  def show_token_usage(token_data)
@@ -210,6 +209,27 @@ module Clacky
210
209
  forward_to_subscribers { |sub| sub.show_warning(message) }
211
210
  end
212
211
 
212
+ # === Phase grouping ===
213
+
214
+ def phase_start(kind:, label: nil)
215
+ pid = SecureRandom.uuid
216
+ Thread.current[:clacky_phase_id] = pid
217
+ # Emit without auto-injection (the start event itself defines the phase)
218
+ event = { type: "phase_start", session_id: @session_id, phase_id: pid, kind: kind.to_s }
219
+ event[:label] = label if label
220
+ @broadcaster.call(@session_id, event)
221
+ pid
222
+ end
223
+
224
+ def phase_end(phase_id, summary: nil)
225
+ # Clear thread-local before emitting end so the end event itself
226
+ # doesn't get tagged with the phase_id it's closing.
227
+ Thread.current[:clacky_phase_id] = nil if Thread.current[:clacky_phase_id] == phase_id
228
+ event = { type: "phase_end", session_id: @session_id, phase_id: phase_id }
229
+ event[:summary] = summary if summary
230
+ @broadcaster.call(@session_id, event)
231
+ end
232
+
213
233
  def show_error(message, code: nil, top_up_url: nil)
214
234
  payload = { message: message }
215
235
  payload[:code] = code if code
@@ -414,6 +434,9 @@ module Clacky
414
434
 
415
435
  def emit(type, **data)
416
436
  event = { type: type, session_id: @session_id }.merge(data)
437
+ if (pid = Thread.current[:clacky_phase_id]) && !data.key?(:phase_id)
438
+ event[:phase_id] = pid
439
+ end
417
440
  @broadcaster.call(@session_id, event)
418
441
  end
419
442