openclacky 1.0.0 → 1.0.2

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/README.md +87 -53
  4. data/lib/clacky/agent/cost_tracker.rb +19 -2
  5. data/lib/clacky/agent/llm_caller.rb +218 -0
  6. data/lib/clacky/agent/message_compressor_helper.rb +32 -2
  7. data/lib/clacky/agent.rb +54 -22
  8. data/lib/clacky/client.rb +44 -5
  9. data/lib/clacky/default_parsers/pdf_parser.rb +58 -17
  10. data/lib/clacky/default_parsers/pdf_parser_ocr.py +103 -0
  11. data/lib/clacky/default_parsers/pdf_parser_plumber.py +62 -0
  12. data/lib/clacky/default_skills/deploy/SKILL.md +201 -77
  13. data/lib/clacky/default_skills/new/SKILL.md +3 -114
  14. data/lib/clacky/default_skills/onboard/SKILL.md +349 -133
  15. data/lib/clacky/default_skills/onboard/scripts/import_external_skills.rb +371 -0
  16. data/lib/clacky/default_skills/onboard/scripts/install_builtin_skills.rb +175 -0
  17. data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +59 -26
  18. data/lib/clacky/message_format/anthropic.rb +72 -8
  19. data/lib/clacky/message_format/bedrock.rb +6 -3
  20. data/lib/clacky/providers.rb +146 -3
  21. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +14 -0
  22. data/lib/clacky/server/channel/adapters/feishu/bot.rb +10 -0
  23. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +1 -0
  24. data/lib/clacky/server/channel/channel_manager.rb +12 -4
  25. data/lib/clacky/server/channel/channel_ui_controller.rb +8 -2
  26. data/lib/clacky/server/http_server.rb +746 -13
  27. data/lib/clacky/server/session_registry.rb +55 -24
  28. data/lib/clacky/skill.rb +10 -9
  29. data/lib/clacky/skill_loader.rb +23 -11
  30. data/lib/clacky/tools/file_reader.rb +232 -127
  31. data/lib/clacky/tools/security.rb +42 -64
  32. data/lib/clacky/tools/terminal/persistent_session.rb +15 -4
  33. data/lib/clacky/tools/terminal/safe_rm.sh +106 -0
  34. data/lib/clacky/tools/terminal/session_manager.rb +8 -3
  35. data/lib/clacky/tools/terminal.rb +263 -16
  36. data/lib/clacky/ui2/layout_manager.rb +8 -1
  37. data/lib/clacky/ui2/output_buffer.rb +83 -23
  38. data/lib/clacky/ui2/ui_controller.rb +74 -7
  39. data/lib/clacky/utils/file_processor.rb +14 -40
  40. data/lib/clacky/utils/model_pricing.rb +215 -0
  41. data/lib/clacky/utils/parser_manager.rb +70 -6
  42. data/lib/clacky/utils/string_matcher.rb +23 -1
  43. data/lib/clacky/version.rb +1 -1
  44. data/lib/clacky/web/app.css +673 -9
  45. data/lib/clacky/web/app.js +40 -1608
  46. data/lib/clacky/web/i18n.js +209 -0
  47. data/lib/clacky/web/index.html +166 -2
  48. data/lib/clacky/web/onboard.js +77 -1
  49. data/lib/clacky/web/profile.js +442 -0
  50. data/lib/clacky/web/sessions.js +1034 -2
  51. data/lib/clacky/web/settings.js +127 -6
  52. data/lib/clacky/web/sidebar.js +39 -0
  53. data/lib/clacky/web/skills.js +460 -0
  54. data/lib/clacky/web/trash.js +343 -0
  55. data/lib/clacky/web/ws-dispatcher.js +255 -0
  56. data/lib/clacky.rb +5 -3
  57. metadata +16 -17
  58. data/lib/clacky/clacky_auth_client.rb +0 -152
  59. data/lib/clacky/clacky_cloud_config.rb +0 -123
  60. data/lib/clacky/cloud_project_client.rb +0 -169
  61. data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +0 -1377
  62. data/lib/clacky/default_skills/deploy/tools/check_health.rb +0 -116
  63. data/lib/clacky/default_skills/deploy/tools/create_database_service.rb +0 -341
  64. data/lib/clacky/default_skills/deploy/tools/execute_deployment.rb +0 -99
  65. data/lib/clacky/default_skills/deploy/tools/fetch_runtime_logs.rb +0 -77
  66. data/lib/clacky/default_skills/deploy/tools/list_services.rb +0 -67
  67. data/lib/clacky/default_skills/deploy/tools/report_deploy_status.rb +0 -67
  68. data/lib/clacky/default_skills/deploy/tools/set_deploy_variables.rb +0 -189
  69. data/lib/clacky/default_skills/new/scripts/cloud_project_init.sh +0 -74
  70. data/lib/clacky/deploy_api_client.rb +0 -484
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "webrick"
4
4
  require "websocket"
5
+ require "socket"
5
6
  require "json"
6
7
  require "thread"
7
8
  require "fileutils"
@@ -9,6 +10,8 @@ require "tmpdir"
9
10
  require "uri"
10
11
  require "securerandom"
11
12
  require "timeout"
13
+ require "yaml"
14
+ require "date"
12
15
  require_relative "session_registry"
13
16
  require_relative "web_ui_controller"
14
17
  require_relative "scheduler"
@@ -392,6 +395,13 @@ module Clacky
392
395
  when ["GET", "/api/brand/skills"] then api_brand_skills(res)
393
396
  when ["GET", "/api/brand"] then api_brand_info(res)
394
397
  when ["GET", "/api/creator/skills"] then api_creator_skills(res)
