samagotchi 0.2.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +198 -1
  3. data/README.md +56 -4
  4. data/bin/chi +118 -50
  5. data/docs/cli.md +184 -9
  6. data/docs/configuration.md +333 -47
  7. data/docs/desktop.md +45 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +208 -5
  10. data/docs/plugins.md +68 -2
  11. data/docs/releasing.md +23 -13
  12. data/docs/sessions.md +45 -17
  13. data/lib/samagotchi/answer_display.rb +95 -0
  14. data/lib/samagotchi/archive_store.rb +90 -0
  15. data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
  16. data/lib/samagotchi/bootstrap/probe.rb +262 -0
  17. data/lib/samagotchi/bootstrap_command.rb +347 -0
  18. data/lib/samagotchi/bridge/pending_card.rb +89 -0
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
  20. data/lib/samagotchi/bridge.rb +13 -1
  21. data/lib/samagotchi/bridge_client.rb +6 -2
  22. data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
  23. data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
  24. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
  25. data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
  26. data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
  28. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  29. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  31. data/lib/samagotchi/client.rb +81 -19
  32. data/lib/samagotchi/commands/registry.rb +8 -0
  33. data/lib/samagotchi/config.rb +252 -48
  34. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  35. data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
  36. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  37. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  38. data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
  39. data/lib/samagotchi/desktop/macos.rb +59 -8
  40. data/lib/samagotchi/desktop_command.rb +6 -3
  41. data/lib/samagotchi/edit_preview.rb +82 -0
  42. data/lib/samagotchi/empty_answer_retry.rb +43 -0
  43. data/lib/samagotchi/engine.rb +434 -140
  44. data/lib/samagotchi/gem_update.rb +89 -0
  45. data/lib/samagotchi/guardrails/approval.rb +35 -4
  46. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  47. data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
  48. data/lib/samagotchi/guardrails.rb +1 -0
  49. data/lib/samagotchi/hooks/registry.rb +24 -5
  50. data/lib/samagotchi/host_registry.rb +9 -12
  51. data/lib/samagotchi/idle_client.rb +24 -15
  52. data/lib/samagotchi/idle_recap.rb +5 -1
  53. data/lib/samagotchi/idle_reminders.rb +2 -2
  54. data/lib/samagotchi/image_store.rb +10 -6
  55. data/lib/samagotchi/kernel_loop.rb +73 -94
  56. data/lib/samagotchi/live_versions.rb +59 -0
  57. data/lib/samagotchi/llm/api_key.rb +41 -0
  58. data/lib/samagotchi/llm/chat_loop.rb +132 -29
  59. data/lib/samagotchi/llm/errors.rb +41 -9
  60. data/lib/samagotchi/llm/http.rb +57 -17
  61. data/lib/samagotchi/llm/openai_chat.rb +17 -30
  62. data/lib/samagotchi/log_subscriber.rb +18 -3
  63. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  64. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  65. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  66. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  67. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  68. data/lib/samagotchi/model_profile.rb +24 -1
  69. data/lib/samagotchi/plugin/context.rb +22 -1
  70. data/lib/samagotchi/plugin/sessions.rb +3 -1
  71. data/lib/samagotchi/prompt.rb +4 -2
  72. data/lib/samagotchi/reminder_store.rb +1 -9
  73. data/lib/samagotchi/reply_wait.rb +126 -0
  74. data/lib/samagotchi/sampling_settings.rb +58 -0
  75. data/lib/samagotchi/self_report.rb +18 -3
  76. data/lib/samagotchi/send_command.rb +252 -11
  77. data/lib/samagotchi/session.rb +52 -11
  78. data/lib/samagotchi/session_archive_command.rb +107 -0
  79. data/lib/samagotchi/session_commands.rb +46 -7
  80. data/lib/samagotchi/session_manager.rb +115 -25
  81. data/lib/samagotchi/session_metrics.rb +222 -106
  82. data/lib/samagotchi/steer.rb +72 -0
  83. data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
  84. data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
  85. data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
  86. data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
  87. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  88. data/lib/samagotchi/terminal_ui.rb +134 -247
  89. data/lib/samagotchi/text_diff.rb +181 -0
  90. data/lib/samagotchi/thinking.rb +115 -0
  91. data/lib/samagotchi/tool_activity.rb +3 -1
  92. data/lib/samagotchi/tool_runner.rb +34 -1
  93. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  94. data/lib/samagotchi/tools/builtins.rb +15 -4
  95. data/lib/samagotchi/tools/delegate_wait.rb +26 -69
  96. data/lib/samagotchi/tools/edit.rb +23 -9
  97. data/lib/samagotchi/tools/execute.rb +52 -14
  98. data/lib/samagotchi/tools/task_runtime.rb +19 -0
  99. data/lib/samagotchi/tools/task_wait.rb +27 -3
  100. data/lib/samagotchi/tools/write.rb +4 -0
  101. data/lib/samagotchi/turn_flow.rb +12 -2
  102. data/lib/samagotchi/turn_note.rb +60 -6
  103. data/lib/samagotchi/update_command.rb +308 -0
  104. data/lib/samagotchi/update_hint.rb +59 -0
  105. data/lib/samagotchi/version.rb +1 -1
  106. data/lib/samagotchi/vision_support.rb +7 -9
  107. data/lib/samagotchi/web/app.rb +91 -7
  108. data/lib/samagotchi/web/message_parts.rb +8 -3
  109. data/lib/samagotchi/web/public/activity.js +13 -1
  110. data/lib/samagotchi/web/public/annotate_presets.js +26 -0
  111. data/lib/samagotchi/web/public/annotations.js +13 -0
  112. data/lib/samagotchi/web/public/app.js +472 -111
  113. data/lib/samagotchi/web/public/card.js +5 -3
  114. data/lib/samagotchi/web/public/chat_view.js +13 -1
  115. data/lib/samagotchi/web/public/copy.js +20 -4
  116. data/lib/samagotchi/web/public/ctx.js +15 -0
  117. data/lib/samagotchi/web/public/data.js +23 -6
  118. data/lib/samagotchi/web/public/diff_view.js +58 -0
  119. data/lib/samagotchi/web/public/format.js +9 -0
  120. data/lib/samagotchi/web/public/index.html +60 -3
  121. data/lib/samagotchi/web/public/notify.js +175 -0
  122. data/lib/samagotchi/web/public/question_card.js +5 -2
  123. data/lib/samagotchi/web/public/sessions_list.js +7 -0
  124. data/lib/samagotchi/web/public/timing.js +39 -14
  125. data/lib/samagotchi/web/public/turn_events.js +75 -5
  126. data/lib/samagotchi/web/public/turn_view.js +49 -8
  127. data/lib/samagotchi/web/server.rb +8 -4
  128. data/lib/samagotchi/web/session_hub.rb +2 -1
  129. data/lib/samagotchi/web/session_summary.rb +24 -1
  130. data/lib/samagotchi/worker.rb +16 -4
  131. metadata +31 -1
