samagotchi 0.141.0 → 0.142.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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -1
  3. data/README.md +1 -1
  4. data/bin/chi +2 -0
  5. data/docs/architecture.md +12 -283
  6. data/docs/bundle-authoring.md +1 -0
  7. data/docs/cli.md +15 -7
  8. data/docs/configuration.md +57 -18
  9. data/docs/context.md +2 -2
  10. data/docs/guardrails.md +20 -11
  11. data/docs/hooks.md +9 -7
  12. data/docs/internals/attached-context.md +226 -0
  13. data/docs/internals/background-tasks.md +1 -1
  14. data/docs/internals/bridge.md +380 -0
  15. data/docs/internals/bundles.md +1 -0
  16. data/docs/internals/context-telemetry.md +1 -1
  17. data/docs/internals/engine.md +24 -18
  18. data/docs/internals/participants.md +13 -12
  19. data/docs/internals/remote-sessions.md +1 -1
  20. data/docs/internals/{tool-guardrails.md → tool-output-limits.md} +21 -2
  21. data/docs/memory.md +31 -1
  22. data/docs/plugins.md +122 -1
  23. data/docs/sessions.md +50 -26
  24. data/docs/system-one.md +122 -0
  25. data/docs/testing.md +3 -1
  26. data/lib/samagotchi/bridge.rb +2 -0
  27. data/lib/samagotchi/bundles/check-in/manifest.yml +6 -3
  28. data/lib/samagotchi/bundles/check-in/plugin.rb +101 -4
  29. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  31. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  32. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  33. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  34. data/lib/samagotchi/config.rb +74 -14
  35. data/lib/samagotchi/context_candidate_choice.rb +8 -0
  36. data/lib/samagotchi/context_candidates_log.rb +25 -1
  37. data/lib/samagotchi/context_judge.rb +6 -2
  38. data/lib/samagotchi/engine.rb +68 -7
  39. data/lib/samagotchi/extension_load.rb +3 -2
  40. data/lib/samagotchi/guardrail_wiring.rb +36 -31
  41. data/lib/samagotchi/guardrails/chi_dirs.rb +7 -4
  42. data/lib/samagotchi/guardrails/path_resolve.rb +1 -1
  43. data/lib/samagotchi/guardrails/protected_paths.rb +7 -4
  44. data/lib/samagotchi/guardrails_command.rb +3 -3
  45. data/lib/samagotchi/judgments_command.rb +155 -0
  46. data/lib/samagotchi/kernel_loop.rb +13 -1
  47. data/lib/samagotchi/llm/chat_loop.rb +74 -10
  48. data/lib/samagotchi/llm/errors.rb +7 -5
  49. data/lib/samagotchi/llm/stray_tool_call.rb +61 -0
  50. data/lib/samagotchi/log_line.rb +1 -1
  51. data/lib/samagotchi/memory_bundle/entry_size.rb +100 -0
  52. data/lib/samagotchi/plugin/api.rb +33 -2
  53. data/lib/samagotchi/plugin/context.rb +49 -1
  54. data/lib/samagotchi/plugin/judgments.rb +72 -0
  55. data/lib/samagotchi/plugin/loader.rb +3 -2
  56. data/lib/samagotchi/run_options.rb +1 -0
  57. data/lib/samagotchi/self_report.rb +36 -0
  58. data/lib/samagotchi/session_access/contract.rb +1 -1
  59. data/lib/samagotchi/session_commands.rb +3 -0
  60. data/lib/samagotchi/session_manager.rb +13 -2
  61. data/lib/samagotchi/session_retention.rb +10 -0
  62. data/lib/samagotchi/side_judgments.rb +12 -1
  63. data/lib/samagotchi/system_one/answer.rb +63 -11
  64. data/lib/samagotchi/system_one/approval_label.rb +56 -0
  65. data/lib/samagotchi/system_one/approval_question.rb +2 -0
  66. data/lib/samagotchi/system_one/client.rb +43 -17
  67. data/lib/samagotchi/system_one/fake_backend.rb +1 -1
  68. data/lib/samagotchi/system_one/http_backend.rb +12 -4
  69. data/lib/samagotchi/system_one/judgment_settings.rb +61 -0
  70. data/lib/samagotchi/system_one/judgments.rb +260 -0
  71. data/lib/samagotchi/system_one/log.rb +4 -2
  72. data/lib/samagotchi/system_one/recording.rb +117 -0
  73. data/lib/samagotchi/system_one/runs.rb +179 -0
  74. data/lib/samagotchi/system_one/runs_store.rb +230 -0
  75. data/lib/samagotchi/system_one.rb +9 -0
  76. data/lib/samagotchi/testing/bundle.rb +9 -2
  77. data/lib/samagotchi/testing/host.rb +2 -1
  78. data/lib/samagotchi/testing/judgments.rb +185 -0
  79. data/lib/samagotchi/testing/recorder.rb +5 -0
  80. data/lib/samagotchi/testing/recording_api.rb +1 -1
  81. data/lib/samagotchi/tool_output_cut.rb +26 -6
  82. data/lib/samagotchi/tool_response.rb +1 -1
  83. data/lib/samagotchi/tool_runner.rb +63 -4
  84. data/lib/samagotchi/toolkit_suggest.rb +11 -2
  85. data/lib/samagotchi/tools/memory.rb +28 -0
  86. data/lib/samagotchi/turn_note.rb +9 -0
  87. data/lib/samagotchi/version.rb +1 -1
  88. metadata +16 -2
@@ -149,7 +149,7 @@ or `chi web` restart.
149
149
  |---|---|---|
150
150
  | `default.model` | New session | Existing sessions keep their saved model; change one with `/model`. |
151
151
  | `default.input`, `no_default_input`, `no_interrupt` | Next CLI run | These set up that run's first prompt or turn limit. |
152
- | `default.max_tokens`, `max_tool_output_chars`, `read.*`, `execute.*` except `execute.description`, `image.*` | Next matching request or tool use | An operation already in progress keeps its resolved limits. |
152
+ | `default.max_tokens`, `max_tool_output_chars`, `memory.read_max_chars`, `read.*`, `execute.*` except `execute.description`, `image.*` | Next matching request or tool use | An operation already in progress keeps its resolved limits. |
153
153
  | `server.host`, `server.port`, `hosts.*` | Worker start | Includes host addresses, APIs, declared models and host-level settings. The web model picker refreshes `hosts:` on file change; a running worker does not. |
154
154
  | `server.open_timeout`, `server.read_timeout`, `server.first_token_timeout` | Worker start | The worker's clients retain the timeouts set when they were built. |
155
155
  | `model_aliases` | Next model lookup | A session keeps its resolved model unless it is explicitly switched. |
@@ -166,9 +166,10 @@ or `chi web` restart.
166
166
  | `toolkit.kits`, `execute.description`, `llm_context.policy` | Worker start | These are held by the engine's toolkit or tool registry. |
167
167
  | `toolkit.suggest.mode`, `toolkit.suggest.threshold`, `toolkit.suggest.defer_turns`, `toolkit.suggest.defer_tokens` | Worker start | Suggestion settings are config-only and are resolved when the worker runs its audit or on-mode ask. |
168
168
  | `skip_agents_md` | Prompt rebuild | A running engine caches its built prompt; a new worker or `/model` rebuild uses the changed value. |
169
- | `context.*` except `context.every_seconds`, `llm_context.*` except `llm_context.policy`, `steer.cut_after`, `memory.index_warn_tokens`, `status.line`, `web.participants.*` | Next matching turn, display, poll or write | Readers resolve these settings when they perform that operation. Session-specific LLM context and thinking values remain saved in the session. A participant's grant keeps the expiry and cap it was issued with; `web.participants.*` is what the next invite gets, and the cap and queue of a grant from before caps. |
169
+ | `context.*` except `context.every_seconds`, `llm_context.*` except `llm_context.policy`, `steer.cut_after`, `memory.index_warn_tokens`, `memory.entry_warn_tokens`, `status.line`, `web.participants.*` | Next matching turn, display, poll or write | Readers resolve these settings when they perform that operation. Session-specific LLM context and thinking values remain saved in the session. A participant's grant keeps the expiry and cap it was issued with; `web.participants.*` is what the next invite gets, and the cap and queue of a grant from before caps. |
170
170
  | `context.every_seconds` | Next context poll | The poller checks it for each source that has no explicit interval. |
171
171
  | `guardrails.*` | Next tool call or approval | Guardrail rules and disable lists are reloaded when the config file changes; rule settings take effect on that evaluation. `guardrails.parent_answers` is checked when a parent answers a question. |
