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.
- checksums.yaml +4 -4
- data/.clacky/skills/gem-release/SKILL.md +1 -1
- data/.clacky/skills/gem-release/scripts/release.sh +4 -1
- data/CHANGELOG.md +23 -0
- data/lib/clacky/agent/llm_caller.rb +40 -25
- data/lib/clacky/agent/memory_updater.rb +12 -0
- data/lib/clacky/agent/skill_auto_creator.rb +7 -4
- data/lib/clacky/agent/skill_evolution.rb +23 -5
- data/lib/clacky/agent/skill_manager.rb +86 -1
- data/lib/clacky/agent/skill_reflector.rb +18 -23
- data/lib/clacky/agent.rb +9 -1
- data/lib/clacky/agent_config.rb +59 -15
- data/lib/clacky/cli.rb +55 -0
- data/lib/clacky/default_skills/persist-memory/SKILL.md +4 -3
- data/lib/clacky/default_skills/search-skills/SKILL.md +61 -0
- data/lib/clacky/idle_compression_timer.rb +1 -1
- data/lib/clacky/message_format/open_ai.rb +7 -1
- data/lib/clacky/openai_stream_aggregator.rb +4 -1
- data/lib/clacky/providers.rb +40 -12
- data/lib/clacky/server/http_server.rb +117 -3
- data/lib/clacky/server/session_registry.rb +30 -8
- data/lib/clacky/server/web_ui_controller.rb +24 -1
- data/lib/clacky/session_manager.rb +120 -0
- data/lib/clacky/tools/web_search.rb +59 -8
- data/lib/clacky/ui2/layout_manager.rb +15 -5
- data/lib/clacky/ui2/progress_handle.rb +7 -1
- data/lib/clacky/ui2/ui_controller.rb +27 -0
- data/lib/clacky/ui_interface.rb +22 -0
- data/lib/clacky/utils/model_pricing.rb +96 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +209 -4
- data/lib/clacky/web/app.js +6 -5
- data/lib/clacky/web/i18n.js +18 -4
- data/lib/clacky/web/index.html +2 -1
- data/lib/clacky/web/sessions.js +408 -80
- data/lib/clacky/web/settings.js +213 -51
- data/lib/clacky/web/skills.js +5 -14
- data/lib/clacky/web/utils.js +57 -0
- data/lib/clacky/web/ws-dispatcher.js +136 -0
- 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
|
-
##
|
|
51
|
+
## Guidelines
|
|
52
52
|
|
|
53
|
-
-
|
|
54
|
-
- If
|
|
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 =
|
|
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)
|
data/lib/clacky/providers.rb
CHANGED
|
@@ -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
|
|
48
|
-
#
|
|
49
|
-
#
|
|
48
|
+
# provider class handles them. `or-` prefix is a routing alias
|
|
49
|
+
# only — the 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
|
|
69
|
-
# they're intentionally not listed here
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
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
|
|