@@ -47,12 +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
- RECAP_COMMAND = "/recap"
50
+ SCRATCH_ARCHIVE_REFUSED = "a scratch session is deleted when you leave; nothing to archive"
56
51
  # The prompt while a question waits for its answer (its choices are in
57
52
  # the notes slot).
58
53
  QUESTION_PROMPT = "? "
@@ -103,9 +98,12 @@ module Samagotchi
103
98
  Engine.system_prompt_for(profile)
104
99
  end
105
100
 
101
+ # @param scratch [Boolean] `chi scratch`: a new session that is deleted
102
+ # however the REPL ends, saves no memories and writes no recap
106
103
  def initialize(mode: :assist, prompt: nil, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, no_default_input: false, model_name: nil, memories: [], muted_memories: [], non_interactive: false, surface: nil,
107
- spinner_tick_interval: THINKING_TICK_INTERVAL)
104
+ spinner_tick_interval: THINKING_TICK_INTERVAL, scratch: false)
108
105
  @mode = mode.to_sym
106
+ @scratch = scratch
109
107
  # nil: no ticker thread (specs that compare exact frames).
110
108
  @spinner_tick_interval = spinner_tick_interval
111
109
  @spinner_lock = Monitor.new
@@ -139,6 +137,9 @@ module Samagotchi
139
137
  # Re-resolve client after resume may change effective model
