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
data/docs/sub-agent.md CHANGED
@@ -81,8 +81,15 @@ With `--format json` stdout is one JSON object (see
81
81
  when the wait is about a known turn, it includes `turn_id`. Use
82
82
  `chi send --wait --format json --turn <turn_id> <session_id>` to wait for
83
83
  that exact turn again after exit 4, or after exit 3 if someone else will
84
- answer the question. A turn id can name the running turn, a queued turn or
85
- the last turn that ended; any other id is an error.
84
+ answer the question. A turn id can name the running turn, a queued turn,
85
+ the last turn that ended, or a message a running turn merged (waited for as
86
+ that turn); any other id is an error. A turn nobody typed (a
87
+ reminder, an attached context change, a finished task, a delegate report: a
88
+ wake turn) has an id its worker made, `wake-` and 16 hex digits, so
89
+ `chi send --wait` with no message names it too and `--turn` waits for it. A
90
+ message merged into a running turn (a steer) gets that turn's reply or end;
91
+ its `turn_id` stays the message's own, and `--turn` with it waits for that
92
+ turn again.
86
93
 
87
94
  ```json
88
95
  {"status":"question","session_id":"297da360-…","question":{"id":"3f1c…","kind":"question",
@@ -198,6 +205,14 @@ answers from any local process.
198
205
  - If the worker dies while a question waits, the question goes with it.
199
206
  `chi answer` then exits 1 and says to send the task again
200
207
  (`chi send --wait -m "…" ID`); a wait ends with `worker_gone` (exit 1).
208
+ - `worker_gone` follows one rule everywhere (`ReplyWait::GONE_AFTER`): no
209
+ worker has owned the session for 5 s while the wait runs (it died without
210
+ marking the session). A worker chi just started is awaited first (`chi send
211
+ --new`, a `delegate` child), so a slow boot isn't read as a death; a
212
+ delegate child whose worker never starts is reported `worker_gone` ("its
213
+ worker did not start") once the boot limit (30 s) passes. That await is
214
+ part of the `delegate` call's wait: the call's `timeout` bounds it (the
215
+ child then reads `running`) and the parent's Stop ends it.
201
216
  - A follow-up (`chi send --wait -m "…" ID`) to a session waiting for an answer
202
217
  isn't sent: it exits 1 with the `chi answer` command for the question. The
203
218
  step-limit question is the exception: it waits between turns, so a message
data/docs/testing.md CHANGED
@@ -75,6 +75,47 @@ spec/engine_turn_golden_spec.rb`, then read `git diff spec/fixtures/turn_goldens
75
75
  to `tmp/turn_goldens/<golden>.actual` and names the `diff` to run. Changes to the turn path (engine, kernel loop, chat loop,
76
76
  tool runner, gate, guardrail wiring, system prompt, tool declarations, registry) pick it in `script/affected-specs`.
77
77
 
78
+ The model-setting characterizations (`spec/engine_effective_model_characterization_spec.rb`,
79
+ `spec/engine_session_model_settings_characterization_spec.rb`, `spec/web/app_session_settings_characterization_spec.rb`
80
+ over `spec/fixtures/effective_model/`, and `spec/model_setting_precedence_characterization_spec.rb`'s ladders over
81
+ `spec/fixtures/model_setting_precedence/`) pin the settings on the code as it was before the `ModelLookup` refactor
82
+ (`UPDATE_GOLDENS=1` regenerates them after an intended change only). They are retired after their refactor: the
83
+ next round folds the first three into one wiring example each in `effective_model_spec.rb`,
84
+ `session_model_settings_spec.rb` and `session_setting_spec.rb`, and the ladders the round after into the setting
85
+ modules' and `model_lookup_spec.rb`.
86
+
87
+ The session file goldens (`spec/session_file_goldens_spec.rb`, `spec/fixtures/session_goldens/`) pin the bytes `Session#save`
88
+ writes: every field away from its default, a new session, a newer chi's file, an old chi's file in a git repo, every key
89
+ present as `null`, the plain writers' raw values, a running turn's checkpoint and the scrub (the `*_in.json` files are
90
+ hand-written inputs, never regenerated); and the file each path that makes or rewrites a session writes (a fork, a
91
+ continue, a delegate child, a plain spawn, a review, a Verify fresh child, a resume, a respawn, an archive), with
92
+ `carry_matrix.json`, what each keeps of its source. They are pinned before Session's fields became one table and are kept
93
+ as the file format's pins, not retired: a diff is a change to every session file on disk, so only a commit that means to
94
+ change the format regenerates them (`UPDATE_GOLDENS=1 bundle exec rspec spec/session_file_goldens_spec.rb`).
95
+
96
+ `UPDATE_GOLDENS=1` is the characterization specs' variable (the prompt and turn goldens above use `UPDATE_PROMPTS`):
97
+ the model-setting ones above, the session file goldens, claude-peer's goldens and the bundle-state corpus below. Regenerate only for an intended
98
+ change, then read the diff.
99
+
100
+ The bundle-state corpus (`spec/samagotchi/memory_bundle/file_state_corpus/`, `spec/fixtures/bundle_states/`) pins what
101
+ every bundle file state does under every command that reads or writes it. Each case in `memory_cases.yml` (a memory:
102
+ owned, edited, behind, missing, dropped, the user's own, another bundle's too…) and `asset_cases.yml` (a rule, the
103
+ plugin, a script, an older install's class-file hook) is written directly as a starting tree, in the 0.155 and the 0.149 record shape, and run
104
+ through install, `--force`, `--dry-run`, upgrade and its flags, chi update's plan, Remerge, the system bundle's restore,
105
+ uninstall and status. A cell holds only what a user or the next command sees (the bytes, the trash, base and checksum,
106
+ the mark, the summary's word, status's label, the warnings), never the Installer's result strings. `expect/*.yml` are
107
+ generated (`UPDATE_GOLDENS=1 bundle exec rspec spec/samagotchi/memory_bundle/file_state_corpus/`, which also writes
108
+ `tmp/bundle_states/review.<file>.txt`, every cell in words); a mismatch names the case, command and shape and writes
109
+ `tmp/bundle_states/<file>.yml.actual` to diff. A regeneration asserts nothing, so run the folder again without
110
+ `UPDATE_GOLDENS`: that run checks the new cells against `cases_spec.rb`, which holds what every regeneration keeps. No
111
+ user bytes (neither the bundle's text nor what the record vouches for, its checksum first) are changed or removed without
112
+ a copy in the trash first; the two record shapes give one answer except on a marked case (an allowlist entry needs a
113
+ note); and for every asset, chi update's plan lists as replaced exactly the files the upgrade copies to the trash. To stay
114
+ under 3 s, `upgrade_spec.rb` runs the 0.149 shape for marked cases only (`MARKED_ONLY_149`), where the shapes can differ. A regenerated cell is
115
+ reviewed: a refactor that turns the corpus red stops and asks, and only a commit that means to change a cell
116
+ regenerates, its changed cells read one by one; a cell that looks wrong today carries a `divergent:` tag and a note in
117
+ its case.
118
+
78
119
  The web specs run in plain node. One that loads lit-html, itself or through a module it imports (`turn_block.js`,
79
120
  `turn_html.js`, `card.js`, `copy.js`, `images.js`, `scope.js`, `command_view.js`, `diff_view.js`, `read_view.js`,
80
121
  `question_cards.js`, `stream_handlers.js`, `stage_view.js`, `bubbles.js`, `cards_place.js`, the chips, `info_bar.js`, `info_bar_model.js`, `lists_view.js`, `context_chips.js`, `families_view.js`,
@@ -124,7 +165,15 @@ Each level owns its own share; a detail is asserted once, at the lowest level th
124
165
  - **e2e** is for real-browser behaviour (layout, focus, reload, two tabs), one happy path per Ruby↔web mechanism and
125
166
  a few journeys. Before adding a test, extend an existing journey's step; a variant goes to node, a Ruby↔web shape
126
167
  variant to a shared fixture (`spec/fixtures/composer_routes.json`). A journey names its stages with `test.step`
127
- (the report names the one that failed: web.spec.js's notifications). The `lan` project's chi web is on the LAN only
168
+ (the report names the one that failed: web.spec.js's notifications). A turn test ends with
169
+ `expectSameAfterReload` (spec/e2e/support/history_shape.js): the history's shape live, then after a reload, the
170
+ same (live is the truth). Its allowlist is `history_shape.js ACCEPTED`, each entry with its reason; a test names
171
+ the entries its scenario has (only deliberate ones are left). A drawn turn's variants are node's: the shared
172
+ scenarios in `spec/shared/turn_drawings/` (written from `spec/support/turn_drawing_scenarios.rb`;
173
+ `UPDATE_TURN_DRAWINGS=1 bundle exec rspec spec/bridge/turn_drawings_spec.rb spec/web/session_show_drawings_spec.rb`
174
+ rewrites their lines and show after an intended change) feed `turn_drawing_parity.test.js`, which draws each one
175
+ live (the real stream handlers and stage) and from its drawing and compares the shapes with no allow list. The
176
+ `lan` project's chi web is on the LAN only
128
177
  when the machine has a private IPv4: without one its LAN tests skip and the participant review journey runs on
129
178
  loopback.
130
179
 
@@ -254,6 +303,7 @@ any other file requires it, so a spec file run alone still finds its requires. E
254
303
  | `lock_holder.rb` | *helper*: `spawn_lock_holder(session_dir)`, a stand-in worker process holding a session's owner lock, which says so on a pipe |
255
304
  | `integration_server.rb` | *helper*: the live model server `:integration` examples use, from `SAMAGOTCHI_INTEGRATION_*` ([Integration specs](#integration-specs)) |
256
305
  | `mcp_bundle.rb` | The shipped mcp bundle's path and the fake MCP server its specs start (`MCP_FAKE_COMMAND`, no RubyGems) |
306
+ | `model_setting_ladders.rb` | shared context "model setting ladders": per setting a config, env and CLI with every layer set, peeled top down (`peel`), against `spec/fixtures/model_setting_precedence/` |
257
307
  | `no_console.rb` | Loaded first (`ruby -r`) by spawned bin/chi children: `IO.console` is nil, so output never wraps at a terminal's width |
258
308
  | `recording_surface.rb` | `RecordingSurface`: a `TerminalUI::Surface` that records commits and slots instead of drawing |
259
309
  | `remote_backend.rb` | `RemoteBackend`: `SessionAccess::Remote` against chi web's Rack app in this process (no socket, LAN token gate) |
@@ -271,7 +321,7 @@ any other file requires it, so a spec file run alone still finds its requires. E
271
321
 
272
322
  ## Bundle specs: the test kit
273
323
 
274
- A shipped bundle's plugin and hook files are tested through the bundle test kit (`samagotchi/testing`,
324
+ A shipped bundle's plugin is tested through the bundle test kit (`samagotchi/testing`,
275
325
  [plugins.md](plugins.md#testing-a-bundle-experimental)): `shipped_bundle("check-in", settings: …)` loads it through
276
326
  chi's own loaders, registries, `ctx` and guardrail Gate, with the session, the hook helpers and `ctx.sessions`
277
327
  recorded. No fake `chi` or `ctx`: what the kit can't express is a kit change (with a kit spec), not a local fake.
@@ -290,13 +340,13 @@ The kit's own specs, in `spec/samagotchi/testing/`, keep it and the bundles hone
290
340
  - `spec/samagotchi/testing/contract_spec.rb`: the kit sets every `Plugin::Host` and `Hooks::Runtime` member,
291
341
  `Testing::Sessions` has `Plugin::Sessions`' methods and parameters, and each `ctx` helper lands in its recorder. A
292
342
  member added to the Host fails it until the kit has it.
293
- - `spec/samagotchi/testing/shipped_bundles_spec.rb`: each shipped bundle with a plugin or hooks registers the same
343
+ - `spec/samagotchi/testing/shipped_bundles_spec.rb`: each shipped bundle with a plugin registers the same
294
344
  commands, tools, hooks, inits, toolkits and sources loaded by the kit as installed and loaded by chi
295
345
  (`Testing::Install`).
296
346
  - `spec/samagotchi/bundle_command/check_spec.rb`: `chi bundle check` passes every shipped bundle with no errors (one
297
347
  example per bundle), so a shipped bundle that wouldn't install or load cleanly for a user fails here too. Run it
298
348
  by hand on one with `ruby -Ilib bin/chi bundle check lib/samagotchi/bundles/NAME`.
299
- - `spec/samagotchi/testing/boundary_spec.rb`, the boundary lint: each bundle's Ruby (plugin, hooks, scripts, specs)
349
+ - `spec/samagotchi/testing/boundary_spec.rb`, the boundary lint: each bundle's Ruby (plugin, scripts, specs)
300
350
  reaches chi only through `Samagotchi::Testing::Boundary::ALLOWED` (the "Stable for bundles" list) and that
301
351
  bundle's `Boundary::EXCEPTIONS` entry, each with why. A new internal reference fails it, and so does an exception
302
352
  no longer used. Fix the reference (the kit or a stable API), or add the exception with its why.
@@ -334,7 +384,8 @@ files it changed (`lib/samagotchi/foo/bar.rb` → `spec/foo/bar_spec.rb`, `bar_*
334
384
  `lib/samagotchi/foo/bar/baz.rb` → `spec/foo/bar_baz_spec.rb`), the specs that read
335
385
  docs or source as text (its `RULES`: add one when a full run catches what the names missed) and the golden of a changed
336
386
  file's layer (any `foo/*.rb` → `spec/foo_golden_spec.rb` when there is one; `turn_flow.rb` and `turn_note.rb`, whose
337
- words the attached TUI prints, pick its `event_renderer` and `attached_loop` specs by a rule). Changes to `tool_declarations.rb` and `system_prompt.rb` also pick the
387
+ words the attached TUI prints, pick its `event_renderer` and `attached_loop` specs by a rule; `memory_bundle/*.rb`,
388
+ `bundle_command.rb`, `memory_paths.rb`, `model_overlay.rb` and the corpus's fixtures pick the bundle-state corpus). Changes to `tool_declarations.rb` and `system_prompt.rb` also pick the
338
389
  effective-model, request, prompt-snapshot and full system-prompt goldens because they hash the request's prompt and
339
390
  tools. Then come the other name matches (a spec named after several changed files first, e.g. `engine_toolkit_spec.rb` for `engine.rb` and
340
391
  `toolkit.rb`; then one beside the file, so `plugin/context.rb` picks `spec/samagotchi/plugin/*context*` before
@@ -138,7 +138,8 @@ module Samagotchi
138
138
  end
139
139
  # Answered here, or no longer open (answered elsewhere, or a newer
140
140
  # question): what comes next either way.
141
- wait_for_reply(id, cursor: start.cursor, baseline: start.baseline, timeout: options[:timeout], turn: start.own_turn)
141
+ wait_for_reply(id, target: ReplyWait::Target.answer(start),
142
+ timeout: options[:timeout])
142
143
  end
143
144
 
144
145
  # The labels to send. A number is an option's place (1-based); an
@@ -4,6 +4,7 @@ require "json"
4
4
  require "fileutils"
5
5
  require "time"
6
6
  require_relative "atomic_file"
7
+ require_relative "session_paths"
7
8
 
8
9
  require_relative "session"
9
10
 
@@ -46,7 +47,7 @@ module Samagotchi
46
47
 
47
48
  # @return [Boolean] whether it was archived (and is not now)
48
49
  def self.unarchive(session_id, state_dir:)
49
- return false unless archived?(Session.session_dir(session_id, state_dir: state_dir))
50
+ return false unless archived?(SessionPaths.session_dir(session_id, state_dir: state_dir))
50
51
 
51
52
  write(session_id, { "unarchived_at" => Time.now.iso8601(3) }, state_dir: state_dir)
52
53
  end
@@ -63,9 +64,9 @@ module Samagotchi
63
64
  end
64
65
 
65
66
  def self.write(session_id, record, state_dir:)
66
- return false unless File.exist?(Session.session_file(session_id, state_dir: state_dir))
67
+ return false unless File.exist?(SessionPaths.session_file(session_id, state_dir: state_dir))
67
68
 
68
- dir = Session.session_dir(session_id, state_dir: state_dir)
69
+ dir = SessionPaths.session_dir(session_id, state_dir: state_dir)
69
70
  FileUtils.mkdir_p(dir)
70
71
  path = File.join(dir, FILE)
71
72
  AtomicFile.write(path, JSON.generate(record))
@@ -3,6 +3,7 @@
3
3
  require "rbconfig"
4
4
  require_relative "config"
5
5
  require_relative "model_profile"
6
+ require_relative "host_address"
6
7
  require_relative "host_registry"
7
8
  require_relative "host_keys"
8
9
  require_relative "chi_home"
@@ -342,7 +343,7 @@ module Samagotchi
342
343
  # The "loading the model?" hint fits a local server (a llama.cpp loading
343
344
  # weights); a remote provider's slow answer is the network, not a load.
344
345
  def loading_hint?(result)
345
- !HostRegistry.remote_address?(result.candidate.scheme, result.candidate.host)
346
+ !HostAddress.remote_address?(result.candidate.scheme, result.candidate.host)
346
347
  end
347
348
 
348
349
  # 0 written, 1 written but not as planned, nil with @exit on a refusal.
@@ -489,7 +490,7 @@ module Samagotchi
489
490
  when :skipped then "skipped (#{result.error})"
490
491
  else "failed (#{result.error})"
491
492
  end
492
- @stdout.puts("system bundle: #{text}")
493
+ @stdout.puts("system bundle: #{[text, *result.warnings].join("; ")}")
493
494
  result.status != :failed
494
495
  end
495
496
 
@@ -14,9 +14,10 @@ module Samagotchi
14
14
  # an entry says how many it came after (+past_idless+, #idless_after), and
15
15
  # which one that is when it has an id (+after_opening+, #after_opening).
16
16
  #
17
- # The same place as a stamp (#opening_of): the id of the opening the entry
18
- # comes after (a prompt's turn_id, a line's opening_id), which the page
19
- # finds by its bubble, not by counting. Pure: no Engine, no file.
17
+ # The same place as a stamp (#opening_of, Steer.opening_stamp): the id of
18
+ # the opening the entry comes after (a prompt's turn_id, a line's
19
+ # opening_id), which the page finds by its bubble, not by counting.
20
+ # Pure: no Engine, no file.
20
21
  class CardPlaces
21
22
  # @param turn_log [Array<String>] the turn ids, oldest first
22
23
  # @param messages [Array<Hash>] the messages the page draws
@@ -114,10 +115,7 @@ module Samagotchi
114
115
  position >= 0 ? @last_at_or_before[position] : nil
115
116
  end
116
117
 
117
- def stamp_of(message)
118
- id = message[:turn_id] || message["turn_id"] || message[:opening_id] || message["opening_id"]
119
- id.is_a?(String) && !id.empty? ? id : nil
120
- end
118
+ def stamp_of(message) = Steer.opening_stamp(message)
121
119
 
122
120
  def idless_run(opening)
123
121
  @ids.drop(opening + 1).take_while(&:nil?).size
@@ -147,6 +147,9 @@ module Samagotchi
147
147
  stamp = places&.opening_of(entry)
148
148
  out[:opening_id] = stamp if stamp
149
149
  out[:current] = current
150
+ # Whose row it is (its turn's id, +of_turn+): the page leaves a drawn
151
+ # turn's own rows and cards to its drawing (Bridge::TurnDrawings).
152
+ out[:of_turn] = entry[:turn_id] if shown[:in_turn] && entry[:turn_id]
150
153
  out[:during] = true if entry[:during]
151
154
  out[:after_failed] = true if entry[:after_failed] && entry[:turn_id] == @failed_turn
152
155
  out[:label] = "from #{entry[:command]}" if entry[:command]
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../events"
4
4
  require_relative "../kernel_loop"
5
+ require_relative "../log"
5
6
  require_relative "../steer"
6
7
  require_relative "../tool_output_cut"
7
8
  require_relative "../tool_row_fields"
@@ -23,10 +24,17 @@ module Samagotchi
23
24
  # its head and tail: a reload's cap, so a joined row is the row a
24
25
  # reload draws
25
26
  # @param clock [#call] monotonic seconds: a tool call's duration
27
+ # @param on_turn_end [#call, nil] (turn, end_event): the turn's final
28
+ # fold (a deep copy, #current_turn's shape without elapsed_ms) and its
29
+ # end event, at the end event, before the turn is cleared (the Bridge's
30
+ # TurnDrawings#append). It runs under this accumulator's mutex and the
31
+ # SessionObserver's lock: it must never call back in here. An error is
32
+ # logged and the fold goes on.
26
33
  def initialize(max_output_chars: ToolOutputCut::WEB_PREVIEW_CHARS,
27
- clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
34
+ clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, on_turn_end: nil)
28
35
  @max_output_chars = max_output_chars
29
36
  @clock = clock
37
+ @on_turn_end = on_turn_end
30
38
  @mutex = Mutex.new
31
39
  @turn = nil
32
40
  # When each running tool call started, by [iteration, call_index].
@@ -47,13 +55,14 @@ module Samagotchi
47
55
 
48
56
  # @return [Hash, nil] a copy of the turn in progress: prompt, turn_id, origin,
49
57
  # continue, at (its wall time), ordered parts (generation / thinking / text / tool /
50
- # input / steer / reminder / notice), pending_question and the last event_seq folded
58
+ # input / steer / reminder / notice / card), pending_question and the last event_seq folded
51
59
  # in. A finished tool part keeps the live row's action and duration_ms
52
60
  # too, a running one the time it has run so far (elapsed_ms), so a
53
61
  # join times its row from its start. A notice part holds the event
54
62
  # of one of the turn's rows (TurnNotice: a hook's notice, an
55
63
  # empty-answer retry, a question and its answer), which a UI replays
56
- # through its live handler.
64
+ # through its live handler. A card part is a card the turn showed in
65
+ # its step (in_turn), its latest version in the place of its first.
57
66
  def current_turn
58
67
  @mutex.synchronize do
59
68
  next nil unless @turn
@@ -132,10 +141,13 @@ module Samagotchi
132
141
  # the live activity: action, tool, params, status), a merged_input
133
142
  # (the one synthetic type) per merged prompt, a steer-only
134
143
  # pending_input_merged per steer, a reminder_injected, and a notice
135
- # part's own event.
144
+ # part's own event. Card parts only with +cards+ (a finished turn's
145
+ # drawing, chi web's unfold of it): a running turn's join gets its cards
146
+ # from CardStore's snapshot.
136
147
  # @param started_at [String, nil] the turn's start, on turn_started
148
+ # @param cards [Boolean] replay card parts as their card events
137
149
  # @return [Array<Hash>] symbol keys and types; [] with no turn
138
- def self.replay_events(turn, started_at: nil)
150
+ def self.replay_events(turn, started_at: nil, cards: false)
139
151
  return [] unless turn
140
152
 
141
153
  started = { type: :turn_started, prompt: turn[:prompt], origin: turn[:origin], continue: !!turn[:continue] }
@@ -171,18 +183,73 @@ module Samagotchi
171
183
  when "notice"
172
184
  close_text.call
173
185
  notice = part[:event]
174
- events << notice.merge(type: notice[:type].to_sym) if notice.is_a?(Hash) && notice[:type]
186
+ # A damaged drawing's type may be anything: only a name replays.
187
+ if notice.is_a?(Hash) && (notice[:type].is_a?(String) || notice[:type].is_a?(Symbol))
188
+ events << notice.merge(type: notice[:type].to_sym)
189
+ end
175
190
  when "steer"
176
191
  close_text.call
177
192
  events << { type: :pending_input_merged, count: 0, content: nil, steers: [{ source: part[:source], text: part[:text] }] }
193
+ when "card"
194
+ next unless cards && part[:event].is_a?(Hash)
195
+
196
+ close_text.call
197
+ events << part[:event].merge(type: :card, in_turn: true)
178
198
  end
179
199
  end
180
200
  events
181
201
  end
182
202
 
203
+ # The page's unfold of a session view (chi web's show sends it as
204
+ # `replay`; turn_events.js snapshotEvents was its JS twin): the turn in
205
+ # progress as #replay_events has it, its turn_started with what the
206
+ # page also reads (its wall time `at`, the run's `cut_after`, and every
207
+ # author key, a pair turn's participant_mode too), then a turn_enqueued
208
+ # per prompt queued behind it and a command_queued per command waiting
209
+ # for its end (spec/shared/turn_snapshot.json pins both sides). The TUI
210
+ # keeps #replay_events.
211
+ # @param current_turn [Hash, nil] #current_turn's shape, symbol keys
212
+ # (a snapshot's: .from_wire)
213
+ # @param queued [Array<Hash>] #queued's entries
214
+ # @param queued_commands [Array<Hash>] #queued_commands' entries
215
+ # @return [Array<Hash>] symbol keys and types
216
+ def self.join_events(current_turn:, queued: [], queued_commands: [], started_at: nil)
217
+ events = replay_events(current_turn, started_at: started_at)
218
+ if (started = events.first)
219
+ started[:at] = current_turn[:at] if current_turn[:at].is_a?(String)
220
+ started[:cut_after] = current_turn[:cut_after] unless current_turn[:cut_after].nil?
221
+ Steer::AUTHOR_KEYS.each { |key| started.delete(key) }
222
+ Steer::AUTHOR_KEYS.each { |key| started[key] = current_turn[key] if current_turn[key] } if current_turn[:author]
223
+ end
224
+ Array(queued).each do |entry|
225
+ enqueued = { type: :turn_enqueued, enqueued_id: entry[:enqueued_id], client_id: entry[:client_id], prompt: entry[:prompt] }
226
+ enqueued[:delivery] = entry[:delivery] if entry[:delivery]
227
+ enqueued[:author] = entry[:author] if entry[:author]
228
+ events << with_images(enqueued, entry[:images])
229
+ end
230
+ Array(queued_commands).each do |entry|
231
+ command = { type: :command_queued, command_id: entry[:command_id], client_id: entry[:client_id], line: entry[:line],
232
+ waits: "turn_end" }
233
+ command[:author] = entry[:author] if entry[:author]
234
+ events << command
235
+ end
236
+ events
237
+ end
238
+
239
+ # A Bridge snapshot's part (JSON: string keys) as the accumulator's own
240
+ # shapes: symbol keys all the way down, values as they are.
241
+ def self.from_wire(value)
242
+ case value
243
+ when Hash then value.to_h { |key, item| [key.is_a?(String) ? key.to_sym : key, from_wire(item)] }
244
+ when Array then value.map { |item| from_wire(item) }
245
+ else value
246
+ end
247
+ end
248
+
183
249
  def self.tool_replay_events(part)
184
250
  call = { iteration: part[:iteration], call_index: part[:call_index], tool: part[:tool] }
185
251
  call[:label] = part[:label] if part[:label]
252
+ call[:tool_id] = part[:tool_id] if part[:tool_id]
186
253
  # Its row fields (ToolRowFields), each in its place: title and, for
187
254
  # an alias's call, the name the model used, also in the activity.
188
255
  title = part.slice(*ToolRowFields::ACTIVITY_KEYS).compact
@@ -296,6 +363,7 @@ module Samagotchi
296
363
 
297
364
  fold_turn_event(event)
298
365
  if Events::TURN_END.include?(type)
366
+ turn_ended(event)
299
367
  @turn = nil
300
368
  @merged_origins = []
301
369
  @working_set = nil
@@ -326,6 +394,8 @@ module Samagotchi
326
394
  part = { kind: "tool", iteration: event[:iteration], call_index: event[:call_index],
327
395
  tool: event[:tool], params: event[:params], status: "running" }
328
396
  part[:label] = event[:label] if event[:label]
397
+ # The id its output is saved under (ToolIds; not a row field).
398
+ part[:tool_id] = event[:tool_id] if event[:tool_id]
329
399
  ToolRowFields::KEYS.each { |key| part[key] = event[key] if event[key] }
330
400
  parts << part
331
401
  @tool_started_at[[event[:iteration], event[:call_index]]] = @clock.call
@@ -342,16 +412,26 @@ module Samagotchi
342
412
  # Only System 1's auto-allow is drawn on the row (the web's label).
343
413
  guardrail = event.dig(:activity, :guardrail)
344
414
  tool[:guardrail] = guardrail if guardrail.is_a?(Hash) && guardrail[:decided_by] == "system one"
345
- tool[:output] = capped ? ToolOutputCut.preview(output, cap: @max_output_chars, tool: event[:tool]) : output.dup
415
+ # The event's web preview (ToolRunner: the reload's cut) when it
416
+ # has one; an older worker's event is cut here the same way.
417
+ tool[:output] = if event[:preview] then event[:preview].to_s.dup
418
+ elsif capped then ToolOutputCut.preview(output, cap: @max_output_chars, tool: event[:tool])
419
+ else output.dup
420
+ end
346
421
  tool[:output_truncated] = capped || !!event[:output_truncated]
347
422
  tool[:images] = event[:images] if event[:images]
348
423
  tool[:diff] = event[:diff] if event[:diff]
349
424
  action = event.dig(:activity, :action)
350
425
  tool[:action] = action.to_s if action
351
- # As the live row times it (EventRenderer): from tool_call_started,
352
- # less an approval wait.
426
+ # The event's (ToolRunner's: the figure every UI shows); without
427
+ # one (an older worker's event), as the live row times it: from
428
+ # tool_call_started, less an approval wait.
353
429
  started_at = @tool_started_at.delete([event[:iteration], event[:call_index]])
354
- tool[:duration_ms] = [((@clock.call - started_at) * 1000) - event[:waited_ms].to_f, 0].max.round if started_at
430
+ if event[:duration_ms].is_a?(Integer)
431
+ tool[:duration_ms] = event[:duration_ms]
432
+ elsif started_at
433
+ tool[:duration_ms] = [((@clock.call - started_at) * 1000) - event[:waited_ms].to_f, 0].max.round
434
+ end
355
435
  when :pending_input_merged
356
436
  # A steer-only merge (count 0) has no user part: the origins stay
357
437
  # for the user lines' own merge.
@@ -366,6 +446,8 @@ module Samagotchi
366
446
  end
367
447
  when :working_set_updated
368
448
  @working_set = { items: event[:items], at: event[:at] }
449
+ when :card
450
+ fold_card(parts, event) if event[:in_turn]
369
451
  when :reminder_injected
370
452
  parts << { kind: "reminder", reminders: Array(event[:reminders]).map(&:dup) }
371
453
  when :question_requested
@@ -382,6 +464,27 @@ module Samagotchi
382
464
  parts << { kind: "notice", event: TurnNotice.slice(event) } if TurnNotice.notice?(event)
383
465
  end
384
466
 
467
+ # The card fields a UI draws (CardStore keeps the same), a copy.
468
+ CARD_FIELDS = %i[id source title body level actions].freeze
469
+ private_constant :CARD_FIELDS
470
+
471
+ # A card the turn shows in its step; one with an earlier card's id
472
+ # replaces that part where it was.
473
+ def fold_card(parts, event)
474
+ card = { kind: "card", id: event[:id], event: Marshal.load(Marshal.dump(event.slice(*CARD_FIELDS))) }
475
+ index = parts.index { |part| part[:kind] == "card" && part[:id] == card[:id] }
476
+ index ? parts[index] = card : parts << card
477
+ end
478
+
479
+ # The turn's final fold to the hook (#initialize's on_turn_end).
480
+ def turn_ended(event)
481
+ return unless @on_turn_end
482
+
483
+ @on_turn_end.call(Marshal.load(Marshal.dump(@turn)), event)
484
+ rescue StandardError => e
485
+ Log.warn(:bridge, "turn_end_hook_failed", error: "#{e.class}: #{e.message}")
486
+ end
487
+
385
488
  def append_text(iteration, kind, text)
386
489
  return if text.nil? || text.empty?
387
490