398
+ when ["GET", "/api/trash"] then api_trash(req, res)
399
+ when ["POST", "/api/trash/restore"] then api_trash_restore(req, res)
400
+ when ["DELETE", "/api/trash"] then api_trash_delete(req, res)
401
+ when ["GET", "/api/profile"] then api_profile_get(res)
402
+ when ["PUT", "/api/profile"] then api_profile_put(req, res)
403
+ when ["GET", "/api/memories"] then api_memories_list(res)
404
+ when ["POST", "/api/memories"] then api_memories_create(req, res)
395
405
  when ["GET", "/api/channels"] then api_list_channels(res)
396
406
  when ["POST", "/api/tool/browser"] then api_tool_browser(req, res)
397
407
  when ["POST", "/api/upload"] then api_upload_file(req, res)
@@ -462,6 +472,15 @@ module Clacky
462
472
  elsif method == "POST" && path.match?(%r{^/api/my-skills/[^/]+/publish$})
463
473
  name = URI.decode_www_form_component(path.sub("/api/my-skills/", "").sub("/publish", ""))
464
474
  api_publish_my_skill(name, req, res)
475
+ elsif method == "GET" && path.match?(%r{^/api/memories/[^/]+$})
476
+ filename = URI.decode_www_form_component(path.sub("/api/memories/", ""))
477
+ api_memories_get(filename, res)
478
+ elsif method == "PUT" && path.match?(%r{^/api/memories/[^/]+$})
479
+ filename = URI.decode_www_form_component(path.sub("/api/memories/", ""))
480
+ api_memories_update(filename, req, res)
481
+ elsif method == "DELETE" && path.match?(%r{^/api/memories/[^/]+$})
482
+ filename = URI.decode_www_form_component(path.sub("/api/memories/", ""))
483
+ api_memories_delete(filename, res)
465
484
  else
466
485
  not_found(res)
467
486
  end
@@ -1449,6 +1468,10 @@ module Clacky
1449
1468
  path = parse_json_body(req)["path"]
1450
1469
  return json_response(res, 400, { error: "path is required" }) unless path && !path.empty?
1451
1470
 
1471
+ # Expand ~ to the user's home directory (e.g. "~/Desktop/file.pdf").
1472
+ # Ruby's File.exist? does NOT automatically expand ~ — that's a shell feature.
1473
+ path = File.expand_path(path)
1474
+
1452
1475
  # On WSL the file may be specified as a Windows path (e.g. "C:/Users/…").
1453
1476
  # Convert it to the Linux-side path so File.exist? works.
1454
1477
  linux_path = Utils::EnvironmentDetector.win_to_linux_path(path)
@@ -1887,6 +1910,469 @@ module Clacky
1887
1910
  })
1888
1911
  end
1889
1912
 
