samagotchi 0.156.0 → 0.158.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -1
  3. data/README.md +1 -1
  4. data/bin/chi +4 -1
  5. data/docs/architecture.md +18 -12
  6. data/docs/bundle-authoring.md +5 -5
  7. data/docs/bundles.md +4 -1
  8. data/docs/cli.md +29 -24
  9. data/docs/configuration.md +43 -9
  10. data/docs/context.md +5 -3
  11. data/docs/guardrails.md +12 -7
  12. data/docs/hooks.md +53 -83
  13. data/docs/internals/bridge.md +84 -7
  14. data/docs/internals/bundles.md +69 -46
  15. data/docs/internals/context-telemetry.md +4 -3
  16. data/docs/internals/engine.md +23 -7
  17. data/docs/internals/participants.md +3 -2
  18. data/docs/internals/remote-sessions.md +5 -2
  19. data/docs/internals/web-frontend.md +3 -2
  20. data/docs/internals/working-set.md +1 -1
  21. data/docs/memory.md +51 -10
  22. data/docs/plugins.md +48 -11
  23. data/docs/releasing.md +6 -5
  24. data/docs/sessions.md +13 -13
  25. data/docs/sub-agent.md +17 -2
  26. data/docs/testing.md +56 -5
  27. data/lib/samagotchi/answer_command.rb +2 -1
  28. data/lib/samagotchi/archive_store.rb +4 -3
  29. data/lib/samagotchi/bootstrap_command.rb +3 -2
  30. data/lib/samagotchi/bridge/card_places.rb +5 -7
  31. data/lib/samagotchi/bridge/card_store.rb +3 -0
  32. data/lib/samagotchi/bridge/turn_accumulator.rb +113 -10
  33. data/lib/samagotchi/bridge/turn_drawings.rb +271 -0
  34. data/lib/samagotchi/bridge.rb +31 -6
  35. data/lib/samagotchi/broadcast/recipients.rb +8 -8
  36. data/lib/samagotchi/broadcast/triage_model.rb +1 -1
  37. data/lib/samagotchi/bundle_command/check.rb +41 -10
  38. data/lib/samagotchi/bundle_command/installed_check.rb +13 -2
  39. data/lib/samagotchi/bundle_command/try/run.rb +4 -1
  40. data/lib/samagotchi/bundle_command/try/sandbox.rb +1 -1
  41. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  42. data/lib/samagotchi/bundle_command.rb +20 -45
  43. data/lib/samagotchi/bundles/known-names/manifest.yml +4 -7
  44. data/lib/samagotchi/bundles/known-names/{hooks/known_names.rb → plugin.rb} +17 -0
  45. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -7
  46. data/lib/samagotchi/bundles/source-links/{hooks/source_links.rb → plugin.rb} +23 -4
  47. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  48. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  49. data/lib/samagotchi/bundles/system/memory_guide.md +7 -7
  50. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  51. data/lib/samagotchi/child_move.rb +6 -6
  52. data/lib/samagotchi/child_reports.rb +2 -2
  53. data/lib/samagotchi/children_status.rb +15 -21
  54. data/lib/samagotchi/cli/parent_wait.rb +8 -22
  55. data/lib/samagotchi/config.rb +3 -0
  56. data/lib/samagotchi/context_command.rb +7 -5
  57. data/lib/samagotchi/context_sources.rb +9 -6
  58. data/lib/samagotchi/context_window.rb +9 -12
  59. data/lib/samagotchi/effective_model.rb +57 -22
  60. data/lib/samagotchi/engine/plugins.rb +1 -1
  61. data/lib/samagotchi/engine/recap.rb +8 -4
  62. data/lib/samagotchi/engine.rb +30 -9
  63. data/lib/samagotchi/events.rb +3 -2
  64. data/lib/samagotchi/extension_load.rb +26 -44
  65. data/lib/samagotchi/guardrails/load_failures.rb +3 -2
  66. data/lib/samagotchi/guardrails/path_resolve.rb +11 -0
  67. data/lib/samagotchi/guardrails/review_paths.rb +1 -5
  68. data/lib/samagotchi/guardrails_command.rb +1 -1
  69. data/lib/samagotchi/hook_routes.rb +2 -2
  70. data/lib/samagotchi/hooks.rb +2 -3
  71. data/lib/samagotchi/host_address.rb +52 -0
  72. data/lib/samagotchi/host_registry.rb +57 -45
  73. data/lib/samagotchi/kernel_loop.rb +7 -3
  74. data/lib/samagotchi/llm/chat_loop.rb +6 -3
  75. data/lib/samagotchi/llm_context_strategy.rb +27 -63
  76. data/lib/samagotchi/memory_bundle/asset_installer.rb +92 -177
  77. data/lib/samagotchi/memory_bundle/builder.rb +12 -35
  78. data/lib/samagotchi/memory_bundle/bundle_hook.rb +6 -31
  79. data/lib/samagotchi/memory_bundle/file_actions.rb +193 -0
  80. data/lib/samagotchi/memory_bundle/file_digest.rb +13 -0
  81. data/lib/samagotchi/memory_bundle/file_state.rb +152 -0
  82. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  83. data/lib/samagotchi/memory_bundle/installed_copy.rb +13 -19
  84. data/lib/samagotchi/memory_bundle/installed_manifest.rb +10 -17
  85. data/lib/samagotchi/memory_bundle/installer.rb +212 -178
  86. data/lib/samagotchi/memory_bundle/manifest.rb +51 -58
  87. data/lib/samagotchi/memory_bundle/merger.rb +69 -65
  88. data/lib/samagotchi/memory_bundle/plugin_ref.rb +6 -5
  89. data/lib/samagotchi/memory_bundle/profile.rb +5 -5
  90. data/lib/samagotchi/memory_bundle/provenance.rb +26 -52
  91. data/lib/samagotchi/memory_bundle/remerge.rb +31 -14
  92. data/lib/samagotchi/memory_bundle/shas.rb +5 -8
  93. data/lib/samagotchi/memory_bundle/shipped_update.rb +51 -11
  94. data/lib/samagotchi/memory_bundle/status.rb +26 -30
  95. data/lib/samagotchi/memory_bundle/system_bundle.rb +39 -10
  96. data/lib/samagotchi/memory_bundle/trash.rb +28 -18
  97. data/lib/samagotchi/memory_bundle/uninstaller.rb +16 -11
  98. data/lib/samagotchi/memory_orphans.rb +4 -4
  99. data/lib/samagotchi/memory_paths.rb +102 -13
  100. data/lib/samagotchi/memory_usage.rb +1 -1
  101. data/lib/samagotchi/model_lookup.rb +75 -0
  102. data/lib/samagotchi/model_profile.rb +1 -0
  103. data/lib/samagotchi/models_command.rb +1 -1
  104. data/lib/samagotchi/note_command.rb +1 -1
  105. data/lib/samagotchi/participant_mode.rb +2 -1
  106. data/lib/samagotchi/plugin/api.rb +42 -10
  107. data/lib/samagotchi/plugin/loader.rb +75 -15
  108. data/lib/samagotchi/plugin/sessions.rb +1 -1
  109. data/lib/samagotchi/project_command.rb +167 -0
  110. data/lib/samagotchi/project_init.rb +69 -0
  111. data/lib/samagotchi/project_scope.rb +6 -4
  112. data/lib/samagotchi/prompt_files.rb +10 -7
  113. data/lib/samagotchi/reasoning_history.rb +14 -20
  114. data/lib/samagotchi/recap_place.rb +26 -0
  115. data/lib/samagotchi/reply_wait/liveness.rb +38 -0
  116. data/lib/samagotchi/reply_wait/poll.rb +44 -15
  117. data/lib/samagotchi/reply_wait/target.rb +113 -0
  118. data/lib/samagotchi/reply_wait.rb +22 -2
  119. data/lib/samagotchi/run_options.rb +1 -0
  120. data/lib/samagotchi/sampling_settings.rb +9 -16
  121. data/lib/samagotchi/self_report.rb +34 -35
  122. data/lib/samagotchi/send_command.rb +17 -23
  123. data/lib/samagotchi/session.rb +349 -441
  124. data/lib/samagotchi/session_access/contract.rb +1 -1
  125. data/lib/samagotchi/session_access/local.rb +5 -6
  126. data/lib/samagotchi/session_access/remote.rb +5 -3
  127. data/lib/samagotchi/session_field.rb +134 -0
  128. data/lib/samagotchi/session_inbox.rb +49 -1
  129. data/lib/samagotchi/session_manager/emptiness.rb +2 -1
  130. data/lib/samagotchi/session_manager/listing.rb +29 -100
  131. data/lib/samagotchi/session_manager.rb +2 -1
  132. data/lib/samagotchi/session_metrics.rb +7 -4
  133. data/lib/samagotchi/session_paths.rb +57 -0
  134. data/lib/samagotchi/session_retention.rb +2 -2
  135. data/lib/samagotchi/session_row.rb +200 -0
  136. data/lib/samagotchi/session_setup.rb +15 -12
  137. data/lib/samagotchi/sessions_command.rb +11 -129
  138. data/lib/samagotchi/steer.rb +10 -0
  139. data/lib/samagotchi/steer_cut_after.rb +7 -14
  140. data/lib/samagotchi/system_prompt.rb +20 -11
  141. data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -26
  142. data/lib/samagotchi/terminal_ui/join_replay.rb +91 -8
  143. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  144. data/lib/samagotchi/terminal_ui.rb +3 -1
  145. data/lib/samagotchi/testing/bundle.rb +5 -12
  146. data/lib/samagotchi/testing/install.rb +5 -4
  147. data/lib/samagotchi/testing/recorder.rb +1 -1
  148. data/lib/samagotchi/testing.rb +1 -1
  149. data/lib/samagotchi/thinking.rb +16 -21
  150. data/lib/samagotchi/tool_declarations.rb +1 -1
  151. data/lib/samagotchi/tool_response.rb +5 -3
  152. data/lib/samagotchi/tool_runner.rb +18 -1
  153. data/lib/samagotchi/toolkit_binding.rb +1 -4
  154. data/lib/samagotchi/toolkit_command.rb +1 -4
  155. data/lib/samagotchi/tools/delegate.rb +60 -51
  156. data/lib/samagotchi/tools/delegate_relay.rb +5 -5
  157. data/lib/samagotchi/tools/delegate_result.rb +1 -1
  158. data/lib/samagotchi/tools/delegate_wait.rb +41 -17
  159. data/lib/samagotchi/tools/list_sessions.rb +7 -7
  160. data/lib/samagotchi/update_command.rb +11 -7
  161. data/lib/samagotchi/verify_fresh.rb +7 -11
  162. data/lib/samagotchi/version.rb +1 -1
  163. data/lib/samagotchi/vision_support.rb +11 -14
  164. data/lib/samagotchi/web/app.rb +66 -21
  165. data/lib/samagotchi/web/message_display.rb +57 -7
  166. data/lib/samagotchi/web/participant_access.rb +1 -1
  167. data/lib/samagotchi/web/project_list.rb +3 -2
  168. data/lib/samagotchi/web/public/activity.js +16 -7
  169. data/lib/samagotchi/web/public/app.js +29 -8
  170. data/lib/samagotchi/web/public/bubbles.js +8 -3
  171. data/lib/samagotchi/web/public/cards_place.js +15 -0
  172. data/lib/samagotchi/web/public/commands.js +5 -1
  173. data/lib/samagotchi/web/public/data.js +4 -2
  174. data/lib/samagotchi/web/public/history_shape.js +220 -0
  175. data/lib/samagotchi/web/public/index.html +6 -1
  176. data/lib/samagotchi/web/public/project_picker.js +86 -4
  177. data/lib/samagotchi/web/public/recap_stamp.js +17 -0
  178. data/lib/samagotchi/web/public/reload_check.js +71 -0
  179. data/lib/samagotchi/web/public/stage_model.js +7 -5
  180. data/lib/samagotchi/web/public/stage_view.js +7 -3
  181. data/lib/samagotchi/web/public/stream_handlers.js +8 -2
  182. data/lib/samagotchi/web/public/timing.js +43 -2
  183. data/lib/samagotchi/web/public/turn_block.js +215 -30
  184. data/lib/samagotchi/web/public/turn_drawing.js +43 -0
  185. data/lib/samagotchi/web/public/turn_events.js +10 -1
  186. data/lib/samagotchi/web/public/turn_pieces.js +29 -0
  187. data/lib/samagotchi/web/reload_check_route.rb +95 -0
  188. data/lib/samagotchi/web/server.rb +4 -2
  189. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  190. data/lib/samagotchi/web/session_control.rb +1 -1
  191. data/lib/samagotchi/web/session_hub.rb +9 -17
  192. data/lib/samagotchi/web/session_show.rb +64 -2
  193. data/lib/samagotchi/web/session_summary.rb +36 -24
  194. data/lib/samagotchi/worker.rb +28 -5
  195. data/lib/samagotchi/worker_link.rb +3 -0
  196. metadata +22 -5
  197. data/lib/samagotchi/hooks/bundle_loader.rb +0 -191
  198. data/lib/samagotchi/send_command/wait_target.rb +0 -85
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require_relative "../log"
7
+ require_relative "../message_time"
8
+ require_relative "../steer"
9
+
10
+ module Samagotchi
11
+ class Bridge
12
+ # A finished turn as live drew it (its drawing), one JSON line per turn in
13
+ # the session's folder (FILE), so a reload or a reconnect draws the turn
14
+ # the way the live page did: the TurnAccumulator's final fold of the turn
15
+ # (its parts in order: steps, rows, notices, steers, merged lines,
16
+ # reminders, in-turn cards) and the end event's display keys (END_KEYS).
17
+ # Display-side only: no request, message, strategy, recap, --resume or
18
+ # continue reads it.
19
+ #
20
+ # A line names the turn's own messages by the range the Engine put on
21
+ # the end event (`kept_from`/`kept_to`: the turn's messages are
22
+ # `kept[from...to]`) and digests both ends (#digest). It holds while the
23
+ # messages still reach `to` and both ends digest the same: a rollback, a
24
+ # rolled-back failure or a prompt handed back drops it. Of two valid
25
+ # lines that overlap (a rollback, then a re-run that said the same at the
26
+ # same boundaries), and of several for one turn id, the newest wins
27
+ # (.valid). A turn with no range (an older worker, a turn that kept
28
+ # nothing) or an empty one writes no line, and draws as before.
29
+ #
30
+ # Written by the accumulator's on_turn_end hook (#append), inside the
31
+ # SessionObserver's delivery: the line is on disk before any stream gets
32
+ # the end frame. That departs from SessionObserver's "enqueue-only" note,
33
+ # as CardStore's save does: one append per turn end.
34
+ class TurnDrawings
35
+ FILE = "turn_drawings.jsonl"
36
+ # In the sidecar's features (Bridge::FEATURES): this worker's CardStore
37
+ # lists an in-turn entry's of_turn, which a page drawing the turn needs
38
+ # to leave that entry to the drawing. chi web draws no drawings for a
39
+ # live worker without it (an older one: its rows would show twice).
40
+ FEATURE = "turn_drawings"
41
+ # A show draws at most this many turns from their drawings, the newest
42
+ # (the payload and the page's offscreen render stay bounded in a long
43
+ # session); older turns draw as before, from their messages.
44
+ SHOWN_MAX = 100
45
+ VERSION = 1
46
+ # The end event's keys the page draws a finished turn's end from (not
47
+ # the answer: its text, html and refs stay the message's; not the range:
48
+ # the line's from/to).
49
+ END_KEYS = %i[type turn_id duration_ms revises verify cancellation_reason cancelled_by summary message error_class
50
+ kept_steps].freeze
51
+ # What a line keeps of the accumulator's turn.
52
+ DRAWING_KEYS = (%i[prompt turn_id origin continue at images parts] + Steer::AUTHOR_KEYS).uniq.freeze
53
+ KINDS = { "turn_completed" => "completed", "turn_canceled" => "canceled", "turn_failed" => "failed" }.freeze
54
+
55
+ # One line of the file (string-keyed drawing and end, as JSON has them).
56
+ Line = Data.define(:v, :turn_id, :kind, :from, :to, :from_digest, :to_digest, :drawing, :end) do
57
+ # @return [Line, nil] nil for a line this chi can't draw (another
58
+ # version, a missing field, parts that aren't rows: .rows?)
59
+ def self.from_h(raw)
60
+ return nil unless raw.is_a?(Hash) && raw["v"] == VERSION
61
+ return nil unless raw["turn_id"].is_a?(String) && !raw["turn_id"].empty?
62
+ return nil unless raw["from"].is_a?(Integer) && raw["to"].is_a?(Integer) && raw["from"] < raw["to"] && raw["from"] >= 0
63
+ return nil unless raw["from_digest"].is_a?(String) && raw["to_digest"].is_a?(String)
64
+ return nil unless raw["drawing"].is_a?(Hash) && raw["end"].is_a?(Hash)
65
+ return nil unless TurnDrawings.rows?(raw["drawing"]["parts"])
66
+
67
+ new(v: raw["v"], turn_id: raw["turn_id"], kind: raw["kind"].to_s, from: raw["from"], to: raw["to"],
68
+ from_digest: raw["from_digest"], to_digest: raw["to_digest"], drawing: raw["drawing"], end: raw["end"])
69
+ end
70
+
71
+ def to_h = super.transform_keys(&:to_s)
72
+
73
+ def range = (from...to)
74
+ end
75
+
76
+ # @param path [String, nil] the file (nil: nothing is written)
77
+ # @param messages [#call] the session's messages now (the Engine's,
78
+ # as CardStore's): the end step replaced them before the end event, so
79
+ # they hold the turn's range when #append runs
80
+ def initialize(path:, messages:)
81
+ @path = path
82
+ @messages = messages
83
+ @last = path ? self.class.tail_line(path) : nil
84
+ end
85
+
86
+ # The newest line this store wrote or started from (P3's snapshot).
87
+ # @return [Line, nil]
88
+ attr_reader :last
89
+
90
+ # The accumulator's hook: the turn's line, when its end event names a
91
+ # range. Never raises (a turn that can't be drawn still ended).
92
+ # @param turn [Hash] the accumulator's final fold (symbol keys)
93
+ # @param end_event [Hash] the end event
94
+ def append(turn, end_event)
95
+ line = line_for(turn, end_event)
96
+ return unless line
97
+
98
+ write(line)
99
+ @last = line
100
+ rescue StandardError => e
101
+ Log.warn(:bridge, "turn_drawing_failed", error: "#{e.class}: #{e.message}")
102
+ end
103
+
104
+ def line_for(turn, end_event)
105
+ from = end_event[:kept_from]
106
+ to = end_event[:kept_to]
107
+ return nil unless @path && turn && from.is_a?(Integer) && to.is_a?(Integer) && from < to && from >= 0
108
+
109
+ messages = @messages.call || []
110
+ return nil if messages.size < to
111
+
112
+ id = end_event[:turn_id] || turn[:turn_id]
113
+ return nil unless id
114
+
115
+ Line.new(v: VERSION, turn_id: id.to_s, kind: KINDS[end_event[:type].to_s] || end_event[:type].to_s, from: from, to: to,
116
+ from_digest: self.class.digest(messages[from]), to_digest: self.class.digest(messages[to - 1]),
117
+ drawing: JSON.parse(JSON.generate(scrubbed(turn.slice(*DRAWING_KEYS)))),
118
+ end: JSON.parse(JSON.generate(scrubbed(self.class.end_of(end_event)))))
119
+ end
120
+ private :line_for
121
+
122
+ # Newline-first: a torn last line (a crash mid-write) ends before this
123
+ # one starts, so both parse apart; then one write.
124
+ def write(line)
125
+ FileUtils.mkdir_p(File.dirname(@path))
126
+ File.open(@path, File::RDWR | File::CREAT | File::APPEND, 0o600) do |file|
127
+ size = file.size
128
+ torn = size.positive? && (file.pread(1, size - 1) != "\n")
129
+ file.write("#{"\n" if torn}#{JSON.generate(line.to_h)}\n")
130
+ end
131
+ end
132
+ private :write
133
+
134
+ def scrubbed(value) = self.class.scrub(value)
135
+ private :scrubbed
136
+
137
+ class << self
138
+ # Whether a drawing's +parts+ are rows TurnAccumulator.replay_events
139
+ # can unfold: none, or a list of Hashes (a hand-edited line's may be
140
+ # anything, and the unfold would raise).
141
+ def rows?(parts) = parts.nil? || (parts.is_a?(Array) && parts.all?(Hash))
142
+
143
+ # The end event's display keys (END_KEYS), with an empty answer's
144
+ # notice from its summary.
145
+ # @return [Hash]
146
+ def end_of(event)
147
+ out = event.slice(*END_KEYS).compact
148
+ out[:type] = out[:type].to_s if out[:type]
149
+ empty = event.dig(:turn_summary, :empty_answer)
150
+ out[:turn_summary] = { empty_answer: empty } if empty
151
+ out
152
+ end
153
+
154
+ # A message's digest in one form for the live (symbol keys, raw
155
+ # strings) and the saved (string keys, scrubbed as Session#to_h does)
156
+ # messages: role, content and time, each read from either key.
157
+ # @return [String]
158
+ def digest(message)
159
+ return "" unless message.is_a?(Hash)
160
+
161
+ role = fetch(message, :role)
162
+ time = fetch(message, MessageTime::KEY)
163
+ Digest::SHA1.hexdigest(JSON.generate(scrub([role&.to_s, fetch(message, :content), time&.to_s])))
164
+ end
165
+
166
+ # Every String in +value+ as Session#to_h saves it (UTF-8, invalid
167
+ # bytes as "?"); symbol keys stay symbols (JSON writes them alike).
168
+ def scrub(value)
169
+ case value
170
+ when String
171
+ text = value.encoding == Encoding::UTF_8 ? value : value.dup.force_encoding(Encoding::UTF_8)
172
+ text.valid_encoding? ? text : text.scrub("?")
173
+ when Array then value.map { |item| scrub(item) }
174
+ when Hash then value.to_h { |key, item| [key, scrub(item)] }
175
+ when Symbol then value.to_s
176
+ else value
177
+ end
178
+ end
179
+
180
+ # The session folder's lines, oldest first (unreadable, torn and
181
+ # unknown lines skipped), cached by the file's path, size and mtime
182
+ # (chi web reads it on each full show).
183
+ # @return [Array<Line>]
184
+ def read(session_dir)
185
+ path = File.join(session_dir, FILE)
186
+ stat = File.stat(path)
187
+ key = [stat.size, stat.mtime.to_r]
188
+ cached = cache_mutex.synchronize { cache[path] }
189
+ return cached[1] if cached && cached[0] == key
190
+
191
+ lines = parse(File.binread(path))
192
+ cache_mutex.synchronize do
193
+ cache.shift while cache.size >= CACHE_SIZE
194
+ cache[path] = [key, lines]
195
+ end
196
+ lines
197
+ rescue SystemCallError, IOError
198
+ []
199
+ end
200
+
201
+ # @return [Array<Line>]
202
+ def parse(text)
203
+ text.dup.force_encoding(Encoding::UTF_8).scrub("?").each_line.filter_map do |raw|
204
+ Line.from_h(JSON.parse(raw))
205
+ rescue JSON::ParserError
206
+ nil
207
+ end
208
+ end
209
+
210
+ # The lines that hold against +messages+ (Session messages, symbol
211
+ # or string keys): each one's range still in them with both ends the
212
+ # same; of overlapping ranges and of one turn's lines the newest
213
+ # (last in the file) wins. Ordered by their range.
214
+ # @return [Array<Line>]
215
+ def valid(lines, messages)
216
+ messages = Array(messages)
217
+ kept = []
218
+ lines.reverse_each do |line|
219
+ next unless holds?(line, messages)
220
+ next if kept.any? { |other| other.turn_id == line.turn_id || overlap?(other, line) }
221
+
222
+ kept << line
223
+ end
224
+ kept.sort_by(&:from)
225
+ end
226
+
227
+ # The last parseable line of +path+, or nil.
228
+ # @return [Line, nil]
229
+ def tail_line(path)
230
+ return nil unless File.file?(path)
231
+
232
+ File.open(path, "rb") do |file|
233
+ size = file.size
234
+ chunk = file.pread([size, TAIL_BYTES].min, [size - TAIL_BYTES, 0].max)
235
+ parse(chunk.to_s).last
236
+ end
237
+ rescue SystemCallError, IOError
238
+ nil
239
+ end
240
+
241
+ # Whether +line+ is small enough to send in a snapshot: within what
242
+ # #tail_line reads back after a restart (TAIL_BYTES), so a join draws
243
+ # a turn the same before and after one (a bigger line: rebuilt).
244
+ def sendable?(line) = JSON.generate(line.to_h).bytesize <= TAIL_BYTES
245
+
246
+ def holds?(line, messages)
247
+ messages.size >= line.to && digest(messages[line.from]) == line.from_digest &&
248
+ digest(messages[line.to - 1]) == line.to_digest
249
+ end
250
+
251
+ def overlap?(one, other) = one.from < other.to && other.from < one.to
252
+
253
+ private
254
+
255
+ def fetch(message, key)
256
+ message.key?(key) ? message[key] : message[key.to_s]
257
+ end
258
+
259
+ def cache = (@cache ||= {})
260
+ def cache_mutex = (@cache_mutex ||= Mutex.new)
261
+ end
262
+
263
+ # Sessions whose parsed file chi web keeps (a long one's is tens of MB
264
+ # of objects; the mtime moves at every turn end anyway).
265
+ CACHE_SIZE = 8
266
+ # Read from the end at start (#last): a drawing is a few KB, its outputs cut.
267
+ TAIL_BYTES = 1024 * 1024
268
+ private_constant :CACHE_SIZE, :TAIL_BYTES
269
+ end
270
+ end
271
+ end
@@ -21,6 +21,7 @@ require_relative "bridge/pending_card"
21
21
  require_relative "bridge/ring_buffer"
