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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +83 -1
- data/README.md +16 -0
- data/bin/chi +32 -31
- data/docs/cli.md +77 -5
- data/docs/configuration.md +104 -2
- data/docs/desktop.md +39 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +88 -6
- data/docs/releasing.md +6 -6
- data/docs/sessions.md +19 -17
- data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
- data/lib/samagotchi/bridge.rb +4 -1
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
- data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/client.rb +9 -6
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +64 -20
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
- data/lib/samagotchi/desktop/macos/Images.swift +113 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
- data/lib/samagotchi/desktop/macos.rb +59 -8
- data/lib/samagotchi/desktop_command.rb +6 -3
- data/lib/samagotchi/edit_preview.rb +82 -0
- data/lib/samagotchi/engine.rb +201 -104
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +26 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/host_registry.rb +8 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +26 -79
- data/lib/samagotchi/live_versions.rb +59 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +77 -13
- data/lib/samagotchi/llm/errors.rb +21 -7
- data/lib/samagotchi/llm/http.rb +15 -4
- data/lib/samagotchi/llm/openai_chat.rb +5 -26
- data/lib/samagotchi/memory_bundle/installer.rb +65 -63
- data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
- data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
- data/lib/samagotchi/memory_bundle/status.rb +4 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
- data/lib/samagotchi/model_profile.rb +23 -0
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/self_report.rb +17 -3
- data/lib/samagotchi/send_command.rb +107 -12
- data/lib/samagotchi/session_commands.rb +38 -8
- data/lib/samagotchi/session_manager.rb +1 -16
- data/lib/samagotchi/terminal_ui/attached_loop.rb +21 -25
- data/lib/samagotchi/terminal_ui/event_renderer.rb +8 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +9 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +4 -19
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui.rb +62 -248
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +115 -0
- data/lib/samagotchi/tool_runner.rb +34 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- data/lib/samagotchi/update_command.rb +308 -0
- data/lib/samagotchi/update_hint.rb +59 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +6 -4
- data/lib/samagotchi/web/app.rb +3 -3
- data/lib/samagotchi/web/message_parts.rb +8 -3
- data/lib/samagotchi/web/public/activity.js +3 -0
- data/lib/samagotchi/web/public/app.js +36 -24
- data/lib/samagotchi/web/public/chat_view.js +3 -0
- data/lib/samagotchi/web/public/data.js +2 -0
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/index.html +22 -1
- data/lib/samagotchi/web/public/question_card.js +3 -1
- data/lib/samagotchi/web/public/turn_events.js +29 -5
- data/lib/samagotchi/web/public/turn_view.js +2 -1
- data/lib/samagotchi/worker.rb +5 -4
- 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
|
-
|
|
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
|
-
|
|
563
|
-
|
|
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
|
|
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
|
|
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? &&
|
|
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
|
|
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,
|
|
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
|
-
|
|
831
|
-
|
|
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
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
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
|
-
#
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
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
|
-
|
|
1143
|
-
return
|
|
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
|
-
|
|
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
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
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
|
-
|
|
1209
|
-
|
|
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
|
-
|
|
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
|