samagotchi 0.3.0 → 0.4.0

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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -1
  3. data/README.md +16 -0
  4. data/bin/chi +32 -31
  5. data/docs/cli.md +77 -5
  6. data/docs/configuration.md +104 -2
  7. data/docs/desktop.md +39 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +88 -6
  10. data/docs/releasing.md +6 -6
  11. data/docs/sessions.md +19 -17
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  13. data/lib/samagotchi/bridge.rb +4 -1
  14. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
  15. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  16. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  17. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  18. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  19. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  20. data/lib/samagotchi/client.rb +9 -6
  21. data/lib/samagotchi/commands/registry.rb +8 -0
  22. data/lib/samagotchi/config.rb +64 -20
  23. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  24. data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
  25. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  26. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  27. data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
  28. data/lib/samagotchi/desktop/macos.rb +59 -8
  29. data/lib/samagotchi/desktop_command.rb +6 -3
  30. data/lib/samagotchi/edit_preview.rb +82 -0
  31. data/lib/samagotchi/engine.rb +201 -104
  32. data/lib/samagotchi/gem_update.rb +89 -0
  33. data/lib/samagotchi/guardrails/approval.rb +26 -4
  34. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  35. data/lib/samagotchi/host_registry.rb +8 -12
  36. data/lib/samagotchi/idle_client.rb +24 -15
  37. data/lib/samagotchi/idle_reminders.rb +2 -2
  38. data/lib/samagotchi/image_store.rb +10 -6
  39. data/lib/samagotchi/kernel_loop.rb +26 -79
  40. data/lib/samagotchi/live_versions.rb +59 -0
  41. data/lib/samagotchi/llm/api_key.rb +41 -0
  42. data/lib/samagotchi/llm/chat_loop.rb +77 -13
  43. data/lib/samagotchi/llm/errors.rb +21 -7
  44. data/lib/samagotchi/llm/http.rb +15 -4
  45. data/lib/samagotchi/llm/openai_chat.rb +5 -26
  46. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  47. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  48. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  49. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  50. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  51. data/lib/samagotchi/model_profile.rb +23 -0
  52. data/lib/samagotchi/prompt.rb +4 -2
  53. data/lib/samagotchi/reminder_store.rb +1 -9
  54. data/lib/samagotchi/self_report.rb +17 -3
  55. data/lib/samagotchi/send_command.rb +107 -12
  56. data/lib/samagotchi/session_commands.rb +38 -8
  57. data/lib/samagotchi/session_manager.rb +1 -16
  58. data/lib/samagotchi/terminal_ui/attached_loop.rb +21 -25
  59. data/lib/samagotchi/terminal_ui/event_renderer.rb +8 -3
  60. data/lib/samagotchi/terminal_ui/formatting.rb +9 -0
  61. data/lib/samagotchi/terminal_ui/input_support.rb +4 -19
  62. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  63. data/lib/samagotchi/terminal_ui.rb +62 -248
  64. data/lib/samagotchi/text_diff.rb +181 -0
  65. data/lib/samagotchi/thinking.rb +115 -0
  66. data/lib/samagotchi/tool_runner.rb +34 -1
  67. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  68. data/lib/samagotchi/tools/edit.rb +23 -9
  69. data/lib/samagotchi/tools/write.rb +4 -0
  70. data/lib/samagotchi/turn_flow.rb +12 -2
  71. data/lib/samagotchi/update_command.rb +308 -0
  72. data/lib/samagotchi/update_hint.rb +59 -0
  73. data/lib/samagotchi/version.rb +1 -1
  74. data/lib/samagotchi/vision_support.rb +6 -4
  75. data/lib/samagotchi/web/app.rb +3 -3
  76. data/lib/samagotchi/web/message_parts.rb +8 -3
  77. data/lib/samagotchi/web/public/activity.js +3 -0
  78. data/lib/samagotchi/web/public/app.js +36 -24
  79. data/lib/samagotchi/web/public/chat_view.js +3 -0
  80. data/lib/samagotchi/web/public/data.js +2 -0
  81. data/lib/samagotchi/web/public/diff_view.js +58 -0
  82. data/lib/samagotchi/web/public/index.html +22 -1
  83. data/lib/samagotchi/web/public/question_card.js +3 -1
  84. data/lib/samagotchi/web/public/turn_events.js +29 -5
  85. data/lib/samagotchi/web/public/turn_view.js +2 -1
  86. data/lib/samagotchi/worker.rb +5 -4
  87. metadata +12 -1
@@ -47,15 +47,7 @@ module Samagotchi
47
47
  include Formatting
