samagotchi 0.65.0 → 0.67.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 (155) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -1
  3. data/README.md +2 -1
  4. data/bin/chi +51 -2
  5. data/docs/architecture.md +50 -28
  6. data/docs/cli.md +14 -2
  7. data/docs/configuration.md +101 -16
  8. data/docs/context.md +39 -2
  9. data/docs/desktop.md +90 -22
  10. data/docs/guardrails.md +8 -5
  11. data/docs/hooks.md +2 -2
  12. data/docs/internals/anthropic.md +156 -0
  13. data/docs/internals/background-tasks.md +1 -1
  14. data/docs/internals/bundles.md +2 -0
  15. data/docs/internals/engine.md +75 -30
  16. data/docs/internals/gemma4-contract.md +1 -1
  17. data/docs/internals/llm-context-bench.md +5 -4
  18. data/docs/internals/prompt-caching.md +36 -13
  19. data/docs/memory.md +1 -1
  20. data/docs/plugins.md +289 -25
  21. data/docs/releasing.md +1 -1
  22. data/docs/sessions.md +16 -6
  23. data/docs/sub-agent.md +6 -1
  24. data/docs/testing.md +17 -3
  25. data/lib/samagotchi/bootstrap/probe.rb +4 -3
  26. data/lib/samagotchi/bootstrap_command.rb +13 -3
  27. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  28. data/lib/samagotchi/bridge.rb +6 -3
  29. data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
  30. data/lib/samagotchi/bundles/claude-peer/plugin.rb +40 -302
  31. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
  32. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
  33. data/lib/samagotchi/bundles/codex-peer/manifest.yml +22 -0
  34. data/lib/samagotchi/bundles/codex-peer/plugin.rb +189 -0
  35. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +136 -0
  36. data/lib/samagotchi/bundles/codex-peer/scripts/pair +92 -0
  37. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +76 -0
  38. data/lib/samagotchi/bundles/coordinator/manifest.yml +5 -5
  39. data/lib/samagotchi/bundles/coordinator/plugin.rb +24 -2
  40. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  41. data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
  42. data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
  44. data/lib/samagotchi/bundles/system/delegated.md +2 -1
  45. data/lib/samagotchi/bundles/system/manifest.yml +5 -5
  46. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  47. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  48. data/lib/samagotchi/cache_confirm.rb +9 -6
  49. data/lib/samagotchi/commands/registry.rb +25 -8
  50. data/lib/samagotchi/config.rb +23 -8
  51. data/lib/samagotchi/context_absorber.rb +66 -13
  52. data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
  53. data/lib/samagotchi/desktop/macos/App.swift +3 -3
  54. data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
  55. data/lib/samagotchi/desktop/macos/Images.swift +2 -2
  56. data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
  57. data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
  58. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
  59. data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
  60. data/lib/samagotchi/desktop/macos.rb +84 -13
  61. data/lib/samagotchi/desktop_command.rb +14 -3
  62. data/lib/samagotchi/engine.rb +120 -107
  63. data/lib/samagotchi/engine_peers.rb +3 -0
  64. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  65. data/lib/samagotchi/guardrails/approval.rb +1 -1
  66. data/lib/samagotchi/guardrails/approvals.rb +1 -1
  67. data/lib/samagotchi/guardrails/rules.rb +1 -1
  68. data/lib/samagotchi/guardrails/targets.rb +1 -1
  69. data/lib/samagotchi/hook_routes.rb +151 -0
  70. data/lib/samagotchi/hooks/registry.rb +2 -2
  71. data/lib/samagotchi/host_keys.rb +147 -0
  72. data/lib/samagotchi/kernel_loop.rb +9 -1
  73. data/lib/samagotchi/llm/adapter_common.rb +138 -0
  74. data/lib/samagotchi/llm/anthropic_assembly.rb +4 -2
  75. data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
  76. data/lib/samagotchi/llm/api_key.rb +23 -7
  77. data/lib/samagotchi/llm/chat_loop.rb +10 -6
  78. data/lib/samagotchi/llm/openai_chat.rb +9 -92
  79. data/lib/samagotchi/llm/refusal_memo.rb +29 -0
  80. data/lib/samagotchi/llm_context_override.rb +1 -0
  81. data/lib/samagotchi/peer_sources.rb +58 -0
  82. data/lib/samagotchi/plugin/api.rb +138 -156
  83. data/lib/samagotchi/plugin/context.rb +22 -1
  84. data/lib/samagotchi/plugin/loader.rb +5 -2
  85. data/lib/samagotchi/plugin/peer.rb +394 -0
  86. data/lib/samagotchi/plugin/tool_spec.rb +166 -0
  87. data/lib/samagotchi/plugin_tasks.rb +2 -2
  88. data/lib/samagotchi/row_refs.rb +1 -1
  89. data/lib/samagotchi/self_report.rb +5 -3
  90. data/lib/samagotchi/send_command.rb +41 -15
  91. data/lib/samagotchi/session.rb +18 -1
  92. data/lib/samagotchi/session_commands.rb +16 -1
  93. data/lib/samagotchi/session_continue.rb +58 -15
  94. data/lib/samagotchi/session_inbox.rb +0 -13
  95. data/lib/samagotchi/session_manager.rb +4 -1
  96. data/lib/samagotchi/session_setup.rb +14 -7
  97. data/lib/samagotchi/system_prompt.rb +16 -1
  98. data/lib/samagotchi/terminal_ui/attach_launcher.rb +19 -9
  99. data/lib/samagotchi/terminal_ui.rb +18 -2
  100. data/lib/samagotchi/tool_call_parser.rb +1 -1
  101. data/lib/samagotchi/tool_runner.rb +13 -1
  102. data/lib/samagotchi/toolkit.rb +246 -0
  103. data/lib/samagotchi/toolkit_binding.rb +138 -0
  104. data/lib/samagotchi/toolkit_command.rb +117 -0
  105. data/lib/samagotchi/tools/context_read.rb +14 -0
  106. data/lib/samagotchi/tools/execute.rb +12 -12
  107. data/lib/samagotchi/tools/registry.rb +43 -6
  108. data/lib/samagotchi/tools/task_create.rb +6 -6
  109. data/lib/samagotchi/tools/task_get.rb +13 -13
  110. data/lib/samagotchi/tools/task_list.rb +6 -6
  111. data/lib/samagotchi/tools/task_record.rb +52 -0
  112. data/lib/samagotchi/tools/task_runtime.rb +55 -91
  113. data/lib/samagotchi/tools/task_stop.rb +6 -6
  114. data/lib/samagotchi/tools/task_wait.rb +12 -13
  115. data/lib/samagotchi/tools/tool_index.rb +454 -0
  116. data/lib/samagotchi/update_command.rb +9 -6
  117. data/lib/samagotchi/version.rb +1 -1
  118. data/lib/samagotchi/web/app.rb +28 -9
  119. data/lib/samagotchi/web/capabilities.rb +1 -1
  120. data/lib/samagotchi/web/public/activity.js +2 -2
  121. data/lib/samagotchi/web/public/annotate_actions.js +109 -0
  122. data/lib/samagotchi/web/public/annotate_bar.js +84 -2
  123. data/lib/samagotchi/web/public/app.js +52 -43
  124. data/lib/samagotchi/web/public/chains.js +1 -1
  125. data/lib/samagotchi/web/public/commands.js +6 -4
  126. data/lib/samagotchi/web/public/composer_size.js +2 -2
  127. data/lib/samagotchi/web/public/copy.js +1 -1
  128. data/lib/samagotchi/web/public/ctx.js +1 -1
  129. data/lib/samagotchi/web/public/delivery_line.js +4 -4
  130. data/lib/samagotchi/web/public/families_view.js +1 -1
  131. data/lib/samagotchi/web/public/format.js +11 -7
  132. data/lib/samagotchi/web/public/images.js +25 -2
  133. data/lib/samagotchi/web/public/index.html +9 -0
  134. data/lib/samagotchi/web/public/info_bar.js +5 -3
  135. data/lib/samagotchi/web/public/llm_context_chip.js +2 -2
  136. data/lib/samagotchi/web/public/notify.js +6 -4
  137. data/lib/samagotchi/web/public/question_card.js +1 -1
  138. data/lib/samagotchi/web/public/ref_scan.js +1 -1
  139. data/lib/samagotchi/web/public/session_state.js +17 -0
  140. data/lib/samagotchi/web/public/session_title.js +2 -3
  141. data/lib/samagotchi/web/public/sessions_list.js +0 -8
  142. data/lib/samagotchi/web/public/stage_model.js +2 -2
  143. data/lib/samagotchi/web/public/stream_handlers.js +13 -0
  144. data/lib/samagotchi/web/public/strip.js +3 -3
  145. data/lib/samagotchi/web/public/task_stop.js +3 -2
  146. data/lib/samagotchi/web/public/toolkit_chip.js +29 -0
  147. data/lib/samagotchi/web/public/turn_events.js +1 -1
  148. data/lib/samagotchi/web/public/turn_html.js +2 -2
  149. data/lib/samagotchi/web/public/turn_model.js +2 -2
  150. data/lib/samagotchi/web/public/turn_view.js +1 -1
  151. data/lib/samagotchi/web/server.rb +0 -4
  152. data/lib/samagotchi/worker.rb +13 -8
  153. data/lib/samagotchi/worker_inbound.rb +36 -4
  154. data/lib/samagotchi/worker_wakes.rb +67 -7
  155. metadata +23 -1
