openclacky 0.9.38 → 1.0.0.beta.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +67 -13
  3. data/CHANGELOG.md +40 -0
  4. data/lib/clacky/agent/llm_caller.rb +48 -2
  5. data/lib/clacky/agent/memory_updater.rb +131 -35
  6. data/lib/clacky/agent/message_compressor.rb +30 -3
  7. data/lib/clacky/agent/message_compressor_helper.rb +53 -19
  8. data/lib/clacky/agent/time_machine.rb +12 -3
  9. data/lib/clacky/agent/tool_executor.rb +0 -3
  10. data/lib/clacky/agent.rb +190 -61
  11. data/lib/clacky/agent_config.rb +201 -47
  12. data/lib/clacky/brand_config.rb +77 -5
  13. data/lib/clacky/cli.rb +101 -45
  14. data/lib/clacky/message_format/bedrock.rb +4 -0
  15. data/lib/clacky/message_history.rb +79 -4
  16. data/lib/clacky/platform_http_client.rb +7 -7
  17. data/lib/clacky/providers.rb +170 -8
  18. data/lib/clacky/server/http_server.rb +138 -21
  19. data/lib/clacky/telemetry.rb +111 -0
  20. data/lib/clacky/tools/terminal.rb +27 -0
  21. data/lib/clacky/tools/todo_manager.rb +11 -2
  22. data/lib/clacky/ui2/layout_manager.rb +22 -1
  23. data/lib/clacky/ui2/progress_handle.rb +291 -0
  24. data/lib/clacky/ui2/ui_controller.rb +261 -185
  25. data/lib/clacky/ui_interface.rb +69 -0
  26. data/lib/clacky/version.rb +1 -1
  27. data/lib/clacky/web/app.css +53 -0
  28. data/lib/clacky/web/app.js +1 -1
  29. data/lib/clacky/web/brand.js +112 -1
  30. data/lib/clacky/web/i18n.js +24 -16
  31. data/lib/clacky/web/index.html +15 -2
  32. data/lib/clacky/web/sessions.js +23 -6
  33. data/lib/clacky/web/settings.js +34 -0
  34. data/lib/clacky/web/ws.js +3 -2
  35. data/lib/clacky.rb +1 -0
  36. data/scripts/install.ps1 +20 -5
  37. metadata +3 -2
  38. data/lib/clacky/ui2/README.md +0 -214
@@ -93,13 +93,22 @@ module Clacky
93
93
  # Filter messages to only show tasks up to active_task_id.
94
94
  # This hides "future" messages when user has undone.
95
95
  # Returns API-ready array (strips internal fields + handles orphaned tool_calls).
96
+ # @param force_reasoning_content_pad [Boolean] forwarded to MessageHistory,
97
+ # enables one-shot pad-and-retry for thinking-mode providers that
98
+ # require reasoning_content on every assistant message.
96
99
  # Made public for testing
97
- def active_messages
98
- return @history.to_api if @active_task_id == @current_task_id
100
+ def active_messages(force_reasoning_content_pad: false)
101
+ if @active_task_id == @current_task_id
102
+ return @history.to_api(force_reasoning_content_pad: force_reasoning_content_pad)
103
+ end
99
104
 
100
- @history.for_task(@active_task_id).map do |msg|
105
+ stripped = @history.for_task(@active_task_id).map do |msg|
101
106
  msg.reject { |k, _| MessageHistory::INTERNAL_FIELDS.include?(k) }
102
107
  end
108
+ # Apply the same reasoning_content padding rule used by to_api so
109
+ # Time Machine replays satisfy thinking-mode providers after a
110
+ # 400 retry.
111
+ MessageHistory.pad_reasoning_content_if_needed(stripped, force: force_reasoning_content_pad)
103
112
  end
104
113
 
105
114
  # Undo to parent task
@@ -10,9 +10,6 @@ module Clacky
10
10
  # @param tool_params [Hash, String] Tool parameters
11
11
  # @return [Boolean] true if should auto-execute
12
12
  def should_auto_execute?(tool_name, tool_params = {})
13
- # During memory update phase, always auto-execute (no user confirmation needed)
14
- return true if @memory_updating
15
-
16
13
  case @config.permission_mode
17
14
  when :auto_approve, :confirm_all
18
15
  # Both modes auto-execute all file/shell tools without confirmation.
