samagotchi 0.155.0 → 0.157.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 (234) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -1
  3. data/bin/chi +2 -1
  4. data/docs/architecture.md +22 -13
  5. data/docs/bundles.md +3 -1
  6. data/docs/cli.md +9 -8
  7. data/docs/configuration.md +36 -4
  8. data/docs/guardrails.md +9 -5
  9. data/docs/hooks.md +11 -5
  10. data/docs/internals/anthropic.md +1 -1
  11. data/docs/internals/attached-context.md +3 -2
  12. data/docs/internals/background-tasks.md +1 -1
  13. data/docs/internals/bridge.md +83 -6
  14. data/docs/internals/bundles.md +28 -18
  15. data/docs/internals/context-telemetry.md +4 -3
  16. data/docs/internals/engine.md +641 -937
  17. data/docs/internals/llm-context-forget.md +8 -3
  18. data/docs/internals/objectives.md +2 -2
  19. data/docs/internals/participants.md +3 -2
  20. data/docs/internals/prompt-caching.md +4 -1
  21. data/docs/internals/remote-sessions.md +8 -3
  22. data/docs/internals/web-frontend.md +3 -2
  23. data/docs/internals/working-set.md +2 -2
  24. data/docs/memory.md +13 -2
  25. data/docs/plugins.md +35 -2
  26. data/docs/sessions.md +11 -11
  27. data/docs/sub-agent.md +17 -2
  28. data/docs/system-one.md +3 -2
  29. data/docs/testing.md +53 -2
  30. data/lib/samagotchi/answer_command.rb +2 -1
  31. data/lib/samagotchi/archive_store.rb +4 -3
  32. data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
  33. data/lib/samagotchi/bootstrap_command.rb +3 -2
  34. data/lib/samagotchi/bridge/card_places.rb +10 -4
  35. data/lib/samagotchi/bridge/card_store.rb +3 -0
  36. data/lib/samagotchi/bridge/turn_accumulator.rb +109 -9
  37. data/lib/samagotchi/bridge/turn_drawings.rb +265 -0
  38. data/lib/samagotchi/bridge.rb +31 -6
  39. data/lib/samagotchi/broadcast/recipients.rb +8 -8
  40. data/lib/samagotchi/broadcast/triage_model.rb +1 -1
  41. data/lib/samagotchi/broadcast_command.rb +3 -3
  42. data/lib/samagotchi/bundle_command/check.rb +18 -0
  43. data/lib/samagotchi/bundle_command/try/sandbox.rb +2 -2
  44. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  45. data/lib/samagotchi/bundle_command.rb +5 -2
  46. data/lib/samagotchi/bundles/known-names/manifest.yml +4 -7
  47. data/lib/samagotchi/bundles/known-names/{hooks/known_names.rb → plugin.rb} +17 -0
  48. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -7
  49. data/lib/samagotchi/bundles/source-links/{hooks/source_links.rb → plugin.rb} +23 -4
  50. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  51. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  52. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  53. data/lib/samagotchi/child_move.rb +13 -57
  54. data/lib/samagotchi/child_reports.rb +2 -2
  55. data/lib/samagotchi/children_status.rb +15 -21
  56. data/lib/samagotchi/cli/parent_wait.rb +9 -23
  57. data/lib/samagotchi/config.rb +20 -112
  58. data/lib/samagotchi/context_command.rb +1 -2
  59. data/lib/samagotchi/context_sources.rb +5 -4
  60. data/lib/samagotchi/context_window.rb +9 -12
  61. data/lib/samagotchi/desktop/macos.rb +1 -1
  62. data/lib/samagotchi/effective_model.rb +57 -22
  63. data/lib/samagotchi/engine/context_judge.rb +19 -11
  64. data/lib/samagotchi/engine/messages.rb +1 -1
  65. data/lib/samagotchi/engine/models.rb +5 -2
  66. data/lib/samagotchi/engine/objectives.rb +29 -11
  67. data/lib/samagotchi/engine/plugins.rb +1 -1
  68. data/lib/samagotchi/engine/recap.rb +8 -4
  69. data/lib/samagotchi/engine/side_requests.rb +21 -11
  70. data/lib/samagotchi/engine/toolkit.rb +10 -9
  71. data/lib/samagotchi/engine/turn_end.rb +1 -1
  72. data/lib/samagotchi/engine/turn_helpers.rb +7 -7
  73. data/lib/samagotchi/engine/verify.rb +1 -1
  74. data/lib/samagotchi/engine.rb +43 -12
  75. data/lib/samagotchi/events.rb +3 -2
  76. data/lib/samagotchi/extension_load.rb +6 -3
  77. data/lib/samagotchi/guardrails/targets.rb +24 -9
  78. data/lib/samagotchi/guardrails_command.rb +1 -1
  79. data/lib/samagotchi/host_address.rb +52 -0
  80. data/lib/samagotchi/host_config.rb +72 -1
  81. data/lib/samagotchi/host_model.rb +2 -6
  82. data/lib/samagotchi/host_registry.rb +58 -46
  83. data/lib/samagotchi/kernel_loop.rb +53 -124
  84. data/lib/samagotchi/llm/anthropic_wire.rb +2 -2
  85. data/lib/samagotchi/llm/chat_loop.rb +40 -21
  86. data/lib/samagotchi/llm/native_backend.rb +1 -1
  87. data/lib/samagotchi/llm_context_apply.rb +54 -1
  88. data/lib/samagotchi/llm_context_notice.rb +1 -1
  89. data/lib/samagotchi/llm_context_strategy.rb +29 -67
  90. data/lib/samagotchi/llm_context_view.rb +9 -0
  91. data/lib/samagotchi/memory_bundle/asset_installer.rb +71 -76
  92. data/lib/samagotchi/memory_bundle/builder.rb +2 -1
  93. data/lib/samagotchi/memory_bundle/file_actions.rb +193 -0
  94. data/lib/samagotchi/memory_bundle/file_digest.rb +13 -0
  95. data/lib/samagotchi/memory_bundle/file_state.rb +152 -0
  96. data/lib/samagotchi/memory_bundle/installed_copy.rb +2 -1
  97. data/lib/samagotchi/memory_bundle/installed_manifest.rb +8 -1
  98. data/lib/samagotchi/memory_bundle/installer.rb +185 -169
  99. data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
  100. data/lib/samagotchi/memory_bundle/merger.rb +69 -65
  101. data/lib/samagotchi/memory_bundle/plugin_ref.rb +6 -5
  102. data/lib/samagotchi/memory_bundle/profile.rb +5 -5
  103. data/lib/samagotchi/memory_bundle/provenance.rb +15 -1
  104. data/lib/samagotchi/memory_bundle/remerge.rb +25 -12
  105. data/lib/samagotchi/memory_bundle/shipped_update.rb +51 -6
  106. data/lib/samagotchi/memory_bundle/status.rb +14 -20
  107. data/lib/samagotchi/memory_bundle/system_bundle.rb +24 -6
  108. data/lib/samagotchi/memory_bundle/trash.rb +23 -8
  109. data/lib/samagotchi/memory_bundle/uninstaller.rb +16 -11
  110. data/lib/samagotchi/memory_usage.rb +1 -1
  111. data/lib/samagotchi/model_lookup.rb +75 -0
  112. data/lib/samagotchi/model_price.rb +4 -10
  113. data/lib/samagotchi/model_profile.rb +7 -6
  114. data/lib/samagotchi/model_settings.rb +37 -2
  115. data/lib/samagotchi/models_command.rb +1 -1
  116. data/lib/samagotchi/note_command.rb +2 -3
  117. data/lib/samagotchi/objectives_audit.rb +4 -3
  118. data/lib/samagotchi/participant_mode.rb +2 -1
  119. data/lib/samagotchi/participants.rb +2 -2
  120. data/lib/samagotchi/plugin/api.rb +41 -6
  121. data/lib/samagotchi/plugin/context.rb +1 -1
  122. data/lib/samagotchi/plugin/loader.rb +33 -10
  123. data/lib/samagotchi/plugin/sessions.rb +3 -6
  124. data/lib/samagotchi/plugin/tool_spec.rb +5 -3
  125. data/lib/samagotchi/project_env.rb +1 -1
  126. data/lib/samagotchi/reasoning_history.rb +14 -20
  127. data/lib/samagotchi/recap_place.rb +27 -0
  128. data/lib/samagotchi/reminder_carry.rb +1 -1
  129. data/lib/samagotchi/reply_wait/liveness.rb +38 -0
  130. data/lib/samagotchi/reply_wait/poll.rb +44 -15
  131. data/lib/samagotchi/reply_wait/target.rb +113 -0
  132. data/lib/samagotchi/reply_wait.rb +22 -2
  133. data/lib/samagotchi/reprefill_meter.rb +45 -0
  134. data/lib/samagotchi/review_command.rb +1 -1
  135. data/lib/samagotchi/sampling_settings.rb +9 -16
  136. data/lib/samagotchi/self_report.rb +36 -37
  137. data/lib/samagotchi/send_command.rb +17 -23
  138. data/lib/samagotchi/session.rb +370 -445
  139. data/lib/samagotchi/session_access/local.rb +12 -11
  140. data/lib/samagotchi/session_access/remote.rb +22 -6
  141. data/lib/samagotchi/session_archive_command.rb +2 -2
  142. data/lib/samagotchi/session_chain.rb +7 -6
  143. data/lib/samagotchi/session_continue.rb +6 -5
  144. data/lib/samagotchi/session_delete_command.rb +1 -1
  145. data/lib/samagotchi/session_field.rb +133 -0
  146. data/lib/samagotchi/session_inbox.rb +49 -1
  147. data/lib/samagotchi/session_manager/delivery.rb +254 -0
  148. data/lib/samagotchi/session_manager/emptiness.rb +139 -0
  149. data/lib/samagotchi/session_manager/lifecycle.rb +274 -0
  150. data/lib/samagotchi/session_manager/listing.rb +118 -0
  151. data/lib/samagotchi/session_manager/removal.rb +190 -0
  152. data/lib/samagotchi/session_manager/spawn.rb +242 -0
  153. data/lib/samagotchi/session_manager/worker_command.rb +157 -0
  154. data/lib/samagotchi/session_manager.rb +28 -1541
  155. data/lib/samagotchi/session_metrics.rb +8 -5
  156. data/lib/samagotchi/session_paths.rb +57 -0
  157. data/lib/samagotchi/session_retention.rb +2 -2
  158. data/lib/samagotchi/session_row.rb +200 -0
  159. data/lib/samagotchi/session_setup.rb +15 -12
  160. data/lib/samagotchi/sessions_command.rb +11 -129
  161. data/lib/samagotchi/share_command.rb +1 -1
  162. data/lib/samagotchi/starting_marker.rb +65 -0
  163. data/lib/samagotchi/steer_cut_after.rb +7 -14
  164. data/lib/samagotchi/system_one/recorder.rb +49 -0
  165. data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -26
  166. data/lib/samagotchi/terminal_ui/exit_lines.rb +1 -1
  167. data/lib/samagotchi/terminal_ui/join_replay.rb +86 -8
  168. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  169. data/lib/samagotchi/terminal_ui.rb +5 -7
  170. data/lib/samagotchi/testing/install.rb +2 -1
  171. data/lib/samagotchi/testing/sessions.rb +1 -1
  172. data/lib/samagotchi/thinking.rb +16 -21
  173. data/lib/samagotchi/token_usage.rb +1 -1
  174. data/lib/samagotchi/tool_activity.rb +10 -4
  175. data/lib/samagotchi/tool_declarations.rb +2 -2
  176. data/lib/samagotchi/tool_output_cut.rb +17 -0
  177. data/lib/samagotchi/tool_response.rb +5 -3
  178. data/lib/samagotchi/tool_runner.rb +25 -5
  179. data/lib/samagotchi/toolkit_command.rb +1 -1
  180. data/lib/samagotchi/tools/delegate.rb +55 -45
  181. data/lib/samagotchi/tools/delegate_relay.rb +5 -5
  182. data/lib/samagotchi/tools/delegate_result.rb +5 -5
  183. data/lib/samagotchi/tools/delegate_wait.rb +41 -17
  184. data/lib/samagotchi/tools/index_hint.rb +14 -0
  185. data/lib/samagotchi/tools/list_sessions.rb +7 -7
  186. data/lib/samagotchi/tools/registry.rb +28 -11
  187. data/lib/samagotchi/tools/send_note.rb +4 -6
  188. data/lib/samagotchi/tools/tool_index.rb +52 -51
  189. data/lib/samagotchi/tools/tool_surface.rb +22 -8
  190. data/lib/samagotchi/turn_context.rb +2 -1
  191. data/lib/samagotchi/turn_stages/context_judge.rb +38 -36
  192. data/lib/samagotchi/turn_stages/objectives.rb +56 -52
  193. data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +13 -17
  194. data/lib/samagotchi/turn_stages/verify_anchors.rb +9 -10
  195. data/lib/samagotchi/turn_stages/warmup.rb +14 -15
  196. data/lib/samagotchi/update_command.rb +11 -7
  197. data/lib/samagotchi/verify_fresh.rb +7 -11
  198. data/lib/samagotchi/version.rb +1 -1
  199. data/lib/samagotchi/vision_support.rb +11 -14
  200. data/lib/samagotchi/web/app.rb +45 -28
  201. data/lib/samagotchi/web/message_display.rb +57 -7
  202. data/lib/samagotchi/web/participant_access.rb +3 -3
  203. data/lib/samagotchi/web/public/activity.js +16 -7
  204. data/lib/samagotchi/web/public/app.js +29 -8
  205. data/lib/samagotchi/web/public/bubbles.js +8 -3
  206. data/lib/samagotchi/web/public/cards_place.js +15 -0
  207. data/lib/samagotchi/web/public/commands.js +5 -1
  208. data/lib/samagotchi/web/public/data.js +4 -2
  209. data/lib/samagotchi/web/public/history_shape.js +220 -0
  210. data/lib/samagotchi/web/public/recap_stamp.js +17 -0
  211. data/lib/samagotchi/web/public/reload_check.js +71 -0
  212. data/lib/samagotchi/web/public/stage_model.js +7 -5
  213. data/lib/samagotchi/web/public/stage_view.js +7 -3
  214. data/lib/samagotchi/web/public/stream_handlers.js +8 -2
  215. data/lib/samagotchi/web/public/timing.js +43 -2
  216. data/lib/samagotchi/web/public/turn_block.js +215 -30
  217. data/lib/samagotchi/web/public/turn_drawing.js +43 -0
  218. data/lib/samagotchi/web/public/turn_events.js +10 -1
  219. data/lib/samagotchi/web/public/turn_pieces.js +29 -0
  220. data/lib/samagotchi/web/reload_check_route.rb +95 -0
  221. data/lib/samagotchi/web/server.rb +4 -2
  222. data/lib/samagotchi/web/session_access_routes.rb +6 -6
  223. data/lib/samagotchi/web/session_control.rb +4 -4
  224. data/lib/samagotchi/web/session_create.rb +3 -3
  225. data/lib/samagotchi/web/session_hub.rb +9 -17
  226. data/lib/samagotchi/web/session_show.rb +67 -5
  227. data/lib/samagotchi/web/session_summary.rb +39 -28
  228. data/lib/samagotchi/worker.rb +83 -32
  229. data/lib/samagotchi/worker_boot.rb +184 -0
  230. data/lib/samagotchi/worker_link.rb +3 -0
  231. data/lib/samagotchi/worker_signals.rb +1 -1
  232. data/lib/samagotchi/worker_task_wakes.rb +2 -2
  233. metadata +32 -4
  234. data/lib/samagotchi/send_command/wait_target.rb +0 -85