140
138
  @client = @host_registry.resolve(@effective_model_name).client
141
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)
142
143
  # Only a caller's profile goes in; otherwise the Engine resolves one
143
144
  # for the effective model and hands it to the kernel.
144
145
  @kernel = KernelLoop.new(client: @client, profile: profile, no_interrupt: no_interrupt, reminder_store: Samagotchi::ReminderStore.new)
@@ -172,11 +173,14 @@ module Samagotchi
172
173
  profile: profile,
173
174
  session_id: session_id,
174
175
  no_interrupt: no_interrupt,
175
- 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,
176
179
  memories: @requested_memories,
177
180
  muted_memories: @muted_memory_names,
178
181
  kernel: @kernel,
179
- recap: recap_config,
182
+ recap: scratch ? nil : recap_config,
183
+ scratch: scratch,
180
184
  reminders: {
181
185
  callback: lambda { |due_names|
182
186
  # When a reminder is due, queue a synthetic turn that
@@ -242,15 +246,20 @@ module Samagotchi
242
246
  model_name: @effective_model_name,
243
247
  working_directory: Dir.pwd,
244
248
  preloaded_memory_names: @requested_memories,
245
- muted_memory_names: @muted_memory_names
249
+ muted_memory_names: @muted_memory_names,
250
+ scratch: @scratch
246
251
  )
247
252
  claim_session!(session.id) unless @owner_lock
253
+ # Saved at once, so a process killed before its first turn leaves a
254
+ # file the sweep knows to delete.
255
+ session.save if @scratch
248
256
  # Attach before building the prompt so it can name the session id.
249
257
  @engine.session = session
250
258
  messages = messages_for(session)
251
259
 
252
260
  if @prompt && @non_interactive
253
261
  # Headless / CI mode: run directly without TTY rendering.
262
+ ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir)
254
263
  result = @engine.run_turn(
255
264
  session,
256
265
  @prompt,
@@ -259,6 +268,13 @@ module Samagotchi
259
268
  cancel_controller: nil,
260
269
  images: ImageInput.extract(@prompt)
261
270
  )
271
+ if empty_answer?(result)
272
+ # Nothing to print: say so on stderr and fail, so a script that
273
+ # pipes the answer doesn't take the silence for one.
274
+ session.save
275
+ warn EMPTY_ANSWER_ERROR
276
+ exit 1
277
+ end
262
278
  @surface.commit(result.output)
263
279
  session.save
264
280
  return
@@ -266,10 +282,14 @@ module Samagotchi
266
282
 
267
283
  assist_loop(session: session, messages: messages)
268
284
  # After the idle layer has stopped: nothing writes the session now.
269
- if @delete_on_exit
285
+ if @scratch
286
+ nil # deleted below, however the REPL ended
287
+ elsif @delete_on_exit
270
288
  delete_after_exit(session)
271
289
  elsif @discard_on_exit
272
290
  discard_after_exit(session)
291
+ elsif @archive_on_exit
292
+ archive_after_exit(session)
273
293
  else
274
294
  keep_after_exit(session)
275
295
  end
@@ -278,6 +298,32 @@ module Samagotchi
278
298
  # #initialize): the anytime commands finish, the plugins' services
279
299
  # stop (a server process).
280
300
  @engine&.shutdown
301
+ # A scratch session goes on every way out: /exit, Ctrl-D, an error,
302
+ # SIGTERM or SIGHUP (Ruby raises those here). Only kill -9 leaves it
303
+ # to the sweep.
304
+ delete_scratch(session, quiet: @non_interactive || !$!.nil?) if @scratch && session
305
+ end
306
+
307
+ EMPTY_ANSWER_ERROR = "chi: the model gave an empty answer"
308
+
309
+ # A -p --non-interactive turn that ended with no answer (the empty-answer
310
+ # retries used up): no text, or the chat loop's placeholder.
311
+ def empty_answer?(result)
312
+ return false if result.respond_to?(:canceled?) && result.canceled?
313
+ return false if result.respond_to?(:resumable?) && result.resumable?
314
+
315
+ result.output.to_s.strip.empty? || (result.respond_to?(:empty_answer?) && result.empty_answer?)
316
+ end
317
+
318
+ # Delete the scratch session, the last thing the REPL does. Quiet after
319
+ # a one-shot or on the way out of an error or a signal.
320
+ def delete_scratch(session, quiet:)
321
+ @owner_lock&.release
322
+ @owner_lock = nil
323
+ SessionManager.delete_session(session.id)
324
+ @surface.commit("Scratch session deleted.") unless quiet
325
+ rescue StandardError => e
326
+ warn "Scratch session #{session.id} was not deleted (#{e.message}): chi sessions delete #{session.id}"
281
327
  end
