openclacky 1.3.5 → 1.3.7

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 (108) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/lib/clacky/agent/message_compressor.rb +32 -8
  4. data/lib/clacky/agent/message_compressor_helper.rb +113 -12
  5. data/lib/clacky/agent.rb +8 -4
  6. data/lib/clacky/agent_profile.rb +121 -53
  7. data/lib/clacky/brand_config.rb +309 -6
  8. data/lib/clacky/cli.rb +4 -58
  9. data/lib/clacky/default_extensions/coding/ext.yml +18 -0
  10. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +95 -0
  11. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +254 -0
  12. data/lib/clacky/default_extensions/ext-studio/ext.yml +32 -0
  13. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +1269 -0
  14. data/lib/clacky/default_extensions/ext-studio/skills/ext-debug/SKILL.md +71 -0
  15. data/lib/clacky/default_extensions/ext-studio/skills/ext-publish/SKILL.md +74 -0
  16. data/lib/clacky/default_extensions/ext-studio/skills/ext-scaffold/SKILL.md +65 -0
  17. data/lib/clacky/default_extensions/general/ext.yml +18 -0
  18. data/lib/clacky/default_extensions/git/ext.yml +12 -0
  19. data/lib/clacky/{default_agents/_panels/git/panel.js → default_extensions/git/panels/git/view.js} +1 -2
  20. data/lib/clacky/default_extensions/meeting/{handler.rb → api/handler.rb} +78 -26
  21. data/lib/clacky/default_extensions/meeting/ext.yml +15 -0
  22. data/lib/clacky/default_extensions/meeting/{meeting.js → panels/meeting/view.js} +266 -89
  23. data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +2 -1
  24. data/lib/clacky/default_extensions/time_machine/ext.yml +12 -0
  25. data/lib/clacky/{default_agents/_panels/time_machine/panel.js → default_extensions/time_machine/panels/time_machine/view.js} +58 -47
  26. data/lib/clacky/default_skills/channel-manager/discord_setup.rb +1 -1
  27. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +83 -18
  28. data/lib/clacky/{api_extension.rb → extension/api_extension.rb} +27 -11
  29. data/lib/clacky/extension/api_loader.rb +136 -0
  30. data/lib/clacky/extension/cli_commands.rb +226 -0
  31. data/lib/clacky/{server/api_extension_dispatcher.rb → extension/dispatcher.rb} +22 -9
  32. data/lib/clacky/extension/hook_loader.rb +77 -0
  33. data/lib/clacky/extension/loader.rb +483 -0
  34. data/lib/clacky/extension/packager.rb +226 -0
  35. data/lib/clacky/{patch_loader.rb → extension/patch_loader.rb} +45 -0
  36. data/lib/clacky/extension/scaffold/template_renderer.rb +64 -0
  37. data/lib/clacky/extension/scaffold/templates/full/README.md.erb +48 -0
  38. data/lib/clacky/extension/scaffold/templates/full/agents/designer.md +5 -0
  39. data/lib/clacky/extension/scaffold/templates/full/api/handler.rb.erb +16 -0
  40. data/lib/clacky/extension/scaffold/templates/full/channels/noop.rb.erb +40 -0
  41. data/lib/clacky/extension/scaffold/templates/full/ext.yml.erb +41 -0
  42. data/lib/clacky/extension/scaffold/templates/full/hooks/audit.rb +9 -0
  43. data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +194 -0
  44. data/lib/clacky/extension/scaffold/templates/full/patches/audit.rb +15 -0
  45. data/lib/clacky/extension/scaffold/templates/full/skills/__slug__-skill/SKILL.md.erb +8 -0
  46. data/lib/clacky/extension/scaffold/templates/hello/api/handler.rb.erb +9 -0
  47. data/lib/clacky/extension/scaffold/templates/hello/ext.yml.erb +12 -0
  48. data/lib/clacky/extension/scaffold/templates/hello/panels/hello/view.js.erb +31 -0
  49. data/lib/clacky/extension/scaffold.rb +55 -0
  50. data/lib/clacky/extension/verifier.rb +196 -0
  51. data/lib/clacky/identity.rb +70 -0
  52. data/lib/clacky/locales/en.rb +19 -1
  53. data/lib/clacky/locales/zh.rb +19 -1
  54. data/lib/clacky/platform_http_client.rb +18 -15
  55. data/lib/clacky/server/channel/extension_adapter_loader.rb +32 -0
  56. data/lib/clacky/server/channel.rb +6 -0
  57. data/lib/clacky/server/http_server.rb +435 -263
  58. data/lib/clacky/server/session_registry.rb +59 -3
  59. data/lib/clacky/session_manager.rb +35 -4
  60. data/lib/clacky/skill_loader.rb +28 -1
  61. data/lib/clacky/ui2/layout_manager.rb +0 -5
  62. data/lib/clacky/ui2/progress_handle.rb +0 -3
  63. data/lib/clacky/ui2/ui_controller.rb +9 -15
  64. data/lib/clacky/version.rb +1 -1
  65. data/lib/clacky/web/app.css +782 -554
  66. data/lib/clacky/web/app.js +63 -15
  67. data/lib/clacky/web/auth.js +2 -0
  68. data/lib/clacky/web/components/notify.js +2 -0
  69. data/lib/clacky/web/components/sidebar.js +2 -8
  70. data/lib/clacky/web/core/ext.js +316 -79
  71. data/lib/clacky/web/features/backup/store.js +24 -0
  72. data/lib/clacky/web/features/backup/view.js +49 -23
  73. data/lib/clacky/web/features/brand/view.js +13 -5
  74. data/lib/clacky/web/features/extensions/store.js +184 -0
  75. data/lib/clacky/web/features/extensions/view.js +389 -0
  76. data/lib/clacky/web/features/new-session/store.js +186 -0
  77. data/lib/clacky/web/features/new-session/view.js +319 -0
  78. data/lib/clacky/web/features/share/view.js +63 -14
  79. data/lib/clacky/web/features/skills/store.js +3 -1
  80. data/lib/clacky/web/features/skills/view.js +1 -1
  81. data/lib/clacky/web/features/tasks/view.js +88 -27
  82. data/lib/clacky/web/features/trash/store.js +24 -6
  83. data/lib/clacky/web/features/trash/view.js +46 -9
  84. data/lib/clacky/web/features/workspace/store.js +2 -0
  85. data/lib/clacky/web/features/workspace/view.js +0 -1
  86. data/lib/clacky/web/i18n.js +140 -108
  87. data/lib/clacky/web/index.html +190 -214
  88. data/lib/clacky/web/sessions.js +99 -304
  89. data/lib/clacky/web/settings.js +1 -1
  90. data/lib/clacky/web/skills.js +2 -0
  91. data/lib/clacky/web/theme.js +2 -0
  92. data/lib/clacky/web/ws-dispatcher.js +2 -1
  93. data/lib/clacky/web/ws.js +2 -0
  94. data/lib/clacky.rb +21 -10
  95. metadata +55 -24
  96. data/benchmark/runner.rb +0 -441
  97. data/lib/clacky/api_extension_loader.rb +0 -168
  98. data/lib/clacky/default_agents/SOUL.md +0 -3
  99. data/lib/clacky/default_agents/USER.md +0 -1
  100. data/lib/clacky/default_agents/coding/profile.yml +0 -5
  101. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  102. data/lib/clacky/default_agents/general/profile.yml +0 -2
  103. data/lib/clacky/default_extensions/meeting/meta.yml +0 -3
  104. data/lib/clacky/web/features/creator/store.js +0 -81
  105. data/lib/clacky/web/features/creator/view.js +0 -380
  106. /data/lib/clacky/{default_agents → default_extensions/coding/agents}/coding/system_prompt.md +0 -0
  107. /data/lib/clacky/{default_agents → default_extensions/general/agents}/general/system_prompt.md +0 -0
  108. /data/lib/clacky/{default_agents/base_prompt.md → prompts/base.md} +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5a7474760c07220891bc62795e95ab0f5b9f83387b4881e1cc9eec4133545222