22
22
  require_relative "bridge/sse_writer"
23
23
  require_relative "bridge/turn_accumulator"
24
+ require_relative "bridge/turn_drawings"
24
25
  require_relative "session"
25
26
  require_relative "engine"
26
27
  require_relative "system_one/log"
@@ -134,7 +135,10 @@ module Samagotchi
134
135
  @bind = bind
135
136
  @port = port
136
137
  @ring = RingBuffer.new(capacity: ring_capacity)
137
- @accumulator = TurnAccumulator.new
138
+ # A finished turn's drawing (TurnDrawings), written by the accumulator
139
+ # at the end event, before any stream gets it.
140
+ @drawings = TurnDrawings.new(path: File.join(session_dir, TurnDrawings::FILE), messages: -> { @engine.session&.messages })
141
+ @accumulator = TurnAccumulator.new(on_turn_end: @drawings.method(:append))
138
142
  # Saved in the session's folder: a later worker, and the web without
139
143
  # one, show the cards where they were.
140
144
  @cards = CardStore.new(path: File.join(session_dir, CardStore::FILE), messages: -> { @engine.session&.messages })
@@ -300,7 +304,7 @@ module Samagotchi
300
304
  # the event log: the Engine's messages (not the lagging copy on disk),
301
305
  # the turn in progress, turns queued behind it, the idle recap since the