@@ -0,0 +1,265 @@
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)
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
+
66
+ new(v: raw["v"], turn_id: raw["turn_id"], kind: raw["kind"].to_s, from: raw["from"], to: raw["to"],
67
+ from_digest: raw["from_digest"], to_digest: raw["to_digest"], drawing: raw["drawing"], end: raw["end"])
68
+ end
69
+
70
+ def to_h = super.transform_keys(&:to_s)
71
+
72
+ def range = (from...to)
73
+ end
74
+
75
+ # @param path [String, nil] the file (nil: nothing is written)
76
+ # @param messages [#call] the session's messages now (the Engine's,
77
+ # as CardStore's): the end step replaced them before the end event, so
78
+ # they hold the turn's range when #append runs
79
+ def initialize(path:, messages:)
80
+ @path = path
81
+ @messages = messages
82
+ @last = path ? self.class.tail_line(path) : nil
83
+ end
84
+
85
+ # The newest line this store wrote or started from (P3's snapshot).
86
+ # @return [Line, nil]
87
+ attr_reader :last
88
+
89
+ # The accumulator's hook: the turn's line, when its end event names a
90
+ # range. Never raises (a turn that can't be drawn still ended).
91
+ # @param turn [Hash] the accumulator's final fold (symbol keys)
92
+ # @param end_event [Hash] the end event
93
+ def append(turn, end_event)
94
+ line = line_for(turn, end_event)
95
+ return unless line
96
+
97
+ write(line)
98
+ @last = line
99
+ rescue StandardError => e
100
+ Log.warn(:bridge, "turn_drawing_failed", error: "#{e.class}: #{e.message}")
101
+ end
102
+
103
+ def line_for(turn, end_event)
104
+ from = end_event[:kept_from]
105
+ to = end_event[:kept_to]
106
+ return nil unless @path && turn && from.is_a?(Integer) && to.is_a?(Integer) && from < to && from >= 0
107
+
108
+ messages = @messages.call || []
109
+ return nil if messages.size < to
110
+
111
+ id = end_event[:turn_id] || turn[:turn_id]
112
+ return nil unless id
113
+
114
+ 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,
115
+ from_digest: self.class.digest(messages[from]), to_digest: self.class.digest(messages[to - 1]),
116
+ drawing: JSON.parse(JSON.generate(scrubbed(turn.slice(*DRAWING_KEYS)))),
117
+ end: JSON.parse(JSON.generate(scrubbed(self.class.end_of(end_event)))))
118
+ end
119
+ private :line_for
120
+
121
+ # Newline-first: a torn last line (a crash mid-write) ends before this
122
+ # one starts, so both parse apart; then one write.
123
+ def write(line)
124
+ FileUtils.mkdir_p(File.dirname(@path))
125
+ File.open(@path, File::RDWR | File::CREAT | File::APPEND, 0o600) do |file|
126
+ size = file.size
127
+ torn = size.positive? && (file.pread(1, size - 1) != "\n")
128
+ file.write("#{"\n" if torn}#{JSON.generate(line.to_h)}\n")
129
+ end
130
+ end
131
+ private :write
132
+
133
+ def scrubbed(value) = self.class.scrub(value)
134
+ private :scrubbed
135
+
136
+ class << self
137
+ # The end event's display keys (END_KEYS), with an empty answer's
138
+ # notice from its summary.
139
+ # @return [Hash]
140
+ def end_of(event)
141
+ out = event.slice(*END_KEYS).compact
142
+ out[:type] = out[:type].to_s if out[:type]
143
+ empty = event.dig(:turn_summary, :empty_answer)
144
+ out[:turn_summary] = { empty_answer: empty } if empty
145
+ out
146
+ end
147
+
148
+ # A message's digest in one form for the live (symbol keys, raw
149
+ # strings) and the saved (string keys, scrubbed as Session#to_h does)
150
+ # messages: role, content and time, each read from either key.
151
+ # @return [String]
152
+ def digest(message)
153
+ return "" unless message.is_a?(Hash)
154
+
155
+ role = fetch(message, :role)
156
+ time = fetch(message, MessageTime::KEY)
157
+ Digest::SHA1.hexdigest(JSON.generate(scrub([role&.to_s, fetch(message, :content), time&.to_s])))
158
+ end
159
+
160
+ # Every String in +value+ as Session#to_h saves it (UTF-8, invalid
161
+ # bytes as "?"); symbol keys stay symbols (JSON writes them alike).
162
+ def scrub(value)
163
+ case value
164
+ when String
165
+ text = value.encoding == Encoding::UTF_8 ? value : value.dup.force_encoding(Encoding::UTF_8)
166
+ text.valid_encoding? ? text : text.scrub("?")
167
+ when Array then value.map { |item| scrub(item) }
168
+ when Hash then value.to_h { |key, item| [key, scrub(item)] }
169
+ when Symbol then value.to_s
170
+ else value
171
+ end
172
+ end
173
+
174
+ # The session folder's lines, oldest first (unreadable, torn and
175
+ # unknown lines skipped), cached by the file's path, size and mtime
176
+ # (chi web reads it on each full show).
177
+ # @return [Array<Line>]
178
+ def read(session_dir)
179
+ path = File.join(session_dir, FILE)
180
+ stat = File.stat(path)
181
+ key = [stat.size, stat.mtime.to_r]
182
+ cached = cache_mutex.synchronize { cache[path] }
183
+ return cached[1] if cached && cached[0] == key
184
+
185
+ lines = parse(File.binread(path))
186
+ cache_mutex.synchronize do
187
+ cache.shift while cache.size >= CACHE_SIZE
188
+ cache[path] = [key, lines]
189
+ end
190
+ lines
191
+ rescue SystemCallError, IOError
192
+ []
193
+ end
194
+
195
+ # @return [Array<Line>]
196
+ def parse(text)
197
+ text.dup.force_encoding(Encoding::UTF_8).scrub("?").each_line.filter_map do |raw|
198
+ Line.from_h(JSON.parse(raw))
199
+ rescue JSON::ParserError
200
+ nil
201
+ end
202
+ end
203
+
204
+ # The lines that hold against +messages+ (Session messages, symbol
205
+ # or string keys): each one's range still in them with both ends the
206
+ # same; of overlapping ranges and of one turn's lines the newest
207
+ # (last in the file) wins. Ordered by their range.
208
+ # @return [Array<Line>]
209
+ def valid(lines, messages)
210
+ messages = Array(messages)
211
+ kept = []
212
+ lines.reverse_each do |line|
213
+ next unless holds?(line, messages)
214
+ next if kept.any? { |other| other.turn_id == line.turn_id || overlap?(other, line) }
215
+
216
+ kept << line
217
+ end
218
+ kept.sort_by(&:from)
219
+ end
220
+
221
+ # The last parseable line of +path+, or nil.
222
+ # @return [Line, nil]
223
+ def tail_line(path)
224
+ return nil unless File.file?(path)
225
+
226
+ File.open(path, "rb") do |file|
227
+ size = file.size
228
+ chunk = file.pread([size, TAIL_BYTES].min, [size - TAIL_BYTES, 0].max)
229
+ parse(chunk.to_s).last
230
+ end
231
+ rescue SystemCallError, IOError
232
+ nil
233
+ end
234
+
235
+ # Whether +line+ is small enough to send in a snapshot: within what
236
+ # #tail_line reads back after a restart (TAIL_BYTES), so a join draws
237
+ # a turn the same before and after one (a bigger line: rebuilt).
238
+ def sendable?(line) = JSON.generate(line.to_h).bytesize <= TAIL_BYTES
239
+
240
+ def holds?(line, messages)
241
+ messages.size >= line.to && digest(messages[line.from]) == line.from_digest &&
242
+ digest(messages[line.to - 1]) == line.to_digest
243
+ end
244
+
245
+ def overlap?(one, other) = one.from < other.to && other.from < one.to
246
+
247
+ private
248
+
249
+ def fetch(message, key)
250
+ message.key?(key) ? message[key] : message[key.to_s]
251
+ end
252
+
253
+ def cache = (@cache ||= {})
254
+ def cache_mutex = (@cache_mutex ||= Mutex.new)
255
+ end
256
+
257
+ # Sessions whose parsed file chi web keeps (a long one's is tens of MB
258
+ # of objects; the mtime moves at every turn end anyway).
259
+ CACHE_SIZE = 8
260
+ # Read from the end at start (#last): a drawing is a few KB, its outputs cut.
261
+ TAIL_BYTES = 1024 * 1024
262
+ private_constant :CACHE_SIZE, :TAIL_BYTES
263
+ end
264
+ end
265
+ 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)
@@ -348,11 +348,11 @@ module Samagotchi
348
348
  # The full id +given+ names (an id or the start of one), or nil after
