openclacky 1.5.2 → 1.5.4
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/.dockerignore +13 -0
- data/CHANGELOG.md +50 -0
- data/Dockerfile +21 -1
- data/README.md +26 -7
- data/README_CN.md +26 -7
- data/README_JA.md +26 -7
- data/lib/clacky/agent/message_compressor.rb +4 -29
- data/lib/clacky/agent/message_compressor_helper.rb +13 -15
- data/lib/clacky/agent/session_serializer.rb +6 -0
- data/lib/clacky/agent.rb +13 -1
- data/lib/clacky/agent_config.rb +42 -11
- data/lib/clacky/brand_config.rb +32 -12
- data/lib/clacky/client.rb +7 -0
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +15 -0
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +2 -6
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +11 -4
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +5 -1
- data/lib/clacky/default_parsers/xlsx_parser.py +66 -0
- data/lib/clacky/extension/api_extension.rb +3 -2
- data/lib/clacky/message_format/anthropic.rb +27 -1
- data/lib/clacky/message_format/open_ai.rb +92 -4
- data/lib/clacky/providers.rb +67 -14
- data/lib/clacky/server/http_server.rb +361 -36
- data/lib/clacky/server/project_manager.rb +150 -0
- data/lib/clacky/server/server_master.rb +7 -0
- data/lib/clacky/server/session_registry.rb +14 -1
- data/lib/clacky/session_manager.rb +5 -4
- data/lib/clacky/tools/file_reader.rb +2 -1
- data/lib/clacky/utils/model_pricing.rb +77 -18
- data/lib/clacky/utils/parser_manager.rb +115 -7
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +1053 -143
- data/lib/clacky/web/app.js +19 -5
- data/lib/clacky/web/components/code-editor.js +50 -12
- data/lib/clacky/web/components/notify.js +139 -22
- data/lib/clacky/web/core/aside.js +21 -0
- data/lib/clacky/web/features/billing/view.js +2 -1
- data/lib/clacky/web/features/brand/store.js +3 -1
- data/lib/clacky/web/features/brand/view.js +29 -5
- data/lib/clacky/web/features/extensions/store.js +15 -6
- data/lib/clacky/web/features/extensions/view.js +8 -4
- data/lib/clacky/web/features/new-session/view.js +2 -2
- data/lib/clacky/web/features/trash/store.js +23 -9
- data/lib/clacky/web/features/trash/view.js +51 -0
- data/lib/clacky/web/features/workspace/store.js +9 -0
- data/lib/clacky/web/features/workspace/view.js +8 -1
- data/lib/clacky/web/i18n.js +99 -4
- data/lib/clacky/web/index.html +70 -17
- data/lib/clacky/web/projects.js +1143 -0
- data/lib/clacky/web/sessions.js +318 -537
- data/lib/clacky/web/settings.js +146 -7
- data/lib/clacky/web/ws-dispatcher.js +59 -1
- data/scripts/build/src/install_system_deps.sh.cc +11 -3
- data/scripts/install_system_deps.sh +11 -3
- metadata +5 -2
- data/lib/clacky/default_parsers/xlsx_parser.rb +0 -121
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
module Clacky
|
|
9
|
+
module Server
|
|
10
|
+
# ProjectManager handles CRUD for "projects" — named groups that sessions
|
|
11
|
+
# can be assigned to, analogous to ChatGPT / Codex Projects.
|
|
12
|
+
#
|
|
13
|
+
# Storage: ~/.clacky/projects.json
|
|
14
|
+
# Each project entry shape:
|
|
15
|
+
# {
|
|
16
|
+
# id: String (8-char hex),
|
|
17
|
+
# name: String,
|
|
18
|
+
# description: String | null,
|
|
19
|
+
# color: String | null (e.g. "#6366f1"),
|
|
20
|
+
# icon: String | null (e.g. "folder", "code"),
|
|
21
|
+
# working_dir: String | null (absolute path; new sessions inherit this),
|
|
22
|
+
# created_at: ISO8601,
|
|
23
|
+
# updated_at: ISO8601
|
|
24
|
+
# }
|
|
25
|
+
#
|
|
26
|
+
# Thread-safety: a Mutex guards every read/write.
|
|
27
|
+
class ProjectManager
|
|
28
|
+
PROJECTS_FILE = File.join(Dir.home, ".clacky", "projects.json")
|
|
29
|
+
|
|
30
|
+
def initialize(projects_file: nil)
|
|
31
|
+
@projects_file = projects_file || PROJECTS_FILE
|
|
32
|
+
@mutex = Mutex.new
|
|
33
|
+
@cache = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Return all projects sorted by created_at ascending (oldest first).
|
|
37
|
+
def all
|
|
38
|
+
@mutex.synchronize { load_projects.dup }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Find a single project by id. Returns nil if not found.
|
|
42
|
+
def find(id)
|
|
43
|
+
@mutex.synchronize { load_projects.find { |p| p[:id] == id.to_s } }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Create a new project. Returns the created project hash.
|
|
47
|
+
# Required: name. Optional: description, color, icon, working_dir.
|
|
48
|
+
def create(name:, description: nil, color: nil, icon: nil, working_dir: nil)
|
|
49
|
+
raise ArgumentError, "name is required" if name.to_s.strip.empty?
|
|
50
|
+
|
|
51
|
+
now = Time.now.iso8601
|
|
52
|
+
project = {
|
|
53
|
+
id: SecureRandom.hex(4),
|
|
54
|
+
name: name.to_s.strip,
|
|
55
|
+
description: optional_str(description),
|
|
56
|
+
color: optional_str(color),
|
|
57
|
+
icon: optional_str(icon),
|
|
58
|
+
working_dir: optional_str(working_dir),
|
|
59
|
+
created_at: now,
|
|
60
|
+
updated_at: now
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@mutex.synchronize do
|
|
64
|
+
projects = load_projects
|
|
65
|
+
projects << project
|
|
66
|
+
save_projects(projects)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
project
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Update an existing project. Only explicitly passed (non-sentinel) keys
|
|
73
|
+
# are changed. Pass description: nil or color: nil or icon: nil or working_dir: nil to clear those fields.
|
|
74
|
+
# Returns updated project hash, or nil if not found.
|
|
75
|
+
def update(id, name: :__unset, description: :__unset, color: :__unset, icon: :__unset, working_dir: :__unset)
|
|
76
|
+
@mutex.synchronize do
|
|
77
|
+
projects = load_projects
|
|
78
|
+
project = projects.find { |p| p[:id] == id.to_s }
|
|
79
|
+
return nil unless project
|
|
80
|
+
|
|
81
|
+
unless name == :__unset
|
|
82
|
+
raise ArgumentError, "name cannot be empty" if name.to_s.strip.empty?
|
|
83
|
+
|
|
84
|
+
project[:name] = name.to_s.strip
|
|
85
|
+
end
|
|
86
|
+
project[:description] = optional_str(description) unless description == :__unset
|
|
87
|
+
project[:color] = optional_str(color) unless color == :__unset
|
|
88
|
+
project[:icon] = optional_str(icon) unless icon == :__unset
|
|
89
|
+
project[:working_dir] = optional_str(working_dir) unless working_dir == :__unset
|
|
90
|
+
project[:updated_at] = Time.now.iso8601
|
|
91
|
+
|
|
92
|
+
save_projects(projects)
|
|
93
|
+
project.dup
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Delete a project by id. Returns true if found and deleted, false otherwise.
|
|
98
|
+
# NOTE: caller is responsible for clearing project_id on orphaned sessions.
|
|
99
|
+
def delete(id)
|
|
100
|
+
@mutex.synchronize do
|
|
101
|
+
projects = load_projects
|
|
102
|
+
before = projects.size
|
|
103
|
+
projects.reject! { |p| p[:id] == id.to_s }
|
|
104
|
+
return false if projects.size == before
|
|
105
|
+
|
|
106
|
+
save_projects(projects)
|
|
107
|
+
true
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# ── Private helpers ───────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
# Load from disk (or return []). NOT mutex-protected — must be called
|
|
114
|
+
# with @mutex held. Results cached until next save_projects.
|
|
115
|
+
private def load_projects
|
|
116
|
+
return @cache if @cache
|
|
117
|
+
|
|
118
|
+
unless File.exist?(@projects_file)
|
|
119
|
+
@cache = []
|
|
120
|
+
return @cache
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
begin
|
|
124
|
+
raw = JSON.parse(File.read(@projects_file), symbolize_names: true)
|
|
125
|
+
@cache = Array(raw).sort_by { |p| p[:created_at].to_s }
|
|
126
|
+
rescue JSON::ParserError
|
|
127
|
+
@cache = []
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
@cache
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Persist to disk and refresh cache. NOT mutex-protected.
|
|
134
|
+
private def save_projects(projects)
|
|
135
|
+
FileUtils.mkdir_p(File.dirname(@projects_file))
|
|
136
|
+
File.write(@projects_file, JSON.pretty_generate(projects))
|
|
137
|
+
FileUtils.chmod(0o600, @projects_file)
|
|
138
|
+
@cache = projects
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Convert blank / nil values to nil for optional string fields.
|
|
142
|
+
private def optional_str(value)
|
|
143
|
+
return nil if value.nil?
|
|
144
|
+
|
|
145
|
+
s = value.to_s.strip
|
|
146
|
+
s.empty? ? nil : s
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -4,6 +4,8 @@ require "socket"
|
|
|
4
4
|
require "tmpdir"
|
|
5
5
|
require_relative "../banner"
|
|
6
6
|
require_relative "../version"
|
|
7
|
+
require_relative "../agent_config"
|
|
8
|
+
require_relative "../platform_http_client"
|
|
7
9
|
|
|
8
10
|
module Clacky
|
|
9
11
|
module Server
|
|
@@ -129,6 +131,11 @@ module Clacky
|
|
|
129
131
|
"CLACKY_INHERIT_FD" => @socket.fileno.to_s,
|
|
130
132
|
"CLACKY_MASTER_PID" => Process.pid.to_s
|
|
131
133
|
}
|
|
134
|
+
selected_source = Clacky::AgentConfig.load.clacky_license_server.to_s.strip
|
|
135
|
+
unless selected_source.empty?
|
|
136
|
+
env["CLACKY_LICENSE_SERVER"] =
|
|
137
|
+
selected_source == Clacky::PlatformHttpClient::PRIMARY_HOST ? nil : selected_source
|
|
138
|
+
end
|
|
132
139
|
# Keep the socket fd open across exec — mark it as non-CLOEXEC.
|
|
133
140
|
@socket.close_on_exec = false
|
|
134
141
|
|
|
@@ -203,7 +203,7 @@ module Clacky
|
|
|
203
203
|
# [ ...all_pinned_matching (newest-first), ...non_pinned (newest-first, limited) ]
|
|
204
204
|
#
|
|
205
205
|
# source and profile are orthogonal — either can be nil independently.
|
|
206
|
-
def list(limit: nil, before: nil, q: nil, q_scope: "name", date: nil, type: nil, exclude_type: nil, include_pinned: true)
|
|
206
|
+
def list(limit: nil, before: nil, q: nil, q_scope: "name", date: nil, type: nil, exclude_type: nil, include_pinned: true, project_id: nil, exclude_project: false, include_hidden: false)
|
|
207
207
|
return [] unless @session_manager
|
|
208
208
|
|
|
209
209
|
live = @mutex.synchronize do
|
|
@@ -227,6 +227,10 @@ module Clacky
|
|
|
227
227
|
|
|
228
228
|
all = @session_manager.all_sessions # already sorted newest-first
|
|
229
229
|
|
|
230
|
+
# Hidden sessions (extension-managed) are excluded from the list by
|
|
231
|
+
# default; callers that need them pass include_hidden: true.
|
|
232
|
+
all = all.reject { |s| s[:hidden] } unless include_hidden
|
|
233
|
+
|
|
230
234
|
# ── type filter (replaces old source/profile split) ──────────────────
|
|
231
235
|
# type=coding → agent_profile == "coding"
|
|
232
236
|
# type=manual/cron/channel/setup → source match (profile=general implied)
|
|
@@ -244,6 +248,13 @@ module Clacky
|
|
|
244
248
|
all = all.reject { |s| excluded.include?(s_source(s)) }
|
|
245
249
|
end
|
|
246
250
|
|
|
251
|
+
# ── project_id filter — when provided, return ONLY sessions belonging
|
|
252
|
+
# to the given project (no limit applied by default).
|
|
253
|
+
all = all.select { |s| s[:project_id].to_s == project_id.to_s } if project_id
|
|
254
|
+
|
|
255
|
+
# ── exclude_project filter — exclude sessions that belong to any project.
|
|
256
|
+
all = all.reject { |s| s[:project_id].to_s.strip != "" } if exclude_project
|
|
257
|
+
|
|
247
258
|
# ── date filter (YYYY-MM-DD, matches created_at prefix) ──────────────
|
|
248
259
|
all = all.select { |s| s[:created_at].to_s.start_with?(date) } if date
|
|
249
260
|
|
|
@@ -367,6 +378,7 @@ module Clacky
|
|
|
367
378
|
reasoning_effort: ls&.dig(:reasoning_effort) || s.dig(:config, :reasoning_effort),
|
|
368
379
|
pinned: s[:pinned] || false,
|
|
369
380
|
channel_info: s[:channel_info],
|
|
381
|
+
project_id: s[:project_id],
|
|
370
382
|
}
|
|
371
383
|
end
|
|
372
384
|
|
|
@@ -550,6 +562,7 @@ module Clacky
|
|
|
550
562
|
agent_profile: agent.agent_profile.name,
|
|
551
563
|
pinned: agent.pinned || false,
|
|
552
564
|
latest_latency: agent.latest_latency,
|
|
565
|
+
project_id: agent.project_id,
|
|
553
566
|
}
|
|
554
567
|
end
|
|
555
568
|
end
|
|
@@ -64,6 +64,7 @@ module Clacky
|
|
|
64
64
|
forked[:created_at] = Time.now.iso8601
|
|
65
65
|
forked[:updated_at] = Time.now.iso8601
|
|
66
66
|
forked[:pinned] = false
|
|
67
|
+
forked[:hidden] = false
|
|
67
68
|
forked[:name] = "#{original[:name] || "Unnamed session"} (copy)"
|
|
68
69
|
forked[:stats] = (original[:stats] || {}).merge(
|
|
69
70
|
total_tasks: 0, total_iterations: 0, total_cost_usd: 0.0,
|
|
@@ -328,12 +329,12 @@ module Clacky
|
|
|
328
329
|
deleted
|
|
329
330
|
end
|
|
330
331
|
|
|
331
|
-
# Keep only the most recent N non-pinned sessions by created_at;
|
|
332
|
-
# are soft-deleted (moved to the session trash, recoverable). Pinned
|
|
333
|
-
# sessions are never deleted and do not count toward the cap.
|
|
332
|
+
# Keep only the most recent N non-pinned, non-hidden sessions by created_at;
|
|
333
|
+
# the rest are soft-deleted (moved to the session trash, recoverable). Pinned
|
|
334
|
+
# and hidden sessions are never deleted and do not count toward the cap.
|
|
334
335
|
# Returns count of soft-deleted sessions.
|
|
335
336
|
def cleanup_by_count(keep:, keep_cron: 200)
|
|
336
|
-
non_pinned = all_sessions.reject { |s| s[:pinned] } # already sorted newest-first
|
|
337
|
+
non_pinned = all_sessions.reject { |s| s[:pinned] || s[:hidden] } # already sorted newest-first
|
|
337
338
|
|
|
338
339
|
cron, regular = non_pinned.partition { |s| s[:source].to_s == "cron" }
|
|
339
340
|
|
|
@@ -402,8 +402,9 @@ module Clacky
|
|
|
402
402
|
message_lines << "Parser error: #{ref.parse_error}" if ref.parse_error
|
|
403
403
|
if ref.parser_path
|
|
404
404
|
expected_preview = "#{path}.preview.md"
|
|
405
|
+
interp = Utils::ParserManager.interpreter_for(File.basename(ref.parser_path))
|
|
405
406
|
message_lines << "Parser script: #{ref.parser_path}"
|
|
406
|
-
message_lines << "To fix: edit the parser, then run:
|
|
407
|
+
message_lines << "To fix: edit the parser, then run: #{interp} #{ref.parser_path} #{path} > #{expected_preview}"
|
|
407
408
|
message_lines << "After a successful parse, re-run file_reader on this file."
|
|
408
409
|
end
|
|
409
410
|
{
|
|
@@ -23,6 +23,39 @@ module Clacky
|
|
|
23
23
|
}
|
|
24
24
|
},
|
|
25
25
|
|
|
26
|
+
# Claude Sonnet 5 / Opus 5 (2026) — flat rate, no 200K tier (matches
|
|
27
|
+
# llm_proxy's costMap: single price regardless of prompt length).
|
|
28
|
+
# Source: openclacky-platform/llm_proxy/internal/proxy/proxy.go
|
|
29
|
+
"claude-sonnet-5" => {
|
|
30
|
+
input: {
|
|
31
|
+
default: 3.00, # $3/MTok, same for all tiers
|
|
32
|
+
over_200k: 3.00
|
|
33
|
+
},
|
|
34
|
+
output: {
|
|
35
|
+
default: 15.00, # $15/MTok, same for all tiers
|
|
36
|
+
over_200k: 15.00
|
|
37
|
+
},
|
|
38
|
+
cache: {
|
|
39
|
+
write: 3.75, # $3.75/MTok cache write
|
|
40
|
+
read: 0.30 # $0.30/MTok cache read
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
"claude-opus-5" => {
|
|
45
|
+
input: {
|
|
46
|
+
default: 5.00, # $5/MTok, same for all tiers
|
|
47
|
+
over_200k: 5.00
|
|
48
|
+
},
|
|
49
|
+
output: {
|
|
50
|
+
default: 25.00, # $25/MTok, same for all tiers
|
|
51
|
+
over_200k: 25.00
|
|
52
|
+
},
|
|
53
|
+
cache: {
|
|
54
|
+
write: 6.25, # $6.25/MTok cache write
|
|
55
|
+
read: 0.50 # $0.50/MTok cache read
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
|
|
26
59
|
"claude-opus-4.5" => {
|
|
27
60
|
input: {
|
|
28
61
|
default: 5.00, # $5/MTok for prompts ≤ 200K tokens
|
|
@@ -472,6 +505,12 @@ module Clacky
|
|
|
472
505
|
# endpoints don't charge separately for cache writes (Z.ai's page lists
|
|
473
506
|
# "Cached Input Storage: Limited-time Free"), so bill writes at the
|
|
474
507
|
# regular input miss rate for safe "displayed ≤ actual" behaviour.
|
|
508
|
+
"glm-5.2" => {
|
|
509
|
+
input: { default: 1.40, over_200k: 1.40 },
|
|
510
|
+
output: { default: 4.40, over_200k: 4.40 },
|
|
511
|
+
cache: { write: 1.40, read: 0.26 }
|
|
512
|
+
},
|
|
513
|
+
|
|
475
514
|
"glm-5.1" => {
|
|
476
515
|
input: { default: 1.40, over_200k: 1.40 },
|
|
477
516
|
output: { default: 4.40, over_200k: 4.40 },
|
|
@@ -506,36 +545,45 @@ module Clacky
|
|
|
506
545
|
},
|
|
507
546
|
|
|
508
547
|
# MiniMax — USD per 1M tokens.
|
|
509
|
-
# Source: https://platform.
|
|
510
|
-
# MiniMax pricing is identical across
|
|
511
|
-
# (.io)
|
|
512
|
-
# DeepSeek/Kimi/GLM: bill writes at the
|
|
513
|
-
# usage responses from MiniMax don't
|
|
514
|
-
# cache_creation_input_tokens field, so a
|
|
515
|
-
# dead code in practice).
|
|
516
|
-
# Note: providers.rb uses the capitalised "MiniMax-
|
|
548
|
+
# Source: https://platform.minimax.io/docs/api-reference/api-overview
|
|
549
|
+
# (Pay-as-You-Go). MiniMax pricing is identical across the international
|
|
550
|
+
# (.io) and mainland China (.com) endpoints per the team's verification.
|
|
551
|
+
# Same cache-write convention as DeepSeek/Kimi/GLM: bill writes at the
|
|
552
|
+
# input miss rate (OpenAI-compatible usage responses from MiniMax don't
|
|
553
|
+
# reliably carry a separate cache_creation_input_tokens field, so a
|
|
554
|
+
# distinct write rate would be dead code in practice).
|
|
555
|
+
# Note: providers.rb uses the capitalised "MiniMax-M*" model id, but
|
|
517
556
|
# the pricing table keys are lowercased to stay consistent with the
|
|
518
557
|
# rest of this file; normalize_model_name() lowercases incoming model
|
|
519
558
|
# names before lookup.
|
|
559
|
+
|
|
560
|
+
# M2.5 — high-throughput text model, optimised for coding and agent tasks
|
|
561
|
+
# (40,960-token context window). Listed at Pay-as-You-Go prices.
|
|
562
|
+
# Source: https://www.minimax.io/models/text (MiniMax-M2.5 product page).
|
|
520
563
|
"minimax-m2.5" => {
|
|
521
564
|
input: { default: 0.30, over_200k: 0.30 },
|
|
522
565
|
output: { default: 1.20, over_200k: 1.20 },
|
|
523
566
|
cache: { write: 0.30, read: 0.03 }
|
|
524
567
|
},
|
|
525
568
|
|
|
526
|
-
# M3 (released 2026-06-01) is MiniMax's multimodal flagship
|
|
527
|
-
#
|
|
528
|
-
#
|
|
529
|
-
#
|
|
530
|
-
#
|
|
531
|
-
#
|
|
532
|
-
#
|
|
569
|
+
# M3 (released 2026-06-01) is MiniMax's multimodal flagship (image +
|
|
570
|
+
# video input, 1,000,000-token context window). Official pricing is
|
|
571
|
+
# tiered by context length (≤512K vs 512K–1M); per the project's
|
|
572
|
+
# "displayed ≤ actual" convention we record only the lowest (≤512K)
|
|
573
|
+
# tier as a flat rate — the global TIERED_PRICING_THRESHOLD is 200K,
|
|
574
|
+
# so applying the 512K–1M rate to the 200K–512K band would over-charge.
|
|
575
|
+
# Listed at original (non-promotional) prices: input $0.60, output
|
|
576
|
+
# $2.40, cache read $0.12 per 1M tokens; cache write is billed at the
|
|
577
|
+
# input miss rate.
|
|
533
578
|
"minimax-m3" => {
|
|
534
579
|
input: { default: 0.60, over_200k: 0.60 },
|
|
535
580
|
output: { default: 2.40, over_200k: 2.40 },
|
|
536
581
|
cache: { write: 0.60, read: 0.12 }
|
|
537
582
|
},
|
|
538
583
|
|
|
584
|
+
# M2.7 — text-only model (204,800-token context window). Listed at
|
|
585
|
+
# original (non-promotional) prices: input $0.30, output $1.20, cache
|
|
586
|
+
# read $0.06 per 1M tokens; cache write billed at the input miss rate.
|
|
539
587
|
"minimax-m2.7" => {
|
|
540
588
|
input: { default: 0.30, over_200k: 0.30 },
|
|
541
589
|
output: { default: 1.20, over_200k: 1.20 },
|
|
@@ -735,6 +783,15 @@ module Clacky
|
|
|
735
783
|
case model
|
|
736
784
|
when /claude.*fable.*5/i
|
|
737
785
|
"claude-fable-5"
|
|
786
|
+
# Claude Sonnet 5 / Opus 5 (2026) — anchored on the literal "sonnet-5"
|
|
787
|
+
# / "opus-5" substring (no "4" in between) so this never collides
|
|
788
|
+
# with "sonnet-4-5" / "opus-4-5", which are handled by the 4.x
|
|
789
|
+
# tiered-pricing branches below. Also matches Bedrock cross-region
|
|
790
|
+
# prefixes like "global.anthropic.claude-sonnet-5".
|
|
791
|
+
when /claude.*sonnet-5(?!\d)/i
|
|
792
|
+
"claude-sonnet-5"
|
|
793
|
+
when /claude.*opus-5(?!\d)/i
|
|
794
|
+
"claude-opus-5"
|
|
738
795
|
when /claude.*opus.*4[.-]?[5-9]/i
|
|
739
796
|
"claude-opus-4.5"
|
|
740
797
|
when /claude.*sonnet.*4[.-]?[5-9]/i
|
|
@@ -786,6 +843,8 @@ module Clacky
|
|
|
786
843
|
# (mainland bigmodel.cn vs intl z.ai) the user configured.
|
|
787
844
|
# Strict anchored match so unrelated strings like "glm-5-x-foo"
|
|
788
845
|
# don't silently borrow a nearby model's rate.
|
|
846
|
+
when /^glm-5\.2$/i
|
|
847
|
+
"glm-5.2"
|
|
789
848
|
when /^glm-5\.1$/i
|
|
790
849
|
"glm-5.1"
|
|
791
850
|
when /^glm-5v-turbo$/i
|
|
@@ -796,14 +855,14 @@ module Clacky
|
|
|
796
855
|
"glm-5"
|
|
797
856
|
when /^glm-4\.7$/i
|
|
798
857
|
"glm-4.7"
|
|
799
|
-
# MiniMax — model ids in providers.rb use capitalised "MiniMax-
|
|
858
|
+
# MiniMax — model ids in providers.rb use capitalised "MiniMax-M*"
|
|
800
859
|
# but we match case-insensitively and map to the lowercased table key.
|
|
801
860
|
when /^minimax-m3$/i
|
|
802
861
|
"minimax-m3"
|
|
803
|
-
when /^minimax-m2\.5$/i
|
|
804
|
-
"minimax-m2.5"
|
|
805
862
|
when /^minimax-m2\.7$/i
|
|
806
863
|
"minimax-m2.7"
|
|
864
|
+
when /^minimax-m2\.5(-highspeed)?$/i
|
|
865
|
+
"minimax-m2.5"
|
|
807
866
|
|
|
808
867
|
# Qwen (Alibaba DashScope) — strict anchored match per registered
|
|
809
868
|
# model id in providers.rb. qwen3.7-* is the latest flagship line;
|
|
@@ -26,8 +26,8 @@ module Clacky
|
|
|
26
26
|
".pdf" => "pdf_parser.rb",
|
|
27
27
|
".doc" => "doc_parser.rb",
|
|
28
28
|
".docx" => "docx_parser.rb",
|
|
29
|
-
".xlsx" => "xlsx_parser.
|
|
30
|
-
".xls" => "xlsx_parser.
|
|
29
|
+
".xlsx" => "xlsx_parser.py",
|
|
30
|
+
".xls" => "xlsx_parser.py",
|
|
31
31
|
".pptx" => "pptx_parser.rb",
|
|
32
32
|
".ppt" => "pptx_parser.rb",
|
|
33
33
|
".wps" => "wps_parser.rb",
|
|
@@ -35,7 +35,17 @@ module Clacky
|
|
|
35
35
|
".dps" => "wps_parser.rb",
|
|
36
36
|
}.freeze
|
|
37
37
|
|
|
38
|
-
#
|
|
38
|
+
# Map a parser script's extension to the interpreter that runs it.
|
|
39
|
+
# Lets PARSER_FOR point at scripts in any language (see extract_version).
|
|
40
|
+
INTERPRETER_FOR = { ".rb" => RbConfig.ruby, ".py" => "python3" }.freeze
|
|
41
|
+
|
|
42
|
+
# Third-party libraries a given Python parser needs at runtime.
|
|
43
|
+
PYTHON_PARSER_LIBS = { "xlsx_parser.py" => "openpyxl" }.freeze
|
|
44
|
+
|
|
45
|
+
# Hard ceiling on how long a single parser subprocess may run. A runaway
|
|
46
|
+
# parser (huge/malformed file) is killed rather than hanging the caller
|
|
47
|
+
# forever and starving the machine.
|
|
48
|
+
PARSE_TIMEOUT = 60
|
|
39
49
|
# Called at Agent startup (idempotent — safe to run every time).
|
|
40
50
|
#
|
|
41
51
|
# Copies every file from default_parsers/ (not just the entry-point .rb
|
|
@@ -137,17 +147,32 @@ module Clacky
|
|
|
137
147
|
parser_path: parser_path }
|
|
138
148
|
end
|
|
139
149
|
|
|
140
|
-
|
|
150
|
+
interpreter = interpreter_for(script)
|
|
151
|
+
|
|
152
|
+
# Python parsers need Python + their libs present before parsing.
|
|
153
|
+
# ensure_python_deps returns an error string if python3 is missing or
|
|
154
|
+
# a lib can't be installed — the caller surfaces it as parse_error.
|
|
155
|
+
if interpreter == "python3"
|
|
156
|
+
dep_error = ensure_python_deps(script)
|
|
157
|
+
return { success: false, text: nil, error: dep_error, parser_path: parser_path } if dep_error
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
raw_stdout, raw_stderr, status =
|
|
161
|
+
capture3_with_timeout(interpreter, parser_path, file_path, timeout: PARSE_TIMEOUT)
|
|
141
162
|
|
|
142
163
|
# capture3 returns ASCII-8BIT across the subprocess boundary on Ruby 2.6+.
|
|
143
164
|
# Normalise both streams to UTF-8 immediately so all downstream code is clean.
|
|
144
|
-
stdout = Clacky::Utils::Encoding.to_utf8(raw_stdout)
|
|
145
|
-
stderr = Clacky::Utils::Encoding.to_utf8(raw_stderr)
|
|
165
|
+
stdout = Clacky::Utils::Encoding.to_utf8(raw_stdout.to_s)
|
|
166
|
+
stderr = Clacky::Utils::Encoding.to_utf8(raw_stderr.to_s)
|
|
146
167
|
|
|
147
168
|
# Filter out Ruby/Bundler version warnings that pollute stderr
|
|
148
169
|
clean_stderr = stderr.lines.reject { |l| l.match?(/warning:|already initialized constant/) }.join.strip
|
|
149
170
|
|
|
150
|
-
if status
|
|
171
|
+
if status == :timeout
|
|
172
|
+
{ success: false, text: nil,
|
|
173
|
+
error: "Parser timed out after #{PARSE_TIMEOUT}s (file too large or malformed)",
|
|
174
|
+
parser_path: parser_path }
|
|
175
|
+
elsif status.success? && stdout.strip.length > 0
|
|
151
176
|
{ success: true, text: stdout.strip, error: nil, parser_path: parser_path }
|
|
152
177
|
else
|
|
153
178
|
{ success: false, text: nil,
|
|
@@ -156,6 +181,89 @@ module Clacky
|
|
|
156
181
|
end
|
|
157
182
|
end
|
|
158
183
|
|
|
184
|
+
# Map a parser script to its interpreter (Ruby, Python, ...).
|
|
185
|
+
def self.interpreter_for(script)
|
|
186
|
+
INTERPRETER_FOR[File.extname(script)] || RbConfig.ruby
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Run a subprocess with a hard timeout. On timeout the whole process
|
|
190
|
+
# GROUP is killed (TERM, 2s grace, then KILL) so grandchildren spawned
|
|
191
|
+
# by the parser die too. Mirrors mcp/stdio_transport.rb's kill sequence.
|
|
192
|
+
#
|
|
193
|
+
# Returns [stdout, stderr, status] — status is a Process::Status on
|
|
194
|
+
# normal exit, or the symbol :timeout when the subprocess was killed.
|
|
195
|
+
def self.capture3_with_timeout(*cmd, timeout:)
|
|
196
|
+
stdin, stdout, stderr, wait_thr = Open3.popen3(*cmd, pgroup: true)
|
|
197
|
+
stdin.close
|
|
198
|
+
pgid = Process.getpgid(wait_thr.pid)
|
|
199
|
+
|
|
200
|
+
out_thr = Thread.new { stdout.read }
|
|
201
|
+
err_thr = Thread.new { stderr.read }
|
|
202
|
+
|
|
203
|
+
if wait_thr.join(timeout)
|
|
204
|
+
[out_thr.value, err_thr.value, wait_thr.value]
|
|
205
|
+
else
|
|
206
|
+
kill_process_group(pgid)
|
|
207
|
+
out_thr.kill
|
|
208
|
+
err_thr.kill
|
|
209
|
+
[nil, "timed out", :timeout]
|
|
210
|
+
end
|
|
211
|
+
ensure
|
|
212
|
+
[stdout, stderr].each { |io| io&.close rescue nil }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Terminate a process group: TERM, wait up to 2s, then KILL.
|
|
216
|
+
def self.kill_process_group(pgid)
|
|
217
|
+
Process.kill("TERM", -pgid)
|
|
218
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
219
|
+
else
|
|
220
|
+
deadline = Time.now + 2
|
|
221
|
+
sleep 0.05 while process_group_alive?(pgid) && Time.now < deadline
|
|
222
|
+
begin
|
|
223
|
+
Process.kill("KILL", -pgid) if process_group_alive?(pgid)
|
|
224
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def self.process_group_alive?(pgid)
|
|
229
|
+
Process.kill(0, -pgid)
|
|
230
|
+
true
|
|
231
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
232
|
+
false
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Ensure Python 3 and the libs a Python parser needs are present,
|
|
236
|
+
# installing on demand. Returns nil on success, or an error string.
|
|
237
|
+
#
|
|
238
|
+
# If python3 is missing, returns an error instructing the caller (the
|
|
239
|
+
# LLM via terminal tool) to run install_system_deps.sh --clt-only — we
|
|
240
|
+
# do NOT run it here because it blocks for 100+ seconds (CLT download).
|
|
241
|
+
# If python3 exists, probe + install the missing lib via pip --user.
|
|
242
|
+
def self.ensure_python_deps(script)
|
|
243
|
+
lib = PYTHON_PARSER_LIBS[script]
|
|
244
|
+
|
|
245
|
+
unless python3_available?
|
|
246
|
+
return "Python 3 is required to parse this file. " \
|
|
247
|
+
"Run: bash ~/.clacky/scripts/install_system_deps.sh --clt-only\n" \
|
|
248
|
+
"Then retry."
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
return nil if lib.nil? || python_lib_present?(lib)
|
|
252
|
+
pip_install(lib) ? nil : "Failed to install #{lib} (required to parse this file)."
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def self.python3_available?
|
|
256
|
+
system("python3", "--version", out: File::NULL, err: File::NULL)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def self.python_lib_present?(lib)
|
|
260
|
+
system("python3", "-c", "import #{lib}", out: File::NULL, err: File::NULL)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def self.pip_install(lib)
|
|
264
|
+
system("python3", "-m", "pip", "install", "--user", lib, out: File::NULL, err: File::NULL)
|
|
265
|
+
end
|
|
266
|
+
|
|
159
267
|
# Returns the path to a parser script for a given extension.
|
|
160
268
|
# Used by agent to tell LLM where to find/modify the parser.
|
|
161
269
|
def self.parser_path_for(ext)
|
data/lib/clacky/version.rb
CHANGED