@@ -0,0 +1,156 @@
1
+ # Anthropic Messages (`api: anthropic`)
2
+
3
+ A host with `api: anthropic` (Anthropic itself, a gateway passing Claude through, a local server such as Splash,
4
+ Fireworks' Anthropic-compatible API) runs the same chat loop as `api: openai`: `LLM::Adapters::BY_API` maps both apis
5
+ to an adapter, `HostRegistry::HostEntry#chat?` is true for either, and `Engine#backend_for` picks `LLM::ChatLoop`
6
+ (engine.md, "The model↔tool loop"). Only the adapter differs: `LLM::AnthropicMessages` instead of `LLM::OpenAIChat`.
7
+ The user-facing settings (`dialect:`, `auth:`, `max_tokens`, the known limits) are in configuration.md.
8
+
9
+ ```
10
+ ChatLoop::Run#generate
11
+ → ChatLoop#wire_messages # the OpenAI-shaped wire every chat host gets,
12
+ # plus anthropic_content on replayed model turns
13
+ → AnthropicMessages#chat
14
+ → #request_body # options as Messages takes them, breakpoints on or off
15
+ → AnthropicWire.translate # system blocks + messages, cache_control placed
16
+ → AnthropicWire.tools # function schemas → {name, description, input_schema}
17
+ → POST {base}/v1/messages # LLM::HTTP: retries, cancel, first-token limit
18
+ → AnthropicAssembly#add per SSE event # → ChatResponse (text, reasoning, tool_calls, usage,
19
+ # finish_reason, provider_content)
20
+ → a 400 it can fix: #refusal_fix, then the same request again (once per kind)
21
+ ```
22
+
23
+ ## Files
24
+
25
+ Paths are under `lib/samagotchi/llm/`.
26
+
27
+ | File | Role |
28
+ |---|---|
29
+ | `adapters.rb` | `Adapters::BY_API` (`openai`, `anthropic`), `.for(entry)`, `.build(api:, …)` for side requests (`IdleClient`) |
30
+ | `anthropic_messages.rb` | `AnthropicMessages`: the request body, `#chat` (streamed or not), the 400 fixes, `#list_models`, `#thinking_fields`, `#replays_content?` |
31
+ | `anthropic_wire.rb` | `AnthropicWire`: ChatLoop's wire messages as Messages' `system` and `messages`, the two breakpoints, tool ids and names |
32
+ | `anthropic_assembly.rb` | `AnthropicAssembly`: a response from a whole body (`.from_message`) or stream events (`#add`), its usage in the OpenAI shape |
33
+ | `dialect.rb` | `Dialect` (`STANDARD`, `FIREWORKS`): what a provider's `/v1/messages` does differently |
34
+ | `prompt_cache.rb` | `PromptCache.control`: the `cache_control` a breakpoint carries (`cache.ttl`) |
35
+ | `chat_loop.rb` | `ChatLoop#replay_blocks`, `#reasoning_history`'s floor, `Run#with_thinking` (saves `anthropic_content`) |
36
+
37
+ ## The adapter
38
+
39
+ `AnthropicMessages.for(entry)` takes the host's API root, key variable, `auth:`, `dialect:` and remoteness;
40
+ `HostRegistry#adapter_for` keeps one per host for the worker's life, so what it learns from a 400 (below) lasts that
41
+ long. Each request posts `{base}/v1/messages` with `anthropic-version: 2023-06-01`, the key in the header the host's
42
+ `auth:` names (else the dialect's: `x-api-key` on the standard one), and `Session-Id` (plus the dialect's
43
+ `session_header`) when the loop has a session id.
44
+
45
+ `#request_body` builds, in order: `model`, `max_tokens`, `system` (when there is one), `messages`, `stream`, `tools`
46
+ with `tool_choice: {type: "auto"}` (when any), then the loop's options. The options lose chi's OpenAI-only fields
47
+ (`DROPPED_OPTIONS`: `reasoning_effort`, `chat_template_kwargs`, `stream_options`, `prompt_cache_key`, `logprobs`,
48
+ `top_logprobs`, `max_completion_tokens`); configured sampling passes as written. `max_tokens` is the options'
49
+ (`default.max_tokens`), else the model list's `max_tokens` capped at `DEFAULT_MAX_TOKENS` (64000), and never over an
50
+ output cap a 400 named. `#with_budget_room` keeps `max_tokens` above a budget-form `budget_tokens` plus `MIN_BUDGET`.
51
+
52
+ `#thinking_fields(level)` is the thinking level on this wire: `off` is `thinking: {type: "disabled"}`; `low`, `medium`
53
+ and `high` are `thinking: {type: "adaptive", display: <dialect's>}` with `output_config: {effort: <level>}`; `default`
54
+ sends nothing. `#thinking_off_refused_fields` (low effort) is what `IdleClient` sends a side ask whose model refused
55
+ thinking off.
56
+
57
+ `#list_models` reads `GET {base}/v1/models` page by page (`limit=1000`, `after_id`, `has_more`/`last_id`, at most
58
+ `AdapterCommon::MAX_MODEL_PAGES`, as OpenAIChat does); `#window_of` takes `max_input_tokens`, else the OpenAI-shaped fields (`ModelListing`).
59
+
60
+ ## Wire translation (`AnthropicWire.translate`)
61
+
62
+ ChatLoop builds the same OpenAI-shaped wire messages for every chat host (`#wire_messages`); the adapter translates
63
+ them:
64
+
65
+ - The head system message becomes the top-level `system`, as text blocks: with its `cache_split:` (from
66
+ `SystemPrompt#stable_length`) two blocks, the stable part first. A later system message (a reminder, a turn note,
67
+ the CONTEXT line) becomes user text: a mid-conversation system role is model-gated.
68
+ - An assistant message with saved blocks (`anthropic_content`) sends them verbatim, a `tool_use`'s name and id made
69
+ valid (`#valid_tool_use`); without them, a text block and its `tool_use` blocks (arguments parsed into the `input`
70
+ object, `{}` with a warning when they aren't a JSON object). An assistant message with neither is dropped.
71
+ - Tool messages become `tool_result` blocks in a user message. Ids are made what Messages takes
72
+ (`AnthropicWire.tool_id`: invalid characters replaced and a short hash appended, the same on both sides), and tool
73
+ names too (`.tool_name`), so a malformed one never fails every later request.
74
+ - User parts become text and `image` blocks (a data URL as base64, an http(s) URL as a url source).
75
+ - Consecutive messages of one role merge into one, its `tool_result` blocks first. Blank text blocks are never sent;
76
+ a user message left empty says `(empty)`. `reasoning_content` and `cache_split` never go out.
77
+
78
+ ## The response (`AnthropicAssembly`)
79
+
80
+ A stream is read from its `data:` lines alone (a gateway that drops the `event:` lines works), by content-block
81
+ index: `message_start` (served model, usage), `content_block_start` (text, thinking, redacted_thinking, tool_use),
82
+ `content_block_delta` (`text_delta`, `thinking_delta`, `signature_delta`, `input_json_delta`), `message_delta`
83
+ (stop reason, `stop_details`, usage). `AnthropicMessages#chat_streamed` passes every event but `ping` to `on_delta`,
84
+ `message_start` and `message_delta` with the usage so far attached (`#with_usage`); a block's start or delta counts as shown for the first-token limit
85
+ (`.shows?`). A 200 whose body is a JSON error instead of a stream raises that error (`#raise_plain_error`).
86
+
87
+ `#response` is a `ChatResponse`:
88
+
89
+ - `provider_content`: the blocks as received (string keys). A blank text block is dropped (Messages refuses one sent
90
+ back); a `tool_use` input is an object; a thinking block always has a `signature` (`""` when none came).
91
+ - `finish_reason` from `FINISH_REASONS`: `end_turn`/`stop_sequence` → `stop`, `max_tokens` and
92
+ `model_context_window_exceeded` → `length`, `tool_use` → `tool_calls`, `refusal` → `refusal`; anything else as it is.
93
+ - Usage in the OpenAI shape the rest of chi reads (`TokenUsage`, `SessionMetrics`, `ContextStatus`, the web's
94
+ `ctx.js`): Messages counts `input_tokens` without the cache, so `prompt_tokens` is input + cache reads + cache
95
+ writes, with `cached_tokens` and `cache_write_tokens` under `prompt_tokens_details`. Every field is optional and the
96
+ later value wins; all zeros is no usage (`#usage_payload`: Fireworks' `message_start` carries only zeros).
97
+
98
+ ## Replaying thinking blocks
99
+
100
+ Messages wants a model turn's blocks back as they came: a thinking block with its signature, in its place among the
101
+ text and `tool_use` blocks (a tool loop with thinking needs the last assistant turn's). So:
102
+
103
+ - `ChatLoop::Run#with_thinking` saves the response's `provider_content` on the model entry as `anthropic_content`,
104
+ beside `thinking:` (string keys, so a resumed session sends the same bytes).
105
+ - `AnthropicMessages#replays_content?` is true, and `ChatLoop#replay_blocks` puts those blocks on the wire message,
106
+ which `AnthropicWire` sends verbatim. Not for an interrupted entry (its partial blocks have no signature) nor for
107
+ one with an unpaired call (it goes as flattened text). Before the reasoning boundary (`#reasoning_boundary`) the
108
+ thinking blocks are left out, so only the front of the history loses them; the conversation's last model entry,
109
+ when it has tool calls, keeps them even there (a run stopped between steps left its `tool_use` last).
110
+ - `ChatLoop#reasoning_history` floors the level at `turn` on a replaying adapter: `drop` would remove the running
111
+ run's thinking, which Messages needs. For the same reason a reasoning 400 doesn't ask again without reasoning
112
+ (`Run#generate`: it would resend the same body).
113
+ - A 400 about a thinking block's signature (`BadRequest#thinking_signature?`, a Claude 5.x block bound to a
114
+ different conversation) fails the turn for now; configuration.md has the known limit.
115
+
116
+ ## Cache breakpoints
117
+
118
+ `#request_body` asks `AnthropicWire.translate` for breakpoints (`cache: PromptCache.control`, carrying `ttl: "1h"`
119
+ under `cache.ttl: 1h`) only for a chat request (`purpose: "chat"`; a recap, `ctx.ask_model` or a triage sets none),
120
+ on a dialect with `cache_breakpoints`, and until the host refused `cache_control`. Unlike `PromptCache.mark` on the
121
+ OpenAI path, it doesn't look at the model id or remoteness. The two marks:
122
+
123
+ 1. the first `system` block (with `cache_split:`, the stable part; the per-session tail goes after it unmarked), which
124
+ caches the tools and the system prompt for every session on that prefix;
125
+ 2. the last user block that can carry one (`#mark_last`): never a thinking block, nor one that came from a later
126
+ system message, which moves every request.
127
+
128
+ The log line's `cache=` says `on`, `1h` or nothing (`#cache_summary`). prompt-caching.md has the cache rules.
129
+
130
+ ## Dialects
131
+
132
+ `LLM::Dialect` is a host's `dialect:` (`Dialect.parse` warns once on an unknown name or on a host whose api isn't
133
+ `anthropic`): `auth` (the default key header), `thinking_display` (the `display` sent with adaptive or budget
134
+ thinking, nil for none), `cache_breakpoints` and `session_header`.
135
+
136
+ | Dialect | auth | thinking display | breakpoints | session header |
137
+ |---|---|---|---|---|
138
+ | `STANDARD` (no `dialect:`) | `x-api-key` | `summarized` | yes | none |
139
+ | `FIREWORKS` | Bearer | none | no (an automatic prefix cache) | `x-session-affinity` |
140
+
141
+ ## 400 fallbacks
142
+
143
+ `AnthropicMessages#chat` handles the 400s it can fix itself, once each per request, and remembers the fix for the
144
+ adapter's life, so ChatLoop's broader paths never see them (`#refusal_fix`, logged under `http`; kept in an
145
+ `LLM::RefusalMemo`, locked, since the turn, side-ask and recap threads share the host's adapter):
146
+
147
+ | 400 about | Fix | Remembered |
148
+ |---|---|---|
149
+ | `thinking.display` (`DISPLAY_RE`) | no `display` from now on (`#without_display`), the thinking kept | per host (`thinking_display_refused`) |
150
+ | adaptive thinking or its effort (`ADAPTIVE_REFUSED_RE`) | the budget form: `thinking: {type: "enabled", budget_tokens: BUDGETS[level]}`, no effort (`#budget_form`) | per model (`thinking_form_switched`) |
151
+ | `cache_control` | no breakpoints | per host (`cache_control_refused`) |
152
+ | `max_tokens: N > M` (`OUTPUT_CAP_RE`) | `max_tokens` capped at M | per model (`output_cap`) |
153
+
154
+ The display check comes before the adaptive one: its path (`thinking.adaptive.display`) names adaptive too. A context
155
+ overflow, a refused sampling field, a signature or a binding-controls 400 is not one of these: it goes on as it is,
156
+ to the loop's own handling (ChatLoop's sampling and thinking-fields fallbacks) or to the turn's failure.
@@ -18,7 +18,7 @@ Recommended workflow:
18
18
  Behavior:
19
19
 
20
20
  - `execute`, `task_create` and the `!cmd` shell bang run the command in a non-login shell: `/bin/sh -c` on Linux; on macOS, when `/bin/zsh` is there, `/bin/zsh --emulate sh +o ignore_braces +o sh_glob -o bash_rematch +o bsd_echo -c` (macOS's `/bin/sh` is bash 3.2, which can't parse an apostrophe in a heredoc inside `"$( )"`, the usual commit message shape). That mode reads no rc files and keeps POSIX word splitting, unmatched globs left as is, `{1..3}`, `[[ … =~ (…) ]]` with `BASH_REMATCH`, arrays, `$'…'`, `&>` and echo expanding `\t`. Bash-only builtins and variables (`shopt`, `BASH_VERSION`, `${!prefix*}`, `read -p`) are not there.
21
- - Task metadata and output are persisted under `tmp/tasks/`.
21
+ - Task metadata and output are persisted under `tmp/tasks/`: each task's `tmp/tasks/<id>/task.json` is a `TaskRecord` (`lib/samagotchi/tools/task_record.rb`, a value object: `.from_file` reads it, `#to_file` is the JSON object written, an update is a `#with`). Its keys keep their order; `stderr_path` and `handed_over_by` (a handed-over execute) and `stop_requested_by` (a stop under way or done) are left out until set. `bundles/slow-commands` reads these files on its own, so the shape is a contract.
22
22
  - A task record keeps its process's pid and start time (`pid_started`). Before `task_stop` signals, the pid must still lead its own process group and must not have started at another time; otherwise the pid was reused by another process, nothing is signalled and the task ends as failed. A start time that can't be read at that moment (a failed `ps`) falls back to the group check, as does a record without `pid_started` (from an older chi). `task_get`, `task_list`, `task_wait`'s polls and the running-task lists (a cancelled turn's note, a worker's idle exit) make the same check, so a task whose pid was reused reads as exited (failed) at once, not as running until a `task_stop`; on macOS that is one `ps` per running task per check (~5 ms).
23
23
  - A stop (`task_stop`, the user's Stop of an `execute`, the execute limit in kill mode) TERMs the task's process group, then KILLs it after a grace. The groups its processes started are stopped too: a GNU `timeout N cmd` (without `--foreground`) moves itself and its command to a group of its own. `ProcessGroup.stop` finds them with one `ps -A -o pid=,ppid=,pgid=` (~40 ms on macOS) before the TERM and again before the KILL, walking down from the group's members. A process whose parent already exited (reparented to init, e.g. a setsid'd `cmd &`, or a backgrounded `timeout N cmd &` after its shell exited) is not found and runs on.
24
24
  - Task listing is workspace-scoped (current project only).
@@ -35,6 +35,8 @@ user-facing side (what a bundle owns, profiles, needs) is in [memory.md](../memo
35
35
  | `SystemBundle`, `ShippedUpdate` | the built-in system bundle (`ensure!` at Engine start, `sync` in `chi update`) and `chi update`'s upgrade of shipped bundles; both drive the Installer with `upgrade: true` |
36
36
  | `Uninstaller`, `Trash`, `Status`, `Listing` | remove, keep removed files, `chi bundle status`, `chi bundle list` |
37
37
  | `IndexUpdater`, `IndexSync` | the managed `index.md` lines (Installer; and the write/edit tools for memories) |
38
+ | `IndexSize` | one scope's memory index as the prompt carries it (tokens, lines, bytes): `memory.index_warn_tokens`' crossing note after a memory write, `chi self`'s summary, the prompt's figures (`SystemPrompt`) |
39
+ | `Placeholder` | `{{placeholder}}` patterns left in an installed memory, one line per file from `Installer#detect_placeholders`; a hint, never a gate |
38
40
 
39
41
  Readers of the installed state: `Hooks::BundleLoader` (Engine), `GuardrailWiring#bundle_rules`, `Plugin::Loader`,
40
42
  `ContextProviders` (scripts), `BundleNeeds` (index markers). Each goes through `Provenance.each_installed` / `#record`
@@ -35,7 +35,8 @@ Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:,
35
35
  │ # images (only when there are any), cut_after (the model's cut wait, s)}
36
36
  │ # refresh_profile! (may ask the server's /props once)
37
37
  │ # turn.settings = LLM::TurnSettings (vision, sampling, thinking; model_name is set in generate)
38
- │ # announces guardrail load failures, starts and awaits plugin init tasks, applies staged tools
38
+ │ # announces guardrail load failures, starts and awaits plugin init tasks, applies staged tools,
39
+ │ # then the session's toolkit (apply_toolkit!: Registry#toolkit, a prompt rebuild when it changed)
39
40
  │ # fires the :session_start hook on the Engine's first turn, then the :before_turn hook
40
41
  │ # (a frozen copy of the history, the prompt, and resumed)
41
42
  │ # turn_messages: system head + history + due reminders (:reminder_injected) + the user message
@@ -71,7 +72,7 @@ Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:,
71
72
  │
72
73
  └── ensure: release_turn(probe_cancel_before)
73
74
  # TurnState#finish! (turn flag off, controller, sink and steers cleared)
74
- # GenerationPhase#finished!, WaitingSteer#clear!
75
+ # SteerCut#finished! (its GenerationPhase finished, its WaitingSteer cleared)
75
76
  # probe cancel swapped back
76
77
  # the context window cache dropped, so the next turn asks the server again
77
78
  # steers left logged as dropped, activity recorded, turn-scoped hooks cleared
@@ -82,7 +83,7 @@ Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:,
82
83
  ```
83
84
  StandardError in run_turn:
84
85
  → if turn.ended (it came from the after_turn/session_end hooks): logged as post_turn_error, re-raised
85
- → else: end_turn(turn, "failed", save: true)
86
+ → else: end_turn(turn, "failed", save: true) (a save that fails there is logged, turn_end_save_failed)
86
87
  # Both loops call LLM::FailedTurn.attach(e, conversation) before re-raising, so
87
88
  # failed_messages keeps the loop's partial conversation (tool iterations included),
88
89
  # else turn.messages, with TurnNote.failed(summary, continued: turn.continue) at the tail.
@@ -134,7 +135,8 @@ Engine#backend → backend_for(@host_registry.resolve(@effective_model_name))
134
135
  ```
135
136
 
136
137
  Both implement the `LLM::ModelBackend` contract (`#complete(messages:, max_iterations:, on_stream_event:,
137
- cancel_controller:, model_name:, max_tool_output_chars:, pending_input:)`). `NativeBackend#complete` only calls
138
+ cancel_controller:, model_name:, max_tool_output_chars:, pending_input:, reasoning_since:)`; `reasoning_since:` is
139
+ where the run a step-limit Continue resumes started, the Engine's `resume_from`). `NativeBackend#complete` only calls
138
140
  `KernelLoop#run` with the same arguments. `ChatLoop` runs its own loop but is built with the same KernelLoop,
139
141
  whose tools, hooks, gate and dispatch it uses.
140
142
 
@@ -171,17 +173,21 @@ KernelLoop#run(messages, max_iterations:, on_stream_event:, cancel_controller:,
171
173
  `turn.conversation` is the loop's own copy, not the Engine's `turn.messages`. A failure attaches it to the error
172
174
  (`LLM::FailedTurn.attach`), so `Engine#failed_messages` can keep it; a Ctrl-C does not (see Error paths).
173
175
 
174
- ### ChatLoop (the OpenAI chat API)
176
+ ### ChatLoop (the chat APIs)
175
177
 
176
- Runs hosts with `api: openai`: remote providers such as OpenRouter (Claude and others) and any OpenAI-compatible
177
- chat server. The model gets the tools as JSON schemas and returns structured tool calls.
178
+ Runs hosts whose api has a chat adapter (`LLM::Adapters::BY_API`): `api: openai` on `LLM::OpenAIChat` (remote
179
+ providers such as OpenRouter, Claude among others, and any OpenAI-compatible chat server) and `api: anthropic` on
180
+ `LLM::AnthropicMessages` (Anthropic's Messages API; [anthropic.md](anthropic.md)). The Engine sets the adapter per
181
+ turn for the effective model's host (`HostRegistry#adapter_for`). The model gets the tools as JSON schemas and returns
182
+ structured tool calls.
178
183
 
179
184
  ```
180
185
  ChatLoop#complete(messages:, max_iterations:, on_stream_event:, cancel_controller:,
181
- model_name:, max_tool_output_chars:, pending_input:)
186
+ model_name:, max_tool_output_chars:, pending_input:, reasoning_since:)
182
187
  │
183
188
  │ conversation = Array(messages).map(&:dup) # a shallow copy of each message
184
- │ Run.new(self, conversation, on_stream_event, cancel_controller, model_name, pending_input)
189
+ │ Run.new(self, conversation, on_stream_event, cancel_controller, model_name, pending_input,
190
+ │ start: reasoning_since)
185
191
  │ .call(max_iterations: max_iterations || 1, cap: KernelLoop.resolve_output_char_cap(max_tool_output_chars))
186
192
  │
187
193
  └──→ LLM::ModelResult(text, conversation, exhausted, canceled, tool_activity, ...)
@@ -190,10 +196,13 @@ ChatLoop#complete(messages:, max_iterations:, on_stream_event:, cancel_controlle
190
196
  The differences from the native loop:
191
197
 
192
198
  - The tools go in each request's `tools` field (`tool_definitions`), not in the system prompt.
193
- - The conversation is converted to the OpenAI wire format (`wire_messages`).
199
+ - The conversation is converted to the OpenAI wire format (`wire_messages`), for every chat host;
200
+ `AnthropicMessages` translates that again into Messages' `system` and content blocks (`LLM::AnthropicWire`).
194
201
  - The first system message carries `cache_split:` (from `SystemPrompt#stable_length`, which the Engine passes in
195
- as `stable_length:`), where the prompt's stable part ends. For remote Claude, `LLM::PromptCache` puts the
196
- first cache breakpoint there; see prompt-caching.md.
202
+ as `stable_length:`), where the prompt's stable part ends. The adapter puts the first cache breakpoint there:
203
+ `OpenAIChat` for a Claude model on a remote host (`LLM::PromptCache.mark`), `AnthropicMessages` on every chat
204
+ request unless its dialect has no breakpoints or the host refused `cache_control` (`AnthropicWire.translate`,
205
+ `Dialect#cache_breakpoints`); see prompt-caching.md and anthropic.md.
197
206
  - Each call's result is its own tool message, paired by `tool_call_id`.
198
207
  - A model turn's saved reasoning (`thinking:`) goes back as `reasoning_content` per the turn's
199
208
  `TurnSettings#reasoning_history` (`ReasoningHistory.resolve`, the session's own level first:
@@ -207,8 +216,15 @@ The differences from the native loop:
207
216
  in `#resumed_run_start`), past a user entry added after that run's last reply (a `!cmd`'s output). Without