282
328
 
283
329
  # The session stays: the recap first, then the resume line, last.
@@ -330,6 +376,17 @@ module Samagotchi
330
376
  @surface.commit("Session #{session.id} was not deleted (#{e.message}): chi sessions delete #{session.id}")
331
377
  end
332
378
 
379
+ # /archive: give up the session, then archive it (hidden from the lists,
380
+ # kept for good). An empty one is discarded instead (#discard_on_exit?).
381
+ def archive_after_exit(session)
382
+ @owner_lock&.release
383
+ @owner_lock = nil
384
+ SessionManager.archive_session(session.id)
385
+ @surface.commit("Archived session #{session.id}. chi sessions list --archived finds it.")
386
+ rescue SessionManager::ArchiveRefused, SessionManager::OwnedByTUI, ArgumentError, SystemCallError => e
387
+ @surface.commit("Session #{session.id} was not archived (#{e.message}): chi sessions archive #{session.id}")
388
+ end
389
+
333
390
  # Take the session's OwnerLock for this process's lifetime: the TUI runs
334
391
  # its own Engine, so no worker may run the session meanwhile.
335
392
  # @raise [SessionBusy] when a worker or another TUI owns it
@@ -356,6 +413,9 @@ module Samagotchi
356
413
  @surface.commit("Resumed session: #{session.id}")
357
414
  saved = @engine.saved_recap
358
415
  @surface.commit(recap_block(saved[:text], turns_since: saved[:turns_since])) if saved
416
+ elsif @scratch
417
+ messages = [system_message]
418
+ @surface.commit("Scratch session: nothing is kept, it is deleted when you leave.")
359
419
  else
360
420
  messages = [system_message]
361
421
  @surface.commit("Session: #{session.id}")
@@ -363,26 +423,8 @@ module Samagotchi
363
423
  messages
364
424
  end
365
425
 
366
- # Public entrypoint for background session workers.
367
- # Keeps worker call sites out of TerminalUI private API details.
368
- def process_background_prompt(session:, prompt:)
369
- @engine.process_background_prompt(session: session, prompt: prompt)
370
- end
371
-
372
426
  private
373
427
 
374
- # Generate profile-aware tool calling hint.
375
- # Kept as a one-line delegator to Engine (single source of truth).
376
- def tool_call_hint
377
- return @engine.tool_call_hint
378
- end
379
-
380
- # Assist system prompt — delegates wholesale to Engine so the two can
381
- # never drift apart. This single delegator fixes the 4-line regression.
382
- def assist_system_prompt
383
- return @engine.assist_system_prompt
384
- end
385
-
386
428
  # Interactive REPL loop. Session seed + messages are built by #run and
387
429
  # threaded in here (so --prompt / --resume share one code path). The
388
430
  # working session is persisted at the end of every turn.
@@ -496,15 +538,22 @@ module Samagotchi
496
538
  end
497
539
  end
498
540
  break if input.nil?
499
- if exit_command?(input)
500
- @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)
544
+ break
545
+ end
546
+ if local == :archive
547
+ next @surface.commit(SCRATCH_ARCHIVE_REFUSED) if @scratch
548
+
549
+ @archive_on_exit = true
501
550
  break
502
551
  end
503
552
  # Not an answer to a continue offer either.
504
- next detach_note if detach_command?(input)
553
+ next detach_note if local == :detach
505
554
 
506
555
  # /stats and /recap run; the offer stays open.
507
- if @turn_flow.awaiting_continue? && !stats_command?(input) && !recap_command?(input)
556
+ if @turn_flow.awaiting_continue? && !%i[stats recap].include?(local)
508
557
  answer_continue_offer(session, input)
509
558
  else