1913
+ # GET /api/trash[?project=<path>]
1914
+ # Lists recently deleted files in the AI trash.
1915
+ #
1916
+ # The trash is organized by project_root; each project gets its own
1917
+ # hashed subdirectory under ~/.clacky/trash/ (see TrashDirectory).
1918
+ # Returns ALL projects' deletions by default, with a per-file
1919
+ # project_root field so the UI can group or filter.
1920
+ #
1921
+ # Optional ?project=<absolute-path> restricts to a single project.
1922
+ # Response:
1923
+ # { ok: true,
1924
+ # files: [ { original_path, deleted_at, file_size, file_type,
1925
+ # project_root, project_name, trash_file } ],
1926
+ # projects: [ { project_root, project_name, file_count, total_size } ],
1927
+ # total_count, total_size }
1928
+ private def api_trash(req, res)
1929
+ query = URI.decode_www_form(req.query_string.to_s).to_h
1930
+ filter_project = query["project"].to_s.strip
1931
+ filter_project = nil if filter_project.empty?
1932
+
1933
+ projects =
1934
+ if filter_project
1935
+ [{ project_root: File.expand_path(filter_project),
1936
+ project_name: File.basename(File.expand_path(filter_project)),
1937
+ trash_dir: Clacky::TrashDirectory.new(filter_project).trash_dir }]
1938
+ else
1939
+ Clacky::TrashDirectory.all_projects
1940
+ end
1941
+
1942
+ all_files = []
1943
+ project_rows = []
1944
+
1945
+ projects.each do |p|
1946
+ files = _trash_files_in(p[:trash_dir], p[:project_root])
1947
+ next if files.empty? && filter_project.nil?
1948
+
1949
+ total_size = files.sum { |f| f[:file_size].to_i }
1950
+ project_rows << {
1951
+ project_root: p[:project_root],
1952
+ project_name: p[:project_name],
1953
+ file_count: files.size,
1954
+ total_size: total_size
1955
+ }
1956
+
1957
+ files.each do |f|
1958
+ all_files << f.merge(
1959
+ project_root: p[:project_root],
1960
+ project_name: p[:project_name]
1961
+ )
1962
+ end
1963
+ end
1964
+
1965
+ all_files.sort_by! { |f| f[:deleted_at].to_s }.reverse!
1966
+
1967
+ json_response(res, 200, {
1968
+ ok: true,
1969
+ files: all_files,
1970
+ projects: project_rows,
1971
+ total_count: all_files.size,
1972
+ total_size: all_files.sum { |f| f[:file_size].to_i }
1973
+ })
1974
+ end
1975
+
1976
+ # POST /api/trash/restore
1977
+ # Body: { project_root: "...", original_path: "..." }
1978
+ # Restores a single file from trash back to its original location.
1979
+ # Refuses if the target already exists on disk.
1980
+ private def api_trash_restore(req, res)
1981
+ data = parse_json_body(req)
1982
+ project_root = data["project_root"].to_s.strip
1983
+ original_path = data["original_path"].to_s.strip
1984
+
1985
+ if project_root.empty? || original_path.empty?
1986
+ json_response(res, 400, { ok: false, error: "project_root and original_path are required" })
1987
+ return
1988
+ end
1989
+
1990
+ tool = Clacky::Tools::TrashManager.new
1991
+ result = tool.execute(action: "restore",
1992
+ file_path: original_path,
1993
+ working_dir: project_root)
1994
+
1995
+ if result[:success]
1996
+ json_response(res, 200, { ok: true, restored_file: result[:restored_file], message: result[:message] })
1997
+ else
1998
+ json_response(res, 422, { ok: false, error: result[:message] })
1999
+ end
2000
+ end
2001
+
2002
+ # DELETE /api/trash[?project=<path>][&days_old=<n>][&file=<original_path>]
2003
+ # Three modes:
2004
+ # ?file=<original_path>&project=<root> → permanently delete one file
2005
+ # ?project=<root>[&days_old=0] → empty that project's trash
2006
+ # (no project, days_old required) → empty ALL projects older than N days
2007
+ private def api_trash_delete(req, res)
2008
+ query = URI.decode_www_form(req.query_string.to_s).to_h
2009
+ project_root = query["project"].to_s.strip
2010
+ days_old = query["days_old"].to_s.strip
2011
+ file_path = query["file"].to_s.strip
2012
+
2013
+ project_root = nil if project_root.empty?
2014
+ file_path = nil if file_path.empty?
2015
+
2016
+ # Mode 1: single-file permanent delete
2017
+ if file_path
2018
+ unless project_root
2019
+ json_response(res, 400, { ok: false, error: "project is required when file is given" })
2020
+ return
2021
+ end
2022
+ deleted = _trash_delete_single(project_root, file_path)
2023
+ if deleted
2024
+ json_response(res, 200, { ok: true, deleted_count: 1, freed_size: deleted[:file_size].to_i })
2025
+ else
2026
+ json_response(res, 404, { ok: false, error: "File not found in trash: #{file_path}" })
2027
+ end
2028
+ return
2029
+ end
2030
+
2031
+ # Mode 2 & 3: bulk empty (optionally scoped to one project, optionally by age)
2032
+ days_i = days_old.empty? ? 0 : days_old.to_i
2033
+ tool = Clacky::Tools::TrashManager.new
2034
+
2035
+ targets =
2036
+ if project_root
2037
+ [project_root]
2038
+ else
2039
+ Clacky::TrashDirectory.all_projects.map { |p| p[:project_root] }
2040
+ end
2041
+
2042
+ total_deleted = 0
2043
+ total_freed = 0
2044
+ targets.each do |root|
2045
+ result = tool.execute(action: "empty", days_old: days_i, working_dir: root)
2046
+ next unless result[:success]
2047
+ total_deleted += result[:deleted_count].to_i
2048
+ total_freed += result[:freed_size].to_i
2049
+ end
2050
+
2051
+ json_response(res, 200, {
2052
+ ok: true,
2053
+ deleted_count: total_deleted,
2054
+ freed_size: total_freed,
2055
+ days_old: days_i
2056
+ })
2057
+ end
2058
+
2059
+ # ── Trash helpers (private) ─────────────────────────────────────
2060
+ # Reads all metadata sidecars in `trash_dir` and returns enriched
2061
+ # file records. Silently skips sidecars whose payload file has
2062
+ # already been purged from disk.
2063
+ private def _trash_files_in(trash_dir, project_root)
2064
+ return [] unless trash_dir && Dir.exist?(trash_dir)
2065
+
2066
+ files = []
2067
+ Dir.glob(File.join(trash_dir, "*.metadata.json")).each do |meta_path|
2068
+ begin
2069
+ meta = JSON.parse(File.read(meta_path))
2070
+ trash = meta_path.sub(/\.metadata\.json\z/, "")
2071
+ next unless File.exist?(trash)
2072
+ files << {
2073
+ original_path: meta["original_path"],
2074
+ deleted_at: meta["deleted_at"],
2075
+ deleted_by: meta["deleted_by"],
2076
+ file_size: meta["file_size"].to_i,
2077
+ file_type: meta["file_type"],
2078
+ file_mode: meta["file_mode"],
2079
+ trash_file: trash
2080
+ }
2081
+ rescue StandardError
2082
+ # Corrupt or partial metadata — skip.
2083
+ end
2084
+ end
2085
+ files
2086
+ end
2087
+
2088
+ # Permanently deletes the single trash entry whose original_path
2089
+ # matches inside `project_root`'s trash. Returns the removed
2090
+ # metadata hash, or nil if not found.
2091
+ private def _trash_delete_single(project_root, original_path)
2092
+ trash_dir = Clacky::TrashDirectory.new(project_root).trash_dir
2093
+ expanded = File.expand_path(original_path, project_root)
2094
+ entry = _trash_files_in(trash_dir, project_root).find do |f|
2095
+ f[:original_path] == expanded
2096
+ end
2097
+ return nil unless entry
2098
+
2099
+ File.delete(entry[:trash_file]) if File.exist?(entry[:trash_file])
2100
+ File.delete("#{entry[:trash_file]}.metadata.json") if File.exist?("#{entry[:trash_file]}.metadata.json")
2101
+ entry
2102
+ rescue StandardError
2103
+ nil
2104
+ end
2105
+
2106
+ # ── Profile API (USER.md / SOUL.md) ──────────────────────────────
2107
+ #
2108
+ # User can override the built-in defaults by writing their own
2109
+ # ~/.clacky/agents/USER.md and ~/.clacky/agents/SOUL.md. These
2110
+ # endpoints let the Web UI read and edit those files.
2111
+
2112
+ PROFILE_USER_AGENTS_DIR = File.expand_path("~/.clacky/agents").freeze
2113
+ PROFILE_DEFAULT_AGENTS_DIR = File.expand_path("../../default_agents", __dir__).freeze
2114
+ PROFILE_MAX_BYTES = 50_000 # Hard limit; prevents runaway content.
2115
+
2116
+ # GET /api/profile
2117
+ # Returns { ok:, user: { path, content, is_default }, soul: { ... } }
2118
+ private def api_profile_get(res)
2119
+ json_response(res, 200, {
2120
+ ok: true,
2121
+ user: _profile_read_file("USER.md"),
2122
+ soul: _profile_read_file("SOUL.md")
2123
+ })
2124
+ end
2125
+
2126
+ # PUT /api/profile
2127
+ # Body: { kind: "user"|"soul", content: "..." }
2128
+ # Writes the file to ~/.clacky/agents/<KIND>.md. Empty content
2129
+ # deletes the override so the built-in default is used again.
2130
+ private def api_profile_put(req, res)
2131
+ data = parse_json_body(req)
2132
+ kind = data["kind"].to_s.downcase
2133
+ content = data["content"].to_s
2134
+
2135
+ filename = case kind
2136
+ when "user" then "USER.md"
2137
+ when "soul" then "SOUL.md"
2138
+ else
2139
+ json_response(res, 400, { ok: false, error: "kind must be 'user' or 'soul'" })
2140
+ return
2141
+ end
2142
+
2143
+ if content.bytesize > PROFILE_MAX_BYTES
2144
+ json_response(res, 413, { ok: false, error: "Content too large (max #{PROFILE_MAX_BYTES} bytes)" })
2145
+ return
2146
+ end
2147
+
2148
+ FileUtils.mkdir_p(PROFILE_USER_AGENTS_DIR)
2149
+ target = File.join(PROFILE_USER_AGENTS_DIR, filename)
2150
+
2151
+ # Treat whitespace-only payload as "reset to built-in default":
2152
+ # delete the override file so AgentProfile falls back to default.
2153
+ if content.strip.empty?
2154
+ File.delete(target) if File.exist?(target)
2155
+ json_response(res, 200, { ok: true, reset: true, file: _profile_read_file(filename) })
2156
+ return
2157
+ end
2158
+
2159
+ File.write(target, content)
2160
+ json_response(res, 200, { ok: true, file: _profile_read_file(filename) })
2161
+ rescue StandardError => e
2162
+ json_response(res, 500, { ok: false, error: e.message })
2163
+ end
2164
+
2165
+ # Read a profile file — user override if present, else built-in default.
2166
+ # Returns { path:, content:, is_default:, writable: }.
2167
+ private def _profile_read_file(filename)
2168
+ user_path = File.join(PROFILE_USER_AGENTS_DIR, filename)
2169
+ default_path = File.join(PROFILE_DEFAULT_AGENTS_DIR, filename)
2170
+
2171
+ if File.exist?(user_path) && !File.zero?(user_path)
2172
+ {
2173
+ path: user_path,
2174
+ content: File.read(user_path),
2175
+ is_default: false
2176
+ }
2177
+ elsif File.exist?(default_path)
2178
+ {
2179
+ path: default_path,
2180
+ content: File.read(default_path),
2181
+ is_default: true
2182
+ }
2183
+ else
2184
+ {
2185
+ path: user_path, # Where it WILL be written
2186
+ content: "",
2187
+ is_default: true
2188
+ }
2189
+ end
2190
+ rescue StandardError => e
2191
+ { path: "", content: "", is_default: true, error: e.message }
2192
+ end
2193
+
2194
+ # ── Memories API (~/.clacky/memories/*.md) ───────────────────────
2195
+ #
2196
+ # Long-term memories are plain Markdown files with YAML frontmatter
2197
+ # stored under ~/.clacky/memories/. These endpoints let the user
2198
+ # inspect, edit, create, and delete them from the Web UI.
2199
+
2200
+ MEMORIES_DIR = File.expand_path("~/.clacky/memories").freeze
2201
+ MEMORY_MAX_BYTES = 50_000
2202
+
2203
+ # GET /api/memories
2204
+ # Returns { ok:, dir:, memories: [ { filename, topic, description, updated_at, size, preview } ] }
2205
+ # Sorted by updated_at (YAML frontmatter) descending, falling back to file mtime.
2206
+ private def api_memories_list(res)
2207
+ FileUtils.mkdir_p(MEMORIES_DIR)
2208
+ memories = Dir.glob(File.join(MEMORIES_DIR, "*.md")).map do |path|
2209
+ _memory_summary(path)
2210
+ end.compact
2211
+
2212
+ # Sort key: prefer updated_at string (ISO-ish sorts correctly), fall back to mtime.
2213
+ # `mtime` is always present in the summary (ISO 8601), so we use it as the
2214
+ # ultimate tiebreaker. Negate by reversing after sort for descending order.
2215
+ memories.sort_by! do |m|
2216
+ key = m[:updated_at].to_s
2217
+ key = m[:mtime].to_s if key.empty?
2218
+ key
2219
+ end
2220
+ memories.reverse!
2221
+
2222
+ json_response(res, 200, { ok: true, dir: MEMORIES_DIR, memories: memories })
2223
+ end
2224
+
2225
+ # GET /api/memories/:filename
2226
+ # Returns { ok:, filename:, path:, content: }
2227
+ private def api_memories_get(filename, res)
2228
+ safe = _memory_safe_filename(filename)
2229
+ unless safe
2230
+ json_response(res, 400, { ok: false, error: "Invalid filename" })
2231
+ return
2232
+ end
2233
+ path = File.join(MEMORIES_DIR, safe)
2234
+ unless File.exist?(path)
2235
+ json_response(res, 404, { ok: false, error: "Memory not found" })
2236
+ return
2237
+ end
2238
+ json_response(res, 200, {
2239
+ ok: true,
2240
+ filename: safe,
2241
+ path: path,
2242
+ content: File.read(path)
2243
+ })
2244
+ end
2245
+
2246
+ # POST /api/memories
2247
+ # Body: { filename: "topic.md", content: "..." }
2248
+ # Create a new memory file. Refuses to overwrite existing.
2249
+ private def api_memories_create(req, res)
2250
+ data = parse_json_body(req)
2251
+ filename = _memory_safe_filename(data["filename"].to_s)
2252
+ content = data["content"].to_s
2253
+
2254
+ unless filename
2255
+ json_response(res, 400, { ok: false, error: "Invalid filename (must end in .md, no path separators)" })
2256
+ return
2257
+ end
2258
+ if content.bytesize > MEMORY_MAX_BYTES
2259
+ json_response(res, 413, { ok: false, error: "Content too large (max #{MEMORY_MAX_BYTES} bytes)" })
2260
+ return
2261
+ end
2262
+
2263
+ FileUtils.mkdir_p(MEMORIES_DIR)
2264
+ path = File.join(MEMORIES_DIR, filename)
2265
+ if File.exist?(path)
2266
+ json_response(res, 409, { ok: false, error: "Memory '#{filename}' already exists" })
2267
+ return
2268
+ end
2269
+
2270
+ File.write(path, content)
2271
+ json_response(res, 201, { ok: true, memory: _memory_summary(path) })
2272
+ rescue StandardError => e
2273
+ json_response(res, 500, { ok: false, error: e.message })
2274
+ end
2275
+
2276
+ # PUT /api/memories/:filename
2277
+ # Body: { content: "..." }
2278
+ private def api_memories_update(filename, req, res)
2279
+ safe = _memory_safe_filename(filename)
2280
+ unless safe
2281
+ json_response(res, 400, { ok: false, error: "Invalid filename" })
2282
+ return
2283
+ end
2284
+ data = parse_json_body(req)
2285
+ content = data["content"].to_s
2286
+ if content.bytesize > MEMORY_MAX_BYTES
2287
+ json_response(res, 413, { ok: false, error: "Content too large (max #{MEMORY_MAX_BYTES} bytes)" })
2288
+ return
2289
+ end
2290
+
2291
+ path = File.join(MEMORIES_DIR, safe)
2292
+ unless File.exist?(path)
2293
+ json_response(res, 404, { ok: false, error: "Memory not found" })
2294
+ return
2295
+ end
2296
+
2297
+ File.write(path, content)
2298
+ json_response(res, 200, { ok: true, memory: _memory_summary(path) })
2299
+ rescue StandardError => e
2300
+ json_response(res, 500, { ok: false, error: e.message })
2301
+ end
2302
+
2303
+ # DELETE /api/memories/:filename
2304
+ private def api_memories_delete(filename, res)
2305
+ safe = _memory_safe_filename(filename)
2306
+ unless safe
2307
+ json_response(res, 400, { ok: false, error: "Invalid filename" })
2308
+ return
2309
+ end
2310
+ path = File.join(MEMORIES_DIR, safe)
2311
+ unless File.exist?(path)
2312
+ json_response(res, 404, { ok: false, error: "Memory not found" })
2313
+ return
2314
+ end
2315
+ File.delete(path)
2316
+ json_response(res, 200, { ok: true, filename: safe })
2317
+ rescue StandardError => e
2318
+ json_response(res, 500, { ok: false, error: e.message })
2319
+ end
2320
+
2321
+ # Returns nil if the filename is unsafe. Must end in .md, contain
2322
+ # no path separators or shell metacharacters, and be non-empty.
2323
+ private def _memory_safe_filename(name)
2324
+ s = name.to_s.strip
2325
+ return nil if s.empty?
2326
+ return nil if s.include?("/") || s.include?("\\")
2327
+ return nil if s.start_with?(".")
2328
+ return nil unless s.end_with?(".md")
2329
+ return nil unless s.match?(/\A[A-Za-z0-9._\-]+\z/)
2330
+ s
2331
+ end
2332
+
2333
+ # Build a summary record for a memory file. Parses YAML frontmatter
2334
+ # if present; otherwise falls back to filename-derived topic.
2335
+ # Returns nil if the file can't be read.
2336
+ private def _memory_summary(path)
2337
+ content = File.read(path)
2338
+ stat = File.stat(path)
2339
+
2340
+ topic = File.basename(path, ".md")
2341
+ description = ""
2342
+ updated_at = stat.mtime.strftime("%Y-%m-%d")
2343
+
2344
+ # Parse YAML frontmatter: --- ... --- at the top of the file.
2345
+ if content.start_with?("---")
2346
+ if (m = content.match(/\A---\s*\n(.*?)\n---\s*\n/m))
2347
+ begin
2348
+ # permitted_classes: Date so YAML `updated_at: 2026-05-01`
2349
+ # parses to a Date instance instead of raising DisallowedClass.
2350
+ fm = YAML.safe_load(m[1], permitted_classes: [Date, Time]) || {}
2351
+ topic = fm["topic"].to_s unless fm["topic"].to_s.strip.empty?
2352
+ description = fm["description"].to_s
2353
+ updated_at = fm["updated_at"].to_s unless fm["updated_at"].to_s.strip.empty?
2354
+ rescue StandardError
2355
+ # Bad frontmatter — fall back to defaults above.
2356
+ end
2357
+ end
2358
+ end
2359
+
2360
+ preview = content.sub(/\A---.*?---\s*\n/m, "").strip[0, 200]
2361
+
2362
+ {
2363
+ filename: File.basename(path),
2364
+ path: path,
2365
+ topic: topic,
2366
+ description: description,
2367
+ updated_at: updated_at,
2368
+ size: stat.size,
2369
+ mtime: stat.mtime.iso8601,
2370
+ preview: preview
2371
+ }
2372
+ rescue StandardError
2373
+ nil
2374
+ end
2375
+
1890
2376
  # Auto-packages the named skill directory into a ZIP and uploads it to the
