openclacky 1.5.3 → 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 +26 -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 +6 -3
- data/lib/clacky/brand_config.rb +32 -12
- 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 +1 -5
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +1 -1
- 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/open_ai.rb +26 -3
- data/lib/clacky/providers.rb +16 -8
- data/lib/clacky/server/http_server.rb +341 -24
- 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 +69 -18
- data/lib/clacky/utils/parser_manager.rb +115 -7
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +903 -135
- data/lib/clacky/web/app.js +11 -2
- data/lib/clacky/web/components/notify.js +139 -22
- data/lib/clacky/web/core/aside.js +21 -0
- 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/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/i18n.js +95 -4
- data/lib/clacky/web/index.html +69 -15
- data/lib/clacky/web/projects.js +1143 -0
- data/lib/clacky/web/sessions.js +318 -537
- data/lib/clacky/web/settings.js +138 -5
- 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
|
|
@@ -512,36 +545,45 @@ module Clacky
|
|
|
512
545
|
},
|
|
513
546
|
|
|
514
547
|
# MiniMax — USD per 1M tokens.
|
|
515
|
-
# Source: https://platform.
|
|
516
|
-
# MiniMax pricing is identical across
|
|
517
|
-
# (.io)
|
|
518
|
-
# DeepSeek/Kimi/GLM: bill writes at the
|
|
519
|
-
# usage responses from MiniMax don't
|
|
520
|
-
# cache_creation_input_tokens field, so a
|
|
521
|
-
# dead code in practice).
|
|
522
|
-
# 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
|
|
523
556
|
# the pricing table keys are lowercased to stay consistent with the
|
|
524
557
|
# rest of this file; normalize_model_name() lowercases incoming model
|
|
525
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).
|
|
526
563
|
"minimax-m2.5" => {
|
|
527
564
|
input: { default: 0.30, over_200k: 0.30 },
|
|
528
565
|
output: { default: 1.20, over_200k: 1.20 },
|
|
529
566
|
cache: { write: 0.30, read: 0.03 }
|
|
530
567
|
},
|
|
531
568
|
|
|
532
|
-
# M3 (released 2026-06-01) is MiniMax's multimodal flagship
|
|
533
|
-
#
|
|
534
|
-
#
|
|
535
|
-
#
|
|
536
|
-
#
|
|
537
|
-
#
|
|
538
|
-
#
|
|
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.
|
|
539
578
|
"minimax-m3" => {
|
|
540
579
|
input: { default: 0.60, over_200k: 0.60 },
|
|
541
580
|
output: { default: 2.40, over_200k: 2.40 },
|
|
542
581
|
cache: { write: 0.60, read: 0.12 }
|
|
543
582
|
},
|
|
544
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.
|
|
545
587
|
"minimax-m2.7" => {
|
|
546
588
|
input: { default: 0.30, over_200k: 0.30 },
|
|
547
589
|
output: { default: 1.20, over_200k: 1.20 },
|
|
@@ -741,6 +783,15 @@ module Clacky
|
|
|
741
783
|
case model
|
|
742
784
|
when /claude.*fable.*5/i
|
|
743
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"
|
|
744
795
|
when /claude.*opus.*4[.-]?[5-9]/i
|
|
745
796
|
"claude-opus-4.5"
|
|
746
797
|
when /claude.*sonnet.*4[.-]?[5-9]/i
|
|
@@ -804,14 +855,14 @@ module Clacky
|
|
|
804
855
|
"glm-5"
|
|
805
856
|
when /^glm-4\.7$/i
|
|
806
857
|
"glm-4.7"
|
|
807
|
-
# MiniMax — model ids in providers.rb use capitalised "MiniMax-
|
|
858
|
+
# MiniMax — model ids in providers.rb use capitalised "MiniMax-M*"
|
|
808
859
|
# but we match case-insensitively and map to the lowercased table key.
|
|
809
860
|
when /^minimax-m3$/i
|
|
810
861
|
"minimax-m3"
|
|
811
|
-
when /^minimax-m2\.5$/i
|
|
812
|
-
"minimax-m2.5"
|
|
813
862
|
when /^minimax-m2\.7$/i
|
|
814
863
|
"minimax-m2.7"
|
|
864
|
+
when /^minimax-m2\.5(-highspeed)?$/i
|
|
865
|
+
"minimax-m2.5"
|
|
815
866
|
|
|
816
867
|
# Qwen (Alibaba DashScope) — strict anchored match per registered
|
|
817
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