samagotchi 0.155.0 → 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 (124) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -1
  3. data/bin/chi +1 -1
  4. data/docs/architecture.md +10 -6
  5. data/docs/internals/anthropic.md +1 -1
  6. data/docs/internals/attached-context.md +3 -2
  7. data/docs/internals/background-tasks.md +1 -1
  8. data/docs/internals/engine.md +625 -935
  9. data/docs/internals/llm-context-forget.md +8 -3
  10. data/docs/internals/objectives.md +2 -2
  11. data/docs/internals/prompt-caching.md +4 -1
  12. data/docs/internals/remote-sessions.md +3 -1
  13. data/docs/internals/working-set.md +1 -1
  14. data/docs/sessions.md +2 -2
  15. data/docs/system-one.md +3 -2
  16. data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
  17. data/lib/samagotchi/broadcast_command.rb +3 -3
  18. data/lib/samagotchi/bundle_command/try/sandbox.rb +1 -1
  19. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  20. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  21. data/lib/samagotchi/child_move.rb +7 -51
  22. data/lib/samagotchi/children_status.rb +1 -1
  23. data/lib/samagotchi/cli/parent_wait.rb +1 -1
  24. data/lib/samagotchi/config.rb +17 -112
  25. data/lib/samagotchi/context_command.rb +1 -2
  26. data/lib/samagotchi/context_window.rb +3 -3
  27. data/lib/samagotchi/desktop/macos.rb +1 -1
  28. data/lib/samagotchi/engine/context_judge.rb +19 -11
  29. data/lib/samagotchi/engine/messages.rb +1 -1
  30. data/lib/samagotchi/engine/models.rb +5 -2
  31. data/lib/samagotchi/engine/objectives.rb +29 -11
  32. data/lib/samagotchi/engine/side_requests.rb +21 -11
  33. data/lib/samagotchi/engine/toolkit.rb +10 -9
  34. data/lib/samagotchi/engine/turn_end.rb +1 -1
  35. data/lib/samagotchi/engine/turn_helpers.rb +7 -7
  36. data/lib/samagotchi/engine/verify.rb +1 -1
  37. data/lib/samagotchi/engine.rb +14 -9
  38. data/lib/samagotchi/guardrails/targets.rb +24 -9
  39. data/lib/samagotchi/host_config.rb +72 -1
  40. data/lib/samagotchi/host_model.rb +2 -6
  41. data/lib/samagotchi/host_registry.rb +1 -1
  42. data/lib/samagotchi/kernel_loop.rb +46 -121
  43. data/lib/samagotchi/llm/anthropic_wire.rb +2 -2
  44. data/lib/samagotchi/llm/chat_loop.rb +34 -18
  45. data/lib/samagotchi/llm/native_backend.rb +1 -1
  46. data/lib/samagotchi/llm_context_apply.rb +54 -1
  47. data/lib/samagotchi/llm_context_notice.rb +1 -1
  48. data/lib/samagotchi/llm_context_strategy.rb +7 -9
  49. data/lib/samagotchi/llm_context_view.rb +9 -0
  50. data/lib/samagotchi/model_price.rb +4 -10
  51. data/lib/samagotchi/model_profile.rb +6 -6
  52. data/lib/samagotchi/model_settings.rb +37 -2
  53. data/lib/samagotchi/note_command.rb +1 -2
  54. data/lib/samagotchi/objectives_audit.rb +4 -3
  55. data/lib/samagotchi/participants.rb +2 -2
  56. data/lib/samagotchi/plugin/context.rb +1 -1
  57. data/lib/samagotchi/plugin/sessions.rb +2 -5
  58. data/lib/samagotchi/plugin/tool_spec.rb +5 -3
  59. data/lib/samagotchi/project_env.rb +1 -1
  60. data/lib/samagotchi/reasoning_history.rb +3 -3
  61. data/lib/samagotchi/reminder_carry.rb +1 -1
  62. data/lib/samagotchi/reprefill_meter.rb +45 -0
  63. data/lib/samagotchi/review_command.rb +1 -1
  64. data/lib/samagotchi/sampling_settings.rb +3 -3
  65. data/lib/samagotchi/self_report.rb +7 -7
  66. data/lib/samagotchi/session.rb +50 -7
  67. data/lib/samagotchi/session_access/local.rb +7 -5
  68. data/lib/samagotchi/session_access/remote.rb +17 -3
  69. data/lib/samagotchi/session_archive_command.rb +2 -2
  70. data/lib/samagotchi/session_chain.rb +7 -6
  71. data/lib/samagotchi/session_continue.rb +6 -5
  72. data/lib/samagotchi/session_delete_command.rb +1 -1
  73. data/lib/samagotchi/session_manager/delivery.rb +254 -0
  74. data/lib/samagotchi/session_manager/emptiness.rb +138 -0
  75. data/lib/samagotchi/session_manager/lifecycle.rb +274 -0
  76. data/lib/samagotchi/session_manager/listing.rb +189 -0
  77. data/lib/samagotchi/session_manager/removal.rb +190 -0
  78. data/lib/samagotchi/session_manager/spawn.rb +242 -0
  79. data/lib/samagotchi/session_manager/worker_command.rb +157 -0
  80. data/lib/samagotchi/session_manager.rb +26 -1540
  81. data/lib/samagotchi/session_metrics.rb +1 -1
  82. data/lib/samagotchi/sessions_command.rb +1 -1
  83. data/lib/samagotchi/share_command.rb +1 -1
  84. data/lib/samagotchi/starting_marker.rb +65 -0
  85. data/lib/samagotchi/steer_cut_after.rb +3 -3
  86. data/lib/samagotchi/system_one/recorder.rb +49 -0
  87. data/lib/samagotchi/terminal_ui/exit_lines.rb +1 -1
  88. data/lib/samagotchi/terminal_ui.rb +2 -6
  89. data/lib/samagotchi/testing/sessions.rb +1 -1
  90. data/lib/samagotchi/thinking.rb +3 -3
  91. data/lib/samagotchi/token_usage.rb +1 -1
  92. data/lib/samagotchi/tool_activity.rb +10 -4
  93. data/lib/samagotchi/tool_declarations.rb +2 -2
  94. data/lib/samagotchi/tool_output_cut.rb +17 -0
  95. data/lib/samagotchi/tool_runner.rb +7 -4
  96. data/lib/samagotchi/toolkit_command.rb +1 -1
  97. data/lib/samagotchi/tools/delegate.rb +10 -5
  98. data/lib/samagotchi/tools/delegate_result.rb +4 -4
  99. data/lib/samagotchi/tools/index_hint.rb +14 -0
  100. data/lib/samagotchi/tools/registry.rb +28 -11
  101. data/lib/samagotchi/tools/send_note.rb +4 -6
  102. data/lib/samagotchi/tools/tool_index.rb +52 -51
  103. data/lib/samagotchi/tools/tool_surface.rb +22 -8
  104. data/lib/samagotchi/turn_context.rb +2 -1
  105. data/lib/samagotchi/turn_stages/context_judge.rb +38 -36
  106. data/lib/samagotchi/turn_stages/objectives.rb +56 -52
  107. data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +13 -17
  108. data/lib/samagotchi/turn_stages/verify_anchors.rb +9 -10
  109. data/lib/samagotchi/turn_stages/warmup.rb +14 -15
  110. data/lib/samagotchi/version.rb +1 -1
  111. data/lib/samagotchi/vision_support.rb +3 -3
  112. data/lib/samagotchi/web/app.rb +15 -8
  113. data/lib/samagotchi/web/participant_access.rb +2 -2
  114. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  115. data/lib/samagotchi/web/session_control.rb +3 -3
  116. data/lib/samagotchi/web/session_create.rb +3 -3
  117. data/lib/samagotchi/web/session_hub.rb +1 -1
  118. data/lib/samagotchi/web/session_show.rb +3 -3
  119. data/lib/samagotchi/web/session_summary.rb +3 -4
  120. data/lib/samagotchi/worker.rb +55 -27
  121. data/lib/samagotchi/worker_boot.rb +184 -0
  122. data/lib/samagotchi/worker_signals.rb +1 -1
  123. data/lib/samagotchi/worker_task_wakes.rb +2 -2
  124. metadata +13 -1
