samagotchi 0.101.0 → 0.103.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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -1
  3. data/bin/chi +31 -196
  4. data/docs/architecture.md +62 -6
  5. data/docs/cli.md +154 -7
  6. data/docs/configuration.md +32 -5
  7. data/docs/guardrails.md +2 -1
  8. data/docs/internals/bundles.md +1 -1
  9. data/docs/internals/engine.md +1 -1
  10. data/docs/internals/llm-context-forget.md +25 -1
  11. data/docs/internals/prompt-caching.md +8 -1
  12. data/docs/internals/remote-sessions.md +303 -0
  13. data/docs/internals/working-set.md +174 -15
  14. data/docs/plugins.md +32 -5
  15. data/docs/sessions.md +38 -14
  16. data/docs/sub-agent.md +8 -0
  17. data/docs/testing.md +7 -0
  18. data/lib/samagotchi/answer_command.rb +27 -25
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +16 -0
  20. data/lib/samagotchi/bridge.rb +33 -7
  21. data/lib/samagotchi/bridge_client/event_stream.rb +81 -12
  22. data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
  23. data/lib/samagotchi/bridge_client.rb +22 -38
  24. data/lib/samagotchi/bundle_command/check.rb +5 -1
  25. data/lib/samagotchi/bundle_command/try/command.rb +6 -6
  26. data/lib/samagotchi/bundle_command/try/sandbox.rb +3 -3
  27. data/lib/samagotchi/bundle_command.rb +4 -4
  28. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  29. data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
  30. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
  32. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
  33. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  34. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  35. data/lib/samagotchi/chi_invocation.rb +43 -0
  36. data/lib/samagotchi/cli/command.rb +3 -2
  37. data/lib/samagotchi/cli/flags.rb +5 -0
  38. data/lib/samagotchi/cli/parent_wait.rb +22 -13
  39. data/lib/samagotchi/commands/registry.rb +12 -1
  40. data/lib/samagotchi/config.rb +89 -53
  41. data/lib/samagotchi/context_command.rb +80 -73
  42. data/lib/samagotchi/engine.rb +138 -15
  43. data/lib/samagotchi/idle_recap.rb +7 -1
  44. data/lib/samagotchi/idle_scheduler.rb +16 -1
  45. data/lib/samagotchi/kernel_loop.rb +13 -1
  46. data/lib/samagotchi/launch_mode.rb +22 -0
  47. data/lib/samagotchi/llm_context_edit.rb +32 -10
  48. data/lib/samagotchi/llm_context_forget.rb +24 -8
  49. data/lib/samagotchi/llm_context_view.rb +19 -7
  50. data/lib/samagotchi/memory_bundle/installer.rb +2 -0
  51. data/lib/samagotchi/memory_bundle/manifest.rb +13 -2
  52. data/lib/samagotchi/message_parts.rb +8 -3
  53. data/lib/samagotchi/note_command.rb +8 -7
  54. data/lib/samagotchi/parent_report.rb +55 -43
  55. data/lib/samagotchi/plugin/context.rb +2 -2
  56. data/lib/samagotchi/prompt.rb +1 -1
  57. data/lib/samagotchi/recap_command.rb +68 -0
  58. data/lib/samagotchi/recap_setup.rb +27 -14
  59. data/lib/samagotchi/remote_command.rb +170 -0
  60. data/lib/samagotchi/remotes.rb +130 -0
  61. data/lib/samagotchi/run_options.rb +233 -0
  62. data/lib/samagotchi/send_command/busy.rb +58 -0
  63. data/lib/samagotchi/send_command/parent_gate.rb +23 -10
  64. data/lib/samagotchi/send_command/wait_target.rb +16 -3
  65. data/lib/samagotchi/send_command.rb +186 -118
  66. data/lib/samagotchi/session.rb +48 -16
  67. data/lib/samagotchi/session_access/contract.rb +129 -0
  68. data/lib/samagotchi/session_access/dispatch.rb +133 -0
  69. data/lib/samagotchi/session_access/http_transport.rb +109 -0
  70. data/lib/samagotchi/session_access/local.rb +349 -0
  71. data/lib/samagotchi/session_access/remote.rb +638 -0
  72. data/lib/samagotchi/session_access/web_bridge_client.rb +181 -0
  73. data/lib/samagotchi/session_access.rb +141 -0
  74. data/lib/samagotchi/session_archive_command.rb +9 -5
  75. data/lib/samagotchi/session_commands.rb +53 -6
  76. data/lib/samagotchi/session_config.rb +411 -0
  77. data/lib/samagotchi/session_delete_command.rb +8 -4
  78. data/lib/samagotchi/session_manager.rb +7 -2
  79. data/lib/samagotchi/session_setup.rb +15 -10
  80. data/lib/samagotchi/sessions_command.rb +42 -16
  81. data/lib/samagotchi/terminal_ui/attach_launcher.rb +54 -18
  82. data/lib/samagotchi/terminal_ui/attach_link.rb +104 -0
  83. data/lib/samagotchi/terminal_ui/attached_loop.rb +82 -76
  84. data/lib/samagotchi/terminal_ui/formatting.rb +10 -3
  85. data/lib/samagotchi/terminal_ui/local_link.rb +130 -0
  86. data/lib/samagotchi/terminal_ui/version_lines.rb +5 -3
  87. data/lib/samagotchi/terminal_ui.rb +8 -6
  88. data/lib/samagotchi/testing/bundle.rb +3 -2
  89. data/lib/samagotchi/title_command.rb +9 -11
  90. data/lib/samagotchi/version.rb +1 -1
  91. data/lib/samagotchi/web/app.rb +387 -79
  92. data/lib/samagotchi/web/public/app.js +2 -0
  93. data/lib/samagotchi/web/public/commands.js +8 -2
  94. data/lib/samagotchi/web/public/data.js +6 -4
  95. data/lib/samagotchi/web/public/index.html +37 -3
  96. data/lib/samagotchi/web/public/stage_frame.js +7 -2
  97. data/lib/samagotchi/web/public/stage_view.js +3 -2
  98. data/lib/samagotchi/web/public/stream_handlers.js +7 -1
  99. data/lib/samagotchi/web/public/turn_html.js +5 -0
  100. data/lib/samagotchi/web/public/working_set.js +247 -24
  101. data/lib/samagotchi/web/public/working_set_rail.js +39 -12
  102. data/lib/samagotchi/web/public/working_set_view.js +198 -25
  103. data/lib/samagotchi/web/session_access_routes.rb +343 -0
  104. data/lib/samagotchi/worker.rb +9 -6
  105. data/lib/samagotchi/worker_sidecar.rb +12 -5
  106. data/lib/samagotchi/working_set.rb +132 -20
  107. data/lib/samagotchi/working_set_command.rb +222 -0
  108. data/lib/samagotchi/working_set_pin.rb +34 -0
  109. data/lib/samagotchi/working_set_steer.rb +414 -0
  110. metadata +23 -2
  111. data/lib/samagotchi/bundle_session_settings.rb +0 -251