302
306
  # last turn, the recap saved with the session (also from before the last
303
- # turns: {text:, covered:, turns_since:, created_at:}), a pending continue offer, the guardrail and plugin load warnings,
307
+ # turns: {text:, covered:, turns_since:, opening_id:, created_at:}), a pending continue offer, the guardrail and plugin load warnings,
304
308
  # the plugins' init tasks still running (Engine#init_tasks), the
305
309
  # last cards and between-turns notices (CardStore#list), the session's commands (Commands::Registry#listing:
306
310
  # what an attached TUI routes and completes, the web's autocomplete), and the event_seq it all covers.
@@ -315,13 +319,20 @@ module Samagotchi
315
319
  # working_set: the running turn's last :working_set_updated ({items:,
316
320
  # at:}, TurnAccumulator#working_set), which the messages lag; nil
317
321
  # between turns and before the turn emitted one.
322
+ # last_turn_drawing: the last exchange's turn as live drew it
323
+ # ({drawing:, end:}, TurnDrawings#last, not unfolded: an attaching TUI
324
+ # unfolds it, TurnAccumulator.replay_events), only while it holds against
325
+ # these messages and ends at their end; absent otherwise (a rollback, a
326
+ # later Continue or note, an older worker's file).
318
327
  # @return [Hash] {messages:, current_turn:, working_set:, queued:, queued_commands:, recap:, saved_recap:, continue_offer:, pending_question:,