data/lib/clacky/agent.rb CHANGED
@@ -265,9 +265,15 @@ module Clacky
265
265
  path = f[:path] || f["path"]
266
266
  name = f[:name] || f["name"]
267
267
  next f unless path && File.exist?(path.to_s)
268
+ # Preserve the downgrade_reason tag across the remap (process_path
269
+ # returns a fresh FileRef that doesn't know about it). Without this,
270
+ # the file_prompt builder can't emit the "not supported by model" /
271
+ # "too large" note for downgraded images.
272
+ downgrade_reason = f[:downgrade_reason] || f["downgrade_reason"]
268
273
  ref = Utils::FileProcessor.process_path(path, name: name)
269
274
  { name: ref.name, type: ref.type.to_s, path: ref.original_path,
270
- preview_path: ref.preview_path, parse_error: ref.parse_error, parser_path: ref.parser_path }
275
+ preview_path: ref.preview_path, parse_error: ref.parse_error, parser_path: ref.parser_path,
276
+ downgrade_reason: downgrade_reason }
271
277
  end
272
278
 
273
279
  # Build display_files for replay: lightweight metadata so the UI can reconstruct
@@ -295,13 +301,14 @@ module Clacky
295
301
 
296
302
  unless all_meta_files.empty?
297
303
  file_prompt = all_meta_files.filter_map do |f|
298
- name = f[:name] || f["name"]
299
- type = f[:type] || f["type"]
300
- path = f[:path] || f["path"]
301
- preview_path = f[:preview_path] || f["preview_path"]
302
- size_bytes = f[:size_bytes] || f["size_bytes"]
303
- parse_error = f[:parse_error] || f["parse_error"]
304
- parser_path = f[:parser_path] || f["parser_path"]
304
+ name = f[:name] || f["name"]
305
+ type = f[:type] || f["type"]
306
+ path = f[:path] || f["path"]
307
+ preview_path = f[:preview_path] || f["preview_path"]
308
+ size_bytes = f[:size_bytes] || f["size_bytes"]
309
+ parse_error = f[:parse_error] || f["parse_error"]
310
+ parser_path = f[:parser_path] || f["parser_path"]
311
+ downgrade_reason = f[:downgrade_reason] || f["downgrade_reason"]
305
312
 
306
313
  next unless name
307
314
 
@@ -310,6 +317,13 @@ module Clacky
310
317
  lines << "Original: #{path}" if path
311
318
  lines << "Preview (Markdown): #{preview_path}" if preview_path
312
319
 
320
+ # Inline note explaining why an image was *not* sent as vision
321
+ # content. Colocated with the file info (not in system prompt) so
322
+ # it reflects the exact reason for *this* upload under *this*
323
+ # model — switching models later won't leave stale warnings.
324
+ note = downgrade_note_for(downgrade_reason)
325
+ lines << "Note: #{note}" if note
326
+
313
327
  # Parser failed — instruct LLM to fix and re-run
314
328
  if preview_path.nil? && parse_error
315
329
  lines << "Parse failed: #{parse_error}"
@@ -362,10 +376,7 @@ module Clacky
362
376
 
363
377
  # Check if done (no more tool calls needed)
364
378
  if response[:finish_reason] == "stop" || response[:tool_calls].nil? || response[:tool_calls].empty?
365
- # During memory update phase, show LLM response as info (not a chat bubble)
366
- if @memory_updating && response[:content] && !response[:content].empty?
367
- @ui&.show_info(response[:content].strip)
368
- elsif response[:content] && !response[:content].empty?
379
+ if response[:content] && !response[:content].empty?
369
380
  emit_assistant_message(response[:content])
370
381
  end
371
382
 
@@ -382,15 +393,11 @@ module Clacky
382
393
  end
383
394
  end
384
395
 
385
- # Inject memory update prompt and let the loop handle it naturally
386
- next if inject_memory_prompt!
387
-
388
396
  break
389
397
  end
390
398
 
391
399
  # Show assistant message if there's content before tool calls
392
- # During memory update phase, suppress text output (only tool calls matter)
393
- if response[:content] && !response[:content].empty? && !@memory_updating
400
+ if response[:content] && !response[:content].empty?
394
401
  emit_assistant_message(response[:content])
395
402
  end
396
403
 
@@ -453,6 +460,17 @@ module Clacky
453
460
  run_skill_evolution_hooks