208
217
  `since:` (`#reasoning_chars` from outside a Run) the boundary is the last query (a `role: "user"` or `turn_start`
209
218
  entry). `Run#prompt_chars` counts it (`#reasoning_chars`).
219
+ - On an adapter that sends its blocks back (`#replays_content?`: `AnthropicMessages`), a model turn's saved blocks
220
+ (`anthropic_content`, the response's `provider_content`, saved by `Run#with_thinking`) go back verbatim, thinking
221
+ blocks with their signatures (`#replay_blocks`): not for an interrupted entry or one with an unpaired call, and
222
+ without the thinking blocks before the reasoning boundary (the last model entry with tool calls keeps them).
223
+ `#reasoning_history` floors `drop` at `turn` there, since Messages needs the running run's thinking blocks.
210
224
  - A 400 about reasoning (`BadRequest#reasoning_refused?`) for a request that sent reasoning back asks the step again
211
- without it; when that goes through, `#reasoning_history_refused!` marks the model (worker lifetime) and
225
+ without it (not on a replaying adapter, where the body would be the same); when that goes through,
226
+ `#reasoning_history_refused!` marks the model (worker lifetime; the loop's `LLM::RefusalMemo`, as for the thinking
227
+ and sampling refusals) and
212
228
  `:reasoning_history_refused` goes out. When it is refused too, the thinking-fields fallback (`#thinking_refused!`,
213
229
  `:thinking_refused`) runs on it, still without reasoning. Up to three requests per step. A 400 about a thinking
214
230
  block's signature (`BadRequest#thinking_signature?`) or a refused binding control
@@ -227,6 +243,8 @@ One iteration with tool calls:
227
243
  → :tool_dispatch_started {iteration, call_count}
228
244
  → for each call: ToolRunner#run(call, iteration:, call_index:, call_count:,
229
245
  on_stream_event:, max_tool_output_chars:)