510
559
  # The ? read left no echo: show what ran.
@@ -557,15 +606,14 @@ module Samagotchi
557
606
  persist_recent_history(input) if command.shell
558
607
  return
559
608
  end
560
- return @surface.commit("\nmodel> session stats:\n#{format_session_metrics(@engine.stats_snapshot)}") if stats_command?(input)
561
- return @surface.commit("\nmodel> #{handle_recap_command}") if recap_command?(input)
609
+ return if show_local_command(input)
562
610
 
563
611
  @turn_flow.before_prompt_turn
564
612
  persist_recent_history(input)
565
613
  begin
566
614
  # Engine#run_turn injects due reminders as a tail message, appends
567
615
  # the prompt, and renders through @renderer via on_event.
568
- result = run_engine_turn(session, normalize_model_input(input), images: ImageInput.extract(input))
616
+ result = run_engine_turn(session, input, images: ImageInput.extract(input))
569
617
  rescue LLM::ProviderError, ImageStore::Error => e
570
618
  # Engine closed the turn (:turn_failed); show its duration.
571
619
  # Retries were already tallied via generation_retrying events.
@@ -661,18 +709,6 @@ module Samagotchi
661
709
  prompt
662
710
  end
663
711
 
664
- # Appends the current memory index to the base system prompt so the agent
665
- # is always aware of stored memories without needing to call a tool first.
666
- # Delegates wholesale to Engine (single source of truth).
667
- def system_prompt_with_index(base)
668
- result = @engine.system_prompt_with_index(base)
669
- # Mirror any --memory activations the Engine performed so the sticky
670
- # status line can surface them. The prompt body injection moved into
671
- # Engine; echoing the activated names here is purely a UI concern.
672
- sync_engine_activated_memories
673
- result
674
- end
675
-
676
712
  # Engine owns the system prompt (including --memory activation), but the
677
713
  # sticky status line is a UI concern. Mirror the activated names so they
678
714
  # appear in the status line.
@@ -682,12 +718,6 @@ module Samagotchi
682
718
  end
683
719
  end
684
720
 
685
- # Render a result from a turn that bypassed Engine#run_turn (continue,
686
- # reminder turns) exactly as a :turn_completed would be rendered.
687
- def emit_result(result)
688
- @renderer.render_turn_summary(@engine.turn_summary(result))
689
- end
690
-
691
721
  # ── Turn view: the drawing surface EventRenderer calls ──────────────────
692
722
  public
693
723
 
@@ -758,8 +788,21 @@ module Samagotchi
758
788
  @thinking_waiting_since = nil
759
789
  clear_retry_spinner_status
760
790
  @turn_tally.started(key: tally_key(event), tool: event[:tool], params: event[:params])
761
- memory_loaded = capture_memory_tool_call(event)
762
- 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
763
806
  refresh_thinking_spinner_status
764
807
  end
765
808
  end
@@ -806,16 +849,6 @@ module Samagotchi
806
849
  @surface.commit("#{paint('chi>', 36)} turn #{state} (#{format_elapsed_duration(record[:duration_ms])})")
807
850
  end
808
851
 
809
- def split_memory_scope(raw)
810
- value = raw.to_s.strip
811
- if value.include?("/")
812
- scope, name = value.split("/", 2)
813
- return [scope, name] if Tools::VALID_SCOPES.include?(scope)
814
- end
815
-
816
- [nil, value]
817
- end
818
-
819
852
  def read_input(awaiting_continue:)
820
853
  emit_idle_status_line
821
854
 
@@ -841,6 +874,11 @@ module Samagotchi
841
874
  # empty synthetic turn then (it duplicated output).
842
875
  return unless @engine.reminders_due?
843
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
844
882
  hint = "reminder: #{names.join(", ")} · Ctrl-C cancels it"
845
883
  @surface.set_slot(:hints, [color_output? ? paint(hint, 90) : hint])
846
884
  result = nil
@@ -954,27 +992,20 @@ module Samagotchi
954
992
  @repl_input.prefill(input) ? "prompt restored for retry" : "the failed prompt is in the input history (↑)"
955
993
  end
956
994
 
957
- def shell_bang_command?(input)
958
- input.to_s.match?(/\A!\s*\S/)
959
- 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
960
998
 
