samagotchi 0.154.1 → 0.156.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 (185) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/bin/chi +1 -1
  4. data/docs/architecture.md +12 -7
  5. data/docs/bundles.md +21 -0
  6. data/docs/cli.md +1 -1
  7. data/docs/guardrails.md +2 -2
  8. data/docs/hooks.md +1 -1
  9. data/docs/internals/anthropic.md +5 -5
  10. data/docs/internals/attached-context.md +9 -6
  11. data/docs/internals/background-tasks.md +1 -1
  12. data/docs/internals/block-renderers.md +8 -1
  13. data/docs/internals/engine.md +642 -831
  14. data/docs/internals/inline-widgets.md +136 -0
  15. data/docs/internals/llm-context-forget.md +8 -3
  16. data/docs/internals/objectives.md +8 -6
  17. data/docs/internals/participants.md +5 -5
  18. data/docs/internals/prompt-caching.md +13 -6
  19. data/docs/internals/remote-sessions.md +3 -1
  20. data/docs/internals/web-frontend.md +1 -1
  21. data/docs/internals/working-set.md +2 -1
  22. data/docs/plugins.md +2 -0
  23. data/docs/releasing.md +1 -1
  24. data/docs/sessions.md +4 -4
  25. data/docs/system-one.md +3 -2
  26. data/docs/testing.md +1 -1
  27. data/lib/samagotchi/block_renderers.rb +5 -0
  28. data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
  29. data/lib/samagotchi/broadcast_command.rb +3 -3
  30. data/lib/samagotchi/bundle_command/check.rb +1 -1
  31. data/lib/samagotchi/bundle_command/try/sandbox.rb +1 -1
  32. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  33. data/lib/samagotchi/bundles/inline-widgets/manifest.yml +11 -0
  34. data/lib/samagotchi/bundles/inline-widgets/plugin.rb +76 -0
  35. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  36. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  37. data/lib/samagotchi/child_move.rb +7 -51
  38. data/lib/samagotchi/children_status.rb +1 -1
  39. data/lib/samagotchi/cli/parent_wait.rb +1 -1
  40. data/lib/samagotchi/config.rb +17 -112
  41. data/lib/samagotchi/context_command.rb +1 -2
  42. data/lib/samagotchi/context_window.rb +3 -3
  43. data/lib/samagotchi/desktop/macos.rb +1 -1
  44. data/lib/samagotchi/engine/context_judge.rb +21 -119
  45. data/lib/samagotchi/engine/guardrails.rb +4 -2
  46. data/lib/samagotchi/engine/messages.rb +9 -6
  47. data/lib/samagotchi/engine/models.rb +5 -2
  48. data/lib/samagotchi/engine/objectives.rb +30 -80
  49. data/lib/samagotchi/engine/participants.rb +15 -10
  50. data/lib/samagotchi/engine/plugins.rb +3 -1
  51. data/lib/samagotchi/engine/questions.rb +1 -1
  52. data/lib/samagotchi/engine/side_requests.rb +26 -13
  53. data/lib/samagotchi/engine/toolkit.rb +35 -191
  54. data/lib/samagotchi/engine/turn_end.rb +1 -1
  55. data/lib/samagotchi/engine/turn_helpers.rb +16 -39
  56. data/lib/samagotchi/engine/verify.rb +7 -143
  57. data/lib/samagotchi/engine.rb +166 -98
  58. data/lib/samagotchi/engine_peers.rb +1 -1
  59. data/lib/samagotchi/event_fanout.rb +18 -12
  60. data/lib/samagotchi/guardrail_wiring.rb +83 -82
  61. data/lib/samagotchi/guardrails/gate.rb +41 -40
  62. data/lib/samagotchi/guardrails/pair_boundary.rb +2 -1
  63. data/lib/samagotchi/guardrails/review_boundary.rb +2 -1
  64. data/lib/samagotchi/guardrails/targets.rb +28 -11
  65. data/lib/samagotchi/guardrails_command.rb +1 -1
  66. data/lib/samagotchi/host_config.rb +72 -1
  67. data/lib/samagotchi/host_model.rb +2 -6
  68. data/lib/samagotchi/host_registry.rb +1 -1
  69. data/lib/samagotchi/inline_widgets.rb +325 -0
  70. data/lib/samagotchi/kernel_loop.rb +215 -313
  71. data/lib/samagotchi/llm/anthropic_wire.rb +2 -2
  72. data/lib/samagotchi/llm/backend.rb +3 -1
  73. data/lib/samagotchi/llm/chat_loop.rb +87 -49
  74. data/lib/samagotchi/llm/native_backend.rb +3 -2
  75. data/lib/samagotchi/llm_context_apply.rb +54 -1
  76. data/lib/samagotchi/llm_context_forget.rb +5 -3
  77. data/lib/samagotchi/llm_context_notice.rb +1 -1
  78. data/lib/samagotchi/llm_context_strategy.rb +7 -9
  79. data/lib/samagotchi/llm_context_view.rb +9 -0
  80. data/lib/samagotchi/model_binding.rb +3 -3
  81. data/lib/samagotchi/model_price.rb +4 -10
  82. data/lib/samagotchi/model_profile.rb +6 -6
  83. data/lib/samagotchi/model_settings.rb +37 -2
  84. data/lib/samagotchi/note_command.rb +1 -2
  85. data/lib/samagotchi/objectives_audit.rb +4 -3
  86. data/lib/samagotchi/participants.rb +2 -2
  87. data/lib/samagotchi/plugin/context.rb +1 -1
  88. data/lib/samagotchi/plugin/prompt_sections.rb +7 -0
  89. data/lib/samagotchi/plugin/sessions.rb +2 -5
  90. data/lib/samagotchi/plugin/tool_spec.rb +5 -3
  91. data/lib/samagotchi/project_env.rb +1 -1
  92. data/lib/samagotchi/question_desk.rb +1 -1
  93. data/lib/samagotchi/reasoning_history.rb +3 -3
  94. data/lib/samagotchi/reminder_carry.rb +1 -1
  95. data/lib/samagotchi/reprefill_meter.rb +45 -0
  96. data/lib/samagotchi/review_command.rb +1 -1
  97. data/lib/samagotchi/revise_anchor.rb +1 -1
  98. data/lib/samagotchi/sampling_settings.rb +3 -3
  99. data/lib/samagotchi/self_report.rb +7 -7
  100. data/lib/samagotchi/session.rb +50 -7
  101. data/lib/samagotchi/session_access/local.rb +7 -5
  102. data/lib/samagotchi/session_access/remote.rb +17 -3
  103. data/lib/samagotchi/session_archive_command.rb +2 -2
  104. data/lib/samagotchi/session_chain.rb +7 -6
  105. data/lib/samagotchi/session_continue.rb +6 -5
  106. data/lib/samagotchi/session_delete_command.rb +1 -1
  107. data/lib/samagotchi/session_manager/delivery.rb +254 -0
  108. data/lib/samagotchi/session_manager/emptiness.rb +138 -0
  109. data/lib/samagotchi/session_manager/lifecycle.rb +274 -0
  110. data/lib/samagotchi/session_manager/listing.rb +189 -0
  111. data/lib/samagotchi/session_manager/removal.rb +190 -0
  112. data/lib/samagotchi/session_manager/spawn.rb +242 -0
  113. data/lib/samagotchi/session_manager/worker_command.rb +157 -0
  114. data/lib/samagotchi/session_manager.rb +26 -1540
  115. data/lib/samagotchi/session_metrics.rb +1 -1
  116. data/lib/samagotchi/sessions_command.rb +1 -1
  117. data/lib/samagotchi/share_command.rb +1 -1
  118. data/lib/samagotchi/starting_marker.rb +65 -0
  119. data/lib/samagotchi/steer_cut_after.rb +3 -3
  120. data/lib/samagotchi/system_one/recorder.rb +49 -0
  121. data/lib/samagotchi/system_prompt.rb +37 -30
  122. data/lib/samagotchi/terminal_ui/exit_lines.rb +1 -1
  123. data/lib/samagotchi/terminal_ui.rb +2 -6
  124. data/lib/samagotchi/testing/boundary.rb +7 -0
  125. data/lib/samagotchi/testing/bundle.rb +7 -5
  126. data/lib/samagotchi/testing/install.rb +3 -2
  127. data/lib/samagotchi/testing/sessions.rb +1 -1
  128. data/lib/samagotchi/thinking.rb +3 -3
  129. data/lib/samagotchi/token_usage.rb +1 -1
  130. data/lib/samagotchi/tool_activity.rb +15 -7
  131. data/lib/samagotchi/tool_declarations.rb +6 -6
  132. data/lib/samagotchi/tool_output_cut.rb +17 -0
  133. data/lib/samagotchi/tool_response.rb +4 -3
  134. data/lib/samagotchi/tool_runner.rb +96 -102
  135. data/lib/samagotchi/toolkit_binding.rb +40 -14
  136. data/lib/samagotchi/toolkit_command.rb +1 -1
  137. data/lib/samagotchi/tools/deferred_tools.rb +17 -21
  138. data/lib/samagotchi/tools/delegate.rb +10 -5
  139. data/lib/samagotchi/tools/delegate_result.rb +4 -4
  140. data/lib/samagotchi/tools/index_hint.rb +14 -0
  141. data/lib/samagotchi/tools/registry.rb +64 -44
  142. data/lib/samagotchi/tools/send_note.rb +4 -6
  143. data/lib/samagotchi/tools/tool_index.rb +150 -102
  144. data/lib/samagotchi/tools/tool_surface.rb +187 -0
  145. data/lib/samagotchi/turn_context.rb +68 -0
  146. data/lib/samagotchi/turn_stages/context_judge.rb +172 -0
  147. data/lib/samagotchi/turn_stages/objectives.rb +150 -0
  148. data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +257 -0
  149. data/lib/samagotchi/turn_stages/verify_anchors.rb +168 -0
  150. data/lib/samagotchi/turn_stages/warmup.rb +82 -0
  151. data/lib/samagotchi/turn_state.rb +46 -4
  152. data/lib/samagotchi/version.rb +1 -1
  153. data/lib/samagotchi/vision_support.rb +3 -3
  154. data/lib/samagotchi/web/app.rb +15 -8
  155. data/lib/samagotchi/web/markdown_renderer.rb +39 -14
  156. data/lib/samagotchi/web/message_display.rb +1 -1
  157. data/lib/samagotchi/web/participant_access.rb +2 -2
  158. data/lib/samagotchi/web/public/app.js +5 -1
  159. data/lib/samagotchi/web/public/block_host.js +74 -199
  160. data/lib/samagotchi/web/public/block_renderers.css +2 -0
  161. data/lib/samagotchi/web/public/block_source.js +7 -4
  162. data/lib/samagotchi/web/public/block_watch.js +256 -0
  163. data/lib/samagotchi/web/public/composer_fold.js +3 -0
  164. data/lib/samagotchi/web/public/index.html +28 -9
  165. data/lib/samagotchi/web/public/inline_widgets.js +146 -0
  166. data/lib/samagotchi/web/public/owner_verify.js +1 -1
  167. data/lib/samagotchi/web/public/participant.js +1 -1
  168. data/lib/samagotchi/web/public/revise_stack.js +1 -1
  169. data/lib/samagotchi/web/public/stage_watch.js +6 -2
  170. data/lib/samagotchi/web/public/text_roots.js +1 -1
  171. data/lib/samagotchi/web/public/verify_target.js +1 -1
  172. data/lib/samagotchi/web/public/widget_picks.js +130 -0
  173. data/lib/samagotchi/web/public/widget_walk.js +230 -0
  174. data/lib/samagotchi/web/public/widgets.css +70 -0
  175. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  176. data/lib/samagotchi/web/session_control.rb +3 -3
  177. data/lib/samagotchi/web/session_create.rb +3 -3
  178. data/lib/samagotchi/web/session_hub.rb +1 -1
  179. data/lib/samagotchi/web/session_show.rb +3 -3
  180. data/lib/samagotchi/web/session_summary.rb +3 -4
  181. data/lib/samagotchi/worker.rb +55 -27
  182. data/lib/samagotchi/worker_boot.rb +184 -0
  183. data/lib/samagotchi/worker_signals.rb +1 -1
  184. data/lib/samagotchi/worker_task_wakes.rb +2 -2
  185. metadata +29 -1
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The inline-widgets bundle (docs/bundles.md, The inline-widgets bundle): two
4
+ # prompt sections that teach the model the ```chi-ui blocks chi web draws
5
+ # (docs/internals/inline-widgets.md): picks, rows of options that fill the
6
+ # user's reply, and walk, a scenario stepped through. The drawing is core's; this
7
+ # bundle only says the blocks exist. Uninstalled, the prompt loses the sections
8
+ # and blocks already in a history still draw.
9
+ #
10
+ # The text is eval 0013's round-2 hint, split in two. It is static, so the
11
+ # prompt cache keeps: same text at every build. A delegate child's answer is
12
+ # read by its parent, not drawn for a user, and a one-shot (-p --non-interactive)
13
+ # prints to a terminal: neither gets a section.
14
+ class Plugin
15
+ PICKS = <<~TEXT
16
+ The web UI draws fenced ```chi-ui code blocks (YAML inside) as interactive widgets; the terminal shows their source. Use one whenever it helps the user more than prose: a widget replaces the long explanation, it doesn't sit beside one. Never use one for what markdown shows well (tables, lists, key-value). At most one widget per answer; keep the prose around it to a few sentences and don't repeat the widget's content.
17
+
18
+ picks: whenever your answer leaves the user two or more things to decide (review findings, a plan's open decisions, which of several fixes to do), even if they didn't ask you to list them. The user clicks one option per row, can add a note, and the UI fills their reply.
19
+ ```chi-ui
20
+ type: picks
21
+ title: What to do with the findings
22
+ rows:
23
+ - id: "1" # unique, short; the user's reply names it
24
+ text: Bridge drops the replay buffer on reset # the item, <= 80 chars
25
+ options: # 2-4 short options, one per line
26
+ - fix now
27
+ - skip
28
+ - later
29
+ recommended: fix now # exactly one of the options
30
+ why: loses events a slow tab still needs # optional, one short line
31
+ ```
32
+ Every row id unique; a row's options unique. Quote a value that starts with a quote or contains ": ".
33
+ TEXT
34
+
35
+ WALK = <<~TEXT
36
+ walk is the other ```chi-ui widget (the same rules: the web UI draws it, at most one widget per answer): a scenario the user steps through one step at a time: who does what, what the user sees, what happens behind it. Use it for "when does this happen / how does this work / what does the user go through" questions.
37
+ ```chi-ui
38
+ type: walk
39
+ title: A tab reconnects
40
+ actors: [user, web, worker] # 2-5 short ids, unique
41
+ steps:
42
+ - id: s1 # unique
43
+ actor: user # one of actors
44
+ does: reopens the laptop # the action, <= 80 chars
45
+ sees: the page shows "reconnecting" # optional: what the user sees at this step
46
+ behind: the tab sends GET /events with Last-Event-ID # optional: what happens out of sight
47
+ - id: s2
48
+ actor: worker
49
+ does: checks the event id
50
+ cases: # optional: the step forks. List EVERY case, the usual one too
51
+ - when: the id is still buffered
52
+ goto: s3
53
+ - when: the id is too old
54
+ goto: s4
55
+ - id: s3
56
+ actor: worker
57
+ does: replays the missed events
58
+ next: end # optional: where to go after this step (default: the step below); end ends the walk here
59
+ - id: s4
60
+ actor: web
61
+ does: reloads the whole session
62
+ sees: the page redraws from the top
63
+ ```
64
+ A step needs `does` or `sees` (or both). Steps run in order unless `next` or `cases` say otherwise. Every id unique; every goto names a step; a next names another step or is `end`. Quote a value that starts with a quote or contains ": ".
65
+ TEXT
66
+
67
+ def register(chi)
68
+ chi.prompt_section("picks") { |ctx| PICKS if drawn?(ctx) }
69
+ chi.prompt_section("walk") { |ctx| WALK if drawn?(ctx) }
70
+ end
71
+
72
+ private
73
+
74
+ # Whether the session's answers reach a web page that draws them.
75
+ def drawn?(ctx) = ctx.frontend != :one_shot && !ctx.delegate?
76
+ 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.154.1
3
+ version: 0.156.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
9
+ config_modification_protocol.md: sha256:25ed2d4901f00eeea226db5eb2b75a05d6cdee4015ac8daa4bed624f745c3922
10
10
  memory_guide.md: sha256:8b7727b939b9c04486ca59859561023dcc319a73e0f5b47b3fcf5741edaa69e2