319
- # guardrail_warning:, plugin_warning:, init_tasks:, cards:, commands:, chi_version:, event_seq:, event_id:}
328
+ # guardrail_warning:, plugin_warning:, init_tasks:, cards:, commands:, chi_version:, event_seq:, event_id:, last_turn_drawing:}
320
329
  def snapshot
321
330
  @engine.synchronize_events do
322
331
  seq = @engine.event_count
332
+ messages = @engine.messages_checkpoint
333
+ drawing = last_turn_drawing(messages)
323
334
  {
324
- messages: @engine.messages_checkpoint,
335
+ messages: messages,
325
336
  current_turn: current_turn_with_generation,
326
337
  working_set: @accumulator.working_set,
327
338
  queued: @accumulator.queued,
@@ -338,10 +349,24 @@ module Samagotchi
338
349
  chi_version: Samagotchi::VERSION,
339
350
  event_seq: seq,
340
351
  event_id: event_id(seq)
341
- }
352
+ }.merge(drawing ? { last_turn_drawing: drawing } : {})
342
353
  end
343
354
  end
344
355
 
356
+ # The newest drawing (TurnDrawings#last) as the snapshot sends it, when
357
+ # it is the last exchange's: its range still holds against +messages+
358
+ # and ends at their end, and is no bigger than a restart reads back
359
+ # (TurnDrawings.sendable?). nil otherwise.
360
+ # @return [Hash, nil] {drawing:, end:} (string keys inside, as the file)
361
+ def last_turn_drawing(messages)
362
+ line = @drawings.last
363
+ return nil unless line && line.to == messages.size && TurnDrawings.holds?(line, messages)
364
+ return nil unless TurnDrawings.sendable?(line)
365
+
366
+ { drawing: line.drawing, end: line.end }
367
+ end
368
+ private :last_turn_drawing
369
+
345
370
  # The snapshot and the session state (status, pending question, ...) as
