samagotchi 0.141.0 → 0.143.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 (165) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +111 -7
  3. data/README.md +2 -1
  4. data/bin/chi +2 -0
  5. data/docs/architecture.md +25 -289
  6. data/docs/bundle-authoring.md +7 -6
  7. data/docs/bundles.md +1232 -0
  8. data/docs/cli.md +51 -13
  9. data/docs/configuration.md +68 -30
  10. data/docs/context.md +12 -12
  11. data/docs/guardrails.md +27 -20
  12. data/docs/hooks.md +28 -9
  13. data/docs/internals/attached-context.md +225 -0
  14. data/docs/internals/background-tasks.md +1 -1
  15. data/docs/internals/bridge.md +380 -0
  16. data/docs/internals/bundles.md +6 -3
  17. data/docs/internals/context-telemetry.md +1 -1
  18. data/docs/internals/engine.md +31 -19
  19. data/docs/internals/participants.md +13 -12
  20. data/docs/internals/prompt-caching.md +12 -12
  21. data/docs/internals/remote-sessions.md +1 -1
  22. data/docs/internals/{tool-guardrails.md → tool-output-limits.md} +21 -2
  23. data/docs/internals/web-push.md +1 -1
  24. data/docs/memory.md +33 -3
  25. data/docs/plugins.md +127 -1231
  26. data/docs/sessions.md +51 -27
  27. data/docs/sub-agent.md +6 -6
  28. data/docs/system-one.md +122 -0
  29. data/docs/testing.md +3 -1
  30. data/lib/samagotchi/bridge.rb +2 -0
  31. data/lib/samagotchi/bundle_command/api_calls.rb +132 -0
  32. data/lib/samagotchi/bundle_command/check.rb +50 -34
  33. data/lib/samagotchi/bundle_command/installed_check.rb +86 -0
  34. data/lib/samagotchi/bundle_command/scaffold.rb +1 -0
  35. data/lib/samagotchi/bundle_settings_check.rb +58 -0
  36. data/lib/samagotchi/bundles/agents-md/manifest.yml +4 -2
  37. data/lib/samagotchi/bundles/agents-md/plugin.rb +1 -1
  38. data/lib/samagotchi/bundles/btw/manifest.yml +4 -2
  39. data/lib/samagotchi/bundles/btw/plugin.rb +1 -1
  40. data/lib/samagotchi/bundles/check-in/manifest.yml +7 -3
  41. data/lib/samagotchi/bundles/check-in/plugin.rb +101 -11
  42. data/lib/samagotchi/bundles/claude-peer/manifest.yml +3 -3
  43. data/lib/samagotchi/bundles/claude-peer/plugin.rb +1 -1
  44. data/lib/samagotchi/bundles/claude-peer/scripts/pair +1 -1
  45. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  46. data/lib/samagotchi/bundles/codex-peer/plugin.rb +1 -1
  47. data/lib/samagotchi/bundles/coordinator/manifest.yml +2 -2
  48. data/lib/samagotchi/bundles/coordinator/plugin.rb +1 -1
  49. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  50. data/lib/samagotchi/bundles/cost-cap/manifest.yml +6 -3
  51. data/lib/samagotchi/bundles/cost-cap/plugin.rb +5 -7
  52. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -1
  53. data/lib/samagotchi/bundles/known-names/manifest.yml +3 -1
  54. data/lib/samagotchi/bundles/loop-guard/manifest.yml +4 -2
  55. data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
  56. data/lib/samagotchi/bundles/mcp/manifest.yml +4 -2
  57. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -2
  58. data/lib/samagotchi/bundles/ruby/manifest.yml +2 -2
  59. data/lib/samagotchi/bundles/ruby/plugin.rb +1 -1
  60. data/lib/samagotchi/bundles/skills/manifest.yml +4 -2
  61. data/lib/samagotchi/bundles/skills/plugin.rb +1 -1
  62. data/lib/samagotchi/bundles/slow-commands/manifest.yml +4 -2
  63. data/lib/samagotchi/bundles/slow-commands/plugin.rb +1 -1
  64. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -1
  65. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -3
  66. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  67. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  68. data/lib/samagotchi/bundles/system/self_map.md +3 -3
  69. data/lib/samagotchi/bundles/work-cap/manifest.yml +6 -3
  70. data/lib/samagotchi/bundles/work-cap/plugin.rb +11 -13
  71. data/lib/samagotchi/config.rb +74 -14
  72. data/lib/samagotchi/context_candidate_choice.rb +20 -3
  73. data/lib/samagotchi/context_candidates.rb +4 -1
  74. data/lib/samagotchi/context_candidates_log.rb +36 -1
  75. data/lib/samagotchi/context_judge.rb +6 -2
  76. data/lib/samagotchi/context_status.rb +1 -2
  77. data/lib/samagotchi/engine.rb +96 -70
  78. data/lib/samagotchi/extension_load.rb +11 -4
  79. data/lib/samagotchi/guardrail_wiring.rb +36 -31
  80. data/lib/samagotchi/guardrails/chi_dirs.rb +7 -4
  81. data/lib/samagotchi/guardrails/path_resolve.rb +1 -1
  82. data/lib/samagotchi/guardrails/protected_paths.rb +7 -4
  83. data/lib/samagotchi/guardrails_command.rb +3 -3
  84. data/lib/samagotchi/hook_routes.rb +37 -2
  85. data/lib/samagotchi/hooks/bundle_loader.rb +20 -4
  86. data/lib/samagotchi/hooks/registry.rb +23 -5
  87. data/lib/samagotchi/host_model.rb +3 -3
  88. data/lib/samagotchi/host_registry.rb +14 -7
  89. data/lib/samagotchi/judgments_command.rb +155 -0
  90. data/lib/samagotchi/kernel_loop.rb +20 -3
  91. data/lib/samagotchi/llm/chat_loop.rb +79 -10
  92. data/lib/samagotchi/llm/errors.rb +7 -5
  93. data/lib/samagotchi/llm/stray_tool_call.rb +68 -0
  94. data/lib/samagotchi/log_line.rb +1 -1
  95. data/lib/samagotchi/memory_bundle/builder.rb +4 -2
  96. data/lib/samagotchi/memory_bundle/entry_size.rb +100 -0
  97. data/lib/samagotchi/memory_bundle/installed_bundle.rb +9 -2
  98. data/lib/samagotchi/memory_bundle/installed_copy.rb +92 -0
  99. data/lib/samagotchi/memory_bundle/installer.rb +2 -0
  100. data/lib/samagotchi/memory_bundle/manifest.rb +23 -13
  101. data/lib/samagotchi/memory_bundle/provenance.rb +4 -1
  102. data/lib/samagotchi/memory_bundle/shipped_update.rb +13 -1
  103. data/lib/samagotchi/model_catalog.rb +3 -2
  104. data/lib/samagotchi/models_command.rb +1 -1
  105. data/lib/samagotchi/plugin/api.rb +41 -7
  106. data/lib/samagotchi/plugin/context.rb +59 -1
  107. data/lib/samagotchi/plugin/judgments.rb +72 -0
  108. data/lib/samagotchi/plugin/loader.rb +6 -3
  109. data/lib/samagotchi/plugin/peer.rb +17 -22
  110. data/lib/samagotchi/plugin_session_state.rb +32 -5
  111. data/lib/samagotchi/prompt_warmup.rb +1 -9
  112. data/lib/samagotchi/run_options.rb +1 -0
  113. data/lib/samagotchi/self_report.rb +36 -0
  114. data/lib/samagotchi/session_access/contract.rb +1 -1
  115. data/lib/samagotchi/session_access/local.rb +1 -1
  116. data/lib/samagotchi/session_access/remote.rb +3 -3
  117. data/lib/samagotchi/session_archive_command.rb +3 -3
  118. data/lib/samagotchi/session_commands.rb +7 -4
  119. data/lib/samagotchi/session_config.rb +9 -3
  120. data/lib/samagotchi/session_continue.rb +1 -1
  121. data/lib/samagotchi/session_manager.rb +25 -5
  122. data/lib/samagotchi/session_retention.rb +10 -0
  123. data/lib/samagotchi/side_judgments.rb +12 -1
  124. data/lib/samagotchi/system_one/answer.rb +63 -11
  125. data/lib/samagotchi/system_one/approval_label.rb +56 -0
  126. data/lib/samagotchi/system_one/approval_question.rb +2 -0
  127. data/lib/samagotchi/system_one/client.rb +43 -17
  128. data/lib/samagotchi/system_one/fake_backend.rb +1 -1
  129. data/lib/samagotchi/system_one/http_backend.rb +12 -4
  130. data/lib/samagotchi/system_one/judgment_settings.rb +61 -0
  131. data/lib/samagotchi/system_one/judgments.rb +243 -0
  132. data/lib/samagotchi/system_one/log.rb +25 -2
  133. data/lib/samagotchi/system_one/recording.rb +117 -0
  134. data/lib/samagotchi/system_one/runs.rb +179 -0
  135. data/lib/samagotchi/system_one/runs_store.rb +230 -0
  136. data/lib/samagotchi/system_one.rb +9 -0
  137. data/lib/samagotchi/terminal_ui/attach_link.rb +2 -2
  138. data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +1 -1
  139. data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
  140. data/lib/samagotchi/terminal_ui/local_link.rb +1 -1
  141. data/lib/samagotchi/terminal_ui/remote_link.rb +1 -2
  142. data/lib/samagotchi/testing/bundle.rb +9 -2
  143. data/lib/samagotchi/testing/host.rb +2 -1
  144. data/lib/samagotchi/testing/judgments.rb +176 -0
  145. data/lib/samagotchi/testing/recorder.rb +5 -0
  146. data/lib/samagotchi/testing/recording_api.rb +1 -1
  147. data/lib/samagotchi/tool_output_cut.rb +26 -6
  148. data/lib/samagotchi/tool_response.rb +1 -1
  149. data/lib/samagotchi/tool_runner.rb +63 -4
  150. data/lib/samagotchi/toolkit_suggest.rb +11 -2
  151. data/lib/samagotchi/tools/memory.rb +28 -0
  152. data/lib/samagotchi/turn_note.rb +9 -0
  153. data/lib/samagotchi/update_command.rb +49 -16
  154. data/lib/samagotchi/version.rb +1 -1
  155. data/lib/samagotchi/web/app.rb +1 -0
  156. data/lib/samagotchi/web/auto_update.rb +79 -49
  157. data/lib/samagotchi/web/auto_update_record.rb +59 -0
  158. data/lib/samagotchi/web/public/app.js +8 -4
  159. data/lib/samagotchi/web/public/hash_intent.js +24 -0
  160. data/lib/samagotchi/web/public/index.html +3 -1
  161. data/lib/samagotchi/web/public/revise_stack.js +14 -5
  162. data/lib/samagotchi/web/session_control.rb +3 -3
  163. data/lib/samagotchi/web/update_check.rb +7 -6
  164. data/lib/samagotchi/web/update_ops.rb +80 -25
  165. metadata +23 -2