11
11
  delegated.md: sha256:a0d6ddcd4a8be06526d964898611775fd251cfd9d145a9d16afaccaa503597bd
@@ -18,27 +18,22 @@ module Samagotchi
18
18
  autoload :SessionContinue, File.expand_path("session_continue", __dir__)
19
19
 
20
20
  # A continue moving the previous link's open delegates to the chain's new
21
- # link (SessionManager.continue_session), instead of archiving them with
21
+ # link (SessionContinue.run), instead of archiving them with
22
22
  # it. A moved child gets a parent override (Session::PARENT_FILE), never a
23
23
  # rewritten session file, so its live worker saving a stale copy changes
24
24
  # nothing; its worker reads the override for its rings and at each turn's
25
25
  # start (Bridge::FEATURES "reparent").
26
26
  #
27
27
  # #apply runs between the new link's save and its worker's spawn, while
28
- # the link is #mark_starting (a ring from a moved child must not spawn a
29
- # second worker from the child's process until the link's own holds it).
30
- # Each step is idempotent,
31
- # and an intent file (INTENT_FILE) in the new link's folder names the move
32
- # until it is done: a worker that starts and finds one finishes the move
33
- # (#adopt), and a failed continue moves the same children back.
28
+ # the link is marked starting (StartingMarker): a ring from a moved child
29
+ # must not spawn a second worker from the child's process until the link's
30
+ # own holds it. Each step is idempotent, and an intent file (INTENT_FILE)
31
+ # in the new link's folder names the move until it is done: a worker that
32
+ # starts and finds one finishes the move (#adopt), and a failed continue
33
+ # moves the same children back.
34
34
  module ChildMove
