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/guardrails.md
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# Guardrails
|
|
2
|
+
|
|
3
|
+
Every tool call the model makes passes one check before it runs, in both
|
|
4
|
+
loops (native and chat hosts). The verdict is **allow**, **ask** or
|
|
5
|
+
**deny**; the strictest vote wins, and a deny can't be undone by a later
|
|
6
|
+
voter.
|
|
7
|
+
|
|
8
|
+
Who votes, in order:
|
|
9
|
+
|
|
10
|
+
1. `before_tool_call` hooks (Ruby; see [Hooks](hooks.md#guardrails-from-a-hook)).
|
|
11
|
+
2. Core checks: a required guardrail that failed to load, then protected paths.
|
|
12
|
+
3. YAML rules: `config.yml`'s `guardrails:` section, then installed bundles' rule files (by bundle name).
|
|
13
|
+
|
|
14
|
+
The UI shows the tool line first, then the approval under it.
|
|
15
|
+
|
|
16
|
+
## Ask
|
|
17
|
+
|
|
18
|
+
The REPL, the attached TUI and the web show what would run, where and why:
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
Approve tool call?
|
|
22
|
+
! execute: git push origin main
|
|
23
|
+
in /home/me/app (repo app, branch main)
|
|
24
|
+
why: git push publishes commits (rule git-push, bundle guardrails)
|
|
25
|
+
1) Allow once
|
|
26
|
+
2) Allow this call for the session
|
|
27
|
+
3) Allow this call in this repo
|
|
28
|
+
4) Allow rule git-push in this repo
|
|
29
|
+
5) Deny
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
In a terminal, answer with a number, the exact label, `y` (Allow once) or `n`
|
|
33
|
+
(Deny); add `; reason` to tell the model why (`n; open a PR instead`). An empty
|
|
34
|
+
answer, Ctrl-C, the web's Deny button or a cancelled turn deny it. The prompt stays open during turns: when the question comes up it turns
|
|
35
|
+
into a yellow `? `, with the call and the options listed under it; only a line
|
|
36
|
+
submitted there answers it (never one typed before), and what you had typed comes back
|
|
37
|
+
once it closes. On a short terminal the list shrinks (the hint row, then the `in`/`why`
|
|
38
|
+
lines, then the header go, then the options fold onto fewer rows). Once answered, one
|
|
39
|
+
line stays in the scrollback: `! execute: git push origin main → Allow once`.
|
|
40
|
+
|
|
41
|
+
Who answers:
|
|
42
|
+
|
|
43
|
+
- REPL (`chi --no-shared`, `-p` without `--non-interactive`): at the `? ` prompt.
|
|
44
|
+
- A shared session's worker: any attached TUI or web page. With none attached,
|
|
45
|
+
the approval waits (in the session file) and shows on attach.
|
|
46
|
+
- `-p … --non-interactive`: nobody; the call is denied ("No one to approve it
|
|
47
|
+
(non-interactive run)").
|
|
48
|
+
|
|
49
|
+
The model gets one line on a deny. A rule's or hook's deny reads
|
|
50
|
+
`[execute] Error: denied by guardrail (rule git-push, bundle guardrails): git push publishes commits. The user was not asked. Do not retry it or reach the same result another way; ask the user how to proceed.`
|
|
51
|
+
When the user picks Deny on an ask, it leads with the user's answer:
|
|
52
|
+
`[execute] Error: The user declined this call: "open a PR instead". It needed approval (rule git-push, bundle guardrails): git push publishes commits. Do not retry it or reach the same result another way; ask the user how to proceed.`
|
|
53
|
+
|
|
54
|
+
## Approvals
|
|
55
|
+
|
|
56
|
+
Allowing beyond "once" is stored in `$XDG_STATE_HOME/samagotchi/guardrails/approvals.json`
|
|
57
|
+
(default `~/.local/state/samagotchi/guardrails/`):
|
|
58
|
+
|
|
59
|
+
| Scope | Allows |
|
|
60
|
+
|---|---|
|
|
61
|
+
| session | this exact call (tool + command, or paths) in this session |
|
|
62
|
+
| repo | this exact call in this repo (the cwd outside a repo), any session |
|
|
63
|
+
| rule | anything this rule asks about in this repo |
|
|
64
|
+
|
|
65
|
+
A stored approval only relaxes an ask; a deny rule is never approvable.
|
|
66
|
+
A file that doesn't parse is moved aside to `approvals.json.corrupt-<UTC time>`
|
|
67
|
+
with one warning, and chi starts with no stored approvals (more asks, nothing lost).
|
|
68
|
+
`/guardrails` lists the rules and approvals; `/guardrails revoke N` removes one.
|
|
69
|
+
The file tools can't write the store.
|
|
70
|
+
|
|
71
|
+
## Protected paths
|
|
72
|
+
|
|
73
|
+
Built in, for `write`, `edit` and `memory_write` (symlinks resolved):
|
|
74
|
+
|
|
75
|
+
- deny: the approval store's dir, and installed bundles (`memories/.bundles/`);
|
|
76
|
+
- ask (once or for the session): `config.yml` and the plain hooks dir.
|
|
77
|
+
|
|
78
|
+
`execute` can still reach them; the guardrails bundle asks about shell
|
|
79
|
+
commands that name them.
|
|
80
|
+
|
|
81
|
+
## Rules in config.yml
|
|
82
|
+
|
|
83
|
+
```yaml
|
|
84
|
+
guardrails:
|
|
85
|
+
enabled: true # false: no rules, and hooks' asks are dropped (a deny still applies)
|
|
86
|
+
rules:
|
|
87
|
+
- id: git-push
|
|
88
|
+
tool: shell # execute + task_create; or a tool name, a glob, or a list
|
|
89
|
+
command: '\bgit\s+push\b' # Ruby regex on the command
|
|
90
|
+
verdict: ask # ask | deny
|
|
91
|
+
reason: git push publishes commits
|
|
92
|
+
scopes: [once, session, repo] # optional; default all four
|
|
93
|
+
- id: write-outside-repo
|
|
94
|
+
tool: [write, edit]
|
|
95
|
+
path: outside_repo # or a glob: "**/.git/hooks/**", "/etc/**", "config/*.yml"
|
|
96
|
+
verdict: ask
|
|
97
|
+
reason: writes outside the repository
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
A tool name may be a glob, so one rule covers a plugin's tools (an MCP server's,
|
|
101
|
+
say): `tool: "mcp_*"` or `tool: ["mcp_{git,gh}_*", web_fetch]` (`*`, `?`, `[…]` and
|
|
102
|
+
`{a,b}`, matched with `File.fnmatch`). `/guardrails` lists the glob as given.
|
|
103
|
+
A plugin tool whose `targets:` name no command or path (an MCP tool) is asked
|
|
104
|
+
about with its arguments (`mcp_x_sum: a=20 b=22`), and an approval of "this
|
|
105
|
+
call" is keyed by them.
|
|
106
|
+
|
|
107
|
+
```yaml
|
|
108
|
+
- id: mcp-ask
|
|
109
|
+
tool: "mcp_*" # every MCP tool (the mcp bundle's mcp_<server>_<tool>)
|
|
110
|
+
verdict: ask
|
|
111
|
+
reason: an MCP server's tool
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
All the fields a rule gives must match. Absolute and `**/` globs match the
|
|
115
|
+
resolved path; other globs match the path relative to the repo root. Paths
|
|
116
|
+
resolve the way the tools resolve them (against the cwd; `~` expanded).
|
|
117
|
+
|
|
118
|
+
To switch off single rules (a bundle's, say) without editing its files, list
|
|
119
|
+
them under `disable:`. A plain id switches off every rule with that id; `bundle:id`
|
|
120
|
+
only that bundle's:
|
|
121
|
+
|
|
122
|
+
```yaml
|
|
123
|
+
guardrails:
|
|
124
|
+
disable: [git-rebase, guardrails:git-push]
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`/guardrails` marks them `disabled (guardrails.disable)` and names entries that
|
|
128
|
+
match no rule. `disable:` only removes rules; hooks and the core checks still vote.
|
|
129
|
+
|
|
130
|
+
Rules load when chi starts (a long-running worker picks up changes after its
|
|
131
|
+
next start). A rule that doesn't parse (an unknown key, a bad regex, no
|
|
132
|
+
verdict, a `disable:` that isn't a list of ids) makes chi **deny every tool call** and say why, rather than run
|
|
133
|
+
without it.
|
|
134
|
+
|
|
135
|
+
## The guardrails bundle
|
|
136
|
+
|
|
137
|
+
```sh
|
|
138
|
+
chi bundle install guardrails
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
installs a default rule set plus a short memory telling the model not to
|
|
142
|
+
route around a deny. It asks before `git push`, `reset --hard`, `clean -f`,
|
|
143
|
+
`branch -D`, `rebase`, `filter-branch`/`filter-repo`; `rm -rf` on `/`, `~`,
|
|
144
|
+
`$HOME` or `..` paths; `curl … | sh` and `base64 -d … | sh`; writes outside the
|
|
145
|
+
repo; and shell commands that name chi's config, hooks or guardrails or
|
|
146
|
+
`.git/hooks`. It denies writes into `.git/hooks`. The rules are in
|
|
147
|
+
`lib/samagotchi/bundles/guardrails/guardrails/rules.yml`.
|
|
148
|
+
|
|
149
|
+
A bundle ships rules as `guardrails/*.yml` (the same `rules:` shape). Install
|
|
150
|
+
records each file's sha256; a file changed afterwards, missing, or not parsing
|
|
151
|
+
denies every call until the bundle is reinstalled.
|
|
152
|
+
|
|
153
|
+
## The known-names bundle
|
|
154
|
+
|
|
155
|
+
```sh
|
|
156
|
+
chi bundle install known-names
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
installs one `before_tool_call` hook and a short memory. A local model that
|
|
160
|
+
once misspells a name inside a path (`jonathandoe` → `jonathndoe`)
|
|
161
|
+
keeps copying the wrong spelling from its context, and every call after
|
|
162
|
+
that fails. The hook knows the right names and compares strings: the
|
|
163
|
+
user's home folder name, login (`$USER`), git `user.name` words and email
|
|
164
|
+
local part, the repo folder name, and any names from config. A token in a
|
|
165
|
+
call's command, `cwd` or paths (never a write's content) that is within one
|
|
166
|
+
edit of a known name shorter than 10 characters, or two edits of a longer
|
|
167
|
+
one, is a near miss. Tokens and names shorter than `min_length` (6) are
|
|
168
|
+
skipped, as is a token that equals another known name.
|
|
169
|
+
|
|
170
|
+
By default (`mode: reject`) the call is denied with advice in place of the
|
|
171
|
+
usual tail, so the model retries it corrected:
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
[execute] Error: denied by guardrail (hook known_names, bundle known-names): "johndeo" in the command is 1 edit away from the known name "johndoe". The user was not asked. Retry with "johndoe". If "johndeo" is really what you meant, say so to the user instead of retrying.
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
and the user sees one line: `known-names> rejected execute: "johndeo" looks like "johndoe"`.
|
|
178
|
+
|
|
179
|
+
```yaml
|
|
180
|
+
bundles:
|
|
181
|
+
known-names:
|
|
182
|
+
names: [jonathandoe] # protected besides the derived ones
|
|
183
|
+
mode: reject # reject | correct | ask
|
|
184
|
+
derive: [home, user, git, repo]
|
|
185
|
+
ignore: [jondoe] # a real name that is near a protected one
|
|
186
|
+
min_length: 6
|
|
187
|
+
max_distance: 2 # default: 1 under 10 characters, else 2
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`mode: correct` rewrites the call (whole tokens, everywhere they appear) and
|
|
191
|
+
says so; `mode: ask` shows the call with three choices, *Correct it and
|
|
192
|
+
run*, *Run as is*, *Deny*; with no one to ask (`--non-interactive`) or a
|
|
193
|
+
dismissed question it rejects. A real near name (a folder `jondoe` next to
|
|
194
|
+
user `johndoe`, a login one letter from another) is caught too: list it under
|
|
195
|
+
`ignore:`. The hook is `on_error: log`: a bug in it warns and lets the call
|
|
196
|
+
through. As with every bundle hook, a running worker picks it up after its
|
|
197
|
+
next start.
|
|
198
|
+
|
|
199
|
+
The system prompt names the home directory once, with the advice to write
|
|
200
|
+
it as `~` or `$HOME`, so the model rarely has to spell it.
|
|
201
|
+
|
|
202
|
+
## Failing closed
|
|
203
|
+
|
|
204
|
+
- A config hook with `required: true`, or a bundle `before_tool_call` hook with
|
|
205
|
+
`on_error: fail_closed`, that fails to load (missing, syntax error, or for a
|
|
206
|
+
bundle hook a sha256 that differs from the installed one) makes chi deny
|
|
207
|
+
every tool call. One that raises when called denies that call.
|
|
208
|
+
- Other hooks stay fail-open; a load failure is a warning.
|
|
209
|
+
- Every load failure is shown once, at the start of the first turn
|
|
210
|
+
(`guardrails> …` in the terminal, a red line on the web).
|
|
211
|
+
|
|
212
|
+
## Limits
|
|
213
|
+
|
|
214
|
+
Text matching on shell commands stops accidents, not a model set on getting
|
|
215
|
+
around it: `sh -c`, base64, a script written earlier, `git -C` variants and
|
|
216
|
+
aliases can get past a regex. `!cmd` lines typed by you are not checked. The only
|
|
217
|
+
real defence against an adversarial model is isolation (a sandbox, a git
|
|
218
|
+
identity without push rights).
|
data/docs/hooks.md
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# Hooks
|
|
2
|
+
|
|
3
|
+
Samagotchi supports pluggable Ruby hooks that fire at key lifecycle points
|
|
4
|
+
during agent turns. Hooks let you add external tooling (CI checks, logging,
|
|
5
|
+
analytics) or in-process verification (test gates, policy checks).
|
|
6
|
+
|
|
7
|
+
A bundle can go further with a plugin: slash commands and tools as well as
|
|
8
|
+
hooks. See [Plugins](plugins.md).
|
|
9
|
+
|
|
10
|
+
## Configuration
|
|
11
|
+
|
|
12
|
+
Add a `hooks:` section to your global config file (`~/.config/samagotchi/config.yml`):
|
|
13
|
+
|
|
14
|
+
```yaml
|
|
15
|
+
hooks:
|
|
16
|
+
hooks_dir: "~/.config/samagotchi/hooks/"
|
|
17
|
+
session_start:
|
|
18
|
+
- path: "analytics.rb"
|
|
19
|
+
on_error: log
|
|
20
|
+
before_turn:
|
|
21
|
+
- path: "audit.rb"
|
|
22
|
+
on_error: skip
|
|
23
|
+
after_tool_call:
|
|
24
|
+
- path: "metrics.rb"
|
|
25
|
+
on_error: skip
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Plugin Format
|
|
29
|
+
|
|
30
|
+
Each plugin is a `.rb` file in the hooks directory. The class name must match
|
|
31
|
+
the filename (snake_case → PascalCase):
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
# ~/.config/samagotchi/hooks/metrics.rb
|
|
35
|
+
class Metrics
|
|
36
|
+
def call(event)
|
|
37
|
+
# event is a Hash — you can read or mutate fields
|
|
38
|
+
tool = event[:tool]
|
|
39
|
+
output = event[:output]
|
|
40
|
+
# ... record metrics, log, etc.
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The plugin class must respond to `#call(event)` — duck-typed, no base class required.
|
|
46
|
+
|
|
47
|
+
## Hook Events
|
|
48
|
+
|
|
49
|
+
| Event | When it fires | Event payload |
|
|
50
|
+
|-------|--------------|---------------|
|
|
51
|
+
| `:session_start` | First turn of the session | `{ type: :session_start, session_id: "..." }` |
|
|
52
|
+
| `:before_turn` | Before each turn starts | `{ type: :before_turn, session_id: "...", prompt: "..." (nil on a continue), messages: [...] (the history before this turn) }` |
|
|
53
|
+
| `:after_turn` | After a turn completed or was cancelled (not after one that failed) | `{ type: :after_turn, status: "completed" \| "canceled", messages: [...] (the conversation the turn stored; a cancelled or empty turn ends it with a `kind: turn_note` system message, and a context line is `kind: context`, see [sessions.md](sessions.md#notes-a-turn-leaves-for-the-model)) }` |
|
|
54
|
+
| `:before_generation` | Before each LLM API call (both loops) | `{ type: :before_generation, iteration: N }` |
|
|
55
|
+
| `:after_generation` | After LLM returns (both loops) | `{ type: :after_generation, iteration: N, response: "...", messages: [...] (the conversation as sent) }` |
|
|
56
|
+
| `:before_tool_call` | Before tool dispatch (and before `tool_call_started`) | `{ type: :before_tool_call, iteration: N, call: {...}, params: "...", guardrail: Verdict, context: {...}, targets: {...}, blocked: false, block_reason: nil }` |
|
|
57
|
+
| `:after_tool_call` | After tool execution | `{ type: :after_tool_call, iteration: N, tool: "read", output: "..." }` |
|
|
58
|
+
| `:session_end` | After every turn (turn-level lifecycle) | `{ type: :session_end, session_id: "..." }` |
|
|
59
|
+
|
|
60
|
+
Every event also carries the hook runtime (next section): `hook:` (the label
|
|
61
|
+
of the hook about to run) and the callables `notify:`, `ask_user:`,
|
|
62
|
+
`stop_turn:`.
|
|
63
|
+
|
|
64
|
+
`messages:` is a **read-only copy**: a frozen array of copied message hashes
|
|
65
|
+
(`{role:, content:, …}`). A hook that mutates it, or its strings, gets
|
|
66
|
+
undefined behaviour. `:before_tool_call` carries no messages (the gate stays
|
|
67
|
+
cheap).
|
|
68
|
+
|
|
69
|
+
## What a hook can do: the runtime
|
|
70
|
+
|
|
71
|
+
Besides reading (and, on `:before_tool_call`, voting on) its event, a hook
|
|
72
|
+
can talk to the user through three callables the registry puts on every
|
|
73
|
+
event:
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
class Watchful
|
|
77
|
+
def call(event)
|
|
78
|
+
case event[:type]
|
|
79
|
+
when :after_generation
|
|
80
|
+
# One line in the REPL, the attached TUI and the web ("<bundle>> text",
|
|
81
|
+
# or "hook> text" for a config hook); level: :warn colours it.
|
|
82
|
+
event[:notify].call("the model repeated itself", level: :warn)
|
|
83
|
+
when :before_tool_call
|
|
84
|
+
# A single-select question through the question flow (REPL, attached
|
|
85
|
+
# TUI, web); returns {selected: [...], freeform:, selected_indices:}
|
|
86
|
+
# or nil when there is no one to ask (--non-interactive), the
|
|
87
|
+
# question was dismissed, or the options were not 2-8 strings.
|
|
88
|
+
answer = event[:ask_user].call(question: "#{event[:call][:name]}: #{event[:params]}\nRun it?",
|
|
89
|
+
options: ["Run", "Deny"], header: "my guard", allow_freeform: false)
|
|
90
|
+
event[:guardrail].deny!("the user said no") unless answer&.dig(:selected)&.first == "Run"
|
|
91
|
+
when :before_generation
|
|
92
|
+
# Cancel the running turn: a warn notice with the reason, then the
|
|
93
|
+
# turn ends as cancelled (hook). From :before_tool_call it also denies
|
|
94
|
+
# that call, and the rest of the batch is denied; from :after_turn or
|
|
95
|
+
# :session_end it does nothing (false).
|
|
96
|
+
event[:stop_turn].call("too many iterations without progress") if event[:iteration] > 20
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`event[:hook]` is the label the notices carry: `known_names.rb (bundle
|
|
103
|
+
known-names)` for a bundle hook, `audit.rb (config)` for a config hook,
|
|
104
|
+
`turn hook` for one registered at runtime.
|
|
105
|
+
|
|
106
|
+
Timing: a notice from `:after_turn` or `:session_end` shows after the turn's
|
|
107
|
+
end line. A question from `:before_tool_call` shows **before** the tool
|
|
108
|
+
line (the gate runs first), so its text should name the call. The notices
|
|
109
|
+
are also logged (`turn` tag, `hook_notice`).
|
|
110
|
+
|
|
111
|
+
## Settings
|
|
112
|
+
|
|
113
|
+
A hook class whose `initialize` takes an argument gets its settings: **one
|
|
114
|
+
positional Hash with string keys** (`def initialize(settings = {})`;
|
|
115
|
+
`initialize(**kw)` is not supported). A class whose `initialize` takes none
|
|
116
|
+
is built bare. Defaults belong in the hook.
|
|
117
|
+
|
|
118
|
+
```yaml
|
|
119
|
+
bundles: # per bundle, by name, for its hooks
|
|
120
|
+
known-names:
|
|
121
|
+
names: [jonathandoe]
|
|
122
|
+
mode: reject
|
|
123
|
+
hooks:
|
|
124
|
+
before_tool_call:
|
|
125
|
+
- path: my_guard.rb # a config hook: its entry's settings
|
|
126
|
+
settings: { threshold: 2 }
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Two config entries for the same file with different settings get two
|
|
130
|
+
instances. A running worker reads config at start (restart it after a
|
|
131
|
+
change), as for every hook.
|
|
132
|
+
|
|
133
|
+
## Error Handling
|
|
134
|
+
|
|
135
|
+
- `on_error: "skip"` (default): silently ignore hook failures
|
|
136
|
+
- `on_error: "log"`: emit a `warn` message to stderr
|
|
137
|
+
- `required: true` (config hooks): the hook is a guardrail. If it fails to load
|
|
138
|
+
(missing file, syntax error), chi denies every tool call and says why; if it
|
|
139
|
+
raises as a `before_tool_call` hook, that call is denied. See
|
|
140
|
+
[Guardrails](guardrails.md#failing-closed).
|
|
141
|
+
|
|
142
|
+
A hook that fails to load is reported as a `[samagotchi:hooks]` warning and
|
|
143
|
+
once in the UI.
|
|
144
|
+
|
|
145
|
+
Hook failures never break the engine loop — each hook is wrapped in its own
|
|
146
|
+
try/catch.
|
|
147
|
+
|
|
148
|
+
## Runtime Hook Registration
|
|
149
|
+
|
|
150
|
+
You can also register hooks programmatically during a turn (they are cleared
|
|
151
|
+
automatically after each `run_turn`):
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
engine = Samagotchi::Engine.new(mode: :assist)
|
|
155
|
+
engine.register_hook(:before_turn) do |event|
|
|
156
|
+
puts "Turn starting..."
|
|
157
|
+
end
|
|
158
|
+
engine.run_turn(session, "Hello")
|
|
159
|
+
# Hooks cleared automatically — won't fire on the next turn
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Example Plugins
|
|
163
|
+
|
|
164
|
+
**Logging every tool call:**
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
# ~/.config/samagotchi/hooks/audit.rb
|
|
168
|
+
class Audit
|
|
169
|
+
def call(event)
|
|
170
|
+
return unless event[:type] == :after_tool_call
|
|
171
|
+
puts "[audit] #{event[:tool]} → #{event[:output][0..100]}"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**Tracking tool call counts:**
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
# ~/.config/samagotchi/hooks/tool_counter.rb
|
|
180
|
+
class ToolCounter
|
|
181
|
+
def initialize
|
|
182
|
+
@counts = Hash.new(0)
|
|
183
|
+
@mutex = Mutex.new
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def call(event)
|
|
187
|
+
return unless event[:type] == :after_tool_call
|
|
188
|
+
@mutex.synchronize { @counts[event[:tool]] += 1 }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def report
|
|
192
|
+
@mutex.synchronize { @counts.dup }
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
<a id="guardrails-from-a-hook"></a>
|
|
198
|
+
**Guardrails from a hook (allow / ask / deny):**
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
# ~/.config/samagotchi/hooks/safety.rb
|
|
202
|
+
class Safety
|
|
203
|
+
def call(event)
|
|
204
|
+
return unless event[:type] == :before_tool_call
|
|
205
|
+
command = event[:targets][:command].to_s # execute / task_create
|
|
206
|
+
if command.include?("rm -rf /")
|
|
207
|
+
event[:guardrail].deny!("dangerous command denied by policy")
|
|
208
|
+
elsif event[:targets][:outside_repo]
|
|
209
|
+
event[:guardrail].ask!("writes outside the repo", scopes: %w[once session])
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
`event[:guardrail]` is the call's verdict. `deny!(reason, rule: nil, source: nil, advice: nil)`
|
|
216
|
+
and `ask!(reason, scopes: nil, rule: nil, source: nil)` vote; the strictest
|
|
217
|
+
vote wins (deny > ask > allow) and a vote never relaxes it, so a later hook
|
|
218
|
+
can't undo a deny. An ask goes to the user (see [Guardrails](guardrails.md#ask)).
|
|
219
|
+
`advice:` replaces the fixed "Do not retry it…" tail of the deny text with
|
|
220
|
+
the voter's own (a guard that wants the model to retry a corrected call:
|
|
221
|
+
`Retry with "…".`).
|
|
222
|
+
|
|
223
|
+
`event[:context]` is `{cwd:, repo_root:, branch:, session_id:, interface:, origin:}`
|
|
224
|
+
(`interface` is `:repl`, `:worker` or `:non_interactive`). `event[:targets]` is
|
|
225
|
+
what the call acts on, resolved as the tools resolve it:
|
|
226
|
+
`{command:, paths:, cwd:, repo_root:, outside_repo:}`.
|
|
227
|
+
|
|
228
|
+
The older flag still works: `event[:blocked] = true` with an optional
|
|
229
|
+
`event[:block_reason]`. It is folded into the verdict after each hook (so it
|
|
230
|
+
is sticky too), and the model gets `[<tool>] Error: blocked by guardrail: <reason>`
|
|
231
|
+
(default reason `blocked by hook`). A verdict's deny reads
|
|
232
|
+
`[<tool>] Error: denied by guardrail (<rule or hook>): <reason>. … Do not retry it …`
|
|
233
|
+
(after a user's Deny on an ask: `[<tool>] Error: The user declined this call… It needed approval (<rule or hook>): <reason>. …`).
|
|
234
|
+
Either way the activity status is `blocked`, and `:after_tool_call` still fires.
|
|
235
|
+
Only `:before_tool_call` votes.
|
|
236
|
+
|
|
237
|
+
**Mutating params (legacy):**
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
# ~/.config/samagotchi/hooks/safety_legacy.rb
|
|
241
|
+
class SafetyLegacy
|
|
242
|
+
def call(event)
|
|
243
|
+
return unless event[:type] == :before_tool_call
|
|
244
|
+
tool = event[:call][:name]
|
|
245
|
+
if tool == "execute" && event[:call][:content]&.include?("rm -rf /")
|
|
246
|
+
event[:call][:content] = "echo 'Safety check: dangerous command blocked'"
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Note: `:before_tool_call` can replace the `:call` hash to change what runs; `tool_call_started` (what the UIs show) and the rules see the final call.
|
|
253
|
+
|
|
254
|
+
## Bundle Hooks (unified workflow bundle)
|
|
255
|
+
|
|
256
|
+
Bundles can ship executable guardrails alongside memories. A bundle with hooks lives as a directory with a `hooks/` subdirectory (flat, basename-keyed):
|
|
257
|
+
|
|
258
|
+
```
|
|
259
|
+
my-bundle/
|
|
260
|
+
manifest.yml
|
|
261
|
+
identity.md
|
|
262
|
+
hooks/
|
|
263
|
+
guardrails.rb # class Guardrails; def call(event); ...; end; end
|
|
264
|
+
audit.rb
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
`manifest.yml` may carry an optional `hooks:` map (both `files:` and `hooks:` are optional; a bundle may carry only one):
|
|
268
|
+
|
|
269
|
+
```yaml
|
|
270
|
+
name: code-review-workflow
|
|
271
|
+
version: 1.0.0
|
|
272
|
+
scope: project
|
|
273
|
+
files:
|
|
274
|
+
identity.md: sha256:abc...
|
|
275
|
+
hooks:
|
|
276
|
+
guardrails.rb:
|
|
277
|
+
sha256: 1234...
|
|
278
|
+
event: before_tool_call
|
|
279
|
+
on_error: fail_closed # default for before_tool_call
|
|
280
|
+
priority: 10
|
|
281
|
+
audit.rb:
|
|
282
|
+
sha256: 5678...
|
|
283
|
+
event: after_tool_call
|
|
284
|
+
on_error: log
|
|
285
|
+
priority: 100
|
|
286
|
+
trust_level: reviewed # reviewed | experimental (default)
|
|
287
|
+
needs: [gh] # optional: outside commands the memories use (docs/memory.md#bundles-that-need-outside-commands)
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Notes:
|
|
291
|
+
|
|
292
|
+
- Hook key = basename (flat under `hooks/`). No subdirs in v1.
|
|
293
|
+
- `event` is required for auto-registration; a hook with no event is skipped.
|
|
294
|
+
- `sha256` is integrity (not authenticity). No signing in v1. Install records the sha256 of the copied file; at `Engine.new` a hook whose file differs is not loaded (reinstall the bundle after editing one by hand).
|
|
295
|
+
- Hook code is the bundle author's source of truth: on upgrade, hooks are overwritten; if the installed file was locally modified, a warning is emitted (`was locally modified; overwriting`).
|
|
296
|
+
- A raising `:before_tool_call` guardrail respects `on_error`: `fail_closed` denies the call (fail-closed), `log` warns, `skip` is silent.
|
|
297
|
+
- A `fail_closed` `:before_tool_call` hook is required: if it is missing, fails to load or its sha256 differs, chi denies every tool call until it is fixed.
|
|
298
|
+
- A bundle can also ship YAML rules in `guardrails/*.yml`; see [Guardrails](guardrails.md#the-guardrails-bundle).
|
|
299
|
+
- Ordering: bundle hooks fire by `(priority, bundle_name, hook_name)` (lower priority first), then plain `config.yml` hooks in registration order.
|
|
300
|
+
- Settings: a hook class with `initialize(settings = {})` gets the bundle's section of `config.yml` `bundles:` (see [Settings](#settings)).
|
|
301
|
+
- A bundle can also ship a `plugin.rb` whose `chi.on(event)` blocks are bundle hooks too, next to commands and tools; see [Plugins](plugins.md).
|
|
302
|
+
- Shipped bundles: `chi bundle install guardrails` (rules, see [Guardrails](guardrails.md#the-guardrails-bundle)) and `chi bundle install known-names` (a hook, see [Guardrails](guardrails.md#the-known-names-bundle)), `chi bundle install btw` (a plugin: `/btw`, see [Plugins](plugins.md#the-btw-bundle)), `chi bundle install mcp` (a plugin: tools from MCP servers, see [Plugins](plugins.md#the-mcp-bundle)) and `chi bundle install loop-guard` (a plugin: breaks tool-call loops, see [Plugins](plugins.md#the-loop-guard-bundle)).
|
|
303
|
+
- Installing a bundle executes its hook code at `Engine` startup. Only install bundles you trust, as you would a gem. Hooks are **not** executed at install time (copy-only); they are `module_eval`'d at `Engine.new` inside per-bundle `Samagotchi::Bundles::<name>` namespaces (no top-level `require` collisions). Keep hook files side-effect-free at load time; do work in `#call` — top-level side effects (require, IO, `at_exit`, global assignment) run once per `Engine.new` (class redefinition is idempotent).
|
|
304
|
+
|
|
305
|
+
Lifecycle:
|
|
306
|
+
|
|
307
|
+
- `chi bundle install <source>` copies `hooks/*.rb` to `~/.config/samagotchi/memories/.bundles/<name>/hooks/` and persists metadata + `trust_level` + `source_commit` (git HEAD) to provenance.
|
|
308
|
+
- `Engine.new` loads `config.yml` hooks first, then bundle hooks via `Provenance.each_installed_holding_hooks` → `Hooks::BundleLoader.load`. Bundle hooks are process-scoped (they survive the per-turn `clear_hooks`; only plain hooks are cleared). Experimental bundles emit a one-line startup warning.
|
|
309
|
+
- `chi bundle status`, `diff`, `uninstall`, `build` are hook-aware (counts, metadata, removal).
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Background task tools
|
|
2
|
+
|
|
3
|
+
Samagotchi supports long-running commands in the background through five task tools:
|
|
4
|
+
|
|
5
|
+
- `task_create`: start a background command and return `task_id` plus `output_path`.
|
|
6
|
+
- `task_get`: fetch current task metadata by id.
|
|
7
|
+
- `task_list`: list all tasks for the current workspace.
|
|
8
|
+
- `task_stop`: stop a running task by id.
|
|
9
|
+
- `task_wait`: wait up to 600 seconds by default for a task to finish.
|
|
10
|
+
|
|
11
|
+
Recommended workflow:
|
|
12
|
+
|
|
13
|
+
1. Create a task with `task_create`.
|
|
14
|
+
2. Use `task_wait` once. On timeout it returns the last 10 log lines, avoiding a separate read just to see progress.
|
|
15
|
+
3. For commands with a reliable completion marker, pass `done_pattern` to return when the recent log tail matches it.
|
|
16
|
+
4. Use `task_get` or `task_list` for nonblocking status checks, and `task_stop` if needed.
|
|
17
|
+
|
|
18
|
+
Behavior:
|
|
19
|
+
|
|
20
|
+
- Task metadata and output are persisted under `tmp/tasks/`.
|
|
21
|
+
- Task listing is workspace-scoped (current project only).
|
|
22
|
+
- `task_get` returns metadata and `output_path`; use `read` for output contents.
|
|
23
|
+
- `task_wait` accepts `timeout`, `tail_lines` (maximum 100), and `done_pattern` (a regular expression string).
|
|
24
|
+
- `task_create` accepts `env` as a JSON object string for deterministic overrides such as `PATH`; use an absolute interpreter path when that is simpler. Ruby/Bundler isolation variables remain protected.
|
|
25
|
+
|
|
26
|
+
Work that needs a model, not a shell command, goes to a child chi session instead: `delegate` / `delegate_result` have the same shape (start, then wait) and return only the child's final reply; see [Sessions: Delegating](../sessions.md#delegating).
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Context status telemetry
|
|
2
|
+
|
|
3
|
+
The kernel surfaces context-usage telemetry to UI consumers (status line,
|
|
4
|
+
web/SSE clients) as a `:context_status` stream event. The telemetry itself is
|
|
5
|
+
not injected into the model's conversation; what the model gets is one short
|
|
6
|
+
line, as a tail system message (`kind: context`), when usage rises into a bucket
|
|
7
|
+
whose guidance asks it to change how it works (from the second threshold, 40%
|
|
8
|
+
by default, up; never on a fall or a cadence tick, and not again for a bucket a
|
|
9
|
+
resumed session's line already names):
|
|
10
|
+
|
|
11
|
+
`[CONTEXT: about 55% of the context window is in use (estimated; bucket=40plus). context moderate — prefer targeted and range reads over full-file dumps]`
|
|
12
|
+
|
|
13
|
+
The native loop only (the chat loop has no context status). The event carries
|
|
14
|
+
a `status` string using this prefix:
|
|
15
|
+
|
|
16
|
+
`CONTEXT_STATUS ...`
|
|
17
|
+
|
|
18
|
+
Emission behavior:
|
|
19
|
+
|
|
20
|
+
- A status is emitted when estimated usage crosses configured threshold buckets.
|
|
21
|
+
- Optional cadence-based updates can also be enabled every N rounds.
|
|
22
|
+
- This is warn-only behavior (no automatic history truncation).
|
|
23
|
+
- When the model server reports real `usage` fields in the stream payload, the
|
|
24
|
+
telemetry uses those actual token counts (prefixed `src=server`) instead of the
|
|
25
|
+
synthetic char-based estimate (`src=estimate`). The guidance text is dynamic
|
|
26
|
+
and escalates with the bucket: healthy → proceed normally; moderate → prefer
|
|
27
|
+
targeted/range reads; elevated → be concise, avoid large re-reads; critical →
|
|
28
|
+
summarize aggressively and delegate broad work to subagents.
|
|
29
|
+
|
|
30
|
+
Configuration:
|
|
31
|
+
|
|
32
|
+
- `SAMAGOTCHI_CONTEXT_STATUS` (`true` by default): set to `false` or `0` to disable telemetry.
|
|
33
|
+
- `SAMAGOTCHI_CONTEXT_WINDOW_TOKENS` / `context.window_tokens`: context window size for when the server doesn't report one. chi asks llama.cpp for its real window first (`/props`, the per-slot `n_ctx`); this setting only fills in when it can't (mlx, oMLX, server down), and 256000 is the last resort.
|
|
34
|
+
- `SAMAGOTCHI_CONTEXT_CHARS_PER_TOKEN` (default `4.0`): heuristic ratio for char-to-token estimation.
|
|
35
|
+
- `SAMAGOTCHI_CONTEXT_STATUS_THRESHOLDS` (default `20,40,60,80`): comma-separated threshold percentages.
|
|
36
|
+
- `SAMAGOTCHI_CONTEXT_STATUS_CADENCE` (default `0`): emit every N rounds in addition to threshold crossings.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Gemma 4 behavior contract
|
|
2
|
+
|
|
3
|
+
This project uses canonical Gemma 4 tool-call parsing and explicit thought-context handling.
|
|
4
|
+
|
|
5
|
+
## Canonical Tool Calls Only
|
|
6
|
+
|
|
7
|
+
The kernel loop accepts canonical calls in this format:
|
|
8
|
+
|
|
9
|
+
`<|tool_call>call:NAME{...}<tool_call|>`
|
|
10
|
+
|
|
11
|
+
XML tool tags and declaration-echo parsing are intentionally not supported.
|
|
12
|
+
|
|
13
|
+
## Thought Context Rules
|
|
14
|
+
|
|
15
|
+
Thought handling follows the Gemma guidance:
|
|
16
|
+
|
|
17
|
+
- Include `<|think|>` in the system instruction to activate thinking mode.
|
|
18
|
+
- When thinking mode is active, the model may emit internal reasoning as `<|channel>thought ... <channel|>`.
|
|
19
|
+
- Standard multi-turn: prior model thoughts are stripped from conversation history before the next turn.
|
|
20
|
+
- Function/tool-calling exception: during a single turn that includes tool calls, thoughts are not stripped between those tool-call rounds.
|
|
21
|
+
- Final model output returned to the caller is thought-stripped.
|
|
22
|
+
|
|
23
|
+
In short, raw thought blocks are treated as in-turn transient context, not durable history.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Tool output guardrails
|
|
2
|
+
|
|
3
|
+
## Read Tool Size Guardrails
|
|
4
|
+
|
|
5
|
+
The `read` tool now applies adaptive limits to avoid accidental context exhaustion
|
|
6
|
+
when opening very large files (for example, VCR cassettes).
|
|
7
|
+
|
|
8
|
+
Behavior:
|
|
9
|
+
|
|
10
|
+
- Small files: return full file content.
|
|
11
|
+
- Large files: return a head+tail preview plus truncation metadata.
|
|
12
|
+
- Extremely large files: return an error indicating the hard size limit.
|
|
13
|
+
|
|
14
|
+
Configuration:
|
|
15
|
+
|
|
16
|
+
- `SAMAGOTCHI_READ_TRUNCATE_AT_BYTES` (default `65536`): files above this size return a preview instead of full content.
|
|
17
|
+
- `SAMAGOTCHI_READ_PREVIEW_BYTES` (default `12288`): total preview budget split across head and tail.
|
|
18
|
+
- `SAMAGOTCHI_READ_HARD_MAX_BYTES` (default `2097152`): files above this size return `Error: file too large`.
|
|
19
|
+
|
|
20
|
+
Optional preview telemetry:
|
|
21
|
+
|
|
22
|
+
- `SAMAGOTCHI_READ_TELEMETRY_THRESHOLD_PCT` (default `80`): include estimated preview token impact only when preview payload is at or above this percentage of the configured context window.
|
|
23
|
+
|
|
24
|
+
Telemetry uses existing context estimation settings:
|
|
25
|
+
|
|
26
|
+
- `SAMAGOTCHI_CONTEXT_WINDOW_TOKENS`
|
|
27
|
+
- `SAMAGOTCHI_CONTEXT_CHARS_PER_TOKEN`
|
|
28
|
+
|
|
29
|
+
## Execute Tool Output Guardrails
|
|
30
|
+
|
|
31
|
+
The `execute` tool applies the same guardrail model to command output:
|
|
32
|
+
|
|
33
|
+
- Small stdout/stderr: returned in full.
|
|
34
|
+
- Large stdout/stderr: returned as head+tail previews with truncation metadata.
|
|
35
|
+
|
|
36
|
+
Configuration:
|
|
37
|
+
|
|
38
|
+
- `SAMAGOTCHI_EXECUTE_TRUNCATE_AT_BYTES` (default `65536`): output above this size is truncated.
|
|
39
|
+
- `SAMAGOTCHI_EXECUTE_PREVIEW_BYTES` (default `12288`): total preview budget split across head and tail.
|
|
40
|
+
- `SAMAGOTCHI_EXECUTE_TELEMETRY_THRESHOLD_PCT` (default `80`): include estimated output token impact only when threshold is crossed.
|
|
41
|
+
|
|
42
|
+
Implementation note:
|
|
43
|
+
|
|
44
|
+
- Shared logic lives in `lib/samagotchi/tools/output_guardrails.rb` and is used by both `read` and `execute`.
|
|
45
|
+
- Additional tools that can emit large payloads should rely on this shared helper for consistent behavior.
|