454
461
  end
455
462
 
463
+ # Run long-term memory update as a forked subagent BEFORE we print
464
+ # show_complete. Running it as a subagent (rather than inline in
465
+ # the main loop) gives us correct visual ordering structurally:
466
+ # the subagent blocks until done, its progress spinner finishes,
467
+ # and only then [OK] Task Complete is printed. No cleanup dance,
468
+ # no cross-method progress handle holding.
469
+ # Skip on interrupt / feedback / subagent (self-guarded inside too).
470
+ unless @is_subagent || task_interrupted || awaiting_user_feedback
471
+ run_memory_update_subagent
472
+ end
473
+
456
474
  if @is_subagent
457
475
  # Parent agent (skill_manager) prints the completion summary; skip here.
458
476
  else
@@ -489,9 +507,6 @@ module Clacky
489
507
  result = build_result(:error, error: e.message) # rubocop:disable Lint/UselessAssignment
490
508
  raise
491
509
  ensure
492
- # Always clean up memory update messages, even if interrupted or error occurred
493
- cleanup_memory_messages
494
-
495
510
  # Safety net: ensure any lingering progress spinner is stopped.
496
511
  # Normal paths close their own spinners; this guards against exceptions
497
512
  # raised between a progress slot's active/done pair.
@@ -501,6 +516,10 @@ module Clacky
501
516
  # This covers the inline-injection path; the subagent path shreds immediately after
502
517
  # subagent.run returns (see execute_skill_with_subagent).
503
518
  shred_script_tmpdirs
519
+
520
+ # Fire-and-forget telemetry after every agent run.
521
+ # Tracks daily active users (distinct devices per day) and task volume.
522
+ Clacky::Telemetry.task!
504
523
  end
505
524
  end
506
525
 
@@ -526,21 +545,33 @@ module Clacky
526
545
  @ui&.show_info(
527
546
  "Message history compression starting (~#{compression_context[:original_token_count]} tokens, #{compression_context[:original_message_count]} messages) - Level #{compression_context[:compression_level]}"
528
547
  )
529
- # Take over the progress slot with a compression-specific message.
530
- # handle_compression_response will close it with the summary line on
531
- # success; on failure the outer ensure below finalizes it.
532
- @ui&.show_progress(
533
- "Compressing message history...",
534
- progress_type: "idle_compress",
535
- phase: "active"
536
- )
548
+
537
549
  compression_message = compression_context[:compression_message]
538
550
  @history.append(compression_message)
539
551
  compression_handled = false
552
+
553
+ # Open a dedicated quiet-style handle for the compression work.
554
+ # This sits on top of the outer thinking progress (if any); Plan B
555
+ # semantics detach the outer spinner until we finish here. On any
556
+ # exception the ensure block in with_progress guarantees the
557
+ # handle is released — no more orphan gray ticker colliding with
558
+ # a yellow ticker (the original flicker bug).
559
+ #
560
+ # NOTE: safe-navigation (+&.+) with blocks silently skips the
561
+ # block when the receiver is nil. We need the compression work to
562
+ # run even when @ui is nil (e.g. in tests), so branch explicitly.
540
563
  begin
541
- response = call_llm
542
- handle_compression_response(response, compression_context)
543
- compression_handled = true
564
+ if @ui
565
+ @ui.with_progress(message: "Compressing message history...", style: :quiet) do |handle|
566
+ response = call_llm
567
+ handle_compression_response(response, compression_context, progress: handle)
568
+ compression_handled = true
569
+ end
570
+ else
571
+ response = call_llm
572
+ handle_compression_response(response, compression_context)
573
+ compression_handled = true
574
+ end
544
575
  ensure
545
576
  # If interrupted or failed, roll back the speculative compression message
546
577
  # so it doesn't pollute future conversation turns.
@@ -552,8 +583,6 @@ module Clacky
552
583
  # (with the user's new message as the last entry), producing consecutive user messages
553
584
  # that confuse the LLM into echoing compression instructions.
554
585
  @compression_level -= 1
555
- # Close the compression progress slot so the spinner does not linger.
556
- @ui&.show_progress(phase: "done")
557
586
  end
558
587
  end
559
588
  return nil
@@ -606,11 +635,18 @@ module Clacky
606
635
  # user/assistant alternation for Bedrock Converse API.