1891
2377
  # OpenClacky cloud. No file picker is required — the server finds the skill
1892
2378
  # directory, zips it, and streams the ZIP to the cloud API.
@@ -2220,12 +2706,16 @@ module Clacky
2220
2706
  def api_list_providers(res)
2221
2707
  providers = Clacky::Providers::PRESETS.map do |id, preset|
2222
2708
  {
2223
- id: id,
2224
- name: preset["name"],
2225
- base_url: preset["base_url"],
2226
- default_model: preset["default_model"],
2227
- models: preset["models"] || [],
2228
- website_url: preset["website_url"]
2709
+ id: id,
2710
+ name: preset["name"],
2711
+ base_url: preset["base_url"],
2712
+ default_model: preset["default_model"],
2713
+ models: preset["models"] || [],
2714
+ # Frontend uses this to render a Base URL dropdown (regional /
2715
+ # billing-plan variants) when present. Absent for single-endpoint
2716
+ # providers — UI renders a plain text input in that case.
2717
+ endpoint_variants: preset["endpoint_variants"],
2718
+ website_url: preset["website_url"]
2229
2719
  }
2230
2720
  end
2231
2721
  json_response(res, 200, { providers: providers })
@@ -2622,6 +3112,11 @@ module Clacky
2622
3112
  conn.session_id = session_id