48
48
  include InputSupport
49
49
 
50
- AGENT_DESCRIPTION_FILE = "AGENT.md"
51
- SKIP_AGENT_DESCRIPTION_ENV = "SAMAGOTCHI_SKIP_AGENT_MD"
52
- STATS_COMMAND = "/stats"
53
- # /exit --delete: delete the session on the way out.
54
- EXIT_DELETE_FLAG = "--delete"
55
- # Leave, then archive the session (hidden from the lists, kept for good).
56
- ARCHIVE_COMMAND = "/archive"
57
50
  SCRATCH_ARCHIVE_REFUSED = "a scratch session is deleted when you leave; nothing to archive"
58
- RECAP_COMMAND = "/recap"
59
51
  # The prompt while a question waits for its answer (its choices are in
60
52
  # the notes slot).
61
53
  QUESTION_PROMPT = "? "
@@ -145,6 +137,9 @@ module Samagotchi
145
137
  # Re-resolve client after resume may change effective model
146
138
  @client = @host_registry.resolve(@effective_model_name).client
147
139
  end
140
+ # The model it starts on names a configured host (a bad default.model
141
+ # doesn't matter when --model or the session picks another).
142
+ ModelProfile.check_host!(@effective_model_name, hosts: @host_registry.entries)
148
143
  # Only a caller's profile goes in; otherwise the Engine resolves one
149
144
  # for the effective model and hands it to the kernel.
150
145
  @kernel = KernelLoop.new(client: @client, profile: profile, no_interrupt: no_interrupt, reminder_store: Samagotchi::ReminderStore.new)
@@ -178,7 +173,9 @@ module Samagotchi
178
173
  profile: profile,
179
174
  session_id: session_id,
180
175
  no_interrupt: no_interrupt,
181
- model_name: @default_model_name,
176
+ # nil: the config default, unchecked here (the model the REPL starts
177
+ # on is checked above, and --model may make the default moot).
178
+ model_name: nil,
182
179
  memories: @requested_memories,
183
180
  muted_memories: @muted_memory_names,
184
181
  kernel: @kernel,
@@ -426,26 +423,8 @@ module Samagotchi
426
423
  messages
427
424
  end
428
425
 
429
- # Public entrypoint for background session workers.
430
- # Keeps worker call sites out of TerminalUI private API details.
431
- def process_background_prompt(session:, prompt:)
432
- @engine.process_background_prompt(session: session, prompt: prompt)
433
- end
434
-
435
426
  private
436
427
 
437
- # Generate profile-aware tool calling hint.
438
- # Kept as a one-line delegator to Engine (single source of truth).
439
- def tool_call_hint
440
- return @engine.tool_call_hint
441
- end
442
-
443
- # Assist system prompt — delegates wholesale to Engine so the two can
444
- # never drift apart. This single delegator fixes the 4-line regression.
445
- def assist_system_prompt
446
- return @engine.assist_system_prompt
447
- end
448
-
449
428
  # Interactive REPL loop. Session seed + messages are built by #run and
450
429
  # threaded in here (so --prompt / --resume share one code path). The
451
430
  # working session is persisted at the end of every turn.
@@ -559,21 +538,22 @@ module Samagotchi
559
538
  end
560
539
  end
561
540
  break if input.nil?
562
- if exit_command?(input)
563
- @delete_on_exit ||= delete_on_exit?(input)
541
+ local = local_command(input)
542
+ if local == :exit
543
+ @delete_on_exit ||= SessionCommands.delete_on_exit?(input)
564
544
  break
565
545
  end
566
- if archive_command?(input)
546
+ if local == :archive
567
547
  next @surface.commit(SCRATCH_ARCHIVE_REFUSED) if @scratch
568
548
 
569
549
  @archive_on_exit = true
570
550
  break
571
551
  end
572
552
  # Not an answer to a continue offer either.
573
- next detach_note if detach_command?(input)
553
+ next detach_note if local == :detach
574
554
 
575
555
  # /stats and /recap run; the offer stays open.
576
- if @turn_flow.awaiting_continue? && !stats_command?(input) && !recap_command?(input)
556
+ if @turn_flow.awaiting_continue? && !%i[stats recap].include?(local)
577
557
  answer_continue_offer(session, input)
578
558
  else
579
559
  # The ? read left no echo: show what ran.
@@ -626,15 +606,14 @@ module Samagotchi
626
606
  persist_recent_history(input) if command.shell
627
607
  return
628
608
  end