607
636
  truncated_text = response[:content] || ""
608
637
  truncated_text = "..." if truncated_text.strip.empty?
609
- @history.append({
638
+ truncated_msg = {
610
639
  role: "assistant",
611
640
  content: truncated_text,
612
641
  task_id: @current_task_id
613
- })
642
+ }
643
+ # Preserve reasoning_content on truncated turns as well.
644
+ # This is the real LLM-emitted reasoning — keeping it here lets
645
+ # MessageHistory#to_api recognize we're in thinking mode and pad any
646
+ # other synthetic assistant messages in the history with an empty
647
+ # reasoning_content automatically (see message_history.rb).
648
+ truncated_msg[:reasoning_content] = response[:reasoning_content] if response[:reasoning_content]
649
+ @history.append(truncated_msg)
614
650
 
615
651
  # Insert system message to guide LLM to retry with smaller steps
616
652
  @history.append({
@@ -644,8 +680,12 @@ module Clacky
644
680
  end
645
681
  # Store token_usage in the message so replay_history can re-emit it
646
682
  msg[:token_usage] = response[:token_usage] if response[:token_usage]
647
- # Preserve reasoning_content so it is echoed back to APIs that require it
648
- # (e.g. Kimi/Moonshot extended thinking omitting it causes HTTP 400)
683
+ # Preserve reasoning_content from the real LLM response.
684
+ # This is the authoritative signal used by MessageHistory#to_api to
685
+ # detect thinking-mode providers (DeepSeek V4, Kimi K2 thinking, etc.)
686
+ # and automatically pad any synthetic assistant messages with an empty
687
+ # reasoning_content so every outgoing payload satisfies the provider's
688
+ # "reasoning_content must be passed back" contract.
649
689
  msg[:reasoning_content] = response[:reasoning_content] if response[:reasoning_content]
650
690
  @history.append(msg)
651
691
 
@@ -744,21 +784,31 @@ module Clacky
744
784
  args[:working_dir] = @working_dir if @working_dir
745
785
 
746
786
  # Show progress immediately for every tool execution so the user
747
- # always knows the agent is working. (Previously we deferred this by
748
- # 2 seconds to avoid flicker in the legacy CLI TUI; that trade-off is
749
- # no longer desirable now that progress is a first-class UI state in
750
- # the Web UI and structured JSON UIs.)
751
- progress_shown = false
787
+ # always knows the agent is working. Using +with_progress+ wraps
788
+ # the execution in an +ensure+ block so the spinner/ticker is
789
+ # released even if the tool raises or the user interrupts.
790
+ #
791
+ # +quiet_on_fast_finish: true+ means "if the tool completes in
792
+ # under FAST_FINISH_THRESHOLD_SECONDS, remove the progress line
793
+ # instead of leaving a permanent 'Executing edit… (0s)' log
794
+ # entry". The preceding `[=>] Edit(...)` tool-call line and the
795
+ # following `[<=] Modified 1 occurrence` result line already
796
+ # tell the full story — the middle progress frame is noise for
797
+ # instant tools like edit/write/read/glob/grep. Truly slow
798
+ # tools (terminal running a build, web_fetch) exceed the
799
+ # threshold and their final frame is preserved as usual.
800
+ result = nil
752
801
  if @ui
753
802
  progress_message = build_tool_progress_message(call[:name], args)
754
- @ui.show_progress(progress_message, prefix_newline: false)
755
- progress_shown = true
756
- end
757
-
758
- begin
803
+ @ui.with_progress(
804
+ message: progress_message,
805
+ style: :quiet,
806
+ quiet_on_fast_finish: true
807
+ ) do
808
+ result = tool.execute(**args)
809
+ end
810
+ else
759
811
  result = tool.execute(**args)
760
- ensure
761
- @ui&.show_progress(phase: "done") if progress_shown
762
812
  end
763
813
 
764
814
  # Track modified files for Time Machine snapshots
@@ -990,12 +1040,34 @@ module Clacky
990
1040
  # Switch to specified model if provided
991
1041
  if model
992
1042
  if model == "lite"
993
- # Special keyword: use lite model if available, otherwise fall back to default
994
- lite_model = subagent_config.lite_model
995
- if lite_model && lite_model["id"]
996
- subagent_config.switch_model_by_id(lite_model["id"])
1043
+ # Special keyword: use lite model if available, otherwise fall back to default.
1044
+ #
1045
+ # Lite is now a *virtual* role — we don't require it to exist as a
1046
+ # concrete entry in @models. Instead we derive it from whatever
1047
+ # model the user is currently on (current_model), so switching
1048
+ # primary models automatically re-pairs with the right lite
1049
+ # companion (Claude → Haiku, DeepSeek V4-pro → V4-flash, ...).
1050
+ lite_cfg = subagent_config.lite_model_config_for_current
1051
+ if lite_cfg
1052
+ if lite_cfg["virtual"]
1053
+ # Provider-preset derived: apply the lite fields as a *session
1054
+ # overlay* on the subagent's config — this intentionally avoids
1055
+ # mutating the shared @models array / hashes which would pollute
1056
+ # the parent agent's own current model (e.g. turning the parent's
1057
+ # Opus entry into Haiku for the rest of the session).
1058
+ subagent_config.apply_virtual_model_overlay!(
1059
+ "api_key" => lite_cfg["api_key"],
1060
+ "base_url" => lite_cfg["base_url"],
1061
+ "model" => lite_cfg["model"],
1062
+ "anthropic_format" => lite_cfg["anthropic_format"]
1063
+ )
1064
+ elsif lite_cfg["id"]
1065
+ # Explicit user-configured lite (from CLACKY_LITE_* env): a
1066
+ # real @models entry with a stable id. Switch to it normally.
1067
+ subagent_config.switch_model_by_id(lite_cfg["id"])
1068
+ end
997
1069
  end
998
- # If no lite model, just use current (default) model
1070
+ # If no lite is resolvable, just use current (primary) model.
999
1071
  else
1000
1072
  # Regular model name lookup — find the first model with a matching
1001
1073
  # name and switch by its stable id.
@@ -1154,12 +1226,29 @@ module Clacky
1154
1226
  # Resolve image files to vision data_urls.
1155
1227
  # Files with data_url: use as-is (already compressed by frontend or adapter).
1156
1228
  # Files with path: convert to data_url via FileProcessor.
1157
- # Oversized images (> MAX_IMAGE_BYTES) are downgraded to disk file refs.
1158
- # @return [Array<String>, Array<Hash>] [vision_urls, downgraded_disk_files]
1229
+ #
1230
+ # Downgrade to disk file refs (with a `downgrade_reason` tag) when:
1231
+ # - :provider_no_vision — current model does not support vision input
1232
+ # (e.g. MiniMax, Kimi, DeepSeek, or openclacky's DeepSeek sidecar).
1233
+ # The downgrade is capability-driven and reflects the *current* model;
1234
+ # switching models takes effect on the next run with no cached state.
1235
+ # - :too_large — base64 payload exceeds MAX_IMAGE_BYTES. Downgrading here
1236
+ # keeps a hot context window from blowing up on e.g. a 20MB screenshot.
1237
+ #
1238
+ # Both reasons share the same downgrade path; `file_prompt` will later
1239
+ # emit a `Note:` line on the file entry explaining why the image isn't
1240
+ # inline, so the LLM has colocated context (no system prompt pollution).
1241
+ #
1242
+ # @return [Array<Hash>, Array<Hash>] [vision_images, downgraded_disk_files]
1159
1243
  private def resolve_vision_images(image_files)
1160
1244
  require "base64"
1161
1245
  max_bytes = Utils::FileProcessor::MAX_IMAGE_BYTES
1162
- vision_images = [] # Array of { url:, name:, size_bytes: }
1246
+ # Capability check once per run current_model_supports? is cheap and
1247
+ # delegates to Providers.supports? under the hood, always reflecting
1248
+ # the current model (no stale state on `/model` switch).
1249
+ vision_supported = @config.current_model_supports?(:vision)
1250
+
1251
+ vision_images = [] # Array of { url:, name:, size_bytes:, path: }
1163
1252
  downgraded = []
1164
1253
 
1165
1254
  image_files.each do |f|
@@ -1173,8 +1262,10 @@ module Clacky
1173
1262
  byte_size = (b64_data.bytesize * 3) / 4
1174
1263
  raw = Base64.decode64(b64_data)
1175
1264
  file_ref = Utils::FileProcessor.save_image_to_disk(body: raw, mime_type: mime, filename: name)
1176
- if byte_size > max_bytes
1177
- downgraded << { name: name, path: file_ref.original_path, type: "image", mime_type: mime, size_bytes: byte_size }
1265
+ reason = downgrade_reason_for(vision_supported, byte_size, max_bytes)
1266
+ if reason
1267
+ downgraded << { name: name, path: file_ref.original_path, type: "image",
1268
+ mime_type: mime, size_bytes: byte_size, downgrade_reason: reason }
1178
1269
  else
1179
1270
  vision_images << { url: data_url, name: name, size_bytes: byte_size, path: file_ref.original_path }
1180
1271
  end
@@ -1183,8 +1274,10 @@ module Clacky
1183
1274
  data_url_from_path = Utils::FileProcessor.image_path_to_data_url(path)
1184
1275
  b64_data = data_url_from_path.split(",", 2).last.to_s
1185
1276
  byte_size = (b64_data.bytesize * 3) / 4
1186
- if byte_size > max_bytes
1187
- downgraded << { name: name, path: path, type: "image", mime_type: mime, size_bytes: byte_size }
1277
+ reason = downgrade_reason_for(vision_supported, byte_size, max_bytes)
1278
+ if reason
1279
+ downgraded << { name: name, path: path, type: "image",
1280
+ mime_type: mime, size_bytes: byte_size, downgrade_reason: reason }
1188
1281
  else
1189
1282
  vision_images << { url: data_url_from_path, name: name, size_bytes: byte_size, path: path }
1190
1283
  end
@@ -1197,6 +1290,30 @@ module Clacky
1197
1290
  [vision_images, downgraded]
1198
1291
  end
1199
1292
 
1293
+ # Decide whether an image must be downgraded to a disk ref, and if so why.
1294
+ # Precedence: provider capability is checked first — a text-only model
1295
+ # can't use the image at any size, so there's no point re-checking size.
1296
+ # @return [Symbol, nil] :provider_no_vision | :too_large | nil (keep inline)
1297
+ private def downgrade_reason_for(vision_supported, byte_size, max_bytes)
1298
+ return :provider_no_vision unless vision_supported
1299
+ return :too_large if byte_size > max_bytes
1300
+ nil
1301
+ end
1302
+
1303
+ # Human-readable note for a downgrade reason, embedded next to the file
1304
+ # entry in the file_prompt. Kept intentionally terse and factual; the LLM
1305
+ # will see this alongside the file's name/type/path so it can tell the
1306
+ # user honestly why it can't see the image.
1307
+ # @return [String, nil] note text, or nil for no note
1308
+ private def downgrade_note_for(reason)
1309
+ case reason&.to_sym
1310
+ when :provider_no_vision
1311
+ "The current model does not support vision input. Image content is not visible to the model; suggest switching to a vision-capable model if the user needs image analysis."
1312
+ when :too_large
1313
+ "Image was too large for inline delivery and has been saved to disk. Read it with a vision-capable tool/model if needed."
1314
+ end
1315
+ end
1316
+
1200
1317
  # Build user message content for LLM.
1201
1318
  # Returns plain String when no vision images; Array of content parts otherwise.
1202
1319
  # Build user message content for LLM.
@@ -1254,7 +1371,19 @@ module Clacky
1254
1371
  # Core method to inject session context (date, model, OS, paths).
1255
1372
  # Called by inject_session_context_if_needed (with date check)
1256
1373
  # and by switch_model (without date check, to force update).
1374
+ #
1375
+ # IMPORTANT: Skip injection when the system prompt hasn't been built yet.
1376
+ # Otherwise, appending a user message to an empty history makes
1377
+ # @history.empty? false, which causes run() to skip building the
1378
+ # system prompt entirely (see run()'s "first run" guard).
1379
+ # The injection will happen naturally in run() via
1380
+ # inject_session_context_if_needed after the system prompt is in place.
1257
1381
  private def inject_session_context
1382
+ # Don't inject context before system prompt exists — defer to
1383
+ # inject_session_context_if_needed which runs inside run()
1384
+ # after the system prompt has been built.
1385
+ return unless @history.has_system_prompt?
1386
+
1258
1387
  today = Time.now.strftime("%Y-%m-%d")
1259
1388
  os = Clacky::Utils::EnvironmentDetector.os_type
1260
1389
  desktop = Clacky::Utils::EnvironmentDetector.desktop_path