2623
3113
  subscribe(session_id, conn)
2624
3114
  conn.send_json(type: "subscribed", session_id: session_id)
3115
+ # Push a fresh snapshot so a reconnecting tab sees the true current
3116
+ # status (it may have missed session_update events while offline).
3117
+ if (snap = @registry.snapshot(session_id))
3118
+ conn.send_json(type: "session_update", session: snap)
3119
+ end
2625
3120
  # If a shell command is still running, replay progress + buffered stdout
2626
3121
  # to the newly subscribed tab so it sees the live state it may have missed.
2627
3122
  @registry.with_session(session_id) { |s| s[:ui]&.replay_live_state }
@@ -2722,13 +3217,113 @@ module Clacky
2722
3217
  ui&.deliver_confirmation(conf_id, result)
2723
3218
  end
2724
3219
 
3220
+ # Interrupt a running agent session.
3221
+ #
3222
+ # Thread#raise alone is not reliable enough in practice — it's
3223
+ # best-effort against blocked syscalls (socket writes, OpenSSL read,
3224
+ # ConditionVariable#wait with a held mutex) and we've seen sessions
3225
+ # that stay "running" forever even after multiple interrupt attempts.
3226
+ #
3227
+ # Strategy: three-tier escalation in a background watchdog Thread so
3228
+ # the HTTP handler returns immediately.
3229
+ #
3230
+ # Tier 1 (t=0): Thread#raise(AgentInterrupted).
3231
+ # Unblocks most pure-Ruby waits and Faraday reads.
3232
+ # Handles the common case.
3233
+ # Tier 2 (t=3): force-close this session's WebSocket connections
3234
+ # so any send_raw stuck on socket write wakes up.
3235
+ # Try Thread#raise again (idempotent).
3236
+ # Tier 3 (t=8): Thread#kill — last resort. Leaks any held
3237
+ # resources but frees the session so the user can
3238
+ # move on.
3239
+ #
3240
+ # Each transition is logged so that when users report "stuck
3241
+ # sessions" we can see in the log whether tier 2/3 ever had to
3242
+ # fire — that's our signal to dig deeper on the underlying block.
2725
3243
  def interrupt_session(session_id)