35
35
  # <new link dir>/move.json: {from, ids, noted}, while a move is under way.
36
36
  INTENT_FILE = "move.json"
37
- # <new link dir>/starting: {pid, at}, from the move to the worker's spawn.
38
- STARTING_FILE = "starting"
39
- # A starting marker older than this, or whose process is gone, is left
40
- # over from a continue that died: it holds nothing back.
41
- STARTING_STALE = 60.0
42
37
  # Who the note to a moved child is from (ContextNote's label).
43
38
  NOTE_SOURCE = "session chain"
44
39
  # What a live child's worker must name in its sidecar to be moved.
@@ -206,44 +201,5 @@ module Samagotchi
206
201
  end
207
202
  SessionInbox.write_note(child_id, text: text, source: NOTE_SOURCE, state_dir: state_dir)
208
203
  end
209
-
210
- # Hold off a worker for the continue's new link (#starting?) until its
211
- # own worker holds it: written before the move, removed by that worker
212
- # once it has the owner lock (Worker#start).
213
- def mark_starting(session_id, state_dir:)
214
- dir = Session.session_dir(session_id, state_dir: state_dir)
215
- FileUtils.mkdir_p(dir)
216
- AtomicFile.write(File.join(dir, STARTING_FILE), JSON.generate({ "pid" => Process.pid, "at" => Time.now.iso8601(3) }))
217
- end
218
-
219
- def clear_starting(session_id, state_dir:)
220
- FileUtils.rm_f(File.join(Session.session_dir(session_id, state_dir: state_dir), STARTING_FILE))
221
- end
222
-
223
- # Whether a continue is moving delegates to +session_id+ right now: its
224
- # marker is there, fresh, and the process that wrote it runs.
225
- def starting?(session_id, state_dir:)
226
- file = File.join(Session.session_dir(session_id, state_dir: state_dir), STARTING_FILE)
227
- return false unless File.exist?(file)
228
-
229
- data = JSON.parse(File.read(file))
230
- return false if Time.now - Time.iso8601(data["at"].to_s) > STARTING_STALE
231
-
232
- process_alive?(data["pid"].to_i)
233
- rescue JSON::ParserError, ArgumentError, SystemCallError
234
- false
235
- end
236
-
237
- # Signal 0 only asks whether the process exists.
238
- def process_alive?(pid)
239
- return false unless pid.positive?
240
-
241
- Process.kill(0, pid)
242
- true
243
- rescue Errno::ESRCH
244
- false
245
- rescue Errno::EPERM
246
- true
247
- end
248
204
  end