4
- data.tar.gz: '099b846d3a8b44af563403c05f5bc8c855b0925122aabeb3f01b77f8f0c8d18f'
3
+ metadata.gz: 48e94ccf83645b5e18be8d5d33582dc128b06824ab226dd314540658b9cc7e4a
4
+ data.tar.gz: 1112ed443505eca99e745838377ebbf4b6f1e96a7c7f83c7bf1cff844b5ddea9
5
5
  SHA512:
6
- metadata.gz: e494c9032f35cf631a91dbbff72c89004c1aa8991a6f630dc21c65a9d97b45bceb2afa9c69b2f25b0d4c0e909d317b64eff5ed7b972fd475edc2f19e81a2f779
7
- data.tar.gz: 7fb32e090e6cfd780cd0b4218bbfb00a0f939ce77089cc124e35d03a8870abdbb27f09de976304526b8e905d14107fc861914ba08f80d447a72e4d24a6711c8f
6
+ metadata.gz: b0e4b2664bb7990ec91dd7c5598cfb561ce6f931b1f006a962b6cbf5516ee424b3a6f605fd70c3996ad4d0e0bf1a20dfd626d6466020393cc8cf18832008a68a
7
+ data.tar.gz: 2b74eea0a552e5d26545779da2f0a2b1f88812063398ffbf15be913e396d6efe7131d8205bff766939ef252c4ca3b8075ce8e86c855de59774226e3e03bdbfde
data/CHANGELOG.md CHANGED
@@ -5,6 +5,48 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.3.7] - 2026-07-06
9
+
10
+ ### Added
11
+ - Extension ecosystem: install, manage, and publish extensions with Creator Studio
12
+ - Brand extension and brand badge support for extension agents
13
+ - Hook/profile extension type for intercepting and customizing agent behavior
14
+ - Extension upload CLI for publishing extensions from the command line
15
+ - New session page with improved layout and discoverability
16
+ - Session auto-cleanup hint, skeleton loading, and type filter in trash panel
17
+ - Renew button on brand warning bar for expiring subscriptions
18
+ - i18n tooltips for header theme toggle and trash toolbar buttons
19
+ - Refresh button in header to reload sessions
20
+ - xhigh reasoning effort level for supported models
21
+ - i18n file upload error messages
22
+
23
+ ### Improved
24
+ - Merge Creator Center into ext-studio as a unified extension hub
25
+ - Extension developer experience: scaffold template improvements, badge and author metadata support
26
+ - Share modal UI polish — theme dots on hover, close button hover effect, layout refinement
27
+ - Unify Clacky.* JS namespace and single-segment extension architecture
28
+ - Migrate default agents to default extensions with unified panel visibility
29
+
30
+ ### Fixed
31
+ - Ctrl+C now works properly in TUI auto-answer mode
32
+ - Show-system-skills toggle persists correctly and uses theme accent color
33
+ - Cron display handles every-N-hours with weekdays pattern correctly
34
+ - New-session dropdown arrow button responds to both click and keyboard
35
+ - New cron virtual entry inserts at the correct position relative to pinned sessions
36
+ - File upload error messages use i18n consistently
37
+
38
+ ## [1.3.6] - 2026-06-30
39
+
40
+ ### Added
41
+ - Cron task session isolation with independent pagination and stable task count
42
+ - Backup/restore UI in settings with WebSocket reconnect refresh and WSL open-folder support
43
+
44
+ ### Fixed
45
+ - Rewrite human-readable cron parser to handle comma-separated lists, hour ranges, and `*/1` normalization
46
+ - Setup submit button hover color follows accent theme variable
47
+ - Settings button no longer navigates away when already on settings page
48
+ - Use Unix epoch for old thread timestamps to avoid date parsing issues
49
+
8
50
  ## [1.3.5] - 2026-06-29