@@ -107,6 +107,8 @@ module Samagotchi
107
107
 
108
108
  # What memory_write answers in a scratch session.
109
109
  SCRATCH_MEMORY_WRITE = "Error: scratch session: nothing is saved"
110
+ # The session setting /recap on|off|default writes (#recap_override=).
111
+ RECAP_ENABLED_KEY = "recap.enabled"
110
112
 
111
113
  # @param client [Client, nil] defaults to Client.new
112
114
  # @param profile [ModelProfile, Symbol, String, nil]
@@ -232,9 +234,9 @@ module Samagotchi
232
234
  # across UIs.
233
235
  @turn_state.restart_clock!
234
236
  @side_client_mutex = Mutex.new
235
- @recap = RecapSetup.build(recap, engine: self, host_registry: @host_registry,
236
- session_target: -> { session_model_recap_target },
237
- session_id: -> { @session&.id }, state_dir: -> { session_state_dir })
237
+ # Kept for a rebuild under a bound session's own recap values.
238
+ @recap_kwarg = recap
239
+ @recap = build_recap
238
240
  # One shared poller for the whole idle layer (reminders + optional
239
241
  # recap). Both jobs read the activity seam above; the scheduler owns
240
242
  # the single background thread and isolates per-job failures.
@@ -345,6 +347,8 @@ module Samagotchi
345
347
  # A plugin tool's row refs resolve against the session's folder, as
346
348
  # answer refs do (AnswerRefs), not the process's.
347
349
  @kernel.session_cwd = -> { @session&.working_directory }
350
+ # forget_outputs refuses what the user pinned in the working set.
351
+ @kernel.working_set_pins = -> { @session&.working_set_pins || [] }
348
352
  # A cut tool output is saved whole in the session's folder.
349
353
  @kernel.session_dir = -> { @session&.id && Session.session_dir(@session.id, state_dir: session_state_dir) }
350
354
  @kernel.guardrail_gate = @guardrail_wiring.gate
@@ -786,7 +790,7 @@ module Samagotchi
786
790
  # UIs need in the event log, emitted outside any turn's stream.
787
791
  ANNOUNCEABLE_EVENTS = %i[turn_enqueued turn_dequeued input_merged prompt_restored continue_offered continue_resolved
788
792
  command_queued command_ran context_added card hook_notice guardrail_warning
789
- plugin_init_started plugin_init_finished].freeze
793
+ plugin_init_started plugin_init_finished working_set_updated].freeze
790
794
 
791
795
  # Put a transport-level event into the ordered event log. Unlike turn
792
796
  # events it reaches only persistent observers (no turn sink, no memory
@@ -807,6 +811,34 @@ module Samagotchi
807
811
  @session_observer.synchronize(&)
808
812
  end
809
813
 
814
+ # The working set's :working_set_updated, announced between turns
815
+ # (after a command changed what the next request sends), when it
816
+ # differs from the last one emitted (#emit_working_set). Under the
817
+ # event lock, so it is ordered with the change's own events.
818
+ def announce_working_set(session = @session)
819
+ return unless session
820
+
821
+ synchronize_events { emit_working_set(nil, session, session.messages) }
822
+ end
823
+
824
+ # The strategy the working set is built under now, as LLMContextView
825
+ # takes it: the running turn's, else the next turn's (#emit_working_set).
826
+ # /working-set's ops act under it.
827
+ # @return [Symbol, Array<Symbol>]
828
+ def working_set_strategy
829
+ working_set_llm_context&.strategy || LLMContextView::NONE
830
+ end
831
+
832
+ # Replace the current session's conversation, between turns (a
833
+ # /working-set op's copy: #clone_messages, then its edits).
834
+ # @param messages [Array<Hash>]
835
+ # @return [Array<Hash>] the session's messages
836
+ def replace_messages(messages)
837
+ raise ArgumentError, "no current session" unless @session
838
+
839
+ replace_session_messages(@session, messages)
840
+ end
841
+
810
842
  # Add a context note to the session's conversation, between turns: a