3244
+ thread = nil
2726
3245
  @registry.with_session(session_id) do |s|
2727
3246
  s[:idle_timer]&.cancel
2728
- s[:thread]&.raise(Clacky::AgentInterrupted, "Interrupted by user")
3247
+ thread = s[:thread]
3248
+
3249
+ next unless thread&.alive?
3250
+
3251
+ Clacky::Logger.info("[interrupt] session=#{session_id} tier=1 raise")
3252
+ begin
3253
+ thread.raise(Clacky::AgentInterrupted, "Interrupted by user")
3254
+ rescue ThreadError => e
3255
+ Clacky::Logger.warn("[interrupt] tier=1 raise failed: #{e.message}")
3256
+ end
3257
+ end
3258
+
3259
+ return unless thread&.alive?
3260
+
3261
+ start_interrupt_watchdog(session_id, thread)
3262
+ end
3263
+
3264
+ # Background watchdog: escalates from WebSocket force-close (tier 2)
3265
+ # to Thread#kill (tier 3) if the agent thread refuses to die.
3266
+ private def start_interrupt_watchdog(session_id, thread)
3267
+ Thread.new do
3268
+ Thread.current.name = "interrupt-watchdog[#{session_id}]" rescue nil
3269
+
3270
+ # Give the first Thread#raise a few seconds to unwind.
3271
+ sleep 3
3272
+ next unless thread.alive?
3273
+
3274
+ Clacky::Logger.warn(
3275
+ "[interrupt] session=#{session_id} tier=2 raise failed after 3s, " \
3276
+ "force-closing session resources"
3277
+ )
3278
+ force_close_session_sockets(session_id)
3279
+ # Re-raise — sometimes the first raise was swallowed deep in a
3280
+ # C-extension syscall; after we force-close the socket the
3281
+ # syscall returns and the next raise sticks.
3282
+ begin
3283
+ thread.raise(Clacky::AgentInterrupted, "Interrupted by user (escalated)")
3284
+ rescue ThreadError
3285
+ # already dead between checks — fine
3286
+ end
3287
+
3288
+ sleep 5
3289
+ next unless thread.alive?
3290
+
3291
+ Clacky::Logger.error(
3292
+ "[interrupt] session=#{session_id} tier=3 still alive after 8s, Thread#kill"
3293
+ )
3294
+ begin
3295
+ thread.kill
3296
+ rescue StandardError => e
3297
+ Clacky::Logger.error("[interrupt] Thread#kill raised: #{e.class}: #{e.message}")
3298
+ end
3299
+
3300
+ # Record the forced-kill so the UI can show a warning and operators
3301
+ # can correlate with any backtrace dumps. The session is left in
3302
+ # :idle state by run_agent_task's rescue clause; if the kill
3303
+ # happened before the rescue could run, patch the state directly.
3304
+ begin
3305
+ @registry.update(session_id, status: :idle, error: "Force-killed (interrupt watchdog)")
3306
+ broadcast_session_update(session_id)
3307
+ rescue StandardError
3308
+ # best effort
3309
+ end
2729
3310
  end
