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,100 @@
1
+ # The btw bundle (docs/plugins.md, The btw bundle): /btw <question> asks the
2
+ # session's model a side question about the conversation. The answer shows
3
+ # as a card in every UI and goes nowhere else: the conversation, the saved
4
+ # session and the model's next turn never see it. The card's "Keep as
5
+ # session" (/btw keep <id>) turns it into a child session that starts from
6
+ # the conversation, the question and the answer.
7
+ #
8
+ # Settings (config.yml, bundles: btw:): max_tokens (the answer's limit,
9
+ # default 1024) and timeout (seconds, default 120).
10
+ require "securerandom"
11
+
12
+ class Plugin
13
+ SYSTEM = "Answer the user's question about the conversation below, briefly. The user asks it on the side: " \
14
+ "don't continue the conversation's task, and don't say what you would do next."
15
+ # How many answers /btw keep can still fork: the last ones, for the
16
+ # worker's (or the REPL's) life.
17
+ KEPT = 10
18
+ TITLE_CHARS = 72
19
+ EARLIER = "(About the conversation before the running turn.)"
20
+
21
+ def initialize(settings = {})
22
+ @max_tokens = positive(settings["max_tokens"])
23
+ @timeout = positive(settings["timeout"]) || 120
24
+ @answers = {}
25
+ @mutex = Mutex.new
26
+ end
27
+
28
+ def register(chi)
29
+ chi.command "/btw", "ask a side question about this conversation; /btw keep <id> makes an answer a session",
30
+ anytime: true do |args, ctx|
31
+ if (keep = args[/\Akeep\s+(\S+)\z/, 1])
32
+ keep(keep, ctx)
33
+ elsif args.empty?
34
+ "usage: /btw <question> — a side answer about this conversation, as a card; nothing is saved"
35
+ else
36
+ ask(args, ctx)
37
+ end
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ # A card at once, then the same card with the answer.
44
+ def ask(question, ctx)
45
+ id = SecureRandom.hex(4)
46
+ card_id = "btw-#{id}"
47
+ title = "btw: #{cut(question)}"
48
+ messages = ctx.messages
49
+ about = ctx.messages_partial? ? "\n\n#{EARLIER}" : ""
50
+ ctx.card(id: card_id, title: title, body: "thinking…#{about}")
51
+ answer = begin
52
+ ctx.ask_model(messages: messages, prompt: question, system: SYSTEM, timeout: @timeout, max_tokens: @max_tokens)
53
+ rescue Samagotchi::Plugin::ModelError => e
54
+ ctx.card(id: card_id, title: title, body: "No answer: #{e.message}", level: :warn)
55
+ return nil
56
+ end
57
+ answer = "(the model gave no answer)" if answer.strip.empty?
58
+ remember(id, messages: messages, question: question, answer: answer, card_id: card_id, title: title, about: about)
59
+ ctx.card(id: card_id, title: title, body: "#{answer}#{about}",
60
+ actions: [{ label: "Keep as session", command: "/btw keep #{id}" }])
61
+ nil
62
+ end
63
+
64
+ # A child session from the conversation the answer was about, plus the
65
+ # question and the answer.
66
+ def keep(id, ctx)
67
+ entry = @mutex.synchronize { @answers[id] }
68
+ return "btw keep #{id}: expired (answers are kept for the last #{KEPT} questions, while this session's worker runs)" unless entry
69
+ return "btw keep #{id}: already kept as session #{entry[:child]}" if entry[:child]
70
+
71
+ seed = entry[:messages] + [{ role: "user", content: entry[:question] }, { role: "model", content: entry[:answer] }]
72
+ child = ctx.sessions.fork(messages: seed, title: "btw: #{entry[:question]}")
73
+ @mutex.synchronize { entry[:child] = child }
74
+ # The answer's card loses its action: it is kept.
75
+ ctx.card(id: entry[:card_id], title: entry[:title], body: "#{entry[:answer]}#{entry[:about]}")
76
+ ctx.card(title: "kept as #{child[0, 8]}",
77
+ body: "A session with this conversation, the question and the answer: `chi --attach #{child}`, " \
78
+ "or pick it in the web (↳ under this session).")
79
+ nil
80
+ rescue Samagotchi::Plugin::Sessions::Error => e
81
+ "btw keep #{id}: #{e.message}"
82
+ end
83
+
84
+ def remember(id, **entry)
85
+ @mutex.synchronize do
86
+ @answers[id] = entry
87
+ @answers.delete(@answers.keys.first) while @answers.size > KEPT
88
+ end
89
+ end
90
+
91
+ def cut(text)
92
+ line = text.gsub(/\s+/, " ").strip
93
+ line.length > TITLE_CHARS ? "#{line[0, TITLE_CHARS - 1]}…" : line
94
+ end
95
+
96
+ def positive(value)
97
+ number = Integer(value.to_s, exception: false)
98
+ number&.positive? ? number : nil
99
+ end
100
+ end
@@ -0,0 +1,82 @@
1
+ # Default guardrail rules (the optional guardrails bundle:
2
+ # `chi bundle install guardrails`). Text matches on shell commands catch
3
+ # accidents, not a determined model: `sh -c`, base64, a script written
4
+ # earlier or `git -C` variants can get past them.
5
+ #
6
+ # Fields: id, tool (a name, a list, or shell = execute + task_create),
7
+ # command (Ruby regex), path (outside_repo or a glob), verdict (ask|deny),
8
+ # reason, scopes (for an ask: once, session, repo, rule; default all).
9
+ # [^;&|\n]* keeps a match inside one command of a chain; the git rules match
10
+ # the subcommand after optional global options (git -C dir push).
11
+ rules:
12
+ # ── git: publishing and rewriting history ──────────────────────────────
13
+ - id: git-push
14
+ tool: shell
15
+ command: '\bgit(?:\s+(?:-C\s+\S+|-c\s+\S+|--?[A-Za-z][\w-]*(?:=\S+)?))*\s+push\b'
16
+ verdict: ask
17
+ reason: git push publishes commits
18
+ - id: git-reset-hard
19
+ tool: shell
20
+ command: '\bgit(?:\s+(?:-C\s+\S+|-c\s+\S+|--?[A-Za-z][\w-]*(?:=\S+)?))*\s+reset\b[^;&|\n]*\s--hard\b'
21
+ verdict: ask
22
+ reason: git reset --hard discards uncommitted work
23
+ - id: git-clean-force
24
+ tool: shell
25
+ command: '\bgit(?:\s+(?:-C\s+\S+|-c\s+\S+|--?[A-Za-z][\w-]*(?:=\S+)?))*\s+clean\b[^;&|\n]*\s(-[a-zA-Z]*f|--force)'
26
+ verdict: ask
27
+ reason: git clean -f deletes untracked files
28
+ - id: git-branch-force-delete
29
+ tool: shell
30
+ command: '\bgit(?:\s+(?:-C\s+\S+|-c\s+\S+|--?[A-Za-z][\w-]*(?:=\S+)?))*\s+branch\b[^;&|\n]*\s(-[a-zA-Z]*D|--delete\s+--force|--force\s+--delete)\b'
31
+ verdict: ask
32
+ reason: git branch -D deletes a branch even if it is not merged
33
+ - id: git-rebase
34
+ tool: shell
35
+ command: '\bgit(?:\s+(?:-C\s+\S+|-c\s+\S+|--?[A-Za-z][\w-]*(?:=\S+)?))*\s+rebase\b'
36
+ verdict: ask
37
+ reason: git rebase rewrites history
38
+ - id: git-history-rewrite
39
+ tool: shell
40
+ command: '\bgit(?:\s+(?:-C\s+\S+|-c\s+\S+|--?[A-Za-z][\w-]*(?:=\S+)?))*\s+(filter-branch|filter-repo)\b'
41
+ verdict: ask
42
+ reason: filter-branch / filter-repo rewrite the whole history
43
+
44
+ # ── deleting ───────────────────────────────────────────────────────────
45
+ # rm with both -r and -f on an absolute path, ~, $HOME or a path with ..
46
+ # (a text heuristic; rm -rf build/ in the repo is not asked).
47
+ - id: rm-rf-wide
48
+ tool: shell
49
+ command: '\brm\b(?=[^;&|\n]*\s(-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)(\s|$))(?=[^;&|\n]*\s(-[a-zA-Z]*f[a-zA-Z]*|--force)(\s|$))[^;&|\n]*\s(/|~|\$HOME|\S*\.\.)'
50
+ verdict: ask
51
+ reason: rm -rf on a path outside the working tree
52
+
53
+ # ── running code from the network ──────────────────────────────────────
54
+ - id: pipe-to-shell
55
+ tool: shell
56
+ command: '\b(curl|wget)\b[^;&\n]*\|\s*(sudo\s+)?(ba|z|da)?sh\b'
57
+ verdict: ask
58
+ reason: runs a script downloaded from the network
59
+ - id: base64-to-shell
60
+ tool: shell
61
+ command: '\bbase64\s+(-d|-D|--decode)\b[^;&\n]*\|\s*(sudo\s+)?(ba|z|da)?sh\b'
62
+ verdict: ask
63
+ reason: runs decoded text as a script
64
+
65
+ # ── files outside the repo, and git hooks ──────────────────────────────
66
+ - id: write-outside-repo
67
+ tool: [write, edit]
68
+ path: outside_repo
69
+ verdict: ask
70
+ reason: writes outside the repository
71
+ - id: git-hooks-write
72
+ tool: [write, edit]
73
+ path: '**/.git/hooks/**'
74
+ verdict: deny
75
+ reason: git hooks run code on every git command
76
+
77
+ # ── chi's own config, hooks, rules and approvals, from the shell ───────
78
+ - id: shell-touches-chi
79
+ tool: shell
80
+ command: '(\.config/samagotchi|samagotchi/config\.yml|samagotchi/hooks|samagotchi/guardrails|memories/\.bundles|\.git/hooks)'
81
+ verdict: ask
82
+ reason: the command names chi's config, hooks, guardrails or git hooks
@@ -0,0 +1,14 @@
1
+ # Guardrails
2
+
3
+ Some tool calls pass a guardrail check before they run. The user sets the rules (config.yml `guardrails:`, installed bundles such as this one, and hooks). Chi itself also protects its approval store, installed bundles, config.yml and the hooks dir.
4
+
5
+ - **ask**: the user sees the call (tool, command or paths, where, why) and allows it once, for the session, for this repo, or for the whole rule in this repo, or denies it.
6
+ - **deny**: the call does not run.
7
+
8
+ A call a rule or hook denies comes back as `[tool] Error: denied by guardrail (rule <id>, <source>): <reason>. …`. A call the user declined when asked comes back as `[tool] Error: The user declined this call… It needed approval (rule <id>, <source>): <reason>. …`, sometimes with the user's reason: that is the user's answer, not the rule's.
9
+
10
+ When a call is denied:
11
+ - Do not retry it, and do not reach the same result another way: a different command, `sh -c`, a script, another tool, or editing the rules, hooks or config.
12
+ - Say what you wanted to do and why, and ask the user how to proceed.
13
+
14
+ `/guardrails` lists the rules and the user's stored approvals (the user can revoke them there). The approvals and installed bundles cannot be written with the file tools.
@@ -0,0 +1,8 @@
1
+ ---
2
+ name: guardrails
3
+ version: 0.1.1
4
+ scope: system
5
+ description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, writes outside the repo, git hooks) and a note for the model
6
+ trust_level: reviewed
7
+ files:
8
+ guardrails.md: sha256:04207fd1738aeeeef3b7a8053fc57597565bddd7862f01acaba784419cbd909b
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A before_tool_call guard against near-misses of protected identifiers in
4
+ # a tool call's command or paths: the user's home folder name, login, git
5
+ # name and email, the repo name, and any names from config. A local model
6
+ # that once misspelled a name in a path keeps copying the wrong spelling;
7
+ # a rejection that names the right one breaks the loop.
8
+ #
9
+ # Settings (config.yml, `bundles: known-names:`), all optional:
10
+ # names: [jonathandoe] names to protect besides the derived ones
11
+ # mode: reject reject (default) | correct | ask
12
+ # derive: [home, user, git, repo] which names to derive (default all)
13
+ # ignore: [jondoe] names never to protect (a real near name)
14
+ # min_length: 6 shorter names and tokens are skipped
15
+ # max_distance: 2 edits allowed; default 1 for a name under
16
+ # 10 characters, 2 otherwise
17
+ class KnownNames
18
+ MODES = %w[reject ask correct].freeze
19
+ DERIVATIONS = %w[home user git repo].freeze
20
+ DEFAULT_MIN_LENGTH = 6
21
+ # Splits a command or path into tokens; each token's [-_.] parts are
22
+ # candidates too.
23
+ TOKEN_SPLIT = %r{[/\s"'=:,;|&()<>@]+}.freeze
24
+ PART_SPLIT = /[-_.]+/.freeze
25
+ ASK_OPTIONS = ["Correct it and run", "Run as is", "Deny"].freeze
26
+
27
+ def initialize(settings = {})
28
+ settings = {} unless settings.is_a?(Hash)
29
+ @names = list(settings["names"])
30
+ @ignore = list(settings["ignore"]).map(&:downcase)
31
+ @mode = MODES.include?(settings["mode"].to_s) ? settings["mode"].to_s : "reject"
32
+ derive = settings.key?("derive") ? list(settings["derive"]) & DERIVATIONS : DERIVATIONS
33
+ @derive = derive
34
+ @min_length = [settings["min_length"].to_i, 1].max
35
+ @min_length = DEFAULT_MIN_LENGTH if settings["min_length"].nil?
36
+ max = settings["max_distance"].to_i
37
+ @max_distance = max.positive? ? max : nil
38
+ @git_cache = {}
39
+ end
40
+
41
+ def call(event)
42
+ return unless event.is_a?(Hash) && event[:type] == :before_tool_call
43
+
44
+ hit = find_near_miss(event)
45
+ return unless hit
46
+
47
+ case @mode
48
+ when "correct" then correct(event, hit)
49
+ when "ask" then ask(event, hit)
50
+ else reject(event, hit)
51
+ end
52
+ rescue StandardError => e
53
+ event[:notify]&.call("known-names failed: #{e.class}: #{e.message}", level: :warn)
54
+ end
55
+
56
+ # Damerau-Levenshtein distance (optimal string alignment: an adjacent
57
+ # transposition counts as one edit).
58
+ def self.distance(a, b)
59
+ a = a.to_s
60
+ b = b.to_s
61
+ return b.length if a.empty?
62
+ return a.length if b.empty?
63
+
64
+ prev2 = nil
65
+ prev = (0..b.length).to_a
66
+ a.each_char.with_index(1) do |ca, i|
67
+ row = [i]
68
+ b.each_char.with_index(1) do |cb, j|
69
+ cost = ca == cb ? 0 : 1
70
+ best = [prev[j] + 1, row[j - 1] + 1, prev[j - 1] + cost].min
71
+ if i > 1 && j > 1 && ca == b[j - 2] && a[i - 2] == cb
72
+ best = [best, prev2[j - 2] + 1].min
73
+ end
74
+ row << best
75
+ end
76
+ prev2 = prev
77
+ prev = row
78
+ end
79
+ prev.last
80
+ end
81
+
82
+ private
83
+
84
+ # The first candidate that is a near miss of a known name.
85
+ # @return [Hash, nil] {miss:, name:, distance:, where:}
86
+ def find_near_miss(event)
87
+ names = known_names(event)
88
+ return nil if names.empty?
89
+
90
+ scan_targets(event).each do |where, text|
91
+ candidates(text).each do |candidate|
92
+ down = candidate.downcase
93
+ next if names.key?(down)
94
+
95
+ names.each do |name_down, name|
96
+ max = @max_distance || (name.length < 10 ? 1 : 2)
97
+ next if (candidate.length - name.length).abs > max
98
+
99
+ d = self.class.distance(down, name_down)
100
+ return { miss: candidate, name: name, distance: d, where: where } if d.between?(1, max)
101
+ end
102
+ end
103
+ end
104
+ nil
105
+ end
106
+
107
+ # [where, text] pairs to scan: the command, the cwd a command names,
108
+ # each path, and a write/edit call's own path (in case normalisation
109
+ # dropped it). Not file contents.
110
+ def scan_targets(event)
111
+ targets = event[:targets].is_a?(Hash) ? event[:targets] : {}
112
+ call = event[:call].is_a?(Hash) ? event[:call] : {}
113
+ pairs = []
114
+ pairs << ["command", targets[:command].to_s] unless targets[:command].to_s.empty?
115
+ pairs << ["cwd", call[:cwd].to_s] unless call[:cwd].to_s.strip.empty?
116
+ Array(targets[:paths]).each { |path| pairs << ["path", path.to_s] }
117
+ pairs << ["path", call[:path].to_s] if %w[write edit].include?(call[:name].to_s) && !call[:path].to_s.empty?
118
+ pairs
119
+ end
120
+
121
+ def candidates(text)
122
+ text.split(TOKEN_SPLIT).flat_map { |token| [token] + token.split(PART_SPLIT) }
123
+ .uniq.select { |c| c.length >= @min_length }
124
+ end
125
+
126
+ # downcased name => name as given, minus ignored and short ones
127
+ def known_names(event)
128
+ names = @names + derived_names(event)
129
+ names.each_with_object({}) do |name, acc|
130
+ next if name.length < @min_length
131
+ down = name.downcase
132
+ next if @ignore.include?(down) || acc.key?(down)
133
+
134
+ acc[down] = name
135
+ end
136
+ end
137
+
138
+ def derived_names(event)
139
+ names = []
140
+ names << File.basename(Dir.home) if @derive.include?("home")
141
+ names << ENV["USER"].to_s if @derive.include?("user")
142
+ context = event[:context].is_a?(Hash) ? event[:context] : {}
143
+ root = context[:repo_root].to_s
144
+ names << File.basename(root) if @derive.include?("repo") && !root.empty?
145
+ names.concat(git_names(root)) if @derive.include?("git") && !root.empty?
146
+ names.map(&:to_s).map(&:strip).reject(&:empty?)
147
+ rescue StandardError
148
+ names
149
+ end
150
+
151
+ # The words of git's user.name and the local part of user.email, one
152
+ # git call per repo root.
153
+ def git_names(root)
154
+ @git_cache[root] ||= begin
155
+ name = git_config(root, "user.name")
156
+ email = git_config(root, "user.email")
157
+ name.split(/\s+/) + [email.split("@").first.to_s]
158
+ end
159
+ end
160
+
161
+ def git_config(root, key)
162
+ IO.popen(["git", "-C", root, "config", "--get", key], err: File::NULL, &:read).to_s.strip
163
+ rescue StandardError
164
+ ""
165
+ end
166
+
167
+ def reject(event, hit)
168
+ tool = event[:call].is_a?(Hash) ? event[:call][:name] : nil
169
+ edits = hit[:distance] == 1 ? "1 edit" : "#{hit[:distance]} edits"
170
+ event[:guardrail]&.deny!(
171
+ "\"#{hit[:miss]}\" in the #{hit[:where]} is #{edits} away from the known name \"#{hit[:name]}\"",
172
+ source: "hook known_names, bundle known-names",
173
+ advice: "Retry with \"#{hit[:name]}\". If \"#{hit[:miss]}\" is really what you meant, say so to the user instead of retrying."
174
+ )
175
+ event[:notify]&.call("rejected #{tool}: \"#{hit[:miss]}\" looks like \"#{hit[:name]}\"")
176
+ end
177
+
178
+ # The same call with the near miss replaced (whole tokens, everywhere it
179
+ # appears in the content, path and cwd).
180
+ def correct(event, hit)
181
+ call = event[:call]
182
+ pattern = /(?<![A-Za-z0-9])#{Regexp.escape(hit[:miss])}(?![A-Za-z0-9])/
183
+ fixed = call.dup
184
+ %i[content path cwd].each do |key|
185
+ next unless fixed[key].is_a?(String)
186
+
187
+ fixed[key] = fixed[key].gsub(pattern, hit[:name])
188
+ end
189
+ event[:call] = fixed
190
+ event[:notify]&.call("corrected \"#{hit[:miss]}\" → \"#{hit[:name]}\" in #{call[:name]}")
191
+ end
192
+
193
+ def ask(event, hit)
194
+ tool = event[:call].is_a?(Hash) ? event[:call][:name] : nil
195
+ answer = event[:ask_user]&.call(
196
+ question: "#{tool}: #{event[:params]}\n\"#{hit[:miss]}\" looks like a misspelling of \"#{hit[:name]}\".",
197
+ options: ASK_OPTIONS, header: "known-names"
198
+ )
199
+ choice = answer.is_a?(Hash) ? Array(answer[:selected]).first.to_s : ""
200
+ case choice
201
+ when ASK_OPTIONS[0] then correct(event, hit)
202
+ when ASK_OPTIONS[1] then nil
203
+ else reject(event, hit)
204
+ end
205
+ end
206
+
207
+ def list(value)
208
+ Array(value).map(&:to_s).map(&:strip).reject(&:empty?)
209
+ end
210
+ end
@@ -0,0 +1,3 @@
1
+ # Known names
2
+
3
+ A tool call that comes back as `[tool] Error: denied by guardrail (hook known_names, bundle known-names): "…" in the command is N edit(s) away from the known name "…"` was caught misspelling a protected name (the user's home folder, login, git name, the repo name). Retry the same call with the name the error gives, spelled exactly like that. If the other spelling really is what you meant, say so to the user instead of retrying.
@@ -0,0 +1,14 @@
1
+ ---
2
+ name: known-names
3
+ version: 0.1.1
4
+ scope: system
5
+ description: Catches a near-miss of a protected name (home folder, login, git name, repo name, names from config) in a tool call and rejects it with the right spelling; mode ask / correct optional
6
+ trust_level: reviewed
7
+ files:
8
+ known_names.md: sha256:d5348b95b6c655017b541910b36e384220da280818160201f6ec9a50e1e4287c
9
+ hooks:
10
+ known_names.rb:
11
+ sha256: sha256:2ccfe9124133d6d12499cb41ad7bdfd4ba59492c067efbf60eaa1400a7cc2504
12
+ event: before_tool_call
13
+ on_error: log
14
+ priority: 50
@@ -0,0 +1,10 @@
1
+ ---
2
+ name: loop-guard
3
+ version: 0.1.0
4
+ scope: system
5
+ description: "Breaks tool-call loops: the same call with the same result again and again in one turn is denied with advice, and after a few denies the turn stops with a card"
6
+ trust_level: reviewed
7
+ plugin:
8
+ file: plugin.rb
9
+ sha256: sha256:f9e66f289f3bb92ba53e6a16ea563c16e7b814accb607a34f20585cf563b7ef5
10
+ requires_chi: ">= 0.1.30"
@@ -0,0 +1,158 @@
1
+ # The loop-guard bundle (docs/plugins.md, The loop-guard bundle): a model
2
+ # that runs the same tool call again and again in one turn, getting the
3
+ # same result each time, is told to stop (a guardrail deny with advice),
4
+ # and after a few such denies the turn is stopped with a card that lists
5
+ # the repeated calls.
6
+ #
7
+ # A call is keyed by its tool and its arguments (whitespace collapsed), its
8
+ # result by a SHA1 of the output. Once a key has returned the same result
9
+ # deny_after times this turn, the next identical call is denied. A denied
10
+ # call records no result, so the deny sticks. The counts are per turn:
11
+ # before_turn resets them (a new user message can make an old call right
12
+ # again); steering merged mid-turn does not.
13
+ #
14
+ # Settings (config.yml, bundles: loop-guard:):
15
+ # deny_after: 2 same call + same result this many times -> deny the next
16
+ # stop_after: 4 stop the turn at this many loop-guard denies
17
+ # ignore_tools: [task_wait, task_get, delegate_result, list_sessions, list_reminders]
18
+ # mode: deny deny | notify (warn once per call, never deny or stop)
19
+ require "digest"
20
+
21
+ class Plugin
22
+ DEFAULT_IGNORE = %w[task_wait task_get delegate_result list_sessions list_reminders].freeze
23
+ # Built-in tools carry flat fields; plugin and unknown tools carry args:.
24
+ KEY_FIELDS = %i[content path start_line end_line cwd env scope].freeze
25
+ SHORT_CHARS = 60
26
+ SOURCE = "bundle loop-guard"
27
+
28
+ def initialize(settings = {})
29
+ settings = {} unless settings.is_a?(Hash)
30
+ @deny_after = positive(settings["deny_after"]) || 2
31
+ @stop_after = positive(settings["stop_after"]) || 4
32
+ @ignore = settings.key?("ignore_tools") ? Array(settings["ignore_tools"]).map(&:to_s) : DEFAULT_IGNORE
33
+ @mode = settings["mode"].to_s == "notify" ? :notify : :deny
34
+ reset
35
+ end
36
+
37
+ def register(chi)
38
+ chi.on(:before_turn) { |_event| reset }
39
+ chi.on(:before_tool_call) { |event, ctx| before(event, ctx) }
40
+ chi.on(:after_tool_call) { |event| after(event) }
41
+ end
42
+
43
+ private
44
+
45
+ def reset
46
+ @last_result = {} # key => result hash
47
+ @results = Hash.new(0) # [key, result hash] => times it ran with that result
48
+ @previews = {} # key => a short form of its last result
49
+ @attempts = Hash.new(0) # key => calls this turn, denied ones too
50
+ @warned = {} # key => true once notified this turn
51
+ @denials = 0
52
+ @pending = nil
53
+ end
54
+
55
+ def before(event, ctx)
56
+ call = event[:call]
57
+ verdict = event[:guardrail]
58
+ return unless call.is_a?(Hash) && verdict
59
+ return if @ignore.include?(call[:name].to_s)
60
+
61
+ key = key_for(call)
62
+ @pending = { key: key, verdict: verdict }
63
+ @attempts[key] += 1
64
+ # Another voter denied it already: it won't run, and isn't ours to count.
65
+ return if verdict.deny?
66
+
67
+ last = @last_result[key]
68
+ times = last ? @results[[key, last]] : 0
69
+ return if times < @deny_after
70
+
71
+ if @mode == :notify
72
+ warn(ctx, key, "repeated #{times} times with the same result")
73
+ return
74
+ end
75
+
76
+ verdict.deny!("repeated call", source: SOURCE, advice: advice(key, times))
77
+ warn(ctx, key, "repeated, denied")
78
+ @denials += 1
79
+ stop(event, ctx) if @denials == @stop_after
80
+ end
81
+
82
+ # A call's result, paired with the before_tool_call that keyed it. A call
83
+ # that was denied (by us or another voter) ran nothing: no result, or the
84
+ # deny text would become the key's result and the next call would pass.
85
+ def after(event)
86
+ pending = @pending
87
+ @pending = nil
88
+ return unless pending && event[:tool].to_s == pending[:key].first
89
+ return if pending[:verdict].deny?
90
+
91
+ key = pending[:key]
92
+ output = event[:output].to_s
93
+ hash = Digest::SHA1.hexdigest(output)
94
+ @last_result[key] = hash
95
+ @results[[key, hash]] += 1
96
+ @previews[key] = preview(output)
97
+ end
98
+
99
+ def stop(event, ctx)
100
+ event[:stop_turn]&.call("the model kept repeating the same calls")
101
+ repeated = @attempts.select { |key, n| n > 1 && @last_result[key] && @results[[key, @last_result[key]]] >= @deny_after }
102
+ lines = repeated.map do |key, n|
103
+ "- `#{short_key(key)}`: #{n} times, the same result each time (#{@previews[key]})"
104
+ end
105
+ ctx.card(title: "loop-guard stopped the turn",
106
+ body: "The model kept repeating these calls:\n\n#{lines.join("\n")}\n\n" \
107
+ "Tell it what to try instead, or where to look.",
108
+ level: :warn)
109
+ end
110
+
111
+ def warn(ctx, key, what)
112
+ return if @warned[key]
113
+
114
+ @warned[key] = true
115
+ ctx.notify("loop: #{short_key(key)} #{what}", level: :warn)
116
+ end
117
+
118
+ def advice(key, times)
119
+ "You already ran this exact call #{times} times this turn and it returned the same result each time " \
120
+ "(#{@previews[key]}). Don't repeat it. Try a different approach, or tell the user what you're stuck on."
121
+ end
122
+
123
+ def key_for(call)
124
+ fields = call[:args].is_a?(Hash) ? call[:args] : call.slice(*KEY_FIELDS).compact
125
+ [call[:name].to_s, normalize(fields)]
126
+ end
127
+
128
+ # Strings stripped and their whitespace collapsed; keys as strings.
129
+ def normalize(value)
130
+ case value
131
+ when String then value.gsub(/\s+/, " ").strip
132
+ when Hash then value.to_h { |k, v| [k.to_s, normalize(v)] }.sort.to_h
133
+ when Array then value.map { |v| normalize(v) }
134
+ else value
135
+ end
136
+ end
137
+
138
+ def short_key(key)
139
+ name, fields = key
140
+ text = fields.is_a?(Hash) ? fields.values.map(&:to_s).reject(&:empty?).join(" ") : fields.to_s
141
+ "#{name} #{cut(text)}".strip
142
+ end
143
+
144
+ # The output without its "[tool]" label, on one line.
145
+ def preview(output)
146
+ text = output.sub(/\A\[[\w.-]+\]\s*/, "").gsub(/\s+/, " ").strip
147
+ text.empty? ? "no output" : cut(text)
148
+ end
149
+
150
+ def cut(text)
151
+ text.length > SHORT_CHARS ? "#{text[0, SHORT_CHARS - 1]}…" : text
152
+ end
153
+
154
+ def positive(value)
155
+ number = Integer(value.to_s, exception: false)
156
+ number&.positive? ? number : nil
157
+ end
158
+ end
@@ -0,0 +1,11 @@
1
+ ---
2
+ name: mcp
3
+ version: 0.3.1
4
+ scope: system
5
+ description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session, its tools are mcp_<server>_<tool>; /mcp lists them"
6
+ trust_level: reviewed
7
+ files: {}
8
+ plugin:
9
+ file: plugin.rb
10
+ sha256: sha256:f7bfcaae51863c0dc2b057b0d30f79b7bed7935891e817fa7373f787e5c4ca5e
11
+ requires_chi: ">= 0.1.37"