811
843
  # tail system message the next turn sees, announced as :context_added.
812
844
  # The array is replaced, not mutated, and the append and the event
@@ -1131,6 +1163,8 @@ module Samagotchi
1131
1163
  # price_host: the effective model's host, whose hosts.<name>.models
1132
1164
  # prices the cost estimate names. kit_prompt: the next turn's kit
1133
1165
  # prompt text ({kit:, tokens:}), only when its toolkit has one.
1166
+ # settings: the session's own settings ({"recap.enabled" => false}),
1167
+ # only when it has some.
1134
1168
  # @return [Hash] @metrics.snapshot, filled in
1135
1169
  def stats_snapshot
1136
1170
  snapshot = @metrics.snapshot
@@ -1145,6 +1179,9 @@ module Samagotchi
1145
1179
  price_host: model.host_name)
1146
1180
  kit_prompt = kit_prompt_stats
1147
1181
  snapshot = snapshot.merge(kit_prompt: kit_prompt) if kit_prompt
1182
+ # The session's own settings (chi --set), as --set names them.
1183
+ settings = @session&.settings&.flat || {}
1184
+ snapshot = snapshot.merge(settings: settings) unless settings.empty?
1148
1185
  unless snapshot.dig(:context, :window_tokens)
1149
1186
  window = current_context_window(model)
1150
1187
  if window
@@ -1722,6 +1759,8 @@ module Samagotchi
1722
1759
  @session = session
1723
1760
  # One session per REPL/worker process: its records carry this sid.
1724
1761
  Log.session_id = session.id if session&.id
1762
+ # Its own recap values (chi --set recap.enabled=false) rebuild the job.
1763
+ rebuild_recap!
1725
1764
  # A woken worker's /stats and status line count the turns before it.
1726
1765
  bind_metrics(session)
1727
1766
  @used_memories.absorb(session)
@@ -1752,6 +1791,66 @@ module Samagotchi
1752
1791
  # @return [IdleRecap, nil] the idle recap detector, or nil when disabled
1753
1792
  attr_reader :recap
1754
1793
 
1794
+ # Whether a continue offer waits (the Worker's or the REPL's TurnFlow),
1795
+ # for the recap job: this one and any rebuilt one (IdleRecap#awaiting_continue=).
1796
+ def recap_awaiting_continue=(callable)
1797
+ @recap_awaiting_continue = callable
1798
+ @recap&.awaiting_continue = callable
1799
+ end
1800
+
1801
+ # The idle recap job (RecapSetup.build) under the current session's own
1802
+ # recap values, which it records.
1803
+ # @return [IdleRecap, nil]
1804
+ def build_recap
1805
+ @recap_built_for = session_recap_values
1806
+ recap = RecapSetup.build(@recap_kwarg, engine: self, host_registry: @host_registry,
1807
+ session_target: -> { session_model_recap_target },
1808
+ session_id: -> { @session&.id }, state_dir: -> { session_state_dir },
1809
+ session_config: -> { @session&.settings&.core })
1810
+ recap.awaiting_continue = @recap_awaiting_continue if recap && @recap_awaiting_continue
1811
+ recap
1812
+ end
1813
+ private :build_recap
1814
+
1815
+ # The bound session's own recap.* values ({} for none).
1816
+ def session_recap_values
1817
+ (@session&.settings&.core || {}).select { |key, _| key.start_with?("recap.") }
1818
+ end
1819
+ private :session_recap_values
1820
+
1821
+ # A new recap job when the bound session's own recap values differ from
1822
+ # the ones the current job was built under (none at start): the old one
1823
+ # is dropped (an attempt in flight goes stale) and the scheduler swaps
1824
+ # it, keeping its one thread. Binding the same session again changes
1825
+ # nothing (the REPL binds it twice).
1826
+ def rebuild_recap!
1827
+ return if session_recap_values == @recap_built_for
1828
+
1829
+ old = @recap
1830
+ old&.invalidate!
1831
+ @recap = build_recap
1832
+ @idle_scheduler&.replace_job(old, @recap)
1833
+ end
1834
+
1835
+ # /recap on|off|default: the bound session's own recap.enabled (true,
1836
+ # false; nil removes it, so config.yml's applies again), and the recap
1837
+ # job rebuilt under it at once. It doesn't save: the command says
1838
+ # changed: [:recap] and its host saves (Worker#run_command).
1839
+ # @param enabled [Boolean, nil]
1840
+ # @raise [ArgumentError] no session is bound
1841
+ def recap_override=(enabled)
1842
+ raise ArgumentError, "no session" unless @session
1843
+
1844
+ core = @session.settings.core.dup
1845
+ enabled.nil? ? core.delete(RECAP_ENABLED_KEY) : core[RECAP_ENABLED_KEY] = enabled
1846
+ @session.settings = @session.settings.with(core: core)
1847
+ rebuild_recap!
1848
+ end
1849
+
1850
+ # The bound session's own recap.enabled (/recap on|off, --set), or nil.
1851
+ # @return [Boolean, nil]
1852
+ def recap_override = @session&.settings&.core&.fetch(RECAP_ENABLED_KEY, nil)
1853
+
1755
1854
  # Write the recap now, before the session is left (IdleRecap#write_now).