961
- def stats_command?(input)
962
- input.to_s.strip == STATS_COMMAND
963
- end
964
-
965
- def recap_command?(input)
966
- input.to_s.strip == RECAP_COMMAND
967
- end
968
-
969
- # SessionCommands runs /model; the REPL mirrors the result.
970
- def handle_model_command(input)
971
- result = @commands.run(input)
972
- sync_model_mirrors
973
- result.output
974
- end
975
-
976
- def handle_models_command
977
- @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
978
1009
  end
979
1010
 
980
1011
  # /recap: the saved recap, and a new one asked for at once when the
@@ -991,29 +1022,10 @@ module Samagotchi
991
1022
  # A resumed session doesn't get the default input either.
992
1023
  def default_input_wanted? = !@resume_session && !@no_default_input
993
1024
 
994
- # exit or /exit, with --delete to delete the session on the way out.
995
- def exit_command?(input)
996
- words = input.to_s.strip.downcase.split
997
- %w[exit /exit].include?(words.first) && (words.size == 1 || words == [words.first, EXIT_DELETE_FLAG])
998
- end
999
-
1000
- def delete_on_exit?(input) = input.to_s.strip.downcase.split.last == EXIT_DELETE_FLAG
1001
-
1002
- def detach_command?(input) = input.to_s.strip.casecmp?("/detach")
1003
-
1004
1025
  def clone_messages(messages)
1005
1026
  Array(messages).map(&:dup)
1006
1027
  end
1007
1028
 
1008
- def skip_agent_description?
1009
- value = ENV[SKIP_AGENT_DESCRIPTION_ENV]
1010
- value == "1" || value&.casecmp?("true")
1011
- end
1012
-
1013
- def rg_available?
1014
- system("command -v rg", out: File::NULL, err: File::NULL)
1015
- end
1016
-
1017
1029
  # Run one REPL turn (a prompt, or a continue/reminder turn with nil) through
1018
1030
  # Engine#run_turn, rendering via @renderer.
1019
1031
  # @param images [Array<Hash>] the prompt's `@path` images ({path:})
@@ -1024,6 +1036,8 @@ module Samagotchi
1024
1036
  # Build the (memoized) prompt now so --memory activations show in this
1025
1037
  # turn's status lines, including after /model rebuilt it.
1026
1038
  seed_system_prompt
1039
+ # A prompt the user typed brings an archived session back to the lists.
1040
+ ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir) if prompt && !continue
1027
1041
  result = with_steering do
1028
1042
  @engine.run_turn(
1029
1043
  session,
@@ -1066,11 +1080,13 @@ module Samagotchi
1066
1080
  # in the prompt.
1067
1081
  # @return [Boolean, :back] whether the turn took it, :back to put it back
1068
1082
  def steer_line(line)
1069
- return exit_after_turn(delete: delete_on_exit?(line)) if line.nil? || exit_command?(line)
1070
- 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
1071
1086
  return false if @active_cancel_controller&.cancelled?
1072
1087
  return start_anytime_command(line) if command_registry.lookup(line)&.anytime
1073
- 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)
1074
1090
  return true if line.strip.empty?
1075
1091
  # Steering merges text only: a line with images runs as the next turn.
1076
1092
  return image_line_waits if ImageInput.extract(line).any?
@@ -1104,15 +1120,10 @@ module Samagotchi
1104
1120
 
1105
1121
  # @return [true, :back]
1106
1122
  def command_during_turn(line)
1107
- if stats_command?(line)
1108
- @surface.commit("\nmodel> session stats:\n#{format_session_metrics(@engine.stats_snapshot)}")
1109
- elsif recap_command?(line)
1110
- @surface.commit("\nmodel> #{handle_recap_command}")
1111
- else
1112
- @surface.commit(COMMAND_BUSY)
1113
- return :back
1114
- end
1115
- true
1123
+ return true if show_local_command(line)
1124
+
1125
+ @surface.commit(COMMAND_BUSY)
1126
+ :back
1116
1127
  end
1117
1128
 
1118
1129
  # D8: an anytime command runs on its own thread while the turn goes on
@@ -1132,16 +1143,12 @@ module Samagotchi
1132
1143
  true
