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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/.dockerignore +13 -0
  3. data/CHANGELOG.md +50 -0
  4. data/Dockerfile +21 -1
  5. data/README.md +26 -7
  6. data/README_CN.md +26 -7
  7. data/README_JA.md +26 -7
  8. data/lib/clacky/agent/message_compressor.rb +4 -29
  9. data/lib/clacky/agent/message_compressor_helper.rb +13 -15
  10. data/lib/clacky/agent/session_serializer.rb +6 -0
  11. data/lib/clacky/agent.rb +13 -1
  12. data/lib/clacky/agent_config.rb +42 -11
  13. data/lib/clacky/brand_config.rb +32 -12
  14. data/lib/clacky/client.rb +7 -0
  15. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +15 -0
  16. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +2 -6
  17. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +11 -4
  18. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +5 -1
  19. data/lib/clacky/default_parsers/xlsx_parser.py +66 -0
  20. data/lib/clacky/extension/api_extension.rb +3 -2
  21. data/lib/clacky/message_format/anthropic.rb +27 -1
  22. data/lib/clacky/message_format/open_ai.rb +92 -4
  23. data/lib/clacky/providers.rb +67 -14
  24. data/lib/clacky/server/http_server.rb +361 -36
  25. data/lib/clacky/server/project_manager.rb +150 -0
  26. data/lib/clacky/server/server_master.rb +7 -0
  27. data/lib/clacky/server/session_registry.rb +14 -1
  28. data/lib/clacky/session_manager.rb +5 -4
  29. data/lib/clacky/tools/file_reader.rb +2 -1
  30. data/lib/clacky/utils/model_pricing.rb +77 -18
  31. data/lib/clacky/utils/parser_manager.rb +115 -7
  32. data/lib/clacky/version.rb +1 -1
  33. data/lib/clacky/web/app.css +1053 -143
  34. data/lib/clacky/web/app.js +19 -5
  35. data/lib/clacky/web/components/code-editor.js +50 -12
  36. data/lib/clacky/web/components/notify.js +139 -22
  37. data/lib/clacky/web/core/aside.js +21 -0
  38. data/lib/clacky/web/features/billing/view.js +2 -1
  39. data/lib/clacky/web/features/brand/store.js +3 -1
  40. data/lib/clacky/web/features/brand/view.js +29 -5
  41. data/lib/clacky/web/features/extensions/store.js +15 -6
  42. data/lib/clacky/web/features/extensions/view.js +8 -4
  43. data/lib/clacky/web/features/new-session/view.js +2 -2
  44. data/lib/clacky/web/features/trash/store.js +23 -9
  45. data/lib/clacky/web/features/trash/view.js +51 -0
  46. data/lib/clacky/web/features/workspace/store.js +9 -0
  47. data/lib/clacky/web/features/workspace/view.js +8 -1
  48. data/lib/clacky/web/i18n.js +99 -4
  49. data/lib/clacky/web/index.html +70 -17
  50. data/lib/clacky/web/projects.js +1143 -0
  51. data/lib/clacky/web/sessions.js +318 -537
  52. data/lib/clacky/web/settings.js +146 -7
  53. data/lib/clacky/web/ws-dispatcher.js +59 -1
  54. data/scripts/build/src/install_system_deps.sh.cc +11 -3
  55. data/scripts/install_system_deps.sh +11 -3
  56. metadata +5 -2
  57. 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; the rest
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: ruby #{ref.parser_path} #{path} > #{expected_preview}"
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.minimaxi.com (Pay-as-You-Go).
510
- # MiniMax pricing is identical across mainland (.com) and international
511
- # (.io) endpoints, verified by the team. Same cache-write convention as
512
- # DeepSeek/Kimi/GLM: bill writes at the input miss rate (OpenAI-compatible
513
- # usage responses from MiniMax don't reliably carry a separate
514
- # cache_creation_input_tokens field, so a distinct write rate would be
515
- # dead code in practice).
516
- # Note: providers.rb uses the capitalised "MiniMax-M2.x" model id, but
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. Official
527
- # pricing is tiered by context length (≤512K vs 512K–1M); per the
528
- # project's "displayed ≤ actual" convention we record only the lowest
529
- # (≤512K) tier as a flat rate — the global TIERED_PRICING_THRESHOLD is
530
- # 200K, so applying the 512K–1M rate to the 200K–512K band would over-
531
- # charge. Listed at original (non-promotional) prices: input $0.60,
532
- # output $2.40, cache read $0.12 per 1M tokens.
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-M2.x"
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.rb",
30
- ".xls" => "xlsx_parser.rb",
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
- # Ensure ~/.clacky/parsers/ exists and all default parsers are present.
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
- raw_stdout, raw_stderr, status = Open3.capture3(RbConfig.ruby, parser_path, file_path)
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.success? && stdout.strip.length > 0
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.5.2"
4
+ VERSION = "1.5.4"
5
5
  end