1756
1855
  # @return [String, nil] the recap written, nil when none was (recap off,
1757
1856
  # nothing new, an error, the timeout)
@@ -2846,30 +2945,54 @@ module Samagotchi
2846
2945
  end
2847
2946
 
2848
2947
  # The working set (WorkingSet) as the model has it, as a
2849
- # :working_set_updated event ({items:, at:}): at each request (the
2948
+ # :working_set_updated event ({items:, at:}, adds: while the user's
2949
+ # adds are pending): at each request (the
2850
2950
  # stream handler's :generation_started, from the running turn's
2851
2951
  # conversation) and at turn end (#end_turn, from the session's kept
2852
2952
  # messages). Only when it differs from the last one emitted for this
2853
- # session, and never while it is empty, so a tool-free turn emits
2854
- # none. The web overlays it on the rows' set; other UIs ignore it.
2953
+ # session, and never while it is empty — except one empty event,
2954
+ # remembered, when the last one for this session had items (a
2955
+ # rollback removed the tool calls), so a tool-free turn emits none
2956
+ # and the next empty request sends nothing more. The web overlays it
2957
+ # on the rows' set; other UIs ignore it.
2958
+ #
2959
+ # Between turns (#announce_working_set: no +on_event+) it is
2960
+ # announced, under the next turn's strategy.
2855
2961
  def emit_working_set(on_event, session, conversation)
2856
2962
  return unless session && conversation
2857
2963
 
2858
- llm_context = @kernel.respond_to?(:turn_settings) ? @kernel.turn_settings&.llm_context : nil
2859
- snapshot = WorkingSet.build(conversation, cwd: session.working_directory,
2860
- strategy: llm_context&.strategy || LLMContextView::NONE)
2861
- return if snapshot.empty?
2964
+ snapshot = WorkingSet.build(conversation, cwd: session.working_directory, strategy: working_set_strategy,
2965
+ pins: session.working_set_pins)
2862
2966
 
2863
- items = snapshot.items.map(&:to_h)
2864
- return if @working_set_emitted == [session.id, items]
2967
+ shown = snapshot.to_h.except(:at)
2968
+ last = @working_set_emitted
2969
+ if snapshot.empty?
2970
+ return unless last && last[0] == session.id && last[1] != { items: [] }
2971
+ elsif last == [session.id, shown]
2972
+ return
2973
+ end
2865
2974
 
2866
- @working_set_emitted = [session.id, items]
2867
- emit_event(on_event, { type: :working_set_updated, items: items, at: snapshot.at })
2975
+ @working_set_emitted = [session.id, shown]
2976
+ event = { type: :working_set_updated, **shown, at: snapshot.at }
2977
+ on_event ? emit_event(on_event, event) : announce_or_hold(event)
2868
2978
  rescue StandardError => e
2869
2979
  Log.warn(:turn, "working_set_failed", error: e.class.name, msg: e.message.to_s[0, 200])
2870
2980
  end
2871
2981
  private :emit_working_set
2872
2982
 
2983
+ # The strategy the set is built under: the running turn's
2984
+ # (TurnSettings#llm_context: what its requests send); between turns
2985
+ # the next turn's (as #warm_up_next_turn: the turn's settings keep the
2986
+ # last turn's, stale after a /llm-context or /model change), the last
2987
+ # turn's when it can't be resolved.
2988
+ def working_set_llm_context
2989
+ turn = @kernel.respond_to?(:turn_settings) ? @kernel.turn_settings&.llm_context : nil
2990
+ return turn if turn_running?
2991
+
2992
+ @model_settings.turn_llm_context || turn
2993
+ end
2994
+ private :working_set_llm_context
2995
+
2873
2996
  # A :generation_completed with the generation's decode speed (`speed:`
2874
2997
  # {decode_tps:, source:}, nil without one), the session's running
2875
2998
  # token totals (`tokens:`, the snapshot's block) and its per-model rows
@@ -550,10 +550,16 @@ module Samagotchi
550
550
  @client
551
551
  end
552
552
 
553
+ # The summarize thread: its recap, or nil when it failed. A failure of
554
+ # any kind (a LoadError, a stack overflow too) ends it quietly, logged:
555
+ # no recap this time, and no dead thread's trace on the worker's
556
+ # stderr. A real exit or interrupt goes on as raised.
553
557
  def spawn_summarize(client, prompt)
554
558
  Thread.new do
555
559
  client.summarize(prompt, session_id: @session_id.call, on_usage: @on_usage)
556
- rescue StandardError => e
560
+ rescue SystemExit, SignalException
561
+ raise
562
+ rescue Exception => e # rubocop:disable Lint/RescueException -- a recap's failure, whatever it is, is no recap
557
563
  # No recap this time (the next idle window tries again); say why.
558
564
  Log.exception(:recap, "summarize_failed", e)
559
565
  nil
@@ -25,7 +25,7 @@ module Samagotchi
25
25
  raise ArgumentError, "IdleScheduler requires an engine" unless engine
26
26
 
27
27
  @engine = engine
28
- @jobs = jobs.compact
28
+ @jobs = jobs.compact.freeze
29
29
  @thread = nil
30
30
  @stopped = false
31
31
  end
@@ -49,6 +49,21 @@ module Samagotchi
49
49
  @thread = nil