629
- return @surface.commit("\nmodel> session stats:\n#{format_session_metrics(@engine.stats_snapshot)}") if stats_command?(input)
630
- return @surface.commit("\nmodel> #{handle_recap_command}") if recap_command?(input)
609
+ return if show_local_command(input)
631
610
 
632
611
  @turn_flow.before_prompt_turn
633
612
  persist_recent_history(input)
634
613
  begin
635
614
  # Engine#run_turn injects due reminders as a tail message, appends
636
615
  # the prompt, and renders through @renderer via on_event.
637
- result = run_engine_turn(session, normalize_model_input(input), images: ImageInput.extract(input))
616
+ result = run_engine_turn(session, input, images: ImageInput.extract(input))
638
617
  rescue LLM::ProviderError, ImageStore::Error => e
639
618
  # Engine closed the turn (:turn_failed); show its duration.
640
619
  # Retries were already tallied via generation_retrying events.
@@ -730,18 +709,6 @@ module Samagotchi
730
709
  prompt
731
710
  end
732
711
 
733
- # Appends the current memory index to the base system prompt so the agent
734
- # is always aware of stored memories without needing to call a tool first.
735
- # Delegates wholesale to Engine (single source of truth).
736
- def system_prompt_with_index(base)
737
- result = @engine.system_prompt_with_index(base)
738
- # Mirror any --memory activations the Engine performed so the sticky
739
- # status line can surface them. The prompt body injection moved into
740
- # Engine; echoing the activated names here is purely a UI concern.
741
- sync_engine_activated_memories
742
- result
743
- end
744
-
745
712
  # Engine owns the system prompt (including --memory activation), but the
746
713
  # sticky status line is a UI concern. Mirror the activated names so they
747
714
  # appear in the status line.
@@ -751,12 +718,6 @@ module Samagotchi
751
718
  end
752
719
  end
753
720
 
754
- # Render a result from a turn that bypassed Engine#run_turn (continue,
755
- # reminder turns) exactly as a :turn_completed would be rendered.
756
- def emit_result(result)
757
- @renderer.render_turn_summary(@engine.turn_summary(result))
758
- end
759
-
760
721
  # ── Turn view: the drawing surface EventRenderer calls ──────────────────
761
722
  public
762
723
 
@@ -827,8 +788,21 @@ module Samagotchi
827
788
  @thinking_waiting_since = nil
828
789
  clear_retry_spinner_status
829
790
  @turn_tally.started(key: tally_key(event), tool: event[:tool], params: event[:params])
830
- memory_loaded = capture_memory_tool_call(event)
831
- capture_thinking_tool_call(event) if memory_loaded
791
+ @last_tool_call_event = event
792
+ refresh_thinking_spinner_status
793
+ end
794
+ end
795
+
796
+ # The Engine's memory list: after a memory read (read_names, right after
797
+ # its tool_call_started) and at the end of a turn (preloads).
798
+ def used_memories_updated(event)
799
+ @spinner_lock.synchronize do
800
+ Array(event[:used_memory_names]).each { |name| add_unique_memory_name(:@session_memory_names, name) }
801
+ added = Array(event[:read_names]).select { |name| add_unique_memory_name(:@thinking_memory_names, name) }
802
+ next if added.empty?
803
+
804
+ @thinking_recent_memory_loaded = added.last
805
+ capture_thinking_tool_call(@last_tool_call_event) if @last_tool_call_event
832
806
  refresh_thinking_spinner_status
833
807
  end
834
808
  end
@@ -875,16 +849,6 @@ module Samagotchi
875
849
  @surface.commit("#{paint('chi>', 36)} turn #{state} (#{format_elapsed_duration(record[:duration_ms])})")
876
850
  end
877
851
 
878
- def split_memory_scope(raw)
879
- value = raw.to_s.strip
880
- if value.include?("/")
881
- scope, name = value.split("/", 2)
882
- return [scope, name] if Tools::VALID_SCOPES.include?(scope)
883
- end
884
-
885
- [nil, value]
886
- end
887
-
888
852
  def read_input(awaiting_continue:)
889
853
  emit_idle_status_line
890
854
 
@@ -910,6 +874,11 @@ module Samagotchi
910
874
  # empty synthetic turn then (it duplicated output).
911
875
  return unless @engine.reminders_due?
912
876
 
877
+ # A pending continue offer goes, as for a new prompt (the worker's rule).
878
+ if @turn_flow.before_reminder_turn
879
+ sync_continue_slot(false)
880
+ @surface.commit(QuestionSlot.continue_summary("(dropped: a reminder ran)", paint: method(:paint)))
881
+ end
913
882
  hint = "reminder: #{names.join(", ")} · Ctrl-C cancels it"