249
205
  end
@@ -113,7 +113,7 @@ module Samagotchi
113
113
  # session counts loaded itself.
114
114
  def summary_of(session, state_dir:)
115
115
  live = WorkerLink.resolve(Session.session_dir(session.id, state_dir: state_dir), unlink: false).worker_owned?
116
- { live: live, busy: live && session.status == Session::STATUS_RUNNING, status: session.status,
116
+ { live: live, busy: live && session.status == Session::STATUS_RUNNING, saved_status: session.status,
117
117
  updated_at: session.updated_at, waiting: session.waiting_question(live: live)&.dig(:kind),
118
118
  preview: session.last_prompt.to_s }
119
119
  end
@@ -55,7 +55,7 @@ module Samagotchi
55
55
  def resolve(given)
56
56
  @session_id = @access.resolve(given)
57
57
  rescue ArgumentError => e
58
- error_line("#{command_name}: #{e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"}")
58
+ error_line("#{command_name}: #{e.message}")
59
59
  nil
60
60
  end
61
61
 
@@ -8,11 +8,14 @@ require_relative "config_text_edit"
8
8
  require_relative "log"
9
9
  require_relative "model_ref"
10
10
  require_relative "paths"
11
- require_relative "llm_context_strategy"
12
- require_relative "host_model"
13
11
  require_relative "config/restart_watch"
14
12
 
15
13
  module Samagotchi
14
+ # Which LLM context strategy a turn runs under; it reads Config.
15
+ autoload :LLMContextStrategy, File.expand_path("llm_context_strategy", __dir__)
16
+ # A hosts.<name>.models entry and its price; they read ConfigFile.
17
+ autoload :HostModel, File.expand_path("host_model", __dir__)
18
+ autoload :ModelPrice, File.expand_path("model_price", __dir__)
16
19
  # Parses the thinking: levels of host and model entries (it needs Config).
17
20
  autoload :Thinking, File.expand_path("thinking", __dir__)
18
21
  # One hosts: entry; it needs ConfigFile's readers.
@@ -89,6 +92,12 @@ module Samagotchi
89
92
  next_history_access next_matching_turn_display_poll_write
90
93
  ].freeze