50
50
  end
51
51
 
52
+ # Swap the job +old+ for +new+ (Engine#session= rebuilding the recap):
53
+ # nil +old+ adds +new+, nil +new+ drops +old+. The list is replaced
54
+ # whole, so a tick on the thread steps the old list or the new one; a
55
+ # running scheduler keeps its one thread, and one with no jobs yet
56
+ # starts it once #start is called.
57
+ def replace_job(old, new)
58
+ jobs = if @jobs.any? { |job| job.equal?(old) }
59
+ @jobs.map { |job| job.equal?(old) ? new : job }
60
+ else
61
+ @jobs + [new]
62
+ end
63
+ @jobs = jobs.compact.freeze
64
+ self
65
+ end
66
+
52
67
  # One step across all jobs. Public so specs can drive it deterministically.
53
68
  def tick
54
69
  return if @stopped
@@ -30,6 +30,7 @@ require_relative "tool_ids"
30
30
  require_relative "llm_context_view"
31
31
  require_relative "llm_context_apply"
32
32
  require_relative "llm_context_forget"
33
+ require_relative "working_set"
33
34
  require_relative "llm_context_notice"
34
35
  require_relative "answer_display"
35
36
  require_relative "llm/model_result"
@@ -178,6 +179,10 @@ module Samagotchi
178
179
  # a plugin tool's row refs resolve their files against (ToolRunner,
179
180
  # RowRefs); the Engine sets it. nil: the process's own (Dir.pwd).
180
181
  attr_accessor :session_cwd
182
+ # The session's working-set pins (a callable → Array<WorkingSetPin>),
183
+ # which forget_outputs refuses (LLMContextForget); the Engine sets it.
184
+ # nil: none.
185
+ attr_accessor :working_set_pins
181
186
  # The session's folder (a callable → String or nil), where ToolRunner
182
187
  # saves a cut output whole (ToolOutputFiles); the Engine sets it. nil:
183
188
  # none (a bare kernel), the cut says the output wasn't saved.
@@ -945,9 +950,16 @@ module Samagotchi
945
950
  end
946
951
 
947
952
  apply = -> { apply_llm_context!(turn.conversation, llm_context, context: turn.context, emit: turn.emit) }
948
- LLMContextForget.call(turn, call, llm_context: llm_context, apply: apply)
953
+ LLMContextForget.call(turn, call, llm_context: llm_context, apply: apply, pinned: method(:pinned_ids))
949
954
  end
950
955
 
956
+ # The ids of +conversation+'s outputs the session's pins hold.
957
+ def pinned_ids(conversation)
958
+ pins = Array(@working_set_pins&.call)
959
+ WorkingSet.pinned_ids(conversation, pins: pins, cwd: @session_cwd&.call)
960
+ end
961
+ private :pinned_ids
962
+
951
963
  # Before each request of both loops (+moment+ :request; the warm-up
952
964
  # too, on its own copy, as the next turn's first request) and at the
953
965
  # end of a turn the model answered (:turn_end): the strategy's layers
@@ -11,6 +11,14 @@ module Samagotchi
11
11
  # reach the worker: a /model command, and no_interrupt on each posted
12
12
  # turn; --memory and --mute are session fields the worker reads.)
13
13
  REPL_ONLY = { verbose: "--verbose" }.freeze
14
+ # What an attached run can't be combined with (the worker runs its turns).
15
+ ATTACHED_CONFLICTS = { non_interactive: "--non-interactive", verbose: "--verbose" }.freeze
16
+ # Opening a `chi scratch` session a killed REPL left behind would turn it
17
+ # into a kept one.
18
+ SCRATCH_LEFTOVER = "that's a leftover scratch session; it is deleted at the next sweep (chi sessions clean)"
19
+ # A worker spawned or resumed for an attach whose Bridge never came (%s:
20
+ # the session id).
21
+ BRIDGE_LATE = "the worker for session %s did not start its Bridge in time"
14
22
 
15
23
  module_function
16
24
 
@@ -30,5 +38,19 @@ module Samagotchi
30
38
 
31
39
  [:attached, nil]
32
40
  end
41
+
42
+ # Why an attached run's flags don't go together, or nil when they do.
43
+ # @param options [Hash] bin/chi's parsed options
44
+ # @return [String, nil]
45
+ def attached_conflict(options)
46
+ conflict = ATTACHED_CONFLICTS.find { |key, _flag| options[key] }
47
+ if options[:attach] && options[:shared]
48
+ "use either --attach ID or --shared"
49
+ elsif options[:attach] && options[:resume]
50
+ "--attach takes the session id; --resume goes with --shared"
51
+ elsif conflict
52
+ "--attach/--shared can't be combined with #{conflict.last}"
53
+ end
54
+ end
33
55
  end
34
56
  end
@@ -14,17 +14,21 @@ module Samagotchi
14
14
  # are replaced, never changed in place (.store), or an edit would reach
15
15
  # a checkpoint it was rolled back from.
16
16
  #
17
- # kind: :stale (chi saw a later read or edit supersede it) or :forget
18
- # (the model forgot it, its note keeping the finding); note: what the
19
- # stub says; by: who made it ("chi", the model); staged_at/applied_at:
17
+ # kind: :stale (chi saw a later read or edit supersede it), :forget
18
+ # (the model forgot it, its note keeping the finding) or :drop (the user
19
+ # dropped it from the working set: WorkingSetSteer; sent whatever the
20
+ # strategy, LLMContextView#sends?); note: what the
21
+ # stub says; by: who made it ("chi", the model, "user"); staged_at/applied_at:
20
22
  # ISO 8601 times, applied_at nil while it waits to reach the prompt;