914
883
  @surface.set_slot(:hints, [color_output? ? paint(hint, 90) : hint])
915
884
  result = nil
@@ -1023,27 +992,20 @@ module Samagotchi
1023
992
  @repl_input.prefill(input) ? "prompt restored for retry" : "the failed prompt is in the input history (↑)"
1024
993
  end
1025
994
 
1026
- def shell_bang_command?(input)
1027
- input.to_s.match?(/\A!\s*\S/)
1028
- end
1029
-
1030
- def stats_command?(input)
1031
- input.to_s.strip == STATS_COMMAND
1032
- end
1033
-
1034
- def recap_command?(input)
1035
- input.to_s.strip == RECAP_COMMAND
1036
- end
995
+ # The id of the terminal's own command +input+ is (:exit, :archive,
996
+ # :detach, :stats, :recap; SessionCommands registers them), or nil.
997
+ def local_command(input) = command_registry.lookup_local(input)&.id
1037
998
 
1038
- # SessionCommands runs /model; the REPL mirrors the result.
1039
- def handle_model_command(input)
1040
- result = @commands.run(input)
1041
- sync_model_mirrors
1042
- result.output
1043
- end
1044
-
1045
- def handle_models_command
1046
- @commands.run(SessionCommands::MODELS_COMMAND).output
999
+ # /stats and /recap, which show and change nothing (at the prompt, a
1000
+ # continue offer's ? prompt, or during a turn).
1001
+ # @return [Boolean] whether +input+ was one
1002
+ def show_local_command(input)
1003
+ case local_command(input)
1004
+ when :stats then @surface.commit("\nmodel> session stats:\n#{format_session_metrics(@engine.stats_snapshot)}")
1005
+ when :recap then @surface.commit("\nmodel> #{handle_recap_command}")
1006
+ else return false
1007
+ end
1008
+ true
1047
1009
  end
1048
1010
 
1049
1011
  # /recap: the saved recap, and a new one asked for at once when the
@@ -1060,31 +1022,10 @@ module Samagotchi
1060
1022
  # A resumed session doesn't get the default input either.
1061
1023
  def default_input_wanted? = !@resume_session && !@no_default_input
1062
1024
 
1063
- # exit or /exit, with --delete to delete the session on the way out.
1064
- def exit_command?(input)
1065
- words = input.to_s.strip.downcase.split
1066
- %w[exit /exit].include?(words.first) && (words.size == 1 || words == [words.first, EXIT_DELETE_FLAG])
1067
- end
1068
-
1069
- def delete_on_exit?(input) = input.to_s.strip.downcase.split.last == EXIT_DELETE_FLAG
1070
-
1071
- def detach_command?(input) = input.to_s.strip.casecmp?("/detach")
1072
-
1073
- def archive_command?(input) = input.to_s.strip.casecmp?(ARCHIVE_COMMAND)
1074
-
1075
1025
  def clone_messages(messages)
1076
1026
  Array(messages).map(&:dup)
1077
1027
  end
1078
1028
 
1079
- def skip_agent_description?
1080
- value = ENV[SKIP_AGENT_DESCRIPTION_ENV]
1081
- value == "1" || value&.casecmp?("true")
1082
- end
1083
-
1084
- def rg_available?
1085
- system("command -v rg", out: File::NULL, err: File::NULL)
1086
- end
1087
-
1088
1029
  # Run one REPL turn (a prompt, or a continue/reminder turn with nil) through
1089
1030
  # Engine#run_turn, rendering via @renderer.
1090
1031
  # @param images [Array<Hash>] the prompt's `@path` images ({path:})
@@ -1139,11 +1080,13 @@ module Samagotchi
1139
1080
  # in the prompt.
1140
1081
  # @return [Boolean, :back] whether the turn took it, :back to put it back
1141
1082
  def steer_line(line)
1142
- return exit_after_turn(delete: delete_on_exit?(line)) if line.nil? || exit_command?(line)
1143
- return detach_note if detach_command?(line)
1083
+ local = local_command(line) unless line.nil?
1084
+ return exit_after_turn(delete: SessionCommands.delete_on_exit?(line)) if line.nil? || local == :exit
1085
+ return detach_note if local == :detach
1144
1086
  return false if @active_cancel_controller&.cancelled?
1145
1087
  return start_anytime_command(line) if command_registry.lookup(line)&.anytime