91
94
 
95
+ # llm_context.policy's default: the sentence forget_outputs' description
96
+ # carries (ToolDeclarations::FORGET_OUTPUTS_POLICY), the plan's D7 line,
97
+ # adapted from CLM's steering example.
98
+ DEFAULT_LLM_CONTEXT_POLICY = "Tidy at subtask boundaries: once a subtask is done, forget its tool outputs and note " \
99
+ "what it established; keep anything you'll still edit against."
100
+
92
101
  ENTRIES = [
93
102
  # universal – env+config+cli
94
103
  Entry.new(key: "default.model", yaml_path: %w[default model], type: :string, default: nil, expose: %i[env config cli], lifecycle: :new_session),
@@ -261,7 +270,7 @@ module Samagotchi
261
270
  # and hosts.<name>.llm_context_budget_tokens come first. Kept as written: LLMContextStrategy.parse_budget reads it.
262
271
  Entry.new(key: "llm_context.budget_tokens", yaml_path: %w[llm_context budget_tokens], type: :string, default: nil, expose: %i[env config], lifecycle: :next_matching_turn_display_poll_write),
263
272
  # The policy sentence forget_outputs' description carries (the forget layer); blank: none.
264
- Entry.new(key: "llm_context.policy", yaml_path: %w[llm_context policy], type: :string, default: LLMContextStrategy::DEFAULT_POLICY, expose: %i[env config], lifecycle: :worker_start),
273
+ Entry.new(key: "llm_context.policy", yaml_path: %w[llm_context policy], type: :string, default: DEFAULT_LLM_CONTEXT_POLICY, expose: %i[env config], lifecycle: :worker_start),
265
274
 
266
275
  # How much models think: off|low|medium|high|default, parsed by Thinking
267
276
  # (a :string, since YAML reads an unquoted off as false). The CLI flag
@@ -399,7 +408,7 @@ module Samagotchi
399
408
  # appended_prompt_files, Hooks::Loader, ConfigFile.bundle_settings.
400
409
  FREE_FORM_MAPS = %w[model_aliases hooks bundles memories append_system_prompt].freeze
401
410
  # Maps of named entries and the keys an entry may hold
402
- # (ConfigFile.hosts_config, ConfigFile.model_settings).
411
+ # (HostConfig.all, ModelSettings.all).
403
412
  MAP_ENTRY_KEYS = {
404
413
  "hosts" => %w[host port url api api_key_env auth dialect profile first_token_timeout vision sampling thinking
405
414
  reasoning_history enabled remote window_tokens steer_cut_after llm_context_strategy llm_context_apply llm_context_budget_tokens models].freeze,
@@ -408,6 +417,8 @@ module Samagotchi
408
417
  }.freeze
409
418
  # The keys a hosts.<name>.models entry may hold (HostModel.parse_map).
410
419
  HOST_MODEL_KEYS = %w[price served].freeze
420
+ # The keys a hosts.<name>.models.<id>.price may hold (ModelPrice.parse).
421
+ MODEL_PRICE_KEYS = %w[input cache_read cache_write output].freeze
411
422
  # Section keys beyond the registry's: guardrails' YAML rules (Engine#guardrail_rules).
412
423
  # And toolkit's kits (Toolkit.kits), and bundles' judgments' settings (SystemOne::JudgmentSettings).
413
424
  SECTION_EXTRA_KEYS = { "guardrails" => %w[rules disable].freeze, "toolkit" => %w[kits].freeze, "system_one" => %w[judgments].freeze }.freeze
@@ -887,9 +898,9 @@ module Samagotchi
887
898
  def price_key_problems(prefix, price)
888
899
  return [] unless price.is_a?(Hash)
889
900
 
890
- (price.keys.map(&:to_s) - ModelPrice::KEYS).map do |k|
901
+ (price.keys.map(&:to_s) - MODEL_PRICE_KEYS).map do |k|
891
902
  # A renamed key is compared by its new name, so that name is the hint.
892
- unknown_key_message("#{prefix}#{k}", RENAMED_PRICE_KEYS.fetch(k, k), ModelPrice::KEYS, prefix: prefix)
903
+ unknown_key_message("#{prefix}#{k}", RENAMED_PRICE_KEYS.fetch(k, k), MODEL_PRICE_KEYS, prefix: prefix)
893
904
  end
894
905
  end
895
906
 
@@ -1172,71 +1183,6 @@ module Samagotchi
1172
1183
  HOST_NAME_RE = /\A[a-z0-9][a-z0-9._-]*\z/i
1173
1184
  ENV_NAME_RE = /\A[A-Za-z_][A-Za-z0-9_]*\z/
1174
1185
 
1175
- # config.yml's hosts: by lowercased name; an entry that is disabled or
1176
- # invalid (warned) is left out. No entry left: "default", from
1177
- # server.host/port.
1178
- # @return [Hash{String => HostConfig}]
1179
- def hosts_config(env: ENV, path: global_path(env: env))
1180
- data = read_yaml(env: env, path: path)
1181
- raw_hosts = data[HOSTS_KEY] if data.is_a?(Hash)
1182
-
1183
- normalized = {}
1184
- if raw_hosts.is_a?(Hash)
1185
- raw_hosts.each do |raw_name, raw_cfg|
1186
- host = HostConfig.parse(raw_name, raw_cfg)
1187
- normalized[host.name] = host if host
1188
- end
1189
- warn_duplicate_host_models(normalized)
1190
- end
1191
- normalized["default"] = default_host_config(data, env) if normalized.empty?
1192
- normalized
1193
- rescue StandardError => e
1194
- Log.error(:config, "hosts_config_failed", error: e.class.name, message: e.message)
1195
- {}
1196
- end
1197
-
1198
- # The host chi uses when hosts: names none: server.host/port.
1199
- def default_host_config(data, env)
1200
- opts = { file_data: data, env: env, cli_overrides: Samagotchi::Config.cli_overrides }
1201
- host = Samagotchi::Config.resolve("server.host", **opts).to_s.strip
1202
- host = "localhost" if host.empty?
1203
- port = Samagotchi::Config.resolve("server.port", **opts).to_i
1204
- port = 8080 if port <= 0 || port > 65_535
1205
- HostConfig.new(name: "default", host: host, port: port)
1206
- end
1207
- private_class_method :default_host_config
1208
-
1209
- # Two hosts declaring one id under hosts.<name>.models: a bare id goes
1210
- # where HostRegistry#host_for_model sends it (the default host when it
1211
- # declares the id, else the first in hosts: order); say so once.
1212
- def warn_duplicate_host_models(hosts)
1213
- declared = Hash.new { |h, k| h[k] = [] }
1214
- hosts.each { |name, cfg| cfg.models.each { |down, model| declared[down] << [name, model.id] } }
1215
- declared.each_value do |owners|
1216
- next if owners.size < 2
1217
-
1218
- names = owners.map(&:first)
1219
- id = owners.first.last
1220
- goes = names.include?("default") ? "default" : "#{names.first} unless the default host lists it"
1221
- warn_once "Warning: #{names.map { |n| "hosts.#{n}.models" }.join(" and ")} #{names.size == 2 ? "both" : "all"} declare #{id}; " \
1222
- "a bare #{id} goes to #{goes}"
1223
- end
1224
- end
1225
- private_class_method :warn_duplicate_host_models
1226
-
1227
- # The names (lowercased) of the hosts entries with enabled: false, which
1228
- # hosts_config leaves out, from config.yml's hosts:.
1229
- # @return [Array<String>]
1230
- def disabled_host_names(env: ENV, path: global_path(env: env))
1231
- data = read_yaml(env: env, path: path)
1232
- raw = data[HOSTS_KEY] if data.is_a?(Hash)
1233
- return [] unless raw.is_a?(Hash)
1234
-
1235
- raw.filter_map { |name, cfg| name.to_s.strip.downcase if cfg.is_a?(Hash) && HostConfig.disabled?(cfg) }
1236
- rescue StandardError # degrade-ok: Config↔Log: Log resolves its file and level through Config
1237
- []
1238
- end
1239
-
1240
1186
  # Resolve the `recap:` section through the Config registry so scalar
1241
1187
  # recap settings share the single precedence path (CLI > ENV > file >
1242
1188
  # default). Returns false when explicitly disabled (file `recap: false`
@@ -1420,47 +1366,6 @@ module Samagotchi
1420
1366
  {}
1421
1367
  end
1422
1368
 
1423
- # The top-level `models:` map: per-model settings keyed by model id or
1424
- # alias (downcased), e.g. `models: {my-alias: {profile: qwen36}}`, each a
1425
- # ModelSettings; an entry that is not a map is skipped.
1426
- # @return [Hash{String => ModelSettings}]
1427
- def model_settings(env: ENV, path: global_path(env: env))
1428
- data = read_yaml(env: env, path: path)
1429
- raw = data[MODELS_KEY] if data.is_a?(Hash)
1430
- return {} unless raw.is_a?(Hash)
1431
-
1432
- raw.each_with_object({}) do |(k, v), result|
1433
- key = k.to_s.strip.downcase
1434
- settings = ModelSettings.parse(key, v)
1435
- result[key] = settings if settings
1436
- end
1437
- rescue StandardError # degrade-ok: Config↔Log: Log resolves its file and level through Config
1438
- {}
1439
- end
1440
-
1441
- # The first +names+ (model as typed, alias-resolved, bare, …) whose
1442
- # models: entry sets +field+ (a ModelSettings member; another name
1443
- # raises ArgumentError): a ModelSetting (key, value), or nil.
1444
- # VisionSupport and SamplingSettings look a model up the same way.
1445
- # @param models [Hash{String => ModelSettings}, nil] model_settings by default
1446
- # @return [ModelSetting, nil]
1447
- def model_setting(names, field, models: nil)
1448
- raise ArgumentError, "unknown models: field #{field.inspect}" unless ModelSettings.members.include?(field)
1449
-
1450
- models ||= model_settings
1451
- Array(names).map { |name| name.to_s.strip.downcase }.reject(&:empty?).uniq.each do |key|
1452
- value = models[key]&.public_send(field)
1453
- return ModelSetting.new(key: key, value: value) unless value.nil?
1454
- end
1455
- nil
1456
- end
1457
-
1458
- # +raw+ parsed against config.yml's hosts (or +hosts+) and aliases.
1459
- # @return [ModelRef]
1460
- def model_ref(raw, env: ENV, path: global_path(env: env), hosts: nil)
1461
- ModelRef.parse(raw, hosts: hosts || hosts_config(env: env, path: path), aliases: model_aliases(env: env, path: path))
1462
- end
1463
-
1464
1369
  # Words /model takes as arguments (clear/default/none/off reset the
1465
1370
  # runtime model), so no alias may be named after one.
1466
1371
  RESERVED_MODEL_ALIASES = %w[clear default none off].freeze
@@ -428,8 +428,7 @@ module Samagotchi
428
428
  session = @access.show(id)
429
429
  Target.new(session_id: id, project_root: session.project_root, cwd: session.working_directory, label: id[0, 8])
430
430
  rescue ArgumentError => e
431
- message = e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"
432
- error_line("#{command_name}: #{message}")
431
+ error_line("#{command_name}: #{e.message}")
433
432
  nil
434
433
  end
435
434
 
@@ -50,11 +50,11 @@ module Samagotchi
50
50
  # and vision look a model up), else hosts.<name>.window_tokens.
51
51
  # @param target [HostRegistry::ModelTarget]
52
52
  # @param names [Array<String>] HostRegistry#lookup_names
53
- # @param models [Hash{String => ModelSettings}, nil] ConfigFile.model_settings (specs)
53
+ # @param models [Hash{String => ModelSettings}, nil] ModelSettings.all (specs)
54
54
  # @return [Resolved, nil]
55
55
  def setting(target, names:, models: nil)
56
- models ||= ConfigFile.model_settings
57
- tokens = ConfigFile.model_setting(names, :window_tokens, models: models)&.value
56
+ models ||= ModelSettings.all
57
+ tokens = ModelSettings.find(names, :window_tokens, models: models)&.value
58
58
  return Resolved.new(tokens: tokens, source: :model_setting) if positive_integer?(tokens)
59
59
 
60
60
  tokens = target&.entry&.window_tokens
@@ -335,7 +335,7 @@ module Samagotchi
335
335
  # are left out. @return [Array<Hash>] name:, hosts:, state:
336
336
  def host_keys
337
337
  config_env = { "XDG_CONFIG_HOME" => launch_config_home }
338
- hosts = ConfigFile.hosts_config(env: config_env).values.select(&:api_key_env).group_by(&:api_key_env)
338
+ hosts = HostConfig.all(env: config_env).values.select(&:api_key_env).group_by(&:api_key_env)
339
339
  hosts.map do |name, entries|
340
340
  state = if HostKeys.from_keychain(name, env: @env) then :keychain
341
341
  elsif !@env[name].to_s.strip.empty? then :shell_only
@@ -2,137 +2,39 @@
2
2
 
3
3
  module Samagotchi
4
4
  class Engine
5
- # The attached-context candidate judge (ContextJudge): its pass at an
6
- # owner turn's start and for a late candidate, the asks it starts, and
7
- # the cards it shows after the turn. Reads @context_cards_after_turn
8
- # under @context_cards_mutex, @side_judgments, @host_registry, @session.
5
+ # The attached-context candidate judge (TurnStages::ContextJudge,
6
+ # forwarded): prepare_turn starts its pass, run_turn's ensure shows the
7
+ # cards it held, and a plugin's late candidate asks it. Reads
8
+ # @context_judge, @side_judgments, @host_registry, @turn_state, @session.
9
9
  module ContextJudgePart
10
10
  private
11
11
 
12
- # The candidate judge (ContextJudge) at an owner turn's start, after
13
- # the toolkit's ask settled: the session's candidates' status changes
14
- # are logged (ContextCandidatesLog#look) and, unless
15
- # context.candidates.mode is off, the proposed candidates due an ask
16
- # are asked about the owner's prompts so far, this one included. Never
17
- # a participant's turn, a review session or a Continue (no new prompt).
18
- def start_context_judge(turn)
19
- session = turn.session
20
- return unless turn.prompt.is_a?(String) && OwnerPrompt.turn?(session: session, author: turn.origin.author)
21
-
22
- context_judge_pass(session, ContextCandidates.owner_texts(session.messages) + [turn.prompt], phase: "turn")
23
- end
24
- private :start_context_judge
12
+ # The judge's stage (TurnStages::ContextJudge).
13
+ attr_reader :context_judge
25
14
 
26
15
  # A late proposal no owner prompt mentions (Plugin::Host
27
- # #candidate_proposed: github-pr's init task runs beside the first
28
- # turn): judged on the owner's prompts so far, the running turn's too.
16
+ # #candidate_proposed, maybe while the plugins load, before a session
17
+ # or the event log is there): TurnStages::ContextJudge#late_candidates
18
+ # on the session the Engine is on (none, or a review session: nothing,
19
+ # before anything else is read), the owner's prompts so far, the
20
+ # recorder and the sessions dir, all read now on the caller's thread.
29
21
  def judge_late_candidates
30
22
  session = @session
31
23
  return if session.nil? || session.review?
32
24
 
33
- texts = ContextCandidates.owner_texts(plugin_messages)
34
- context_judge_pass(session, texts, phase: "late") unless texts.empty?
35
- end
36
- private :judge_late_candidates
37
-
38
- # One look at +session+'s candidates; the asks go to SideJudgments'
39
- # :context kind, one thread asking each due candidate in turn.
40
- def context_judge_pass(session, texts, phase:)
41
- return if session.id.nil? || session.scratch
42
-
43
- rows = ContextCandidates.with_status(session.id, project_root: session.project_root, state_dir: session_state_dir)
44
- return if rows.empty?
45
-
46
- log = ContextCandidatesLog.log_for(session_state_dir)
47
- records = ContextCandidatesLog.records_for(log, session.id)
48
- ContextCandidatesLog.look(log, session.id, rows, records) if phase == "turn"
49
- settings = ContextJudge::Settings.from_config
50
- return if settings.mode == "off"
51
-
52
- jobs = ContextJudge.jobs(rows, records, session_id: session.id, texts: texts, settings: settings, phase: phase,
53
- parent_attached: context_parent_attached(session))
54
- return if jobs.empty?
55
-
56
- client = system_one_recording(SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :context),
57
- ContextJudge::JUDGMENT, session, mode: settings.mode)
58
- return unless client
59
-
60
- start_context_asks(client, jobs, log, settings, context_choice(session, log, settings))
61
- rescue StandardError => e
62
- ContextJudge.warn(e)
25
+ @context_judge.late_candidates(session, messages: plugin_messages, recorder: system_one_recorder,
26
+ state_dir: session_state_dir)
63
27
  end