21
23
  # keep: the output's lines a forget keeps ([[first, last], …], as the
22
24
  # model named them; saved only when there are some), sent under the
23
25
  # stub; keep_offset: what those numbers are past the output's own lines
24
26
  # (a ranged read's start_line - 1: a read's keep names file lines; saved
25
- # only when not 0).
26
- LLMContextEdit = Data.define(:id, :kind, :note, :by, :staged_at, :applied_at, :keep, :keep_offset) do
27
- def initialize(keep: [], keep_offset: 0, **fields) = super
27
+ # only when not 0); was: a drop's, the edit the output held before it
28
+ # (its saved Hash: an applied forget sent whole under none, a staged
29
+ # one, a stale stub), which an undrop puts back (one edit per id).
30
+ LLMContextEdit = Data.define(:id, :kind, :note, :by, :staged_at, :applied_at, :keep, :keep_offset, :was) do
31
+ def initialize(keep: [], keep_offset: 0, was: nil, **fields) = super
28
32
 
29
33
  def applied? = !applied_at.nil?
30
34
 
@@ -33,19 +37,27 @@ module Samagotchi
33
37
  # What the model reads instead of the output, after the output's
34
38
  # "[name]" lead.
35
39
  def stub
36
- kind == :forget ? "(forgotten) #{note}" : note.to_s
40
+ case kind
41
+ when :forget then "(forgotten) #{note}"
42
+ when :drop then LLMContextEdit::DROP_STUB
43
+ else note.to_s
44
+ end
37
45
  end
38
46
 
39
47
  # The saved form, without the id (it is the key).
40
48
  def to_h
41
49
  saved = { "kind" => kind.to_s, "note" => note, "by" => by, "staged_at" => staged_at, "applied_at" => applied_at }
42
50
  saved = saved.merge("keep" => keep) if keep?
43
- keep_offset.zero? ? saved : saved.merge("keep_offset" => keep_offset)
51
+ saved = saved.merge("keep_offset" => keep_offset) unless keep_offset.zero?
52
+ was ? saved.merge("was" => was) : saved
44
53
  end
45
54
  end
46
55
 
47
56
  class LLMContextEdit
48
- KINDS = %i[stale forget].freeze
57
+ KINDS = %i[stale forget drop].freeze
58
+ # A user's drop is sent whatever the strategy (no layer names it).
59
+ DROP = :drop
60
+ DROP_STUB = "(dropped by the user) — read it again if the task needs it"
49
61
 
50
62
  # +hash+ as saved (string or symbol keys), or nil for one of no known
51
63
  # kind.
@@ -58,7 +70,8 @@ module Samagotchi
58
70
 
59
71
  new(id: id.to_s, kind: kind, note: field.call(:note).to_s, by: field.call(:by), staged_at: field.call(:staged_at),
60
72
  applied_at: field.call(:applied_at), keep: ranges(field.call(:keep)),
61
- keep_offset: field.call(:keep_offset).is_a?(Integer) ? [field.call(:keep_offset), 0].max : 0)
73
+ keep_offset: field.call(:keep_offset).is_a?(Integer) ? [field.call(:keep_offset), 0].max : 0,
74
+ was: field.call(:was).is_a?(Hash) ? field.call(:was) : nil)
62
75
  end
63
76
 
64
77
  # Saved keep ranges as [[first, last], …]: pairs of positive Integers,
@@ -87,6 +100,15 @@ module Samagotchi
87
100
  entry
88
101
  end
89
102
 
103
+ # Whether +entry+ holds a saved drop (applied or not): the cheap check
104
+ # LLMContextView#messages makes under none.
105
+ def self.drop_on?(entry)
106
+ saved = entry[:edits]
107
+ saved.is_a?(Hash) && saved.each_value.any? do |hash|
108
+ hash.is_a?(Hash) && (hash["kind"] || hash[:kind]).to_s == "drop"
109
+ end
110
+ end
111
+
90
112
  # The edits saved on +entry+, by id.
91
113
  # @return [Hash{String => LLMContextEdit}]
92
114
  def self.on(entry)
@@ -9,6 +9,7 @@ require_relative "llm_context_stale"
9
9
  require_relative "llm_context_apply"
10
10
  require_relative "llm_context_view"
11
11
  require_relative "tools/forget_outputs"
12
+ require_relative "working_set_steer"
12
13
 
13
14
  module Samagotchi
14
15
  # The forget layer (LLMContextStrategy :forget, experimental): the
@@ -33,7 +34,12 @@ module Samagotchi
33
34
  # read (CLM's lesson);
34
35
  # - a keep that holds all of the output, an output too small for a stub
35
36
  # to free anything (SMALL), an output already stubbed or forgotten, an
36
- # id nothing in context has.
37
+ # id nothing in context has;
38
+ # - an output the user dropped (WorkingSetSteer): neither forgotten nor
39
+ # restored by the model, in WorkingSetSteer::Refused's words;
40
+ # - an output of an item the user pinned (+pinned+: WorkingSet.pinned_ids
41
+ # of the session's pins), in WorkingSetSteer::Refused's words too (a
42
+ # restore of one is fine: it brings the output back).
37
43
  # A read is never restored: reading the file again is its restore, and
