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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +67 -1
- data/bin/chi +31 -196
- data/docs/architecture.md +62 -6
- data/docs/cli.md +154 -7
- data/docs/configuration.md +32 -5
- data/docs/guardrails.md +2 -1
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +1 -1
- data/docs/internals/llm-context-forget.md +25 -1
- data/docs/internals/prompt-caching.md +8 -1
- data/docs/internals/remote-sessions.md +303 -0
- data/docs/internals/working-set.md +174 -15
- data/docs/plugins.md +32 -5
- data/docs/sessions.md +38 -14
- data/docs/sub-agent.md +8 -0
- data/docs/testing.md +7 -0
- data/lib/samagotchi/answer_command.rb +27 -25
- data/lib/samagotchi/bridge/turn_accumulator.rb +16 -0
- data/lib/samagotchi/bridge.rb +33 -7
- data/lib/samagotchi/bridge_client/event_stream.rb +81 -12
- data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
- data/lib/samagotchi/bridge_client.rb +22 -38
- data/lib/samagotchi/bundle_command/check.rb +5 -1
- data/lib/samagotchi/bundle_command/try/command.rb +6 -6
- data/lib/samagotchi/bundle_command/try/sandbox.rb +3 -3
- data/lib/samagotchi/bundle_command.rb +4 -4
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_invocation.rb +43 -0
- data/lib/samagotchi/cli/command.rb +3 -2
- data/lib/samagotchi/cli/flags.rb +5 -0
- data/lib/samagotchi/cli/parent_wait.rb +22 -13
- data/lib/samagotchi/commands/registry.rb +12 -1
- data/lib/samagotchi/config.rb +89 -53
- data/lib/samagotchi/context_command.rb +80 -73
- data/lib/samagotchi/engine.rb +138 -15
- data/lib/samagotchi/idle_recap.rb +7 -1
- data/lib/samagotchi/idle_scheduler.rb +16 -1
- data/lib/samagotchi/kernel_loop.rb +13 -1
- data/lib/samagotchi/launch_mode.rb +22 -0
- data/lib/samagotchi/llm_context_edit.rb +32 -10
- data/lib/samagotchi/llm_context_forget.rb +24 -8
- data/lib/samagotchi/llm_context_view.rb +19 -7
- data/lib/samagotchi/memory_bundle/installer.rb +2 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +13 -2
- data/lib/samagotchi/message_parts.rb +8 -3
- data/lib/samagotchi/note_command.rb +8 -7
- data/lib/samagotchi/parent_report.rb +55 -43
- data/lib/samagotchi/plugin/context.rb +2 -2
- data/lib/samagotchi/prompt.rb +1 -1
- data/lib/samagotchi/recap_command.rb +68 -0
- data/lib/samagotchi/recap_setup.rb +27 -14
- data/lib/samagotchi/remote_command.rb +170 -0
- data/lib/samagotchi/remotes.rb +130 -0
- data/lib/samagotchi/run_options.rb +233 -0
- data/lib/samagotchi/send_command/busy.rb +58 -0
- data/lib/samagotchi/send_command/parent_gate.rb +23 -10
- data/lib/samagotchi/send_command/wait_target.rb +16 -3
- data/lib/samagotchi/send_command.rb +186 -118
- data/lib/samagotchi/session.rb +48 -16
- data/lib/samagotchi/session_access/contract.rb +129 -0
- data/lib/samagotchi/session_access/dispatch.rb +133 -0
- data/lib/samagotchi/session_access/http_transport.rb +109 -0
- data/lib/samagotchi/session_access/local.rb +349 -0
- data/lib/samagotchi/session_access/remote.rb +638 -0
- data/lib/samagotchi/session_access/web_bridge_client.rb +181 -0
- data/lib/samagotchi/session_access.rb +141 -0
- data/lib/samagotchi/session_archive_command.rb +9 -5
- data/lib/samagotchi/session_commands.rb +53 -6
- data/lib/samagotchi/session_config.rb +411 -0
- data/lib/samagotchi/session_delete_command.rb +8 -4
- data/lib/samagotchi/session_manager.rb +7 -2
- data/lib/samagotchi/session_setup.rb +15 -10
- data/lib/samagotchi/sessions_command.rb +42 -16
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +54 -18
- data/lib/samagotchi/terminal_ui/attach_link.rb +104 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +82 -76
- data/lib/samagotchi/terminal_ui/formatting.rb +10 -3
- data/lib/samagotchi/terminal_ui/local_link.rb +130 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +5 -3
- data/lib/samagotchi/terminal_ui.rb +8 -6
- data/lib/samagotchi/testing/bundle.rb +3 -2
- data/lib/samagotchi/title_command.rb +9 -11
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +387 -79
- data/lib/samagotchi/web/public/app.js +2 -0
- data/lib/samagotchi/web/public/commands.js +8 -2
- data/lib/samagotchi/web/public/data.js +6 -4
- data/lib/samagotchi/web/public/index.html +37 -3
- data/lib/samagotchi/web/public/stage_frame.js +7 -2
- data/lib/samagotchi/web/public/stage_view.js +3 -2
- data/lib/samagotchi/web/public/stream_handlers.js +7 -1
- data/lib/samagotchi/web/public/turn_html.js +5 -0
- data/lib/samagotchi/web/public/working_set.js +247 -24
- data/lib/samagotchi/web/public/working_set_rail.js +39 -12
- data/lib/samagotchi/web/public/working_set_view.js +198 -25
- data/lib/samagotchi/web/session_access_routes.rb +343 -0
- data/lib/samagotchi/worker.rb +9 -6
- data/lib/samagotchi/worker_sidecar.rb +12 -5
- data/lib/samagotchi/working_set.rb +132 -20
- data/lib/samagotchi/working_set_command.rb +222 -0
- data/lib/samagotchi/working_set_pin.rb +34 -0
- data/lib/samagotchi/working_set_steer.rb +414 -0
- metadata +23 -2
- data/lib/samagotchi/bundle_session_settings.rb +0 -251
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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:}
|
|
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
|
|
2854
|
-
#
|
|
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
|
-
|
|
2859
|
-
|
|
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
|
-
|
|
2864
|
-
|
|
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,
|
|
2867
|
-
|
|
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
|
|
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)
|
|
18
|
-
# (the model forgot it, its note keeping the finding)
|
|
19
|
-
#
|
|
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
|
-
|
|
27
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
21
|
-
#
|
|
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
|
|
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|
|
|
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 (
|
|
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
|
|
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
|
|