samagotchi 0.66.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +60 -1
- data/docs/architecture.md +50 -28
- data/docs/cli.md +3 -1
- data/docs/configuration.md +50 -9
- data/docs/context.md +35 -1
- data/docs/desktop.md +90 -22
- data/docs/guardrails.md +8 -5
- data/docs/hooks.md +2 -2
- data/docs/internals/anthropic.md +156 -0
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +2 -0
- data/docs/internals/engine.md +71 -29
- data/docs/internals/gemma4-contract.md +1 -1
- data/docs/internals/llm-context-bench.md +5 -4
- data/docs/internals/prompt-caching.md +26 -11
- data/docs/plugins.md +126 -23
- data/docs/sessions.md +6 -6
- data/docs/sub-agent.md +4 -1
- data/docs/testing.md +17 -3
- data/lib/samagotchi/bootstrap/probe.rb +4 -3
- data/lib/samagotchi/bootstrap_command.rb +13 -3
- data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
- data/lib/samagotchi/bridge.rb +6 -3
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +34 -313
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
- data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +7 -6
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +37 -312
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +8 -3
- data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +14 -6
- data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
- data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/config.rb +15 -7
- data/lib/samagotchi/context_absorber.rb +66 -13
- data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
- data/lib/samagotchi/desktop/macos/App.swift +3 -3
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
- data/lib/samagotchi/desktop/macos/Images.swift +2 -2
- data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
- data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
- data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
- data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
- data/lib/samagotchi/desktop/macos.rb +84 -13
- data/lib/samagotchi/desktop_command.rb +14 -3
- data/lib/samagotchi/engine.rb +96 -191
- data/lib/samagotchi/engine_peers.rb +3 -0
- data/lib/samagotchi/guardrail_wiring.rb +1 -1
- data/lib/samagotchi/guardrails/approval.rb +1 -1
- data/lib/samagotchi/guardrails/approvals.rb +1 -1
- data/lib/samagotchi/guardrails/rules.rb +1 -1
- data/lib/samagotchi/guardrails/targets.rb +1 -1
- data/lib/samagotchi/hook_routes.rb +151 -0
- data/lib/samagotchi/hooks/registry.rb +2 -2
- data/lib/samagotchi/host_keys.rb +147 -0
- data/lib/samagotchi/llm/adapter_common.rb +138 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
- data/lib/samagotchi/llm/api_key.rb +23 -7
- data/lib/samagotchi/llm/chat_loop.rb +10 -6
- data/lib/samagotchi/llm/openai_chat.rb +9 -92
- data/lib/samagotchi/llm/refusal_memo.rb +29 -0
- data/lib/samagotchi/llm_context_override.rb +1 -0
- data/lib/samagotchi/peer_sources.rb +58 -0
- data/lib/samagotchi/plugin/api.rb +64 -155
- data/lib/samagotchi/plugin/loader.rb +4 -2
- data/lib/samagotchi/plugin/peer.rb +394 -0
- data/lib/samagotchi/plugin/tool_spec.rb +166 -0
- data/lib/samagotchi/plugin_tasks.rb +2 -2
- data/lib/samagotchi/row_refs.rb +1 -1
- data/lib/samagotchi/self_report.rb +5 -3
- data/lib/samagotchi/send_command.rb +21 -11
- data/lib/samagotchi/session_continue.rb +58 -15
- data/lib/samagotchi/session_inbox.rb +0 -13
- data/lib/samagotchi/tool_call_parser.rb +1 -1
- data/lib/samagotchi/toolkit_binding.rb +138 -0
- data/lib/samagotchi/tools/context_read.rb +14 -0
- data/lib/samagotchi/tools/execute.rb +12 -12
- data/lib/samagotchi/tools/registry.rb +1 -1
- data/lib/samagotchi/tools/task_create.rb +6 -6
- data/lib/samagotchi/tools/task_get.rb +13 -13
- data/lib/samagotchi/tools/task_list.rb +6 -6
- data/lib/samagotchi/tools/task_record.rb +52 -0
- data/lib/samagotchi/tools/task_runtime.rb +55 -91
- data/lib/samagotchi/tools/task_stop.rb +6 -6
- data/lib/samagotchi/tools/task_wait.rb +12 -13
- data/lib/samagotchi/tools/tool_index.rb +454 -0
- data/lib/samagotchi/update_command.rb +9 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +16 -9
- data/lib/samagotchi/web/capabilities.rb +1 -1
- data/lib/samagotchi/web/public/activity.js +2 -2
- data/lib/samagotchi/web/public/annotate_actions.js +1 -1
- data/lib/samagotchi/web/public/app.js +31 -36
- data/lib/samagotchi/web/public/chains.js +1 -1
- data/lib/samagotchi/web/public/commands.js +2 -2
- data/lib/samagotchi/web/public/composer_size.js +2 -2
- data/lib/samagotchi/web/public/copy.js +1 -1
- data/lib/samagotchi/web/public/ctx.js +1 -1
- data/lib/samagotchi/web/public/delivery_line.js +4 -4
- data/lib/samagotchi/web/public/families_view.js +1 -1
- data/lib/samagotchi/web/public/format.js +11 -7
- data/lib/samagotchi/web/public/images.js +25 -2
- data/lib/samagotchi/web/public/llm_context_chip.js +2 -2
- data/lib/samagotchi/web/public/notify.js +6 -4
- data/lib/samagotchi/web/public/question_card.js +1 -1
- data/lib/samagotchi/web/public/ref_scan.js +1 -1
- data/lib/samagotchi/web/public/session_state.js +14 -0
- data/lib/samagotchi/web/public/session_title.js +2 -3
- data/lib/samagotchi/web/public/sessions_list.js +0 -8
- data/lib/samagotchi/web/public/stage_model.js +2 -2
- data/lib/samagotchi/web/public/strip.js +3 -3
- data/lib/samagotchi/web/public/task_stop.js +3 -2
- data/lib/samagotchi/web/public/turn_events.js +1 -1
- data/lib/samagotchi/web/public/turn_html.js +2 -2
- data/lib/samagotchi/web/public/turn_model.js +2 -2
- data/lib/samagotchi/web/public/turn_view.js +1 -1
- data/lib/samagotchi/web/server.rb +0 -4
- data/lib/samagotchi/worker.rb +11 -8
- data/lib/samagotchi/worker_inbound.rb +36 -4
- data/lib/samagotchi/worker_wakes.rb +67 -7
- metadata +14 -1
data/docs/internals/engine.md
CHANGED
|
@@ -72,7 +72,7 @@ Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:,
|
|
|
72
72
|
│
|
|
73
73
|
└── ensure: release_turn(probe_cancel_before)
|
|
74
74
|
# TurnState#finish! (turn flag off, controller, sink and steers cleared)
|
|
75
|
-
#
|
|
75
|
+
# SteerCut#finished! (its GenerationPhase finished, its WaitingSteer cleared)
|
|
76
76
|
# probe cancel swapped back
|
|
77
77
|
# the context window cache dropped, so the next turn asks the server again
|
|
78
78
|
# steers left logged as dropped, activity recorded, turn-scoped hooks cleared
|
|
@@ -83,7 +83,7 @@ Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:,
|
|
|
83
83
|
```
|
|
84
84
|
StandardError in run_turn:
|
|
85
85
|
→ if turn.ended (it came from the after_turn/session_end hooks): logged as post_turn_error, re-raised
|
|
86
|
-
→ 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)
|
|
87
87
|
# Both loops call LLM::FailedTurn.attach(e, conversation) before re-raising, so
|
|
88
88
|
# failed_messages keeps the loop's partial conversation (tool iterations included),
|
|
89
89
|
# else turn.messages, with TurnNote.failed(summary, continued: turn.continue) at the tail.
|
|
@@ -135,7 +135,8 @@ Engine#backend → backend_for(@host_registry.resolve(@effective_model_name))
|
|
|
135
135
|
```
|
|
136
136
|
|
|
137
137
|
Both implement the `LLM::ModelBackend` contract (`#complete(messages:, max_iterations:, on_stream_event:,
|
|
138
|
-
cancel_controller:, model_name:, max_tool_output_chars:, pending_input:)
|
|
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
|
|
139
140
|
`KernelLoop#run` with the same arguments. `ChatLoop` runs its own loop but is built with the same KernelLoop,
|
|
140
141
|
whose tools, hooks, gate and dispatch it uses.
|
|
141
142
|
|
|
@@ -172,17 +173,21 @@ KernelLoop#run(messages, max_iterations:, on_stream_event:, cancel_controller:,
|
|
|
172
173
|
`turn.conversation` is the loop's own copy, not the Engine's `turn.messages`. A failure attaches it to the error
|
|
173
174
|
(`LLM::FailedTurn.attach`), so `Engine#failed_messages` can keep it; a Ctrl-C does not (see Error paths).
|
|
174
175
|
|
|
175
|
-
### ChatLoop (the
|
|
176
|
+
### ChatLoop (the chat APIs)
|
|
176
177
|
|
|
177
|
-
Runs hosts
|
|
178
|
-
|
|
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.
|
|
179
183
|
|
|
180
184
|
```
|
|
181
185
|
ChatLoop#complete(messages:, max_iterations:, on_stream_event:, cancel_controller:,
|
|
182
|
-
model_name:, max_tool_output_chars:, pending_input:)
|
|
186
|
+
model_name:, max_tool_output_chars:, pending_input:, reasoning_since:)
|
|
183
187
|
│
|
|
184
188
|
│ conversation = Array(messages).map(&:dup) # a shallow copy of each message
|
|
185
|
-
│ 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)
|
|
186
191
|
│ .call(max_iterations: max_iterations || 1, cap: KernelLoop.resolve_output_char_cap(max_tool_output_chars))
|
|
187
192
|
│
|
|
188
193
|
└──→ LLM::ModelResult(text, conversation, exhausted, canceled, tool_activity, ...)
|
|
@@ -191,10 +196,13 @@ ChatLoop#complete(messages:, max_iterations:, on_stream_event:, cancel_controlle
|
|
|
191
196
|
The differences from the native loop:
|
|
192
197
|
|
|
193
198
|
- The tools go in each request's `tools` field (`tool_definitions`), not in the system prompt.
|
|
194
|
-
- 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`).
|
|
195
201
|
- The first system message carries `cache_split:` (from `SystemPrompt#stable_length`, which the Engine passes in
|
|
196
|
-
as `stable_length:`), where the prompt's stable part ends.
|
|
197
|
-
|
|
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.
|
|
198
206
|
- Each call's result is its own tool message, paired by `tool_call_id`.
|
|
199
207
|
- A model turn's saved reasoning (`thinking:`) goes back as `reasoning_content` per the turn's
|
|
200
208
|
`TurnSettings#reasoning_history` (`ReasoningHistory.resolve`, the session's own level first:
|
|
@@ -208,8 +216,15 @@ The differences from the native loop:
|
|
|
208
216
|
in `#resumed_run_start`), past a user entry added after that run's last reply (a `!cmd`'s output). Without
|
|
209
217
|
`since:` (`#reasoning_chars` from outside a Run) the boundary is the last query (a `role: "user"` or `turn_start`
|
|
210
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.
|
|
211
224
|
- A 400 about reasoning (`BadRequest#reasoning_refused?`) for a request that sent reasoning back asks the step again
|
|
212
|
-
without it
|
|
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
|
|
213
228
|
`:reasoning_history_refused` goes out. When it is refused too, the thinking-fields fallback (`#thinking_refused!`,
|
|
214
229
|
`:thinking_refused`) runs on it, still without reasoning. Up to three requests per step. A 400 about a thinking
|
|
215
230
|
block's signature (`BadRequest#thinking_signature?`) or a refused binding control
|
|
@@ -256,7 +271,7 @@ One iteration with tool calls:
|
|
|
256
271
|
`EVENT_FIELDS`) copy them by these lists, so a new row field is added there. `ref` is `FileRef.for` (a
|
|
257
272
|
read/write/edit's file against the cwd, `{kind: "file", path, line?, end_line?}`); the web opens it in the editor
|
|
258
273
|
that `Web::Editor` (web.editor) names, for a local viewer only (`App#viewer_capabilities`).
|
|
259
|
-
`refs` is a plugin tool's row refs: its registry entry's `refs` (chi.tool's `refs:` lambda, `
|
|
274
|
+
`refs` is a plugin tool's row refs: its registry entry's `refs` (chi.tool's `refs:` lambda, `ToolSpec#per_call`), run
|
|
260
275
|
once per call by `RowRefs.for` (never raises: a raise or a non-list gives none, `tool_refs_failed`; each entry a
|
|
261
276
|
`Ref`, a bad one dropped, `tool_refs_rejected`; a relative file resolved against the session's folder
|
|
262
277
|
(`KernelLoop#session_cwd`, else the process's), one outside it (its spelling or its real path) dropped, a kept one
|
|
@@ -317,7 +332,8 @@ The turn as the Engine sees it (the KernelLoop has its own `Turn` struct):
|
|
|
317
332
|
|
|
318
333
|
```ruby
|
|
319
334
|
Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
|
|
320
|
-
:settings, :id, :limit) do
|
|
335
|
+
:settings, :id, :limit, :resumed, :resume_from) do
|
|
336
|
+
def resumed? = resumed ? true : false # a step-limit Continue
|
|
321
337
|
def tag(event) = origin ? event.merge(origin: origin) : event # boundary events carry the origin
|
|
322
338
|
def elapsed = ... # seconds since started_at
|
|
323
339
|
end
|
|
@@ -342,10 +358,14 @@ end
|
|
|
342
358
|
running turn's record so far as a killed turn's (canceled, `worker_stopped`), which the next worker loads; the
|
|
343
359
|
turn's end writes over it.
|
|
344
360
|
|
|
345
|
-
### GenerationPhase
|
|
361
|
+
### SteerCut, GenerationPhase and WaitingSteer
|
|
346
362
|
|
|
347
|
-
|
|
348
|
-
|
|
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
|
|
349
369
|
Bridge acks it as `cut`.
|
|
350
370
|
A generation is cuttable while it has streamed only thinking, is still thinking, and has thought for at least
|
|
351
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!`.
|
|
@@ -357,8 +377,8 @@ line and tell whether "cut in now" can land.
|
|
|
357
377
|
### WaitingSteer
|
|
358
378
|
|
|
359
379
|
A message that may cut the generation but came too early (the generation hadn't been thinking long enough). It
|
|
360
|
-
waits until the thinking passes the model's cut wait (
|
|
361
|
-
the message to the model at a boundary, or until the turn ends (`
|
|
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).
|
|
362
382
|
|
|
363
383
|
## Events
|
|
364
384
|
|
|
@@ -414,15 +434,20 @@ hook registry (`@hooks.fire`), not on the sink or the observers. The other hook
|
|
|
414
434
|
|
|
415
435
|
```
|
|
416
436
|
Engine initialization (in this order, among other things):
|
|
417
|
-
@turn_state, @
|
|
437
|
+
@turn_state, @steer_cut (SteerCut: its GenerationPhase and WaitingSteer)
|
|
418
438
|
@host_registry, @effective_model_name; @client = @host_registry.resolve(...).client
|
|
419
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)
|
|
420
441
|
@guardrail_wiring, @question_desk
|
|
442
|
+
@toolkit_binding (ToolkitBinding: the kits, the plugins' among them, and the turn-start apply)
|
|
421
443
|
@extension_load = ExtensionLoad.new; @hooks = @extension_load.hooks (config.yml's, then the bundles')
|
|
422
|
-
@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)
|
|
423
448
|
wire_kernel: @kernel = KernelLoop.new(client:, profile:, hooks:, reminder_store:, tools:)
|
|
424
449
|
@kernel.guardrail_gate = @guardrail_wiring.gate; @kernel.warmup = PromptWarmup.new
|
|
425
|
-
@hooks.runtime =
|
|
450
|
+
@hooks.runtime = @hook_routes.runtime (HookRoutes, built first: what a hook or plugin does reaches the UIs)
|
|
426
451
|
@native_backend = LLM::NativeBackend.new(kernel: @kernel)
|
|
427
452
|
@prompt_builder = SystemPrompt.new(...)
|
|
428
453
|
@session_observer with the metrics, the log subscriber and the thinking tails subscribed (subscribe_session_observers)
|
|
@@ -444,7 +469,7 @@ Model probes (per turn):
|
|
|
444
469
|
|
|
445
470
|
Plugins and hook files register hooks on hook points (see "Hook points" above, and docs/hooks.md). Every fire
|
|
446
471
|
puts the hook runtime on the event: `event[:hook]` (the hook's label) and five helpers, which call the Engine's
|
|
447
|
-
`Hooks::Runtime
|
|
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`, ...).
|
|
448
473
|
|
|
449
474
|
| Helper | What it does | Used by |
|
|
450
475
|
|---|---|---|
|
|
@@ -551,8 +576,10 @@ Both loops:
|
|
|
551
576
|
- **The same events**: generation events, tool dispatch and tool call events, and the before/after generation
|
|
552
577
|
and tool call hooks.
|
|
553
578
|
|
|
554
|
-
Native loop only: the Qwen open-block recovery, and the thinking prefill (
|
|
555
|
-
thought after the cue,
|
|
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).
|
|
556
583
|
|
|
557
584
|
## Key design principles
|
|
558
585
|
|
|
@@ -566,8 +593,9 @@ thought after the cue, kept in the model message so the next prompt starts with
|
|
|
566
593
|
persistent.
|
|
567
594
|
5. **A stable system prompt for the cache.** `SystemPrompt#build` caches the prompt per Engine, keyed by
|
|
568
595
|
`[chat, thinking, layers]` (chat/native, the thinking level, the turn's LLM context layers), and is reset
|
|
569
|
-
only on a model switch, a tools change
|
|
570
|
-
|
|
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).
|
|
571
599
|
prompt-caching.md covers this in detail.
|
|
572
600
|
|
|
573
601
|
## Key files
|
|
@@ -576,12 +604,22 @@ Paths are under `lib/samagotchi/`.
|
|
|
576
604
|
|
|
577
605
|
| File | Role |
|
|
578
606
|
|---|---|
|
|
579
|
-
| `engine.rb` | Engine: turn lifecycle, event emission,
|
|
607
|
+
| `engine.rb` | Engine: turn lifecycle, event emission, plugins, model switching |
|
|
580
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 |
|
|
581
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`) |
|
|
582
612
|
| `steer.rb` | Queued input and steers at iteration boundaries |
|
|
583
613
|
| `kernel_loop.rb` | Native loop: `#run` (format, generate, parse, dispatch, iterate) |
|
|
584
|
-
| `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` |
|
|
585
623
|
| `llm/backend.rb` | The `LLM::ModelBackend` contract (`#complete(...)`) |
|
|
586
624
|
| `llm/native_backend.rb` | Wraps KernelLoop as a backend (`#complete` calls `#run`) |
|
|
587
625
|
| `llm/model_result.rb` | `LLM::ModelResult`, what a backend returns |
|
|
@@ -591,7 +629,11 @@ Paths are under `lib/samagotchi/`.
|
|
|
591
629
|
| `prompt_warmup.rb` | The turn-end warm-up |
|
|
592
630
|
| `tools/registry.rb` | The tools a session offers: schemas, handlers, order |
|
|
593
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!`) |
|
|
594
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 |
|
|
595
637
|
| `extension_load.rb` | What an Engine loads at start: config.yml and bundle hooks, plugins, `bundles:` settings; events held while plugins load |
|
|
596
638
|
| `guardrail_wiring.rb`, `guardrails/gate.rb` | The gate's context and approvals; the verdict per call |
|
|
597
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
|
|
79
|
-
calls runs through chi's `LLMContextForget` (refusals, `protect_steps`, `keep`),
|
|
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
|
|
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
|
|
7
|
-
of what chi sends decides what a new turn, or a new session, has to pay
|
|
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
|
|
|
@@ -23,6 +24,8 @@ right-hand column happens.
|
|
|
23
24
|
shared by every session ────────────────────────────────────────────────── breaks it
|
|
24
25
|
┌──────────────────────────────────────────────────────────────────────────┐
|
|
25
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) │
|
|
26
29
|
│ those the session's toolkit declares (per toolkit) │ /model, a profile change,
|
|
27
30
|
│ (chat: the `tools` field; native: the model's template) │ a toolkit change
|
|
28
31
|
├──────────────────────────────────────────────────────────────────────────┤
|
|
@@ -36,7 +39,7 @@ right-hand column happens.
|
|
|
36
39
|
│ AGENTS.md (root → cwd) │ (picked up by a new
|
|
37
40
|
│ appended files (append_system_prompt:, --append-system-prompt) │ process or a rebuild)
|
|
38
41
|
│ project + system memory indexes │
|
|
39
|
-
│
|
|
42
|
+
│ ◄── remote Claude / Messages: cache_control breakpoint #1 │
|
|
40
43
|
├──────────────────────────────────────────────────────────────────────────┤
|
|
41
44
|
│ SYSTEM, volatile tail (per session, least → most changing) │
|
|
42
45
|
│ Model: this session runs on … │ another session
|
|
@@ -48,17 +51,19 @@ right-hand column happens.
|
|
|
48
51
|
┌──────────────────────────────────────────────────────────────────────────┐
|
|
49
52
|
│ history user / assistant / tool messages, APPEND-ONLY │ !rollback, a failed or
|
|
50
53
|
│ (images: up to the newest N, cut in batches; ImagePlan) │ cancelled turn's tail,
|
|
51
|
-
│
|
|
52
|
-
│
|
|
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
|
|
53
56
|
├──────────────────────────────────────────────────────────────────────────┤ sanitizing (thinking
|
|
54
57
|
│ tail notes reminders, turn notes, steers (system/user messages) │ stripped per spec)
|
|
55
58
|
│ new prompt this turn's user message │
|
|
56
59
|
└──────────────────────────────────────────────────────────────────────────┘
|
|
57
60
|
```
|
|
58
61
|
|
|
59
|
-
- The system prompt is built **once per
|
|
60
|
-
|
|
61
|
-
|
|
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.
|
|
62
67
|
- The tools layer is shared **per toolkit** (configuration.md, "Toolkits"): sessions on the same toolkit share it,
|
|
63
68
|
and a chain's links keep theirs. A session's toolkit is saved when it is created and never resolved again from
|
|
64
69
|
config. A change (`/toolkit`, `ctx.use_toolkit`) is applied only at a turn's start (`Engine#apply_toolkit!`,
|
|
@@ -69,7 +74,14 @@ right-hand column happens.
|
|
|
69
74
|
breakpoint #1, so a **new session** reads it from the cache. The last non-system message carries breakpoint
|
|
70
75
|
#2, so the **next request in the session** reads everything up to it. Mid and tail system messages never take
|
|
71
76
|
a breakpoint. The split point comes from `SystemPrompt#stable_length` via the message's `cache_split:`, which
|
|
72
|
-
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.
|
|
73
85
|
- Side requests (idle recap, `/btw`) go through `IdleClient` with their own short system prompt. On a current
|
|
74
86
|
llama.cpp they don't evict the session (see the server settings below); chi never pins them to a slot.
|
|
75
87
|
|
|
@@ -115,14 +127,17 @@ is prefilled once. The native and the chat loop use the same plan.
|
|
|
115
127
|
one provider. `cache.key: session` also sends the session id as `prompt_cache_key` in the body, the field
|
|
116
128
|
OpenAI's API routes and caches by (OpenRouter honours it too); the log line shows `cache_by=session`. Only
|
|
117
129
|
`api.openai.com` and OpenRouter get it, and only the session's own chat requests (not recap or `/btw`): a local
|
|
118
|
-
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).
|
|
119
134
|
|
|
120
135
|
## Reading the numbers
|
|
121
136
|
|
|
122
137
|
- The debug log's `generation_completed` line: `prompt=` (the request's prompt tokens), `cached=` (read from the
|
|
123
138
|
server's cache), `cache_write=` (written to it, where the provider reports writes) and `reprefill=` (of the last
|
|
124
139
|
request's prompt on the same model, what was prefilled again). The `http` line of a
|
|
125
|
-
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.
|
|
126
141
|
- The warm-up logs `model warmup` with `cached=`, `prefilled=` and `prefill_ms=`.
|
|
127
142
|
- `/stats`: "cached N (P%)", "cache writes" and "re-prefilled" for the session.
|
|
128
143
|
- A healthy session: from the second request on `cached=` is close to `prompt=`; a second fresh session in the
|
data/docs/plugins.md
CHANGED
|
@@ -291,7 +291,7 @@ is what `targets:` says:
|
|
|
291
291
|
- `command:`: a shell command the call runs; `command:` rules match it.
|
|
292
292
|
- `cwd:`: where it runs, for the repo root and relative paths.
|
|
293
293
|
- `acts_as:`: the tool a call stands for, when the tool runs other tools
|
|
294
|
-
(
|
|
294
|
+
(chi's `call_tool`, which calls a tool a [source](#chitool_sourcecall-acts_as-label-wait-prefix) published). Rules keyed by that tool's name match the
|
|
295
295
|
call too, as well as rules on the tool's own name. It can't be one of
|
|
296
296
|
chi's own tools or another bundle's tool: that is dropped (and logged).
|
|
297
297
|
- `args:`: the arguments it acts with (a Hash), when they aren't the call's
|
|
@@ -308,7 +308,7 @@ targets: ->(args) { { acts_as: "mcp_#{args["server"]}_#{args["tool"]}", args: ar
|
|
|
308
308
|
```
|
|
309
309
|
|
|
310
310
|
An approval of an `acts_as:` call keys on the tool's own name as well
|
|
311
|
-
(`
|
|
311
|
+
(`call_tool>mcp_github_x:…`), so it never stands in for an approval of the
|
|
312
312
|
tool it acts as, nor that one for it. Hooks see the call's own name, and so
|
|
313
313
|
does loop-guard's `ignore_tools`.
|
|
314
314
|
|
|
@@ -548,7 +548,7 @@ end
|
|
|
548
548
|
- **`provides_tools: true`**: the task provides what the plugin's tools
|
|
549
549
|
need: tools of its own (with `chi.replace_tools`), or what fixed tools
|
|
550
550
|
read (the mcp bundle's index of a first-run server's tools, which
|
|
551
|
-
`
|
|
551
|
+
`find_tools` searches). A turn sent while it runs starts at once (the user's
|
|
552
552
|
message shows), then waits for it **before its first model request**, so
|
|
553
553
|
the model sees the tools; the UIs keep showing the task meanwhile. The
|
|
554
554
|
wait lasts at most `timeout` seconds from the task's start (default 60).
|
|
@@ -579,6 +579,89 @@ tool (full) until it comes back, with a warning at its first turn. See
|
|
|
579
579
|
chi.toolkit "review", base: "lean", add: %w[delegate delegate_result]
|
|
580
580
|
```
|
|
581
581
|
|
|
582
|
+
### `chi.tool_source(call:, acts_as:, label:, wait:, prefix:)`
|
|
583
|
+
|
|
584
|
+
Tools the model reaches through a search instead of declaring them one by
|
|
585
|
+
one: the plugin publishes them into chi's **tool index**, and chi declares
|
|
586
|
+
two tools in all, whatever the number of sources: `find_tools` (a keyword
|
|
587
|
+
search that answers each match's `inputSchema`) and `call_tool` (calls one as
|
|
588
|
+
`<group>/<tool>`; its row label is the first source's `label`). They
|
|
589
|
+
are declared once, after the plugins load, and only when a plugin registered
|
|
590
|
+
a source: a session without one declares neither. Nothing a source publishes
|
|
591
|
+
later changes the request's tools, only what a search answers, so the prompt
|
|
592
|
+
cache keeps (the search tool's description lists the groups as they were at
|
|
593
|
+
load). The mcp bundle is the only source so far (a group per server), and the
|
|
594
|
+
texts speak of MCP servers.
|
|
595
|
+
|
|
596
|
+
```ruby
|
|
597
|
+
@source = chi.tool_source(label: "mcp", prefix: "mcp_",
|
|
598
|
+
call: ->(tool, args, ctx) { run(tool, args, ctx) }, # the result, as a tool block's
|
|
599
|
+
acts_as: ->(group, tool) { "mcp_#{group}_#{tool}" }, # what rules match
|
|
600
|
+
wait: ->(group) { start_of(group).value }) # a :starting group's start
|
|
601
|
+
index = Samagotchi::Tools::ToolIndex
|
|
602
|
+
@source.publish([index::Group.new(name: "fs", summary: "files in ~/scratch", tools: [
|
|
603
|
+
index::Tool.new(group: "fs", name: "read", description: "Read a file.", schema: read_schema,
|
|
604
|
+
acts_as: "mcp_fs_read")
|
|
605
|
+
]),
|
|
606
|
+
index::Group.new(name: "chrome", state: :starting)])
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
- **`publish(groups)`** swaps in the plugin's whole list, from any thread, at
|
|
610
|
+
any time (a server that started, `list_changed`): a search sees the old
|
|
611
|
+
list or the new one, never half. A `Group` has a `summary` (its line in
|
|
612
|
+
`find_tools`' description), a `state` (`:ready`; `:starting`, its tools may
|
|
613
|
+
still come: a call to it waits on `wait`, a search says "starting, try
|
|
614
|
+
again"; `:failed` with its `error`) and its tools. A group exists before its
|
|
615
|
+
tools do. A `Tool`'s `refusal` says why `call_tool` refuses it (nil: it can
|
|
616
|
+
be called), its `mark` what a search shows beside its name: the plugin
|
|
617
|
+
decides both (the mcp bundle: a bad schema, a name clash) and publishes
|
|
618
|
+
them again when they change.
|
|
619
|
+
- **`call`** gets a call chi looked up and checked: the `Tool`, its `args`
|
|
620
|
+
typed by its `inputSchema`, and the plugin's `ctx`. It returns what a tool's
|
|
621
|
+
block returns (a String, a `ToolResult` with images).
|
|
622
|
+
- **Guardrails** see a `call_tool` as the tool it calls: its `acts_as`, its
|
|
623
|
+
arguments and `"<group>: <tool>"` as the question's label
|
|
624
|
+
([acts_as:](#guardrails)). A call made before its tool is published is
|
|
625
|
+
named by `acts_as` from the names alone; a bare name with `prefix` is
|
|
626
|
+
taken as an `acts_as` as given. The `acts_as:` hook must give exactly the
|
|
627
|
+
`acts_as` the source publishes for that tool: a call whose tool isn't the
|
|
628
|
+
one guardrails were given is refused ("the MCP tools changed while the call
|
|
629
|
+
waited"), so a hook that disagrees refuses every call.
|
|
630
|
+
- **The row** shows `label` (default: the bundle's name) and
|
|
631
|
+
`<group>/<tool> args…`. `@source.declared_names` and `declared_tokens` say
|
|
632
|
+
what the two tools take in every request (none when other tools had both
|
|
633
|
+
names). A `Group` in a state other than the three, holding another group's
|
|
634
|
+
tool, or a `Tool` with a blank `acts_as` raises `ArgumentError`.
|
|
635
|
+
- `find_tools` and `call_tool` aren't chi's own tools: every toolkit declares
|
|
636
|
+
them (unless its `remove:` names them), as it does plugin tools. A bundle
|
|
637
|
+
that declares tools of those names itself (an mcp bundle before 0.7.0 has
|
|
638
|
+
`find_mcp_tools` and `mcp_call`) keeps them, and with no source chi
|
|
639
|
+
declares neither.
|
|
640
|
+
|
|
641
|
+
### `chi.peer_source(name, label:)`
|
|
642
|
+
|
|
643
|
+
Declares a context source as a peer agent's answers: the peer bundles'
|
|
644
|
+
`claude-review` and `codex-review`. Such a source, when it is a pushed source
|
|
645
|
+
of the session's own, wakes the session by the peer loop's rules instead of
|
|
646
|
+
the 10-minute window: up to `context.peer_turns` wake turns in a row, no wake
|
|
647
|
+
for an answer whose verdict is `VERDICT OK`, and a wake line that names the
|
|
648
|
+
peer and keeps the turn within the user's task (see [Waking: peer
|
|
649
|
+
sources](context.md#peer-sources-the-peer-loop)).
|
|
650
|
+
|
|
651
|
+
```ruby
|
|
652
|
+
chi.peer_source "codex-review", label: "Codex"
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
- `name`: a context source name (as `chi context add` takes it). Another
|
|
656
|
+
bundle's is a load error.
|
|
657
|
+
- `label:`: the peer's name, said in chi's own lines ("your peer agent
|
|
658
|
+
Codex"): a capital letter, then up to 23 letters and spaces.
|
|
659
|
+
|
|
660
|
+
The declaration lives in the session's process only, like a tool: a source
|
|
661
|
+
file can't make itself a peer's, and uninstalling the bundle ends it.
|
|
662
|
+
Subclasses of `Samagotchi::Plugin::Peer` (below) declare their `REVIEW`
|
|
663
|
+
source. Needs chi 0.67.0 (guard it with `chi.respond_to?(:peer_source)`).
|
|
664
|
+
|
|
582
665
|
### `chi.ctx`
|
|
583
666
|
|
|
584
667
|
The plugin's context (the `ctx` its handlers get), for `register` itself:
|
|
@@ -907,7 +990,9 @@ only against this API (`lib/samagotchi/bundles/btw/plugin.rb`).
|
|
|
907
990
|
only against this API (`lib/samagotchi/bundles/mcp/plugin.rb`), and gives
|
|
908
991
|
the model the tools of [MCP](https://modelcontextprotocol.io) servers. It
|
|
909
992
|
has no memory file, and the servers' tools aren't declared to the model one
|
|
910
|
-
by one:
|
|
993
|
+
by one: the bundle publishes them into chi's tool index
|
|
994
|
+
([chi.tool_source](#chitool_sourcecall-acts_as-label-wait-prefix), a group
|
|
995
|
+
per server), and the model has its two tools, `find_tools` and `call_tool` (~250 tokens in
|
|
911
996
|
every request plus a line per server, whatever the servers have; `/mcp`
|
|
912
997
|
says how many). Stdio servers only, for now.
|
|
913
998
|
|
|
@@ -925,7 +1010,7 @@ bundles:
|
|
|
925
1010
|
env: {NODE_OPTIONS: "--no-warnings"} # added to chi's environment
|
|
926
1011
|
cwd: ~/scratch # default: where chi runs
|
|
927
1012
|
tools: [read_*, list_directory] # optional: only these (globs)
|
|
928
|
-
description: files in ~/scratch # optional: its line in
|
|
1013
|
+
description: files in ~/scratch # optional: its line in find_tools
|
|
929
1014
|
timeout: 120 # optional: this server's per-call timeout
|
|
930
1015
|
chrome:
|
|
931
1016
|
command: [npx, -y, "chrome-devtools-mcp@latest", --slim, --headless]
|
|
@@ -944,7 +1029,7 @@ bundles:
|
|
|
944
1029
|
one is replaced, and so is what a search finds. The model's tools never
|
|
945
1030
|
change, so the prompt cache keeps. The saved file also keeps the first
|
|
946
1031
|
sentence of the server's `instructions` (from `initialize`), for its line
|
|
947
|
-
in `
|
|
1032
|
+
in `find_tools`.
|
|
948
1033
|
- **The first run** (no saved list, or the config changed) starts the
|
|
949
1034
|
server in an [init task](#chiinitlabel-provides_tools-false-quiet-false-timeout-nil--ctx--):
|
|
950
1035
|
every UI shows `Starting MCP server x (first run, saving its tools)`, and
|
|
@@ -977,9 +1062,9 @@ bundles:
|
|
|
977
1062
|
command: [chrome-devtools-mcp, --slim, --headless] # after npm i -g chrome-devtools-mcp
|
|
978
1063
|
```
|
|
979
1064
|
|
|
980
|
-
- **Tools: search, then call.** The servers' tools are kept in
|
|
1065
|
+
- **Tools: search, then call.** The servers' tools are kept in chi's tool index;
|
|
981
1066
|
the model has two fixed tools:
|
|
982
|
-
- `
|
|
1067
|
+
- `find_tools(query, server?)` searches it by keywords over the
|
|
983
1068
|
server's name, the tool's name and its description, and answers up to 5
|
|
984
1069
|
tools, each with its name `<server>/<tool>`, its description and its
|
|
985
1070
|
whole `inputSchema`. A word counts more the fewer tools have it (a word
|
|
@@ -995,7 +1080,7 @@ bundles:
|
|
|
995
1080
|
`instructions`, and how many tools it has (`- chrome (26 tools)` with
|
|
996
1081
|
neither: tool names there get called blind, without a search; a first
|
|
997
1082
|
run's server is just its name until the next session).
|
|
998
|
-
- `
|
|
1083
|
+
- `call_tool(tool, args)` calls one: `tool` is `<server>/<tool>`, and
|
|
999
1084
|
`mcp_<server>_<tool>` works too (looked up, not parsed). Two tools whose
|
|
1000
1085
|
`mcp_<server>_<tool>` comes out the same (servers `git-hub` and
|
|
1001
1086
|
`git_hub`, names alike up to the 48-character cut) are both refused,
|
|
@@ -1039,18 +1124,18 @@ bundles:
|
|
|
1039
1124
|
closed, then TERM and KILL go to the server's process group.
|
|
1040
1125
|
- **`/mcp`** (anytime) shows a card with the servers, their state (cached
|
|
1041
1126
|
(not started), running with its pid, failed with its count, stopped) and
|
|
1042
|
-
their tools as `<server>/<tool>`, the name `
|
|
1127
|
+
their tools as `<server>/<tool>`, the name `call_tool` takes.
|
|
1043
1128
|
A cached or running server's line says roughly how many tokens its tool
|
|
1044
1129
|
definitions take (`~4,232 tokens`), which only a search's answer carries
|
|
1045
|
-
now; the last line says what every request carries (`
|
|
1046
|
-
`
|
|
1130
|
+
now; the last line says what every request carries (`find_tools` and
|
|
1131
|
+
`call_tool`) and totals the servers'. It is an estimate:
|
|
1047
1132
|
the definitions' JSON as the chat API gets it (`api: openai`), divided by
|
|
1048
1133
|
4. The native prompts (Gemma, Qwen) render a flatter schema, so there they
|
|
1049
1134
|
take less. The log records each server's estimate when it changes
|
|
1050
1135
|
(`mcp_tools_estimated`).
|
|
1051
1136
|
- The server's stderr goes to the debug log (`plugins` records, bundle=mcp).
|
|
1052
1137
|
- **Guardrails.** A rule's `tool:` can be a glob, so one rule covers every
|
|
1053
|
-
MCP tool (a search is `
|
|
1138
|
+
MCP tool (a search is `find_tools`, outside `mcp_*`, so it isn't
|
|
1054
1139
|
asked about):
|
|
1055
1140
|
|
|
1056
1141
|
```yaml
|
|
@@ -1062,20 +1147,21 @@ bundles:
|
|
|
1062
1147
|
reason: an MCP server's tool
|
|
1063
1148
|
```
|
|
1064
1149
|
|
|
1065
|
-
`
|
|
1150
|
+
`call_tool`'s `targets:` act as the tool it calls, by its
|
|
1066
1151
|
`mcp_<server>_<tool>` name ([acts_as:](#guardrails)), so `tool:
|
|
1067
|
-
"mcp_github_*"` or `tool: mcp_github_merge_pull_request` match
|
|
1068
|
-
`
|
|
1152
|
+
"mcp_github_*"` or `tool: mcp_github_merge_pull_request` match a
|
|
1153
|
+
`call_tool` to it, as they matched the tool when it was declared by that
|
|
1069
1154
|
name. A call made before the server's tools are known (a first run
|
|
1070
1155
|
still starting) is matched by the name the model gave, so the rule fires
|
|
1071
1156
|
before the call waits; if the tool it then finds isn't that one, the
|
|
1072
1157
|
call is refused. The question shows the inner arguments under `<server>: <tool>`
|
|
1073
1158
|
(`everything: get_sum: a=20 b=22`), and "Allow this call for the
|
|
1074
1159
|
session" (or in this repo) allows that tool with those arguments only.
|
|
1075
|
-
(Approvals stored before 0.6.0, under the old name, don't carry over
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
`
|
|
1160
|
+
(Approvals stored before 0.6.0, under the old name, don't carry over, nor
|
|
1161
|
+
ones stored before 0.7.0 under `mcp_call`: each MCP tool is asked about once more.)
|
|
1162
|
+
Two things see `call_tool`, not the tool: hooks (a `before_tool_call`
|
|
1163
|
+
event's `tool:` is `"call_tool"`, the tool in its args), and loop-guard's
|
|
1164
|
+
`ignore_tools` (`call_tool` ignores every MCP call; calls to different
|
|
1079
1165
|
tools, or with different args, stay distinct, since the tool is in the
|
|
1080
1166
|
args).
|
|
1081
1167
|
|
|
@@ -1653,10 +1739,20 @@ Claude pairs; without one the bundle costs one tool declaration.
|
|
|
1653
1739
|
- `/claude last` sends the model's last completed answer as context. Mid-turn
|
|
1654
1740
|
it is the answer before the running turn, never the turn's partial text;
|
|
1655
1741
|
a cancelled or failed turn has no answer, so the one before it goes.
|
|
1742
|
+
- **The peer loop.** The bundle declares `claude-review` a peer's
|
|
1743
|
+
([`chi.peer_source`](#chipeer_sourcename-label)), so Claude's answers
|
|
1744
|
+
wake chi by the loop's rules ([Waking: peer
|
|
1745
|
+
sources](context.md#peer-sources-the-peer-loop)): chi fixes what Claude
|
|
1746
|
+
flagged or disagrees and sends again, up to `context.peer_turns` (3) wake
|
|
1747
|
+
turns in a row, and a `VERDICT OK` answer arrives as a note and ends the
|
|
1748
|
+
round. Only a message you type in the web or a TUI starts the count again.
|
|
1656
1749
|
- **The skill.** `skill_claude_peer` (system scope) tells the model to send
|
|
1657
|
-
only when you ask, to pick the part rather than
|
|
1658
|
-
or poll, and how to read the `claude-review`
|
|
1659
|
-
`VERDICT: OK`, `CHANGES` or `UNSURE`)
|
|
1750
|
+
only when you ask or to reply in a peer turn, to pick the part rather than
|
|
1751
|
+
the session, not to wait or poll, and how to read the `claude-review`
|
|
1752
|
+
answer (a validate ends with `VERDICT: OK`, `CHANGES` or `UNSURE`): act on
|
|
1753
|
+
it only within your task, and stop and tell you when Claude agrees or after
|
|
1754
|
+
the last turn. Claude's skill ends each answer's summary with its verdict
|
|
1755
|
+
and never answers with `chi send` (which counts as you typing).
|
|
1660
1756
|
- **Asking first.** The tool has no `targets:`, so guardrails match it by name
|
|
1661
1757
|
only: to be asked before each send, add a rule ([Rules in
|
|
1662
1758
|
config.yml](guardrails.md#rules-in-configyml)):
|
|
@@ -1680,6 +1776,10 @@ Claude pairs; without one the bundle costs one tool declaration.
|
|
|
1680
1776
|
bypasses permissions holds it for approval (in `-p` it expires after 5
|
|
1681
1777
|
minutes). It is local only (a Unix socket), but it does hand session
|
|
1682
1778
|
content to another agent: only what the model or you picked.
|
|
1779
|
+
- **Needs chi 0.67.0** for the loop (`chi update`): the plugin is built on
|
|
1780
|
+
`Samagotchi::Plugin::Peer` (lib/samagotchi/plugin/peer.rb), the base both
|
|
1781
|
+
peer bundles share (the tool, `/claude`, the cards, payloads), and each
|
|
1782
|
+
bundle sets its strings and transport.
|
|
1683
1783
|
|
|
1684
1784
|
## The codex-peer bundle
|
|
1685
1785
|
|
|
@@ -1758,6 +1858,9 @@ skill install card) works the same.
|
|
|
1758
1858
|
Codex's names, and on a missing answer points to Codex being mid-turn or
|
|
1759
1859
|
its sandbox (the `writable_roots` line). To be asked before each send, a
|
|
1760
1860
|
guardrails rule with `tool: codex_send` (see the claude-peer section).
|
|
1861
|
+
- **The peer loop** works as claude-peer's: `codex-review` is declared a
|
|
1862
|
+
peer's, Codex's answers wake chi up to `context.peer_turns` turns in a row,
|
|
1863
|
+
and `VERDICT OK` ends the round. Needs chi 0.67.0.
|
|
1761
1864
|
|
|
1762
1865
|
## Shutdown
|
|
1763
1866
|
|