1133
1144
  end
1134
1145
 
1135
- def command_line?(line)
1136
- command_registry.command?(line) || stats_command?(line) || recap_command?(line) || exit_command?(line)
1137
- end
1138
-
1139
- # The kernel's drain at an iteration boundary: queued lines, #memory
1140
- # 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.
1141
1148
  def drain_steering
1142
1149
  @pending_input_queue.drain.map do |line|
1143
1150
  persist_recent_history(line)
1144
- normalize_model_input(line)
1151
+ line
1145
1152
  end
1146
1153
  end
1147
1154
 
@@ -1299,17 +1306,6 @@ module Samagotchi
1299
1306
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
1300
1307
  end
1301
1308
 
1302
- def capture_memory_tool_call(event)
1303
- call = event[:call].is_a?(Hash) ? event[:call] : {}
1304
- memory_name = memory_name_from_tool_call(call)
1305
- return false if memory_name.nil? || memory_name.empty?
1306
-
1307
- added_to_thinking = add_unique_memory_name(:@thinking_memory_names, memory_name)
1308
- add_unique_memory_name(:@session_memory_names, memory_name)
1309
- @thinking_recent_memory_loaded = memory_name if added_to_thinking
1310
- added_to_thinking
1311
- end
1312
-
1313
1309
  def capture_thinking_tool_call(event)
1314
1310
  call = event[:call].is_a?(Hash) ? event[:call] : {}
1315
1311
  name = call[:name].to_s.strip
@@ -1367,67 +1363,6 @@ module Samagotchi
1367
1363
  true
1368
1364
  end
1369
1365
 
1370
- def memory_name_from_tool_call(call)
1371
- tool_name = call[:name].to_s
1372
- case tool_name
1373
- when Tools::MemoryRead::NAME
1374
- normalize_memory_name(call[:content])
1375
- when Tools::Read::NAME
1376
- memory_name_from_read_path(call[:content])
1377
- else
1378
- nil
1379
- end
1380
- end
1381
-
1382
- def normalize_memory_name(raw)
1383
- value = raw.to_s.strip
1384
- return nil if value.empty?
1385
-
1386
- File.basename(value, ".md")
1387
- end
1388
-
1389
- def memory_name_from_read_path(raw_path)
1390
- path = raw_path.to_s.strip.tr("\\", "/")
1391
- return nil if path.empty?
1392
- return nil unless path.match?(/memories[\/].+\.md\z/)
1393
-
1394
- normalize_memory_name(path)
1395
- end
1396
-
1397
- def memory_spinner_segment
1398
- segment = memory_spinner_segment_plain
1399
- return "" if segment.empty?
1400
-
1401
- color_output? ? paint(segment, MEMORY_SPINNER_COLOR) : segment
1402
- end
1403
-
1404
- def memory_spinner_segment_plain
1405
- names = Array(@thinking_memory_names)
1406
- return "" if names.empty?
1407
-
1408
- visible = names.first(MEMORY_SPINNER_PREVIEW_LIMIT)
1409
- suffix = names.length > visible.length ? ", +#{names.length - visible.length}" : ""
1410
- " mem: #{visible.join(', ')}#{suffix}"
1411
- end
1412
-
1413
- def memory_sticky_line
1414
- names = Array(@session_memory_names)
1415
- muted = @engine.muted_memory_names
1416
- return "" if names.empty? && muted.empty?
1417
-
1418
- parts = []
1419
- parts << "active this session: #{preview_names(names)}" unless names.empty?
1420
- parts << "muted: #{preview_names(muted)}" unless muted.empty?
1421
- body = "memories> #{parts.join(' · ')}"
1422
- color_output? ? paint(body, MEMORY_SPINNER_COLOR) : body
1423
- end
1424
-
1425
- def preview_names(names, limit: MEMORY_STICKY_PREVIEW_LIMIT)
1426
- visible = names.first(limit)
1427
- suffix = names.length > visible.length ? ", +#{names.length - visible.length}" : ""
1428
- "#{visible.join(', ')}#{suffix}"
1429
- end
1430
-
1431
1366
  def reset_thinking_memory_names
1432
1367
  @thinking_memory_names = []
1433
1368
  end