1146
- return command_during_turn(line) if command_line?(line)
1088
+ # A command, never steering text (/archive waits, back in the prompt).
1089
+ return command_during_turn(line) if local || command_registry.command?(line)
1147
1090
  return true if line.strip.empty?
1148
1091
  # Steering merges text only: a line with images runs as the next turn.
1149
1092
  return image_line_waits if ImageInput.extract(line).any?
@@ -1177,15 +1120,10 @@ module Samagotchi
1177
1120
 
1178
1121
  # @return [true, :back]
1179
1122
  def command_during_turn(line)
1180
- if stats_command?(line)
1181
- @surface.commit("\nmodel> session stats:\n#{format_session_metrics(@engine.stats_snapshot)}")
1182
- elsif recap_command?(line)
1183
- @surface.commit("\nmodel> #{handle_recap_command}")
1184
- else
1185
- @surface.commit(COMMAND_BUSY)
1186
- return :back
1187
- end
1188
- true
1123
+ return true if show_local_command(line)
1124
+
1125
+ @surface.commit(COMMAND_BUSY)
1126
+ :back
1189
1127
  end
1190
1128
 
1191
1129
  # D8: an anytime command runs on its own thread while the turn goes on
@@ -1205,16 +1143,12 @@ module Samagotchi
1205
1143
  true
1206
1144
  end
1207
1145
 
1208
- def command_line?(line)
1209
- command_registry.command?(line) || stats_command?(line) || recap_command?(line) || exit_command?(line)
1210
- end
1211
-
1212
- # The kernel's drain at an iteration boundary: queued lines, #memory
1213
- # shorthand made words as for a prompt, and saved in the history.
1146
+ # The kernel's drain at an iteration boundary: queued lines, sent as
1147
+ # typed and saved in the history.
1214
1148
  def drain_steering
1215
1149
  @pending_input_queue.drain.map do |line|
1216
1150
  persist_recent_history(line)
1217
- normalize_model_input(line)
1151
+ line
1218
1152
  end
1219
1153
  end
1220
1154
 
@@ -1372,17 +1306,6 @@ module Samagotchi
1372
1306
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
1373
1307
  end
1374
1308
 
1375
- def capture_memory_tool_call(event)
1376
- call = event[:call].is_a?(Hash) ? event[:call] : {}
1377
- memory_name = memory_name_from_tool_call(call)
1378
- return false if memory_name.nil? || memory_name.empty?
1379
-
1380
- added_to_thinking = add_unique_memory_name(:@thinking_memory_names, memory_name)
1381
- add_unique_memory_name(:@session_memory_names, memory_name)
1382
- @thinking_recent_memory_loaded = memory_name if added_to_thinking
1383
- added_to_thinking
1384
- end
1385
-
1386
1309
  def capture_thinking_tool_call(event)
1387
1310
  call = event[:call].is_a?(Hash) ? event[:call] : {}
1388
1311
  name = call[:name].to_s.strip
@@ -1440,67 +1363,6 @@ module Samagotchi
1440
1363
  true
1441
1364
  end
1442
1365
 
1443
- def memory_name_from_tool_call(call)
1444
- tool_name = call[:name].to_s
1445
- case tool_name
1446
- when Tools::MemoryRead::NAME
1447
- normalize_memory_name(call[:content])
1448
- when Tools::Read::NAME
1449
- memory_name_from_read_path(call[:content])
1450
- else
1451
- nil
1452
- end
1453
- end
1454
-
1455
- def normalize_memory_name(raw)
1456
- value = raw.to_s.strip
1457
- return nil if value.empty?
1458
-
1459
- File.basename(value, ".md")
1460
- end
1461
-
1462
- def memory_name_from_read_path(raw_path)
1463
- path = raw_path.to_s.strip.tr("\\", "/")
1464
- return nil if path.empty?
1465
- return nil unless path.match?(/memories[\/].+\.md\z/)
1466
-
1467
- normalize_memory_name(path)
1468
- end
1469
-
1470
- def memory_spinner_segment
1471
- segment = memory_spinner_segment_plain
1472
- return "" if segment.empty?
1473
-
1474
- color_output? ? paint(segment, MEMORY_SPINNER_COLOR) : segment
1475
- end
1476
-
1477
- def memory_spinner_segment_plain
1478
- names = Array(@thinking_memory_names)
1479
- return "" if names.empty?
1480
-
1481
- visible = names.first(MEMORY_SPINNER_PREVIEW_LIMIT)
1482
- suffix = names.length > visible.length ? ", +#{names.length - visible.length}" : ""
1483
- " mem: #{visible.join(', ')}#{suffix}"
1484
- end
1485
-
1486
- def memory_sticky_line
1487
- names = Array(@session_memory_names)
1488
- muted = @engine.muted_memory_names
1489
- return "" if names.empty? && muted.empty?
1490
-
1491
- parts = []
1492
- parts << "active this session: #{preview_names(names)}" unless names.empty?
1493
- parts << "muted: #{preview_names(muted)}" unless muted.empty?
1494
- body = "memories> #{parts.join(' · ')}"
1495
- color_output? ? paint(body, MEMORY_SPINNER_COLOR) : body
1496
- end
1497
-
1498
- def preview_names(names, limit: MEMORY_STICKY_PREVIEW_LIMIT)
1499
- visible = names.first(limit)
1500
- suffix = names.length > visible.length ? ", +#{names.length - visible.length}" : ""
1501
- "#{visible.join(', ')}#{suffix}"
1502
- end
1503
-
1504
1366
  def reset_thinking_memory_names