64
- private :context_judge_pass
65
-
66
- # @return [Thread, nil] the asks' thread, nil when none started
67
- def start_context_asks(client, jobs, log, settings, choice)
68
- if @side_judgments.in_flight?(:context)
69
- Log.info(:context, "context_judge_in_flight_skipped", session_id: jobs.first.session_id)
70
- return nil
71
- end
72
-
73
- @side_judgments.start(:context, warn: ContextJudge.method(:warn)) do
74
- jobs.each { |job| choice.call(job, ContextJudge.ask(client: client, job: job, log: log, settings: settings)) }
75
- end
76
- end
77
- private :start_context_asks
78
-
79
- # What an answer does on the asks' thread (ContextCandidateChoice
80
- # #after_judged: on mode attaches or cards; audit nothing). Taken on
81
- # the caller's thread. The attach only writes the session's folder: its
82
- # note comes with the next between-turn delivery, never into a running
83
- # turn. A card shows only while this Engine is still on that session,
84
- # and not in a running turn (it isn't the turn's: no "waiting for you"
85
- # on it), but once it has ended.
86
- # @return [#call] (job, fields)
87
- def context_choice(session, log, settings)
88
- session_id = session.id
89
- project_root = session.project_root
90
- state_dir = session_state_dir
91
- show = lambda do |**card|
92
- current = @session
93
- next nil unless current.nil? || current.id == session_id
94
-
95
- @context_cards_mutex.synchronize do
96
- next @context_cards_after_turn << card if turn_running?
97
-
98
- show_card(**card)
99
- end
100
- end
101
- lambda do |job, fields|
102
- ContextCandidateChoice.after_judged(job: job, fields: fields, settings: settings, project_root: project_root,
103
- state_dir: state_dir, log: log, show_card: show)
104
- end
105
- end
106
- private :context_choice
107
-
108
- # The cards #context_choice held while a turn ran (#run_turn's ensure,
109
- # after the turn flag is released).
110
- def show_context_cards_after_turn
111
- cards = @context_cards_mutex.synchronize { @context_cards_after_turn.slice!(0..) }
112
- cards.each { |card| show_card(**card) }
113
- rescue StandardError => e
114
- ContextJudge.warn(e)
115
- end
116
- private :show_context_cards_after_turn
117
-
118
- # A delegate child's parent's sources, by title (else name): the
119
- # judge's started_from. nil for any other session (a chi send --new
120
- # session has no parent; a fork isn't a delegate).
121
- def context_parent_attached(session)
122
- return nil unless session.delegate? && session.parent_id
123
28
 