@@ -1549,54 +1484,24 @@ module Samagotchi
1549
1484
  ConfigFile.recap_config
1550
1485
  end
1551
1486
 
1552
- def bare_model_for(full_ref)
1553
- @host_registry.bare_name(full_ref)
1554
- end
1555
-
1556
- def spinner_status_line
1557
- return "" unless status_line_enabled?
1558
-
1559
- lines = spinner_status_lines
1560
- lines.empty? ? "" : lines.first
1561
- end
1562
-
1563
1487
  def spinner_status_lines(width: status_effective_width)
1564
1488
  return [] unless status_line_enabled?
1565
1489
 
1566
1490
  build_status_lines(scope: :spinner, width: width)
1567
1491
  end
1568
1492
 
1569
- def sticky_status_line
1570
- return "" unless status_line_enabled?
1571
-
1572
- lines = sticky_status_lines
1573
- lines.empty? ? "" : lines.first
1574
- end
1575
-
1576
1493
  def sticky_status_lines(width: status_effective_width)
1577
1494
  return [] unless status_line_enabled?
1578
1495
 
1579
1496
  build_status_lines(scope: :sticky, width: width)
1580
1497
  end
1581
1498
 
1582
- def idle_status_line
1583
- return "" unless status_line_enabled?
1584
-
1585
- lines = idle_status_lines
1586
- lines.empty? ? "" : lines.first
1587
- end
1588
-
1589
1499
  def idle_status_lines(width: status_effective_width)
1590
1500
  return [] unless status_line_enabled?
1591
1501
 
1592
1502
  build_status_lines(scope: :idle, width: width)
1593
1503
  end
1594
1504
 
1595
- def build_status_line(scope:)
1596
- lines = build_status_lines(scope: scope)
1597
- lines.empty? ? "" : lines.first
1598
- end
1599
-
1600
1505
  # The status rows for +scope+ (:spinner, :sticky or :idle), cut to +width+.
1601
1506
  def build_status_lines(scope:, width: status_effective_width)
1602
1507
  status_rows(status_segments(scope), width)
@@ -1772,11 +1677,6 @@ module Samagotchi
1772
1677
  "model> waiting for the first token... #{waited.floor}s #{frame}"
1773
1678
  end
1774
1679
 
1775
- def thinking_spinner_status_line(frame)
1776
- lines = thinking_spinner_status_lines(frame)
1777
- lines.empty? ? "" : lines.first
1778
- end
1779
-
1780
1680
  # The spinner row, then (from a turn's 3rd tool call) its tool tally.
1781
1681
  # The spinner stops while tools run, so the tally shows while the model
1782
1682
  # generates between tool rounds.
@@ -1844,10 +1744,6 @@ module Samagotchi
1844
1744
  nil
1845
1745
  end
1846
1746
 
1847
- def pending_question_event?
1848
- !!@pending_question_event
1849
- end
1850
-
1851
1747
  # Called from REPL thread (run_assist_loop top) — renders widget, blocks
1852
1748
  # until user selects, then answers via Engine#answer_question which wakes
1853
1749
  # the parked turn thread.
@@ -1889,6 +1785,9 @@ module Samagotchi
1889
1785
 
1890
1786
  # Ensure spinner cleared and terminal in known state (same as reminder mute handling)
1891
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?
1892
1791
  choices = prompt.slot(paint: method(:paint))
1893
1792
 
1894
1793
  question_prompt = paint(QUESTION_PROMPT, 33)
@@ -1976,17 +1875,5 @@ module Samagotchi
1976
1875
  return :canceled if controller&.cancelled?
1977
1876
  end
1978
1877
  end
1979
-
1980
- # Process a prompt through the kernel loop and return the model response.
1981
- # Delegates to the internal Engine instance.
1982
- def process_prompt_through_kernel(session, prompt)
1983
- @engine.process_prompt_through_kernel(session, prompt)
1984
- end
1985
-
1986
- # Write an agent response to the session output directory.
1987
- def write_session_output(output_dir, response)
1988
- timestamp = Time.now.strftime("%Y%m%d%H%M%S%9N")
1989
- File.write(File.join(output_dir, "#{timestamp}.txt"), response.to_s)
1990
- end
1991
1878
  end
1992
1879
  end