@@ -29,7 +29,7 @@ lets the model forget its own tool outputs. The pieces:
29
29
  those steps, counted with the forget's own: `protect_steps + 1`) unless the forget keeps lines of it; a keep
30
30
  outside the output's lines, of a preview, or holding all of it; an output of `SMALL` (80) chars or less; an output
31
31
  with an edit already; an unknown id. What passes is saved as a staged `LLMContextEdit` of kind `:forget` (`by:
32
- "model"`, the note, `keep`), and the apply rule runs at once as at a request (`KernelLoop#apply_llm_context!`), so
32
+ "model"`, the note, `keep`), and the apply rule runs at once as at a request (`LLMContextApply.apply!`, through `KernelLoop#apply_llm_context!`), so
33
33
  the result can say whether the batch goes with the next request or waits for the turn's end. `LLMContextApply`'s
34
34
  protect_steps hold covers stale's stubs only: a forget was checked when it was made.
35
35
  - **Restore** takes the forget off the entry (`LLMContextEdit.remove`, a new edits Hash); the next request sends the
@@ -38,8 +38,13 @@ lets the model forget its own tool outputs. The pieces:
38
38
  - **Which edits are sent** is one predicate, `LLMContextView#sends?(edit)`: applied, and its kind one of the view's
39
39
  layers or `:drop`. The view stubs by it and `WorkingSet` decides "still sent" by it. The view's layers are