2730
3311
  end
2731
3312
 
3313
+ # Close every WebSocket connection bound to the given session. Used by
3314
+ # the interrupt watchdog to unblock agent threads stuck in a WS write.
3315
+ private def force_close_session_sockets(session_id)
3316
+ conns = @ws_mutex.synchronize { (@ws_clients[session_id] || []).dup }
3317
+ conns.each do |conn|
3318
+ Clacky::Logger.warn(
3319
+ "[interrupt] session=#{session_id} force-closing WS conn"
3320
+ )
3321
+ conn.force_close!
3322
+ end
3323
+ rescue StandardError => e
3324
+ Clacky::Logger.error("[interrupt] force_close_session_sockets error: #{e.class}: #{e.message}")
3325
+ end
3326
+
2732
3327
  # Start the pending task for a session.
2733
3328
  # Called when the client sends "run_task" over WS — by that point the
2734
3329
  # client has already subscribed, so every broadcast will be delivered.
@@ -2810,14 +3405,24 @@ module Clacky
2810
3405
  end
2811
3406
 
2812
3407
  # Broadcast an event to all clients subscribed to a session.
2813
- # Dead connections (broken pipe / closed socket) are removed automatically.
3408
+ # Dead connections (broken pipe / closed socket / deadline exceeded) are
3409
+ # removed automatically. Connections already marked closed are skipped
3410
+ # upfront so one sluggish client can't delay delivery to healthy ones.
2814
3411
  def broadcast(session_id, event)
2815
3412
  clients = @ws_mutex.synchronize { (@ws_clients[session_id] || []).dup }
2816
- dead = clients.reject { |conn| conn.send_json(event) }
3413
+ dead = []
3414
+ clients.each do |conn|
3415
+ if conn.closed?
3416
+ dead << conn
3417
+ next
3418
+ end
3419
+ dead << conn unless conn.send_json(event)
3420
+ end
2817
3421
  return if dead.empty?
2818
3422
 
2819
3423
  @ws_mutex.synchronize do
2820
3424
  (@ws_clients[session_id] || []).reject! { |conn| dead.include?(conn) }
3425
+ @all_ws_conns.reject! { |conn| dead.include?(conn) }
2821
3426
  end
2822
3427
  end
2823
3428
 
@@ -2825,7 +3430,14 @@ module Clacky
2825
3430
  # Dead connections are removed automatically.
2826
3431
  def broadcast_all(event)
2827
3432
  clients = @ws_mutex.synchronize { @all_ws_conns.dup }
2828
- dead = clients.reject { |conn| conn.send_json(event) }
3433
+ dead = []
3434
+ clients.each do |conn|
3435
+ if conn.closed?
3436
+ dead << conn
3437
+ next
3438
+ end
3439
+ dead << conn unless conn.send_json(event)
3440
+ end
2829
3441
  return if dead.empty?
2830
3442
 
2831
3443
  @ws_mutex.synchronize do
@@ -2837,7 +3449,7 @@ module Clacky
2837
3449
  # Broadcast a session_update event to all clients so they can patch their
2838
3450
  # local session list without needing a full session_list refresh.
2839
3451
  def broadcast_session_update(session_id)
2840
- session = @registry.list(limit: 200).find { |s| s[:id] == session_id }
3452
+ session = @registry.snapshot(session_id)
2841
3453
  return unless session
2842
3454
 
2843
3455
  broadcast_all(type: "session_update", session: session)
@@ -3069,14 +3681,33 @@ module Clacky
3069
3681
  # ── Inner classes ─────────────────────────────────────────────────────────
3070
3682
 
3071
3683
  # Wraps a raw TCP socket, providing thread-safe WebSocket frame sending.
3684
+ #
3685
+ # IMPORTANT: send_raw is called from the Agent thread via broadcast() →
3686
+ # send_json(). A blocking socket write with no deadline can pin the Agent
3687
+ # thread indefinitely when the client's receive buffer fills up (silent
3688
+ # disconnects such as Wi-Fi handoff or NAT timeout, where TCP keepalive
3689
+ # defaults are measured in hours). Thread#raise on blocking native socket
3690
+ # writes is best-effort and unreliable, so instead we bound every write
3691
+ # with an explicit deadline using IO.select + write_nonblock and declare
3692
+ # the connection dead on timeout.
3072
3693
  class WebSocketConnection
3073
3694
  attr_accessor :session_id
3074
3695
 
3696
+ # Maximum time a single send_raw call is allowed to spend writing.
3697
+ # 5 seconds is generous for healthy LAN/Internet clients and short
3698
+ # enough that a stuck Agent becomes responsive again quickly.
3699
+ SEND_DEADLINE = 5.0
3700
+
3701
+ # Warn threshold — any individual send_raw that exceeds this is logged
3702
+ # so we can spot sluggish clients before they fully hang.
3703
+ SEND_SLOW_WARN = 1.0
3704
+
3075
3705
  def initialize(socket, version)
3076
3706
  @socket = socket
3077
3707
  @version = version
3078
3708
  @send_mutex = Mutex.new
3079
3709
  @closed = false
3710
+ WebSocketConnection.apply_keepalive(socket)
3080
3711
  end
3081
3712
 