349
349
  # saying why there is none.
350
350
  def resolve_session(given)
351
- id = Session.resolve_id(given, state_dir: @state_dir)
352
- Session.load(id, state_dir: @state_dir) # raises ArgumentError when there is no such session
351
+ id = Session.resolve!(given, state_dir: @state_dir)
352
+ Session.load(id, state_dir: @state_dir) # a file that won't load is refused here too
353
353
  id
354
354
  rescue ArgumentError => e
355
- error_line("chi broadcast: #{e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"}")
355
+ error_line("chi broadcast: #{e.message}")
356
356
  nil
357
357
  rescue SystemCallError => e
358
358
  error_line("chi broadcast: session #{given} can't be read: #{e.message}")
@@ -91,6 +91,7 @@ module Samagotchi
91
91
  check_settings(manifest) unless @installed
92
92
  check_install(manifest) unless missing || placeholders? || manifest.session_settings_clash
93
93
  check_api(manifest)
94
+ check_fail_closed(manifest) unless @installed
94
95
  check_boundary(manifest)
95
96
  check_tests unless @installed
96
97
  end
@@ -439,6 +440,23 @@ module Samagotchi
439
440
  end
440
441
  end
441
442
 
443
+ # A chi.on(..., on_error: :fail_closed) guard denies the call it raises
444
+ # on; only the manifest's plugin: required: true makes its failing to
445
+ # load deny every call (chi knows it before the plugin registers).
446
+ FAIL_CLOSED = /on_error:\s*(?::fail_closed\b|["']fail_closed["'])/
447
+
448
+ def check_fail_closed(manifest)
449
+ file = manifest.plugin && manifest.plugin[:file]
450
+ return if !file || manifest.plugin[:required] || !File.file?(path(file))
451
+
452
+ source = File.read(path(file))
453
+ return unless (index = source =~ FAIL_CLOSED)
454
+
455
+ add(:warn, :"fail-closed", file, "a fail-closed chi.on hook, but the manifest's plugin: has no required: true: " \
456
+ "if the plugin fails to load, tool calls still run",
457
+ line: source[0, index].count("\n") + 1, fix: "add required: true under plugin: in manifest.yml")
458
+ end
459
+
442
460
  def check_boundary(manifest)
443
461
  require_relative "../testing/boundary"
444
462
  known = shipped? ? Testing::Boundary::EXCEPTIONS.fetch(manifest.name, {}) : {}
@@ -130,7 +130,7 @@ module Samagotchi
130
130
 
131
131
  # Stops what the run started that outlives its child chi: the
132
132
  # workers of its delegates (they leave the child's process tree,
133
- # SessionManager.detach_from_spawner) and its tasks (a group of
133
+ # WorkerBoot.detach_from_spawner) and its tasks (a group of
134
134
  # their own each, reparented once the child exits). Found by their
135
135
  # records in the sandbox, so nothing outside it: a session's lock
136
136
  # held by a group leader, a running task's record whose pid still
@@ -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) }
@@ -639,9 +639,12 @@ module Samagotchi
639
639
  0