124
- parent_root = if Session.exist?(session.parent_id, state_dir: session_state_dir)
125
- Session.load(session.parent_id, state_dir: session_state_dir).project_root
126
- else
127
- session.project_root
128
- end
129
- ContextSources.attached(session.parent_id, project_root: parent_root, state_dir: session_state_dir)
130
- .map { |attached| attached.source.title || attached.name }
131
- rescue StandardError => e
132
- Log.warn(:context, "context_parent_attached_failed", error: e.class.name, msg: e.message.to_s[0, 200])
133
- []
29
+ # The stage over this Engine's side asks (built in
30
+ # #build_tools_and_plugins, before the plugins load: one may propose a
31
+ # candidate as it loads). Its two callables are the asks thread's late
32
+ # reads: the session the Engine is on and where a card goes, both as
33
+ # an answer comes.
34
+ def build_context_judge
35
+ TurnStages::ContextJudge.new(side_judgments: @side_judgments, host_registry: @host_registry, turn_state: @turn_state,
36
+ show_card: ->(**card) { show_card(**card) }, current_session: -> { @session })
134
37
  end
135
- private :context_parent_attached
136
38
  end
137
39
  end
138
40
  end
@@ -76,11 +76,13 @@ module Samagotchi
76
76
  end
77
77
 
78
78
  # Ask the user to approve a call the gate voted `ask` on
79
- # (GuardrailWiring#request_approval).
79
+ # (GuardrailWiring#request_approval), for the running turn (its
80
+ # author), else the last one. The gate's own asks come with their
81
+ # call's TurnContext instead.
80
82
  # @param verdict [Guardrails::Verdict]
81
83
  # @return [Guardrails::Verdict]
82
84
  def request_approval(verdict)
83
- @guardrail_wiring.request_approval(verdict)
85
+ @guardrail_wiring.request_approval(verdict, turn_context || last_turn_context)
84
86
  end
85
87
  end
86
88
  end