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.
Files changed (243) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/LICENSE +21 -0
  4. data/README.md +126 -0
  5. data/bin/chi +1140 -0
  6. data/docs/architecture.md +299 -0
  7. data/docs/cli.md +490 -0
  8. data/docs/configuration.md +494 -0
  9. data/docs/desktop.md +97 -0
  10. data/docs/guardrails.md +218 -0
  11. data/docs/hooks.md +309 -0
  12. data/docs/internals/background-tasks.md +26 -0
  13. data/docs/internals/context-telemetry.md +36 -0
  14. data/docs/internals/gemma4-contract.md +23 -0
  15. data/docs/internals/tool-guardrails.md +45 -0
  16. data/docs/memory.md +85 -0
  17. data/docs/plugins.md +819 -0
  18. data/docs/releasing.md +135 -0
  19. data/docs/sessions.md +155 -0
  20. data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
  21. data/lib/samagotchi/bridge/card_store.rb +126 -0
  22. data/lib/samagotchi/bridge/event_id.rb +25 -0
  23. data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
  24. data/lib/samagotchi/bridge/sse_writer.rb +248 -0
  25. data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
  26. data/lib/samagotchi/bridge.rb +993 -0
  27. data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
  28. data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
  29. data/lib/samagotchi/bridge_client.rb +330 -0
  30. data/lib/samagotchi/bundle_needs.rb +97 -0
  31. data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
  32. data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
  33. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
  34. data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
  35. data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
  36. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
  37. data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
  38. data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
  39. data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
  40. data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
  41. data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
  42. data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
  44. data/lib/samagotchi/bundles/system/delegated.md +10 -0
  45. data/lib/samagotchi/bundles/system/identity.md +7 -0
  46. data/lib/samagotchi/bundles/system/manifest.yml +11 -0
  47. data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
  48. data/lib/samagotchi/bundles/system/self_map.md +55 -0
  49. data/lib/samagotchi/cancellation_controller.rb +78 -0
  50. data/lib/samagotchi/client.rb +429 -0
  51. data/lib/samagotchi/commands/registry.rb +112 -0
  52. data/lib/samagotchi/config.rb +910 -0
  53. data/lib/samagotchi/context_note.rb +77 -0
  54. data/lib/samagotchi/context_quote.rb +21 -0
  55. data/lib/samagotchi/context_usage.rb +66 -0
  56. data/lib/samagotchi/context_window.rb +76 -0
  57. data/lib/samagotchi/debug_log.rb +110 -0
  58. data/lib/samagotchi/desktop/macos/App.swift +102 -0
  59. data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
  60. data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
  61. data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
  62. data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
  63. data/lib/samagotchi/desktop/macos.rb +255 -0
  64. data/lib/samagotchi/desktop.rb +21 -0
  65. data/lib/samagotchi/desktop_command.rb +143 -0
  66. data/lib/samagotchi/engine.rb +2807 -0
  67. data/lib/samagotchi/guardrails/approval.rb +125 -0
  68. data/lib/samagotchi/guardrails/approvals.rb +177 -0
  69. data/lib/samagotchi/guardrails/context.rb +71 -0
  70. data/lib/samagotchi/guardrails/gate.rb +125 -0
  71. data/lib/samagotchi/guardrails/load_failures.rb +46 -0
  72. data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
  73. data/lib/samagotchi/guardrails/rules.rb +199 -0
  74. data/lib/samagotchi/guardrails/targets.rb +119 -0
  75. data/lib/samagotchi/guardrails/verdict.rb +134 -0
  76. data/lib/samagotchi/guardrails.rb +18 -0
  77. data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
  78. data/lib/samagotchi/hooks/loader.rb +162 -0
  79. data/lib/samagotchi/hooks/registry.rb +261 -0
  80. data/lib/samagotchi/hooks.rb +30 -0
  81. data/lib/samagotchi/host_registry.rb +315 -0
  82. data/lib/samagotchi/idle_client.rb +147 -0
  83. data/lib/samagotchi/idle_recap.rb +549 -0
  84. data/lib/samagotchi/idle_reminders.rb +101 -0
  85. data/lib/samagotchi/idle_scheduler.rb +76 -0
  86. data/lib/samagotchi/image_store.rb +393 -0
  87. data/lib/samagotchi/installed_gem.rb +38 -0
  88. data/lib/samagotchi/kernel_loop.rb +1017 -0
  89. data/lib/samagotchi/launch_mode.rb +34 -0
  90. data/lib/samagotchi/llm/backend.rb +28 -0
  91. data/lib/samagotchi/llm/chat_loop.rb +450 -0
  92. data/lib/samagotchi/llm/errors.rb +329 -0
  93. data/lib/samagotchi/llm/http.rb +412 -0
  94. data/lib/samagotchi/llm/model_result.rb +72 -0
  95. data/lib/samagotchi/llm/native_backend.rb +50 -0
  96. data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
  97. data/lib/samagotchi/llm/openai_chat.rb +403 -0
  98. data/lib/samagotchi/llm/usage.rb +79 -0
  99. data/lib/samagotchi/log.rb +200 -0
  100. data/lib/samagotchi/log_line.rb +127 -0
  101. data/lib/samagotchi/log_path.rb +31 -0
  102. data/lib/samagotchi/log_subscriber.rb +163 -0
  103. data/lib/samagotchi/memory_bundle/builder.rb +364 -0
  104. data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
  105. data/lib/samagotchi/memory_bundle/installer.rb +528 -0
  106. data/lib/samagotchi/memory_bundle/listing.rb +72 -0
  107. data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
  108. data/lib/samagotchi/memory_bundle/merger.rb +52 -0
  109. data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
  110. data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
  111. data/lib/samagotchi/memory_bundle/source.rb +153 -0
  112. data/lib/samagotchi/memory_bundle/status.rb +107 -0
  113. data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
  114. data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
  115. data/lib/samagotchi/memory_bundle.rb +17 -0
  116. data/lib/samagotchi/memory_paths.rb +101 -0
  117. data/lib/samagotchi/model_overlay.rb +53 -0
  118. data/lib/samagotchi/model_profile.rb +309 -0
  119. data/lib/samagotchi/muted_memories.rb +66 -0
  120. data/lib/samagotchi/note_command.rb +163 -0
  121. data/lib/samagotchi/output_formatter.rb +100 -0
  122. data/lib/samagotchi/owner_lock.rb +110 -0
  123. data/lib/samagotchi/pending_input_queue.rb +48 -0
  124. data/lib/samagotchi/plugin/api.rb +362 -0
  125. data/lib/samagotchi/plugin/context.rb +193 -0
  126. data/lib/samagotchi/plugin/loader.rb +126 -0
  127. data/lib/samagotchi/plugin/service.rb +117 -0
  128. data/lib/samagotchi/plugin/sessions.rb +150 -0
  129. data/lib/samagotchi/plugin/side_question.rb +60 -0
  130. data/lib/samagotchi/plugin/tool_result.rb +24 -0
  131. data/lib/samagotchi/project_scope.rb +25 -0
  132. data/lib/samagotchi/prompt.rb +119 -0
  133. data/lib/samagotchi/prompt_literal_guard.rb +70 -0
  134. data/lib/samagotchi/recap_store.rb +92 -0
  135. data/lib/samagotchi/reminder_store.rb +165 -0
  136. data/lib/samagotchi/self_report.rb +195 -0
  137. data/lib/samagotchi/send_command.rb +170 -0
  138. data/lib/samagotchi/served_model.rb +32 -0
  139. data/lib/samagotchi/session.rb +508 -0
  140. data/lib/samagotchi/session_commands.rb +527 -0
  141. data/lib/samagotchi/session_delete_command.rb +105 -0
  142. data/lib/samagotchi/session_manager.rb +1049 -0
  143. data/lib/samagotchi/session_metrics.rb +466 -0
  144. data/lib/samagotchi/session_observer.rb +117 -0
  145. data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
  146. data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
  147. data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
  148. data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
  149. data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
  150. data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
  151. data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
  152. data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
  153. data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
  154. data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
  155. data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
  156. data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
  157. data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
  158. data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
  159. data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
  160. data/lib/samagotchi/terminal_ui/screen.rb +316 -0
  161. data/lib/samagotchi/terminal_ui/surface.rb +47 -0
  162. data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
  163. data/lib/samagotchi/terminal_ui.rb +1992 -0
  164. data/lib/samagotchi/thinking_ticker.rb +110 -0
  165. data/lib/samagotchi/thought_stream_splitter.rb +149 -0
  166. data/lib/samagotchi/token_usage.rb +88 -0
  167. data/lib/samagotchi/tool_activity.rb +216 -0
  168. data/lib/samagotchi/tool_call_parser.rb +637 -0
  169. data/lib/samagotchi/tool_declarations.rb +561 -0
  170. data/lib/samagotchi/tool_runner.rb +211 -0
  171. data/lib/samagotchi/tools/args.rb +259 -0
  172. data/lib/samagotchi/tools/ask_user_question.rb +152 -0
  173. data/lib/samagotchi/tools/builtins.rb +122 -0
  174. data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
  175. data/lib/samagotchi/tools/delegate.rb +167 -0
  176. data/lib/samagotchi/tools/delegate_result.rb +53 -0
  177. data/lib/samagotchi/tools/delegate_wait.rb +153 -0
  178. data/lib/samagotchi/tools/edit.rb +155 -0
  179. data/lib/samagotchi/tools/execute.rb +214 -0
  180. data/lib/samagotchi/tools/list_reminders.rb +20 -0
  181. data/lib/samagotchi/tools/list_sessions.rb +74 -0
  182. data/lib/samagotchi/tools/memory.rb +256 -0
  183. data/lib/samagotchi/tools/output_guardrails.rb +93 -0
  184. data/lib/samagotchi/tools/peers.rb +18 -0
  185. data/lib/samagotchi/tools/read.rb +182 -0
  186. data/lib/samagotchi/tools/register_reminder.rb +53 -0
  187. data/lib/samagotchi/tools/registry.rb +60 -0
  188. data/lib/samagotchi/tools/send_note.rb +49 -0
  189. data/lib/samagotchi/tools/task_create.rb +29 -0
  190. data/lib/samagotchi/tools/task_get.rb +39 -0
  191. data/lib/samagotchi/tools/task_list.rb +43 -0
  192. data/lib/samagotchi/tools/task_runtime.rb +311 -0
  193. data/lib/samagotchi/tools/task_stop.rb +29 -0
  194. data/lib/samagotchi/tools/task_wait.rb +104 -0
  195. data/lib/samagotchi/tools/tool_path.rb +18 -0
  196. data/lib/samagotchi/tools/web_fetch.rb +163 -0
  197. data/lib/samagotchi/tools/write.rb +26 -0
  198. data/lib/samagotchi/turn_flow.rb +242 -0
  199. data/lib/samagotchi/turn_note.rb +76 -0
  200. data/lib/samagotchi/turn_tally.rb +101 -0
  201. data/lib/samagotchi/version.rb +7 -0
  202. data/lib/samagotchi/vision_context.rb +132 -0
  203. data/lib/samagotchi/vision_support.rb +109 -0
  204. data/lib/samagotchi/web/app.rb +1349 -0
  205. data/lib/samagotchi/web/markdown_renderer.rb +107 -0
  206. data/lib/samagotchi/web/message_parts.rb +169 -0
  207. data/lib/samagotchi/web/public/activity.js +100 -0
  208. data/lib/samagotchi/web/public/annotations.js +67 -0
  209. data/lib/samagotchi/web/public/app.js +2382 -0
  210. data/lib/samagotchi/web/public/card.js +74 -0
  211. data/lib/samagotchi/web/public/chat_view.js +360 -0
  212. data/lib/samagotchi/web/public/chunk_router.js +25 -0
  213. data/lib/samagotchi/web/public/command_complete.js +39 -0
  214. data/lib/samagotchi/web/public/composer_size.js +19 -0
  215. data/lib/samagotchi/web/public/copy.js +142 -0
  216. data/lib/samagotchi/web/public/ctx.js +35 -0
  217. data/lib/samagotchi/web/public/data.js +256 -0
  218. data/lib/samagotchi/web/public/format.js +232 -0
  219. data/lib/samagotchi/web/public/hold.js +78 -0
  220. data/lib/samagotchi/web/public/images.js +77 -0
  221. data/lib/samagotchi/web/public/index.html +568 -0
  222. data/lib/samagotchi/web/public/init_row.js +60 -0
  223. data/lib/samagotchi/web/public/model_pick.js +23 -0
  224. data/lib/samagotchi/web/public/question_card.js +100 -0
  225. data/lib/samagotchi/web/public/route.js +17 -0
  226. data/lib/samagotchi/web/public/scope.js +36 -0
  227. data/lib/samagotchi/web/public/scroll.js +24 -0
  228. data/lib/samagotchi/web/public/sentences.js +88 -0
  229. data/lib/samagotchi/web/public/sessions_list.js +60 -0
  230. data/lib/samagotchi/web/public/strip.js +25 -0
  231. data/lib/samagotchi/web/public/tally.js +37 -0
  232. data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
  233. data/lib/samagotchi/web/public/timing.js +185 -0
  234. data/lib/samagotchi/web/public/turn_events.js +209 -0
  235. data/lib/samagotchi/web/public/turn_model.js +204 -0
  236. data/lib/samagotchi/web/public/turn_view.js +587 -0
  237. data/lib/samagotchi/web/server.rb +183 -0
  238. data/lib/samagotchi/web/session_hub.rb +329 -0
  239. data/lib/samagotchi/web/session_summary.rb +85 -0
  240. data/lib/samagotchi/worker.rb +635 -0
  241. data/lib/samagotchi/worker_idle_exit.rb +87 -0
  242. data/lib/samagotchi.rb +12 -0
  243. metadata +374 -0
@@ -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.