38
44
  # the file may have changed.
39
45
  module LLMContextForget
@@ -71,8 +77,10 @@ module Samagotchi
71
77
  # call (both loops add the model's entry before its calls run).
72
78
  # @param apply [#call] → LLMContextApply::Outcome: the apply rule at a
73
79
  # request, on the turn's conversation (KernelLoop#apply_llm_context!)
80
+ # @param pinned [#call, nil] conversation → the ids the user's pins
81
+ # hold (WorkingSet.pinned_ids), asked only when a forget is asked
74
82
  # @return [String] the tool result
75
- def call(turn, call, llm_context:, apply:, root: Dir.pwd, now: Time.now.utc.iso8601(3))
83
+ def call(turn, call, llm_context:, apply:, root: Dir.pwd, pinned: nil, now: Time.now.utc.iso8601(3))
76
84
  request = Tools::ForgetOutputs.parse(call)
77
85
  conversation = turn.conversation
78
86
  return restore(turn, request.restore, llm_context) if request.restore? && request.ids.empty?
@@ -82,7 +90,8 @@ module Samagotchi
82
90
  "what's ruled out, NEXT), since it is all that stays of them"
83
91
  end
84
92
 
85
- forgotten, refused = forget(conversation, request, llm_context.protect_steps, root, now)
93
+ pinned_ids = pinned ? pinned.call(conversation) : Set.new
94
+ forgotten, refused = forget(conversation, request, llm_context.protect_steps, root, now, pinned_ids)
86
95
  lines = if forgotten.empty?
87
96
  ["nothing forgotten"]
88
97
  else
@@ -96,7 +105,7 @@ module Samagotchi
96
105
  # Saves a staged forget on each output +request+ names that may go.
97
106
  # @return [Array(Array<String>, Hash{String => String})] the ids
98
107
  # forgotten, and the refused ones with why
99
- def forget(conversation, request, protect_steps, root, now)
108
+ def forget(conversation, request, protect_steps, root, now, pinned_ids)
100
109
  outputs = outputs(conversation)
101
110
  last = LLMContextStale.step_count(conversation)
102
111
  # The steps before the call's own: protected_ids counts that one too.
@@ -104,7 +113,7 @@ module Samagotchi
104
113
  root: root)
105
114
  refused = {}
106
115
  forgotten = request.ids.select do |id|
107
- why = refusal(conversation, outputs[id], request.keep[id], last, protect_steps, kept_reads)
116
+ why = refusal(conversation, outputs[id], request.keep[id], last, protect_steps, kept_reads, pinned_ids)
108
117
  refused[id] = why if why
109
118
  why.nil?
110
119
  end
@@ -121,9 +130,13 @@ module Samagotchi
121
130
  # Why +output+ can't go (a String for the result), nil when it can.
122
131
  # +last+: the call's own step; the protect_steps steps before it are
123
132
  # the model's latest outputs.
124
- def refusal(conversation, output, keep, last, protect_steps, kept_reads)
133
+ def refusal(conversation, output, keep, last, protect_steps, kept_reads, pinned_ids)
125
134
  return "no output with that id in context (ids look like t42)" unless output
126
- return "already forgotten or stubbed" if LLMContextEdit.on(conversation[output.index]).key?(output.ref.id)
135
+
136
+ held = LLMContextEdit.on(conversation[output.index])[output.ref.id]
137
+ return WorkingSetSteer::Refused.text(:dropped) if held&.kind == LLMContextEdit::DROP
138
+ return WorkingSetSteer::Refused.text(:pinned_by_user) if pinned_ids.include?(output.ref.id)
139
+ return "already forgotten or stubbed" if held
127
140
  if protect_steps.positive? && output.step >= last - protect_steps
128
141
  return "from your last #{protect_steps} step#{"s" unless protect_steps == 1}; you may still need it, forget it later"
129
142
  end
@@ -172,7 +185,10 @@ module Samagotchi
172
185
  ids.each do |id|
173
186
  output = outputs[id]
174
187
  edit = output && LLMContextEdit.on(conversation[output.index])[id]
175
- if edit.nil? || edit.kind != :forget
188
+ if edit&.kind == LLMContextEdit::DROP
189
+ # The user's: only the user takes it back (/working-set undrop).
190
+ refused[id] = WorkingSetSteer::Refused.text(:dropped)
191
+ elsif edit.nil? || edit.kind != :forget
176
192
  refused[id] = "not forgotten"
177
193
  elsif output.read? && edit.applied?
178
194
  refused[id] = "a read: read the file again instead (it may have changed)"
@@ -5,6 +5,7 @@ require_relative "tool_response"
5
5
  require_relative "tool_ids"
6
6
  require_relative "llm_context_edit"
7
7
  require_relative "llm_context_stale"
8
+ require_relative "llm_context_strategy"
8
9
 
9
10
  module Samagotchi
10
11
  # What the model is sent of the stored conversation: the one step
@@ -15,11 +16,14 @@ module Samagotchi
15
16
  #
16
17
  # Under +none+ (the default) nothing changes: #messages returns the
17
18
  # conversation it was given, the same Array of the same entries, so the
18
- # prompt is byte for byte what it was before the view existed.
19
+ # prompt is byte for byte what it was before the view existed. The one
20
+ # exception is the user's layer: an applied :drop (WorkingSetSteer, the
21
+ # working set's drop) is sent as its stub under every strategy, none
22
+ # too; a conversation without one is still returned as it is.
19
23
  #