346
371
  # one step of the event log, both as of the same event_seq: what a
347
372
  # snapshot or reset frame (SSEWriter) and GET snapshot carry.
@@ -664,7 +689,7 @@ module Samagotchi
664
689
  # review turn, and chi web says so (Delivery::PAIR_FEATURE).
665
690
  FEATURES = ["restart", "task_stop", "reparent", Delivery::QUEUE_FEATURE, ReplyWait::TURN_IDS_FEATURE,
666
691
  VerifyAnchor::FEATURE, ReviseAnchor::FEATURE,
667
- SessionInbox::UNQUEUE_FEATURE, Delivery::REVIEW_FEATURE, Delivery::PAIR_FEATURE].freeze
692
+ SessionInbox::UNQUEUE_FEATURE, Delivery::REVIEW_FEATURE, Delivery::PAIR_FEATURE, TurnDrawings::FEATURE].freeze
668
693
 
669
694
  # The routes, by method and what follows /session/:id/: a handler takes
670
695
  # the session id and the request body and returns [headers, status, body].
@@ -29,12 +29,12 @@ module Samagotchi
29
29
  # @return [Array<Recipient>] newest first
30
30
  def list(state_dir:, active_hours:, include_tests: Session.test_session_env?, now: Time.now)
31
31
  cutoff = now - (active_hours.to_f * 3600)
