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
|
@@ -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"
|