172
+ | `system_one.host`, `system_one.model`, `system_one.timeout_ms`, `system_one.runs`, `system_one.judgments` | Next tool call or approval | Each ask reads them. |
172
173
  | `hooks`, `bundles` | Worker start | Hooks, plugins and their bundle settings load with the engine. |
173
174
  | `recap.*` | Worker start | The engine builds its recap job at start, and again when it binds a session with its own `recap.enabled` or `recap.sentences` (`--set`, see "Per-session settings") and when `/recap on\|off\|default` changes the session's own value. |
174
175
  | `session.shared` | Next `chi` invocation | It selects attached-worker or in-process mode for that invocation. |
@@ -185,7 +186,7 @@ or `chi web` restart.
185
186
 
186
187
  ### Per-session settings
187
188
 
188
- A few core keys can be set for one session: `chi --set KEY=VALUE` (or `chi send --new --set KEY=VALUE`, or
189
+ A few core keys can be set for one session (the registry entries marked `session: true`, `Config::Entry#session?`): `chi --set KEY=VALUE` (or `chi send --new --set KEY=VALUE`, or
189
190
  `POST /api/sessions` `settings`) at its creation, and `recap.enabled` later too with `/recap on`, `/recap off` or `/recap default`
190
191
  (at once; `default` removes the session's own value, see [Session recap](cli.md#session-recap)). The value is saved in the session file (`config_settings`), so a
191
192
  worker respawn, a continue and a fork keep it; a delegate child starts without it, on config.yml's. It goes first:
@@ -1526,14 +1527,14 @@ toolkit:
1526
1527
  `undecided` for greetings or check-ins. Only a default-origin session with at least two described candidates is
1527
1528
  asked; chosen or inherited toolkits, delegate children, review sessions and participant turns are skipped. After
1528
1529
  `undecided`, later owner prompts may be asked again within both defer limits. The default is `off`. System 1's
1529
- host, model and timeout come from `guardrails.system_one.*`; the suggestion threshold and defer limits are its own
1530
+ host, model and timeout come from `system_one.*`; the suggestion threshold and defer limits are its own
1530
1531
  settings below. Suggestions are
1531
1532
  kept in `toolkit_suggestions.jsonl`; `chi toolkit report` grades them against the session's tool calls. One ask
1532
1533
  runs at a time (a turn that comes while one still runs goes unasked, with an info line), and a kit that doesn't
1533
1534
  resolve (a missing base, a cycle) is left out of the candidates. A worker that exits waits about a second for a
1534
1535
  running ask, so a one-shot `-p --non-interactive` run may end before its suggestion is logged.
1535
1536
  - **On mode.** `toolkit.suggest.mode: on` asks the same question on the same eligible turns, but the turn waits for
1536
- the answer before it declares any tool, for at most `guardrails.system_one.timeout_ms` from the ask's start (it
1537
+ the answer before it declares any tool, for at most `system_one.timeout_ms` from the ask's start (it
1537
1538
  starts right after the turn does, so plugin and MCP start-up hide most of it). A described kit picked with
1538
1539
  confidence at or above `toolkit.suggest.threshold` becomes the session's toolkit, saved with `toolkit_origin:
1539
1540
  suggested`, and the turn's first request declares it; the notice reads
@@ -1541,7 +1542,7 @@ toolkit:
1541
1542
  pick, `undecided`, no answer, a failing System 1, which warns once as `[samagotchi:toolkit]`) keeps the default
1542
1543
  kit. No answer in time or none at all says so in a warn notice, `toolkit: full (no suggestion: System 1 took over
1543
1544
  5000 ms) · /toolkit to change` (or `System 1 gave no answer`), and a timeout logs a `toolkit_suggest_timeout`
1544
- warning: a slow System 1 host wants a larger `guardrails.system_one.timeout_ms`. A Stop during the wait (or before it, even with the answer in) ends the turn before its first request, and
1545
+ warning: a slow System 1 host wants a larger `system_one.timeout_ms`. A Stop during the wait (or before it, even with the answer in) ends the turn before its first request, and
1545
1546
  nothing switches. After an `undecided`, a later prompt within the defer limits may switch the session; that notice
1546
1547
  says `from this turn`, and that request re-reads the whole prompt (system prompt, tools and conversation, which
1547
1548
  the defer limits keep small) without `/toolkit`'s cache confirmation. `/toolkit` or `ctx.use_toolkit` marks the
@@ -1591,7 +1592,7 @@ toolkit:
1591
1592
  - **A new web chat: 🧰.** The start page's 🧰 (beside ⚙; a bottom sheet on a phone) picks the new session's toolkit.
1592
1593
  **Auto**, checked by default, sends nothing: the session is on `toolkit.default` with `toolkit_origin: default`, so
1593
1594
  with `toolkit.suggest.mode: on` its first message may switch it to a suggested kit. Auto is listed only when that
1594
- can happen as far as chi web can tell (on mode, a `guardrails.system_one.host`, two described config kits); bundle
1595
+ can happen as far as chi web can tell (on mode, a `system_one.host`, two described config kits); bundle
1595
1596
  and memory kits can add candidates it doesn't see. A kit picked is sent as `POST /api/sessions`'s `toolkit` and
1596
1597
  saved as `chosen`: no suggestion, the default kit included (its row says `default`); without Auto, the default's
1597
1598
  row is the one checked and sends nothing. The list holds the built-ins and config kits a new session can start
@@ -1640,6 +1641,21 @@ toolkit:
1640
1641
  changes what saved sessions on it send after an upgrade: the next request reads the prompt again once, as a
1641
1642
  tool's schema change does. So can an edit to `toolkit.kits`: a session's worker reads the kits once when it starts, so a running session keeps its
1642
1643
  tools and the edit reaches it at its next worker start (after an idle exit, `chi sessions restart`).
1644
+ - **Why a session is on its kit: `toolkit_origin`.** The session file records `default` (the configured kit),
1645
+ `chosen` (a user-selected kit, `/toolkit` and `ctx.use_toolkit` included), `inherited` (a continue or a fork) or
1646
+ `suggested` (`toolkit.suggest.mode: on` switched it). A file from before the field reads as `chosen`, so an old
1647
+ default session is never newly suggested a kit; only a `default` session is ever suggested one. A delegate child
1648
+ gets the kit its `delegate` call names (`toolkit:`, any kit the parent knows), else `toolkit.child`; a kit its worker
1649
+ can't resolve declares every tool.
1650
+ - **Loads saved in the conversation.** A `find_tools` result on a chat host saves the tools its hit loads as
1651
+ `loaded: ["list_sessions", "mcp_fs_read"]` on its `tool_response` entry (wire names: a built-in's, an MCP tool's
1652
+ `mcp_<server>_<tool>`), whatever the host: a later turn on `api: anthropic` (after a `/model` switch, a resume) sends
1653
+ the load as references, any other host ignores it. A plugin's `ctx.load_tools` leaves a system entry
1654
+ `{"role": "system", "kind": "tool_change", "content": "[tools loaded: delegate (bundle)]", "tool_change": {"add":
1655
+ ["delegate"], "by": "bundle"}}` after the turn's user message, only on a host that takes tool changes
1656
+ (`api: anthropic`; [wire details](internals/anthropic.md#tool-changes-ctxload_tools)). It is never the system head,
1657
+ counts as conversation, is hidden in the web and TUI histories and goes to no other wire. Either way the names stay
1658
+ deferred for the session's life, across respawns, `--resume` and forks.
1643
1659
 
1644
1660
  ## LLM context: the forget layer
1645
1661
 
@@ -1762,7 +1778,7 @@ its own). The first target is a long session such as a day-long coordinator, whe
1762
1778
 
1763
1779
  `context.candidates.mode: audit` asks System 1 whether a session's work is about each candidate a bundle proposed
1764
1780
  (see [The judge](context.md#the-judge)); `on` also acts on the answer: attaches at `threshold`, a card from
1765
- `offer_threshold` ([On mode](context.md#on-mode)). It uses `guardrails.system_one.host` and `.model`, but its own mode: System
1781
+ `offer_threshold` ([On mode](context.md#on-mode)). It uses `system_one.host` and `.model`, but its own mode: System
1766
1782
  1's `mode` (approvals) can stay `off`. Jev, the hosted judgment model, is served through OpenRouter
1767
1783
  (`/api/v1/systemone`), so OpenRouter can be the System 1 host:
1768
1784
 
@@ -1772,10 +1788,9 @@ hosts:
1772
1788
  url: https://openrouter.ai/api/v1
1773
1789
  api: openai
1774
1790
  api_key_env: OPENROUTER_API_KEY
1775
- guardrails:
1776
- system_one:
1777
- host: openrouter
1778
- model: typesafe/jev-1.14
1791
+ system_one:
1792
+ host: openrouter
1793
+ model: typesafe/jev-1.14
1779
1794
  context:
1780
1795
  candidates:
1781
1796
  mode: audit
@@ -1785,6 +1800,27 @@ A remote host is asked beside the turn. A System 1 host that is the model's own
1785
1800
  turn's last request, and the turn-end warm-up follows them, re-warming the prefix they evicted (see
1786
1801
  [prompt caching](internals/prompt-caching.md)).
1787
1802
 
1803
+
1804
+ ## Bundles' judgments
1805
+
1806
+ A bundle can declare its own cheap System 1 questions ([Judgments](plugins.md#judgments-chijudgment-and-ctxjudge)). Each is off until
1807
+ you set its mode under its full name, `<bundle>.<name>`, in `system_one.judgments`:
1808
+
1809
+ ```yaml
1810
+ system_one:
1811
+ host: splash
1812
+ judgments:
1813
+ check-in.progress:
1814
+ mode: audit # off (the default) | audit | on
1815
+ host: openrouter # optional, this judgment alone; else system_one.host
1816
+ model: typesafe/jev-1.14 # optional, this judgment alone; else system_one.model, or on a host of its own that host's first model
1817
+ ```
1818
+
1819
+ A name no loaded bundle declares gets a notice at the session's first turn. Core's judgments (`approvals.*`, `context.*`, `guardrails.*`,
1820
+ `toolkit.*`) take no settings here and warn if named: `SystemOne::Record` and `Similar` don't look at the model, so a
1821
+ model swapped for approvals alone would inherit a clean record and could auto-allow.
1822
+ A bundle's judgments on a hosted host aren't under any cost cap: they stay out of the session's spend, as all of System 1's do.
1823
+
1788
1824
  ## Opening files from chi web
1789
1825
 
1790
1826
  In `chi web`, a file tool's row (read, edit, write) and the lines of its diff are refs to that file. They look like
@@ -2190,12 +2226,13 @@ column says so, a `--kebab-name` flag. The maps (`hosts:`, `models:`,
2190
2226
  | `guardrails.small_models` | `auto` | | Which models get the `models: small` rules: `auto` (32B or less by the size in the name, an MoE's active size), a list of globs, or `[]`. config.yml only. See [Guardrails](guardrails.md#rules-for-some-models). |
2191
2227
  | `guardrails.parent_answers` | `soft` | | Which options a parent agent (`chi answer`) may pick on a session's question, by their effect: `hard` (a deny, a Stop, a plain answer), `standard` (+ Continue and other spending options), `soft` (+ "Allow once"); never a wider scope or an allow on chi's own config. An invalid value warns and is `hard`. A session's own `parent_answers` (`chi --parent-answers`, `/parent-answers`) may only tighten it. Replaces `guardrails.parent_approvals` and `turn.parent_continue`. config.yml only: no environment variable (a parent can still pick the config dir with `XDG_CONFIG_HOME`). A convention for an honest parent, not a security boundary. See [Guardrails](guardrails.md#answers-from-a-parent-agent). |
2192
2228
  | `guardrails.system_one.mode` | `off` | | System 1, the cheap judgment that answers an ask before the user sees it: `off` (the default), `audit` (judge every ask, log it, never decide), `on` (also allowed to allow a call once, behind the gates in [System 1](guardrails.md#system-1-experimental)). An unknown value warns and is `off`. Experimental. config.yml only. See [Guardrails](guardrails.md#system-1-experimental). |
2193
- | `guardrails.system_one.host` | none | | The `hosts:` entry serving `/v1/systemone` (a local Splash, or the hosted Jev API); unset: no judgment. config.yml only. |
2194
- | `guardrails.system_one.model` | none | | The judgment model; unset uses the host's first model, and a host that names none has no judgment. config.yml only. |
2195
2229
  | `guardrails.system_one.rules` | none | | The rule ids System 1 may answer for in `on` mode: `"git-push|git-rebase"` or a YAML list, parsed like `guardrails.small_models`. Nothing is auto-allowed while it is unset; the `chi-answer-piped`, `chi-answer-http` and `chi-broadcast` rules never are, however they are listed. config.yml only. |
2196
2230
  | `guardrails.system_one.threshold` | `0.8` | | The probability an auto-allow needs. config.yml only. |
2197
2231
  | `guardrails.system_one.min_record` | `5` | | How many clean records a rule needs before System 1 is trusted for it: labelled asks of that rule at the threshold the user allowed. config.yml only. |
2198
- | `guardrails.system_one.timeout_ms` | `2000` | | Milliseconds one judgment may take; a slower one is no judgment and the ask goes to the user. config.yml only. |
2232
+ | `system_one.host` | none | | The `hosts:` entry serving `/v1/systemone` (a local Splash, or the hosted Jev API); unset: no judgment, by any use of System 1, so nothing auto-allows and every ask goes to the user. Moved from `guardrails.system_one.host`: while any of host, model and timeout_ms is still under `guardrails.system_one`, chi warns and nothing is judged. config.yml only. |
2233
+ | `system_one.model` | none | | The judgment model; unset uses the host's first model, and a host that names none has no judgment. Moved from `guardrails.system_one.model`. config.yml only. |
2234
+ | `system_one.timeout_ms` | `2000` | | Milliseconds one judgment may take; a slower one is no judgment and the ask goes to the user. Moved from `guardrails.system_one.timeout_ms`. config.yml only. |
2235
+ | `system_one.runs` | `on` | | Save each System 1 ask and what came of it, under the state dir, as long as sessions last (see [System 1's saved runs](system-one.md)); `off` stops saving. config.yml only. |
2199
2236
  | `log.file` | state dir | yes | See "Debug Log File". |
2200
2237
  | `log.disable` | `false` | yes | No file logging. |
2201
2238
  | `log.level` | `info` | yes | `debug`, `info`, `warn`, `error`. |
@@ -2218,13 +2255,13 @@ column says so, a `--kebab-name` flag. The maps (`hosts:`, `models:`,
2218
2255
  | `context.every_seconds` | `300` | | How often an attached context source's command runs when it has no `--every` (seconds, at least 30). See [Attached context](context.md). |
2219
2256
  | `context.wake` | `true` | yes | A source whose update says `wake: true` may start a turn in a live, idle session (one per source per 10 minutes, a peer source up to `context.peer_turns` in a row, within `session.max_wakes`); so may a session's pushed source's first push. `false`: updates wait as notes for the next turn. See [Waking](context.md#waking). |
2220
2257
  | `context.peer_turns` | `3` | yes | Wake turns in a row a peer agent's answers may start (a peer bundle's outbox source, such as `codex-outbox`), with no message typed in the web or a TUI in between (`chi send` doesn't count); within `session.max_wakes`. Then answers wait as notes, and the UIs hear it once. An answer whose summary ends its verdicts with `VERDICT OK` wakes once to close the round, counted toward `session.max_wakes` only. `0`: a peer source wakes like any other. See [Waking](context.md#waking). |
2221
- | `context.candidates.mode` | `off` | | The candidate judge: `off` (the default: no conversation text goes to a System 1 host), `audit` (asks System 1 whether the session's work is about each proposed candidate and logs it in `context_candidates.jsonl`; attaches nothing), `on` (the same asks, acted on: attached at `threshold`, a card from `offer_threshold`; see [On mode](context.md#on-mode)). Its host and model are `guardrails.system_one.host` / `.model`. config.yml only. See [The judge](context.md#the-judge). |
2258
+ | `context.candidates.mode` | `off` | | The candidate judge: `off` (the default: no conversation text goes to a System 1 host), `audit` (asks System 1 whether the session's work is about each proposed candidate and logs it in `context_candidates.jsonl`; attaches nothing), `on` (the same asks, acted on: attached at `threshold`, a card from `offer_threshold`; see [On mode](context.md#on-mode)). Its host and model are `system_one.host` / `.model`. config.yml only. See [The judge](context.md#the-judge). |
2222
2259
  | `context.candidates.threshold` | `0.8` | | The probability at which the judge attaches a candidate in on mode (a judged line's `would: attach`). config.yml only. |
2223
2260
  | `context.candidates.offer_threshold` | `0.6` | | The probability from which on mode shows the candidate's card, once (`would: offer`, below `threshold`). config.yml only. |
2224
2261
  | `context.candidates.reask_after_turns` | `3` | | Owner prompts after a judged ask before the judge's one re-ask of that candidate. config.yml only. |
2225
2262
  | `toolkit.default` | `full` | | The toolkit a new session is stored on: the tools its prompts declare (`full`, `lean`, `child`, one of `toolkit.kits` or a bundle's). `chi --toolkit` picks one per session. See "Toolkits". |
2226
2263
  | `toolkit.child` | `full` | | A delegate child's toolkit, unless its `delegate` call names one (`toolkit:`). See "Toolkits". |
2227
- | `toolkit.suggest.mode` | `off` | | `audit` asks System 1 for a toolkit suggestion without changing the session; `on` waits for it (up to `guardrails.system_one.timeout_ms`) on an eligible turn and starts the session on a confidently suggested kit (Toolkits, On mode). The System 1 host, model and timeout come from `guardrails.system_one.*`. config.yml only. |
2264
+ | `toolkit.suggest.mode` | `off` | | `audit` asks System 1 for a toolkit suggestion without changing the session; `on` waits for it (up to `system_one.timeout_ms`) on an eligible turn and starts the session on a confidently suggested kit (Toolkits, On mode). The System 1 host, model and timeout come from `system_one.*`. config.yml only. |
2228
2265
  | `toolkit.suggest.threshold` | `0.5` | | Minimum confidence for a non-default toolkit suggestion. config.yml only. |
2229
2266
  | `toolkit.suggest.defer_turns` | `3` | | Maximum owner-prompt number for another ask after `undecided` (the owner's own prompts: not steers, notes, reminders or `!` command output). config.yml only. |
2230
2267
  | `toolkit.suggest.defer_tokens` | `5000` | | Maximum estimated conversation size for another ask after `undecided` (characters / 4). config.yml only. |
@@ -2235,7 +2272,9 @@ column says so, a `--kebab-name` flag. The maps (`hosts:`, `models:`,
2235
2272
  | `hosts.<name>.models.<id>.served` | none | | The models the host may answer that id with (exact ids, any case), or `any`: no served-model warning for those. See "Models a host serves but doesn't list". |
2236
2273
  | `hosts.<name>.models.<id>.price` | none | | The model's price on that host, USD per 1M tokens: `input`, `output`, optional `cache_read` and `cache_write`. Estimates the cost a provider doesn't report. See "Prices". |
2237
2274
  | `hosts.<name>.remote` | by address | | `true`/`false`: treat the host as a remote provider or a local server. See "Remote or local". |
2238
- | `max_tool_output_chars` | `10000` | yes | Characters of each tool output kept in the conversation (both loops). A longer one is cut: an `execute` result keeps its first and last lines with a gap line between them and is saved whole in the session's folder (`sessions/<id>/tool_output/`; the gap line names the file, and the model reads it there instead of running the command again); any other tool's keeps its head and ends with `[cut: N of M chars; read it in parts]`. See [Tool output guardrails](internals/tool-guardrails.md#the-runners-cut-max_tool_output_chars). A top-level key (see below). |
2275
+ | `memory.entry_warn_tokens` | `5000` | yes | The tokens one memory may hold (every read of it pays them; about 20K characters at the default `context.chars_per_token`) before a `memory_write`, `write` or `edit` that takes it over gets a note, once per crossing, asking the model to tighten it; `0`: no note. A memory that did not exist counts as 0 tokens. Not for a description-only write, a remove, `index`, a model overlay or a `model_notes_*` memory. Independent of `memory.index_warn_tokens`. See [Memory](memory.md). |
2276
+ | `memory.read_max_chars` | `0` | yes | The most characters one `memory_read` result holds. `0`: no fixed cap, a memory is read whole (`max_tool_output_chars` does not apply to `memory_read`, in any form: one name, a comma list, a blank name). A positive value cuts a longer result and ends it with `[cut: N of M chars; the rest is in the memory's .md file in <dir>: read it with start_line]`. `memory:` is its own section. See [Memory](memory.md). |
2277
+ | `max_tool_output_chars` | `10000` | yes | Characters of each tool output kept in the conversation (both loops). A longer one is cut: an `execute` result keeps its first and last lines with a gap line between them and is saved whole in the session's folder (`sessions/<id>/tool_output/`; the gap line names the file, and the model reads it there instead of running the command again); any other tool's keeps its head and ends with `[cut: N of M chars; read it in parts]`. `memory_read` is the exception: `memory.read_max_chars` caps it. See [Tool output guardrails](internals/tool-output-limits.md#the-runners-cut-max_tool_output_chars). A top-level key (see below). |
2239
2278
  | `cache.warmup` | `auto` | | `auto`: after a turn, send the next turn's prompt (up to the next message) to a local llama.cpp host on the native loop, so the next turn prefills only its message; never a remote or `api: openai` host. `off`: never. See [prompt caching](internals/prompt-caching.md#the-turn-end-warm-up). |
2240
2279
  | `cache.ttl` | `5m` | | How long Anthropic keeps the prompt-cache breakpoints of a Claude model on a remote host: `5m` (Anthropic's default) or `1h`. A 1h cache write costs 2× the input price (5m: 1.25×), a read the same 0.1×. See "Remote or local". |
2241
2280
  | `cache.key` | `off` | | `session`: send the session id as `prompt_cache_key` to OpenAI's API and OpenRouter, which route and cache by it; no other host gets it. `off`: none. |
data/docs/context.md CHANGED
@@ -432,8 +432,8 @@ about each proposed candidate — "Is this session's work about the
432
432
  candidate?" — and logs the answer, so you can see how it would do before
433
433
  anything depends on it; `audit` attaches nothing. With `on` the answer acts
434
434
  ([On mode](#on-mode)). The default is `off`: no conversation text goes to a System 1 host unless you
435
- turn this on, whatever `guardrails.system_one` says. The host and model are
436
- `guardrails.system_one.host` / `.model` (Jev through OpenRouter: see
435
+ turn this on, whatever `guardrails.system_one.mode` says. The host and model are
436
+ `system_one.host` / `.model` (Jev through OpenRouter: see
437
437
  [The candidate judge](configuration.md#the-candidate-judge)).
438
438
 
439
439
  ```yaml
data/docs/guardrails.md CHANGED
@@ -283,8 +283,9 @@ Built in, for `write`, `edit` and `memory_write` (symlinks resolved; see
283
283
  [How paths resolve](#how-paths-resolve)):
284
284
 
285
285
  - deny: the approval store's dir, installed bundles (`memories/.bundles/`),
286
- and [attached context](context.md) sources (`<state dir>/context/`: a
287
- source's command runs later, outside the gate; `chi context` changes them);
286
+ [attached context](context.md) sources (`<state dir>/context/`: a
287
+ source's command runs later, outside the gate; `chi context` changes them)
288
+ and [System 1's saved runs](system-one.md) (`<state dir>/system_one/`);
288
289
  - ask (once or for the session): `config.yml`, `home.json` (the [chi home](configuration.md#the-chi-home): the Ruby every chi launch boots), the chi shim (`$XDG_STATE_HOME/samagotchi/bin/chi`, the `chi` every tool runs: `chi-shim`) and the plain hooks dir.
289
290
 
290
291
  `execute` can still reach them; the guardrails bundle asks about shell
@@ -745,7 +746,7 @@ repo is in count as outside.
745
746
 
746
747
  `touches: chi_dirs` (shell tools) matches a command that names a path in chi's
747
748
  own folders: the config folder (`config.yml`, `memories/`), the hooks folder,
748
- the approval store, installed bundles (`memories/.bundles`), the session repo's
749
+ the approval store, attached context, System 1's saved runs, installed bundles (`memories/.bundles`), the session repo's
749
750
  git hooks (`core.hooksPath` honoured) or any `.git/hooks` (a submodule's `.git/modules/<name>/hooks` too)
750
751
  outside a tmp folder.
751
752
  Each word is read as a path against the folder the command is in by then
@@ -1045,25 +1046,32 @@ reaching them — see [Answering for the user](#answering-for-the-user-on-mode).
1045
1046
  `Samagotchi::SystemOne` (lib/samagotchi/system_one/) speaks the TypeSafe
1046
1047
  "System One" judgment API: `POST <host>/v1/systemone` with
1047
1048
  `{model, state, questions}` and answers of type `noul` (a probability),
1048
- `choice` or `score`. The host is a `hosts:` entry with `api: openai` — a
1049
+ `choice` or `score`. A `score` answer's number is not a level: it is the
1050
+ probability-weighted level index (Σ index × its probability), so a two-level
1051
+ question's score is exactly the probability of its second level. The response
1052
+ also sends `legend` (level index => name) and `probabilities` per index; chi
1053
+ keeps the score as sent and reads the level with the most probability as
1054
+ `answer.level`. A score that isn't a number, or is past the last level, is no
1055
+ answer. The host is a `hosts:` entry with `api: openai` — a
1049
1056
  local Splash server, or the hosted Jev API — and its key is that host's own
1050
1057
  `api_key_env:`.
1051
1058
 
1052
1059
  ```yaml
1060
+ system_one: # what every use of System 1 shares
1061
+ host: splash # the hosts: entry serving /v1/systemone
1062
+ model: Qwen3.8-Flash-Next-GSQ-RCO-GGUF:Q2_0 # else the host's first model
1063
+ timeout_ms: 2000 # a slower judgment is no judgment
1053
1064
  guardrails:
1054
- system_one:
1065
+ system_one: # approvals' own
1055
1066
  mode: off # off (default) | audit (judge, log, never decide) | on
1056
- host: splash # the hosts: entry serving /v1/systemone
1057
- model: Qwen3.8-Flash-Next-GSQ-RCO-GGUF:Q2_0 # else the host's first model
1058
1067
  rules: [git-push, git-rebase] # the rule ids it may answer for
1059
1068
  threshold: 0.8 # the probability an auto-allow needs
1060
1069
  min_record: 5 # clean records a rule needs before it is trusted
1061
- timeout_ms: 2000 # a slower judgment is no judgment
1062
1070
  ```
1063
1071
 
1064
- `SystemOne::Client.from_config(host_registry: ...)` reads these on every
1072
+ `SystemOne::Client.from_config(host_registry:, mode:)` reads these on every
1065
1073
  call, so a config.yml change takes effect on the next ask. `mode: off` (the
1066
- default) or no `host` means there is no client at all. Anything else is no
1074
+ default) or no `system_one.host` means there is no client at all. Anything else is no
1067
1075
  judgment and one `Log.warn(:guardrails, "system_one_failed", …)`: an
1068
1076
  unreachable host, a host that isn't `api: openai`, a status other than 200,
1069
1077
  a body that isn't JSON, a question with no answer, a request longer than
@@ -1118,7 +1126,8 @@ offers no examples. A participant's turn (review or pair) is never judged:
1118
1126
  there is no user there to ask, and its asks go to the owner. The judgment runs before the card appears, so an ask is
1119
1127
  slower by up to `timeout_ms` (2000 ms by default).
1120
1128
 
1121
- Each judgment is a line in `judgments.jsonl`, beside `approvals.json` in
1129
+ Each ask is also saved whole, with what the user answered, as a [run](system-one.md); the line's `id` is the
1130
+ run's. Each judgment is a line in `judgments.jsonl`, beside `approvals.json` in
1122
1131
  `$XDG_STATE_HOME/samagotchi/guardrails/` (`SystemOne::Log`): 0600, appended
1123
1132
  under an exclusive `flock` (several workers write it), and rewritten —
1124
1133
  keeping the newest half of its lines — once it passes 5 MB. A line is never
data/docs/hooks.md CHANGED
@@ -62,7 +62,7 @@ The plugin class must respond to `#call(event)` — duck-typed, no base class re
62
62
  | `:generation_progress` | While the response streams, in batches (see [Watching the stream](#watching-the-stream)) | `{ type: :generation_progress, iteration: N, thinking: "..." (new since the last fire), text: "..." (new visible text), thinking_chars: N, text_chars: N (this generation so far), elapsed_ms: N, restarted: true (only on the first fire after a dropped stream was asked again) }` |
63
63
  | `:before_tool_call` | Before tool dispatch (and before `tool_call_started`) | `{ type: :before_tool_call, iteration: N, call: {...}, params: "...", guardrail: Verdict, context: {...}, targets: {...}, blocked: false, block_reason: nil }` |
64
64
  | `:question_asked` | A question of the model's own (`ask_user_question`) or an approval of its tool call is about to open, on the turn's thread (not a hook's or plugin's `ask_user`, a relayed delegate approval, the step-limit question, a `--non-interactive` run or a stored approval: none of those fire it) | `{ type: :question_asked, kind: "question" \| "approval", text: "..." (the reply that asks, as the asking slot shows it: no thoughts, no tool-call markup), question: {...} (the question's fields, frozen: question/options/header…, an approval's approval: facts too; no id yet), refs: (see [Refs on a question](#refs-on-a-question-display-only)) }` |
65
- | `:after_tool_call` | After tool execution | `{ type: :after_tool_call, iteration: N, tool: "read", output: "..." (the text the model gets: capped at max_tool_output_chars; a cut one has a [cut: …] line, at its end, or for an execute result between its head and its tail, the whole output saved in the session's tool_output/ folder), status: "ok" \| "error" \| "blocked" \| "stopped", call: {...} (the call that ran, as the gate left it), elapsed_ms: N (the tool's own run, without an approval wait; nil for a denied call), result: "..." (the tool's own text: no "[read]" lead, no "ran as:" line, never cut) }`; `stopped` is a `task_wait` the turn's Stop ended (the task runs on) or whose task was stopped. Match on `result:`, not `output:`: it is what the tool said (`Error: command timed out after 120s` for an execute killed at its limit in kill mode, `Samagotchi::Tools::Execute::TIMEOUT_PREFIX`; `Still running at the 120s limit: …` for one handed over as a task, `Samagotchi::Tools::Execute::HANDED_OVER`) |
65
+ | `:after_tool_call` | After tool execution | `{ type: :after_tool_call, iteration: N, tool: "read", output: "..." (the text the model gets: capped at max_tool_output_chars (a memory_read's cap is memory.read_max_chars, 0: none); a cut one has a [cut: …] line, at its end, or for an execute result between its head and its tail, the whole output saved in the session's tool_output/ folder), status: "ok" \| "error" \| "blocked" \| "stopped", call: {...} (the call that ran, as the gate left it), elapsed_ms: N (the tool's own run, without an approval wait; nil for a denied call), result: "..." (the tool's own text: no "[read]" lead, no "ran as:" line, never cut) }`; `stopped` is a `task_wait` the turn's Stop ended (the task runs on) or whose task was stopped. Match on `result:`, not `output:`: it is what the tool said (`Error: command timed out after 120s` for an execute killed at its limit in kill mode, `Samagotchi::Tools::Execute::TIMEOUT_PREFIX`; `Still running at the 120s limit: …` for one handed over as a task, `Samagotchi::Tools::Execute::HANDED_OVER`) |
66
66
  | `:session_end` | After each turn `:after_turn` fires for, after it (turn-level lifecycle) | `{ type: :session_end, session_id: "..." }` |
67
67
 
68
68
  Every event also carries the hook runtime (next section): `hook:` (the label
@@ -76,12 +76,14 @@ cheap).
76
76
 
77
77
  ## Participants' turns
78
78
 
79
- A [participant's](internals/participants.md#turns) review turn fires the same hooks, and every event of it carries
80
- `author: "p:<id>"` (the owner's carry no `author:` key, as before); a plugin reads the same as `ctx.turn_author`
81
- (nil for the owner's turns and between turns). **A hook runs with the owner's power whatever the author**: it may
82
- read, write and run what the owner's chi may, and `notify`, `ask_user` and `steer` reach the owner. A bundle that
83
- acts on a turn's text (a prompt rewrite, a fetch of what it names) should check `author:` first: the text may be a
84
- participant's. The review boundary checks a tool call after the hooks, on the call they leave.
79
+ A [participant's](internals/participants.md#turns) turn — a review turn or a pair turn — fires the same hooks, and
80
+ every event of it carries `author: "p:<id>"` (the owner's carry no `author:` key, as before); a plugin reads the
81
+ same as `ctx.turn_author` (nil for the owner's turns and between turns). The mode does not change that: `author:`
82
+ comes from who wrote the turn, not from `ParticipantMode`. **A hook runs with the owner's power whatever the
83
+ author**: it may read, write and run what the owner's chi may, and `notify`, `ask_user` and `steer` reach the
84
+ owner. A bundle that acts on a turn's text (a prompt rewrite, a fetch of what it names) should check `author:`
85
+ first: the text may be a participant's. The participant's boundary (the review or pair one) checks a tool call
86
+ after the hooks, on the call they leave.
85
87
 
86
88
  ## What a hook can do: the runtime
87
89
 
@@ -0,0 +1,226 @@
1
+ # Attached context: the mechanism
2
+
3
+ For an agent changing the layer. What a user sees and configures (the commands, the note wording, the wake rules,
4
+ the judge's thresholds, the log and the report) is in [../context.md](../context.md); this page is how the code
5
+ gets a source's text into the prompt, and what each class owns. Not to be confused with `ContextStatus` /
6
+ `ContextUsage` / `ContextWindow` ([context-telemetry.md](context-telemetry.md): token counts) or the LLM context
7
+ strategies ([llm-context-forget.md](llm-context-forget.md)).
8
+
9
+ ## From a source to the prompt
10
+
11
+ ```
12
+ source (command | push | URL provider) ContextSources: <name>.json + <name>.snapshot.json
13
+ │ ContextPoller (worker thread) runs a command source via ContextFetch;
14
+ │ chi context push / refresh write the snapshot from another process
15
+ ▼
16
+ snapshot (text, revision, serial, summary, wake_serial, hint)
17
+ │ Worker#pass, between turns: WorkerInbound#absorb_context
18
+ ▼
19
+ ContextAbsorber#pending → Batch of Deliveries (one note per source that is new / changed / failing / gone)
20
+ │ Engine#add_context_note: a tail system message (kind "note", ContextNote), announced as :context_added
21
+ │ session saved, then ContextAbsorber#commit writes subscriptions.json
22
+ ▼
23
+ the next request: the note sits after the history; the model reads the text with the context_read tool
24
+ ```
25
+
26
+ Three rules hold the shape:
27
+
28
+ - **Never the stable prefix.** A note is a tail system message, appended between turns (never mid-turn, never
29
+ before a session's first turn: `conversation_started?`, or `before_turn:` when a prompt is about to run). The
30
+ system prompt carries only the standing line that explains `[CONTEXT NOTE …]`. History stays append-only
31
+ ([prompt-caching.md](prompt-caching.md)). Only the pointer (name, why, hint, summary) is in the note; the text is
32
+ read on demand by `Tools::ContextRead`, which reads snapshots only and never runs a command.
33
+ - **Deliver at least once, record after save.** `absorb_context` adds the notes, saves the session, and only then
34
+ `commit`s the subscriptions. A crash in between re-delivers, and `Engine#add_context_note` skips a `note_id` the
35
+ conversation already holds. Ids are deterministic: `ctx-<name>-<serial>-<revision[0,12]>` (the serial makes A, B, A
36
+ three notes), `ctx-<name>-error-<error_since digits>`, `ctx-<name>-detached-<seen[0,12]>`.
37
+ - **Polling is not activity.** The poller and the absorb never hold a worker up (`WorkerIdleExit`).
38
+
39
+ ## ContextSources
40
+
41
+ The store (`context_sources.rb`), one root `<state dir>/context/`, apart from the session folders; its header
42
+ comment is the file map and [../context.md](../context.md#storage) lists it for users.
43
+
44
+ - **Records** (`Data`/value objects): `Source` (definition; `cmd` nil = pushed; `movable` nil derives from
45
+ `push?`), `Snapshot` (last good text, `revision` = SHA-256 of the text, `serial` counting revisions, `error` and
46
+ `error_since`, `wake_serial` = the newest revision that asked to wake, `fetched_at` last success, `checked_at` last
47
+ try), `Subscription` (what one session has had: `seen`/`seen_serial`, `read`, `error_seen`, `wakes_at`),
48
+ `Candidate`, `Left`, `Moved`/`Stayed`.
49
+ - **Locations.** A `Location` is a folder of sources: a project's (`projects/<project_key>/`, shared by the
50
+ project's workers) or a session's (`sessions/<id>/`, which also holds `subscriptions.json`, the `muted/` and
51
+ `declined/` markers, `candidates/`, `left.json`). `ContextSources.attached(session_id, project_root:)` is what a
52
+ session sees: its own, then the project's it doesn't shadow. Names are `NAME_RE`; `subscriptions` and `left` are
53
+ reserved (`RESERVED_NAMES`) because their files share the folder.
54
+ - **Writes are file replaces** (`AtomicFile`). `Location#locked(name)` takes `<name>.lock`, the lock a fetch holds
55
+ for its whole command; `record_text` and `record_error` run under it, and `remove` leaves the lock file so a fetch
56
+ still running finds its source gone (`ContextFetch` then writes nothing). Removing a *session* source leaves a
57
+ `declined/<name>` marker (hint inside), which is what stops an auto-attach from bringing it back.
58
+ - **The output contract** is `ContextSources.parse_output` (JSON object with a string `text` is
59
+ `{text, summary, wake, hint}`; anything else is all text; empty or over `TEXT_MAX_BYTES` is `Invalid`), the same for
60
+ a command's stdout and a push. `ContextProviders` resolves a URL to a command source from an installed bundle's
61
+ manifest `context_providers:` (read from its provenance, so the CLI and chi web resolve without loading plugins);
62
+ `{url}` is filled at attach, `{bundle_dir}` and `{ruby}` when the command runs.
63
+ - **Protected.** The root is a `ProtectedPaths` entry: the file tools cannot write it, because a source's command
64
+ runs later outside the guardrails gate with the worker's environment ([../context.md](../context.md#safety)).
65
+
66
+ ## ContextPoller and ContextFetch
67
+
68
+ `ContextPoller` is one thread in a worker (`Worker` starts it, `on_change: -> { @waker.wake }`). Every
69
+ `TICK_SECONDS` (5) it walks `ContextSources.attached`, skips pushed and muted sources, and runs each command source
70
+ whose last try (`checked_at`, so a failing source isn't re-run every tick) is older than its interval (`--every`,
71
+ else `context.every_seconds`, floored at `MIN_EVERY_SECONDS` 30). Its first pass uses the floor as the interval, so a
72
+ woken worker's first absorb says what changed while it was away. It calls `on_change` only for `:new` and `:error`.
73
+
74
+ `ContextFetch.fetch` runs one source: `sh -c` in its own process group (a timeout, the output cap or the caller's
75
+ cancel kills the group), cwd the session's folder (a project source: the project root, `project_cwd`), stdin closed,
76
+ `SAMAGOTCHI_CONTEXT_NAME` and `SAMAGOTCHI_CONTEXT_PREVIOUS` in the env. A non-blocking `flock` on `<name>.lock` is
77
+ the arbiter between workers and the CLI: the loser gets `:busy`, and `fresh_within` is checked again under the lock,
78
+ so a project source shared by several workers is fetched once per interval. The `Outcome` status says what happened
79
+ (`:new :same :error :busy :fresh :cancelled :gone`); a cancelled fetch records nothing (a leaving worker isn't the
80
+ source failing). `chi context refresh` and the candidates' first fetch use the same entry.
81
+
82
+ A pushed source has no poller. `chi context push` writes the snapshot from its own process; the worker finds it
83
+ through `ContextAbsorber#store_signature` (names and mtimes of the files in the two folders, `subscriptions.json`
84
+ left out so the worker's own write is not a change), compared on each pass.
85
+
86
+ ## ContextAbsorber
87
+
88
+ Pure decision, no I/O of its own beyond the store. `pending` returns `nil` when the signature hasn't moved since the
89
+ last `commit`, else a `Batch` of `Delivery` values (`name`, `note`, `subscription`, `wake_note`, `peer`, `agreed`):
90
+
91
+ | State of source vs the session's `Subscription` | Delivery |
92
+ |---|---|
93
+ | has text, `seen` nil | *Attached* note |
94
+ | `seen != revision` | *Updated* note, `changes = serial - seen_serial` |
95
+ | error newer than `error_seen` | *Couldn't refresh*, once per run of failures |
96
+ | subscription with no source left | *Detached* if the session was told of it, else dropped quietly |
97
+ | no text yet | nothing |
98
+
99
+ A muted source is skipped. Notes name the source as third-party text; the exact wording lives in the class constants
100
+ (`WAKE_LAST_LINE`, `PEER_WAKE_LINE`, `PEER_AGREED_LINE` were tuned in spikes: change one only with an eval).
101
+
102
+ **Wake notes.** A delivery also carries a `wake_note` (the same note with the wake last line) when its source asked
103
+ (`snapshot.wake_serial > sub.seen_serial`, so an earlier asking revision still counts) or, for a first text, only a
104
+ session's own pushed source (`first_text_wakes?`). Whether it *runs* is the worker's, not the absorber's:
105
+ `WorkerInbound#context_wake_for` applies `context.wake`, `WorkerWakes#context_open?` (no continue offer, not paused,
106
+ under `session.max_wakes`, past the start grace), and the per-source `CONTEXT_WAKE_WINDOW` (600 s, from
107
+ `Subscription#wakes_at`); `Worker#pass` calls it only with nothing queued and no exit requested. A failed or
108
+ stopped wake turn is rolled back (`unmark_wake`) to a plain note.
109
+
110
+ **Peers.** `PeerSources` (the peer bundles' in-process declaration, `chi.peer_source`) marks a pushed session source
111
+ as a peer's answers; `peer_label` then needs `context.peer_turns` > 0. A peer delivery has no 10-minute window; its
112
+ budget is `WorkerWakes#peer_open?` (reset only by typed input), and an answer whose summary ends in `VERDICT OK`
113
+ (`peer_agrees?`) wakes once with the closing line, counted toward `session.max_wakes` only.
114
+
115
+ ## Candidates
116
+
117
+ `ContextCandidates` holds a session's proposed sources (`Candidate`, from `ctx.context.propose`, stored under
118
+ `candidates/<name>.json` and written once). **Status is derived, never stored**: *attached* while a source of its
119
+ name (or whose hint is its URL) is attached, *declined* while the declined marker names it, else *proposed*.
120
+
121
+ - **Mention.** `mention_in` / `pattern_for` implement the user-facing rules (whole tokens, `#N` ≥ 10 or after
122
+ "PR", branch names ≥ 6 chars with `/ - _`, exact URLs, fenced code blanked). Only `OwnerPrompt` texts count
123
+ (`owner_texts`), so a participant's message, a steer, a note or a reminder never attaches anything; a review
124
+ session skips all of it.
125
+ - **Where it runs.** `before_turn` (from `Worker#run_prompt` before the absorb, and the REPL's turn start): attach
126
+ each proposed candidate the owner's prompts so far mention (`attach` writes a session `Source` through
127
+ `source_for`: a URL resolves through `ContextProviders`, else the candidate's own `cmd`), then `first_fetch` them
128
+ together within `FIRST_FETCH_SECONDS` (10) so the note lands in this turn; cut short, the fetch records nothing and
129
+ the poller tries soon. `after_propose` does the same for a late proposal. The REPL has no absorber running, so
130
+ `deliver_notes` runs one.
131
+ - Forks and continues take no candidates.
132
+
133
+ ## ContextJudge and ContextCandidateChoice
134
+
135
+ `ContextJudge` is System 1's "is this session's work about the candidate?" ([../guardrails.md](../guardrails.md#system-1-experimental)).
136
+ It builds a small neutral state per ask (`state(job)`: the candidate's title and describe, `conversation` = the first
137
+ substantive owner prompt and the latest, each bounded at `PROMPT_MAX`, and for a delegate child the parent's attached
138
+ titles), two constant `noul` questions (`about`, `task`: constant so the host's prefix cache holds and nothing a
139
+ plugin wrote is read as instructions), and `ask` writes a `judged` line. Never the branch, the checkout or the
140
+ source's why. `task_p < TASK_MIN` (0.5) leaves the candidate unjudged; `due?` decides asks from the log's `judged`
141
+ lines (never twice at one owner-prompt count; one re-ask after `reask_after_turns`; then done). No answer writes
142
+ nothing, so the next owner prompt asks again.
143
+
144
+ The driver is the Engine, not the judge: `Engine#start_context_judge` (an owner turn's start; skipped for participants,
145
+ review sessions and a Continue) and `judge_late_candidates` (a proposal after the prompts) call `context_judge_pass`.
146
+ It first logs status changes (`ContextCandidatesLog.look`), returns at `mode: off`, builds the due `Job`s, and hands
147
+ them to `SideJudgments` (`:context` kind: one thread, one batch per session at a time). On the model's own local
148
+ server (`system_one_shares_local_host?`) the pass is held in `@context_judge_after_turn` and started after the turn
149
+ (before the prompt warm-up, which waits for it); any other host is asked at once.
150
+
151
+ `ContextCandidateChoice.after_judged` acts on `fields["would"]` in `on` mode only: *attach* → `judge_attach` (a
152
+ session source with `JUDGE_WHY`, no probability in the model-visible why), *offer* → one card (`offer_card`, buttons
153
+ `/context attach NAME` and `/context decline NAME`, guarded by a `carded` log line). It re-checks that the candidate
154
+ is still proposed, since the user may have chosen while it was asked. Cards are shown through the Engine's
155
+ `context_choice` lambda, which holds them while a turn runs (`@context_cards_after_turn`). `choose` is the
156
+ `/context attach|decline` command's body (`SessionCommands`), shared by the card's buttons; it refuses in a review
157
+ session. An attach only writes the session folder: its note comes with the next between-turn delivery, never into a
158
+ running turn.
159
+
160
+ `ContextCandidatesLog` is `context_candidates.jsonl` beside the sessions folder (a `SideJudgments.log`: 0600, one
161
+ line under a lock, the oldest half dropped past 5 MB; written in every mode). `statuses` replays the lines;
162
+ `look` appends `by: "user"` lines for changes it sees (the user's own `chi context add`, a chip's ×); the kinds and the
163
+ report are in [../context.md](../context.md#the-log-and-the-report). `ContextReportCommand` is its only reader besides
164
+ `due?` and `carded?`.
165
+
166
+ ## ContextMove and left.json
167
+
168
+ Movable context is the only code path that copies sources between sessions. `ContextMove.call` runs from
169
+ `SessionManager.spawn_session`'s `before_spawn` (a continue: `SessionContinue`; a plugin fork:
170
+ `Plugin::Sessions#fork` with `seed:` = the fork's messages), after the new session and its first note are saved and
171
+ **before its worker starts**, so the first absorb sees the sources. It calls `ContextSources.move`, then writes a
172
+ second note into the new session (`NOTE_SOURCE` "attached context", named by time so it sorts after the first). Best
173
+ effort: a failure is logged (`context_move_failed`) and the session starts without.
174
+
175
+ `ContextSources.move(from:, to:, mode:, seed:)` copies, by plain read (never `Location#locked`: a fetch holds it for
176
+ its whole command), each source for which `Source#movable?` is true: the source file, its snapshot, and a
177
+ `Subscription`; plus every `declined/` and `muted/` marker (the user's choices). Never `candidates/`, lock files, or a
178
+ source the new session already has. The subscription's content is what makes the first note right: a continue's says
179
+ nothing was seen (so the *Attached* note comes) at the snapshot's serial (so an old wake doesn't wake); a fork's is
180
+ what its seed last had, found from the newest note about the source (`seed_note`, via the `note_id` shape above), so
181
+ a source that changed since gets an *Updated* note.
182
+
183
+ `Moved` carries `moved` names and `stayed` (`Stayed`: name, `why` `:pushed` or `:not_movable`). Its `note_text` is the
184
+ second note; `words` is the few words `chi send --new --continues` prints; `to_h` / `from_h` are chi web's
185
+ `moved_context` answer (an older chi web sends none: `from_h(nil)` is nil).
186
+
187
+ **left.json.** For a *continue* the stayed sources are also written to the new link's `sessions/<id>/left.json`
188
+ (`Left`: `name`, `hint`, `from`; the hint is the snapshot's, since a pushed source's hint is where a peer's re-pair
189
+ pushes). `ctx.context.left_behind` reads it (`Plugin::AttachedContext#left_behind`) and `ctx.continues` names the
190
+ previous link. `Plugin::Peer#pair_again` uses both: from its quiet init task it posts one `moved` message to the
191
+ inbox left behind and records a marker under the bundle's `sessions/<id>/`, so it runs once. `Left#peer` /
192
+ `PEER_NAME` exist only to word the second note (a peer's inbox and outbox are one group: "chi asks Claude to pair
193
+ again" vs "Codex pairs again when it next answers"); `PeerSources` decides what a peer is. A fork writes no
194
+ `left.json`.
195
+
196
+ The refusal side: a push into a session that has been continued is refused with the next link's id
197
+ (`context_continued`, chi web 410, `chi context push` exit 1), from `SessionAccess` (`session_access.rb`) and chi web's push route (`web/session_access_routes.rb`).
198
+
199
+ ## Gates and config
200
+
201
+ | Where | Gate |
202
+ |---|---|
203
+ | Absorb at all | the conversation has started (`conversation_started?`), or `before_turn: true`; the session isn't stopped on disk |
204
+ | A wake turn | `context.wake`; `WorkerWakes#context_open?`; `CONTEXT_WAKE_WINDOW`; nothing queued and no exit requested; peer: `context.peer_turns`, `peer_open?` |
205
+ | Mention attach | `OwnerPrompt.turn?` (owner author, not a review session) |
206
+ | Judge | `context.candidates.mode` `audit`/`on` (default `off`); a System 1 client configured; not a participant's turn, review session, scratch session or Continue |
207
+ | Command source defined by the agent | `chi-context-cmd` guardrails rule asks, always; a parent agent can't approve it for a child |
208
+ | Writes to the store | `ProtectedPaths` denies the file tools |
209
+
210
+ Keys, all in [../configuration.md](../configuration.md): `context.every_seconds`, `context.wake`,
211
+ `context.peer_turns`, `session.max_wakes`, `context.candidates.{mode,threshold,offer_threshold,reask_after_turns}`.
212
+
213
+ ## Data files
214
+
215
+ | File | Written by | Read by |
216
+ |---|---|---|
217
+ | `context/{projects/<key>,sessions/<id>}/<name>.json` | `chi context add`, a provider, a plugin (`ctx.context.attach`), `ContextCandidates.attach`, `ContextMove` | poller, absorber, `context_read`, chi web |
218
+ | `<name>.snapshot.json` | `ContextFetch`, `chi context push`, `ContextMove` | absorber, `context_read`, chi web chips |
219
+ | `<name>.lock` | `ContextFetch` / `Location#locked` | same |
220
+ | `sessions/<id>/subscriptions.json` | the worker (`commit`, `context_read`) only | absorber, chi web (unread dot) |
221
+ | `sessions/<id>/{muted,declined}/<name>` | `chi context mute`, a removal, `ContextMove` | poller, absorber, candidates |
222
+ | `sessions/<id>/candidates/<name>.json` | `ctx.context.propose` | candidates, judge, `/context` |
223
+ | `sessions/<id>/left.json` | `ContextMove` (continue) | `ctx.context.left_behind` |
224
+ | `context_candidates.jsonl` (beside `sessions/`) | `ContextCandidatesLog` | judge (`due?`), choice (`carded?`), `chi context report` |
225
+
226
+ Specs: `spec/context_*_spec.rb`; a store under test uses an isolated `XDG_STATE_HOME`.
@@ -26,7 +26,7 @@ Behavior:
26
26
  - The exit code: the command runs after an EXIT trap that writes the shell's status to the `exit_code` file (`TaskRuntime.wrapped_shell_command`, shared with `task_create`), so `exit N`, a `set -e` failure and a trailing `\` or `|` in the command all record what the shell exited with. The trap runs wherever the shell is when it exits (a `task_create` `cwd`, or a `cd` in the command), so `task_create`'s `exit_code_path` is absolute; its `output_path` stays relative (`tmp/tasks/<id>/output.log`, opened by chi in the worker's cwd before the spawn). A command that sets its own EXIT trap replaces it, and a shell killed by a signal skips it (zsh's sh mode, on TERM, INT and KILL): `execute` still reports the shell's status, and a `task_create` task or a handed-over execute gets its code from chi's waiter while the chi that started it runs (`TaskRuntime.record_exit_status`): the exit status, or `128 + the signal` (TERM → 143), with no new `stop_reason`. A record a refresh already finished without a code in between (`failed`, `process_ended_without_exit_code` or the not-chi's-process reason) is corrected, its `finished_at` kept; a stopped task (its stop flag on disk) is never touched. `process_ended_without_exit_code` is left only when the shell wrote nothing and the chi that started it was gone too.
27
27
  - A stop of an `execute` (the user's Stop, the limit in kill mode) is done once the shell is reaped and nothing holds the logs: a child that ignores TERM after its shell died gets the KILL.
28
28
  - A command still running at `execute.timeout_sec` is handed over as a task (`execute.on_timeout: task`, the default; `kill` stops it instead, and the `!cmd` bang always kills): the result starts `Still running at the 120s limit: moved to a background task, not killed.`, then `task_id: …` and the tails of both logs so far. A command is also handed over early when a person's message waits for the turn's next step (the `message_waiting:` probe, the same `Engine#input_waiting?` as `task_wait`'s): after `Execute::EARLY_HAND_OVER_FLOOR_SEC` (3 s: a short command ends first and its result is the better answer), in the next 0.2 s slice. The first line then reads `Still running at the 4s mark: a message from the user is waiting, so the command moved to a background task, not killed.` (the seconds it had run), and the line after `task_id:` starts `Read the message first.`; the rest is the same hand-over, and the task stays watched. Never in `kill` mode (`execute.on_timeout: kill`, a participant's pair turn, the `!cmd` bang): there the probe is not even read and the command runs to its limit and is killed. A command that can't be adopted at that moment (a record that can't be written) isn't killed early: it runs on to its limit. Everything that matches the first line builds on `Execute::HANDED_OVER` (`Still running at the`) and `Execute::HANDED_OVER_AFTER` (`120s limit:` or `4s mark:`): `TaskRuntime.handed_over_pattern`, `ToolActivity::HANDED_OVER_START`, `TaskWatch` (`limit_sec` for the limit's form, `early_sec` for the mark's, which the finish note words as `handed over by execute after 4s: a message from the user was waiting`) and the web's `task_stop.js`; the slow-commands bundle reads only the limit's form, which is the only one that is a timeout. A timeout during the 1.5 s background grace is a kill (the shell is gone, nothing to hand over). If the record can't be written, the command is killed. `TaskRuntime.adopt` writes a record for the process execute already started, with `handed_over_by: "execute"`. Its logs stay where execute wrote them, so the record's paths are absolute: `output_path` (stdout), `stderr_path` (stderr, a log of its own) and `exit_code_path` (a `task_create` record's `exit_code_path` is absolute too; its `output_path` is relative). `started_at` (and `created_at`) is the spawn time, not the hand-over's; `pid_started` is read at the hand-over, while execute's detach waiter is still running. From there it is an ordinary task: `task_wait`, `task_get`, `task_list` and `task_stop` treat it as any other. Its dir gets a `task` back-pointer file holding the record's absolute path. Once per process, the first `execute` sweeps `…/execute/`: a dir whose logs no process holds is removed when it has no back-pointer and is a day old (chi died mid-command), when its record is gone, or when it is 14 days old. The records live in each project's `tmp/tasks`, which the state dir can't list: hence the back-pointer.
29
- - `task_wait` and `task_get` show `stderr_path` when the record has one; `task_wait`'s output tail is followed by a `stderr_tail:` of at most 10 lines when that log isn't empty. A `task_create` task has one log (stdout and stderr together) and no `stderr_path`. A `task_wait` result longer than `max_tool_output_chars` keeps its header and the tail's end, with a gap line naming `output_path`, where the whole log already is (and `stderr_path`, when the record has one) (`ToolOutputCut`'s `:head_tail_ref`; nothing is copied; see [Tool output guardrails](tool-guardrails.md#the-runners-cut-max_tool_output_chars)).
29
+ - `task_wait` and `task_get` show `stderr_path` when the record has one; `task_wait`'s output tail is followed by a `stderr_tail:` of at most 10 lines when that log isn't empty. A `task_create` task has one log (stdout and stderr together) and no `stderr_path`. A `task_wait` result longer than `max_tool_output_chars` keeps its header and the tail's end, with a gap line naming `output_path`, where the whole log already is (and `stderr_path`, when the record has one) (`ToolOutputCut`'s `:head_tail_ref`; nothing is copied; see [Tool output guardrails](tool-output-limits.md#the-runners-cut-max_tool_output_chars)).
30
30
  - Which tasks are a session's own (a cancelled turn's note, a worker's restart hold, the finish wake below, the web's stop-task button): the ids its `task_create` results and its handed-over executes name (`TaskRuntime.created_ids_in`, `created_id`). An execute's id is read only after its first line (`Execute::HANDED_OVER`, through `TaskRuntime.handed_over_id`; the web has a JS twin in `task_stop.js`), not from a `task_id:` line inside a run, which the command can print. (A command that prints the `---` separator chi joins a turn's results with, then a hand-over line, still fakes one: a known gap.) Either result may start with the `ran as: …` line of a gate-corrected call.
31
31
  - `task_get` returns metadata and `output_path`; use `read` for output contents.
32
32
  - `task_wait` accepts `timeout`, `tail_lines` (maximum 100), and `done_pattern` (a regular expression string).