samagotchi 0.2.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 +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE +21 -0
- data/README.md +126 -0
- data/bin/chi +1140 -0
- data/docs/architecture.md +299 -0
- data/docs/cli.md +490 -0
- data/docs/configuration.md +494 -0
- data/docs/desktop.md +97 -0
- data/docs/guardrails.md +218 -0
- data/docs/hooks.md +309 -0
- data/docs/internals/background-tasks.md +26 -0
- data/docs/internals/context-telemetry.md +36 -0
- data/docs/internals/gemma4-contract.md +23 -0
- data/docs/internals/tool-guardrails.md +45 -0
- data/docs/memory.md +85 -0
- data/docs/plugins.md +819 -0
- data/docs/releasing.md +135 -0
- data/docs/sessions.md +155 -0
- data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
- data/lib/samagotchi/bridge/card_store.rb +126 -0
- data/lib/samagotchi/bridge/event_id.rb +25 -0
- data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
- data/lib/samagotchi/bridge/sse_writer.rb +248 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
- data/lib/samagotchi/bridge.rb +993 -0
- data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
- data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
- data/lib/samagotchi/bridge_client.rb +330 -0
- data/lib/samagotchi/bundle_needs.rb +97 -0
- data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
- data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
- data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
- data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
- data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
- data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
- data/lib/samagotchi/bundles/system/delegated.md +10 -0
- data/lib/samagotchi/bundles/system/identity.md +7 -0
- data/lib/samagotchi/bundles/system/manifest.yml +11 -0
- data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
- data/lib/samagotchi/bundles/system/self_map.md +55 -0
- data/lib/samagotchi/cancellation_controller.rb +78 -0
- data/lib/samagotchi/client.rb +429 -0
- data/lib/samagotchi/commands/registry.rb +112 -0
- data/lib/samagotchi/config.rb +910 -0
- data/lib/samagotchi/context_note.rb +77 -0
- data/lib/samagotchi/context_quote.rb +21 -0
- data/lib/samagotchi/context_usage.rb +66 -0
- data/lib/samagotchi/context_window.rb +76 -0
- data/lib/samagotchi/debug_log.rb +110 -0
- data/lib/samagotchi/desktop/macos/App.swift +102 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
- data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
- data/lib/samagotchi/desktop/macos.rb +255 -0
- data/lib/samagotchi/desktop.rb +21 -0
- data/lib/samagotchi/desktop_command.rb +143 -0
- data/lib/samagotchi/engine.rb +2807 -0
- data/lib/samagotchi/guardrails/approval.rb +125 -0
- data/lib/samagotchi/guardrails/approvals.rb +177 -0
- data/lib/samagotchi/guardrails/context.rb +71 -0
- data/lib/samagotchi/guardrails/gate.rb +125 -0
- data/lib/samagotchi/guardrails/load_failures.rb +46 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
- data/lib/samagotchi/guardrails/rules.rb +199 -0
- data/lib/samagotchi/guardrails/targets.rb +119 -0
- data/lib/samagotchi/guardrails/verdict.rb +134 -0
- data/lib/samagotchi/guardrails.rb +18 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
- data/lib/samagotchi/hooks/loader.rb +162 -0
- data/lib/samagotchi/hooks/registry.rb +261 -0
- data/lib/samagotchi/hooks.rb +30 -0
- data/lib/samagotchi/host_registry.rb +315 -0
- data/lib/samagotchi/idle_client.rb +147 -0
- data/lib/samagotchi/idle_recap.rb +549 -0
- data/lib/samagotchi/idle_reminders.rb +101 -0
- data/lib/samagotchi/idle_scheduler.rb +76 -0
- data/lib/samagotchi/image_store.rb +393 -0
- data/lib/samagotchi/installed_gem.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +1017 -0
- data/lib/samagotchi/launch_mode.rb +34 -0
- data/lib/samagotchi/llm/backend.rb +28 -0
- data/lib/samagotchi/llm/chat_loop.rb +450 -0
- data/lib/samagotchi/llm/errors.rb +329 -0
- data/lib/samagotchi/llm/http.rb +412 -0
- data/lib/samagotchi/llm/model_result.rb +72 -0
- data/lib/samagotchi/llm/native_backend.rb +50 -0
- data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
- data/lib/samagotchi/llm/openai_chat.rb +403 -0
- data/lib/samagotchi/llm/usage.rb +79 -0
- data/lib/samagotchi/log.rb +200 -0
- data/lib/samagotchi/log_line.rb +127 -0
- data/lib/samagotchi/log_path.rb +31 -0
- data/lib/samagotchi/log_subscriber.rb +163 -0
- data/lib/samagotchi/memory_bundle/builder.rb +364 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
- data/lib/samagotchi/memory_bundle/installer.rb +528 -0
- data/lib/samagotchi/memory_bundle/listing.rb +72 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
- data/lib/samagotchi/memory_bundle/merger.rb +52 -0
- data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
- data/lib/samagotchi/memory_bundle/source.rb +153 -0
- data/lib/samagotchi/memory_bundle/status.rb +107 -0
- data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
- data/lib/samagotchi/memory_bundle.rb +17 -0
- data/lib/samagotchi/memory_paths.rb +101 -0
- data/lib/samagotchi/model_overlay.rb +53 -0
- data/lib/samagotchi/model_profile.rb +309 -0
- data/lib/samagotchi/muted_memories.rb +66 -0
- data/lib/samagotchi/note_command.rb +163 -0
- data/lib/samagotchi/output_formatter.rb +100 -0
- data/lib/samagotchi/owner_lock.rb +110 -0
- data/lib/samagotchi/pending_input_queue.rb +48 -0
- data/lib/samagotchi/plugin/api.rb +362 -0
- data/lib/samagotchi/plugin/context.rb +193 -0
- data/lib/samagotchi/plugin/loader.rb +126 -0
- data/lib/samagotchi/plugin/service.rb +117 -0
- data/lib/samagotchi/plugin/sessions.rb +150 -0
- data/lib/samagotchi/plugin/side_question.rb +60 -0
- data/lib/samagotchi/plugin/tool_result.rb +24 -0
- data/lib/samagotchi/project_scope.rb +25 -0
- data/lib/samagotchi/prompt.rb +119 -0
- data/lib/samagotchi/prompt_literal_guard.rb +70 -0
- data/lib/samagotchi/recap_store.rb +92 -0
- data/lib/samagotchi/reminder_store.rb +165 -0
- data/lib/samagotchi/self_report.rb +195 -0
- data/lib/samagotchi/send_command.rb +170 -0
- data/lib/samagotchi/served_model.rb +32 -0
- data/lib/samagotchi/session.rb +508 -0
- data/lib/samagotchi/session_commands.rb +527 -0
- data/lib/samagotchi/session_delete_command.rb +105 -0
- data/lib/samagotchi/session_manager.rb +1049 -0
- data/lib/samagotchi/session_metrics.rb +466 -0
- data/lib/samagotchi/session_observer.rb +117 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
- data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
- data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
- data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
- data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
- data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
- data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
- data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
- data/lib/samagotchi/terminal_ui/screen.rb +316 -0
- data/lib/samagotchi/terminal_ui/surface.rb +47 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
- data/lib/samagotchi/terminal_ui.rb +1992 -0
- data/lib/samagotchi/thinking_ticker.rb +110 -0
- data/lib/samagotchi/thought_stream_splitter.rb +149 -0
- data/lib/samagotchi/token_usage.rb +88 -0
- data/lib/samagotchi/tool_activity.rb +216 -0
- data/lib/samagotchi/tool_call_parser.rb +637 -0
- data/lib/samagotchi/tool_declarations.rb +561 -0
- data/lib/samagotchi/tool_runner.rb +211 -0
- data/lib/samagotchi/tools/args.rb +259 -0
- data/lib/samagotchi/tools/ask_user_question.rb +152 -0
- data/lib/samagotchi/tools/builtins.rb +122 -0
- data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
- data/lib/samagotchi/tools/delegate.rb +167 -0
- data/lib/samagotchi/tools/delegate_result.rb +53 -0
- data/lib/samagotchi/tools/delegate_wait.rb +153 -0
- data/lib/samagotchi/tools/edit.rb +155 -0
- data/lib/samagotchi/tools/execute.rb +214 -0
- data/lib/samagotchi/tools/list_reminders.rb +20 -0
- data/lib/samagotchi/tools/list_sessions.rb +74 -0
- data/lib/samagotchi/tools/memory.rb +256 -0
- data/lib/samagotchi/tools/output_guardrails.rb +93 -0
- data/lib/samagotchi/tools/peers.rb +18 -0
- data/lib/samagotchi/tools/read.rb +182 -0
- data/lib/samagotchi/tools/register_reminder.rb +53 -0
- data/lib/samagotchi/tools/registry.rb +60 -0
- data/lib/samagotchi/tools/send_note.rb +49 -0
- data/lib/samagotchi/tools/task_create.rb +29 -0
- data/lib/samagotchi/tools/task_get.rb +39 -0
- data/lib/samagotchi/tools/task_list.rb +43 -0
- data/lib/samagotchi/tools/task_runtime.rb +311 -0
- data/lib/samagotchi/tools/task_stop.rb +29 -0
- data/lib/samagotchi/tools/task_wait.rb +104 -0
- data/lib/samagotchi/tools/tool_path.rb +18 -0
- data/lib/samagotchi/tools/web_fetch.rb +163 -0
- data/lib/samagotchi/tools/write.rb +26 -0
- data/lib/samagotchi/turn_flow.rb +242 -0
- data/lib/samagotchi/turn_note.rb +76 -0
- data/lib/samagotchi/turn_tally.rb +101 -0
- data/lib/samagotchi/version.rb +7 -0
- data/lib/samagotchi/vision_context.rb +132 -0
- data/lib/samagotchi/vision_support.rb +109 -0
- data/lib/samagotchi/web/app.rb +1349 -0
- data/lib/samagotchi/web/markdown_renderer.rb +107 -0
- data/lib/samagotchi/web/message_parts.rb +169 -0
- data/lib/samagotchi/web/public/activity.js +100 -0
- data/lib/samagotchi/web/public/annotations.js +67 -0
- data/lib/samagotchi/web/public/app.js +2382 -0
- data/lib/samagotchi/web/public/card.js +74 -0
- data/lib/samagotchi/web/public/chat_view.js +360 -0
- data/lib/samagotchi/web/public/chunk_router.js +25 -0
- data/lib/samagotchi/web/public/command_complete.js +39 -0
- data/lib/samagotchi/web/public/composer_size.js +19 -0
- data/lib/samagotchi/web/public/copy.js +142 -0
- data/lib/samagotchi/web/public/ctx.js +35 -0
- data/lib/samagotchi/web/public/data.js +256 -0
- data/lib/samagotchi/web/public/format.js +232 -0
- data/lib/samagotchi/web/public/hold.js +78 -0
- data/lib/samagotchi/web/public/images.js +77 -0
- data/lib/samagotchi/web/public/index.html +568 -0
- data/lib/samagotchi/web/public/init_row.js +60 -0
- data/lib/samagotchi/web/public/model_pick.js +23 -0
- data/lib/samagotchi/web/public/question_card.js +100 -0
- data/lib/samagotchi/web/public/route.js +17 -0
- data/lib/samagotchi/web/public/scope.js +36 -0
- data/lib/samagotchi/web/public/scroll.js +24 -0
- data/lib/samagotchi/web/public/sentences.js +88 -0
- data/lib/samagotchi/web/public/sessions_list.js +60 -0
- data/lib/samagotchi/web/public/strip.js +25 -0
- data/lib/samagotchi/web/public/tally.js +37 -0
- data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
- data/lib/samagotchi/web/public/timing.js +185 -0
- data/lib/samagotchi/web/public/turn_events.js +209 -0
- data/lib/samagotchi/web/public/turn_model.js +204 -0
- data/lib/samagotchi/web/public/turn_view.js +587 -0
- data/lib/samagotchi/web/server.rb +183 -0
- data/lib/samagotchi/web/session_hub.rb +329 -0
- data/lib/samagotchi/web/session_summary.rb +85 -0
- data/lib/samagotchi/worker.rb +635 -0
- data/lib/samagotchi/worker_idle_exit.rb +87 -0
- data/lib/samagotchi.rb +12 -0
- metadata +374 -0
data/docs/plugins.md
ADDED
|
@@ -0,0 +1,819 @@
|
|
|
1
|
+
# Plugins
|
|
2
|
+
|
|
3
|
+
A bundle can ship one Ruby file, its **plugin**, that adds slash commands,
|
|
4
|
+
tools, hooks and background setup to every session. Its hooks are ordinary
|
|
5
|
+
bundle hooks ([hooks.md](hooks.md)). Its commands and tools work like chi's
|
|
6
|
+
own.
|
|
7
|
+
|
|
8
|
+
This is the first version of the plugin API. More of it comes later: see
|
|
9
|
+
[Not yet](#not-yet).
|
|
10
|
+
|
|
11
|
+
## A bundle with a plugin
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
my-bundle/
|
|
15
|
+
manifest.yml
|
|
16
|
+
plugin.rb
|
|
17
|
+
identity.md # optional, like any bundle's memories, hooks/ and guardrails/
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```yaml
|
|
21
|
+
# manifest.yml
|
|
22
|
+
name: my-bundle
|
|
23
|
+
version: 1.0.0
|
|
24
|
+
plugin:
|
|
25
|
+
file: plugin.rb
|
|
26
|
+
sha256: sha256:6a1a7022… # shasum -a 256 plugin.rb
|
|
27
|
+
requires_chi: ">= 0.1.28" # optional: a gem-style requirement (">= 0.1.28, < 0.2")
|
|
28
|
+
needs: [gh] # optional: outside commands it relies on (see memory.md#bundles-that-need-outside-commands)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The file must be a `.rb` name in the bundle's top directory. It defines a
|
|
32
|
+
class named like the file (`plugin.rb` → `Plugin`, `my_plugin.rb` →
|
|
33
|
+
`MyPlugin`), and that class has a `register(chi)` method:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
# plugin.rb
|
|
37
|
+
class Plugin
|
|
38
|
+
def initialize(settings) # optional: config.yml bundles: my-bundle:
|
|
39
|
+
@greeting = settings.fetch("greeting", "hello")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def register(chi)
|
|
43
|
+
chi.command "/hello", "greet, and say what the plugin sees" do |args, ctx|
|
|
44
|
+
who = args.empty? ? "there" : args
|
|
45
|
+
ctx.card(id: "hello", title: "#{@greeting}, #{who}",
|
|
46
|
+
body: "This session has **#{ctx.messages.size}** messages.",
|
|
47
|
+
actions: [{ label: "Again", command: "/hello again" }])
|
|
48
|
+
"#{@greeting}, #{who} (#{ctx.messages.size} messages)"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
chi.tool "echo_args", "Echo the arguments back.",
|
|
52
|
+
params: { text: { type: "string", description: "Any text to echo", required: true } },
|
|
53
|
+
label: "echoing" do |args, _ctx|
|
|
54
|
+
"echo: #{args["text"]}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
chi.on(:after_turn) do |_event, ctx|
|
|
58
|
+
File.open(File.join(ctx.data_dir, "turns.log"), "a") { |f| f.puts(ctx.session_id) }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`spec/fixtures/sample_plugin_bundle` is this bundle, plus the `/hello-slow`
|
|
65
|
+
of [anytime](#anytime-true) and the `save_note` tool (see `chi.tool` below). Install it with
|
|
66
|
+
`chi bundle install spec/fixtures/sample_plugin_bundle`.
|
|
67
|
+
|
|
68
|
+
Settings work as they do for hooks ([hooks.md](hooks.md#settings)). An
|
|
69
|
+
`initialize` that takes an argument gets the bundle's section of config.yml
|
|
70
|
+
`bundles:`, as one Hash with string keys.
|
|
71
|
+
|
|
72
|
+
## The API: `register(chi)`
|
|
73
|
+
|
|
74
|
+
### `chi.command(name, description, anytime: false) { |args, ctx| … }`
|
|
75
|
+
|
|
76
|
+
This adds a slash command. `name` is `/name` (a–z, 0–9, `_` and `-`).
|
|
77
|
+
`args` is the text after the name, stripped, or `""` when there is none. The
|
|
78
|
+
block returns the text to show (a String), or nil to show nothing. If the
|
|
79
|
+
block raises, the user sees `/name: <error>`.
|
|
80
|
+
|
|
81
|
+
A plugin command works in all three UIs:
|
|
82
|
+
|
|
83
|
+
- **REPL** (`chi --no-shared`): typed at the prompt, and Tab completes it.
|
|
84
|
+
- **Attached TUI** (the default `chi`): the worker's snapshot names the
|
|
85
|
+
session's commands, so the TUI sends `/hello` to the worker and Tab
|
|
86
|
+
completes it. It needs a worker started after the bundle was installed.
|
|
87
|
+
- **Web**: typed in the composer; a `/` opens a list of the session's
|
|
88
|
+
commands (arrows move, ⏎ or Tab picks, Esc closes). A card's action button
|
|
89
|
+
runs it too.
|
|
90
|
+
|
|
91
|
+
A line that is not a known command keeps its old meaning: in the terminal
|
|
92
|
+
UIs `/foo` goes to the model as a prompt; the web refuses it and names the
|
|
93
|
+
commands it knows.
|
|
94
|
+
|
|
95
|
+
#### `anytime: true`
|
|
96
|
+
|
|
97
|
+
A normal command waits for its turn: typed while a turn runs, it is refused
|
|
98
|
+
as busy (the REPL puts it back into the prompt). An `anytime: true` command
|
|
99
|
+
runs **at once, on its own thread, beside the running turn**, and the turn
|
|
100
|
+
goes on:
|
|
101
|
+
|
|
102
|
+
- In a worker (attached, web) it runs as soon as it arrives. It is never
|
|
103
|
+
queued, so it is never busy, mid-turn or at the turn's end. The UIs show
|
|
104
|
+
its line when it arrives (its `command_queued` says `anytime: true`),
|
|
105
|
+
then its cards and notices **as it shows them**, and its output (the
|
|
106
|
+
`command_ran`) when it finishes. So a card can say "working…" first and
|
|
107
|
+
be replaced by the result.
|
|
108
|
+
- In the REPL, typed while a turn runs, it starts on a thread. Its cards
|
|
109
|
+
print as it shows them, above the live region; its output prints there
|
|
110
|
+
too while the turn runs, or at the prompt once the turn has ended.
|
|
111
|
+
|
|
112
|
+
Between turns an anytime command runs like any other, except that its
|
|
113
|
+
cards print as it shows them rather than after its output.
|
|
114
|
+
|
|
115
|
+
An anytime command's cards and notices belong to the command, never to the
|
|
116
|
+
running turn: they are not rows of the turn's step, and their events carry
|
|
117
|
+
`anytime: true`.
|
|
118
|
+
|
|
119
|
+
Its block runs on another thread than the turn, so it must be thread-safe:
|
|
120
|
+
|
|
121
|
+
- Read the conversation through `ctx.messages`, a frozen copy. While a turn
|
|
122
|
+
runs, a worker's holds that turn so far; the REPL's is the conversation
|
|
123
|
+
**before** that turn (`ctx.messages_partial?` says so).
|
|
124
|
+
- Show things only through `ctx` (`ctx.card`, `ctx.notify`), and return
|
|
125
|
+
the text to show.
|
|
126
|
+
- Keep your own state (instance variables) behind a `Mutex` if two
|
|
127
|
+
commands, or a command and a hook, may touch it at once.
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
chi.command "/hello-slow", "greet after 2 s, even mid-turn", anytime: true do |args, ctx|
|
|
131
|
+
sleep 2
|
|
132
|
+
ctx.card(title: "slow hello, #{args.empty? ? "there" : args}",
|
|
133
|
+
body: "Ran beside the turn; it saw #{ctx.messages.size} messages.")
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
#### `/help`
|
|
139
|
+
|
|
140
|
+
`/help` (itself an anytime command) lists every command the session knows:
|
|
141
|
+
chi's own, each bundle's with the bundle's name, and the terminal UIs' own
|
|
142
|
+
(`/stats`, `/exit`, `/detach` …), marked `terminal only` or `attached only`.
|
|
143
|
+
It works in all three UIs.
|
|
144
|
+
|
|
145
|
+
### `chi.tool(name, description, params:, schema:, label:, preview:, targets:) { |args, ctx| … }`
|
|
146
|
+
|
|
147
|
+
This adds a tool the model can call. It is declared in the system prompt and
|
|
148
|
+
in the chat path's `tools:`, after chi's own tools.
|
|
149
|
+
|
|
150
|
+
- `name`: a–z first, then a–z, 0–9 and `_`, up to 48 characters.
|
|
151
|
+
- `params`: `{ name => property }`, where a property is a JSON Schema
|
|
152
|
+
property (`type:`, `description:`, `enum:`, `items:`, `properties:` …) plus
|
|
153
|
+
`required: true`. The type defaults to `"string"`.
|
|
154
|
+
- `schema`: instead of `params`, the parameters as one JSON Schema object
|
|
155
|
+
(`{ type: "object", properties: {…}, required: [...] }`), an MCP server's
|
|
156
|
+
`inputSchema` for example.
|
|
157
|
+
- `label`: the activity line's verb (`echoing`). The default is `calling tool`.
|
|
158
|
+
The web shows it in place of the tool's name, in its rows, step titles and
|
|
159
|
+
tally (the mcp bundle's `chrome: screenshot`).
|
|
160
|
+
- `preview`: `->(args) { "…" }` for the activity line's parameters. The
|
|
161
|
+
default is `key="value"` for each argument (a list or object as JSON). If
|
|
162
|
+
it raises, the default is shown. Both are saved with the call's result, so
|
|
163
|
+
a web page reloaded later shows the same row: the web server doesn't run
|
|
164
|
+
plugins.
|
|
165
|
+
- `targets`: `->(args) { { paths: [...], command: "…", cwd: "…" } }`, each
|
|
166
|
+
key optional, says what a call acts on, for [guardrails](#guardrails).
|
|
167
|
+
|
|
168
|
+
The block returns the result text. If it starts with `Error:`, it counts as
|
|
169
|
+
a failure. If it raises, the model gets `Error: <message>`. To return images
|
|
170
|
+
too, see [Returning images](#returning-images).
|
|
171
|
+
|
|
172
|
+
#### `args`
|
|
173
|
+
|
|
174
|
+
`args` is a frozen Hash with **string keys**: the arguments the model gave,
|
|
175
|
+
by name (`args["text"]`). The same Hash goes to `preview` and `targets`.
|
|
176
|
+
|
|
177
|
+
Each parser gives them structured: Gemma's native values (strings, numbers,
|
|
178
|
+
booleans, lists, nested objects), Qwen's `<parameter=…>` text, and the chat
|
|
179
|
+
path's JSON. The values are then **typed by the schema**, because Qwen's are
|
|
180
|
+
all text and a model may quote a number anyway:
|
|
181
|
+
|
|
182
|
+
| type | from |
|
|
183
|
+
|---|---|
|
|
184
|
+
| `integer` | `"3"` → `3`; `3.0` → `3` |
|
|
185
|
+
| `number` | `"2.5"` → `2.5` |
|
|
186
|
+
| `boolean` | `"true"`/`"false"`, any case |
|
|
187
|
+
| `array`, `object` | JSON text → a list or a Hash (string keys), its items or fields typed too |
|
|
188
|
+
| `string` | a number or boolean → its text |
|
|
189
|
+
|
|
190
|
+
A value that doesn't fit its type stays as it came (`"three"` for an
|
|
191
|
+
integer), so check it if it matters. Names the schema doesn't have pass
|
|
192
|
+
through. `"type": ["integer", "null"]` counts as `integer`.
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
chi.tool "save_note", "Save a note to a file.",
|
|
196
|
+
params: { path: { type: "string", description: "The file to write", required: true },
|
|
197
|
+
text: { type: "string", description: "The note", required: true },
|
|
198
|
+
format: { type: "string", enum: %w[plain markdown] },
|
|
199
|
+
meta: { type: "object", description: "Header fields",
|
|
200
|
+
properties: { tags: { type: "array", items: { type: "string" } },
|
|
201
|
+
priority: { type: "integer" } } } },
|
|
202
|
+
preview: ->(args) { "#{args["path"]} (#{args["text"].to_s.length} chars)" },
|
|
203
|
+
targets: ->(args) { { paths: [args["path"]] } } do |args, ctx|
|
|
204
|
+
File.write(File.expand_path(args["path"], ctx.cwd), args["text"])
|
|
205
|
+
"saved #{args["path"]}" # args["meta"]["priority"] is an Integer
|
|
206
|
+
end
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
#### Schemas on the native paths
|
|
210
|
+
|
|
211
|
+
The native prompts (Gemma, Qwen on llama.cpp) declare each parameter with a
|
|
212
|
+
type and a description only. A plugin tool's schema is **flattened** for
|
|
213
|
+
them, and what doesn't fit goes into the description in words:
|
|
214
|
+
|
|
215
|
+
- an `enum`: `How the note is written. One of: "plain", "markdown".`;
|
|
216
|
+
- an object's fields: `type: object`, and `A JSON object with tags (array),
|
|
217
|
+
priority (integer).`;
|
|
218
|
+
- a list's items: `A list of string values.`;
|
|
219
|
+
- `additionalProperties` and deeper nesting are dropped.
|
|
220
|
+
|
|
221
|
+
The chat path (`api: openai` hosts) gets the full schema, nesting and all.
|
|
222
|
+
Either way the call's `args` are typed by the full schema. Keep deeply
|
|
223
|
+
nested schemas for tools that mostly run on chat hosts.
|
|
224
|
+
|
|
225
|
+
#### Guardrails
|
|
226
|
+
|
|
227
|
+
Guardrail rules keyed by a tool's name apply to plugin tools, as they do to
|
|
228
|
+
chi's own. Path and command rules need to know what a call acts on, and that
|
|
229
|
+
is what `targets:` says:
|
|
230
|
+
|
|
231
|
+
- `paths:`: files the call reads or writes, absolute or relative to `cwd:`
|
|
232
|
+
(else the session's directory). `path:` globs, `outside_repo` and the
|
|
233
|
+
protected paths (chi's config, …) match them.
|
|
234
|
+
- `command:`: a shell command the call runs; `command:` rules match it.
|
|
235
|
+
- `cwd:`: where it runs, for the repo root and relative paths.
|
|
236
|
+
|
|
237
|
+
A tool without `targets:` is matched by its name only. A `targets:` that
|
|
238
|
+
raises counts as nothing (it is logged). See [guardrails.md](guardrails.md).
|
|
239
|
+
|
|
240
|
+
#### Returning images
|
|
241
|
+
|
|
242
|
+
A tool can hand the model images too. The block returns a
|
|
243
|
+
`Samagotchi::Plugin::ToolResult`, which is the text plus `images:`:
|
|
244
|
+
|
|
245
|
+
```ruby
|
|
246
|
+
chi.tool "screenshot", "Take a screenshot of the page." do |_args, ctx|
|
|
247
|
+
path = take_screenshot(ctx) # a PNG file
|
|
248
|
+
Samagotchi::Plugin::ToolResult.new("Took a screenshot.", images: [{ path: path }])
|
|
249
|
+
end
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
- An image is `{ path: "/abs/file.png" }` or `{ bytes: png, name: "shot.png" }`
|
|
253
|
+
(raw bytes, not base64). png, jpeg, gif and webp are sent; bmp, tiff and
|
|
254
|
+
heic are converted if ImageMagick or sips is there. A large image is scaled
|
|
255
|
+
down (`image.max_side`, `image.max_bytes`), like one the model `read`s.
|
|
256
|
+
- Each image is stored with the session (`images/`) and goes to the model
|
|
257
|
+
after the tool's text, the same way a `read` of an image file does. The
|
|
258
|
+
web tool row and the terminal show it.
|
|
259
|
+
- At most **4** images per result are attached; each one past that gets a
|
|
260
|
+
line (`shot5.png is not attached: at most 4 images per tool result`).
|
|
261
|
+
- An entry that isn't `{path:}` or `{bytes:}`, or isn't an image, becomes an
|
|
262
|
+
`Error: …` line for that image. The text and the other images still go.
|
|
263
|
+
- A model that can't see images (`vision: false`, or known text-only) gets a
|
|
264
|
+
line instead of each image: `shot.png is an image; this model can't see
|
|
265
|
+
images`. Say what the image shows in the text, if it matters then.
|
|
266
|
+
- `ToolResult` is a String, so hooks, the log and the activity line see the
|
|
267
|
+
text as before.
|
|
268
|
+
|
|
269
|
+
#### When the tools change
|
|
270
|
+
|
|
271
|
+
The system prompt is built once, after the plugins load, so the server can
|
|
272
|
+
keep its cached prompt prefix. A plugin whose tools are known only later
|
|
273
|
+
declares them with [`chi.replace_tools`](#chireplace_tools--set--),
|
|
274
|
+
which rebuilds the prompt for the next turn when the set changed; that
|
|
275
|
+
costs the cache once. `chi.tools_changed!` alone says the tools changed
|
|
276
|
+
without replacing any.
|
|
277
|
+
|
|
278
|
+
### `chi.replace_tools { |set| … }`
|
|
279
|
+
|
|
280
|
+
The plugin's whole tool set, after `register` (from an init task, a
|
|
281
|
+
command, a tool call). The block declares tools on `set` with
|
|
282
|
+
`set.tool(...)`, which takes `chi.tool`'s arguments:
|
|
283
|
+
|
|
284
|
+
```ruby
|
|
285
|
+
@chi.replace_tools do |set|
|
|
286
|
+
listed.each do |t|
|
|
287
|
+
set.tool("idx_#{t[:name]}", t[:description], params: t[:params]) { |args, ctx| query(t, args) }
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
- The set is **staged**: the session applies it at the start of the next
|
|
293
|
+
turn, before that turn's system prompt, on the turn's own thread. So it is
|
|
294
|
+
safe from any thread, and a turn never sees half a set.
|
|
295
|
+
- The plugin's tools not in the set go, new ones are added, and one whose
|
|
296
|
+
schema or label changed is registered again. Unchanged ones stay as they
|
|
297
|
+
are. If anything changed, the prompt is built again.
|
|
298
|
+
- A later set replaces an earlier staged one.
|
|
299
|
+
- A name that another bundle (or chi) has is left out, with a notice. A bad
|
|
300
|
+
tool raises `ArgumentError` at once, as `chi.tool` does, and nothing is
|
|
301
|
+
staged.
|
|
302
|
+
- Inside `register`, use `chi.tool`: `replace_tools` raises there.
|
|
303
|
+
|
|
304
|
+
### `chi.on(event, priority: 100) { |event, ctx| … }`
|
|
305
|
+
|
|
306
|
+
This is a bundle hook, the same as a `hooks/*.rb` file. See
|
|
307
|
+
[hooks.md](hooks.md#hook-events) for the events and for what `event[:notify]`,
|
|
308
|
+
`event[:ask_user]` and `event[:stop_turn]` do. Its label is
|
|
309
|
+
`plugin.rb (bundle my-bundle)`. The block may take only the event. If it
|
|
310
|
+
raises, the error is logged and the hook is skipped.
|
|
311
|
+
|
|
312
|
+
### `chi.service(name, eager: false) { |svc| … }`
|
|
313
|
+
|
|
314
|
+
A long-lived thing the plugin keeps for the session: a server process, a
|
|
315
|
+
connection. The block starts it, and what it returns is the service's value.
|
|
316
|
+
Inside the block, `svc.on_stop { … }` says how to stop it.
|
|
317
|
+
|
|
318
|
+
```ruby
|
|
319
|
+
def register(chi)
|
|
320
|
+
server = chi.service(:index, eager: true) do |svc|
|
|
321
|
+
io = IO.popen(["my-indexer", "--stdio"], "r+")
|
|
322
|
+
svc.on_stop { io.close }
|
|
323
|
+
io
|
|
324
|
+
end
|
|
325
|
+
chi.tool("index_query", "…", params: { q: { type: "string", required: true } }) do |args, _ctx|
|
|
326
|
+
server.value.puts(args["q"])
|
|
327
|
+
server.value.gets
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
- `chi.service` returns the service. `service.value` starts it on first use
|
|
333
|
+
and returns what the block returned; later calls return the same value.
|
|
334
|
+
With `eager: true` it starts at once, inside `register`, so a raise there
|
|
335
|
+
fails the plugin's load unless the plugin rescues it.
|
|
336
|
+
- A block that raises leaves the service unstarted: its `on_stop` callbacks
|
|
337
|
+
so far run, and the next `value` tries again.
|
|
338
|
+
- `service.running?`, `service.state` (`:idle`, `:running`, `:stopped`) and
|
|
339
|
+
`service.stop`.
|
|
340
|
+
- The services stop when chi leaves: the REPL exits, or the session's
|
|
341
|
+
worker exits (an idle exit, `/exit`, a crash, TERM). See
|
|
342
|
+
[Shutdown](#shutdown). A stopped service never starts again; `value`
|
|
343
|
+
raises `Samagotchi::Plugin::Service::Stopped`.
|
|
344
|
+
- A plugin whose load fails after it started services has them stopped.
|
|
345
|
+
- `kill -9` runs nothing: a child process is orphaned then. Most stdio
|
|
346
|
+
servers leave when their stdin closes, which it does as chi's process
|
|
347
|
+
ends.
|
|
348
|
+
|
|
349
|
+
### `chi.init(label, provides_tools: false, quiet: false, timeout: nil, failed: nil) { |ctx| … }`
|
|
350
|
+
|
|
351
|
+
Slow setup that must not hold chi's start: downloading a model, indexing a
|
|
352
|
+
repo, logging in, starting a server for the first time. `register` itself
|
|
353
|
+
should return at once (everything in it runs before the session's UI is
|
|
354
|
+
up), so it hands the slow part to `chi.init`. The block runs **on its own
|
|
355
|
+
thread** once the session can show it: in a worker right after its Bridge
|
|
356
|
+
is up (so a new web chat opens at once), in the REPL at its first prompt.
|
|
357
|
+
|
|
358
|
+
```ruby
|
|
359
|
+
class Plugin
|
|
360
|
+
def initialize(settings = {})
|
|
361
|
+
@model = settings["model"] || "small-embedder"
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def register(chi)
|
|
365
|
+
@chi = chi
|
|
366
|
+
chi.init("Downloading #{@model}", provides_tools: true, timeout: 120) do |ctx|
|
|
367
|
+
path = download(@model, into: ctx.data_dir) { ctx.cancelled? } # stop when chi shuts down
|
|
368
|
+
@chi.replace_tools do |set|
|
|
369
|
+
set.tool("embed_search", "Search the repo by meaning.",
|
|
370
|
+
params: { query: { type: "string", required: true } }) { |args, _ctx| search(path, args["query"]) }
|
|
371
|
+
end
|
|
372
|
+
"#{@model} ready"
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
- **What the UIs show.** Every UI shows a running task (web: a spinner line
|
|
379
|
+
over the composer, `mcp · Starting MCP server chrome (first run, saving
|
|
380
|
+
its tools)…`; the attached TUI: its activity row; the REPL: a line) and a
|
|
381
|
+
line when it is done: `✓` and what the block returned, a short summary
|
|
382
|
+
(`chrome ready, 3 tools`), or `<label>: done` for anything else. A UI that
|
|
383
|
+
joins while it runs sees it too.
|
|
384
|
+
- **A raise** is a warn card. Its title is `failed:`, short (`chrome didn't
|
|
385
|
+
start`; the card shows the bundle beside it), and its body the message;
|
|
386
|
+
without `failed:` the title is `setup failed` and the body
|
|
387
|
+
`<label>: <message>`.
|
|
388
|
+
- **`provides_tools: true`**: the task brings tools (with
|
|
389
|
+
`chi.replace_tools`). A turn sent while it runs starts at once (the user's
|
|
390
|
+
message shows), then waits for it **before its first model request**, so
|
|
391
|
+
the model sees the tools; the UIs keep showing the task meanwhile. The
|
|
392
|
+
wait lasts at most `timeout` seconds from the task's start (default 60).
|
|
393
|
+
A Ctrl-C cancels the turn and ends its wait, but not the task, whose
|
|
394
|
+
tools come with the next turn. A task that fails or ends late leaves the
|
|
395
|
+
turn without its tools. Tasks without `provides_tools` never hold a turn.
|
|
396
|
+
- **`quiet: true`**: nothing is shown unless it fails (a background
|
|
397
|
+
refresh).
|
|
398
|
+
- **`ctx.cancelled?`** in the block says chi is shutting down: the block
|
|
399
|
+
should stop then. It is the task's own, not the running turn's.
|
|
400
|
+
- `ctx.notify` and `ctx.card` from the block show between turns, even while
|
|
401
|
+
a turn runs.
|
|
402
|
+
- Each task runs once per session start. A `-p … --non-interactive` run
|
|
403
|
+
starts them with its turn and shows nothing but the answer.
|
|
404
|
+
|
|
405
|
+
### `chi.ctx`
|
|
406
|
+
|
|
407
|
+
The plugin's context (the `ctx` its handlers get), for `register` itself:
|
|
408
|
+
its settings, log and data_dir, for example. A `ctx.notify` or `ctx.card`
|
|
409
|
+
while chi starts (inside `register`) is shown once the session's UI can show
|
|
410
|
+
it (a worker's Bridge is up, the REPL's first prompt), after the plugins'
|
|
411
|
+
load warnings; a UI that joins later still gets it.
|
|
412
|
+
|
|
413
|
+
### Names
|
|
414
|
+
|
|
415
|
+
A command or tool name that the session already has is a **load error**.
|
|
416
|
+
That includes a chi built-in and another bundle's name. Bundles load in
|
|
417
|
+
name order, so the first bundle keeps the name.
|
|
418
|
+
|
|
419
|
+
## The context: `ctx`
|
|
420
|
+
|
|
421
|
+
Every handler gets the plugin's context. There is one per plugin for the
|
|
422
|
+
session's life, and each read gives the session as it is now.
|
|
423
|
+
|
|
424
|
+
| | |
|
|
425
|
+
|---|---|
|
|
426
|
+
| `ctx.session_id` | the session's id (nil before there is one) |
|
|
427
|
+
| `ctx.cwd` | the session's working directory |
|
|
428
|
+
| `ctx.repo_root` | the git checkout holding `cwd`, or nil |
|
|
429
|
+
| `ctx.settings` | the bundle's settings, frozen |
|
|
430
|
+
| `ctx.data_dir` | `$XDG_STATE_HOME/samagotchi/plugins/<bundle>/`, created on first use |
|
|
431
|
+
| `ctx.log` | `ctx.log.info(:event, key: value)`: debug-log records tagged `plugins`, with `bundle=<bundle>` |
|
|
432
|
+
| `ctx.messages` | the conversation, as a frozen copy, without the system prompt. While a turn runs, a session worker's (attached, web) adds that turn so far: its prompt, the model's text and the lines merged into it (no tool calls or thinking); the REPL's is the conversation before that turn |
|
|
433
|
+
| `ctx.messages_partial?` | whether `ctx.messages` leaves out a running turn (the REPL mid-turn), so a plugin can say what its answer is about |
|
|
434
|
+
| `ctx.notify(text, level: :info)` | one line to the user, like a hook's `event[:notify]`, labelled by the bundle (`my-bundle> …`). Every UI shows it, during a turn (a tool, a hook) or between turns (a command) |
|
|
435
|
+
| `ctx.card(title:, body: "", actions: [], level: :info, id: nil)` | a card in every UI, returning its id: see [Cards](#cards) |
|
|
436
|
+
| `ctx.ask_user(question:, options:, header: nil, allow_freeform: false)` | a question, like a hook's `event[:ask_user]` |
|
|
437
|
+
| `ctx.cancelled?` | whether the running turn was cancelled (a long tool should stop) |
|
|
438
|
+
| `ctx.ask_model(messages:, prompt:, …)` | a side answer from the session's model: see [Side answers](#side-answers-ctxask_model) |
|
|
439
|
+
| `ctx.sessions` | fork, send to and read other sessions: see [Other sessions](#other-sessions-ctxsessions) |
|
|
440
|
+
|
|
441
|
+
The Engine itself is never handed to a plugin.
|
|
442
|
+
|
|
443
|
+
## Cards
|
|
444
|
+
|
|
445
|
+
A card is a small framed message with buttons: a title, a body and
|
|
446
|
+
actions. Core draws it in all three UIs; a plugin has no JS or CSS of its
|
|
447
|
+
own.
|
|
448
|
+
|
|
449
|
+
```ruby
|
|
450
|
+
id = ctx.card(title: "Build finished", body: "**3** warnings in `lib/`",
|
|
451
|
+
actions: [{ label: "Show them", command: "/warnings" }],
|
|
452
|
+
level: :warn)
|
|
453
|
+
ctx.card(id: id, title: "Build finished", body: "no warnings left") # replaces it
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
- `title:` is required. `body:` is markdown in the web. The terminal shows
|
|
457
|
+
it wrapped, with the markdown cheaply stripped: `**bold**` and `__x__`
|
|
458
|
+
lose their marks, backticks and code fences go, headings lose their `#`s,
|
|
459
|
+
and lists stay as they are.
|
|
460
|
+
- `actions:` are up to 6 `{label:, command:}`. A command is a line the
|
|
461
|
+
session runs as if the user typed it: `/hello again`, `/model x`, a
|
|
462
|
+
plugin's own command. The web shows a button; the terminal shows
|
|
463
|
+
`→ /hello again`, to type.
|
|
464
|
+
- `level:` is `:info` or `:warn` (the warning colour).
|
|
465
|
+
- `id:` names an earlier card to replace. Without one a new id is made. The
|
|
466
|
+
web updates the card in place; the terminal prints it again, marked
|
|
467
|
+
`(updated)`. A card that waits for something (a model's answer) shows
|
|
468
|
+
first, then is replaced.
|
|
469
|
+
- A bad card (no title, a bad level or action) raises `ArgumentError`.
|
|
470
|
+
|
|
471
|
+
Where it shows:
|
|
472
|
+
|
|
473
|
+
| | during a turn (a tool, a hook) | between turns (a command) |
|
|
474
|
+
|---|---|---|
|
|
475
|
+
| REPL | where it happens, above the live region | at the prompt, after the command's output |
|
|
476
|
+
| attached TUI | where it happens | as it arrives |
|
|
477
|
+
| web | a row of the running step | between the turns |
|
|
478
|
+
|
|
479
|
+
In the web, a turn's block collapses when the turn ends. A `:warn` card, and
|
|
480
|
+
any card of a turn that ended without completing (cancelled, failed, the
|
|
481
|
+
worker gone), then moves out of the block, after the turn's end line, so it
|
|
482
|
+
stays in sight; a reload puts it in the same place. An `:info` card of a
|
|
483
|
+
completed turn stays in its step.
|
|
484
|
+
|
|
485
|
+
An [anytime command](#anytime-true)'s cards show as it shows them, after its
|
|
486
|
+
line, in every UI, whether a turn runs or not.
|
|
487
|
+
|
|
488
|
+
A worker keeps its last 20 cards and hook notices, for a UI that joins
|
|
489
|
+
later. The web shows them where they arrived after a reload (a turn's
|
|
490
|
+
notice as a row of its step, above the call it came before); the attached
|
|
491
|
+
TUI shows the cards and between-turns notices since the last turn when it
|
|
492
|
+
joins. They live as long as the worker: an idle exit or a restart forgets
|
|
493
|
+
them, and they are not saved with the session.
|
|
494
|
+
|
|
495
|
+
The event is `{type: :card, id:, source:, title:, body:, level:, actions:,
|
|
496
|
+
in_turn:}` (`source` is the bundle), logged as `card` with its source, id and
|
|
497
|
+
title.
|
|
498
|
+
|
|
499
|
+
## Side answers: `ctx.ask_model`
|
|
500
|
+
|
|
501
|
+
```ruby
|
|
502
|
+
answer = ctx.ask_model(messages: ctx.messages, prompt: "what did we decide about the cache?",
|
|
503
|
+
system: "Answer briefly.", timeout: 120, max_tokens: 400)
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
One request to the session's current model on its host, resolved as a turn
|
|
507
|
+
resolves them (a `/model` switch counts). It has **no tools**, thinking is
|
|
508
|
+
off, and it writes nothing: the conversation, the saved session and the
|
|
509
|
+
next turn never see it, and no hook fires. It returns the answer text.
|
|
510
|
+
|
|
511
|
+
- `messages:` go as a transcript, filtered like the idle recap's: no system
|
|
512
|
+
prompt, tool calls, tool output or thinking, and an image is a line
|
|
513
|
+
naming it (`[image shot.png]`). A long one keeps its tail (32,000
|
|
514
|
+
characters). The transcript and `prompt:` go in one user message.
|
|
515
|
+
- `system:` has a short default ("answer about the conversation below,
|
|
516
|
+
briefly, and don't continue its task").
|
|
517
|
+
- `max_tokens:` defaults to 1024. An answer cut off by it ends with `…`.
|
|
518
|
+
- `cancel:` takes a `Samagotchi::CancellationController`; cancelling it
|
|
519
|
+
aborts the request.
|
|
520
|
+
- It blocks until the answer comes, so call it from an anytime command or a
|
|
521
|
+
thread of your own. A local server that runs one request at a time
|
|
522
|
+
(llama.cpp with one slot) answers it after a running turn's current
|
|
523
|
+
request.
|
|
524
|
+
- It raises `Samagotchi::Plugin::ModelError` when the request fails or
|
|
525
|
+
times out (the message says why), and `Samagotchi::Plugin::ModelCancelled`
|
|
526
|
+
when cancelled.
|
|
527
|
+
|
|
528
|
+
It goes through the host's OpenAI API (`/v1/chat/completions`), which
|
|
529
|
+
llama.cpp serves on a native host too.
|
|
530
|
+
|
|
531
|
+
## Other sessions: `ctx.sessions`
|
|
532
|
+
|
|
533
|
+
```ruby
|
|
534
|
+
id = ctx.sessions.fork(messages: ctx.messages + [{ role: "user", content: q }, { role: "model", content: a }],
|
|
535
|
+
title: "btw: #{q}")
|
|
536
|
+
ctx.sessions.send(id, "go on from here")
|
|
537
|
+
ctx.sessions.read(id) # => {id:, title:, status:, parent_id:, running:, messages:}
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
- `fork(messages:, title: nil, prompt: nil)` starts a child session in its
|
|
541
|
+
own worker, from these messages, in this session's folder and model. It
|
|
542
|
+
shows in every list as a child of this one (`↳ parent`), and the user can
|
|
543
|
+
attach to it. It returns the child's id.
|
|
544
|
+
- Without `prompt:` the child waits idle. With one, it runs it as its first
|
|
545
|
+
turn, and counts against `session.max_children` (like `delegate`).
|
|
546
|
+
- `title:` is what the lists show until its first turn (else the prompt, or
|
|
547
|
+
the first user message).
|
|
548
|
+
- An image a message names is copied into the child. One whose file is
|
|
549
|
+
gone is dropped, with `[image x.png was not copied]` in its message.
|
|
550
|
+
- `send(id, text)` sends a user message to a session (an id or a unique
|
|
551
|
+
prefix); it runs as a turn, and a stopped session is woken. It waits up to
|
|
552
|
+
5 s for the session's worker, so call it from an anytime command or a
|
|
553
|
+
thread of your own, never from a tool or hook of a running turn. A
|
|
554
|
+
session open in a chi REPL can't take it.
|
|
555
|
+
- `read(id)` gives a session now: from its worker when one runs (with a
|
|
556
|
+
running turn so far, `running: true`), else as saved. `messages` has no
|
|
557
|
+
system prompt.
|
|
558
|
+
- Each raises `Samagotchi::Plugin::Sessions::Error` with the reason.
|
|
559
|
+
|
|
560
|
+
## The btw bundle
|
|
561
|
+
|
|
562
|
+
`chi bundle install btw` installs the bundle shipped with chi. It is written
|
|
563
|
+
only against this API (`lib/samagotchi/bundles/btw/plugin.rb`).
|
|
564
|
+
|
|
565
|
+
- `/btw <question>` asks the session's model a side question about the
|
|
566
|
+
conversation, even while a turn runs. A card `btw: <question>` shows
|
|
567
|
+
"thinking…" at once, and the same card then shows the answer. Nothing else
|
|
568
|
+
sees the answer.
|
|
569
|
+
- The card's **Keep as session** runs `/btw keep <id>`. It forks the
|
|
570
|
+
conversation, the question and the answer into an idle child session, and
|
|
571
|
+
shows a card `kept as <id>`.
|
|
572
|
+
- The last 10 answers can be kept, while the session's worker (or REPL)
|
|
573
|
+
runs. After that, or after a restart, `keep` says expired.
|
|
574
|
+
- In the REPL, a question asked during a turn is about the conversation
|
|
575
|
+
before that turn, and the card says so. In a worker it includes the turn
|
|
576
|
+
so far.
|
|
577
|
+
- Settings: `bundles: btw: {max_tokens: 1024, timeout: 120}`.
|
|
578
|
+
- It ships no memory, so it adds no line to the prompt's memory index. Its
|
|
579
|
+
0.1.0 shipped `btw.md` as one, and `chi bundle upgrade btw` leaves that file
|
|
580
|
+
(and its index line) behind. Drop it with `chi bundle uninstall btw`, then
|
|
581
|
+
`chi bundle install btw`. After an upgrade already ran, delete
|
|
582
|
+
`~/.config/samagotchi/memories/btw.md` and its `**btw**` line in `index.md`
|
|
583
|
+
there by hand.
|
|
584
|
+
|
|
585
|
+
## The mcp bundle
|
|
586
|
+
|
|
587
|
+
`chi bundle install mcp` installs the bundle shipped with chi. It is written
|
|
588
|
+
only against this API (`lib/samagotchi/bundles/mcp/plugin.rb`), and adds
|
|
589
|
+
tools from [MCP](https://modelcontextprotocol.io) servers. It has no memory
|
|
590
|
+
file, so it costs the prompt nothing but its tools. Stdio servers only, for
|
|
591
|
+
now.
|
|
592
|
+
|
|
593
|
+
```yaml
|
|
594
|
+
# config.yml
|
|
595
|
+
bundles:
|
|
596
|
+
mcp:
|
|
597
|
+
timeout: 60 # seconds per tool call (default 60)
|
|
598
|
+
startup_timeout: 10 # seconds for initialize and tools/list (default 10)
|
|
599
|
+
servers:
|
|
600
|
+
everything:
|
|
601
|
+
command: [npx, -y, "@modelcontextprotocol/server-everything"]
|
|
602
|
+
files:
|
|
603
|
+
command: [npx, -y, "@modelcontextprotocol/server-filesystem", ~/scratch]
|
|
604
|
+
env: {NODE_OPTIONS: "--no-warnings"} # added to chi's environment
|
|
605
|
+
cwd: ~/scratch # default: where chi runs
|
|
606
|
+
tools: [read_*, list_directory] # optional: only these (globs)
|
|
607
|
+
timeout: 120 # optional: this server's per-call timeout
|
|
608
|
+
chrome:
|
|
609
|
+
command: [npx, -y, "chrome-devtools-mcp@latest", --slim, --headless]
|
|
610
|
+
attach_image_paths: true # the default; false leaves a path as text
|
|
611
|
+
start: lazy # the default; eager: start it with every session
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
- **Start: from a cache, on the first call.** A server's `tools/list` is
|
|
615
|
+
saved in the bundle's data dir (`$XDG_STATE_HOME/samagotchi/plugins/mcp/
|
|
616
|
+
tools-<server>.json`), keyed by a digest of its `command`, `env` (names
|
|
617
|
+
and values: only the digest is stored) and `cwd`. A session with a saved
|
|
618
|
+
list registers the tools at once and **doesn't start the server**: the
|
|
619
|
+
first call of one of its tools does (the call's row shows the wait). So a
|
|
620
|
+
session that never uses MCP spawns nothing, and a new chat opens without
|
|
621
|
+
waiting for `npx`. If the live list differs from the saved one, the saved
|
|
622
|
+
one is replaced, and so are the tools, from the next turn on.
|
|
623
|
+
- **The first run** (no saved list, or the config changed) starts the
|
|
624
|
+
server in an [init task](#chiinitlabel-provides_tools-false-quiet-false-timeout-nil--ctx--):
|
|
625
|
+
every UI shows `Starting MCP server x (first run, saving its tools)`, and
|
|
626
|
+
a turn sent meanwhile waits for its tools. A server that doesn't start,
|
|
627
|
+
answer or list its tools within `startup_timeout` (each step) is a warn
|
|
628
|
+
card, `…: failed`, and its tools are left out. The rest of chi works as
|
|
629
|
+
usual.
|
|
630
|
+
- **Freshness.** A saved list older than a day is still used, and a quiet
|
|
631
|
+
background task lists the tools again with a server of its own (then
|
|
632
|
+
stops it), saves them, and replaces the tools if they changed. One worker
|
|
633
|
+
does it at a time.
|
|
634
|
+
- **A cached server that doesn't start** (the command is gone, it crashes)
|
|
635
|
+
fails that call with `Error: MCP server x didn't start: …` and one notice;
|
|
636
|
+
later calls answer the same at once, and its tools are left out from the
|
|
637
|
+
next turn. The saved list stays: the next session tries again.
|
|
638
|
+
- **`start: eager`** on a server starts it with every session (in an init
|
|
639
|
+
task, after the Bridge is up), for a server whose start does something
|
|
640
|
+
you want at once.
|
|
641
|
+
- **`npx -y …@latest` checks the npm registry on every start** (~3.4 s for
|
|
642
|
+
`chrome-devtools-mcp`, against ~0.9 s for the installed binary). The cache
|
|
643
|
+
hides it from new chats, but not from the first call. For a faster first
|
|
644
|
+
call, install the server once and run it directly:
|
|
645
|
+
|
|
646
|
+
```yaml
|
|
647
|
+
chrome:
|
|
648
|
+
command: [chrome-devtools-mcp, --slim, --headless] # after npm i -g chrome-devtools-mcp
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
- **Tools.** Each tool is the model's as `mcp_<server>_<tool>`, lower case,
|
|
652
|
+
with anything but a-z, 0-9 and `_` made `_`, cut at 48 characters. A name
|
|
653
|
+
that clashes is left out, with a notice. The tool's `inputSchema` is its
|
|
654
|
+
schema (flattened on the native paths, see
|
|
655
|
+
[Schemas on the native paths](#schemas-on-the-native-paths)); its label is
|
|
656
|
+
`<server>: <tool>` and its preview the arguments, short.
|
|
657
|
+
- **Calls.** A call is `tools/call`. The text blocks of the answer are joined;
|
|
658
|
+
audio or a resource without text is a short placeholder
|
|
659
|
+
(`[audio: audio/wav]`). `isError` makes it `Error: …`. A call that takes
|
|
660
|
+
longer than the timeout is an `Error:`, and a cancelled turn stops the
|
|
661
|
+
wait; both send `notifications/cancelled` to the server.
|
|
662
|
+
- **Images.** An `image` block goes to the model as a picture
|
|
663
|
+
([Returning images](#returning-images): at most 4 per call, a line
|
|
664
|
+
instead when the model can't see images). Its place in the text is a line,
|
|
665
|
+
`[image 1: image/png, attached]`, so the model knows the order. A text
|
|
666
|
+
block that is **only the absolute path of an image file** is attached too
|
|
667
|
+
(`[image 1: screenshot.png, attached]` after the path), but only when the
|
|
668
|
+
file is under the system temp dir or the server's `cwd`: a server's text
|
|
669
|
+
can't pull in any image on disk. `chrome-devtools-mcp --slim` answers
|
|
670
|
+
`screenshot` that way; without `--slim`, `take_screenshot` returns an image
|
|
671
|
+
block. `attach_image_paths: false` on a server leaves such paths as text.
|
|
672
|
+
- **A server that exits** fails its calls with `Error: MCP server x is not
|
|
673
|
+
running (…)`, and there is one notice. It is not restarted until chi
|
|
674
|
+
restarts (a new session, or the worker's next start).
|
|
675
|
+
- **Stop.** The servers stop with chi ([Shutdown](#shutdown)): stdin is
|
|
676
|
+
closed, then TERM and KILL go to the server's process group.
|
|
677
|
+
- **`/mcp`** (anytime) shows a card with the servers, their state (cached
|
|
678
|
+
(not started), running with its pid, failed, stopped) and their tools.
|
|
679
|
+
- The server's stderr goes to the debug log (`plugins` records, bundle=mcp).
|
|
680
|
+
- **Guardrails.** A rule's `tool:` can be a glob, so one rule covers every
|
|
681
|
+
MCP tool:
|
|
682
|
+
|
|
683
|
+
```yaml
|
|
684
|
+
guardrails:
|
|
685
|
+
rules:
|
|
686
|
+
- id: mcp-ask
|
|
687
|
+
tool: "mcp_*"
|
|
688
|
+
verdict: ask
|
|
689
|
+
reason: an MCP server's tool
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
An MCP tool has no `targets:`, so the question shows its arguments,
|
|
693
|
+
under the tool's label as its row shows it (`everything: get_sum: a=20
|
|
694
|
+
b=22`; any plugin tool with a label is asked about by it), and "Allow this call for the
|
|
695
|
+
session" (or in this repo) allows that tool with those arguments only.
|
|
696
|
+
|
|
697
|
+
## The loop-guard bundle
|
|
698
|
+
|
|
699
|
+
`chi bundle install loop-guard` installs the bundle shipped with chi. It is
|
|
700
|
+
written only against this API (`lib/samagotchi/bundles/loop-guard/plugin.rb`),
|
|
701
|
+
with `chi.on` hooks, and has no memory file.
|
|
702
|
+
|
|
703
|
+
A local model can run the same tool call again and again in one turn: each
|
|
704
|
+
step's thinking starts over, so it never notices it already tried. (A real
|
|
705
|
+
one ran `find . -name 'config.yml'` ten times, getting nothing each time.)
|
|
706
|
+
loop-guard breaks that:
|
|
707
|
+
|
|
708
|
+
- A call is keyed by its tool and its arguments (whitespace collapsed), and
|
|
709
|
+
its result by a hash of the output. When a call has already returned the
|
|
710
|
+
same result `deny_after` times this turn (default 2), the next identical
|
|
711
|
+
call is **denied** with advice, so the 3rd one is caught:
|
|
712
|
+
|
|
713
|
+
```
|
|
714
|
+
[execute] Error: denied by guardrail (bundle loop-guard): repeated call. The user was not asked. You already ran this exact call 2 times this turn and it returned the same result each time (exit: 0 (no output)). Don't repeat it. Try a different approach, or tell the user what you're stuck on.
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
The user sees one line per call per turn: `loop-guard> loop: execute find
|
|
718
|
+
. -name 'config.yml' 2>/dev/null repeated, denied`.
|
|
719
|
+
- At the `stop_after`-th deny in a turn (default 4) the turn is **stopped**
|
|
720
|
+
(core's own "stopped" notice), and a card lists the repeated calls, so the
|
|
721
|
+
user can say what to try instead.
|
|
722
|
+
- A denied call has no result: a deny (loop-guard's, known-names', a rule's)
|
|
723
|
+
never counts as the call's result, so the deny sticks.
|
|
724
|
+
- The counts are per turn, and a count is the turn's total, not a run of
|
|
725
|
+
consecutive repeats: the loop usually has other calls in between. A new
|
|
726
|
+
turn (a prompt, a continue, a reminder) starts from zero, since a new user
|
|
727
|
+
message can make an old call right again. A steering message merged into
|
|
728
|
+
a running turn doesn't reset them.
|
|
729
|
+
- The polling tools, where repeating is the point, are ignored.
|
|
730
|
+
|
|
731
|
+
```yaml
|
|
732
|
+
# config.yml
|
|
733
|
+
bundles:
|
|
734
|
+
loop-guard:
|
|
735
|
+
deny_after: 2 # same call, same result this many times: deny the next one
|
|
736
|
+
stop_after: 4 # stop the turn at this many denies
|
|
737
|
+
ignore_tools: [task_wait, task_get, delegate_result, list_sessions, list_reminders]
|
|
738
|
+
mode: deny # deny | notify: notify only warns, once per call per turn
|
|
739
|
+
```
|
|
740
|
+
|
|
741
|
+
Its hooks run at the default priority (100), after known-names (50), so in
|
|
742
|
+
known-names' `correct` mode loop-guard keys the corrected call.
|
|
743
|
+
|
|
744
|
+
Not caught (yet):
|
|
745
|
+
|
|
746
|
+
- near-duplicates, such as `find . -name 'config*'` after `'config.yml'`;
|
|
747
|
+
- loops across turns;
|
|
748
|
+
- alternating calls (A, B, A, B) that each return something new;
|
|
749
|
+
- thinking that goes in circles inside one long generation.
|
|
750
|
+
|
|
751
|
+
## Shutdown
|
|
752
|
+
|
|
753
|
+
When the REPL exits, or a session's worker exits (an idle exit, `/exit`, a
|
|
754
|
+
crash, TERM), chi shuts the session's Engine down:
|
|
755
|
+
|
|
756
|
+
1. The idle jobs (reminders, the recap) stop.
|
|
757
|
+
2. The plugins' init tasks are cancelled (`ctx.cancelled?` turns true).
|
|
758
|
+
They and the anytime commands still running get up to 3 seconds, all
|
|
759
|
+
together, to finish, so their output reaches the UIs; an init task
|
|
760
|
+
announces nothing after this.
|
|
761
|
+
3. The plugins' services stop, the newest first.
|
|
762
|
+
|
|
763
|
+
## Loading, and when it fails
|
|
764
|
+
|
|
765
|
+
Plugins load when a session starts (`Engine.new`, in the REPL or a
|
|
766
|
+
worker), after the bundle hooks. The steps are:
|
|
767
|
+
|
|
768
|
+
1. The file must match the sha256 recorded at install.
|
|
769
|
+
2. chi must meet `requires_chi`.
|
|
770
|
+
3. The file is `module_eval`'d into a new module in the bundle's namespace
|
|
771
|
+
(`Samagotchi::Bundles::<bundle>`).
|
|
772
|
+
4. The class is built, and `register(chi)` runs.
|
|
773
|
+
|
|
774
|
+
What `register` adds takes effect only when it returns. A plugin that raises
|
|
775
|
+
halfway adds nothing.
|
|
776
|
+
|
|
777
|
+
A plugin that fails to load is shown on stderr at start, and in every UI as
|
|
778
|
+
soon as the session's UI is up (`plugins> plugin plugin.rb (bundle x) failed
|
|
779
|
+
to load (…)`); a UI that joins later gets it too. The rest
|
|
780
|
+
of chi, including the other plugins, works as usual. Unlike a required
|
|
781
|
+
guardrail, a plugin failure does not deny tool calls.
|
|
782
|
+
|
|
783
|
+
A change needs a restart. A running worker keeps the plugins it started
|
|
784
|
+
with. After an install or upgrade, a worker only loads the new code when it
|
|
785
|
+
starts again (`chi sessions stop`, or the idle exit).
|
|
786
|
+
|
|
787
|
+
## Trust
|
|
788
|
+
|
|
789
|
+
A plugin is Ruby code that runs inside chi with your permissions, just like
|
|
790
|
+
a bundle's hooks. **Installing a bundle means trusting it**, as you would a
|
|
791
|
+
gem.
|
|
792
|
+
|
|
793
|
+
- The sha256 is an integrity check, not proof of who wrote the file. A file
|
|
794
|
+
edited after install is not loaded until you reinstall the bundle.
|
|
795
|
+
- Install only copies the file. The code first runs at the next session
|
|
796
|
+
start.
|
|
797
|
+
- Guardrail rules keyed by a tool's name apply to plugin tools, as they do to
|
|
798
|
+
chi's own tools; path and command rules see what `targets:` says.
|
|
799
|
+
|
|
800
|
+
## The bundle commands
|
|
801
|
+
|
|
802
|
+
- `chi bundle install <dir>`: copies the plugin to
|
|
803
|
+
`<memories>/.bundles/<name>/plugin/`. It records the plugin's sha256 and
|
|
804
|
+
`requires_chi`, and warns about a wrong declared sha256 or an unmet
|
|
805
|
+
`requires_chi`.
|
|
806
|
+
- `chi bundle status [<name>]`: shows `plugin=plugin.rb` in the list. For
|
|
807
|
+
one bundle it shows `Plugin: plugin.rb [ok|modified|missing]` and a
|
|
808
|
+
`requires_chi` failure.
|
|
809
|
+
- `chi bundle diff <name> [plugin.rb]`: shows the installed base and the
|
|
810
|
+
file on disk.
|
|
811
|
+
- `chi bundle build --name <name>`: puts the installed plugin (and
|
|
812
|
+
`requires_chi`) into the built bundle.
|
|
813
|
+
- `chi bundle uninstall <name>`: removes the plugin with the bundle.
|
|
814
|
+
|
|
815
|
+
## Not yet
|
|
816
|
+
|
|
817
|
+
These are planned:
|
|
818
|
+
|
|
819
|
+
- `chi.prompt` for sections of the system prompt.
|