1505
1367
  @thinking_memory_names = []
1506
1368
  end
@@ -1622,54 +1484,24 @@ module Samagotchi
1622
1484
  ConfigFile.recap_config
1623
1485
  end
1624
1486
 
1625
- def bare_model_for(full_ref)
1626
- @host_registry.bare_name(full_ref)
1627
- end
1628
-
1629
- def spinner_status_line
1630
- return "" unless status_line_enabled?
1631
-
1632
- lines = spinner_status_lines
1633
- lines.empty? ? "" : lines.first
1634
- end
1635
-
1636
1487
  def spinner_status_lines(width: status_effective_width)
1637
1488
  return [] unless status_line_enabled?
1638
1489
 
1639
1490
  build_status_lines(scope: :spinner, width: width)
1640
1491
  end
1641
1492
 
1642
- def sticky_status_line
1643
- return "" unless status_line_enabled?
1644
-
1645
- lines = sticky_status_lines
1646
- lines.empty? ? "" : lines.first
1647
- end
1648
-
1649
1493
  def sticky_status_lines(width: status_effective_width)
1650
1494
  return [] unless status_line_enabled?
1651
1495
 
1652
1496
  build_status_lines(scope: :sticky, width: width)
1653
1497
  end
1654
1498
 
1655
- def idle_status_line
1656
- return "" unless status_line_enabled?
1657
-
1658
- lines = idle_status_lines
1659
- lines.empty? ? "" : lines.first
1660
- end
1661
-
1662
1499
  def idle_status_lines(width: status_effective_width)
1663
1500
  return [] unless status_line_enabled?
1664
1501
 
1665
1502
  build_status_lines(scope: :idle, width: width)
1666
1503
  end
1667
1504
 
1668
- def build_status_line(scope:)
1669
- lines = build_status_lines(scope: scope)
1670
- lines.empty? ? "" : lines.first
1671
- end
1672
-
1673
1505
  # The status rows for +scope+ (:spinner, :sticky or :idle), cut to +width+.
1674
1506
  def build_status_lines(scope:, width: status_effective_width)
1675
1507
  status_rows(status_segments(scope), width)
@@ -1845,11 +1677,6 @@ module Samagotchi
1845
1677
  "model> waiting for the first token... #{waited.floor}s #{frame}"
1846
1678
  end
1847
1679
 
1848
- def thinking_spinner_status_line(frame)
1849
- lines = thinking_spinner_status_lines(frame)
1850
- lines.empty? ? "" : lines.first
1851
- end
1852
-
1853
1680
  # The spinner row, then (from a turn's 3rd tool call) its tool tally.
1854
1681
  # The spinner stops while tools run, so the tally shows while the model
1855
1682
  # generates between tool rounds.
@@ -1917,10 +1744,6 @@ module Samagotchi
1917
1744
  nil
1918
1745
  end
1919
1746
 
1920
- def pending_question_event?
1921
- !!@pending_question_event
1922
- end
1923
-
1924
1747
  # Called from REPL thread (run_assist_loop top) — renders widget, blocks
1925
1748
  # until user selects, then answers via Engine#answer_question which wakes
1926
1749
  # the parked turn thread.
@@ -1962,6 +1785,9 @@ module Samagotchi
1962
1785
 
1963
1786
  # Ensure spinner cleared and terminal in known state (same as reminder mute handling)
1964
1787
  finish_thinking_spinner rescue nil
1788
+ # An edit's diff goes above the slot, into the scrollback.
1789
+ preview = prompt.preview_lines(paint: method(:paint))
1790
+ @surface.commit(preview.join("\n")) unless preview.empty?
1965
1791
  choices = prompt.slot(paint: method(:paint))