data/docs/sessions.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Sessions
2
2
 
3
- Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/sessions/<uuid>.json` (XDG-aware via `XDG_STATE_HOME`) plus a sidecar dir `<uuid>/` with `input/`/`notes/`/`output/`/`owner.lock`/`bridge.json` (`Session.session_dir`), and `tool_output/` once a long `execute` result was cut: its whole output, which the cut result names (`ToolOutputFiles`, the newest 50 kept; see [Tool output guardrails](internals/tool-guardrails.md#the-runners-cut-max_tool_output_chars)). Besides the messages and turn state, the JSON keeps `written_by` (the chi version that saved it last; see *Which chi a worker runs*), `used_memory_names` (what the session read), `preloaded_memory_names` (`--memory`) and `muted_memory_names` (`--mute`), the last two written before the worker starts so a respawn builds the same prompt, `prompt_notes`, the model notes the system prompt carried at its last build (`[{name, scope, chars, digest}]`, see [Memory](memory.md#model-notes)), `parent_id`, the session that delegated this one (see *Delegating*; `null` otherwise), and `continues`, the session this one continues (the previous link of a chain, see *Session chains*; `null` otherwise); files from before those keys read as empty lists, no parent and no previous link. `model_name` is the resolved model ref (an alias applied, `host:id` when it names a host), so a resumed session keeps its model when an alias is retargeted; `model_typed` is the name it was given as when that differs (an alias), for the `models:` lookup after a resume (`null` otherwise).
3
+ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/sessions/<uuid>.json` (XDG-aware via `XDG_STATE_HOME`) plus a sidecar dir `<uuid>/` with `input/`/`notes/`/`output/`/`owner.lock`/`bridge.json` (`Session.session_dir`), and `tool_output/` once a long `execute` result was cut: its whole output, which the cut result names (`ToolOutputFiles`, the newest 50 kept; see [Tool output guardrails](internals/tool-output-limits.md#the-runners-cut-max_tool_output_chars)). Its keys are in [the Session file table](#session-file). A file from before `used_memory_names`, `preloaded_memory_names`, `muted_memory_names`, `parent_id` or `continues` reads those as empty lists, no parent and no previous link.
4
4
 
5
5
  **Message times:** an entry of `messages` may carry `at`, when it happened: a local-offset ISO time with milliseconds (`2026-10-24T14:32:05.120+02:00`). User prompts, lines merged into a turn and steers, reminders, context and task notes, and the model's replies have one (a note's is its `created_at`); tool results, `!cmd` messages and chi's own turn notes don't, nor does anything saved before chi stamped messages (no backfill). It is for the UIs only (the gap dividers, a bubble's time): it never goes to the model, so it changes nothing in the requests or their prompt cache. The one time the model reads is the reminders': due reminders go into a turn as a system message headed `[SYSTEM: REMINDERS DUE 2026-10-24 14:32 local]` (the turn's local time; `ReminderQueue#inject!`), written into the saved content then, so a later request sends the same text.
6
6
 
@@ -45,6 +45,8 @@ A new session field (the rule in `Session::FIELDS`' comment): only top-level key
45
45
 
46
46
  <a id="archiving-a-session"></a>**Archiving a session:** `chi sessions archive ID...`, `/archive` in a terminal and the Web UI's `archive` go through `SessionManager.archive_session`: it writes `<id>/archived` (`{"archived_at": …}`; not a session.json field, which every save rewrites) for the session and its delegated children. `Session.list` leaves archived sessions out unless `include_archived:`, and everything built on it follows: `chi sessions list` (`--archived` shows them, `[archived]`), the summaries (`--live`, json/tsv, the desktop panel, the agent's `list_sessions`) and the retention prune, which neither deletes nor counts them against `max_count`. `children_of` (max_children, `delegate_result`) still sees them, and `--resume`/`--attach ID`, `chi send`/`chi note ID` reach them as before. Refused: a turn running in the session or in one of its children (naming the child; 409 `busy` on the web), a prompt queued in `input/` (it would run on the next resume) or a live worker's step-limit question still open, in it or a child (409 `busy` too), a plain REPL owning it (`owned_by_tui`), a `chi scratch` session (`scratch`). A live idle worker is stopped first, and the marker is written even if it is still shutting down. Input a human typed brings a session back (`{"unarchived_at": …}`): a prompt from the web, an attached or plain terminal or `chi send` (origin `web:*`, `tui:*`, `cli:send`, none), steering merged into a running turn, a `/continue` answer or a question answered (the step-limit question too); a delegate's follow-up, a plugin's turn, a reminder, the idle recap, a note and just opening it don't. A child's input brings only the child back. The retention sweep ages an unarchived session from when it was unarchived, so unarchiving an old session doesn't let the next sweep take it. `/archive` leaves the session first, as `/exit` does, then archives it: in an attached terminal the worker is asked to exit (one that stays up for another UI is stopped by the archive; a running turn, a queued prompt or an open step-limit question refuses it), and an empty session is discarded instead; `chi scratch` refuses it. Typed while a turn runs, `/archive` waits for the turn's end (an attached terminal for the commands queued behind it too) and then archives. `chi sessions unarchive ID...` and the web's `unarchive` bring back the whole family. Web routes: `POST /api/sessions/:id/archive` (200 `{status, session_id, archived, stopped, discarded}`, ids) and `POST /api/sessions/:id/unarchive` (200 `{status, session_id, unarchived}`). The session hub and `GET /api/sessions` keep archived sessions, `archived: true` in their summaries: the page leaves them out of the strip, its count and the list at render, and the `archived` toggle beside the all-sessions search shows them, dimmed with an `archived` badge. The info bar marks a session whose live worker runs an older chi than the newest installed (`worker_version` in its summary): `chi 0.18.1 · Restart`, which asks to confirm and calls `POST /api/sessions/:id/restart` (a refusal's reason shows in a toast); a worker from before restarts shows `chi sessions stop ID` instead. The info bar reads `continue → · archive · stop · delete` (`unarchive` on an archived one; no `continue →` on a link continued already, see *Session chains*; `continue →` is a plain button like `archive`, in the accent on an archived session, where it is the thing to do); archiving the open session stays on it, with an Undo toast.
47
47
 
48
+ **System 1's saved runs** last as long as sessions: the sweep ages them by day file, and deleting a session purges its runs ([system-one.md](system-one.md)).
49
+
48
50
  **Lazy sweep:** automatic prune runs only while `chi web` runs, at most once per `session.sweep_interval_hours` (default 24): the web's session hub gives it a chance on its periodic full scan, and `GET /api/sessions` does too. No cron, and a terminal-only chi never sweeps. Manual prune is always available.
49
51
 
50
52
  <a id="cli"></a>**CLI:**
@@ -111,6 +113,51 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
111
113
  - A `chi scratch` session (`"scratch": true`) is deleted when its REPL ends; one a killed process left behind is deleted by the next sweep, `prune` or `clean`, whatever its age or status, once nobody owns it. `list` shows it as `[scratch]` (every text form; `scratch: true` in `--format json`); `chi web` never shows one. See [CLI: Scratch sessions](cli.md#scratch-sessions).
112
114
  - For ad-hoc manual QA use `SAMAGOTCHI_ENV=test XDG_STATE_HOME=/tmp/chi-test-$USER chi ...` to isolate from real state; the flag also marks sessions that `chi web` or an attached `chi` spawn (their workers inherit the environment), so `clean` finds them if they land in the real state.
113
115
 
116
+ ## Session file
117
+
118
+ `Session::FIELDS` is the table of `<uuid>.json`, in the order `Session#save` writes it (`Session.from_h` and `#to_h` both go by it; 41 keys). A key a file lacks reads as the default below; `review`, `first_turn_id`, `verdicts` and `verify_fresh` are written only when set (`OPTIONAL_FIELDS`). A key this chi doesn't know is kept and written back as it was (see the rule above). "Who writes it": the Engine runs in the session's worker (or the REPL); the rest are named.
119
+
120
+ | Key | Meaning | Written by |
121
+ |-----|---------|------------|
122
+ | `metadata_version` | The file format version (`METADATA_VERSION`, 3). | `Session#save` |
123
+ | `written_by` | The chi version that saved the file last (see *Which chi a worker runs*). | `Session#save` |
124
+ | `id` | The uuid; also the file name. Required. | `Session.new_session` |
125
+ | `mode` | `assist`, the only mode. Required. | `SessionManager.spawn_session` |
126
+ | `model_name` | The resolved model ref (an alias applied; `host:id` when it names a host), so a resume keeps the model. Required. | creation, `/model` |
127
+ | `model_typed` | The name it was typed as when that differs (an alias), for the `models:` lookup after a resume; `null` otherwise. | creation, `/model` |
128
+ | `working_directory` | The folder the session runs in. Required. | creation |
129
+ | `messages` | The conversation (symbol keys in memory, strings on disk). While a turn runs, a save writes the turn's checkpoint here instead (`TurnCheckpoint`). | the Engine, per turn |
130
+ | `created_at`, `updated_at` | ISO times; `updated_at` is stamped by every save and orders the lists. Required. | `Session#save` |
131
+ | `status` | `idle`, `running`, `error` or `stopped` (turn state, not liveness; a stop is also the `<id>/stopped` marker). | the worker / Engine; the marker by `chi sessions stop` |
132
+ | `last_prompt` | The newest prompt; a session created with a first prompt holds it here until its worker takes it; after an `error` status, the reason. | `SessionManager.spawn_session`, the Engine, `Session.mark_error` |
133
+ | `first_preview` | The first user message as a one-line preview (whitespace collapsed, 80 characters), or the first context note's (`note: …`) until a message is typed. Cached so lists and `Session.resolve_id`'s ambiguity message need not read `messages`. | `Session#save` (`compute_first_preview!`) |
134
+ | `title` | The name the user gave (`/title`, whitespace collapsed, 80 characters); shown in place of `first_preview` (`display_title`); a continue's new link inherits it. The recap's auto title is not stored here. See [Session titles](#session-titles). | `/title` (`TitleCommand`), `SessionManager` |
135
+ | `test_run` | Tagged by `SAMAGOTCHI_ENV=test`, `RACK_ENV=test` or `CI` (see Test-session hygiene). | creation |
136
+ | `pending_question` | The question the worker waits on (`PendingQuestion`), `null` with none. | `QuestionDesk` |
137
+ | `used_memory_names` | The memories the session read. | the Engine |
138
+ | `project_root` | The project the session started in, stored because a worktree may be deleted later; `null` outside a repo. | creation |
139
+ | `preloaded_memory_names`, `muted_memory_names` | `--memory` and `--mute`, written before the worker starts so a respawn builds the same prompt. | creation |
140
+ | `prompt_notes` | The model notes the system prompt carried at its last build (`[{name, scope, chars, digest}]`, see [Memory](memory.md#model-notes)). | the Engine, at each prompt build |
141
+ | `parent_id` | The session that delegated this one or forked it (`ctx.sessions.fork`); `null` otherwise. See [Delegating](#delegating). | `SessionManager.spawn_session`; `ChildMove` writes a `parent.json` beside it instead of this file |
142
+ | `scratch` | A `chi scratch` session, deleted when its REPL ends. | creation |
143
+ | `last_turn` | How the last turn ended (`LastTurn`), for a parent's wait and the web's notifications. | the Engine, at a turn's end |
144
+ | `verdicts` | Verdict cards recorded by completed Verify turns, in append order. | `Engine#record_verdict`, `VerifyFresh` |
145
+ | `delegate` | Started by the `delegate` tool (a fork has a `parent_id` but isn't one). | `Tools::Delegate` via `spawn_session` |
146
+ | `llm_context` | The session's own LLM context values (strategy, apply rule, budget), over the model's; `null` with none. | creation (`--llm-context`), `/llm-context`; copied by a fork and a continue, not a delegate child |
147
+ | `continues` | The previous link of a chain; `null` otherwise. See [Session chains](#session-chains). | `SessionContinue` |
148
+ | `thinking` | The session's own thinking level (`off`/`low`/`medium`/`high`), over the process default and the model's. | creation (`--thinking`), `/thinking`; kept by a continue and a fork |
149
+ | `reasoning_history` | Its own `reasoning_history` level (`drop`/`turn`/`all`). | `/reasoning-history`; carried like `thinking` |
150
+ | `appended_prompt_files` | The absolute paths of the files `--append-system-prompt` put into the system prompt (config.yml's `append_system_prompt:` entries are not stored); kept by respawns, copied by a fork and a continue (`SessionSetup`). | creation |
151
+ | `toolkit` | The toolkit it was created on, as named (`{name, add, remove}`); `null` is `full`. See [Toolkits](configuration.md#toolkits). | creation, `/toolkit` |
152
+ | `toolkit_origin` | Why it is on that kit: `default`, `chosen` (also the reading of a file from before the field), `inherited` or `suggested`. | creation, `/toolkit`, the toolkit suggestion |
153
+ | `bundle_settings`, `config_settings` | `--set`: `BUNDLE.KEY` values by bundle and the session-settable core keys; kept by a respawn, a continue and a fork, not by a delegate child. | creation |
154
+ | `interrupted_turn` | The mark of a turn a worker checkpointed and never finished (`InterruptedTurn`); the next worker settles it and sets it to `null`. | the worker |
155
+ | `parent_answers` | Its own `guardrails.parent_answers` (it may only tighten config.yml's). | creation (`--parent-answers`), `/parent-answers` |
156
+ | `working_set_pins` | The working-set items the user pinned (`/working-set pin`); kept by a respawn, a continue and a fork. | the Engine's command |
157
+ | `review` | A review session's subjects and source (`SessionReview`), see [Review sessions](#review-sessions). | `ReviewStart` |
158
+ | `first_turn_id` | The id the creating prompt's turn gets, so a wait can name it (`chi send --new --wait`, `--wait --turn T ID`); `null` for a session created idle. | `SessionManager.spawn_session` |
159
+ | `verify_fresh` | A Verify fresh child's requester, rules and claim (`VerifyFreshOrigin`). | `VerifyFresh.start`, once |
160
+
114
161
  ## Context notes
115
162
 
116
163
  A context note is text pushed into a session as background: not a prompt, and it starts no turn.
@@ -311,30 +358,7 @@ sessions](cli.md#review-sessions)); both start idle and print the `chi share` li
311
358
 
312
359
  ## Toolkits
313
360
 
314
- A session file's `toolkit` (`{"name", "add", "remove"}`) is the toolkit it was created on, as named: config's
315
- `toolkit.default`, `toolkit.child` for a delegate child, or `chi --toolkit` / `chi send --new --toolkit` (with
316
- `--tool` / `--no-tool`). It is never resolved again from config: a respawned or woken worker declares the same
317
- tools. `null` (a session from before toolkits) is `full`. A continue (`continue →`, `chi send --new --continues`)
318
- and a fork (`ctx.sessions.fork`) keep it, as they keep the thinking level; a delegate child gets the kit its
319
- `delegate` call names (`toolkit:`, any kit the parent knows) or `toolkit.child`; one its worker can't resolve
320
- declares every tool (full). The file stores only the name: a kit's tools and prompt text come from its definition.
321
- `/toolkit` changes it, saved at once, from the next turn's start. See
322
- [Toolkits](configuration.md#toolkits).
323
-
324
- `toolkit_origin` records why the session is on that kit: `default` for the configured kit, `chosen` for a
325
- user-selected kit (including `/toolkit` and `ctx.use_toolkit`), `inherited` for a continue or fork, or `suggested`
326
- when `toolkit.suggest.mode: on` switched it ([Toolkits](configuration.md#toolkits)). A session file from before the
327
- field reads as `chosen`. Only a `default` session is ever suggested a kit.
328
-
329
- A `find_tools` result on a chat host saves the tools its hit loads as `loaded: ["list_sessions", "mcp_fs_read"]` on
330
- its `tool_response` entry (wire names: a built-in's, an MCP tool's `mcp_<server>_<tool>`), whatever the host: a
331
- later turn on `api: anthropic` (after a `/model` switch, a resume) sends the load as references, any other host
332
- ignores it. The names stay deferred for the session's life, across respawns, `--resume` and forks.
333
-
334
- A plugin's `ctx.load_tools` leaves a system entry `{"role": "system", "kind": "tool_change", "content": "[tools loaded:
335
- delegate (bundle)]", "tool_change": {"add": ["delegate"], "by": "bundle"}}` after the turn's user message, only on a
336
- host that takes tool changes (`api: anthropic`). It is never the system head, counts as conversation, is hidden in
337
- the web and TUI histories, and goes to no other wire; its names stay deferred for the session's life like a load's.
361
+ A session's `toolkit` and `toolkit_origin` are in the [Session file](#session-file) table. What a toolkit is, how it is saved at creation, what a continue, a fork and a delegate child get, `/toolkit`, and how `find_tools` loads and `ctx.load_tools` tool changes are saved in the conversation: [Toolkits](configuration.md#toolkits).
338
362
 
339
363
  ## Parent answers
340
364
 
@@ -425,7 +449,7 @@ mise.toml pins Ruby 3.4 gets that `ruby`, `gem` and `bundle` in its tools while
425
449
 
426
450
  ## Delegating
427
451
 
428
- The `delegate` tool hands a task to a **child session**: an ordinary chi session in a worker of its own, started in the parent's `working_directory` with the task, verbatim, as its first user message, on the parent's model unless the call names one (`model:` takes a name or an alias, resolved as `--model` is; an unknown host comes back as the tool's `Error:` line; an id the host's saved model list doesn't have starts the child with a `Warning: host 'h' doesn't list model 'x' (did you mean: …?)` line in the result). Children run in parallel and only their **final reply** comes back to the parent: the child's newest `output/<timestamp>.txt` file, which the worker writes at the end of each turn that produced visible text, cut head-and-tail above 64 KB (`DelegateWait.cut`); a reply longer than `max_tool_output_chars` keeps its start and its end (the conclusion), with a gap line naming that `output/` file, where the whole reply is (`ToolOutputCut`'s `:head_tail_ref`; see [Tool output guardrails](internals/tool-guardrails.md#the-runners-cut-max_tool_output_chars)). Nothing of the child's trace enters the parent's context. Nothing is hidden: a child shows in `chi sessions list` (`↳ <parent>`), the web (folded into its parent's card on the strip and in all sessions, whose `▸ N delegates` chip lists the delegates at any depth, see Web UI above; a card of its own with a `↳` chip when its parent isn't listed; `↑ <parent id>` in its info bar, its tooltip `delegated by session …`; the parent's info bar carries a `⑂ N` chip counting its delegates, with how many run, wait, are done, failed, stopped or idle in the tooltip and the waiting ones named in the attention colour; a click opens all sessions), the terminal's status row (a parent's `⑂ 2 running · 1 waiting`, see [Status Line](cli.md#status-line)) and the `list_sessions` tool (`child`; the parent shows as `parent` in the child's own list); the user can `chi --attach <child id>` and steer it mid-run, and `chi send` and `send_note` reach it like any session.
452
+ The `delegate` tool hands a task to a **child session**: an ordinary chi session in a worker of its own, started in the parent's `working_directory` with the task, verbatim, as its first user message, on the parent's model unless the call names one (`model:` takes a name or an alias, resolved as `--model` is; an unknown host comes back as the tool's `Error:` line; an id the host's saved model list doesn't have starts the child with a `Warning: host 'h' doesn't list model 'x' (did you mean: …?)` line in the result). Children run in parallel and only their **final reply** comes back to the parent: the child's newest `output/<timestamp>.txt` file, which the worker writes at the end of each turn that produced visible text, cut head-and-tail above 64 KB (`DelegateWait.cut`); a reply longer than `max_tool_output_chars` keeps its start and its end (the conclusion), with a gap line naming that `output/` file, where the whole reply is (`ToolOutputCut`'s `:head_tail_ref`; see [Tool output guardrails](internals/tool-output-limits.md#the-runners-cut-max_tool_output_chars)). Nothing of the child's trace enters the parent's context. Nothing is hidden: a child shows in `chi sessions list` (`↳ <parent>`), the web (folded into its parent's card on the strip and in all sessions, whose `▸ N delegates` chip lists the delegates at any depth, see Web UI above; a card of its own with a `↳` chip when its parent isn't listed; `↑ <parent id>` in its info bar, its tooltip `delegated by session …`; the parent's info bar carries a `⑂ N` chip counting its delegates, with how many run, wait, are done, failed, stopped or idle in the tooltip and the waiting ones named in the attention colour; a click opens all sessions), the terminal's status row (a parent's `⑂ 2 running · 1 waiting`, see [Status Line](cli.md#status-line)) and the `list_sessions` tool (`child`; the parent shows as `parent` in the child's own list); the user can `chi --attach <child id>` and steer it mid-run, and `chi send` and `send_note` reach it like any session.
429
453
 
430
454
  - `delegate(task, model:, session:, cwd:, wait:, timeout:)`: starts a child and, by default, waits for its reply. `wait: false` is the usual way for work that takes a while, several children side by side, or to keep talking with the user: it returns at once, the parent ends its turn or works on, and the reply comes back by itself as a *delegate report* (below), in a turn of its own if the parent is idle. The tool descriptions tell the model so, and to wait (`wait: true`, `delegate_result`) only when its turn can't go on without the reply. `timeout`, default 600 s, leaves a slow child running; 0 checks once without waiting. With `session:` (a child's id or prefix) the task goes to that child as a follow-up instead, delivered like `chi send` (client id `delegate:<parent short id>`, shown as `delegate>` in the child's UIs); only this session's children take one. The wait (`wait: true`, a later `delegate_result`) and the delegate report are for the reply of the child's turn the follow-up went to: its running turn, which it merges into, or the turn of its own it runs when the child is idle (`turn` in `delegates.json`, set when the child's worker names `turn_ids`), not a newer reply of a turn queued behind it. A follow-up merged as a steer into a turn the child started at that moment (a wake, a reminder, another message) gets that turn's reply or question. A child on its first turn, or with an older worker, gives its next reply, as before.
431
455
  - `cwd:` starts the child in another folder of the parent's repository: a linked worktree (`git worktree add ../<repo>-<task> -b <branch>`, which the model runs with `execute` first, under guardrails; chi runs no git here) or a subfolder; a relative path is taken from the parent's folder. The folder must exist, have the parent's project root (the same common git dir, `MemoryPaths.project_root`) and be in a work tree: a git dir (`.git`, `.git/objects`, a bare layout's `.bare`) or a bare layout's container is refused (`git rev-parse --is-inside-work-tree`, read-only, decides). Outside git only a subfolder of the parent's folder is allowed. Otherwise the tool returns `Error: cwd must be a folder of this session's repository …` or `… is not in a work tree …`. A follow-up (`session:`) refuses `cwd:`: the child keeps its folder. The child's guardrails measure from its own worktree, and project memories, `list_sessions` and the web's project are the same as the parent's.
@@ -434,7 +458,7 @@ The `delegate` tool hands a task to a **child session**: an ordinary chi session
434
458
  - A wait that timed out or was canceled still gets its delegate report: neither moves the parent's cursor, so the child's reply comes as a report when its turn ends. The result says so (`chi brings its reply here by itself as a delegate report …`; with `queue`, that it joins the next step or turn) and that `delegate_result` waits again only if the turn needs the reply now. A parent that gets no reports (`session.delegate_reports: off`, a `--no-shared` REPL, `--non-interactive`) is told `delegate_result <id> waits again` instead, and `wait: false`'s result says `delegate_result waits for its reply`.
435
459
  - What a wait returns, `session: <id>` then `status:` and the text, with the status words `chi send --wait --format json` uses: `answered` with the reply after a `---` line; `question` as soon as the child opens a question (the model's own, or a hook's), with the question's text, options and the `chi answer` command for it (nothing is answered for it: `chi answer`, attach or use the web, then wait again); `failed`, `canceled` or `no_answer` when the child's turn ended with nothing to say (its session shows what happened); `error` (its worker crashed) or `stopped` (`chi sessions stop`) at once; `worker_gone` when the child has had no worker for 15 seconds (it crashed without saying so, or idle-exited): `delegate` with `session:` starts it again; `running` on timeout, or when the parent's own turn is canceled (the child keeps running; the next turn can wait again). A reply saved before the child's worker idle-exited is returned at once.
436
460
  - **A child's step-limit question goes back to the parent model**, not to its user: `delegate_result` returns `status: question` with the `chi answer` commands for Continue and Stop, and says the model may instead send a narrower follow-up (`delegate session:`, which drops the question) or report back. A parent's `chi answer` follows `guardrails.parent_answers`, or the child's own when stricter (`hard`: Stop only; see [chi as a sub-agent](sub-agent.md#the-step-limit)).
437
- - **A child at its cost cap** (the [cost-cap bundle](plugins.md#the-cost-cap-bundle), the child's own cap: `child:` in its settings, or `/cap`) asks a `hook` question, "Cost cap", `Continue` or `Stop`. Like every hook's question on a `delegate` child, it goes where it can be decided, under the child's `guardrails.parent_answers` (config.yml's, or the child's own when stricter): when the parent may pick an option other than a deny (Stop) there, `standard` and `soft` here, it comes back to the parent's model as `status: question` with the `chi answer` commands; when it may not, `hard` here (a parent's Continue is refused), it goes to the parent's user as a card, as approvals do (the relay below), and the model sees one line: `hook question relayed to your user: Cost cap → Stop` (or `dismissed`, which the hook reads as it likes, cost-cap as Stop; `refused by the child …`, `answered on the child`, `still open (your turn was stopped)`). Free text alone doesn't count as an option the parent may pick. A **background** child (`wait: false`) paused at its cap rings its parent as a question does (delegate reports, below): the parent's model gets it as a report when it may answer; otherwise the parent's worker relays it to its user at its next step boundary, or in a wake turn's first boundary when idle (before the model is called), and the report the model gets is the outcome line. While that card is open the parent's turn waits on it; a Stop closes it and leaves the question for your next input (or a `delegate_result`): no wake turn offers that question again meanwhile, while other children's reports and attached-context wakes go on. Under the default `soft` that means a background parent's model now hears its child's cost cap, and may Continue it.
461
+ - **A child at its cost cap** (the [cost-cap bundle](bundles.md#the-cost-cap-bundle), the child's own cap: `child:` in its settings, or `/cap`) asks a `hook` question, "Cost cap", `Continue` or `Stop`. Like every hook's question on a `delegate` child, it goes where it can be decided, under the child's `guardrails.parent_answers` (config.yml's, or the child's own when stricter): when the parent may pick an option other than a deny (Stop) there, `standard` and `soft` here, it comes back to the parent's model as `status: question` with the `chi answer` commands; when it may not, `hard` here (a parent's Continue is refused), it goes to the parent's user as a card, as approvals do (the relay below), and the model sees one line: `hook question relayed to your user: Cost cap → Stop` (or `dismissed`, which the hook reads as it likes, cost-cap as Stop; `refused by the child …`, `answered on the child`, `still open (your turn was stopped)`). Free text alone doesn't count as an option the parent may pick. A **background** child (`wait: false`) paused at its cap rings its parent as a question does (delegate reports, below): the parent's model gets it as a report when it may answer; otherwise the parent's worker relays it to its user at its next step boundary, or in a wake turn's first boundary when idle (before the model is called), and the report the model gets is the outcome line. While that card is open the parent's turn waits on it; a Stop closes it and leaves the question for your next input (or a `delegate_result`): no wake turn offers that question again meanwhile, while other children's reports and attached-context wakes go on. Under the default `soft` that means a background parent's model now hears its child's cost cap, and may Continue it.
438
462
  - **A child's approvals go to the parent's user** (the approval relay), and so do the hook questions its parent can't usefully answer (above: a card headed `delegate ab12 asks: Cost cap`, the child's options and kind, no approval facts). While the parent waits (`delegate`, `delegate_result`), a guardrail approval the child opens reopens as the parent session's own approval: the same card in the parent's web page and attached terminal, with the same facts and diff, a line naming the delegate and its task (`delegate ab12 · fix the flaky spec…`, linked to its session) and the header `Approve delegate ab12's tool call?`. The options are the child's, by index; "for the session" says it is the delegate's session (the session scope keys on the child; repo and rule approvals are one shared store). The user answers once there; the parent's model never sees the approval, only one line per relayed approval in the tool's result, before the reply: `approval relayed to your user: execute: git push origin main → allowed once` (or `denied ("why")`, `dismissed (denied)`, `answered on the child`, `not delivered (the child's worker is gone)`, `refused by the child …`, `still open (your turn was stopped)`). The time the card is open doesn't count against `timeout`. While the parent waits on one child, its other running children's approvals are relayed too, oldest first, one card at a time (`+1 more delegate waiting`); each outcome comes in that child's next result.
439
463
  - The child's own card stays answerable (the first answer wins: answered there, the parent's card closes as "answered in ab12"). While relayed it says `Waiting for approval in parent ab12: answering here works too`; `chi sessions list` shows the child as `waiting (in parent ab12)` (`--format json`: `relayed_to`), and the web's bell rings for the parent's card, not the child's.
440
464
  - How the child trusts it: the parent's POST that says "answered" (Bridge `POST /session/<child>/relay`) carries no answer. The child asks its parent session's live Bridge (`POST /session/<parent>/relay/status`), which reports only what the parent's question flow took from a UI (`RelayDesk`, in memory only), and takes it only for this child, the question pending now, answered. A parent agent's answer on the relay card (a piped or marked `chi --attach PARENT`, `chi answer`) is held to `guardrails.parent_answers` (each session's own, when stricter) by the parent and again by the child.
data/docs/sub-agent.md CHANGED
@@ -24,10 +24,10 @@ parent's setting, then to the child's.
24
24
 
25
25
  The other direction, chi sending a part of its work to a Claude Code session
26
26
  you already work with (to validate, as context, or with a question), is the
27
- [claude-peer bundle](plugins.md#the-claude-peer-bundle): Claude pairs with the
27
+ [claude-peer bundle](bundles.md#the-claude-peer-bundle): Claude pairs with the
28
28
  chi session, chi's `claude_send` tool or `/claude` writes to Claude's inbox,
29
29
  and Claude's answer comes back as attached context. The
30
- [codex-peer bundle](plugins.md#the-codex-peer-bundle) does the same with a
30
+ [codex-peer bundle](bundles.md#the-codex-peer-bundle) does the same with a
31
31
  Codex CLI session (`codex_send`, `/codex`, through `codex queue`). The peer's
32
32
  answers wake chi in a short loop (fix or disagree, send again) until it says
33
33
  `VERDICT OK` (one closing wake: chi carries on or tells you what was agreed)
@@ -45,10 +45,10 @@ chi answer ID --question QID (--option N|LABEL)... [--text T] [--timeout S] [--f
45
45
  chi answer ID --question QID --dismiss [--timeout S] [--format json]
46
46
  ```
47
47
 
48
- A child with a bundle setting of its own, such as a $ cap (the [cost-cap bundle](plugins.md#the-cost-cap-bundle)):
48
+ A child with a bundle setting of its own, such as a $ cap (the [cost-cap bundle](bundles.md#the-cost-cap-bundle)):
49
49
  `chi send --new --wait --format json --set cost-cap.cap=3 -m "task"`. At the cap the wait exits 3 with the cost-cap
50
50
  question; `chi answer ID --question QID --option Continue` (or `Stop`) answers it. A cap on the child's work across
51
- its turns (the [work-cap bundle](plugins.md#the-work-cap-bundle)), the bound a parent that keeps answering the step
51
+ its turns (the [work-cap bundle](bundles.md#the-work-cap-bundle)), the bound a parent that keeps answering the step
52
52
  limit's Continue doesn't otherwise have: `--set work-cap.tool_calls=400` (or `work-cap.minutes=60`, active minutes).
53
53
 
54
54
  `chi answer` waits after answering, the same way `chi send --wait` does, so the
@@ -124,10 +124,10 @@ A chi parent's `delegate_result` gets a child's step-limit question back as a
124
124
  `delegate session:` (which drops the question), or report back. It isn't
125
125
  relayed to the parent's user the way approvals are.
126
126
 
127
- A session at its cost cap (the [cost-cap bundle](plugins.md#the-cost-cap-bundle)) waits the same way, as a
127
+ A session at its cost cap (the [cost-cap bundle](bundles.md#the-cost-cap-bundle)) waits the same way, as a
128
128
  question of kind `hook` with header "Cost cap" and options `Continue` and `Stop`: `--option Continue` raises the cap
129
129
  by another cap's worth and the turn goes on, `--option Stop` ends it `canceled` with `stopped_by: cost-cap`. A session at its work cap (the
130
- [work-cap bundle](plugins.md#the-work-cap-bundle)) asks the same, header "Work cap", the question naming the
130
+ [work-cap bundle](bundles.md#the-work-cap-bundle)) asks the same, header "Work cap", the question naming the
131
131
  session's totals (`Work cap reached: 412 tool calls of 400 (6 turns, 47 min active, $1.20). Continue raises it to
132
132
  800.`); `stopped_by: work-cap` on Stop.
133
133
 
@@ -0,0 +1,122 @@
1
+ # System 1's saved runs
2
+
3
+ System 1 is the cheap judgment model chi asks small questions: approvals
4
+ ([guardrails.md](guardrails.md#system-1-experimental)), toolkit suggestions
5
+ ([configuration.md](configuration.md#toolkits)), context candidates
6
+ ([context.md](context.md)) and bundles' own ([plugins.md](plugins.md#judgments-chijudgment-and-ctxjudge)). Each use keeps its own mode, gates and log. What they share is
7
+ one host (`system_one.host`, `.model`, `.timeout_ms`) and one layer that saves every ask, so a later comparison or fine-tune has what
8
+ was sent and what came of it. The runs don't leave the machine; a hosted System 1 host sees each state as it is asked.
9
+
10
+ ## The common layer
11
+
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
15
+ one warn and never changes the answer. A use that needs the run's id at once passes it
16
+ (`client.ask(..., run_id: id)`); one client serves several threads.
17
+
18
+ | Judgment | Asked by | Later lines |
19
+ |---|---|---|
20
+ | `approvals.allow` | `GuardrailWiring#system_one_judge` | gates, acted (the judgment line's decision), labels |
21
+ | `toolkit.suggest` | `ToolkitSuggest.judge` | acted (the log's `did`), no labels |
22
+ | `context.candidate` | `ContextJudge.ask` | labels |
23
+ | `<bundle>.<name>` | `SystemOne::Judgments` (`ctx.judge`, `ctx.judge_later`) | the bundle's: labels, acted |
24
+
25
+ Not saved: a toolkit warm-up (it goes straight to the backend), `chi guardrails replay`,
26
+ chi web's availability check, a scratch session's asks or anything about them, and a participant's turn (never judged by core; a bundle's ask there still goes, unsaved).
27
+ An owner's turn in a review session is judged, so it is saved. Test runs are saved with `test_run: true`.
28
+
29
+ ## Bundles' judgments
30
+
31
+ A bundle declares a judgment with `chi.judgment` and asks through `SystemOne::Judgments`, the Engine's (`Plugin::Host#judgments`), on the
32
+ same `Client` and `Recording`: the API is in [plugins.md](plugins.md#judgments-chijudgment-and-ctxjudge), the settings in
33
+ [configuration.md](configuration.md#bundles-judgments).
34
+
35
+ Only bundles' judgments have settings of their own (`JudgmentSettings`). Core's take no host, model or mode here, whatever
36
+ `system_one.judgments` says: `Record` and `Similar` are model-blind (`Record#initialize` filters by rule only), so a model swapped
37
+ for approvals alone would inherit a clean record and could auto-allow. Before any such swap, `Record` and `Similar` have to go by model.
38
+
39
+ Core's uses keep their own threads (`ToolkitSuggest::Pending`, `ContextJudge`, the approval path) and contention; only the ask-and-save
40
+ seam is common. A bundle's `judge_later` runs on the Engine's `SideJudgments` under its full name as the kind, so `SideJudgments#join`
41
+ at shutdown covers it, and a run cut by that one-second limit is lost. Its asks stay out of the session's spend, as all of System 1's do, and no
42
+ cost cap covers them on a hosted host.
43
+
44
+ ## The store
45
+
46
+ `<state dir>/system_one/runs/<judgment>/YYYY-MM-DD.jsonl` (`SystemOne::RunsStore`): one file per UTC day,
47
+ 0600 in 0700 folders, one append under `flock` per line. Each judgment's questions are stored
48
+ once per digest pair in `versions.jsonl` beside them.
49
+
50
+ A line over 16 KB has its state cut, as a string, and is marked `truncated`.
51
+
52
+ Agents can't write here: `write`, `edit` and `memory_write` are denied (`system-one-runs`), a shell
53
+ command naming the folder is `touches: chi_dirs`, and it is in `ChiDirs.protected`, so only the user
54
+ may answer an ask about it. A label here may one day train a model.
55
+
56
+ ### What a run holds
57
+
58
+ A `run` line: `id`, `judgment`, `at`, `instructions_digest`, `criteria_digest`, `state` as sent, `asked`,
59
+ `origin` (`live`), `mode` (the use's own: toolkit and context build an `on` client in audit too),
60
+ `host`, `model` (resolved), `served_model`, `answers` (raw, as the backend sent them), `usage`,
61
+ `failure` and `failure_text`, `latency_ms`, `session_id`, `chain_link`, `session_model`, `test_run`, `truncated`.
62
+ Nil fields are left out. A bundle's run may add `positive` and `negative`, the label values it declared.
63
+
64
+ A skipped ask is a run too, written at once: `asked: false` and `skipped` (`busy`: a `judge_later` whose last one still
65
+ runs), with its state, host and model but no answers, so a later replay can answer it.
66
+
67
+ **That means prompts, commands, diffs and any secret in them are in these files.** A state is whatever the
68
+ use sends System 1: an approval's command (up to 2000 chars), diff and earlier examples, a toolkit's
69
+ first prompt, a context candidate's owner prompts. **check-in's sends tool output** — the calls of a turn
70
+ since its last check-in, each cut as it is captured, and what each tool returned — the first class of data
71
+ approvals and context never send: on a hosted `system_one.host` it reaches that host with the ask, so a
72
+ hosted System 1 sees the machine's tool output, not just what the agent asked about.
73
+
74
+ `chain_link` is the session's parent, else the session it continues. It isn't the chain's root: following a
75
+ chain takes a session listing (`SessionChain`), too slow on an approval's path. An export splitting by chain
76
+ follows the links.
77
+
78
+ A run's id is `<judgment>/<uuid>`, carrying its judgment. Approvals use it as the judgment line's id in
79
+ `judgments.jsonl`, so `verdict.judgment[:id]` and `/guardrails wrong` carry it with no new field; toolkit's
80
+ and context's log lines have it as `run_id`. An id from before runs names no judgment and labels no run.
81
+
82
+ ### Later lines
83
+
84
+ Later lines name their run by `run_id` and may sit in a later day's file, or in theory before
85
+ the run: a join happens at read, and a line whose run is gone is dropped.
86
+
87
+ - `label` {`value`, `by`, `strength`, `question`, `at`}.
88
+ - Approvals (`SystemOne::ApprovalLabel`, the one reading of an answer for the log and the run):
89
+ `allow_once`, `allow_wide`, `deny` by `user`, strong; `should_have_asked` from `/guardrails wrong` or the
90
+ web's **should have asked**, strong; `deny` by `parent`, weak, only where the parent could have allowed; a
91
+ parent's allow is no label; `none` for a cancelled ask, which is not an answer.
92
+ - Context: `attached`, `declined` by `command` (`/context attach|decline`, the card's buttons), `declined`,
93
+ `detached` by `user` (a removal seen at an owner turn's start), on the latest `judged` line's run, question
94
+ `about`. A chip removed after the last turn isn't seen, and an attach by hand is no label.
95
+ - Toolkit has none: `did: chosen` doesn't say what was picked. "Covers" stays derived in `chi toolkit report`.
96
+ - A bundle's: its own word, raw, by `<bundle>` or `<bundle>:<what>`, through `ctx.judgments.label`. A bundle reaches only its own runs.
97
+ check-in's are the card's actions that took effect (`nudge`, `keep_going`, `stop`, weak — a Nudge asks for a status, not a
98
+ verdict, and Keep going may be habit) and `turn_stopped` by `turn` when the turn ended with the card still open.
99
+ - `acted` {`what`, `at`}: approvals' decision (`auto`, `asked`, `audit`), toolkit's `did`, a bundle's own word.
100
+ - `gates` {`rule`, `rule_listed`, `self_protect_refused`, `threshold`, `at`}: approvals', at ask time,
101
+ since neither can be recomputed later.
102
+
103
+ Labels are biased, and a later comparison must handle it: an auto-allowed call has no answer unless
104
+ flagged, and a stored wide approval never reaches the judge at all.
105
+
106
+ ## Retention and deleting
107
+
108
+ Runs last as long as sessions. The session sweep deletes day files older than `session.retention_days` (0: forever),
109
+ then the oldest until the total is under 100 MB; today's stay. A judgment with no day file left loses its folder.
110
+
111
+ `chi sessions delete` (and the web's delete, and `/exit --delete`) purges that session's runs and their later lines; a sweep
112
+ doesn't, and neither does discarding a session its worker left empty. A purge can't reach text a later run of another session
113
+ embedded: an approval's state carries earlier asks as examples.
114
+
115
+ To stop saving: `system_one.runs: off` in config.yml (what's there still ages out). To delete it all:
116
+ `rm -rf <state dir>/system_one`.
117
+
118
+ ## Looking at it
119
+
120
+ `chi judgments ls` lists each judgment's digests, runs, labels (each run's last, per question), failures, models and size. `chi judgments report NAME
121
+ [--days N]` gives one judgment's counts, p histogram and labels by value (each run's last, per question). Both only read.
122
+ A use's own report stays: `chi context report`, `chi toolkit report`, `/guardrails`.
data/docs/testing.md CHANGED
@@ -40,6 +40,8 @@ that way locally: `npx playwright run-server --port 3333`, then `PW_TEST_CONNECT
40
40
 
41
41
  No spec reads your own setup. The suite points `XDG_CONFIG_HOME` and `XDG_STATE_HOME` at temp folders,
42
42
  clears your `SAMAGOTCHI_*` environment, and keeps every spec off the network (WebMock).
43
+ `SAMAGOTCHI_TAILSCALE_CLI` points at a file that does not exist (`spec_helper.rb`), so no spec reaches a real `tailscale`; a
44
+ `chi web --tunnel tailscale` spec writes a fake one and sets the variable.
43
45
 
44
46
  Goldens (`*_golden_spec.rb`, the prompt snapshots) hold no machine state, so they pass on any runner: no absolute
45
47
  paths (replace them with placeholders), no PATH tools (the suite pins `rg` as found, `spec_helper.rb`; an example
@@ -194,7 +196,7 @@ callback the spec can wait on), not another local fix in the spec.
194
196
  ### Talking to a real Bridge
195
197
 
196
198
  A session's Bridge answers only a request that names its secret (`X-Chi-Bridge`, from the worker's `bridge.json`;
197
- see [architecture.md](architecture.md)). A spec that writes its own request to a real Bridge (`Net::HTTP`, a raw
199
+ see [internals/bridge.md](internals/bridge.md)). A spec that writes its own request to a real Bridge (`Net::HTTP`, a raw
198
200
  socket) names it too: `include BridgeAuth` (spec/support/bridge_auth.rb, required by the spec that needs it) gives
199
201
  `bridge_auth(session_dir_or_sidecar)`, the header as a Hash, and `bridge_auth_line(...)` for a raw request; or read
200
202
  `port` and `secret` from the sidecar in one go, as `WorkerLink.client` does. A client made with `BridgeClient.new`
@@ -23,6 +23,7 @@ require_relative "bridge/turn_accumulator"
23
23
  require_relative "session"
24
24
  require_relative "engine"
25
25
  require_relative "system_one/log"
26
+ require_relative "system_one/approval_label"
26
27
  require_relative "session_commands"
27
28
  require_relative "queued_command"
28
29
  require_relative "steer"
@@ -815,6 +816,7 @@ module Samagotchi
815
816
  return [{}, 409, { error: "already_labelled" }] unless entry["answer"].nil?
816
817
  return [{}, 500, { error: "label_failed", detail: "could not write the System 1 label" }] unless log.label(judgment_id, "should_have_asked")
817
818
 
819
+ SystemOne::ApprovalLabel.save(SystemOne::Runs.for_sessions_dir(@state_dir), judgment_id, SystemOne::ApprovalLabel::WRONG)
818
820
  [{}, 200, { status: "labelled", id: judgment_id }]
819
821
  end
820
822
 
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../config"
5
+ require_relative "../version"
6
+ require_relative "../plugin/api"
7
+ require_relative "../plugin/context"
8
+
9
+ module Samagotchi
10
+ class BundleCommand
11
+ # What a plugin's source calls of the plugin API, read with Prism
12
+ # (`chi bundle check`'s `api` and `requires-chi-floor`). The classes are
13
+ # the source of truth: a name is checked against their public methods
14
+ # and a keyword against the method's parameters, so nothing here lists
15
+ # the API. It sees only calls written on a local `ctx` or `chi` itself
16
+ # (ctx.sessions.nope; not `s = ctx.sessions; s.nope`): a miss, never a
17
+ # false alarm, is what a variable costs.
18
+ class ApiCalls
19
+ # A call that isn't there, or a keyword its method doesn't take.
20
+ Finding = Data.define(:line, :message, :fix)
21
+
22
+ # What each API object's member gives: the only list here, of the
23
+ # objects behind a call (their names and keywords are their classes').
24
+ # Resolved on use: Sessions alone loads ~150 files.
25
+ MEMBERS = {
26
+ "Plugin::Api" => { ctx: "Plugin::Context" },
27
+ "Plugin::Context" => { sessions: "Plugin::Sessions", context: "Plugin::AttachedContext", http: "Plugin::Http",
28
+ judgments: "Plugin::Judgments", cost: "SessionMetrics::Cost", usage: "SessionMetrics::Usage",
29
+ log: "Plugin::Context::Logger" }
30
+ }.freeze
31
+ ROOTS = { ctx: "Plugin::Context", chi: "Plugin::Api" }.freeze
32
+
33
+ def initialize(source)
34
+ @result = Prism.parse(source)
35
+ @nodes = []
36
+ return unless @result.success?
37
+
38
+ queue = [@result.value]
39
+ while (node = queue.pop)
40
+ @nodes << node
41
+ queue.concat(node.compact_child_nodes)
42
+ end
43
+ @nodes.sort_by! { |node| node.location.start_offset }
44
+ end
45
+
46
+ # Each call's keywords, as "chi.toolkit(groups:)": the call's receiver
47
+ # as written, its name and the keyword (with or without parens, over
48
+ # lines). Nothing when the source doesn't parse.
49
+ # @return [Array<String>]
50
+ def keywords
51
+ calls.flat_map do |node|
52
+ next [] unless node.receiver && node.arguments
53
+
54
+ keyword_names(node).map { |keyword| "#{node.receiver.slice}.#{node.name}(#{keyword}:)" }
55
+ end
56
+ end
57
+
58
+ # What the source calls that this chi's API lacks, in source order.
59
+ # Nothing when it doesn't parse (the install's error says so). A name
60
+ # the source guards with respond_to? is a newer chi's, on purpose.
61
+ # @return [Array<Finding>]
62
+ def findings
63
+ calls.filter_map do |node|
64
+ owner = owner_of(node.receiver) or next
65
+ klass = class_named(owner)
66
+ if klass.public_method_defined?(node.name)
67
+ keyword_finding(node, klass)
68
+ elsif !guarded?(node.name)
69
+ missing_call(node, klass)
70
+ end
71
+ end
72
+ end
73
+
74
+ private
75
+
76
+ def calls = @nodes.grep(Prism::CallNode)
77
+
78
+ # The API class +receiver+ is an instance of, by how it is written, or nil.
79
+ def owner_of(receiver)
80
+ case receiver
81
+ when Prism::LocalVariableReadNode then ROOTS[receiver.name] unless shadowed?(receiver.name)
82
+ when Prism::CallNode then MEMBERS.dig(owner_of(receiver.receiver), receiver.name)
83
+ end
84
+ end
85
+
86
+ # A local ctx or chi the plugin set to anything but chi.ctx isn't the API.
87
+ def shadowed?(name)
88
+ @nodes.grep(Prism::LocalVariableWriteNode).any? { |write| write.name == name && write.value.slice != "chi.ctx" }
89
+ end
90
+
91
+ def guarded?(name)
92
+ calls.any? do |node|
93
+ next false unless node.name == :respond_to? && owner_of(node.receiver)
94
+
95
+ first = node.arguments&.arguments&.first
96
+ first.respond_to?(:unescaped) && first.unescaped == name.to_s
97
+ end
98
+ end
99
+
100
+ def class_named(name) = Samagotchi.const_get(name)
101
+
102
+ def missing_call(node, klass)
103
+ near = Config.near_names(node.name.to_s, klass.public_instance_methods(false).map(&:to_s)).first
104
+ call = "#{node.receiver.slice}.#{node.name}"
105
+ fix = near ? "did you mean #{node.receiver.slice}.#{near}? docs/plugins.md has the API" : "docs/plugins.md has the API"
106
+ Finding.new(line: node.location.start_line, message: "#{call} isn't in chi #{VERSION}'s plugin API", fix: fix)
107
+ end
108
+
109
+ # One Finding, the first keyword the method doesn't take. A method with
110
+ # no keywords takes a hash as an argument; one with ** takes any.
111
+ def keyword_finding(node, klass)
112
+ parameters = klass.instance_method(node.name).parameters
113
+ return nil if parameters.any? { |kind, _| kind == :keyrest }
114
+
115
+ taken = parameters.filter_map { |kind, name| name if %i[key keyreq].include?(kind) }.map(&:to_s)
116
+ return nil if taken.empty?
117
+
118
+ unknown = keyword_names(node).find { |keyword| !taken.include?(keyword) } or return nil
119
+ near = Config.near_names(unknown, taken).first
120
+ takes = "it takes #{taken.map { |name| "#{name}:" }.join(", ")}"
121
+ Finding.new(line: node.location.start_line, message: "#{node.receiver.slice}.#{node.name} takes no #{unknown}: in chi #{VERSION}",
122
+ fix: near ? "did you mean #{near}:? #{takes}" : takes)
123
+ end
124
+
125
+ def keyword_names(node)
126
+ Array(node.arguments&.arguments).grep(Prism::KeywordHashNode).flat_map do |hash|
127
+ hash.elements.filter_map { |pair| pair.key.unescaped if pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode) }
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end