32
- SessionManager.session_summaries(include_tests: include_tests, state_dir: state_dir).filter_map do |row|
33
- next if row[:delegate] || row[:scratch]
34
- next unless row[:live] || row[:owner] == "tui" || active_since?(row, cutoff, state_dir)
32
+ SessionManager.session_rows(include_tests: include_tests, state_dir: state_dir).filter_map do |row|
33
+ next if row.delegate || row.scratch
34
+ next unless row.live || row.owner == "tui" || active_since?(row, cutoff, state_dir)
35
35
 
36
- Recipient.new(id: row[:id], short_id: row[:short_id], project: row[:project], cwd: row[:cwd],
37
- desc: row[:desc], live: row[:live], owner: row[:owner])
36
+ Recipient.new(id: row.id, short_id: row.short_id, project: row.project, cwd: row.cwd,
37
+ desc: row.desc, live: row.live, owner: row.owner)
38
38
  end
39
39
  end
40
40
 
@@ -43,11 +43,11 @@ module Samagotchi
43
43
  # session with no turn recorded; it is never older than the last
44
44
  # turn's end, so an older one rules the session out unread.
45
45
  def active_since?(row, cutoff, state_dir)
46
- return false unless (time(row[:updated_at]) || Time.at(0)) >= cutoff
46
+ return false unless (time(row.updated_at) || Time.at(0)) >= cutoff
47
47
 
48
- session = Session.summary_from_file(Session.session_file(row[:id], state_dir: state_dir))
48
+ session = Session.summary_from_file(Session.session_file(row.id, state_dir: state_dir))
49
49
  ended = time(session&.last_turn&.ended_at)
50
- (ended || time(row[:updated_at])) >= cutoff
50
+ (ended || time(row.updated_at)) >= cutoff
51
51
  end
52
52
  private_class_method :active_since?
53
53
 
@@ -29,7 +29,7 @@ module Samagotchi
29
29
  # @param get [#call] a Config key → its value
30
30
  # @return [Choice]
31
31
  def resolve(host_registry: nil, get: ->(key) { Config.get(key) })
32
- registry = host_registry || HostRegistry.new
32
+ registry = host_registry || HostRegistry.current
33
33
  SETTINGS.each do |model, host_ref, base_url|
34
34
  target = IdleTarget.resolve(model: get.call(model), host_ref: get.call(host_ref), base_url: get.call(base_url),
35
35
  host_registry: registry)
@@ -21,7 +21,7 @@ module Samagotchi
21
21
  #
22
22
  # It installs and loads the bundle in a Testing::Sandbox (temp HOME and
23
23
  # XDG dirs), not in the user's config or state, but in this process: the
24
- # plugin's and hooks' own code runs as written; --fix rewrites stale
24
+ # plugin's own code runs as written; --fix rewrites stale
25
25
  # sha256 lines (MemoryBundle::Shas). #run returns 0 without errors, 1