640
640
  end
641
641
 
642
+ # The base and the file; a marked one also the bundle's text it stays
643
+ # behind (its source-less FileState says which).
642
644
  def diff_memory(prov, target_dir, key)
643
- print_base_and_current(key, prov.base_path(key), File.join(target_dir, key))
644
- print_incoming(prov, key)
645
+ state = Samagotchi::MemoryBundle::FileState.of(key, record: prov.record, provenance: prov, target_dir: target_dir, source_dir: nil)
646
+ print_base_and_current(key, state.paths[:base], state.paths[:current])
647
+ print_incoming(prov, key) if state.marked?
645
648
  end
646
649
 
647
650
  # The bundle's text a file stays behind (Provenance#incoming_path), so the
@@ -1,17 +1,14 @@
1
1
  ---
2
2
  name: known-names
3
- version: 0.1.9
3
+ version: 0.2.0
4
4
  requires_chi: ">= 0.150.0"
5
5
  scope: system
6
6
  description: Catches a near-miss of a protected name (home folder, login, git name, repo name, names from config) in a tool call and rejects it with the right spelling; mode ask / correct optional
7
7
  trust_level: reviewed
8
8
  files:
9
9
  known_names.md: sha256:d5348b95b6c655017b541910b36e384220da280818160201f6ec9a50e1e4287c