40
40
  `LLMContextStrategy::LAYERS` (`stale`, `forget`), the strategy's words, not `LLMContextEdit::KINDS`: whatever a
41
- caller passes as the strategy, an edit kind no strategy names never becomes a layer that `apply_llm_context!` would
42
- hand to `LLMContextApply.run!`. Under none the view has no layers and, unless an entry holds a drop
41
+ caller passes as the strategy, an edit kind no strategy names never becomes a layer that `LLMContextApply.apply!` would
42
+ hand to `LLMContextApply.run!`. Both loops build their view with `LLMContextView.for(llm_context)` (the turn's
43
+ strategy, none without one) and apply with `LLMContextApply.apply!(conversation, llm_context, moment:, context:,
44
+ warmup:, emit:, model:)`, module functions that hold no loop state: the run, its `llm_context_apply` log line (with
45
+ the run's model) and its ✂ row (`LLMContextNotice`; none for the warm-up's copy). `KernelLoop#llm_context_view` and
46
+ `#apply_llm_context!` are one-line defaults over them (the native run's settings), `ChatLoop`'s the same over its
47
+ turn's. Under none the view has no layers and, unless an entry holds a drop
43
48
  (`LLMContextEdit.drop_on?`, one scan), `#messages` returns the Array it was given.
44
49
  - **The user's layer, always on**: an edit of kind `:drop` (`by: "user"`, the working set's drop:
45
50
  [working-set.md](working-set.md#steering-drop-and-undrop)) is sent under every strategy, none included, as
@@ -117,7 +117,7 @@ Bridge's `TurnAccumulator#current_messages`) leaves tool calls out. A move shows
117
117
  `action: propose` (`change` add, reword, done or drop; `id`; `text`; `note`, why) checks the change against the list
118
118
  (`Objectives::Proposal#refusal`: its line through `Objectives::Edit`, the edit the card's Apply runs, so the tool refuses
119
119
  what Apply would) and hands it to `Engine#propose_objective` (`TurnStages::Objectives#propose`), which keeps it until the turn is over,
120
- as `TurnStages::ContextJudge`'s cards are kept. `run_turn`'s ensure (`TurnStages::Objectives#finally`) shows them only for a turn that completed: a canceled or
120
+ as `TurnStages::ContextJudge`'s cards are kept. `run_turn`'s ensure (`Engine#show_objective_proposals`, the cards `TurnStages::Objectives#finally` gives) shows them only for a turn that completed: a canceled or
121
121
  failed one drops them, so its Apply card never outlives a turn whose call a
122
122
  rollback has taken out of the history, and nothing carries over to the next turn. A card is between turns (no
123
123
  `in_turn`), so it writes no `pending_card.json`, the stage doesn't say it waits, and it keeps its buttons over a
@@ -139,7 +139,7 @@ any call. An add's text can't split on ` | `. The body names the line, for a ter
139
139
 
140
140
  `Objectives.view` (`List#to_view`) is what the page draws: the items, each with its last move, and `pending`, whether
141
141
  the newest list is a file still waiting. The session read (`objectives`, no key with no list) and the live
142
- `:objectives_updated` carry it. `Engine#announce_objectives` (`TurnStages::Objectives#announce`) emits when the view differs from the last: after an edit,
142
+ `:objectives_updated` carry it. `Engine#announce_objectives` (under the event lock, `TurnStages::Objectives#announce` giving the event) emits when the view differs from the last: after an edit,
143
143
  once a list note is in (`add_context_note`), at a turn's end (its moves) and after a rollback between turns. A session
144
144
  with no list emits nothing. Between two tabs that is all it takes. A participant gets neither: the event is in
145
145
  `ParticipantView::DROPPED`.
@@ -222,7 +222,10 @@ is prefilled once. The native and the chat loop use the same plan.
222
222
  - The debug log's `generation_completed` line: `prompt=` (the request's prompt tokens), `cached=` (read from the
223
223
  server's cache), `cache_write=` (written to it, where the provider reports writes) and `reprefill=` (of the last
224
224
  request's prompt on the same model through the same host, what was prefilled again; a `/model` to the same bare
225
- model on another host is the switch's prefill, not a re-prefill). The `http` line of a
225
+ model on another host is the switch's prefill, not a re-prefill). The baseline is the Engine's `ReprefillMeter`
226
+ (`reprefill_meter.rb`), one for both loops: the last request whichever loop sent it, keyed by the model and the
227
+ host's client (`KernelLoop#client`, `ChatLoop#client`), so a switch to another host and back starts over on the
228
+ way back; the turn-end warm-up resets it (`KernelLoop#warmed_up!`). The `http` line of a
226
229
  remote Claude or Messages request shows `cache=on` (or `cache=1h`) when it carried breakpoints.
227
230
  - The warm-up logs `model warmup` with `cached=`, `prefilled=` and `prefill_ms=`.
228
231
  - `/stats`: "cached N (P%)", "cache writes" and "re-prefilled" for the session.
@@ -121,7 +121,9 @@ the session commands*. Keep that table in step with `SessionAccessRoutes`.
121
121
  - **Refusals.** `SessionAccessRoutes#refusal_response` (and the page routes that raise) send
122
122
  `{error, detail, message, …fields}`: `detail` is the page's wording, `message` is the exception's own text, and the
123
123
  fields are those the class is built from (`session_id`, `reason`, `ids`, `busy_id`, `version`).
124
- `Remote#raise_refusal` rebuilds the class that Local's callee raises: `ArgumentError`, `Session::AmbiguousId`,
124
+ `Remote#raise_refusal` rebuilds the class that Local's callee raises: `Session::NotFound` for a `404 not_found`
125
+ whose detail is `no session X` (or, from chi web 0.155.0 and older, `Session not found: X`), a plain
126
+ `ArgumentError` for another `not_found`, `Session::AmbiguousId`,
125
127
  `SessionManager::OwnedByTUI` / `DeleteRefused` / `ArchiveRefused` / `RestartRefused`, `SessionContinue::Refused`,
126
128
  and `ModelProfile::MissingModel` for `invalid_model` (no model there, or `--model`'s host unknown there: "could not
127
129
  start a session: …", exit 1, as here).
@@ -103,7 +103,7 @@ pending: [Steering: add and unadd](#steering-add-and-unadd)) (`Engine#emit_worki
103
103
  - after each `:generation_started`, in `build_stream_event_handler` (`EventFanout`), from the running turn's
104
104
  conversation (`Engine#live_conversation`, read on the turn's thread) under the turn's strategy
105
105
  (`TurnContext#settings`' `llm_context`; `EventFanout#working_set_llm_context`: the running turn's, the last turn's
106
- until it has its own). Both loops start a generation after `apply_llm_context!`, so the set is
106
+ until it has its own). Both loops start a generation after `LLMContextApply.apply!`, so the set is
107
107
  what that request sends;
108
108
  - once in `Engine#end_turn`, after `replace_session_messages` and before the end event, from the kept messages (the
109
109
  turn-end apply has run by then);
data/docs/sessions.md CHANGED
@@ -23,7 +23,7 @@ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/s
23
23
 
24
24
  A session is deleted if **expired by age OR overflow by count** (unless `keep_status` or the live-owner guard: a session a worker or `chi` still has open is never pruned). A session's `status` is its turn state (`idle`/`running`), not whether a worker is alive. Orphan dirs without a `*.json` are never deleted, except skeleton-only ones (see *Empty sessions*). Deletion removes both `*.json` and sidecar dir atomically. Set both `DAYS=0` and `MAX=0` to retain forever.
25
25
 
26
- **Worker idle exit:** a background worker (plain `chi`, Web UI sessions) exits after `SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES` (`session.idle_exit_minutes`, default `30`, `0`=never) with no turn running or queued, no client on its stream (an open web tab or attached terminal keeps it), no reminder registered, no background task it watches for a [finish wake](internals/background-tasks.md#finish-wake) still running or waiting for its wake turn (with `session.task_wakes: wake`; not one whose turn you stopped) and no continue offer (the step-limit question) waiting for an answer. It removes `bridge.json` and frees `owner.lock`; the next prompt or `--attach` wakes a new worker (`WorkerIdleExit`, `SessionManager.run_session_loop`). As its Bridge stops, the worker marks the lock leaving (`OwnerLock#mark_leaving`: `leaving_at`, and `leave_wait`, its `recap.timeout`), since it keeps the lock while it writes the recap on leave: an `--attach` or `--resume` meanwhile (here, or a remote one through chi web's wake) prints `(waiting for the session to finish its recap…)` (`LaunchMode::RECAP_WAIT`) and waits for the release, up to that budget plus `OwnerLock::LEAVE_MARGIN` (5 s), before starting the new worker (`OwnerLock.await_release`). A lock held by a worker that isn't leaving (a live one, a stuck one) is left as before. A client can also ask the worker to exit right away (Bridge `POST /session/:id/exit`; `/exit` in an attached terminal does, `/detach` doesn't): the same rules apply without the timeout (even with `0`), and the asker's own stream doesn't count. The reply names what keeps it up (for background tasks, which ones and how to stop them). When every hold is one the user can stop — reminders, a watched task still running, a delegate with a live worker — the 409 also carries `holds: [{kind:, items: [{id:, label:, detail:}]}]` (a reminder's detail is its schedule, `every 20m`/`every 1h`, `, due now` when it is already due), and an attached terminal asks what to do with them; its "stop them and exit" posts `{"stop": "all"}`, which clears the reminders, stops those tasks (`TaskRuntime.stop_task`, as `task_stop`) and stops those children (`SessionManager.stop_session`) before leaving, re-checking first that nothing the user can't stop appeared meanwhile (`HoldStop`). A delegate counts only for that ask: it never holds the idle exit, and an exit that deletes or archives the session does not ask about it. The session's status stays as it was, not `stopped`. With `"restart": true` (a worker whose sidecar lists the `restart` feature) the worker hands the session to a new worker on the newest chi installed: it leaves, then starts its successor once its lock is free, keeping even an empty session and writing no recap. Clients' streams don't hold a restart (a web tab and an attached terminal reconnect to the new worker); what would be lost does: a turn running or queued, a continue offer, a question or approval waiting, a `/btw` (or another anytime command) still running, a delegate's approval relay open or settled in the last 10 minutes, and background tasks this conversation started that still run (`WorkerIdleExit#hold_for_restart`; 409 `held` with `reason`). Reminders go along: as it leaves, the worker writes them to `<id>/reminders.json` (0600: `session_id`, `written_at`, and each reminder's id, name, description, interval and next fire as wall-clock time; `ReminderCarry`), and its successor reads and deletes the file as it starts, before its idle scheduler runs. A restored reminder keeps its id and schedule; one that came due meanwhile fires once at once, and one further ahead than its interval (a clock that jumped) is due one interval from now. The file counts only for the same session within 2 minutes of `written_at`, and not once the session is stopped: otherwise it is deleted unread (log `reminders_carry_dropped`, at warn unless the session was stopped). A stop that lands while the worker leaves for a restart wins: the worker writes no file, and no successor starts (`SessionManager.run_session_loop`, log `restart_dropped`); an archive deletes the file of each session it archives. Once an exit or restart is taken (claimed before a stop-all stops anything), the first one decides how the worker leaves: a later request of the same kind is answered as that exit, one of another kind (a stop-all from another terminal after a restart, chi web's "Restart workers" after a `/exit`) gets 409 `held` with `reason` `leaving_exit`, `leaving_delete` or `leaving_restart`, and a restart says it was refused rather than "restarted". A write that fails keeps the worker up with its reminders (log `reminders_carry_failed`), and the caller reports no new worker. A reminder already due when the restart is asked runs its turn first, so the restart's wait (10 s) may report no new worker yet though the hand-off follows. A worker from before this still refuses a restart for its reminders (409 `reminders`). Only an asked restart carries them: the fallback's TERM (a worker that can't be asked, below), a stop, an idle exit (which reminders hold anyway) and a crash drop them as before. What lives only in the worker's memory goes: the event history a late client replays, the kept `/btw` answers, a plugin's in-memory state.
26
+ **Worker idle exit:** a background worker (plain `chi`, Web UI sessions) exits after `SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES` (`session.idle_exit_minutes`, default `30`, `0`=never) with no turn running or queued, no client on its stream (an open web tab or attached terminal keeps it), no reminder registered, no background task it watches for a [finish wake](internals/background-tasks.md#finish-wake) still running or waiting for its wake turn (with `session.task_wakes: wake`; not one whose turn you stopped) and no continue offer (the step-limit question) waiting for an answer. It removes `bridge.json` and frees `owner.lock`; the next prompt or `--attach` wakes a new worker (`WorkerIdleExit`, `WorkerBoot.run_session_loop`). As its Bridge stops, the worker marks the lock leaving (`OwnerLock#mark_leaving`: `leaving_at`, and `leave_wait`, its `recap.timeout`), since it keeps the lock while it writes the recap on leave: an `--attach` or `--resume` meanwhile (here, or a remote one through chi web's wake) prints `(waiting for the session to finish its recap…)` (`LaunchMode::RECAP_WAIT`) and waits for the release, up to that budget plus `OwnerLock::LEAVE_MARGIN` (5 s), before starting the new worker (`OwnerLock.await_release`). A lock held by a worker that isn't leaving (a live one, a stuck one) is left as before. A client can also ask the worker to exit right away (Bridge `POST /session/:id/exit`; `/exit` in an attached terminal does, `/detach` doesn't): the same rules apply without the timeout (even with `0`), and the asker's own stream doesn't count. The reply names what keeps it up (for background tasks, which ones and how to stop them). When every hold is one the user can stop — reminders, a watched task still running, a delegate with a live worker — the 409 also carries `holds: [{kind:, items: [{id:, label:, detail:}]}]` (a reminder's detail is its schedule, `every 20m`/`every 1h`, `, due now` when it is already due), and an attached terminal asks what to do with them; its "stop them and exit" posts `{"stop": "all"}`, which clears the reminders, stops those tasks (`TaskRuntime.stop_task`, as `task_stop`) and stops those children (`SessionManager.stop_session`) before leaving, re-checking first that nothing the user can't stop appeared meanwhile (`HoldStop`). A delegate counts only for that ask: it never holds the idle exit, and an exit that deletes or archives the session does not ask about it. The session's status stays as it was, not `stopped`. With `"restart": true` (a worker whose sidecar lists the `restart` feature) the worker hands the session to a new worker on the newest chi installed: it leaves, then starts its successor once its lock is free, keeping even an empty session and writing no recap. Clients' streams don't hold a restart (a web tab and an attached terminal reconnect to the new worker); what would be lost does: a turn running or queued, a continue offer, a question or approval waiting, a `/btw` (or another anytime command) still running, a delegate's approval relay open or settled in the last 10 minutes, and background tasks this conversation started that still run (`WorkerIdleExit#hold_for_restart`; 409 `held` with `reason`). Reminders go along: as it leaves, the worker writes them to `<id>/reminders.json` (0600: `session_id`, `written_at`, and each reminder's id, name, description, interval and next fire as wall-clock time; `ReminderCarry`), and its successor reads and deletes the file as it starts, before its idle scheduler runs. A restored reminder keeps its id and schedule; one that came due meanwhile fires once at once, and one further ahead than its interval (a clock that jumped) is due one interval from now. The file counts only for the same session within 2 minutes of `written_at`, and not once the session is stopped: otherwise it is deleted unread (log `reminders_carry_dropped`, at warn unless the session was stopped). A stop that lands while the worker leaves for a restart wins: the worker writes no file, and no successor starts (`WorkerBoot.run_session_loop`, log `restart_dropped`); an archive deletes the file of each session it archives. Once an exit or restart is taken (claimed before a stop-all stops anything), the first one decides how the worker leaves: a later request of the same kind is answered as that exit, one of another kind (a stop-all from another terminal after a restart, chi web's "Restart workers" after a `/exit`) gets 409 `held` with `reason` `leaving_exit`, `leaving_delete` or `leaving_restart`, and a restart says it was refused rather than "restarted". A write that fails keeps the worker up with its reminders (log `reminders_carry_failed`), and the caller reports no new worker. A reminder already due when the restart is asked runs its turn first, so the restart's wait (10 s) may report no new worker yet though the hand-off follows. A worker from before this still refuses a restart for its reminders (409 `reminders`). Only an asked restart carries them: the fallback's TERM (a worker that can't be asked, below), a stop, an idle exit (which reminders hold anyway) and a crash drop them as before. What lives only in the worker's memory goes: the event history a late client replays, the kept `/btw` answers, a plugin's in-memory state.
27
27
 
28
28
  **Empty sessions:** a session nothing happened in is deleted when it is left, so `chi` → `/exit` leaves no trace. Empty means: no messages, no turn tried (a failed turn leaves a `last_prompt` and keeps it), no memory used, no pending question, nothing in `input/`, `notes/` or `images/`, nothing in its dir beyond the worker's skeleton, mode `assist`, and the default model. A session on another model (`/model`, `--model`) counts as prepared and stays (`SessionManager.discardable?`). The worker checks as it idle-exits or leaves on a client's request and deletes it once its lock is free, checking again first. The attached `/exit` then says `Detached; the session is empty: it will be discarded if nothing arrives before its worker stops.` (a note or prompt landing first keeps it). The retention sweep catches those killed first: empty sessions over an hour old with no live owner, whatever `DAYS`/`MAX`, and skeleton-only dirs with no `*.json`. `session.keep_empty: true` (env `SAMAGOTCHI_SESSION_KEEP_EMPTY`) turns all of it off.
29
29
 
@@ -345,7 +345,7 @@ The title names the session wherever its first message did: `chi sessions list`
345
345
 
346
346
  A session can **continue** an earlier one: "the next run of the same routine" (a daily coordinator, say, continued once a day or several times). The new session's `continues` field names the previous link; the chain is derived from it, never stored: follow `continues` back for the earlier links, forward for the later ones. It is not a parent: archiving one link leaves the next alone, and lists fold delegates, not links. A link is continued at most once, so a chain has no forks and its latest link is the one nobody continues.
347
347
 
348
- `SessionManager.continue_session` makes the next link:
348
+ `SessionContinue.run` makes the next link:
349
349
 
350
350
  - **What it copies:** the previous link's folder, its model (the name it was typed as, so an alias holds) unless the continue names one (`--model`, the web's `model`), and its own LLM context values. A model given is checked like a new session's (a host config.yml doesn't have refuses the continue, nothing archived). An inherited model whose host can't run it any more (gone from config.yml, `enabled: false`, an alias naming another host) **falls back to the default model** with a warning, `gone:org/model can't run (unknown host 'gone' in model …); started on <default>`, decided before the archive; with no default model configured the continue is refused. The carry note doesn't mention the model. The new link carries no transcript beyond the note's last answer, so a different model, or another provider, starts clean there. Its preview is the previous link's (until its first message is saved), unless it starts with a message. Its title (`/title`) is the previous link's, with or without a first message.
351
351
  - **The carry note:** the new link starts with a context note from `session chain` (shown in its transcript as `note from session chain`), queued before its worker spawns, so its first turn sees it: `This session continues 3fa2c1d0 (2026-10-08 09:12), the previous link of its chain. Its recap, as of 2026-10-08 18:02:` and the previous link's recap (`recap.json`; cut at 8000 characters). No recap (fewer user turns than a recap needs, recaps off, none written in time): `… It left no recap.` Then `Its last answer:` and the previous link's last answer, read after the recap wait, so its exact shas, paths and next steps survive: a longer one keeps its last 4000 characters, after a `…` (a wrap-up closes on what comes next). There is none when the newest bubble was a step that asked for tools, when a later turn failed or was cut before it answered, and on a review link (a continue doesn't copy the grants, so the next link's participants could differ); `session.carry_last_answer: false` leaves it out of every note. No other transcript. The note never makes a continue fail: it stays within the 16 KiB a note may hold, the answer taking what the head and recap leave, and a session file that can't be read gives no answer. An idle new link holding only the note is kept, not discarded as empty.
data/docs/system-one.md CHANGED
@@ -10,8 +10,9 @@ was sent and what came of it. The runs don't leave the machine; a hosted System
10
10
  ## The common layer
11
11
 
12
12
  `SystemOne::Client` asks. `SystemOne::Recording` wraps it where a use builds its
13
- client (`Engine#system_one_recording`): the same duck type, the same answers and
14
- the same raises. A cancelled ask saves its run, then raises; a run that can't be saved is
13
+ client (`SystemOne::Recorder#wrap`, over the recorder `Engine#system_one_recorder` builds
14
+ for each call: the Engine's runs and its model then; `Engine#system_one_recording` for the
15
+ guardrails): the same duck type, the same answers and the same raises. A cancelled ask saves its run, then raises; a run that can't be saved is
15
16
  one warn and never changes the answer. A use that needs the run's id at once passes it
16
17
  (`client.ask(..., run_id: id)`); one client serves several threads.
17
18
 
@@ -304,7 +304,7 @@ module Samagotchi
304
304
 
305
305
  def written_ok?(real, expected, name)
306
306
  parse(File.read(real)) == expected &&
307
- ConfigFile.hosts_config(env: @env, path: real).key?(name.downcase)
307
+ HostConfig.all(env: @env, path: real).key?(name.downcase)
308
308
  rescue StandardError
309
309
  false
310
310
  end
@@ -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}")
@@ -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
@@ -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.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
@@ -5,7 +5,7 @@ module Samagotchi
5
5
  # The attached-context candidate judge (TurnStages::ContextJudge,
6
6
  # forwarded): prepare_turn starts its pass, run_turn's ensure shows the
7
7
  # cards it held, and a plugin's late candidate asks it. Reads
8
- # @context_judge, @side_judgments, @host_registry.
8
+ # @context_judge, @side_judgments, @host_registry, @turn_state, @session.
9
9
  module ContextJudgePart
10
10
  private
11
11
 
@@ -13,19 +13,27 @@ module Samagotchi
13
13
  attr_reader :context_judge
14
14
 
15
15
  # A late proposal no owner prompt mentions (Plugin::Host
16
- # #candidate_proposed): TurnStages::ContextJudge#late_candidates.
17
- def judge_late_candidates = @context_judge.late_candidates
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.
21
+ def judge_late_candidates
22
+ session = @session
23
+ return if session.nil? || session.review?
18
24
 
19
- # The stage over this Engine's side asks and its session (built in
25
+ @context_judge.late_candidates(session, messages: plugin_messages, recorder: system_one_recorder,
26
+ state_dir: session_state_dir)
27
+ end
28
+
29
+ # The stage over this Engine's side asks (built in
20
30
  # #build_tools_and_plugins, before the plugins load: one may propose a
21
- # candidate as it loads).
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.
22
34
  def build_context_judge
23
- TurnStages::ContextJudge.new(
24
- side_judgments: @side_judgments, host_registry: @host_registry, state_dir: -> { session_state_dir },
25
- recording: ->(client, judgment, session, mode:) { system_one_recording(client, judgment, session, mode: mode) },
26
- session: -> { session }, messages: -> { plugin_messages }, turn_running: -> { turn_running? },
27
- show_card: ->(**card) { show_card(**card) }
28
- )
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 })
29
37
  end
30
38
  end
31
39
  end
@@ -46,7 +46,7 @@ module Samagotchi
46
46
  from_cwd: note[:from_cwd], text: note[:text], context_source: note[:context_source],
47
47
  created_at: note[:created_at], objectives: entry }.compact)
48
48
  announce_objectives(session) if entry
49
- @objectives_stage.note(session, note)
49
+ @objectives_stage.note(session, note, recorder: system_one_recorder, state_dir: session_state_dir)
50
50
  message
51
51
  end
52
52
  end