26
26
  # with any.
27
27
  #
@@ -34,8 +34,8 @@ module Samagotchi
34
34
 
35
35
  Check the bundle at PATH (default: .) as an install would take it, one finding per line:
36
36
  error|warn|info CODE file[:line]: message (fix: hint)
37
- It installs and loads the bundle in a sandbox (temp HOME, config and state), not in yours, but its plugin and
38
- hooks run in this process as written: run it on code you'd install.
37
+ It installs and loads the bundle in a sandbox (temp HOME, config and state), not in yours, but its plugin
38
+ runs in this process as written: run it on code you'd install.
39
39
  --fix rewrite stale and placeholder sha256 lines in manifest.yml
40
40
  --set KEY=VALUE a setting for the load (config.yml bundles: <name>: KEY), repeatable
41
41
  Exit status: 0 no errors, 1 errors, 2 usage.
@@ -81,6 +81,7 @@ module Samagotchi
81
81
  # @return [Integer] exit status
82
82
  def run
83
83
  manifest = read_manifest
84
+ manifest = nil if manifest && hooks_retired?
84
85
  if manifest
85
86
  missing = check_files(manifest)
86
87
  check_source_tree(manifest, missing) unless @installed
@@ -91,6 +92,7 @@ module Samagotchi
91
92
  check_settings(manifest) unless @installed
92
93
  check_install(manifest) unless missing || placeholders? || manifest.session_settings_clash
93
94
  check_api(manifest)
95
+ check_fail_closed(manifest) unless @installed
94
96
  check_boundary(manifest)
95
97
  check_tests unless @installed
96
98
  end
@@ -135,12 +137,27 @@ module Samagotchi
135
137
  Dir.mktmpdir("chi-bundle-check-") do |tmp|
136
138
  File.write(File.join(tmp, "manifest.yml"), MemoryBundle::Shas.parseable(@text))
137
139
  MemoryBundle::Manifest.read(dir: tmp)
140
+ rescue MemoryBundle::Manifest::HooksRetired => e
141
+ add(:error, :"hooks-retired", "manifest.yml", e.message, line: manifest_line("hooks"), fix: hooks_fix)
142
+ nil
138
143
  rescue MemoryBundle::Manifest::ValidationError => e
139
144
  add(:error, :manifest, "manifest.yml", e.message.gsub(tmp, display(@dir)))
140
145
  nil
141
146
  end
142
147
  end
143
148
 
149
+ # A source with hooks/*.rb (listed or not) can't be installed: the run
150
+ # stops there, as after a manifest error (Installer#refuse_hooks).
151
+ def hooks_retired?
152
+ files = MemoryBundle::Manifest::HooksRetired.files_in(@dir)
153
+ return false if files.empty?
154
+
155
+ add(:error, :"hooks-retired", "hooks/", MemoryBundle::Manifest::HooksRetired.for_files(files).message, fix: hooks_fix)
156
+ true
157
+ end
158
+
159
+ def hooks_fix = "hook the events from plugin.rb with chi.on, then remove hooks/ and the manifest's hooks:"
160
+
144
161
  # The line of manifest.yml naming +file+ (its entry), or nil.
145
162
  def manifest_line(file)
146
163
  base = File.basename(file)
@@ -191,7 +208,7 @@ module Samagotchi
191
208
  # @return [Boolean] whether a listed file is missing
192
209
  def check_files(manifest)
193
210
  listed = manifest.files.keys + Array(manifest.plugin && manifest.plugin[:file]) +
194
- manifest.hooks.keys.map { |h| File.join("hooks", h) } + manifest.scripts.keys.map { |s| File.join("scripts", s) }
211
+ manifest.scripts.keys.map { |s| File.join("scripts", s) }
195
212
  missing = listed.reject { |rel| File.file?(path(rel)) }
196
213
  missing.each do |rel|