10
- hooks:
11
- known_names.rb:
12
- sha256: sha256:d7eed0dd0f418d8099a147ed124c6e6062b8c752035dd6b5aa5e926e29941c8b
13
- event: before_tool_call
14
- on_error: log
15
- priority: 50
10
+ plugin:
11
+ file: plugin.rb
12
+ sha256: sha256:d140612e6c34a9d2d205bf818a2a0797ad1e133829481f354aaa9e56698017e3
16
13
  # config.yml bundles: known-names: (another key warns; an older chi ignores this)
17
14
  settings: [names, ignore, mode, derive, min_length, max_distance]
@@ -243,3 +243,20 @@ class KnownNames
243
243
  Array(value).map { |name| name.to_s.strip }.reject(&:empty?)
244
244
  end
245
245
  end
246
+
247
+ # What chi loads: one KnownNames, built once from config.yml
248
+ # `bundles: known-names:` when the plugin loads, on before_tool_call at
249
+ # priority 50. A raise in the handler is logged and the call goes on
250
+ # (chi.on's log policy); KnownNames#call turns its own errors into a
251
+ # notice first.
252
+ class Plugin
253
+ PRIORITY = 50
254
+
255
+ def initialize(settings = {})
256
+ @names = KnownNames.new(settings)
257
+ end
258
+
259
+ def register(chi)
260
+ chi.on(:before_tool_call, priority: PRIORITY) { |event, _ctx| @names.call(event) }
261
+ end
262
+ end
@@ -1,17 +1,14 @@
1
1
  ---