9
51
 
10
52
  ### Added
@@ -39,10 +39,14 @@ module Clacky
39
39
 
40
40
  REQUIRED RESPONSE FORMAT:
41
41
  First output a <topics> line listing 3-6 key topic phrases (comma-separated, concise).
42
+ Then output a <continues_previous> line: "true" if this conversation is a direct
43
+ continuation of the SAME task/topic as the PREVIOUS chunk shown below, "false" if it
44
+ has moved on to a different task or topic. If there is no previous chunk, output "false".
42
45
  Then output the full summary wrapped in <summary> tags.
43
46
 
44
47
  Example format:
45
48
  <topics>Rails setup, database config, deploy pipeline, Tailwind CSS</topics>
49
+ <continues_previous>false</continues_previous>
46
50
  <summary>
47
51
  ...full summary text...
48
52
  </summary>
@@ -54,7 +58,8 @@ module Clacky
54
58
  - Errors encountered and fixes applied
55
59
  - Current work status and pending tasks
56
60
 
57
- Begin your response NOW. Remember: PURE TEXT only, starting with <topics> then <summary>.
61
+ Begin your response NOW. Remember: PURE TEXT only, starting with <topics> then
62
+ <continues_previous> then <summary>.
58
63
  PROMPT
59
64
 
60
65
  def initialize(client, model: nil)
@@ -72,18 +77,25 @@ module Clacky
72
77
  #
73
78
  # @param messages [Array<Hash>] Original conversation messages
74
79
  # @param recent_messages [Array<Hash>] Recent messages to keep uncompressed (optional)
80
+ # @param previous_topics [String, nil] Topics of the most recent chunk on disk,
81
+ # shown to the LLM so it can decide whether the current conversation is a
82
+ # continuation (drives the <continues_previous> output for chunk merging).
75
83
  # @return [Hash] Compression instruction message to insert, or nil if nothing to compress