197
214
  add(:error, :"missing-file", "manifest.yml", "lists #{rel}, which isn't in the bundle", line: manifest_line(rel),
@@ -201,7 +218,7 @@ module Samagotchi
201
218
  end
202
219
 
203
220
  # What an install leaves out: a memory files: doesn't list (by
204
- # MemoryFiles' rules), a plugin, hook or script the manifest doesn't name.
221
+ # MemoryFiles' rules), a plugin or script the manifest doesn't name.
205
222
  def check_unlisted(manifest)
206
223
  memories = MemoryBundle::MemoryFiles.select(@dir, manifest)
207
224
  loose = Dir.glob(path("*.md")).map { |f| File.basename(f) }.sort.reject do |name|
@@ -210,9 +227,6 @@ module Samagotchi
210
227
  loose.each { |name| unlisted(name, "files:") }
211
228
  plugin = manifest.plugin && manifest.plugin[:file]
212
229
  unlisted("plugin.rb", "plugin:") if File.file?(path("plugin.rb")) && plugin != "plugin.rb"
213
- Dir.glob(path("hooks/*.rb")).sort.each do |file|
214
- unlisted("hooks/#{File.basename(file)}", "hooks:") unless manifest.hooks.key?(File.basename(file))
215
- end
216
230
  Dir.glob(path("scripts/*")).sort.each do |file|
217
231
  unlisted("scripts/#{File.basename(file)}", "scripts:") unless manifest.scripts.key?(File.basename(file))
218
232
  end
@@ -250,7 +264,7 @@ module Samagotchi
250
264
 
251
265
  # ── requires_chi ──────────────────────────────────────────────────
252
266
 
253
- def code?(manifest) = manifest.plugin || manifest.hooks.any? || manifest.scripts.any?
267
+ def code?(manifest) = manifest.plugin || manifest.scripts.any?
254
268
 
255
269
  def check_requires_chi(manifest)
256
270
  line = manifest_line("requires_chi")
@@ -413,7 +427,7 @@ module Samagotchi
413
427
  registered.tools.each { |name| add(:info, :registered, plugin, "tool #{name}") }
414
428
  registered.hooks.each do |event, hooks|
415
429
  hooks.each do |hook|
416
- file = manifest.hooks.key?(hook.hook) ? "hooks/#{hook.hook}" : plugin
430
+ file = plugin
417
431
  next add(:info, :registered, file, "hook #{event} (priority #{hook.priority})") if Hooks::EVENTS.include?(event)
418
432
 
419
433
  add(:warn, :"hook-event", file, "hook on #{event.inspect}, which chi never fires",
@@ -439,6 +453,23 @@ module Samagotchi
439
453
  end
440
454
  end
441
455
 
456
+ # A chi.on(..., on_error: :fail_closed) guard denies the call it raises
457
+ # on; only the manifest's plugin: required: true makes its failing to
458
+ # load deny every call (chi knows it before the plugin registers).
459
+ FAIL_CLOSED = /on_error:\s*(?::fail_closed\b|["']fail_closed["'])/
460
+
461
+ def check_fail_closed(manifest)
462
+ file = manifest.plugin && manifest.plugin[:file]
463
+ return if !file || manifest.plugin[:required] || !File.file?(path(file))
464
+
465
+ source = File.read(path(file))
466
+ return unless (index = source =~ FAIL_CLOSED)
467
+
468
+ add(:warn, :"fail-closed", file, "a fail-closed chi.on hook, but the manifest's plugin: has no required: true: " \
469
+ "if the plugin fails to load, tool calls still run",
470
+ line: source[0, index].count("\n") + 1, fix: "add required: true under plugin: in manifest.yml")
471
+ end
472
+
442
473
  def check_boundary(manifest)
443
474
  require_relative "../testing/boundary"
444
475
  known = shipped? ? Testing::Boundary::EXCEPTIONS.fetch(manifest.name, {}) : {}
@@ -59,8 +59,9 @@ module Samagotchi
59
59
 
60
60
  # What the child sends: an edited file, which the copy's own shas can't
61
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.
62
+ # value or none: InstalledManifest#key_problems), the class-file hooks
63
+ # the record still has (they don't load), and Check's warnings and
64
+ # errors, naming the installed bundle's folder rather than the copy's.
64
65
  def answer_of(bundle)
65
66
  edited = MemoryBundle::ShippedUpdate.edited_executables(bundle.name, bundle).map do |file|
66
67
  Check::Finding.new(level: :warn, code: :edited, file: file, line: nil, fix: "chi bundle upgrade #{bundle.name}, or reinstall it",
@@ -69,6 +70,7 @@ module Samagotchi
69
70
  edited += bundle.manifest.key_problems.values.map do |message|
70
71
  Check::Finding.new(level: :error, code: :manifest, file: "manifest.yml", line: nil, message: message, fix: nil)
71
72
  end
73
+ edited << retired_hooks(bundle) unless bundle.hooks.empty?
72
74
  edited = edited.map { |finding| { level: finding.level, text: finding.to_s } }
73
75
  installed_dir = MemoryBundle::Provenance.new(name: bundle.name).bundle_dir
74
76
  edited + MemoryBundle::InstalledCopy.with(bundle) do |dir|
@@ -80,6 +82,15 @@ module Samagotchi
80
82
  end
81
83
  private_class_method :answer_of
82
84
 
85
+ # A 0.157 record's class-file hooks: they no longer load, and only an
86
+ # upgrade from a source that has moved them (or an uninstall) clears it.
87
+ def retired_hooks(bundle)
88
+ Check::Finding.new(level: :warn, code: :"hooks-retired", file: "hooks/", line: nil,
89
+ message: MemoryBundle::Manifest::HooksRetired::WORDS,
90
+ fix: "chi bundle upgrade #{bundle.name} once its source hooks from plugin.rb, or chi bundle uninstall #{bundle.name}")
91
+ end
92
+ private_class_method :retired_hooks
93
+
83
94
  def died(why, prefix: true) = Found.new(level: "error", text: check_line(prefix ? "the check died: #{why}" : why))
84
95
  private_class_method :died
85
96
 
@@ -163,7 +163,10 @@ module Samagotchi
163
163
  reader.join(0.1) if reader.alive?
164
164
  reader.kill if reader.alive?
165
165
  end
166
- readers.map { |reader| reader.value.to_s }
166
+ # readpartial gives raw bytes: the child writes UTF-8 (the answer,
167
+ # the trace), so each stream is read as that, a bad byte as "?",
168
+ # here once, for the text and the --json report alike.
169
+ readers.map { |reader| reader.value.to_s.dup.force_encoding(Encoding::UTF_8).scrub("?") }
167
170
  end
168
171
 
169
172
  def close_streams(streams)
@@ -186,7 +186,7 @@ module Samagotchi
186
186
  end
187
187
 
188
188
  def leftover_workers
189
- sessions = File.join(state_home, Session::STATE_SUBDIR)
189
+ sessions = File.join(state_home, SessionPaths::STATE_SUBDIR)
190
190
  Dir.glob(File.join(sessions, "*", OwnerLock::FILE)).filter_map do |lock|
191
191
  pid = OwnerLock.owner(File.dirname(lock))&.pid
192
192
  pid if ProcessGroup.leader?(pid)
@@ -28,7 +28,7 @@ module Samagotchi
28
28
  def read(exit_code:, answer:, stderr:, sandbox:, timed_out: false)
29
29
  return empty_trace(exit_code, answer, stderr, sandbox, timed_out) unless @session_id
30
30
 
31
- sessions_dir = File.join(@state_home, Session::STATE_SUBDIR)
31
+ sessions_dir = File.join(@state_home, SessionPaths::STATE_SUBDIR)
32
32
  session_dir = Session.session_dir(@session_id, state_dir: sessions_dir)
33
33
  session = json_file(Session.session_file(@session_id, state_dir: sessions_dir)) || {}
34
34
  messages = Array(session["messages"]).select { |message| message.is_a?(Hash) }