1966
1792
 
1967
1793
  question_prompt = paint(QUESTION_PROMPT, 33)
@@ -2049,17 +1875,5 @@ module Samagotchi
2049
1875
  return :canceled if controller&.cancelled?
2050
1876
  end
2051
1877
  end
2052
-
2053
- # Process a prompt through the kernel loop and return the model response.
2054
- # Delegates to the internal Engine instance.
2055
- def process_prompt_through_kernel(session, prompt)
2056
- @engine.process_prompt_through_kernel(session, prompt)
2057
- end
2058
-
2059
- # Write an agent response to the session output directory.
2060
- def write_session_output(output_dir, response)
2061
- timestamp = Time.now.strftime("%Y%m%d%H%M%S%9N")
2062
- File.write(File.join(output_dir, "#{timestamp}.txt"), response.to_s)
2063
- end
2064
1878
  end
2065
1879
  end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # Line-based unified diff (like `diff -u` without the file header), used to
5
+ # show what chi's edit/write tools change. Pure: strings in, a hash out.
6
+ #
7
+ # TextDiff.unified("a\nb\n", "a\nc\n")
8
+ # # => {text: "@@ -1,2 +1,2 @@\n a\n-b\n+c", added: 1, removed: 1, truncated: false}
9
+ #
10
+ # The common prefix and suffix are trimmed first (edits are local), then
11
+ # Myers O(ND) runs on the middle. When the edit distance passes
12
+ # MAX_EDIT_DISTANCE it gives up on a minimal diff and shows the whole middle
13
+ # as removed then added: correct, just not minimal. The text is cut at
14
+ # MAX_LINES / MAX_BYTES with a "… N more lines" line; the counts stay exact.
15
+ module TextDiff
16
+ CONTEXT = 3
17
+ MAX_LINES = 120
18
+ MAX_BYTES = 8 * 1024
19
+ MAX_EDIT_DISTANCE = 1000 # D² work: ~0.35 s for a dense 5 000-line edit
20
+ NO_NEWLINE = "\"
21
+
22
+ module_function
23
+
24
+ def unified(before, after, context: CONTEXT, max_lines: MAX_LINES, max_bytes: MAX_BYTES)
25
+ a = clean(before).lines
26
+ b = clean(after).lines
27
+ ops = edit_script(a, b)
28
+ added = ops.count { |op| op[0] == :add }
29
+ removed = ops.count { |op| op[0] == :del }
30
+ text, truncated = render(hunks(ops, context), a, b, max_lines, max_bytes)
31
+ { text: text, added: added, removed: removed, truncated: truncated }
32
+ end
33
+
34
+ def clean(text)
35
+ text.to_s.dup.force_encoding(Encoding::UTF_8).scrub("�")
36
+ end
37
+
38
+ # Ops are [:eq, ai, bi], [:del, ai, nil] or [:add, nil, bi] in file order.
39
+ def edit_script(a, b)
40
+ pre = 0
41
+ pre += 1 while pre < a.size && pre < b.size && a[pre] == b[pre]
42
+ suf = 0
43
+ suf += 1 while suf < a.size - pre && suf < b.size - pre && a[-1 - suf] == b[-1 - suf]
44
+
45
+ ops = (0...pre).map { |i| [:eq, i, i] }
46
+ a_mid = a[pre...(a.size - suf)]
47
+ b_mid = b[pre...(b.size - suf)]
48
+ middle = myers(a_mid, b_mid) ||
49
+ a_mid.each_index.map { |i| [:del, i, nil] } + b_mid.each_index.map { |j| [:add, nil, j] }
50
+ middle.each { |t, i, j| ops << [t, i && i + pre, j && j + pre] }
51
+ (0...suf).each { |s| ops << [:eq, a.size - suf + s, b.size - suf + s] }
52
+ ops
53
+ end
54
+
55
+ # Myers' greedy forward search, keeping one slice of V per round for the
56
+ # backtrack. Returns nil when the edit distance passes max_d.
57
+ def myers(a, b, max_d = MAX_EDIT_DISTANCE)
58
+ ids = {}
59
+ a = a.map { |l| ids[l] ||= ids.size }
60
+ b = b.map { |l| ids[l] ||= ids.size }
61
+ # No line in common (a full rewrite): the fallback is the minimal diff.
62
+ return nil if ids.size == a.uniq.size + b.uniq.size
63
+
64
+ n = a.size
65
+ m = b.size
66
+ off = n + m + 1
67
+ v = Array.new(2 * off + 1, 0)
68
+ trace = []
69
+ (0..(n + m)).each do |d|
70
+ return nil if d > max_d
71
+
72
+ # Round d reads k-1 and k+1 for k in -d..d: keep -(d+1)..d+1.
73
+ trace << v[off - d - 1, 2 * d + 3]
74
+ k = -d
75
+ while k <= d
76
+ x = if k == -d || (k != d && v[off + k - 1] < v[off + k + 1])
77
+ v[off + k + 1]
78
+ else
79
+ v[off + k - 1] + 1
80
+ end
81
+ y = x - k
82
+ while x < n && y < m && a[x] == b[y]
83
+ x += 1
84
+ y += 1
85
+ end
86
+ v[off + k] = x
87
+ return backtrack(trace, n, m) if x >= n && y >= m
88
+
89
+ k += 2
90
+ end
91
+ end
92
+ []
93
+ end
94
+
95
+ def backtrack(trace, x, y)
96
+ ops = []
97
+ (trace.size - 1).downto(0) do |d|
98
+ vd = trace[d]
99
+ at = ->(k) { vd[k + d + 1] }
100
+ k = x - y
101
+ prev_k = k == -d || (k != d && at.(k - 1) < at.(k + 1)) ? k + 1 : k - 1
102
+ prev_x = at.(prev_k)
103
+ prev_y = prev_x - prev_k
104
+ while x > prev_x && y > prev_y
105
+ x -= 1
106
+ y -= 1
107
+ ops << [:eq, x, y]
108
+ end
109
+ break if d.zero?
110
+
111
+ if x == prev_x
112
+ ops << [:add, nil, prev_y]
113
+ else
114
+ ops << [:del, prev_x, nil]
115
+ end
116
+ x = prev_x
117
+ y = prev_y
118
+ end
119
+ ops.reverse
120
+ end
121
+
122
+ # Groups ops into hunks: runs of changes whose gaps are at most 2*context
123
+ # unchanged lines, each padded with up to context lines on both sides.
124
+ def hunks(ops, context)
125
+ changed = ops.each_index.reject { |i| ops[i][0] == :eq }
126
+ return [] if changed.empty?
127
+
128
+ groups = [[changed.first, changed.first]]
129
+ changed.drop(1).each do |i|
130
+ if i - groups.last[1] - 1 <= 2 * context
131
+ groups.last[1] = i
132
+ else
133
+ groups << [i, i]
134
+ end
135
+ end
136
+ groups.map { |first, last| ops[[first - context, 0].max..[last + context, ops.size - 1].min] }
137
+ end
138
+
139
+ def render(hunks, a, b, max_lines, max_bytes)
140
+ lines = []
141
+ hunks.each do |hunk|
142
+ lines << header(hunk)
143
+ hunk.each do |t, i, j|
144
+ line = t == :add ? b[j] : a[i]
145
+ mark = { eq: " ", del: "-", add: "+" }[t]
146
+ # Only the "\n": chomp("\n") would take a CRLF's "\r" too.
147
+ lines << mark + (line.end_with?("\n") ? line[0...-1] : line)
148
+ lines << NO_NEWLINE unless line.end_with?("\n")
149
+ end
150
+ end
151
+ cut(lines, max_lines, max_bytes)
152
+ end
153
+
154
+ # @@ -start,len +start,len @@ with diff -u's conventions: ",1" is left
155
+ # out. A side with no lines in the hunk is an empty file (any other hunk
156
+ # has context lines), shown as 0,0.
157
+ def header(hunk)
158
+ a_lines = hunk.filter_map { |t, i, _| i unless t == :add }
159
+ b_lines = hunk.filter_map { |t, _, j| j unless t == :del }
160
+ "@@ -#{range(a_lines)} +#{range(b_lines)} @@"
161
+ end
162
+
163
+ def range(indexes)
164
+ return "0,0" if indexes.empty?
165
+
166
+ indexes.size == 1 ? (indexes.first + 1).to_s : "#{indexes.first + 1},#{indexes.size}"
167
+ end
168
+
169
+ def cut(lines, max_lines, max_bytes)
170
+ bytes = 0
171
+ lines.each_with_index do |line, i|
172
+ bytes += line.bytesize + 1
173
+ next unless i >= max_lines || bytes > max_bytes
174
+
175
+ rest = lines.size - i
176
+ return [(lines[0, i] + ["… #{rest} more lines"]).join("\n"), true]
177
+ end
178
+ [lines.join("\n"), false]
179
+ end
180
+ end
181
+ end