samagotchi 0.101.0 → 0.102.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 +28 -1
- data/bin/chi +13 -10
- data/docs/architecture.md +8 -6
- data/docs/cli.md +47 -7
- data/docs/configuration.md +25 -4
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +1 -1
- data/docs/internals/llm-context-forget.md +14 -0
- data/docs/internals/prompt-caching.md +8 -1
- data/docs/internals/working-set.md +86 -11
- data/docs/plugins.md +6 -3
- data/docs/sessions.md +28 -11
- data/docs/testing.md +7 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +16 -0
- data/lib/samagotchi/bridge.rb +9 -3
- 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/system/manifest.yml +1 -1
- data/lib/samagotchi/commands/registry.rb +12 -1
- data/lib/samagotchi/config.rb +89 -53
- data/lib/samagotchi/engine.rb +131 -12
- data/lib/samagotchi/idle_scheduler.rb +16 -1
- data/lib/samagotchi/llm_context_edit.rb +32 -10
- data/lib/samagotchi/llm_context_forget.rb +12 -3
- 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/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/send_command.rb +14 -11
- data/lib/samagotchi/session.rb +19 -12
- data/lib/samagotchi/session_commands.rb +44 -2
- data/lib/samagotchi/session_config.rb +411 -0
- data/lib/samagotchi/session_setup.rb +9 -9
- data/lib/samagotchi/sessions_command.rb +6 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +10 -3
- data/lib/samagotchi/terminal_ui.rb +8 -6
- data/lib/samagotchi/testing/bundle.rb +3 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +30 -13
- 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 +3 -2
- data/lib/samagotchi/web/public/index.html +20 -2
- data/lib/samagotchi/web/public/stream_handlers.js +4 -0
- data/lib/samagotchi/web/public/turn_html.js +5 -0
- data/lib/samagotchi/web/public/working_set.js +113 -21
- data/lib/samagotchi/web/public/working_set_view.js +136 -21
- data/lib/samagotchi/worker.rb +9 -6
- data/lib/samagotchi/working_set.rb +49 -11
- data/lib/samagotchi/working_set_command.rb +119 -0
- data/lib/samagotchi/working_set_steer.rb +250 -0
- metadata +5 -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.
|
|
@@ -786,7 +788,7 @@ module Samagotchi
|
|
|
786
788
|
# UIs need in the event log, emitted outside any turn's stream.
|
|
787
789
|
ANNOUNCEABLE_EVENTS = %i[turn_enqueued turn_dequeued input_merged prompt_restored continue_offered continue_resolved
|
|
788
790
|
command_queued command_ran context_added card hook_notice guardrail_warning
|
|
789
|
-
plugin_init_started plugin_init_finished].freeze
|
|
791
|
+
plugin_init_started plugin_init_finished working_set_updated].freeze
|
|
790
792
|
|
|
791
793
|
# Put a transport-level event into the ordered event log. Unlike turn
|
|
792
794
|
# events it reaches only persistent observers (no turn sink, no memory
|
|
@@ -807,6 +809,34 @@ module Samagotchi
|
|
|
807
809
|
@session_observer.synchronize(&)
|
|
808
810
|
end
|
|
809
811
|
|
|
812
|
+
# The working set's :working_set_updated, announced between turns
|
|
813
|
+
# (after a command changed what the next request sends), when it
|
|
814
|
+
# differs from the last one emitted (#emit_working_set). Under the
|
|
815
|
+
# event lock, so it is ordered with the change's own events.
|
|
816
|
+
def announce_working_set(session = @session)
|
|
817
|
+
return unless session
|
|
818
|
+
|
|
819
|
+
synchronize_events { emit_working_set(nil, session, session.messages) }
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
# The strategy the working set is built under now, as LLMContextView
|
|
823
|
+
# takes it: the running turn's, else the next turn's (#emit_working_set).
|
|
824
|
+
# /working-set's ops act under it.
|
|
825
|
+
# @return [Symbol, Array<Symbol>]
|
|
826
|
+
def working_set_strategy
|
|
827
|
+
working_set_llm_context&.strategy || LLMContextView::NONE
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
# Replace the current session's conversation, between turns (a
|
|
831
|
+
# /working-set op's copy: #clone_messages, then its edits).
|
|
832
|
+
# @param messages [Array<Hash>]
|
|
833
|
+
# @return [Array<Hash>] the session's messages
|
|
834
|
+
def replace_messages(messages)
|
|
835
|
+
raise ArgumentError, "no current session" unless @session
|
|
836
|
+
|
|
837
|
+
replace_session_messages(@session, messages)
|
|
838
|
+
end
|
|
839
|
+
|
|
810
840
|
# Add a context note to the session's conversation, between turns: a
|
|
811
841
|
# tail system message the next turn sees, announced as :context_added.
|
|
812
842
|
# The array is replaced, not mutated, and the append and the event
|
|
@@ -1131,6 +1161,8 @@ module Samagotchi
|
|
|
1131
1161
|
# price_host: the effective model's host, whose hosts.<name>.models
|
|
1132
1162
|
# prices the cost estimate names. kit_prompt: the next turn's kit
|
|
1133
1163
|
# prompt text ({kit:, tokens:}), only when its toolkit has one.
|
|
1164
|
+
# settings: the session's own settings ({"recap.enabled" => false}),
|
|
1165
|
+
# only when it has some.
|
|
1134
1166
|
# @return [Hash] @metrics.snapshot, filled in
|
|
1135
1167
|
def stats_snapshot
|
|
1136
1168
|
snapshot = @metrics.snapshot
|
|
@@ -1145,6 +1177,9 @@ module Samagotchi
|
|
|
1145
1177
|
price_host: model.host_name)
|
|
1146
1178
|
kit_prompt = kit_prompt_stats
|
|
1147
1179
|
snapshot = snapshot.merge(kit_prompt: kit_prompt) if kit_prompt
|
|
1180
|
+
# The session's own settings (chi --set), as --set names them.
|
|
1181
|
+
settings = @session&.settings&.flat || {}
|
|
1182
|
+
snapshot = snapshot.merge(settings: settings) unless settings.empty?
|
|
1148
1183
|
unless snapshot.dig(:context, :window_tokens)
|
|
1149
1184
|
window = current_context_window(model)
|
|
1150
1185
|
if window
|
|
@@ -1722,6 +1757,8 @@ module Samagotchi
|
|
|
1722
1757
|
@session = session
|
|
1723
1758
|
# One session per REPL/worker process: its records carry this sid.
|
|
1724
1759
|
Log.session_id = session.id if session&.id
|
|
1760
|
+
# Its own recap values (chi --set recap.enabled=false) rebuild the job.
|
|
1761
|
+
rebuild_recap!
|
|
1725
1762
|
# A woken worker's /stats and status line count the turns before it.
|
|
1726
1763
|
bind_metrics(session)
|
|
1727
1764
|
@used_memories.absorb(session)
|
|
@@ -1752,6 +1789,66 @@ module Samagotchi
|
|
|
1752
1789
|
# @return [IdleRecap, nil] the idle recap detector, or nil when disabled
|
|
1753
1790
|
attr_reader :recap
|
|
1754
1791
|
|
|
1792
|
+
# Whether a continue offer waits (the Worker's or the REPL's TurnFlow),
|
|
1793
|
+
# for the recap job: this one and any rebuilt one (IdleRecap#awaiting_continue=).
|
|
1794
|
+
def recap_awaiting_continue=(callable)
|
|
1795
|
+
@recap_awaiting_continue = callable
|
|
1796
|
+
@recap&.awaiting_continue = callable
|
|
1797
|
+
end
|
|
1798
|
+
|
|
1799
|
+
# The idle recap job (RecapSetup.build) under the current session's own
|
|
1800
|
+
# recap values, which it records.
|
|
1801
|
+
# @return [IdleRecap, nil]
|
|
1802
|
+
def build_recap
|
|
1803
|
+
@recap_built_for = session_recap_values
|
|
1804
|
+
recap = RecapSetup.build(@recap_kwarg, engine: self, host_registry: @host_registry,
|
|
1805
|
+
session_target: -> { session_model_recap_target },
|
|
1806
|
+
session_id: -> { @session&.id }, state_dir: -> { session_state_dir },
|
|
1807
|
+
session_config: -> { @session&.settings&.core })
|
|
1808
|
+
recap.awaiting_continue = @recap_awaiting_continue if recap && @recap_awaiting_continue
|
|
1809
|
+
recap
|
|
1810
|
+
end
|
|
1811
|
+
private :build_recap
|
|
1812
|
+
|
|
1813
|
+
# The bound session's own recap.* values ({} for none).
|
|
1814
|
+
def session_recap_values
|
|
1815
|
+
(@session&.settings&.core || {}).select { |key, _| key.start_with?("recap.") }
|
|
1816
|
+
end
|
|
1817
|
+
private :session_recap_values
|
|
1818
|
+
|
|
1819
|
+
# A new recap job when the bound session's own recap values differ from
|
|
1820
|
+
# the ones the current job was built under (none at start): the old one
|
|
1821
|
+
# is dropped (an attempt in flight goes stale) and the scheduler swaps
|
|
1822
|
+
# it, keeping its one thread. Binding the same session again changes
|
|
1823
|
+
# nothing (the REPL binds it twice).
|
|
1824
|
+
def rebuild_recap!
|
|
1825
|
+
return if session_recap_values == @recap_built_for
|
|
1826
|
+
|
|
1827
|
+
old = @recap
|
|
1828
|
+
old&.invalidate!
|
|
1829
|
+
@recap = build_recap
|
|
1830
|
+
@idle_scheduler&.replace_job(old, @recap)
|
|
1831
|
+
end
|
|
1832
|
+
|
|
1833
|
+
# /recap on|off|default: the bound session's own recap.enabled (true,
|
|
1834
|
+
# false; nil removes it, so config.yml's applies again), and the recap
|
|
1835
|
+
# job rebuilt under it at once. It doesn't save: the command says
|
|
1836
|
+
# changed: [:recap] and its host saves (Worker#run_command).
|
|
1837
|
+
# @param enabled [Boolean, nil]
|
|
1838
|
+
# @raise [ArgumentError] no session is bound
|
|
1839
|
+
def recap_override=(enabled)
|
|
1840
|
+
raise ArgumentError, "no session" unless @session
|
|
1841
|
+
|
|
1842
|
+
core = @session.settings.core.dup
|
|
1843
|
+
enabled.nil? ? core.delete(RECAP_ENABLED_KEY) : core[RECAP_ENABLED_KEY] = enabled
|
|
1844
|
+
@session.settings = @session.settings.with(core: core)
|
|
1845
|
+
rebuild_recap!
|
|
1846
|
+
end
|
|
1847
|
+
|
|
1848
|
+
# The bound session's own recap.enabled (/recap on|off, --set), or nil.
|
|
1849
|
+
# @return [Boolean, nil]
|
|
1850
|
+
def recap_override = @session&.settings&.core&.fetch(RECAP_ENABLED_KEY, nil)
|
|
1851
|
+
|
|
1755
1852
|
# Write the recap now, before the session is left (IdleRecap#write_now).
|
|
1756
1853
|
# @return [String, nil] the recap written, nil when none was (recap off,
|
|
1757
1854
|
# nothing new, an error, the timeout)
|
|
@@ -2850,26 +2947,48 @@ module Samagotchi
|
|
|
2850
2947
|
# stream handler's :generation_started, from the running turn's
|
|
2851
2948
|
# conversation) and at turn end (#end_turn, from the session's kept
|
|
2852
2949
|
# messages). Only when it differs from the last one emitted for this
|
|
2853
|
-
# session, and never while it is empty
|
|
2854
|
-
#
|
|
2950
|
+
# session, and never while it is empty — except one empty event,
|
|
2951
|
+
# remembered, when the last one for this session had items (a
|
|
2952
|
+
# rollback removed the tool calls), so a tool-free turn emits none
|
|
2953
|
+
# and the next empty request sends nothing more. The web overlays it
|
|
2954
|
+
# on the rows' set; other UIs ignore it.
|
|
2955
|
+
#
|
|
2956
|
+
# Between turns (#announce_working_set: no +on_event+) it is
|
|
2957
|
+
# announced, under the next turn's strategy.
|
|
2855
2958
|
def emit_working_set(on_event, session, conversation)
|
|
2856
2959
|
return unless session && conversation
|
|
2857
2960
|
|
|
2858
|
-
|
|
2859
|
-
snapshot = WorkingSet.build(conversation, cwd: session.working_directory,
|
|
2860
|
-
strategy: llm_context&.strategy || LLMContextView::NONE)
|
|
2861
|
-
return if snapshot.empty?
|
|
2961
|
+
snapshot = WorkingSet.build(conversation, cwd: session.working_directory, strategy: working_set_strategy)
|
|
2862
2962
|
|
|
2863
2963
|
items = snapshot.items.map(&:to_h)
|
|
2864
|
-
|
|
2964
|
+
last = @working_set_emitted
|
|
2965
|
+
if items.empty?
|
|
2966
|
+
return unless last && last[0] == session.id && !last[1].empty?
|
|
2967
|
+
elsif last == [session.id, items]
|
|
2968
|
+
return
|
|
2969
|
+
end
|
|
2865
2970
|
|
|
2866
2971
|
@working_set_emitted = [session.id, items]
|
|
2867
|
-
|
|
2972
|
+
event = { type: :working_set_updated, items: items, at: snapshot.at }
|
|
2973
|
+
on_event ? emit_event(on_event, event) : announce_or_hold(event)
|
|
2868
2974
|
rescue StandardError => e
|
|
2869
2975
|
Log.warn(:turn, "working_set_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
2870
2976
|
end
|
|
2871
2977
|
private :emit_working_set
|
|
2872
2978
|
|
|
2979
|
+
# The strategy the set is built under: the running turn's
|
|
2980
|
+
# (TurnSettings#llm_context: what its requests send); between turns
|
|
2981
|
+
# the next turn's (as #warm_up_next_turn: the turn's settings keep the
|
|
2982
|
+
# last turn's, stale after a /llm-context or /model change), the last
|
|
2983
|
+
# turn's when it can't be resolved.
|
|
2984
|
+
def working_set_llm_context
|
|
2985
|
+
turn = @kernel.respond_to?(:turn_settings) ? @kernel.turn_settings&.llm_context : nil
|
|
2986
|
+
return turn if turn_running?
|
|
2987
|
+
|
|
2988
|
+
@model_settings.turn_llm_context || turn
|
|
2989
|
+
end
|
|
2990
|
+
private :working_set_llm_context
|
|
2991
|
+
|
|
2873
2992
|
# A :generation_completed with the generation's decode speed (`speed:`
|
|
2874
2993
|
# {decode_tps:, source:}, nil without one), the session's running
|
|
2875
2994
|
# token totals (`tokens:`, the snapshot's block) and its per-model rows
|
|
@@ -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
|
|
@@ -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,9 @@ 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.
|
|
37
40
|
# A read is never restored: reading the file again is its restore, and
|
|
38
41
|
# the file may have changed.
|
|
39
42
|
module LLMContextForget
|
|
@@ -123,7 +126,10 @@ module Samagotchi
|
|
|
123
126
|
# the model's latest outputs.
|
|
124
127
|
def refusal(conversation, output, keep, last, protect_steps, kept_reads)
|
|
125
128
|
return "no output with that id in context (ids look like t42)" unless output
|
|
126
|
-
|
|
129
|
+
|
|
130
|
+
held = LLMContextEdit.on(conversation[output.index])[output.ref.id]
|
|
131
|
+
return WorkingSetSteer::Refused.text(:dropped) if held&.kind == LLMContextEdit::DROP
|
|
132
|
+
return "already forgotten or stubbed" if held
|
|
127
133
|
if protect_steps.positive? && output.step >= last - protect_steps
|
|
128
134
|
return "from your last #{protect_steps} step#{"s" unless protect_steps == 1}; you may still need it, forget it later"
|
|
129
135
|
end
|
|
@@ -172,7 +178,10 @@ module Samagotchi
|
|
|
172
178
|
ids.each do |id|
|
|
173
179
|
output = outputs[id]
|
|
174
180
|
edit = output && LLMContextEdit.on(conversation[output.index])[id]
|
|
175
|
-
if edit
|
|
181
|
+
if edit&.kind == LLMContextEdit::DROP
|
|
182
|
+
# The user's: only the user takes it back (/working-set undrop).
|
|
183
|
+
refused[id] = WorkingSetSteer::Refused.text(:dropped)
|
|
184
|
+
elsif edit.nil? || edit.kind != :forget
|
|
176
185
|
refused[id] = "not forgotten"
|
|
177
186
|
elsif output.read? && edit.applied?
|
|
178
187
|
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
|
|
|
@@ -187,6 +187,9 @@ module Samagotchi
|
|
|
187
187
|
mark[:staged] = true unless edit.applied?
|
|
188
188
|
if edit.kind == :stale
|
|
189
189
|
mark[:note] = edit.note[/superseded by .*\z/] || edit.note
|
|
190
|
+
elsif edit.kind == LLMContextEdit::DROP
|
|
191
|
+
# The user's: no note, no grouping (a forget's key is its note).
|
|
192
|
+
mark[:by] = edit.by
|
|
190
193
|
else
|
|
191
194
|
key = [edit.note, edit.by, edit.staged_at]
|
|
192
195
|
if first.key?(key)
|
|
@@ -201,12 +204,14 @@ module Samagotchi
|
|
|
201
204
|
mark
|
|
202
205
|
end
|
|
203
206
|
|
|
204
|
-
# +output+'s ✂ mark (+marks+ by +id+); an applied stale stub
|
|
205
|
-
# what it frees: its output, ~chars/4.
|
|
207
|
+
# +output+'s ✂ mark (+marks+ by +id+); an applied stale stub or a
|
|
208
|
+
# user's drop says what it frees: its output, ~chars/4.
|
|
209
|
+
WHOLE_STUBS = %w[stale drop].freeze
|
|
210
|
+
|
|
206
211
|
def mark_of(id, marks, output)
|
|
207
212
|
mark = marks[id]
|
|
208
213
|
return nil unless mark
|
|
209
|
-
return mark unless mark[:kind]
|
|
214
|
+
return mark unless WHOLE_STUBS.include?(mark[:kind]) && !mark[:staged] && output
|
|
210
215
|
|
|
211
216
|
mark.merge(tokens: (output.length / TokenUsage::CHARS_PER_TOKEN).ceil)
|
|
212
217
|
end
|
|
@@ -104,7 +104,7 @@ module Samagotchi
|
|
|
104
104
|
|
|
105
105
|
# config.yml `bundles: <bundle>:` with the session's own values for
|
|
106
106
|
# the bundle laid over it (chi --set BUNDLE.KEY=VALUE, through the
|
|
107
|
-
# Host's setup: SessionSetup#
|
|
107
|
+
# Host's setup: SessionSetup#settings's bundles), string keys, frozen.
|
|
108
108
|
# Plugin init and chi.ctx run before the session is bound: with no
|
|
109
109
|
# session id yet it is the config alone, and it is built again once
|
|
110
110
|
# per session id (the session's values never change after it starts).
|
|
@@ -473,7 +473,7 @@ module Samagotchi
|
|
|
473
473
|
# The session's own values for this bundle ({} without any; nil when
|
|
474
474
|
# the Host couldn't tell them, so #settings can retry).
|
|
475
475
|
def session_values(id)
|
|
476
|
-
values = @host.setup&.call&.
|
|
476
|
+
values = @host.setup&.call&.settings&.bundles
|
|
477
477
|
values.is_a?(Hash) ? values.fetch(@bundle, {}) : {}
|
|
478
478
|
rescue StandardError => e
|
|
479
479
|
unless (@session_settings_unread ||= {}).key?(id)
|
data/lib/samagotchi/prompt.rb
CHANGED
|
@@ -27,7 +27,7 @@ module Samagotchi
|
|
|
27
27
|
# - Generation cue: <|im_start|>assistant\n
|
|
28
28
|
module Prompt
|
|
29
29
|
# Tail system messages whose text isn't the harness's own.
|
|
30
|
-
ESCAPED_KINDS = %w[note turn_note context].freeze
|
|
30
|
+
ESCAPED_KINDS = %w[note turn_note context working_set].freeze
|
|
31
31
|
|
|
32
32
|
# @param messages [Array<Hash>] each element has :role and :content.
|
|
33
33
|
# Recognised roles: "system", "user", "model", "tool_response".
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# /recap on|off|default, the setting form of /recap (the bare /recap,
|
|
7
|
+
# which shows the recap, is the UIs' own): this session's own
|
|
8
|
+
# recap.enabled (Engine#recap_override=), the same value
|
|
9
|
+
# `chi --set recap.enabled=…` saves, applied at once (the idle recap job
|
|
10
|
+
# is rebuilt or dropped). `default` removes it, and config.yml (or env)
|
|
11
|
+
# decides again. The session's value beats config.yml's: `/recap on`
|
|
12
|
+
# turns it on under `recap: false` too. It doesn't save: its host does
|
|
13
|
+
# on the reply's changed: [:recap] (Worker#run_command).
|
|
14
|
+
class RecapCommand
|
|
15
|
+
NAME = "/recap"
|
|
16
|
+
CHOICES = { "on" => true, "off" => false, "default" => nil }.freeze
|
|
17
|
+
USAGE = "usage: #{NAME} [on|off|default]".freeze
|
|
18
|
+
|
|
19
|
+
# @param engine [Engine] #session, #recap_override=, #recap
|
|
20
|
+
def initialize(engine:)
|
|
21
|
+
@engine = engine
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param args [String] what follows /recap
|
|
25
|
+
# @return [Array(String, Boolean, Symbol)] the reply, whether this
|
|
26
|
+
# session's recap setting changed, and :ok or :error
|
|
27
|
+
def run(args)
|
|
28
|
+
word = args.to_s.strip.downcase
|
|
29
|
+
return [USAGE, false, :error] unless CHOICES.key?(word)
|
|
30
|
+
return ["no session yet", false, :error] unless @engine.session
|
|
31
|
+
|
|
32
|
+
value = CHOICES.fetch(word)
|
|
33
|
+
changed = @engine.recap_override != value
|
|
34
|
+
@engine.recap_override = value if changed
|
|
35
|
+
[status_line(value), changed, :ok]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# "recap: off (this session; config.yml: on)", "recap: on (config.yml)".
|
|
39
|
+
# @param own [Boolean, nil] the session's own value, nil for none
|
|
40
|
+
def status_line(own)
|
|
41
|
+
from_config = self.class.config_status
|
|
42
|
+
line = if own.nil?
|
|
43
|
+
"recap: #{word(from_config[:on])} (#{from_config[:source]})"
|
|
44
|
+
else
|
|
45
|
+
"recap: #{word(own)} (this session; #{from_config[:source]}: #{word(from_config[:on])})"
|
|
46
|
+
end
|
|
47
|
+
# On by its own word, yet no job: a pinned recap host or model that
|
|
48
|
+
# doesn't resolve (warned in the log), or an embedder's recap: false.
|
|
49
|
+
on = own.nil? ? from_config[:on] : own
|
|
50
|
+
on && @engine.recap.nil? ? "#{line}; no recap is written: its host or model doesn't resolve (see the log)" : line
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Whether the recap is on without the session's own value: off by a
|
|
54
|
+
# scalar `recap: false`, `recap: {enabled: false}` or
|
|
55
|
+
# SAMAGOTCHI_RECAP_ENABLED=false (ConfigFile.recap_config sees all
|
|
56
|
+
# three), else on (the default).
|
|
57
|
+
# @return [Hash] {on: Boolean, source: "config.yml" or "env"}
|
|
58
|
+
def self.config_status
|
|
59
|
+
on = ConfigFile.recap_config != false
|
|
60
|
+
_value, origin = Config.get_with_origin("recap.enabled")
|
|
61
|
+
{ on: on, source: origin == :env ? "env" : "config.yml" }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def word(enabled) = enabled ? "on" : "off"
|
|
67
|
+
end
|
|
68
|
+
end
|