246
+ → a tool the session's toolkit leaves out (Registry#left_out) skips the gate: no hooks, no card;
247
+ KernelLoop#dispatch answers it with the not-loaded error
230
248
  → Guardrails::Gate#evaluate (the before_tool_call hooks, then the core checks)
231
249
  → :tool_call_started
232
250
  → an ask verdict: Gate#settle_ask (asks the user, records waited_ms)
@@ -253,7 +271,7 @@ One iteration with tool calls:
253
271
  `EVENT_FIELDS`) copy them by these lists, so a new row field is added there. `ref` is `FileRef.for` (a
254
272
  read/write/edit's file against the cwd, `{kind: "file", path, line?, end_line?}`); the web opens it in the editor
255
273
  that `Web::Editor` (web.editor) names, for a local viewer only (`App#viewer_capabilities`).
256
- `refs` is a plugin tool's row refs: its registry entry's `refs` (chi.tool's `refs:` lambda, `Api.per_call`), run
274
+ `refs` is a plugin tool's row refs: its registry entry's `refs` (chi.tool's `refs:` lambda, `ToolSpec#per_call`), run
257
275
  once per call by `RowRefs.for` (never raises: a raise or a non-list gives none, `tool_refs_failed`; each entry a
258
276
  `Ref`, a bad one dropped, `tool_refs_rejected`; a relative file resolved against the session's folder
259
277
  (`KernelLoop#session_cwd`, else the process's), one outside it (its spelling or its real path) dropped, a kept one
@@ -314,7 +332,8 @@ The turn as the Engine sees it (the KernelLoop has its own `Turn` struct):
314
332
 
315
333
  ```ruby
316
334
  Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
317
- :settings, :id, :limit) do
335
+ :settings, :id, :limit, :resumed, :resume_from) do
336
+ def resumed? = resumed ? true : false # a step-limit Continue
318
337
  def tag(event) = origin ? event.merge(origin: origin) : event # boundary events carry the origin
319
338
  def elapsed = ... # seconds since started_at
320
339
  end
@@ -339,10 +358,14 @@ end
339
358
  running turn's record so far as a killed turn's (canceled, `worker_stopped`), which the next worker loads; the
340
359
  turn's end writes over it.
341
360
 
342
- ### GenerationPhase
361
+ ### SteerCut, GenerationPhase and WaitingSteer
343
362
 
344
- What the running generation streams, fed by the Engine's stream handler: whether a steer sent as a cut (delivery
345
- `cut`: `/cut TEXT`, `chi send --cut`) may cut it. `SteerCut#cut_for_steer` answers `:now`, `:waits` or `:off`; the
363
+ The Engine holds one `SteerCut` (`@steer_cut`), which owns a `GenerationPhase` and a `WaitingSteer`: the Bridge and
364
+ the TUI ask it (`Engine#cut_for_steer`, `#input_epoch`, `#generation_snapshot`), the Engine's stream handler feeds it
365
+ (`SteerCut#observe`), a drain that took input calls `#delivered!`, and `release_turn` calls `#finished!`.
366
+
367
+ `GenerationPhase` is what the running generation streams, fed through `SteerCut#observe`: whether a steer sent as a
368
+ cut (delivery `cut`: `/cut TEXT`, `chi send --cut`) may cut it. `SteerCut#cut_for_steer` answers `:now`, `:waits` or `:off`; the
346
369
  Bridge acks it as `cut`.
347
370
  A generation is cuttable while it has streamed only thinking, is still thinking, and has thought for at least
348
371
  the model's cut wait (`SteerCutAfter.resolve`: `steer_cut_after` per models:/hosts: entry, else `steer.cut_after`) seconds (`cuttable?(min_age)`). It has `started!`, `chunk!`, `retrying!` and `finished!`.
@@ -354,8 +377,8 @@ line and tell whether "cut in now" can land.
354
377
  ### WaitingSteer
355
378
 
356
379
  A message that may cut the generation but came too early (the generation hadn't been thinking long enough). It
357
- waits until the thinking passes the model's cut wait (the Engine checks on each thinking chunk), until a drain hands
358
- the message to the model at a boundary, or until the turn ends (`clear!` in `release_turn`).
380
+ waits until the thinking passes the model's cut wait (`SteerCut#observe` rechecks it on each thinking chunk), until a
381
+ drain hands the message to the model at a boundary, or until the turn ends (`SteerCut#finished!` clears it).
359
382
 
360
383
  ## Events
361
384
 
@@ -411,15 +434,20 @@ hook registry (`@hooks.fire`), not on the sink or the observers. The other hook
411
434
 
412
435
  ```
413
436
  Engine initialization (in this order, among other things):
414
- @turn_state, @generation_phase, @waiting_steer
437
+ @turn_state, @steer_cut (SteerCut: its GenerationPhase and WaitingSteer)
415
438
  @host_registry, @effective_model_name; @client = @host_registry.resolve(...).client
416
439
  @given_profile = profile or nil (resolved on first need, so building an Engine makes no network call)
440
+ @hook_routes (HookRoutes: notices, hook questions, stop_turn/stop_generation, steer attribution)
417
441
  @guardrail_wiring, @question_desk
442
+ @toolkit_binding (ToolkitBinding: the kits, the plugins' among them, and the turn-start apply)
418
443
  @extension_load = ExtensionLoad.new; @hooks = @extension_load.hooks (config.yml's, then the bundles')
419
- @tools = Tools::Builtins.registry; the bundles' plugins load (load_plugins → ExtensionLoad#load_plugins)
444
+ @tools = Tools::Builtins.registry; the bundles' plugins load (load_plugins → ExtensionLoad#load_plugins),
445
+ then @tool_index.declare(@tools): find_tools + call_tool (source "index", not core) when a plugin
446
+ registered a chi.tool_source (Tools::ToolIndex: the index's groups, search, call checks; the source runs
447
+ the call with its own plugin Context)
420
448
  wire_kernel: @kernel = KernelLoop.new(client:, profile:, hooks:, reminder_store:, tools:)
421
449
  @kernel.guardrail_gate = @guardrail_wiring.gate; @kernel.warmup = PromptWarmup.new
422
- @hooks.runtime = hook_runtime
450
+ @hooks.runtime = @hook_routes.runtime (HookRoutes, built first: what a hook or plugin does reaches the UIs)
423
451
  @native_backend = LLM::NativeBackend.new(kernel: @kernel)
424
452
  @prompt_builder = SystemPrompt.new(...)
425
453
  @session_observer with the metrics, the log subscriber and the thinking tails subscribed (subscribe_session_observers)
@@ -441,7 +469,7 @@ Model probes (per turn):
441
469
 
442
470
  Plugins and hook files register hooks on hook points (see "Hook points" above, and docs/hooks.md). Every fire
443
471
  puts the hook runtime on the event: `event[:hook]` (the hook's label) and five helpers, which call the Engine's
444
- `Hooks::Runtime`. A plugin's `ctx` has the same ones (`ctx.notify`, `ctx.steer`, ...).
472
+ `Hooks::Runtime` (built by `HookRoutes`, `hook_routes.rb`, which routes each to the UIs). A plugin's `ctx` has the same ones (`ctx.notify`, `ctx.steer`, ...).
445
473
 
446
474
  | Helper | What it does | Used by |
447
475
  |---|---|---|
@@ -548,8 +576,10 @@ Both loops:
548
576
  - **The same events**: generation events, tool dispatch and tool call events, and the before/after generation
549
577
  and tool call hooks.
550
578
 
551
- Native loop only: the Qwen open-block recovery, and the thinking prefill (Qwen with thinking off gets an empty
552
- thought after the cue, kept in the model message so the next prompt starts with what the server has cached).
579
+ Native loop only: the Qwen open-block recovery, and the thinking prefill (`Thinking.native`: with thinking off, Qwen
580
+ gets an empty thought after the cue, `QWEN_EMPTY_THOUGHT`, and Gemma 4 its empty thought channel,
581
+ `GEMMA_EMPTY_THOUGHT`, with no `<|think|>` token before the system prompt; kept in the model message so the next
582
+ prompt starts with what the server has cached).
553
583
 
554
584
  ## Key design principles
555
585
 
@@ -563,8 +593,9 @@ thought after the cue, kept in the model message so the next prompt starts with
563
593
  persistent.
564
594
  5. **A stable system prompt for the cache.** `SystemPrompt#build` caches the prompt per Engine, keyed by
565
595
  `[chat, thinking, layers]` (chat/native, the thinking level, the turn's LLM context layers), and is reset
566
- only on a model switch, a tools change or a profile change. The stable part (base prompt, identity,
567
- memories, AGENTS.md, memory indexes) comes before the volatile tail (model, working directory, session).
596
+ only on a model switch, a tools change, a profile change or a toolkit change (`Engine#apply_toolkit!`). The
597
+ stable part (base prompt, the toolkit's not-loaded line, rg guidance, identity, model notes, preloaded memories,
598
+ AGENTS.md, appended files, memory indexes) comes before the volatile tail (model, working directory, session).
568
599
  prompt-caching.md covers this in detail.
569
600
 
570
601
  ## Key files
@@ -573,12 +604,22 @@ Paths are under `lib/samagotchi/`.
573
604
 
574
605
  | File | Role |
575
606
  |---|---|
576
- | `engine.rb` | Engine: turn lifecycle, event emission, hooks runtime, plugins, model switching |
607
+ | `engine.rb` | Engine: turn lifecycle, event emission, plugins, model switching |
577
608
  | `turn_state.rb` | Cross-thread turn state (running, controller, sink, steers, activity clock) |
609
+ | `steer_cut.rb` | `SteerCut`: the Engine's seam for a message cutting the generation |
578
610
  | `generation_phase.rb`, `waiting_steer.rb` | Whether a steer may cut the running generation, and the steer that waits to |
611
+ | `steer_cut_after.rb`, `cut_policy.rb` | The model's cut wait (`SteerCutAfter.resolve`); the loops' cut path (`CutPolicy`) |
579
612
  | `steer.rb` | Queued input and steers at iteration boundaries |
580
613
  | `kernel_loop.rb` | Native loop: `#run` (format, generate, parse, dispatch, iterate) |
581
- | `llm/chat_loop.rb` | Chat loop: `#complete` (tool schemas per request, wire conversion) |
614
+ | `llm/chat_loop.rb` | Chat loop: `#complete` (tool schemas per request, wire conversion, block replay) |
615
+ | `llm/adapters.rb` | `LLM::Adapters::BY_API`: which chat adapter a host's api gets |
616
+ | `llm/openai_chat.rb` | `LLM::OpenAIChat`, the `/v1/chat/completions` adapter |
617
+ | `llm/adapter_common.rb` | `LLM::AdapterCommon`: what both chat adapters share (the HTTP layer and its timeouts, the paged model list, the SSE line loop, JSON errors) |
618
+ | `llm/anthropic_messages.rb`, `llm/anthropic_wire.rb`, `llm/anthropic_assembly.rb`, `llm/dialect.rb` | The `/v1/messages` adapter, its wire translation, its response, its dialects ([anthropic.md](anthropic.md)) |
619
+ | `llm/prompt_cache.rb` | Cache breakpoints on the OpenAI path, and the `cache_control` both adapters send |
620
+ | `llm/turn_settings.rb` | `LLM::TurnSettings`, what a turn runs under (vision, sampling, thinking, reasoning history, …) |
621
+ | `llm/native_tool_normalizer.rb`, `llm/stream_drops.rb` | A chat adapter's tool calls as chi's call hashes (`Tools::BuiltinCalls`); asking again for a step whose stream dropped (both loops) |
622
+ | `reasoning_history.rb` | `ReasoningHistory`: the levels, `.resolve`, `.run_start` |
582
623
  | `llm/backend.rb` | The `LLM::ModelBackend` contract (`#complete(...)`) |
583
624
  | `llm/native_backend.rb` | Wraps KernelLoop as a backend (`#complete` calls `#run`) |
584
625
  | `llm/model_result.rb` | `LLM::ModelResult`, what a backend returns |
@@ -588,7 +629,11 @@ Paths are under `lib/samagotchi/`.
588
629
  | `prompt_warmup.rb` | The turn-end warm-up |
589
630
  | `tools/registry.rb` | The tools a session offers: schemas, handlers, order |
590
631
  | `system_prompt.rb` | System prompt construction (stable part, volatile tail) |
632
+ | `prompt_files.rb`, `model_notes.rb` | The AGENTS.md chain and appended files; the model notes the prompt loads |
633
+ | `toolkit.rb`, `toolkit_command.rb` | A session's toolkit (`Toolkit#resolve`, what `Registry` declares); `/toolkit` |
634
+ | `toolkit_binding.rb` | `ToolkitBinding`: the Engine's toolkit seam (the kits, `#explained`, `ctx.use_toolkit`, the turn-start `#apply!` behind `Engine#apply_toolkit!`) |
591
635
  | `hooks/registry.rb` | Hook registry, fire order, `Hooks::Runtime` |
636
+ | `hook_routes.rb` | `HookRoutes`: the Engine's routes for a hook's or plugin's notice (`:hook_notice`, `fallback_for:`), question, stop and steer, and the guardrails' after-load line |
592
637
  | `extension_load.rb` | What an Engine loads at start: config.yml and bundle hooks, plugins, `bundles:` settings; events held while plugins load |
593
638
  | `guardrail_wiring.rb`, `guardrails/gate.rb` | The gate's context and approvals; the verdict per call |
594
639
  | `events.rb` | Event type sets shared by the Worker, the Bridge, the TUI and the web |
@@ -14,7 +14,7 @@ XML tool tags and declaration-echo parsing are intentionally not supported.
14
14
 
15
15
  Thought handling follows the Gemma guidance:
16
16
 
17
- - Include `<|think|>` in the system instruction to activate thinking mode.
17
+ - Include `<|think|>` in the system instruction to activate thinking mode. With thinking off (`/thinking off`) chi leaves the token out and prefills Gemma's empty thought channel after the model cue (`Thinking::GEMMA_EMPTY_THOUGHT`, `Thinking.native`, `Prompt.prefill_for`), as Gemma's chat template does with `enable_thinking` false.
18
18
  - When thinking mode is active, the model may emit internal reasoning as `<|channel>thought ... <channel|>`.
19
19
  - Standard multi-turn: prior model thoughts are stripped from conversation history before the next turn.
20
20
  - Function/tool-calling exception: during a single turn that includes tool calls, thoughts are not stripped between those tool-call rounds.
@@ -75,9 +75,9 @@ The proxies are the context-edit spike's (lexical and lenient: a basename mentio
75
75
  stale's stubs, every output led by its `[#tN]` id (`LLMContextView`), the `[CONTEXT: …]` line chi's `ContextStatus`
76
76
  adds at a turn's start under `--budget N` (default 64000; no line below its guided buckets, so a small case may get
77
77
  none), and the chat messages and tools of chi's `ChatLoop`, `forget_outputs` with its real description. The model
78
- (`--forget-model MODEL`, a chi model ref on an `api: openai` host) answers once; a `forget_outputs` call among its
79
- calls runs through chi's `LLMContextForget` (refusals, `protect_steps`, `keep`), and the row scores stale's stubs
80
- plus the forgets. Each answer is saved in `--out DIR` (`<case>.forget_offer.json`) and read back, so a rerun asks
78
+ (`--forget-model MODEL`, a chi model ref on a chat host: `api: openai` or `api: anthropic`) answers once; a
79
+ `forget_outputs` call among its calls runs through chi's `LLMContextForget` (refusals, `protect_steps`, `keep`),
80
+ and the row scores stale's stubs plus the forgets. Each answer is saved in `--out DIR` (`<case>.forget_offer.json`) and read back, so a rerun asks
81
81
  only for the rest (an error isn't saved); `--out` alone scores saved answers; `--max-cost USD` stops the asking
82
82
  once the costs the server reports reach it (exit 1, the answers so far kept). The row's notes give the call rate
83
83
  (`called`, `no_call`, `error`, `unasked`), the ids chi refused and the notes' mean length. Ids in this row cost
@@ -96,7 +96,8 @@ ruby script/llm_context_bench.rb "$SESSIONS" --picks deepseek=picks/deepseek --p
96
96
  ## Live picks (the D7 tool-name A/B)
97
97
 
98
98
  The one part that calls a model, and only with `--live MODEL`: at each case it asks the model which of its tool
99
- outputs to forget, through chi's own chat client (the model must be on an `api: openai` host), and saves each
99
+ outputs to forget, through chi's own chat client (the model must be on a chat host, `api: openai` or `api: anthropic`:
100
+ `LivePick.chat_adapter` takes any `HostEntry#chat?` host), and saves each
100
101
  answer under `--out DIR` for `--picks` to score. A saved pick is never asked for again. The request is the
101
102
  session's conversation up to the turn's end (thinking dropped, every tool result led by its id `[#tN]`), chi's
102
103
  built-in chat tools plus the forget tool, and the policy's tail line; a model that doesn't call the tool is asked
@@ -3,8 +3,9 @@
3
3
  A model server reuses work only for the **exact token prefix** it has already seen. Change one token and
4
4
  everything after it is computed again (prefill). The local models we run (hybrid Qwen 3.6 / Ornith on llama.cpp
5
5
  and Splash) can only resume from a **restore point near the end** of an earlier prompt, not from any shared
6
- prefix. Anthropic (Claude via OpenRouter) caches only up to an explicit `cache_control` breakpoint. So the order
7
- of what chi sends decides what a new turn, or a new session, has to pay for.
6
+ prefix. Anthropic (Claude via OpenRouter, or over the Messages API, `api: anthropic`) caches only up to an explicit
7
+ `cache_control` breakpoint. So the order of what chi sends decides what a new turn, or a new session, has to pay
8
+ for.
8
9
 
9
10
  Measured with prompt-cache slice 1 (2026-10-04, fresh sessions, ~7.7k-token prompt):
10
11
 
@@ -22,11 +23,15 @@ right-hand column happens.
22
23
  ```
23
24
  shared by every session ────────────────────────────────────────────────── breaks it
24
25
  ┌──────────────────────────────────────────────────────────────────────────┐
25
- │ tools built-ins, then plugin tools sorted by (bundle, name) │ a tool added/changed,
26
- │ (chat: the `tools` field; native: the model's template) │ /model, a profile change
26
+ │ tools built-ins, then plugin tools sorted by (bundle, name), │ a tool added/changed,
27
+ │ the tool index's find_tools/call_tool as bundle "index" │
28
+ │ (declared once at load; publishes change only results) │
29
+ │ those the session's toolkit declares (per toolkit) │ /model, a profile change,
30
+ │ (chat: the `tools` field; native: the model's template) │ a toolkit change
27
31
  ├──────────────────────────────────────────────────────────────────────────┤
28
32
  │ SYSTEM, stable part SystemPrompt#system_prompt_with_index
29
33
  │ [native thinking token] base prompt │
34
+ │ not-loaded line (only when the toolkit leaves tools out) │
30
35
  │ rg guidance │ /thinking level,
31
36
  │ system identity (+ model overlay) │ /model, editing a
32
37
  │ model notes (model_notes_* matching the model) │ memory / AGENTS.md
@@ -34,7 +39,7 @@ right-hand column happens.
34
39
  │ AGENTS.md (root → cwd) │ (picked up by a new
35
40
  │ appended files (append_system_prompt:, --append-system-prompt) │ process or a rebuild)
36
41
  │ project + system memory indexes │
37
- │ ◄── remote Claude: cache_control breakpoint #1 │
42
+ │ ◄── remote Claude / Messages: cache_control breakpoint #1 │
38
43
  ├──────────────────────────────────────────────────────────────────────────┤
39
44
  │ SYSTEM, volatile tail (per session, least → most changing) │
40
45
  │ Model: this session runs on … │ another session
@@ -46,22 +51,37 @@ right-hand column happens.
46
51
  ┌──────────────────────────────────────────────────────────────────────────┐
47
52
  │ history user / assistant / tool messages, APPEND-ONLY │ !rollback, a failed or
48
53
  │ (images: up to the newest N, cut in batches; ImagePlan) │ cancelled turn's tail,
49
- │ ◄── remote Claude: breakpoint #2 on the last │ the image cut moving
50
- │ non-system message │ a batch, history
54
+ │ ◄── remote Claude / Messages: breakpoint #2 on the last │ the image cut moving
55
+ │ non-system message (Messages: its last user block) │ a batch, history
51
56
  ├──────────────────────────────────────────────────────────────────────────┤ sanitizing (thinking
52
57
  │ tail notes reminders, turn notes, steers (system/user messages) │ stripped per spec)
53
58
  │ new prompt this turn's user message │
54
59
  └──────────────────────────────────────────────────────────────────────────┘
55
60
  ```
56
61
 
57
- - The system prompt is built **once per loop** and rebuilt only on `@prompt_builder.reset!`: a model switch
58
- (`Engine#switch_model!`), a plugin's `tools_changed!`, or a profile change. A per-turn rebuild would
59
- break everything below it.
62
+ - The system prompt is built **once per key** and kept (`SystemPrompt#build`, keyed by `[chat, thinking, layers]`:
63
+ the loop, the thinking level, the turn's LLM context layers; a `/thinking` change builds under another key). All
64
+ of them are dropped only on `@prompt_builder.reset!`: a model switch (`Engine#switch_model!`), a plugin's
65
+ `tools_changed!`, a profile change, or a toolkit change (`Engine#apply_toolkit!`). A per-turn rebuild would break
66
+ everything below it.
67
+ - The tools layer is shared **per toolkit** (configuration.md, "Toolkits"): sessions on the same toolkit share it,
68
+ and a chain's links keep theirs. A session's toolkit is saved when it is created and never resolved again from
69
+ config. A change (`/toolkit`, `ctx.use_toolkit`) is applied only at a turn's start (`Engine#apply_toolkit!`,
70
+ beside `apply_staged_tools!`, then `reset!`): the chat loop builds `tools:` per request, so a mid-turn change
71
+ would differ from the turn's prompt. Under `full` nothing is filtered and no line is added: the bytes are what
72
+ they were before toolkits.
60
73
  - On remote Claude, `LLM::PromptCache.mark` sends the system message as two text parts. The stable part carries
61
74
  breakpoint #1, so a **new session** reads it from the cache. The last non-system message carries breakpoint
62
75
  #2, so the **next request in the session** reads everything up to it. Mid and tail system messages never take
63
76
  a breakpoint. The split point comes from `SystemPrompt#stable_length` via the message's `cache_split:`, which
64
- never goes on the wire.
77
+ never goes on the wire. `PromptCache.mark` keys on the model id (`claude`) and runs only for a remote host's chat
78
+ requests (`OpenAIChat#cache_breakpoints`).
79
+ - On a Messages host (`api: anthropic`), `AnthropicWire.translate` sets the same two breakpoints on the translated
80
+ request: the first `system` block (the stable part, when `cache_split:` splits it) and the last user block that
81
+ can carry one (never a thinking block, never one from a mid or tail system message). It doesn't look at the model
82
+ id: every chat request carries them, unless the host's dialect has none (`Dialect#cache_breakpoints`: `fireworks`,
83
+ an automatic prefix cache) or the host refused `cache_control` with a 400 (no breakpoints for that worker's life).
84
+ [anthropic.md](anthropic.md) has the details.
65
85
  - Side requests (idle recap, `/btw`) go through `IdleClient` with their own short system prompt. On a current
66
86
  llama.cpp they don't evict the session (see the server settings below); chi never pins them to a slot.
67
87
 
@@ -107,14 +127,17 @@ is prefilled once. The native and the chat loop use the same plan.
107
127
  one provider. `cache.key: session` also sends the session id as `prompt_cache_key` in the body, the field
108
128
  OpenAI's API routes and caches by (OpenRouter honours it too); the log line shows `cache_by=session`. Only
109
129
  `api.openai.com` and OpenRouter get it, and only the session's own chat requests (not recap or `/btw`): a local
110
- llama.cpp, Splash or another gateway may reject an unknown field. Off by default.
130
+ llama.cpp, Splash or another gateway may reject an unknown field. Off by default. A Messages host never gets
131
+ `prompt_cache_key` (`AnthropicMessages::DROPPED_OPTIONS`); its dialect may add a header of its own beside
132
+ `Session-Id` (`Dialect#session_header`: `x-session-affinity` under `dialect: fireworks`, which keeps a session on
133
+ the replica that has its cache).
111
134
 
112
135
  ## Reading the numbers
113
136
 
114
137
  - The debug log's `generation_completed` line: `prompt=` (the request's prompt tokens), `cached=` (read from the
115
138
  server's cache), `cache_write=` (written to it, where the provider reports writes) and `reprefill=` (of the last
116
139
  request's prompt on the same model, what was prefilled again). The `http` line of a
117
- remote Claude request shows `cache=on` (or `cache=1h`) when it carried breakpoints.
140
+ remote Claude or Messages request shows `cache=on` (or `cache=1h`) when it carried breakpoints.
118
141
  - The warm-up logs `model warmup` with `cached=`, `prefilled=` and `prefill_ms=`.
119
142
  - `/stats`: "cached N (P%)", "cache writes" and "re-prefilled" for the session.
120
143
  - A healthy session: from the second request on `cached=` is close to `prompt=`; a second fresh session in the
data/docs/memory.md CHANGED
@@ -270,7 +270,7 @@ profiles, meta bundles that install a set of them:
270
270
  | profile | bundles |
271
271
  |---|---|
272
272
  | `core` | loop-guard, check-in, guardrails, slow-commands, agents-md: the recommended safety set |
273
- | `dev` | known-names, mcp, btw, skills, source-links, github-pr, coordinator, claude-peer |
273
+ | `dev` | known-names, mcp, btw, skills, source-links, github-pr, coordinator, claude-peer, codex-peer |
274
274
 
275
275
  `chi bootstrap` installs `core` and, on a terminal, offers `dev`. An existing
276
276
  install gets neither by itself: `chi bundle install core` (or `dev`).