3082
3713
  # Returns true if the underlying socket has been detected as dead.
@@ -3084,6 +3715,15 @@ module Clacky
3084
3715
  @closed
3085
3716
  end
3086
3717
 
3718
+ # Force-close the connection (used by the interrupt watchdog when an
3719
+ # Agent thread is stuck on an unresponsive socket write).
3720
+ def force_close!
3721
+ @closed = true
3722
+ @socket.close
3723
+ rescue StandardError
3724
+ # best effort
3725
+ end
3726
+
3087
3727
  # Send a JSON-serializable object over the WebSocket.
3088
3728
  # Returns true on success, false if the connection is dead.
3089
3729
  def send_json(data)
@@ -3094,8 +3734,14 @@ module Clacky
3094
3734
  end
3095
3735
 
3096
3736
  # Send a raw WebSocket frame.
3097
- # Returns true on success, false on broken/closed socket.
3737
+ # Returns true on success, false on broken/closed/sluggish socket.
3738
+ #
3739
+ # Uses write_nonblock with an overall deadline so the caller (typically
3740
+ # the Agent thread) never blocks longer than SEND_DEADLINE, even if the
3741
+ # client silently stopped reading.
3098
3742
  def send_raw(type, data)
3743
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
3744
+
3099
3745
  @send_mutex.synchronize do
3100
3746
  return false if @closed
3101
3747
 
@@ -3104,7 +3750,30 @@ module Clacky
3104
3750
  data: data,
3105
3751
  type: type
3106
3752
  )
3107
- @socket.write(outgoing.to_s)
3753
+ bytes = outgoing.to_s
3754
+
3755
+ unless write_with_deadline(bytes, SEND_DEADLINE)
3756
+ # Deadline exceeded — treat as a dead connection so broadcast
3757
+ # purges it and the Agent thread is freed immediately.
3758
+ @closed = true
3759
+ begin
3760
+ @socket.close
3761
+ rescue StandardError
3762
+ # ignore
3763
+ end
3764
+ Clacky::Logger.warn(
3765
+ "[WS] send_raw deadline exceeded — closing sluggish connection " \
3766
+ "(bytes=#{bytes.bytesize}, deadline=#{SEND_DEADLINE}s)"
3767
+ )
3768
+ return false
3769
+ end
3770
+ end
3771
+
3772
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
3773
+ if elapsed > SEND_SLOW_WARN
3774
+ Clacky::Logger.warn(
3775
+ "[WS] send_raw slow: #{elapsed.round(2)}s (type=#{type})"
3776
+ )
3108
3777
  end
3109
3778
  true
3110
3779
  rescue Errno::EPIPE, Errno::ECONNRESET, IOError, Errno::EBADF => e
@@ -3116,6 +3785,70 @@ module Clacky
3116
3785
  Clacky::Logger.debug("WS send_raw unexpected error: #{e.message}")
3117
3786
  false
3118
3787
  end
3788
+
3789
+ # Write `data` to the underlying socket, bounded by `deadline` seconds
3790
+ # of *total* wall time across partial writes. Returns true on full
3791
+ # success, false on timeout.
3792
+ private def write_with_deadline(data, deadline)
3793
+ remaining = data
3794
+ deadline_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + deadline
3795
+
3796
+ until remaining.empty?
3797
+ time_left = deadline_at - Process.clock_gettime(Process::CLOCK_MONOTONIC)
3798
+ return false if time_left <= 0
3799
+
3800
+ begin
3801
+ written = @socket.write_nonblock(remaining, exception: false)
3802
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError, Errno::EBADF
3803
+ raise
3804
+ end
3805
+
3806
+ case written
3807
+ when :wait_writable
3808
+ ready = IO.select(nil, [@socket], nil, [time_left, 0.25].min)
3809
+ # Not ready → loop and re-check the overall deadline.
3810
+ next unless ready
3811
+ when Integer
3812
+ remaining = remaining.byteslice(written, remaining.bytesize - written)
3813
+ else
3814
+ # Nil or unexpected — treat as dead.
3815
+ return false
3816
+ end
3817
+ end
3818
+
3819
+ true
3820
+ end
3821
+
3822
+ # Enable TCP keepalive on the underlying socket so silently dead
3823
+ # peers are detected in minutes instead of the OS default of hours.
3824
+ # Best-effort: any failure is logged at debug level and ignored.
3825
+ def self.apply_keepalive(socket)
3826
+ return unless socket.respond_to?(:setsockopt)
3827
+
3828
+ socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
3829
+
3830
+ # TCP-level keepalive tuning — constants vary by platform and are
3831
+ # only set when available. Values chosen to detect dead peers in
3832
+ # roughly 60-90 seconds total.
3833
+ if defined?(Socket::IPPROTO_TCP)
3834
+ # Idle time before first probe (Linux: TCP_KEEPIDLE, macOS: TCP_KEEPALIVE)
3835
+ idle_const = if Socket.const_defined?(:TCP_KEEPIDLE)
3836
+ Socket::TCP_KEEPIDLE
3837
+ elsif Socket.const_defined?(:TCP_KEEPALIVE)
3838
+ Socket::TCP_KEEPALIVE
3839
+ end
3840
+ socket.setsockopt(Socket::IPPROTO_TCP, idle_const, 60) if idle_const
3841
+
3842
+ if Socket.const_defined?(:TCP_KEEPINTVL)
3843
+ socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_KEEPINTVL, 10)
3844
+ end
3845
+ if Socket.const_defined?(:TCP_KEEPCNT)
3846
+ socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_KEEPCNT, 3)
3847
+ end
3848
+ end
3849
+ rescue StandardError => e
3850
+ Clacky::Logger.debug("[WS] failed to set keepalive: #{e.class}: #{e.message}")
3851
+ end
3119
3852
  end
3120
3853
  end
3121
3854
  end