2
2
  name: source-links
3
- version: 0.6.3
3
+ version: 0.7.0
4
4
  scope: system
5
5
  description: Links source refs (JIRA tickets, GitHub issues, …) in the model's answer — inline links in the web, refs in a waiting question's reply, and a one-line note after the turn (note false turns it off); sources are configured patterns
6
6
  trust_level: reviewed
7
7
  requires_chi: ">= 0.150.0"
8
8
  files:
9
9
  source_links.md: sha256:ace01a0f7c308a0ae54ece714c31c0e6d7a49c372d98111030e6d268320bed3a
10
- hooks:
11
- source_links.rb:
12
- sha256: sha256:21d7a9f7c381c653b126b625feed0e0b6422eaa84c8dfe716b6c5b41644a33b3
13
- events: [after_turn, question_asked]
14
- on_error: log
15
- priority: 90
10
+ plugin:
11
+ file: plugin.rb
12
+ sha256: sha256:b4a777d8b6135f80e248dd21b5370c91dfcf2aea17057734e77241eb9cc4afe4
16
13
  # config.yml bundles: source-links: (another key warns; an older chi ignores this)
17
14
  settings: [sources, max, note]
@@ -59,10 +59,10 @@ require "samagotchi/ref"
59
59
  # action, "Open in <name>", the ref's URL. A URL that isn't http(s) stays a
60
60
  # markdown link only. The web's ref menu shows the action.
61
61
  #
62
- # The hook is on :question_asked too (the manifest's events:, chi newer than
63
- # 0.97.0): while a question of the model's (or an approval of its call)
64
- # waits, the refs in the reply that asks it, and in the question's text and
65
- # options, are refs on that reply, the same as an answer's. The web pairs
62
+ # The hook is on :question_asked too (Plugin below hooks both events): while
63
+ # a question of the model's (or an approval of its call) waits, the refs in
64
+ # the reply that asks it, and in the question's text and options, are refs
65
+ # on that reply, the same as an answer's. The web pairs
66
66
  # them in the stage's asking slot, on the question's card and on that step
67
67
  # in the history (a ref only in the question's text pairs there alone). No
68
68
  # links in the text and no note line for it: the turn's end has those.
@@ -657,3 +657,22 @@ class SourceLinks
657
657
  value.to_s.gsub(/[^A-Za-z0-9\-._~]/) { |c| c.bytes.map { |b| format("%%%02X", b) }.join }
658
658
  end
659
659
  end
