samagotchi 0.150.0 → 0.152.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 +50 -1
- data/docs/architecture.md +2 -2
- data/docs/bundle-authoring.md +5 -5
- data/docs/cli.md +32 -7
- data/docs/configuration.md +1 -1
- data/docs/guardrails.md +4 -0
- data/docs/internals/block-renderers.md +98 -0
- data/docs/internals/bridge.md +53 -17
- data/docs/internals/bundles.md +49 -14
- data/docs/internals/objectives.md +6 -4
- data/docs/internals/web-frontend.md +2 -2
- data/docs/memory.md +7 -2
- data/docs/plugins.md +91 -14
- data/docs/releasing.md +7 -2
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/block_renderers.rb +116 -0
- data/lib/samagotchi/bridge/card_places.rb +93 -0
- data/lib/samagotchi/bridge/card_store.rb +219 -122
- data/lib/samagotchi/bridge.rb +12 -4
- data/lib/samagotchi/bundle_command/check.rb +43 -3
- data/lib/samagotchi/bundle_command/installed_check.rb +6 -2
- data/lib/samagotchi/bundle_command/try/sandbox.rb +11 -3
- data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
- data/lib/samagotchi/bundle_command.rb +88 -10
- data/lib/samagotchi/bundle_needs.rb +4 -6
- data/lib/samagotchi/bundle_settings_check.rb +14 -13
- data/lib/samagotchi/bundles/agents-md/manifest.yml +2 -2
- data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +3 -3
- data/lib/samagotchi/child_env.rb +15 -0
- data/lib/samagotchi/context_providers.rb +42 -10
- data/lib/samagotchi/engine.rb +47 -6
- data/lib/samagotchi/extension_load.rb +4 -3
- data/lib/samagotchi/guardrail_wiring.rb +28 -19
- data/lib/samagotchi/guardrails/approval.rb +2 -2
- data/lib/samagotchi/guardrails/rules.rb +1 -0
- data/lib/samagotchi/guardrails/verdict.rb +20 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +5 -7
- data/lib/samagotchi/memory_bundle/asset_installer.rb +18 -9
- data/lib/samagotchi/memory_bundle/builder.rb +39 -31
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +3 -2
- data/lib/samagotchi/memory_bundle/file_digest.rb +31 -0
- data/lib/samagotchi/memory_bundle/file_standing.rb +2 -2
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +55 -43
- data/lib/samagotchi/memory_bundle/installed_copy.rb +38 -14
- data/lib/samagotchi/memory_bundle/installed_manifest.rb +224 -0
- data/lib/samagotchi/memory_bundle/installer.rb +91 -16
- data/lib/samagotchi/memory_bundle/manifest.rb +166 -5
- data/lib/samagotchi/memory_bundle/manifest_standing.rb +171 -0
- data/lib/samagotchi/memory_bundle/merger.rb +7 -8
- data/lib/samagotchi/memory_bundle/profile.rb +3 -2
- data/lib/samagotchi/memory_bundle/provenance.rb +220 -19
- data/lib/samagotchi/memory_bundle/shas.rb +4 -3
- data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -24
- data/lib/samagotchi/memory_bundle/status.rb +11 -10
- data/lib/samagotchi/memory_bundle/system_bundle.rb +13 -2
- data/lib/samagotchi/plugin/loader.rb +1 -1
- data/lib/samagotchi/session_chain.rb +2 -7
- data/lib/samagotchi/session_commands.rb +15 -9
- data/lib/samagotchi/session_config.rb +5 -4
- data/lib/samagotchi/session_manager.rb +10 -7
- data/lib/samagotchi/steer.rb +17 -0
- data/lib/samagotchi/system_one/record.rb +10 -2
- data/lib/samagotchi/terminal_ui.rb +4 -4
- data/lib/samagotchi/tools/delegate.rb +20 -2
- data/lib/samagotchi/turn_checkpoint.rb +11 -5
- data/lib/samagotchi/turn_flow.rb +2 -3
- data/lib/samagotchi/update_command.rb +13 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +24 -13
- data/lib/samagotchi/web/block_renderer_routes.rb +32 -0
- data/lib/samagotchi/web/markdown_renderer.rb +39 -6
- data/lib/samagotchi/web/message_display.rb +3 -3
- data/lib/samagotchi/web/participant_view.rb +1 -1
- data/lib/samagotchi/web/public/app.js +2 -0
- data/lib/samagotchi/web/public/block_frame.js +202 -0
- data/lib/samagotchi/web/public/block_host.js +502 -0
- data/lib/samagotchi/web/public/block_renderers.css +20 -0
- data/lib/samagotchi/web/public/block_source.js +37 -0
- data/lib/samagotchi/web/public/bubbles.js +9 -5
- data/lib/samagotchi/web/public/card.js +17 -10
- data/lib/samagotchi/web/public/cards_place.js +2 -2
- data/lib/samagotchi/web/public/claim_link.js +4 -2
- data/lib/samagotchi/web/public/history_reveal.js +4 -1
- data/lib/samagotchi/web/public/index.html +2 -0
- data/lib/samagotchi/web/public/markdown_place.js +2 -3
- data/lib/samagotchi/web/public/text_roots.js +6 -0
- data/lib/samagotchi/web/public/turn_html.js +4 -1
- data/lib/samagotchi/web/session_access_routes.rb +2 -1
- data/lib/samagotchi/web/session_hub.rb +6 -0
- data/lib/samagotchi/web/session_show.rb +1 -1
- data/lib/samagotchi/worker.rb +4 -2
- metadata +13 -1
|
@@ -4,6 +4,7 @@ require "json"
|
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require_relative "../atomic_file"
|
|
6
6
|
require_relative "../events"
|
|
7
|
+
require_relative "card_places"
|
|
7
8
|
|
|
8
9
|
module Samagotchi
|
|
9
10
|
class Bridge
|
|
@@ -22,16 +23,18 @@ module Samagotchi
|
|
|
22
23
|
# reload draws the resolved card where it was.
|
|
23
24
|
#
|
|
24
25
|
# A card with an earlier card's id replaces it where it was. Each entry
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
# actions; its other rows (no step to sit in)
|
|
26
|
+
# holds the id of the turn it belongs to or follows (+turn_id+: the running
|
|
27
|
+
# turn's, else the last one's, nil before any) and lists with +turns_since+,
|
|
28
|
+
# the turns drawn after it, counted from the messages the page draws
|
|
29
|
+
# (CardPlaces, with the turn log the store keeps), and +current+ for a card
|
|
30
|
+
# of the turn running now. A card that isn't the turn's but comes while one
|
|
31
|
+
# runs (an anytime command's, such as /btw) goes after that turn: its prompt
|
|
32
|
+
# is already in the history. Listed against messages, an in-turn entry of a
|
|
33
|
+
# turn that left no opening there (failed, rolled back, stopped before the
|
|
34
|
+
# model) stays where it was as a between-turns one: a card or hook notice
|
|
35
|
+
# without its actions; its other rows (no step to sit in) aren't listed. A
|
|
36
|
+
# kept Continue or reminder turn never has an opening: its entries are
|
|
37
|
+
# +continued+ and stay rows.
|
|
35
38
|
#
|
|
36
39
|
# Notices (hook notices, load warnings, "asking again" rows), ✂ rows
|
|
37
40
|
# and cards (with questions) are capped apart, +capacity+ each, so a
|
|
@@ -40,29 +43,21 @@ module Samagotchi
|
|
|
40
43
|
# never pushed out: the oldest other card or answered question goes.
|
|
41
44
|
#
|
|
42
45
|
# With a +path+ (the Bridge's: FILE in the session's folder) the store
|
|
43
|
-
# saves its entries and turn
|
|
46
|
+
# saves its entries and turn log there on each change, and a later
|
|
44
47
|
# worker's store starts from them, so a turn's cards and notices outlive
|
|
45
48
|
# the worker that showed them; the web reads the file (.saved) for a
|
|
46
|
-
# session no worker runs.
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
# an earlier worker lists with +earlier+: true (an attached TUI's resync
|
|
52
|
-
# onto a new worker doesn't print those again). An earlier card shown
|
|
49
|
+
# session no worker runs. The load warnings stay the worker's own (each
|
|
50
|
+
# start announces its own), and a question still open when the worker went
|
|
51
|
+
# waits for no one: neither is saved or seeded. Same caps as in memory. An
|
|
52
|
+
# entry from an earlier worker lists with +earlier+: true (an attached TUI's
|
|
53
|
+
# resync onto a new worker doesn't print those again). An earlier card shown
|
|
53
54
|
# during a turn (+in_turn+) comes back without its actions: they belong
|
|
54
55
|
# to a turn no worker runs any more (check-in's Nudge would steer the
|
|
55
56
|
# new worker's turn). A card shown between turns (github-pr's offer,
|
|
56
57
|
# btw, coordinator) keeps its buttons.
|
|
57
58
|
#
|
|
58
|
-
# A
|
|
59
|
-
#
|
|
60
|
-
# canceled. So +killed_turn_stays+ (a worker's) saves that a turn runs, and
|
|
61
|
-
# a store started from such a file counts that turn. A turn that ended
|
|
62
|
-
# (a graceful stop's cancel too) saved that it no longer runs. Started
|
|
63
|
-
# from the same file again, the count is the same: nothing is written
|
|
64
|
-
# until the next event. A REPL keeps no running turn in the session: a kill
|
|
65
|
-
# takes the prompt along, so its store saves no such thing.
|
|
59
|
+
# A file from before the log (version 1) keeps its places: each entry's
|
|
60
|
+
# count becomes +base_since+, and new openings add to it (#seed_v1).
|
|
66
61
|
class CardStore
|
|
67
62
|
CAPACITY = 20
|
|
68
63
|
NOTICE_TYPES = %i[hook_notice guardrail_warning empty_answer_retry steer_cut].freeze
|
|
@@ -71,30 +66,40 @@ module Samagotchi
|
|
|
71
66
|
LLM_CONTEXT_FIELDS = %i[type moment why freed_tokens tail_tokens staged text groups].freeze
|
|
72
67
|
FILE = "cards.json"
|
|
73
68
|
# The events after which the file is written again: the ones that
|
|
74
|
-
# change an entry or the turn
|
|
69
|
+
# change an entry or the turn log.
|
|
75
70
|
SAVED_ON = (%i[turn_started card hook_notice empty_answer_retry steer_cut llm_context_edited question_requested question_answered
|
|
76
|
-
question_cancelled question_relay
|
|
71
|
+
question_cancelled question_relay] + Events::TURN_END).freeze
|
|
72
|
+
# What only the store reads: never listed.
|
|
73
|
+
INTERNAL_FIELDS = %i[turn_id continued base_since past_idless during after_failed command].freeze
|
|
77
74
|
|
|
78
75
|
# What a session's saved file lists, as a store started from it lists
|
|
79
76
|
# (no turn running): the web's cards for a session no worker runs.
|
|
80
77
|
# @param session_dir [String]
|
|
78
|
+
# @param messages [Array<Hash>, nil] the session's messages, for the
|
|
79
|
+
# places (#list)
|
|
81
80
|
# @return [Array<Hash>] [] without a readable file
|
|
82
|
-
def self.saved(session_dir)
|
|
83
|
-
new(path: File.join(session_dir, FILE)).list
|
|
81
|
+
def self.saved(session_dir, messages: nil)
|
|
82
|
+
new(path: File.join(session_dir, FILE)).list(messages: messages)
|
|
84
83
|
end
|
|
85
84
|
|
|
86
85
|
# @param path [String, nil] the file the entries are saved in and
|
|
87
86
|
# started from; nil keeps them in memory only
|
|
88
|
-
# @param
|
|
89
|
-
#
|
|
90
|
-
|
|
87
|
+
# @param messages [#call, nil] the session's messages now (Session#messages:
|
|
88
|
+
# a running turn leaves them alone, a change replaces the array, so any
|
|
89
|
+
# thread may read them): where an opening with no id sits against an
|
|
90
|
+
# entry, and how far the saved log must reach. Read at an idle show and a
|
|
91
|
+
# turn's start and end, never for a running turn's row.
|
|
92
|
+
def initialize(capacity: CAPACITY, path: nil, messages: nil)
|
|
91
93
|
@capacity = capacity
|
|
92
94
|
@path = path
|
|
93
|
-
@
|
|
95
|
+
@messages = messages
|
|
94
96
|
@mutex = Mutex.new
|
|
95
97
|
@entries = []
|
|
96
|
-
@
|
|
97
|
-
@
|
|
98
|
+
@turn_log = []
|
|
99
|
+
@running_turn = nil
|
|
100
|
+
@continuing = false
|
|
101
|
+
@failed_turn = nil
|
|
102
|
+
@log_start = nil
|
|
98
103
|
@iteration = nil
|
|
99
104
|
@calls = 0
|
|
100
105
|
seed
|
|
@@ -109,72 +114,160 @@ module Samagotchi
|
|
|
109
114
|
nil # never break the running turn
|
|
110
115
|
end
|
|
111
116
|
|
|
117
|
+
# @param messages [Array<Hash>, nil] the messages the page draws. Without
|
|
118
|
+
# them (a body re-read, a trace): no turns_since:, nothing to tell a turn
|
|
119
|
+
# that left nothing by.
|
|
112
120
|
# @return [Array<Hash>] oldest first: the card's (or notice's) fields
|
|
113
|
-
# plus turns_since:
|
|
114
|
-
# worker saved,
|
|
115
|
-
#
|
|
116
|
-
#
|
|
117
|
-
|
|
121
|
+
# plus current:, turns_since: with +messages+, earlier: true for one an
|
|
122
|
+
# earlier worker saved, during: true for a card that came while the
|
|
123
|
+
# running turn runs but isn't that turn's (it goes after the running
|
|
124
|
+
# turn's prompt), and after_failed: true for one shown after the last
|
|
125
|
+
# turn's prompt went back (the page draws that prompt at the end: it goes
|
|
126
|
+
# after it, as it showed live); label: for a card a command showed ("from
|
|
127
|
+
# /short"), for a page whose reload has lost the command's line
|
|
128
|
+
def list(messages: nil)
|
|
118
129
|
@mutex.synchronize do
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
current = entry[:in_turn] && @running && turns == @turns_done
|
|
122
|
-
since = @turns_done - turns - (entry[:in_turn] && !current ? 1 : 0)
|
|
123
|
-
listed = entry.except(:turns, :during).merge(turns_since: [since, 0].max, current: current ? true : false)
|
|
124
|
-
listed[:during] = true if entry[:during]
|
|
125
|
-
listed
|
|
126
|
-
end
|
|
130
|
+
places = messages && !@entries.empty? ? CardPlaces.new(@turn_log, messages) : nil
|
|
131
|
+
@entries.filter_map { |entry| listed(entry, places) }
|
|
127
132
|
end
|
|
128
133
|
end
|
|
129
134
|
|
|
130
135
|
private
|
|
131
136
|
|
|
137
|
+
def listed(entry, places)
|
|
138
|
+
current = current?(entry)
|
|
139
|
+
shown = places && left_nothing?(entry, current, places) ? between_turns(entry) : entry
|
|
140
|
+
return unless shown
|
|
141
|
+
|
|
142
|
+
out = shown.except(*INTERNAL_FIELDS)
|
|
143
|
+
out[:turns_since] = places.turns_since(entry) if places
|
|
144
|
+
out[:current] = current
|
|
145
|
+
out[:during] = true if entry[:during]
|
|
146
|
+
out[:after_failed] = true if entry[:after_failed] && entry[:turn_id] == @failed_turn
|
|
147
|
+
out[:label] = "from #{entry[:command]}" if entry[:command]
|
|
148
|
+
out
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The turn running now's own. An entry with no anchor is none's: nil ==
|
|
152
|
+
# nil would make every one the running turn's while nothing runs.
|
|
153
|
+
def current?(entry)
|
|
154
|
+
entry[:in_turn] && @running_turn && entry[:turn_id] == @running_turn ? true : false
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# An in-turn entry whose turn has no opening in the messages, and was no
|
|
158
|
+
# kept Continue or reminder (those never have one).
|
|
159
|
+
def left_nothing?(entry, current, places)
|
|
160
|
+
entry[:in_turn] && !current && !entry[:continued] && entry[:turn_id] && !places.opened?(entry[:turn_id])
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Of a turn that left nothing, what stays where it was: a card without
|
|
164
|
+
# its actions (their turn is gone), a hook's notice; its other rows have
|
|
165
|
+
# no step to sit in.
|
|
166
|
+
def between_turns(entry)
|
|
167
|
+
case entry[:type]
|
|
168
|
+
when :card then entry.merge(in_turn: false, **(entry.key?(:actions) ? { actions: [] } : {}))
|
|
169
|
+
when :hook_notice then entry.except(:iteration, :calls).merge(in_turn: false)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
132
173
|
def save
|
|
133
174
|
return unless @path
|
|
134
175
|
|
|
135
176
|
entries = @entries.reject { |entry| entry[:type] == :guardrail_warning }
|
|
136
|
-
# A session that never showed one gets no file
|
|
137
|
-
# only places entries relative to each other).
|
|
177
|
+
# A session that never showed one gets no file.
|
|
138
178
|
return if entries.empty? && !File.exist?(@path)
|
|
139
179
|
|
|
140
180
|
FileUtils.mkdir_p(File.dirname(@path))
|
|
141
|
-
saved = { "version" =>
|
|
142
|
-
saved["
|
|
181
|
+
saved = { "version" => 2, "turn_log" => pruned_log(entries), "entries" => entries }
|
|
182
|
+
saved["failed_turn"] = @failed_turn if @failed_turn
|
|
143
183
|
AtomicFile.write(@path, JSON.generate(saved))
|
|
144
184
|
rescue StandardError
|
|
145
185
|
nil # a card that can't be saved still shows live
|
|
146
186
|
end
|
|
147
187
|
|
|
148
|
-
# The
|
|
149
|
-
#
|
|
150
|
-
#
|
|
151
|
-
#
|
|
152
|
-
#
|
|
188
|
+
# The log from the opening the earliest anchor counts from. Found by
|
|
189
|
+
# walking the messages, so a running turn's saves reuse what its start
|
|
190
|
+
# found (#fold forgets it there): its rows would walk them each, on the
|
|
191
|
+
# turn's thread. Nothing in a running turn needs an earlier start: a new
|
|
192
|
+
# entry anchors to it, and an evicted one only leaves the kept part longer.
|
|
193
|
+
def pruned_log(entries)
|
|
194
|
+
@log_start = log_start(entries) unless @running_turn && @log_start
|
|
195
|
+
@turn_log.drop(@log_start)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# A turn before that opening orders nothing. 0 when an entry has no anchor,
|
|
199
|
+
# or none does yet (such an entry counts from the log's first turn), or with
|
|
200
|
+
# no messages to find that opening in.
|
|
201
|
+
def log_start(entries)
|
|
202
|
+
positions = @turn_log.each_with_index.to_h
|
|
203
|
+
anchors = entries.map { |entry| positions[entry[:turn_id]] }
|
|
204
|
+
return 0 if anchors.empty? || anchors.include?(nil) || !@messages
|
|
205
|
+
|
|
206
|
+
CardPlaces.new(@turn_log, @messages.call).log_start_for(anchors.min)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# The entries an earlier worker saved (a missing or broken file: none),
|
|
210
|
+
# their types as symbols again. A card that came during that worker's
|
|
211
|
+
# last turn stays where it was, as after a failed turn, or after that
|
|
212
|
+
# turn when the worker was killed in it: the messages say which.
|
|
153
213
|
def seed
|
|
154
214
|
return unless @path && File.file?(@path)
|
|
155
215
|
|
|
156
216
|
data = JSON.parse(File.read(@path), symbolize_names: true)
|
|
157
|
-
return unless data.is_a?(Hash) && data[:
|
|
217
|
+
return unless data.is_a?(Hash) && data[:entries].is_a?(Array)
|
|
158
218
|
|
|
219
|
+
if data[:turn_log].is_a?(Array)
|
|
220
|
+
@turn_log = data[:turn_log].grep(String)
|
|
221
|
+
@failed_turn = data[:failed_turn] if data[:failed_turn].is_a?(String)
|
|
222
|
+
@entries = seeded(data[:entries])
|
|
223
|
+
elsif data[:turns].is_a?(Integer)
|
|
224
|
+
seed_v1(data)
|
|
225
|
+
end
|
|
226
|
+
rescue StandardError
|
|
227
|
+
@turn_log = []
|
|
228
|
+
@entries = []
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# A file from before the turn log: each entry's place by the count it was
|
|
232
|
+
# saved with, killed turn included (nothing in it can say where a drifted
|
|
233
|
+
# card was). New openings add to it. The next save keeps +base_since+.
|
|
234
|
+
# Can go a retention period after the release that wrote version 2.
|
|
235
|
+
def seed_v1(data)
|
|
159
236
|
killed_in_turn = data[:running] == true
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
237
|
+
turns_done = data[:turns] + (killed_in_turn ? 1 : 0)
|
|
238
|
+
olds = data[:entries].select { |entry| entry.is_a?(Hash) && entry[:turns].is_a?(Integer) }
|
|
239
|
+
@entries = seeded(olds.map do |entry|
|
|
240
|
+
turns = entry[:during] && killed_in_turn ? turns_done : entry[:turns]
|
|
241
|
+
entry.except(:turns).merge(base_since: [turns_done - turns - (entry[:in_turn] ? 1 : 0), 0].max)
|
|
242
|
+
end)
|
|
243
|
+
end
|
|
163
244
|
|
|
164
|
-
|
|
245
|
+
def seeded(entries)
|
|
246
|
+
entries.filter_map do |entry|
|
|
247
|
+
next unless entry.is_a?(Hash) && entry[:type].is_a?(String) && sound_place?(entry)
|
|
248
|
+
|
|
249
|
+
entry = entry.except(:during).merge(type: entry[:type].to_sym, earlier: true)
|
|
165
250
|
entry = entry.merge(actions: []) if entry[:type] == :card && entry[:in_turn] && entry.key?(:actions)
|
|
166
251
|
entry unless entry[:type] == :guardrail_warning || open_question?(entry)
|
|
167
252
|
end
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# A hand-edited or foreign file's entry goes, rather than break every later
|
|
256
|
+
# listing of the session.
|
|
257
|
+
def sound_place?(entry)
|
|
258
|
+
return false unless entry[:turn_id].nil? || entry[:turn_id].is_a?(String)
|
|
259
|
+
|
|
260
|
+
entry.values_at(:base_since, :past_idless).compact.all? { |count| count.is_a?(Integer) && !count.negative? }
|
|
172
261
|
end
|
|
173
262
|
|
|
174
263
|
def fold(event)
|
|
175
264
|
case event[:type]
|
|
176
265
|
when :turn_started
|
|
177
|
-
@
|
|
266
|
+
@turn_log << event[:turn_id] if event[:turn_id]
|
|
267
|
+
@running_turn = event[:turn_id]
|
|
268
|
+
@continuing = event[:continue] ? true : false
|
|
269
|
+
@failed_turn = nil
|
|
270
|
+
@log_start = nil
|
|
178
271
|
@iteration = nil
|
|
179
272
|
@calls = 0
|
|
180
273
|
when :generation_started
|
|
@@ -183,82 +276,74 @@ module Samagotchi
|
|
|
183
276
|
when :tool_call_started
|
|
184
277
|
@iteration = event[:iteration]
|
|
185
278
|
@calls = event[:call_index].to_i
|
|
186
|
-
|
|
187
|
-
# stayed too); a failed one rolled back leaves none.
|
|
188
|
-
when *Events::TURN_END
|
|
189
|
-
@running = false
|
|
190
|
-
kept = Events.kept_turn?(event)
|
|
191
|
-
@turns_done += 1 if kept
|
|
192
|
-
settle_during(after_turn: kept)
|
|
193
|
-
unstep_turn_left_nothing unless kept
|
|
194
|
-
when :turn_rolled_back then take_turn_back
|
|
279
|
+
when *Events::TURN_END then end_turn(event)
|
|
195
280
|
when :card then add_card(event)
|
|
196
281
|
when :hook_notice then add_notice(event)
|
|
197
282
|
# What failed to load (guardrails, plugins): before the first turn
|
|
198
|
-
# this worker runs, where it showed live.
|
|
283
|
+
# this worker runs, where it showed live. The Engine announces it inside
|
|
284
|
+
# that turn: it anchors before the turn.
|
|
199
285
|
when :guardrail_warning then push({ type: :guardrail_warning, message: event[:message], label: event[:label] }
|
|
200
|
-
.compact.merge(in_turn: false,
|
|
201
|
-
when :empty_answer_retry then add_turn_row(event.slice(:type, :attempt, :of, :stopped_by, :malformed)) if @
|
|
202
|
-
when :steer_cut then add_turn_row(event.slice(:type, :source)) if @
|
|
203
|
-
when :llm_context_edited then add_turn_row(Marshal.load(Marshal.dump(event.slice(*LLM_CONTEXT_FIELDS)))) if @
|
|
286
|
+
.compact.merge(in_turn: false, turn_id: @running_turn ? @turn_log[-2] : @turn_log.last))
|
|
287
|
+
when :empty_answer_retry then add_turn_row(event.slice(:type, :attempt, :of, :stopped_by, :malformed)) if @running_turn
|
|
288
|
+
when :steer_cut then add_turn_row(event.slice(:type, :source)) if @running_turn
|
|
289
|
+
when :llm_context_edited then add_turn_row(Marshal.load(Marshal.dump(event.slice(*LLM_CONTEXT_FIELDS)))) if @running_turn
|
|
204
290
|
when :question_requested
|
|
205
|
-
add_turn_row({ type: :question, pending_question: Marshal.load(Marshal.dump(event[:pending_question])) }) if @
|
|
291
|
+
add_turn_row({ type: :question, pending_question: Marshal.load(Marshal.dump(event[:pending_question])) }) if @running_turn
|
|
206
292
|
when :question_answered then resolve_question(event[:id], answer: event[:answer])
|
|
207
293
|
when :question_cancelled then resolve_question(event[:id], cancelled: true, reason: event[:reason])
|
|
208
294
|
when :question_relay then mark_relay(event[:id], event[:relayed_to])
|
|
209
295
|
end
|
|
210
296
|
end
|
|
211
297
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
entry = card.merge(in_turn: event[:in_turn] ? true : false, turns: @turns_done)
|
|
221
|
-
entry[:during] = true if @running && !event[:in_turn]
|
|
222
|
-
push(entry)
|
|
298
|
+
# The running turn ended. A :during card now reads as after it when its
|
|
299
|
+
# opening is in the messages, and as before the next turn when not. A
|
|
300
|
+
# failed Continue or wake left nothing either, opening or not.
|
|
301
|
+
def end_turn(event)
|
|
302
|
+
settle_past_idless
|
|
303
|
+
@entries.each do |entry|
|
|
304
|
+
entry.delete(:during)
|
|
305
|
+
entry.delete(:continued) if entry[:turn_id] == @running_turn && !Events.kept_turn?(event)
|
|
223
306
|
end
|
|
307
|
+
@failed_turn = Events.kept_turn?(event) ? nil : @running_turn
|
|
308
|
+
@running_turn = nil
|
|
224
309
|
end
|
|
225
310
|
|
|
226
|
-
#
|
|
227
|
-
#
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
311
|
+
# What the running turn showed that isn't its own (an anytime command's
|
|
312
|
+
# card) goes after that turn, so after any opening with no id inside it. The
|
|
313
|
+
# end event's messages hold the turn; its own rows count such a line as usual.
|
|
314
|
+
def settle_past_idless
|
|
315
|
+
shown = @entries.select { |entry| entry[:turn_id] == @running_turn && !entry[:in_turn] }
|
|
316
|
+
return if shown.empty? || !@messages
|
|
231
317
|
|
|
232
|
-
|
|
233
|
-
|
|
318
|
+
run = CardPlaces.new(@turn_log, @messages.call).idless_after(@running_turn)
|
|
319
|
+
shown.each { |entry| entry[:past_idless] = run if run.positive? }
|
|
234
320
|
end
|
|
235
321
|
|
|
236
|
-
# The
|
|
237
|
-
|
|
238
|
-
def take_turn_back
|
|
239
|
-
@turns_done = [@turns_done - 1, 0].max
|
|
240
|
-
unstep_turn_left_nothing
|
|
241
|
-
@entries.each { |entry| entry[:turns] = [entry[:turns], @turns_done].min }
|
|
242
|
-
end
|
|
322
|
+
# The entry's anchor: the running turn, else the last one.
|
|
323
|
+
def anchor = @running_turn || @turn_log.last
|
|
243
324
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
325
|
+
def add_card(event)
|
|
326
|
+
card = event.slice(:type, :id, :source, :title, :body, :level, :actions, :command)
|
|
327
|
+
index = @entries.index { |entry| entry[:type] == :card && entry[:id] == card[:id] }
|
|
328
|
+
if index
|
|
329
|
+
# Replaced where it was: the first one's place stays, and whose it is
|
|
330
|
+
# (a command's answer from another thread; a turn's proposal its own
|
|
331
|
+
# button's command replaced, which nobody typed).
|
|
332
|
+
old = @entries[index]
|
|
333
|
+
@entries[index] = card.except(:command).merge(updated: true, **old.slice(:in_turn, :turn_id, :during, :continued, :base_since,
|
|
334
|
+
:past_idless, :after_failed, :command))
|
|
335
|
+
else
|
|
336
|
+
entry = card.merge(in_turn: event[:in_turn] ? true : false, turn_id: anchor)
|
|
337
|
+
entry[:during] = true if @running_turn && !event[:in_turn]
|
|
338
|
+
entry[:continued] = true if @running_turn && event[:in_turn] && @continuing
|
|
339
|
+
push(entry)
|
|
255
340
|
end
|
|
256
341
|
end
|
|
257
342
|
|
|
258
343
|
def add_notice(event)
|
|
259
344
|
notice = event.slice(:type, :hook, :text, :level, :fallback_for)
|
|
260
|
-
if event[:between_turns] || !@
|
|
261
|
-
push(notice.merge(in_turn: false,
|
|
345
|
+
if event[:between_turns] || !@running_turn
|
|
346
|
+
push(notice.merge(in_turn: false, turn_id: anchor))
|
|
262
347
|
else
|
|
263
348
|
add_turn_row(notice)
|
|
264
349
|
end
|
|
@@ -266,7 +351,9 @@ module Samagotchi
|
|
|
266
351
|
|
|
267
352
|
# A row of the running turn's current step.
|
|
268
353
|
def add_turn_row(row)
|
|
269
|
-
|
|
354
|
+
entry = row.merge(in_turn: true, turn_id: @running_turn, iteration: @iteration, calls: @calls).compact
|
|
355
|
+
entry[:continued] = true if @continuing
|
|
356
|
+
push(entry)
|
|
270
357
|
end
|
|
271
358
|
|
|
272
359
|
def resolve_question(id, **outcome)
|
|
@@ -291,6 +378,8 @@ module Samagotchi
|
|
|
291
378
|
end
|
|
292
379
|
|
|
293
380
|
def push(entry)
|
|
381
|
+
entry[:after_failed] = true if @failed_turn && !entry[:in_turn]
|
|
382
|
+
note_past_idless(entry) unless @running_turn
|
|
294
383
|
@entries << entry
|
|
295
384
|
cap = cap_class(entry)
|
|
296
385
|
kind = @entries.select { |e| cap_class(e) == cap }
|
|
@@ -300,6 +389,14 @@ module Samagotchi
|
|
|
300
389
|
@entries.delete_at(@entries.index { |e| e.equal?(evict) }) if evict
|
|
301
390
|
end
|
|
302
391
|
|
|
392
|
+
# Shown between turns: below the openings with no id already there.
|
|
393
|
+
def note_past_idless(entry)
|
|
394
|
+
return unless @messages
|
|
395
|
+
|
|
396
|
+
run = CardPlaces.new(@turn_log, @messages.call).idless_after(entry[:turn_id])
|
|
397
|
+
entry[:past_idless] = run if run.positive?
|
|
398
|
+
end
|
|
399
|
+
|
|
303
400
|
# Which cap +entry+ counts against: :notice, :llm_context or :card.
|
|
304
401
|
def cap_class(entry)
|
|
305
402
|
return :llm_context if LLM_CONTEXT_TYPES.include?(entry[:type])
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -136,7 +136,7 @@ module Samagotchi
|
|
|
136
136
|
@accumulator = TurnAccumulator.new
|
|
137
137
|
# Saved in the session's folder: a later worker, and the web without
|
|
138
138
|
# one, show the cards where they were.
|
|
139
|
-
@cards = CardStore.new(path: File.join(session_dir, CardStore::FILE),
|
|
139
|
+
@cards = CardStore.new(path: File.join(session_dir, CardStore::FILE), messages: -> { @engine.session&.messages })
|
|
140
140
|
@pending_card = PendingCard.new(session_dir)
|
|
141
141
|
@heartbeat_interval = heartbeat_interval
|
|
142
142
|
@epoch = SecureRandom.hex(4)
|
|
@@ -315,7 +315,7 @@ module Samagotchi
|
|
|
315
315
|
guardrail_warning: @engine.guardrail_warning,
|
|
316
316
|
plugin_warning: @engine.plugin_warning,
|
|
317
317
|
init_tasks: @engine.init_tasks,
|
|
318
|
-
cards:
|
|
318
|
+
cards: cards_listing,
|
|
319
319
|
commands: command_registry.listing,
|
|
320
320
|
chi_version: Samagotchi::VERSION,
|
|
321
321
|
event_seq: seq,
|
|
@@ -339,7 +339,8 @@ module Samagotchi
|
|
|
339
339
|
# What the page re-reads at a turn's end and when a card arrives: the
|
|
340
340
|
# session state, the last answer (Engine#last_answer_message: one
|
|
341
341
|
# message, not the conversation) and the cards, as one step of the event
|
|
342
|
-
# log (one event_seq). Light
|
|
342
|
+
# log (one event_seq). Light for a long session: GET tail copies no messages,
|
|
343
|
+
# and placing the cards only walks them (about 10 ms at 40k messages).
|
|
343
344
|
# The session's folder (working_directory) rides along: what the web
|
|
344
345
|
# checks the answer's file refs against without reading the session file.
|
|
345
346
|
# @return [Hash] {session_id:, working_directory:, session_state_snapshot:, answer:, cards:, event_seq:, event_id:}
|
|
@@ -350,7 +351,7 @@ module Samagotchi
|
|
|
350
351
|
state = @engine.session_state_snapshot.merge(event_seq: seq, event_id: event_id(seq))
|
|
351
352
|
{ session_id: @session_id, working_directory: @engine.session&.working_directory,
|
|
352
353
|
session_state_snapshot: state, answer: @engine.last_answer_message(turn_id: turn_id),
|
|
353
|
-
cards:
|
|
354
|
+
cards: cards_listing, event_seq: seq, event_id: event_id(seq) }
|
|
354
355
|
end
|
|
355
356
|
end
|
|
356
357
|
|
|
@@ -372,6 +373,13 @@ module Samagotchi
|
|
|
372
373
|
|
|
373
374
|
private
|
|
374
375
|
|
|
376
|
+
# The cards, placed against the messages a snapshot's page draws (the
|
|
377
|
+
# session's own, which a running turn leaves alone). No copy: GET tail
|
|
378
|
+
# stays light.
|
|
379
|
+
def cards_listing
|
|
380
|
+
@cards.list(messages: @engine.session&.messages)
|
|
381
|
+
end
|
|
382
|
+
|
|
375
383
|
# The turn in progress (TurnAccumulator#current_turn) with the running
|
|
376
384
|
# generation's phase as generation: {thinking_only:, age:}
|
|
377
385
|
# (Engine#generation_snapshot), so a page joining mid-generation can
|
|
@@ -84,6 +84,7 @@ module Samagotchi
|
|
|
84
84
|
if manifest
|
|
85
85
|
missing = check_files(manifest)
|
|
86
86
|
check_source_tree(manifest, missing) unless @installed
|
|
87
|
+
check_keys(manifest)
|
|
87
88
|
check_requires_chi(manifest)
|
|
88
89
|
check_trust(manifest) unless @installed
|
|
89
90
|
check_clash(manifest)
|
|
@@ -147,6 +148,44 @@ module Samagotchi
|
|
|
147
148
|
index && (index + 1)
|
|
148
149
|
end
|
|
149
150
|
|
|
151
|
+
# ── the manifest's own keys ───────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
# A key this chi's parser never reads is one it ignores, and a key whose
|
|
154
|
+
# floor is above what requires_chi admits is one an older chi would
|
|
155
|
+
# ignore too. The floor is the author's fix, so an installed bundle gets
|
|
156
|
+
# only the unknown-key line, as check_floor's call floor does today.
|
|
157
|
+
def check_keys(manifest)
|
|
158
|
+
manifest.unknown_keys.each do |key|
|
|
159
|
+
add(:warn, :"unknown-key", "manifest.yml", MemoryBundle::Manifest.unknown_key_line(key), line: manifest_line(key),
|
|
160
|
+
fix: "update chi, or remove #{key}: if it's a typo")
|
|
161
|
+
end
|
|
162
|
+
check_key_floor(manifest) unless @installed
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# The manifest uses a key the oldest chi requires_chi admits never read.
|
|
166
|
+
# No plugin needed: the key is the bundle's own claim.
|
|
167
|
+
def check_key_floor(manifest)
|
|
168
|
+
key, version = manifest.floor_keys.max_by { |_, floor| Gem::Version.new(floor) }
|
|
169
|
+
return if key.nil?
|
|
170
|
+
|
|
171
|
+
bound = lower_bound(manifest.requires_chi)
|
|
172
|
+
return if bound && Gem::Version.new(version) <= bound
|
|
173
|
+
|
|
174
|
+
add(:warn, :"requires-chi-floor", "manifest.yml",
|
|
175
|
+
"#{manifest.requires_chi ? "requires_chi #{manifest.requires_chi.inspect} admits chi #{bound}," : "no requires_chi,"} " \
|
|
176
|
+
"but the manifest uses #{key_label(key)}, which needs chi #{version}",
|
|
177
|
+
line: manifest_line(key.split(".").first), fix: "requires_chi: \">= #{version}\"")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# How to name a SINCE key in a message: a shape is not a key on its own.
|
|
181
|
+
def key_label(key)
|
|
182
|
+
case key
|
|
183
|
+
when "files.description" then "a files: entry given as a mapping"
|
|
184
|
+
when "session_settings.types", "settings.types" then "#{key.split(".").first}: as a mapping of keys to types"
|
|
185
|
+
else "#{key}:"
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
150
189
|
# ── files ─────────────────────────────────────────────────────────
|
|
151
190
|
|
|
152
191
|
# @return [Boolean] whether a listed file is missing
|
|
@@ -340,15 +379,16 @@ module Samagotchi
|
|
|
340
379
|
return if manifest.settings.nil? || @settings.empty?
|
|
341
380
|
|
|
342
381
|
require_relative "../bundle_settings_check"
|
|
343
|
-
|
|
344
|
-
session_settings: manifest.session_settings)
|
|
345
|
-
BundleSettingsCheck.record_warnings(record, @settings).each { |line| add(:warn, :settings, "manifest.yml", line, line: manifest_line("settings")) }
|
|
382
|
+
BundleSettingsCheck.record_warnings(manifest, @settings).each { |line| add(:warn, :settings, "manifest.yml", line, line: manifest_line("settings")) }
|
|
346
383
|
end
|
|
347
384
|
|
|
348
385
|
def install_warnings(warnings)
|
|
349
386
|
stale_reported = @findings.any? { |f| f.code == :sha }
|
|
350
387
|
warnings.each do |warning|
|
|
351
388
|
next if stale_reported && warning.start_with?("Checksum mismatch")
|
|
389
|
+
# An unknown manifest key is check_keys' row, with the fix the install
|
|
390
|
+
# can't offer; the same condition twice would be two rows.
|
|
391
|
+
next if warning.include?("doesn't know the manifest key")
|
|
352
392
|
next if @too_new && warning.include?("won't load:")
|
|
353
393
|
# This machine's PATH, said at install; nothing a chi update changed.
|
|
354
394
|
next if @installed && warning.start_with?("needs ")
|
|
@@ -58,13 +58,17 @@ module Samagotchi
|
|
|
58
58
|
private_class_method :in_child
|
|
59
59
|
|
|
60
60
|
# What the child sends: an edited file, which the copy's own shas can't
|
|
61
|
-
# tell,
|
|
62
|
-
#
|
|
61
|
+
# tell, a manifest key that doesn't parse (the copy holds the mirror's
|
|
62
|
+
# value or none: InstalledManifest#key_problems), and Check's warnings
|
|
63
|
+
# and errors, naming the installed bundle's folder rather than the copy's.
|
|
63
64
|
def answer_of(bundle)
|
|
64
65
|
edited = MemoryBundle::ShippedUpdate.edited_executables(bundle.name, bundle).map do |file|
|
|
65
66
|
Check::Finding.new(level: :warn, code: :edited, file: file, line: nil, fix: "chi bundle upgrade #{bundle.name}, or reinstall it",
|
|
66
67
|
message: "changed since it was installed, so chi doesn't load it")
|
|
67
68
|
end
|
|
69
|
+
edited += bundle.manifest.key_problems.values.map do |message|
|
|
70
|
+
Check::Finding.new(level: :error, code: :manifest, file: "manifest.yml", line: nil, message: message, fix: nil)
|
|
71
|
+
end
|
|
68
72
|
edited = edited.map { |finding| { level: finding.level, text: finding.to_s } }
|
|
69
73
|
installed_dir = MemoryBundle::Provenance.new(name: bundle.name).bundle_dir
|
|
70
74
|
edited + MemoryBundle::InstalledCopy.with(bundle) do |dir|
|