20
- # Under a strategy of layers (:stale, :forget), each applied edit saved
21
- # on a tool_response entry (LLMContextEdit) whose kind is one of the
22
- # layers sends that run's output as its stub. The entry stays where it
24
+ # Under a strategy of layers (:stale, :forget), each edit saved on a
25
+ # tool_response entry (LLMContextEdit) that #sends? (applied, and its
26
+ # kind one of the layers) sends that run's output as its stub. The entry stays where it
23
27
  # is, with its role and tool_call_id, so a call keeps its response and
24
28
  # the image plan (which indexes by entry) lines up. A stub keeps the
25
29
  # output's "[name]" lead (Gemma's prompt writes response:NAME from it),
@@ -44,7 +48,10 @@ module Samagotchi
44
48
  # (re-reading the file is a read's restore).
45
49
  class LLMContextView
46
50
  NONE = :none
47
- LAYERS = LLMContextEdit::KINDS
51
+ # The strategy's layers (LLMContextStrategy::LAYERS), not the edit
52
+ # kinds (LLMContextEdit::KINDS): a kind no strategy names never
53
+ # becomes a layer, whatever a caller passes as the strategy.
54
+ LAYERS = LLMContextStrategy::LAYERS
48
55
 
49
56
  # @return [Array<Symbol>] the layers; empty under none
50
57
  attr_reader :layers
@@ -78,7 +85,7 @@ module Samagotchi
78
85
  # @param conversation [Array<Hash>] the stored entries, in order
79
86
  # @return [Array<Hash>] the entries to format
80
87
  def messages(conversation)
81
- return conversation if none?
88
+ return conversation if none? && conversation.none? { |entry| LLMContextEdit.drop_on?(entry) }
82
89
 
83
90
  group = Group.new
84
91
  group.paired = forget? ? LLMContextStale.paired_runs(conversation).to_set { |run| run.ref.id } : Set.new
@@ -87,6 +94,11 @@ module Samagotchi
87
94
 
88
95
  def forget? = layers.include?(:forget)
89
96
 
97
+ # Whether +edit+ (an LLMContextEdit) is sent: the one rule for which
98
+ # edits the view applies, applied and of one of the layers, or the
99
+ # user's drop (always on: no strategy names it).
100
+ def sends?(edit) = edit.applied? && (edit.kind == LLMContextEdit::DROP || layers.include?(edit.kind))
101
+
90
102
  # The forget whose stub carries the note the next forgotten output of
91
103
  # the same call points to: its first id and its note, author and stamp.
92
104
  # +paired+: the ids of the runs that pair with their calls.
@@ -100,7 +112,7 @@ module Samagotchi
100
112
  def edited(conversation, entry, index, group)
101
113
  return entry unless entry[:role].to_s == "tool_response"
102
114
 
103
- edits = LLMContextEdit.on(entry).select { |_id, edit| edit.applied? && layers.include?(edit.kind) }
115
+ edits = LLMContextEdit.on(entry).select { |_id, edit| sends?(edit) }
104
116
  return entry if edits.empty? && !forget?
105
117
 
106
118
  refs = ToolIds.refs_at(conversation, index)
@@ -259,6 +259,8 @@ module Samagotchi
259
259
  @source_owned = prepared.owned
260
260
  @source_commit = prepared.commit
261
261
  @manifest = Manifest.read(dir: @bundle_dir)
262
+ clash = @manifest.session_settings_clash
263
+ raise InstallError, clash if clash
262
264
  rescue SourceNormalizer::UnknownSourceError => e
263
265
  raise InstallError, "Source normalization failed: #{e.message}"
264
266
  rescue Manifest::ValidationError => e
@@ -93,6 +93,17 @@ module Samagotchi
93
93
  @session_setting_types = self.class.parse_session_setting_types(raw["session_settings"])
94
94
  end
95
95
 
96
+ # Why the install refuses this bundle's session_settings: a key whose
97
+ # NAME.KEY is chi's own session setting (SessionConfig.clash, the
98
+ # words chi bundle check reports too), nil when none clashes.
99
+ # @return [String, nil]
100
+ def session_settings_clash
101
+ return nil if @session_settings.empty?
102
+
103
+ require_relative "../session_config"
104
+ SessionConfig.clash(@name, @session_settings)
105
+ end
106
+
96
107
  # A profile: it installs the bundles in includes instead of files.
97
108
  def meta?
98
109
  !@includes.empty?
@@ -171,11 +182,11 @@ module Samagotchi
171
182
  File.write(File.join(dir, "manifest.yml"), YAML.dump(manifest))
172
183
  end
173
184
 
174
- # A setting key a session may set (BundleSessionSettings::KEY).
185
+ # A setting key a session may set (SessionConfig::KEY).
175
186
  SESSION_SETTING = /\A[A-Za-z0-9_][A-Za-z0-9_-]*\z/
176
187
 
177
188
  # The types a session setting may be given (session_settings: {key:
178
- # type}), checked by BundleSessionSettings.check!: number (or off),
189
+ # type}), checked by SessionConfig.check!: number (or off),
179
190
  # boolean, string; any (or a list's key) takes every scalar.
180
191
  SESSION_SETTING_TYPES = %w[number boolean string any].freeze
181
192