660
+
661
+ # What chi loads: one SourceLinks, built once from config.yml
662
+ # `bundles: source-links:` when the plugin loads, on after_turn and
663
+ # question_asked at priority 90 (its state, the remotes it asked git for,
664
+ # is shared). A raise in the handler is logged and the turn goes on
665
+ # (chi.on's log policy).
666
+ class Plugin
667
+ PRIORITY = 90
668
+
669
+ def initialize(settings = {})
670
+ @links = SourceLinks.new(settings)
671
+ end
672
+
673
+ def register(chi)
674
+ %i[after_turn question_asked].each do |event|
675
+ chi.on(event, priority: PRIORITY) { |payload, _ctx| @links.call(payload) }
676
+ end
677
+ end
678
+ end
@@ -55,8 +55,8 @@ Only the nested form is read. An env name used as a top-level key (`SAMAGOTCHI_D
55
55
  **Excluded maps** (YAML-only, not part of the flat registry; skipped by scalar loader):
56
56
 
57
57
  - `model_aliases:` map of alias → model id (`ModelRef.parse`, through `HostRegistry#resolve`). Keys lowercased on write (`ConfigFile.write_model_alias!`). Values may be bare `model` or qualified `host:model` (hybrid; only `:` names a host, `org/model` is an id). Aliases apply once: never point an alias at another alias.
58
- - `hosts:` map of `name → {host, port | url, api, api_key_env, auth, dialect, profile, first_token_timeout, vision, sampling, thinking, enabled, models}` (`ConfigFile.hosts_config`, `host_registry.rb` `HostEntry`). Names lowercased; `url:` (http/https, optional path) replaces host/port, never both; `api_key_env:` names the env var holding the API key (never write a key into config.yml); `auth:` (`bearer|x-api-key`) is the header the key goes in (default `x-api-key` on `api: anthropic`, Bearer elsewhere); `dialect:` (`fireworks`, or `standard` for the default; `api: anthropic` only) adjusts the Messages wire for that provider (Bearer by default, no thinking `display`, no `cache_control`, `x-session-affinity`; `LLM::Dialect`, docs/configuration.md "Anthropic Messages hosts"); `first_token_timeout` (seconds, `0` = off; a negative or non-number warns and is ignored) overrides `server.first_token_timeout` for that host; `models:` (a map of model ids, or a plain list) declares ids the host serves whatever its `/v1/models` lists: no "doesn't list" warning, and a bare id routes there as if listed (`HostModel`); an entry may hold `price: {input, output, cache_read?, cache_write?}` in USD per 1M tokens (`ModelPrice`; an unset cache rate is the input rate), which estimates a cost the provider doesn't report or reports as 0 (`cost_estimate`, shown as `~$`), and `served: [ids] | any`, the models a gateway may answer that id with (no served-model warning for them); model settings stay in the root `models:`; a worker reads `hosts:` from the file as it starts (no environment variable sets hosts).
59
- - `models:` map of model id or alias → `{profile, vision, sampling, thinking, reasoning_history}` (`ConfigFile.model_settings`). `thinking:` (here, on a host, or `thinking.level` for every model) is `off|low|medium|high|default` (`Thinking`; unquoted `off` works, `on` is not a level; `default` = send nothing). Precedence: the session's own level (`/thinking LEVEL`, saved in the session; `chi --thinking` on a session run, `chi send --new --thinking`) > `chi web --thinking`/`SAMAGOTCHI_THINKING_LEVEL` > `models:` > `hosts.<name>.thinking` > `thinking.level` in config.yml > `default`. A `sampling:` key wins over the fields a level sends (`null` drops one); `docs/configuration.md` "Thinking". `reasoning_history:` (here or on a host) is `drop|turn|all`, which of its reasoning a chat host's model gets back. Precedence: the session's own level (`/reasoning-history LEVEL`, saved in the session) > `SAMAGOTCHI_REASONING_HISTORY` > `models:` > `hosts.<name>.reasoning_history` > `turn`; `docs/configuration.md` "Reasoning history". `/thinking` and `/reasoning-history` ask before a change that restarts the prompt cache; `--yes` applies it (`chi send` commands apply directly). `sampling:` (here or on a host) is a map of request fields passed to the provider as written (`temperature`, `top_p`, `presence_penalty`, `repeat_penalty`, …; a model's fields win over its host's per field; `null` = don't send; chi's own fields like `max_tokens`/`stream` are refused with a warning; `docs/configuration.md` "Sampling"). Keys match case-insensitively. `profile` (here or on a host) is `qwen36|gemma4`: the raw prompt format for native hosts. Precedence: `--profile`/`SAMAGOTCHI_MODEL_PROFILE` > `models:` > `hosts.<name>.profile` > the llama.cpp server's chat template > the name (`qwen`/`gemma`) > `qwen36` (`ModelProfile.resolve`). Set one when a model's name hides its family (e.g. a Qwen fine-tune under another name whose server publishes no template).
58
+ - `hosts:` map of `name → {host, port | url, api, api_key_env, auth, dialect, profile, first_token_timeout, vision, sampling, thinking, enabled, models}` (`HostConfig.all`, `host_registry.rb` `HostEntry`). Names lowercased; `url:` (http/https, optional path) replaces host/port, never both; `api_key_env:` names the env var holding the API key (never write a key into config.yml); `auth:` (`bearer|x-api-key`) is the header the key goes in (default `x-api-key` on `api: anthropic`, Bearer elsewhere); `dialect:` (`fireworks`, or `standard` for the default; `api: anthropic` only) adjusts the Messages wire for that provider (Bearer by default, no thinking `display`, no `cache_control`, `x-session-affinity`; `LLM::Dialect`, docs/configuration.md "Anthropic Messages hosts"); `first_token_timeout` (seconds, `0` = off; a negative or non-number warns and is ignored) overrides `server.first_token_timeout` for that host; `models:` (a map of model ids, or a plain list) declares ids the host serves whatever its `/v1/models` lists: no "doesn't list" warning, and a bare id routes there as if listed (`HostModel`); an entry may hold `price: {input, output, cache_read?, cache_write?}` in USD per 1M tokens (`ModelPrice`; an unset cache rate is the input rate), which estimates a cost the provider doesn't report or reports as 0 (`cost_estimate`, shown as `~$`), and `served: [ids] | any`, the models a gateway may answer that id with (no served-model warning for them); model settings stay in the root `models:`; a worker reads `hosts:` from the file as it starts (no environment variable sets hosts).
59
+ - `models:` map of model id or alias → `{profile, vision, sampling, thinking, reasoning_history}` (`ModelSettings.all`). `thinking:` (here, on a host, or `thinking.level` for every model) is `off|low|medium|high|default` (`Thinking`; unquoted `off` works, `on` is not a level; `default` = send nothing). Precedence: the session's own level (`/thinking LEVEL`, saved in the session; `chi --thinking` on a session run, `chi send --new --thinking`) > `chi web --thinking`/`SAMAGOTCHI_THINKING_LEVEL` > `models:` > `hosts.<name>.thinking` > `thinking.level` in config.yml > `default`. A `sampling:` key wins over the fields a level sends (`null` drops one); `docs/configuration.md` "Thinking". `reasoning_history:` (here or on a host) is `drop|turn|all`, which of its reasoning a chat host's model gets back. Precedence: the session's own level (`/reasoning-history LEVEL`, saved in the session) > `SAMAGOTCHI_REASONING_HISTORY` > `models:` > `hosts.<name>.reasoning_history` > `turn`; `docs/configuration.md` "Reasoning history". `/thinking` and `/reasoning-history` ask before a change that restarts the prompt cache; `--yes` applies it (`chi send` commands apply directly). `sampling:` (here or on a host) is a map of request fields passed to the provider as written (`temperature`, `top_p`, `presence_penalty`, `repeat_penalty`, …; a model's fields win over its host's per field; `null` = don't send; chi's own fields like `max_tokens`/`stream` are refused with a warning; `docs/configuration.md` "Sampling"). Keys match case-insensitively. `profile` (here or on a host) is `qwen36|gemma4`: the raw prompt format for native hosts. Precedence: `--profile`/`SAMAGOTCHI_MODEL_PROFILE` > `models:` > `hosts.<name>.profile` > the llama.cpp server's chat template > the name (`qwen`/`gemma`) > `qwen36` (`ModelProfile.resolve`). Set one when a model's name hides its family (e.g. a Qwen fine-tune under another name whose server publishes no template).
60
60
  - `hooks:` map of `hooks_dir` + per-event lists `{path, on_error}` (`Hooks::Loader.load`). `hooks_dir` may start with `~`.
61
61
  - `guardrails:` tool-call rules (`docs/guardrails.md`; read in `Engine#guardrail_rules`, parsed by `Guardrails::Rules.parse`): `enabled` (bool, default true; `false` drops rules and hooks' asks, a deny still applies; config.yml only, like every `guardrails` setting: no env variable), `parent_answers` (`soft`, the default: a parent agent's `chi answer` may deny, answer, Continue and give "Allow once"; `standard`: no allow; `hard`: deny, Stop and plain answers only; never a wider scope; replaces `parent_approvals` and `turn.parent_continue`; a convention for an honest parent, not a security boundary, and the user's choice: set it only when the user asks), `rules:` (list), `disable:` (a rule id or a list of them, `id` or `bundle:id`, switching off a bundle's or config rule without editing it), `small_models` (`auto`, the default: small = 32B or less read from the name; a glob or list of globs; `[]` for none). A rule takes only `id`, `tool`, `command`, `path`, `git`, `models`, `verdict`, `reason`, `scopes`: `id` required; at least one of `tool` (a name, `shell` = execute + task_create, a `File.fnmatch` glob like `"mcp_*"`, or a list), `command` (a Ruby regex on the shell command), `path` (a glob, or `outside_repo`: outside the session's repo, tmp folders and memory files excepted), `git` (only `outside_repo`: a shell call runs commit/add/reset/… in another checkout); `models` (optional: `small`, a model-name glob, or a list; without it the rule is for every model); `verdict` `ask|deny`; `scopes` (for `ask`) a subset of `once, session, repo, rule`. **Any parse error (an unknown key, a bad regex, no verdict) makes chi deny every tool call** until fixed, so validate with `YAML.safe_load` and keep the list shape. Rules are read again on the next tool call after config.yml or a bundle's rules change: no restart needed. Installed bundles' rule files (`chi bundle install guardrails`) add to them; `/guardrails` lists what loaded.
62
62
 
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.155.0
3
+ version: 0.157.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
8
  self_map.md: sha256:709bd52710bf6c50334a89a10742ea1554c5985cc2d7c4e7571e526cc4479526
9
- config_modification_protocol.md: sha256:f3f6545c93d59b624a2bf1766e26059520ffc7b236470dd9f1b51ab77312376d
10
- memory_guide.md: sha256:8b7727b939b9c04486ca59859561023dcc319a73e0f5b47b3fcf5741edaa69e2
9
+ config_modification_protocol.md: sha256:25ed2d4901f00eeea226db5eb2b75a05d6cdee4015ac8daa4bed624f745c3922
10
+ memory_guide.md: sha256:fe4dbb25ce4c9f9b51507a6130fa7bf992a2cc41af2453830eacd27c09a65d1e
11
11
  delegated.md: sha256:a0d6ddcd4a8be06526d964898611775fd251cfd9d145a9d16afaccaa503597bd