76
- def build_compression_message(messages, recent_messages: [])
84
+ def build_compression_message(messages, recent_messages: [], previous_topics: nil)
77
85
  # Get messages to compress (exclude system message and recent messages)
78
86
  messages_to_compress = messages.reject { |m| m[:role] == "system" || recent_messages.include?(m) }
79
87
 
80
88
  # If nothing to compress, return nil
81
89
  return nil if messages_to_compress.empty?
82
90
 
83
- # Simple compression instruction - LLM can see the history already
84
- {
85
- role: "user",
86
- content: COMPRESSION_PROMPT,
91
+ content = COMPRESSION_PROMPT
92
+ if previous_topics && !previous_topics.strip.empty?
93
+ content = "#{COMPRESSION_PROMPT}\n\nPREVIOUS CHUNK TOPICS (for <continues_previous> judgement): #{previous_topics}"
94
+ end
95
+
96
+ {
97
+ role: "user",
98
+ content: content,
87
99
  system_injected: true
88
100
  }
89
101
  end
@@ -142,6 +154,15 @@ module Clacky
142
154
  m ? m[1].strip : nil
143
155
  end
144
156
 
157
+ # Parse the <continues_previous> tag. Returns true only when the LLM
158
+ # explicitly says "true"; missing tag or any other value → false.
159
+ # This conservative default ensures we never merge unless the model is sure.
160
+ def parse_continues_previous(content)
161
+ return false if content.nil? || content.to_s.empty?
162
+ m = content.to_s.match(/<continues_previous>(.*?)<\/continues_previous>/m)
163
+ m ? m[1].strip.downcase == "true" : false
164
+ end
165
+
145
166
  def parse_compressed_result(result, chunk_path: nil, topics: nil, previous_chunks: [])
146
167
  # Return the compressed result as a single user message (role: "user").
147
168
  #
@@ -171,8 +192,11 @@ module Clacky
171
192
  if content.empty?
172
193
  []
173
194
  else
174
- # Strip out the <topics> blockit's metadata for the chunk file, not for AI context
175
- content_without_topics = content.gsub(/<topics>.*?<\/topics>\n*/m, "").strip
195
+ # Strip out the <topics> and <continues_previous> blocks they're
196
+ # metadata for chunk handling, not for AI context.
197
+ content_without_topics = content.gsub(/<topics>.*?<\/topics>\n*/m, "")
198
+ .gsub(/<continues_previous>.*?<\/continues_previous>\n*/m, "")
199
+ .strip
176
200
 
177
201
  # Build previous chunks index section — links to older chunk files so the AI
178
202
  # can find earlier conversations without keeping all prior compressed_summary
@@ -193,8 +193,18 @@ module Clacky
193
193
  recent_messages = get_recent_messages_with_tool_pairs(all_messages, target_recent_count)
194
194
  recent_messages = [] if recent_messages.nil?
195
195
 
196
+ # Surface the most recent chunk's topics so the compression LLM can judge
197
+ # whether this conversation continues the same task (drives chunk merging).
198
+ previous_topics = nil
199
+ if @session_id && @created_at
200
+ latest = session_manager.chunks_for_current(@session_id, @created_at).last
201
+ previous_topics = latest && latest[:topics]
202
+ end
203
+
196
204
  # Build compression instruction message (to be inserted into conversation)
197
- compression_message = @message_compressor.build_compression_message(all_messages, recent_messages: recent_messages)
205
+ compression_message = @message_compressor.build_compression_message(
206
+ all_messages, recent_messages: recent_messages, previous_topics: previous_topics
207
+ )
198
208
 
199
209
  return nil if compression_message.nil?
200
210
 
@@ -242,25 +252,52 @@ module Clacky
242
252
  # all chunk file I/O (naming, writing, discovery) — we just ask it.
243
253
  sm = session_manager
244
254
  existing_chunks = sm.chunks_for_current(@session_id, @created_at)
245
- chunk_index = sm.next_chunk_index(@session_id, @created_at)
246
255
 
247
256
  # Extract topics from the LLM response to store in both the chunk MD front
248
257
  # matter and the compressed_summary message hash (for future chunk indexing).
249
258
  topics = @message_compressor.parse_topics(compressed_content)
250
259
 
251
- chunk_path = save_compressed_chunk(
252
- original_messages,
253
- compression_context[:recent_messages],
254
- chunk_index: chunk_index,
255
- compression_level: compression_context[:compression_level],
256
- topics: topics
257
- )
260
+ # Decide whether to MERGE into the previous chunk or create a NEW one.
261
+ # The LLM judges (via <continues_previous>) whether this conversation is a
262
+ # direct continuation of the previous chunk's task. Merging avoids tiny
263
+ # fragmented chunks (e.g. a long task compressed mid-flight into 2-message
264
+ # chunks) that pollute the topics index and degrade recall.
265
+ latest_chunk = existing_chunks.last
266
+ continues = latest_chunk && @message_compressor.parse_continues_previous(compressed_content)
267
+
268
+ if continues
269
+ chunk_path = merge_into_previous_chunk(
270
+ latest_chunk,
271
+ original_messages,
272
+ compression_context[:recent_messages],
273
+ compression_level: compression_context[:compression_level],
274
+ topics: topics
275
+ )
276
+ # Fallback to new chunk if the merge could not be performed.
277
+ chunk_path ||= save_compressed_chunk(
278
+ original_messages, compression_context[:recent_messages],
279
+ chunk_index: sm.next_chunk_index(@session_id, @created_at),
280
+ compression_level: compression_context[:compression_level], topics: topics
281
+ )
282
+ # The merged chunk is the current chunk — exclude it from previous_chunks.
283
+ index_chunks = existing_chunks.reject { |c| c[:index] == latest_chunk[:index] }
284
+ else
285
+ chunk_index = sm.next_chunk_index(@session_id, @created_at)
286
+ chunk_path = save_compressed_chunk(
287
+ original_messages,
288
+ compression_context[:recent_messages],
289
+ chunk_index: chunk_index,
290
+ compression_level: compression_context[:compression_level],
291
+ topics: topics
292
+ )
293
+ index_chunks = existing_chunks
294
+ end
258
295
 
259
296
  # Build previous_chunks index from the disk-discovered chunks (already
260
297
  # sorted by index ascending). This gives the new summary a complete
261
298
  # chronological index of all older archives so the AI can recall any
262
299
  # past chunk via file_reader, not just the most recent one.
263
- previous_chunks = existing_chunks.map do |c|
300
+ previous_chunks = index_chunks.map do |c|
264
301
  { basename: c[:basename], path: c[:path], topics: c[:topics] }
265
302
  end
266
303
 
@@ -483,6 +520,62 @@ module Clacky
483
520
  nil
484
521
  end
485
522
 
523
+ # Merge the current batch of compressed messages INTO an existing chunk
524
+ # (overwrite-in-place, same chunk index). Used when the LLM judged this
525
+ # conversation as a continuation of the previous chunk's task. Keeps the
526
+ # archive on a single, growing, well-formed chunk instead of fragmenting
527
+ # into tiny standalone files that pollute the topics index.
528
+ #
529
+ # Every write hits disk immediately, so a crash never loses archived
530
+ # messages — there is no in-memory buffering.
531
+ #
532
+ # @param prev_chunk [Hash] disk-discovered chunk hash ({ index:, path:, topics: })
533
+ # @return [String, nil] the chunk path on success, nil if merge not possible
534
+ def merge_into_previous_chunk(prev_chunk, original_messages, recent_messages, compression_level:, topics: nil)
535
+ return nil unless @session_id && @created_at
536
+
537
+ recent_set = recent_messages.to_a
538
+ messages_to_archive = original_messages.reject do |m|
539
+ m[:role] == "system" || m[:system_injected] || m[:compressed_summary] || recent_set.include?(m)
540
+ end
541
+ return nil if messages_to_archive.empty?
542
+
543
+ sm = session_manager
544
+ raw = sm.read_chunk(prev_chunk[:path])
545
+ return nil unless raw
546
+
547
+ fm, body = sm.split_chunk_md(raw)
548
+ return nil unless fm
549
+
550
+ new_sections = render_message_sections(messages_to_archive)
551
+
552
+ fm["compression_level"] = compression_level.to_s
553
+ fm["archived_at"] = Time.now.iso8601
554
+ fm["message_count"] = (fm["message_count"].to_i + messages_to_archive.size).to_s
555
+ fm["merged_count"] = (fm.fetch("merged_count", "1").to_i + 1).to_s
556
+ fm["topics"] = merge_topics(fm["topics"], topics)
557
+
558
+ lines = ["---"]
559
+ fm.each { |k, v| lines << "#{k}: #{v}" }
560
+ lines << "---"
561
+ lines << body.rstrip
562
+ lines << ""
563
+ lines.concat(new_sections)
564
+
565
+ sm.write_chunk(@session_id, @created_at, prev_chunk[:index], lines.join("\n"))
566
+ rescue => e
567
+ @ui&.log("Failed to merge chunk MD: #{e.message}", level: :warn)
568
+ nil
569
+ end
570
+
571
+ # Union two comma-separated topic strings, preserving order, dropping dups.
572
+ private def merge_topics(existing, incoming)
573
+ a = (existing || "").split(/\s*,\s*/).map(&:strip).reject(&:empty?)
574
+ b = (incoming || "").split(/\s*,\s*/).map(&:strip).reject(&:empty?)
575
+ merged = (a + b).uniq
576
+ merged.empty? ? nil : merged.join(", ")
577
+ end
578
+
486
579
  # Build markdown content from a list of messages
487
580
  # @param messages [Array<Hash>] Messages to render
488
581
  # @param chunk_index [Integer] Chunk number for metadata
@@ -508,6 +601,15 @@ module Clacky
508
601
  lines << "> Use `file_reader` to recall specific details from this conversation."
509
602
  lines << ""
510
603
 
604
+ lines.concat(render_message_sections(messages))
605
+
606
+ lines.join("\n")
607
+ end
608
+
609
+ # Render messages into chunk MD body sections (no front matter / header).
610
+ # Shared by build_chunk_md and the chunk-merge path.
611
+ def render_message_sections(messages)
612
+ lines = []
511
613
  messages.each do |msg|
512
614
  role = msg[:role]
513
615
  content = msg[:content]
@@ -560,8 +662,7 @@ module Clacky
560
662
  lines << ""
561
663
  end
562
664
  end
563
-
564
- lines.join("\n")
665
+ lines
565
666
  end
566
667
 
567
668
  # Format message content (handles string or array of content blocks)
data/lib/clacky/agent.rb CHANGED
@@ -51,7 +51,7 @@ module Clacky
51
51
  attr_accessor :pinned
52
52
  attr_accessor :channel_info
53
53
 
54
- REASONING_EFFORTS = %w[low medium high].freeze
54
+ REASONING_EFFORTS = %w[low medium high xhigh].freeze
55
55
 
56
56
  def permission_mode
57
57
  @config&.permission_mode&.to_s || ""
@@ -136,6 +136,8 @@ module Clacky
136
136
  # Free-mode counterpart: branded but not activated → fetch unencrypted skills
137
137
  # via the public endpoint so users get a working install with no serial number.
138
138
  @brand_config.sync_free_skills_async!
139
+ # Brand extensions bundled into the activated license's distribution.
140
+ @brand_config.sync_brand_extensions_async!
139
141
 
140
142
  # Initialize Time Machine
141
143
  init_time_machine
@@ -146,6 +148,11 @@ module Clacky
146
148
  # Load declarative shell hooks from ~/.clacky/hooks.yml
147
149
  ShellHookLoader.load_into(@hooks)
148
150
 
151
+ # Copy ext.yml-contributed hook callbacks (contributes.hooks) onto this
152
+ # agent's hook manager. The callbacks were registered process-wide at
153
+ # boot via ExtensionHookLoader.
154
+ ExtensionHookRegistry.apply_to(@hooks)
155
+
149
156
  # Ensure user-space parsers are in place (~/.clacky/parsers/)
150
157
  Utils::ParserManager.setup!
151
158
 
@@ -683,9 +690,6 @@ module Clacky
683
690
  raise
684
691
  ensure
685
692
  # Safety net: ensure any lingering progress spinner is stopped.
686
- # Normal paths close their own spinners; this guards against exceptions
687
- # raised between a progress slot's active/done pair.
688
- Clacky::Logger.warn("[ph_debug] agent_run_ensure")
689
693
  @ui&.show_progress(phase: "done")
690
694
 
691
695
  # Fire-and-forget telemetry after every agent run.
@@ -6,107 +6,175 @@ module Clacky
6
6
  # Loads and represents an agent profile (system prompt + skill whitelist).
7
7
  #
8
8
  # Lookup order for a profile named "coding":
9
- # 1. ~/.clacky/agents/coding/ (user override)
10
- # 2. <gem>/lib/clacky/default_agents/coding/ (built-in default)
9
+ # 1. ~/.clacky/agents/coding/ (user override, physical dir)
10
+ # 2. extension agent unit with id == "coding" (ext.yml contributes.agents)
11
11
  #
12
- # Each profile directory must contain:
12
+ # Each user profile directory (opt-in override) contains:
13
13
  # - profile.yml — name, description, skills whitelist
14
14
  # - system_prompt.md — agent-specific system prompt content
15
15
  #
16
- # Global files (shared across all agents), also with user-override support:
17
- # - SOUL.md — agent personality/values
18
- # - USER.md — user profile information
19
- # - base_prompt.md universal behavioral rules (todo manager, tool usage, etc.)
16
+ # Global files (shared across all agents) are user-only overrides:
17
+ # - ~/.clacky/agents/SOUL.md — agent personality/values (else DEFAULT_SOUL)
18
+ # - ~/.clacky/agents/USER.md — user profile info (else DEFAULT_USER)
19
+ # The universal behavioural rules (todo manager, tool usage, response style,
20
+ # etc.) live in a bundled resource file at lib/clacky/prompts/base.md.
20
21
  class AgentProfile
21
- DEFAULT_AGENTS_DIR = File.expand_path("../default_agents", __FILE__).freeze
22
22
  USER_AGENTS_DIR = File.expand_path("~/.clacky/agents").freeze
23
+ BASE_PROMPT_PATH = File.expand_path("../prompts/base.md", __FILE__).freeze
24
+
25
+ DEFAULT_SOUL = <<~MD.freeze
26
+ You are calm, precise, and helpful. You communicate clearly and concisely.
27
+ You are honest about uncertainty and ask for clarification when needed.
28
+ You take initiative but respect the user's preferences and decisions.
29
+ MD
30
+
31
+ DEFAULT_USER = "(No user profile configured yet. To personalize, create ~/.clacky/agents/USER.md)"
23
32
 
24
33
  attr_reader :name, :description
25
34
 
26
35
  def initialize(name)
27
36
  @name = name.to_s
37
+ result = ExtensionLoader.last_result
38
+ @ext_unit = result&.agents&.find { |u| u.id == @name }
39
+ if @ext_unit.nil?
40
+ result = ExtensionLoader.load_all(force: true)
41
+ @ext_unit = result&.agents&.find { |u| u.id == @name }
42
+ end
28
43
  profile_data = load_profile_yml
29
44
  @description = profile_data["description"] || ""
30
45
  @system_prompt_content = load_agent_file("system_prompt.md")
31
46
  end
32
47
 
33
- # Load a named profile. Raises ArgumentError if profile directory not found.
34
48
  # @param name [String, Symbol] profile name (e.g. "coding", "general")
35
49
  # @return [AgentProfile]
36
50
  def self.load(name)
37
51
  new(name)
38
52
  end
39
53
 
54
+ # List all available agent profiles across user + extension layers.
55
+ # Precedence on id collision: user override → extension unit.
56
+ # @return [Array<Hash>] each: { id:, title:, title_zh:, description:, description_zh:, source:, order:, layer:, author: }
57
+ def self.all
58
+ out = {}
59
+
60
+ add = lambda do |id, title, title_zh, description, description_zh, source, order, layer, author|
61
+ next if id.nil? || id.empty?
62
+ out[id] = {
63
+ id: id,
64
+ title: title,
65
+ title_zh: title_zh,
66
+ description: description,
67
+ description_zh: description_zh,
68
+ source: source,
69
+ order: order,
70
+ layer: layer,
71
+ author: author,
72
+ }
73
+ end
74
+
75
+ ext_result = ExtensionLoader.last_result || ExtensionLoader.load_all
76
+ ext_result&.agents&.each do |unit|
77
+ spec = unit.spec || {}
78
+ title = spec["title"].to_s
79
+ title = unit.id if title.empty?
80
+ add.call(
81
+ unit.id, title, spec["title_zh"].to_s,
82
+ spec["description"].to_s, spec["description_zh"].to_s,
83
+ "extension", spec["order"], unit.layer.to_s,
84
+ spec["author"].to_s
85
+ )
86
+ end
87
+
88
+ Dir.glob(File.join(USER_AGENTS_DIR, "*")).sort.each do |path|
89
+ next unless File.directory?(path)
90
+ id = File.basename(path)
91
+ next if id.start_with?("_")
92
+ next unless File.file?(File.join(path, "profile.yml"))
93
+ meta = read_profile_yml(File.join(path, "profile.yml"))
94
+ add.call(
95
+ id, meta["title"] || meta["name"] || id, meta["title_zh"].to_s,
96
+ meta["description"].to_s, meta["description_zh"].to_s,
97
+ "user", meta["order"], "user",
98
+ meta["author"].to_s.empty? ? "You" : meta["author"].to_s
99
+ )
100
+ end
101
+
102
+ source_rank = { "user" => 0, "extension" => 1 }
103
+ out.values.sort_by { |a| [source_rank[a[:source]] || 9, a[:order] || 999, a[:id]] }
104
+ end
105
+
106
+ private_class_method def self.read_profile_yml(path)
107
+ return {} unless File.file?(path)
108
+ YAML.safe_load(File.read(path)) || {}
109
+ rescue StandardError
110
+ {}
111
+ end
112
+
40
113
  # @return [String] agent-specific system prompt content
41
114
  def system_prompt
42
115
  @system_prompt_content
43
116
  end
44
117
 
45
- # @return [String] base prompt shared by all agents
118
+ # @return [String] base prompt shared by all agents (bundled resource)
46
119
  def base_prompt
47
- load_global_file("base_prompt.md")
120
+ return "" unless File.file?(BASE_PROMPT_PATH)
121
+ File.read(BASE_PROMPT_PATH).strip
48
122
  end
49
123
 
50
- # @return [String] soul content (user override built-in default)
124
+ # @return [String] soul content (user override, else default)
51
125
  def soul
52
- load_global_file("SOUL.md")
126
+ user_path = File.join(USER_AGENTS_DIR, "SOUL.md")
127
+ if File.exist?(user_path) && !File.zero?(user_path)
128
+ File.read(user_path).strip
129
+ else
130
+ DEFAULT_SOUL.strip
131
+ end
53
132
  end
54
133
 
55
- # @return [String] user profile content (user override built-in default)
134
+ # @return [String] user profile content (user override, else default)
56
135
  def user_profile
57
- load_global_file("USER.md")
136
+ user_path = File.join(USER_AGENTS_DIR, "USER.md")
137
+ if File.exist?(user_path) && !File.zero?(user_path)
138
+ File.read(user_path).strip
139
+ else
140
+ DEFAULT_USER
141
+ end
58
142
  end
59
143
 
60
144
  private def load_profile_yml
61
- path = find_agent_file("profile.yml")
62
- raise ArgumentError, "Agent profile '#{@name}' not found. " \
63
- "Looked in #{user_agent_dir} and #{default_agent_dir}" unless path
64
-
65
- YAML.safe_load(File.read(path)) || {}
66
- end
67
-
68
- # Load a file from the agent-specific directory (user override → built-in)
69
- private def load_agent_file(filename)
70
- path = find_agent_file(filename)
71
- return "" unless path
72
-
73
- File.read(path).strip
74
- end
75
-
76
- # Load a global file shared across all agents (user override → built-in)
77
- private def load_global_file(filename)
78
- user_path = File.join(USER_AGENTS_DIR, filename)
79
- default_path = File.join(DEFAULT_AGENTS_DIR, filename)
80
-
81
- path = if File.exist?(user_path) && !File.zero?(user_path)
82
- user_path
83
- elsif File.exist?(default_path)
84
- default_path
85
- end
145
+ user_yml = File.join(user_agent_dir, "profile.yml")
146
+ if File.file?(user_yml)
147
+ return YAML.safe_load(File.read(user_yml)) || {}
148
+ end
86
149
 
87
- return "" unless path
150
+ if @ext_unit
151
+ return {
152
+ "name" => @name,
153
+ "description" => @ext_unit.spec["description"],
154
+ "panels" => @ext_unit.spec["panels"],
155
+ "skills" => @ext_unit.spec["skills"],
156
+ }
157
+ end
88
158
 
89
- File.read(path).strip
159
+ raise ArgumentError, "Agent profile '#{@name}' not found. " \
160
+ "Looked in #{user_agent_dir} and extension registry."
90
161
  end
91
162
 
92
- # Find a file in user override dir first, then built-in default dir
93
- private def find_agent_file(filename)
163
+ # Agent-specific file lookup: user override extension prompt (system_prompt.md only).
164
+ private def load_agent_file(filename)
94
165
  user_path = File.join(user_agent_dir, filename)
95
- default_path = File.join(default_agent_dir, filename)
166
+ return File.read(user_path).strip if File.exist?(user_path) && !File.zero?(user_path)
96
167
 
97
- if File.exist?(user_path) && !File.zero?(user_path)
98
- user_path
99
- elsif File.exist?(default_path)
100
- default_path
168
+ if @ext_unit && filename == "system_prompt.md"
169
+ prompt_abs = @ext_unit.spec["prompt_abs"]
170
+ return File.read(prompt_abs).strip if prompt_abs && File.file?(prompt_abs)
101
171
  end
172
+
173
+ ""
102
174
  end
103
175
 
104
176
  private def user_agent_dir
105
177
  File.join(USER_AGENTS_DIR, @name)
106
178
  end
107
-
108
- private def default_agent_dir
109
- File.join(DEFAULT_AGENTS_DIR, @name)
110
- end
111
179
  end
112
180
  end