samagotchi 0.80.0 → 0.81.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 +4 -4
- data/CHANGELOG.md +30 -1
- data/docs/architecture.md +9 -1
- data/docs/cli.md +8 -0
- data/docs/configuration.md +3 -0
- data/docs/guardrails.md +26 -3
- data/docs/plugins.md +8 -3
- data/docs/sessions.md +8 -0
- data/docs/testing.md +19 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
- data/lib/samagotchi/bootstrap_command.rb +1 -1
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -4
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +31 -5
- data/lib/samagotchi/guardrails/data_readers.rb +214 -0
- data/lib/samagotchi/guardrails/rules.rb +4 -2
- data/lib/samagotchi/guardrails/shell_lex.rb +166 -28
- data/lib/samagotchi/guardrails/targets.rb +14 -0
- data/lib/samagotchi/host_config.rb +8 -8
- data/lib/samagotchi/idle_client.rb +41 -11
- data/lib/samagotchi/idle_recap.rb +6 -1
- data/lib/samagotchi/idle_target.rb +12 -4
- data/lib/samagotchi/llm/usage.rb +9 -3
- data/lib/samagotchi/model_price.rb +16 -0
- data/lib/samagotchi/plugin/context.rb +10 -3
- data/lib/samagotchi/plugin/service.rb +24 -1
- data/lib/samagotchi/recap_setup.rb +2 -1
- data/lib/samagotchi/session_metrics/side_totals.rb +110 -0
- data/lib/samagotchi/session_metrics.rb +46 -12
- data/lib/samagotchi/session_observer.rb +3 -0
- data/lib/samagotchi/side_usage.rb +38 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
- data/lib/samagotchi/terminal_ui/model_segments_text.rb +27 -3
- data/lib/samagotchi/testing/host.rb +2 -2
- data/lib/samagotchi/testing/recorder.rb +7 -4
- data/lib/samagotchi/testing/sandbox.rb +5 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +4 -1
- data/lib/samagotchi/web/public/ctx.js +32 -6
- data/lib/samagotchi/web/public/info_bar_model.js +1 -1
- data/lib/samagotchi/web/public/lists_view.js +1 -1
- data/lib/samagotchi/web/public/stream_handlers.js +7 -0
- data/lib/samagotchi/web/public/timing.js +6 -1
- data/lib/samagotchi/web/session_summary.rb +2 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e0e5c6acd4a2774291e421b71b7b5729b0e3031c9afaa46715f40a1d976ebed2
|
|
4
|
+
data.tar.gz: 6390322560ddd65a847ea4d3a122b3751be778c40eb573d457c617880daa0e06
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c1d209965a45e687b89280cbcecfe0b51798dcff1a418837b3604f4b3b1e69e8d0f210d8b4972c84912e274c7f738c322a2270af36833fe17049e999081a2705
|
|
7
|
+
data.tar.gz: 9c5d2a4e2205da68d70d99db385ab3f79ebec1f83f8762baaf36d3ae466fc98204c68bcd93ab1868127e703f5d50eaed0dd363ac910541849298c3bbc72bb931
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,34 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.81.0] - 2026-10-18
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Stats: side requests (the idle recap, `/btw` and plugins' `ctx.ask_model`) are counted on their own
|
|
16
|
+
`side requests:` line in `/stats` and the web's ctx tooltip, with their tokens and cost; the session's totals stay
|
|
17
|
+
its turns'.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Guardrails: a `command:` rule no longer matches the text of a heredoc its command only reads as data
|
|
22
|
+
(`git commit -F -`, `gh pr create --body-file -`, `cat` to stdout, a `"$(cat <<'EOF' …)"` commit message); a
|
|
23
|
+
heredoc fed to a shell or an interpreter, or piped on (also from a `( … )` or `{ … }` group), is still
|
|
24
|
+
matched. The bundle's `chi-answer-piped` (guardrails bundle 0.9.1) no longer asks about stdin from
|
|
25
|
+
`/dev/null`.
|
|
26
|
+
- Guardrails: the `rm` and `touches` checks now see a heredoc that bash runs after all — piped on from a `( … )` or
|
|
27
|
+
`{ … }` group, fed through `<( … )` / `>( … )`, or written with `&>` / `>& file` and then run — which they used to
|
|
28
|
+
read as plain data.
|
|
29
|
+
- Plugins: a `chi.service` that starts lazily from a tool or hook handler runs its start block outside the handler,
|
|
30
|
+
so Stop no longer cancels the service's startup `ctx.http` calls (as docs/plugins.md says).
|
|
31
|
+
- Bundles: the test kit's sandbox also points `XDG_DATA_HOME` and `XDG_CACHE_HOME` at its temp folder, so a bundle
|
|
32
|
+
test no longer writes to your own data or cache home when you set them.
|
|
33
|
+
- `chi bootstrap`: re-running it against a server the config already has says so, without the misleading "saved as …"
|
|
34
|
+
line before it.
|
|
35
|
+
|
|
36
|
+
Update with `chi update` (the system bundle 0.81.0, `guardrails` 0.9.1, `slow-commands` 0.2.3), then restart
|
|
37
|
+
`chi web`.
|
|
38
|
+
|
|
11
39
|
## [0.80.0] - 2026-10-17
|
|
12
40
|
|
|
13
41
|
### Added
|
|
@@ -3310,7 +3338,8 @@ and long-lived sessions.
|
|
|
3310
3338
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
3311
3339
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
3312
3340
|
|
|
3313
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
3341
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.81.0...HEAD
|
|
3342
|
+
[0.81.0]: https://github.com/dm1try/samagotchi/compare/v0.80.0...v0.81.0
|
|
3314
3343
|
[0.80.0]: https://github.com/dm1try/samagotchi/compare/v0.79.0...v0.80.0
|
|
3315
3344
|
[0.79.0]: https://github.com/dm1try/samagotchi/compare/v0.78.0...v0.79.0
|
|
3316
3345
|
[0.78.0]: https://github.com/dm1try/samagotchi/compare/v0.77.0...v0.78.0
|
data/docs/architecture.md
CHANGED
|
@@ -338,6 +338,14 @@ carries the generation's `speed` (`{decode_tps, source}`, null without one), its
|
|
|
338
338
|
counts (`prompt_tokens`, `cached_tokens`, `cache_write_tokens`: null when the server reported no writes) and
|
|
339
339
|
those running `tokens` and `model_segments`: the Engine closes the generation in `SessionMetrics#finish_generation` before relaying
|
|
340
340
|
it, so the web's info bar updates per generation without parsing chunk payloads.
|
|
341
|
+
Side requests (the idle recap, a plugin's `ctx.ask_model` such as `/btw`) are counted apart, in a `side` block
|
|
342
|
+
(only when there were any; `SessionMetrics::SideTotals`: requests, token sums, `cost_sum` null until reported,
|
|
343
|
+
`cost_estimate_sum` from the target host's price, `by_kind`): `IdleClient` hands each reply's usage to its
|
|
344
|
+
`on_usage:` callback, `Engine#note_side_usage`, which folds it (`SessionMetrics#add_side`) and announces
|
|
345
|
+
`:side_usage` (`kind`, the whole `side` block) under the event lock, so the blocks arrive in fold order, then saves
|
|
346
|
+
`analytics.json` between turns (a running turn's end saves it). `tokens`, `model_segments` and the turn records
|
|
347
|
+
stay the turns' only. `SessionMetrics#persist` is serialized (its own mutex, taken before the metrics one), so two
|
|
348
|
+
threads' saves land in snapshot order.
|
|
341
349
|
|
|
342
350
|
The page's frequent reads of `GET /api/sessions/:id` stay light too:
|
|
343
351
|
- `?tail=1&recent=1` (each turn's end, a cancel, an answer display): `{tail, session: {id, status,
|
|
@@ -523,7 +531,7 @@ A map of the parts the overview above doesn't name (paths under `lib/samagotchi/
|
|
|
523
531
|
| Attached context | `context_sources.rb` (`ContextSources`), `context_poller.rb`, `context_absorber.rb`, `peer_sources.rb` (`PeerSources`: the sources peer bundles declare) | External sources a session keeps fresh, and the peer loop's wakes ([context.md](context.md)). |
|
|
524
532
|
| LLM context strategies | `llm_context_strategy.rb`, `llm_context_view.rb`, `llm_context_stale.rb`, `llm_context_forget.rb` | What a request sends of the history (none, stale, forget) ([internals/llm-context-forget.md](internals/llm-context-forget.md)). |
|
|
525
533
|
| Session chains | `session_continue.rb` (`SessionContinue`), `session_chain.rb` (`SessionChain`) | A session that continues an earlier one. `SessionChain.opener_for` gives the text web `continue →` prefills in the new link: the previous link's own first typed message when that link continues one itself, else nil (the 201's `opener`; not saved) ([sessions.md](sessions.md)). |
|
|
526
|
-
| Analytics | `analytics_file.rb` (`AnalyticsFile`), `session_metrics/totals.rb` (`SessionMetrics::Totals`), `session_metrics/model_segment.rb` (`SessionMetrics::ModelSegment`) | A session's `analytics.json` (`SessionMetrics#persist`); the sums over its turn records (`Totals`: tokens, cost, speeds, kept as running counters) and their split per model after a `/model` switch (`ModelSegment`: a run of turns on one model, the `model_segments` rows). |
|
|
534
|
+
| Analytics | `analytics_file.rb` (`AnalyticsFile`), `session_metrics/totals.rb` (`SessionMetrics::Totals`), `session_metrics/model_segment.rb` (`SessionMetrics::ModelSegment`) | A session's `analytics.json` (`SessionMetrics#persist`); the sums over its turn records (`Totals`: tokens, cost, speeds, kept as running counters) and their split per model after a `/model` switch (`ModelSegment`: a run of turns on one model, the `model_segments` rows). `session_metrics/side_totals.rb` (`SessionMetrics::SideTotals`) and `side_usage.rb` (`SideUsage`): the side requests' sums, apart from the turns'. |
|
|
527
535
|
| Images / vision | `image_store.rb` (`ImageStore`), `vision_support.rb`, `vision_context.rb` | Stored prompt images and whether a model takes them. |
|
|
528
536
|
| Toolkits | `toolkit.rb` (`Toolkit`), `toolkit_binding.rb` (`ToolkitBinding`, the Engine's seam), `toolkit_command.rb` (`/toolkit`), `tools/registry.rb` (`Registry#toolkit`, `#left_out_names`), `web/public/toolkit_chip.js` | Which registered tools a session's prompts declare: saved in the session, resolved at a turn's start (`Engine#apply_toolkit!`), named in the prompt's not-loaded line, a kit's prompt text after the appended files; `chi.toolkit` and `ctx.use_toolkit` for plugins ([configuration.md](configuration.md#toolkits)). |
|
|
529
537
|
| Tool index | `tools/tool_index.rb` (`Tools::ToolIndex`) | `find_tools` and `call_tool`: the tools a session knows but doesn't declare one by one (an mcp bundle's servers through `chi.tool_source`, the built-ins its toolkit leaves out), searched and called by name ([plugins.md](plugins.md)). |
|
data/docs/cli.md
CHANGED
|
@@ -486,6 +486,14 @@ Generations under 8 tokens or 100 ms get no speed of their own, nor does a
|
|
|
486
486
|
reply without server token counts or a non-streamed one. A delegated child
|
|
487
487
|
keeps its own numbers: a parent's tokens and cost leave them out.
|
|
488
488
|
|
|
489
|
+
Side requests have their own line, right after `cost:` (in its place without one), only when the session made any:
|
|
490
|
+
`side requests: 3 (recap 2, btw 1) · 14.2k in / 380 out · $0.0012 + ~$0.0009 (not in the totals above)`. It
|
|
491
|
+
counts the idle recap (`recap`), `/btw` and every other plugin's `ctx.ask_model` (under the asking bundle's name),
|
|
492
|
+
with their tokens and cost: a reported cost as is, else estimated from the price of the model they asked, on its
|
|
493
|
+
host (as a turn's, `~`). They are never in the tokens, `models:` or `cost:` lines above it, which stay the turns'.
|
|
494
|
+
A recap thrown away (stale, overdue) still counts: it was billed. `chi sessions stats` shows the line too (saved
|
|
495
|
+
between turns), and the web's ctx tooltip has the same words.
|
|
496
|
+
|
|
489
497
|
After a `/model` switch (or a hook's) the tokens line becomes a `models:` block, a row per run of turns on one
|
|
490
498
|
model, in order (A → B → A is three rows): the model (`host:model` as asked), when (`10:43–now` while it runs; with
|
|
491
499
|
the day when the rows span several), turns, tokens in/out, its average speed and cost. Under the row of the model
|
data/docs/configuration.md
CHANGED
|
@@ -573,6 +573,9 @@ hosts:
|
|
|
573
573
|
- The estimate is saved apart from the reported cost (`cost_estimate` per turn, `cost_estimate_sum` in the
|
|
574
574
|
session's totals in `analytics.json`) and shows with a `~`.
|
|
575
575
|
- A server that sends no cached counts has its whole prompt priced at `input`: an overestimate.
|
|
576
|
+
- Side requests (the idle recap, `/btw`, a plugin's `ctx.ask_model`) are priced by the same rule from the price of
|
|
577
|
+
the model they asked on its host: the recap's host and model (`recap.*`, else the session's), `ask_model(model:)`'s
|
|
578
|
+
host. They count on `/stats`' own `side requests:` line, never in the session's `cost:`.
|
|
576
579
|
|
|
577
580
|
## Thinking
|
|
578
581
|
|
data/docs/guardrails.md
CHANGED
|
@@ -230,7 +230,7 @@ guardrails:
|
|
|
230
230
|
rules:
|
|
231
231
|
- id: git-push
|
|
232
232
|
tool: shell # execute + task_create; or a tool name, a glob, or a list
|
|
233
|
-
command: '\bgit\s+push\b' # Ruby regex on the command
|
|
233
|
+
command: '\bgit\s+push\b' # Ruby regex on the command (data heredoc bodies left out)
|
|
234
234
|
verdict: ask # ask | deny
|
|
235
235
|
reason: git push publishes commits
|
|
236
236
|
scopes: [once, session, repo] # optional; default all four
|
|
@@ -248,6 +248,23 @@ guardrails:
|
|
|
248
248
|
|
|
249
249
|
A rule gives at least one of `tool`, `command`, `path`, `git`, `touches`, `rm` and `memory`.
|
|
250
250
|
|
|
251
|
+
`command:` reads the command without the body of a heredoc its command
|
|
252
|
+
only reads as data: `cat` or `tee` to stdout, `git commit`/`git tag` with
|
|
253
|
+
`-F -`/`--file -`, `gh pr|issue`/`gh release` with `--body-file -`/
|
|
254
|
+
`--notes-file -`, and a `"$(cat <<'EOF' … EOF)"` given to `-m`, `--body`
|
|
255
|
+
or another message option. Each counts only when its command ends at a
|
|
256
|
+
newline, `;`, `&&` or `||` and sits outside any `( … )` or `{ … }` group;
|
|
257
|
+
the `<<TAG` word and the terminator line stay in the text, so a rule on
|
|
258
|
+
`<<` or on what follows the terminator still matches. A heredoc fed to
|
|
259
|
+
anything else (`bash`, an interpreter, `ssh`, a wrapper), an unquoted one
|
|
260
|
+
whose body holds `$(…)` or backticks, a piped one, a file-writing
|
|
261
|
+
`cat`/`tee` (a `>&2` or `>&-` writes no file; `>& file` does), a
|
|
262
|
+
here-string and an unterminated heredoc all stay in it. An fd opened
|
|
263
|
+
earlier and written to by number (`exec 3>r.sh; cat <<E >&3`) isn't
|
|
264
|
+
followed, so that body counts as data: out of scope here, which is
|
|
265
|
+
chi-nah's job for a model that goes looking. Approvals, the approval
|
|
266
|
+
question and hooks see the whole command.
|
|
267
|
+
|
|
251
268
|
`memory: remove` matches a `memory_write` with `remove: true` (it moves a
|
|
252
269
|
memory into the bundle trash). Nothing else asks about it: a memory's file is
|
|
253
270
|
never outside the repo (below), so without such a rule any session, a delegate
|
|
@@ -493,7 +510,7 @@ only by the user: these change a model's system prompt for later sessions (see
|
|
|
493
510
|
[Model notes](memory.md#model-notes); a shell command reaches them only under
|
|
494
511
|
`shell-touches-chi`'s text heuristic); and answering chi's
|
|
495
512
|
questions around `chi answer`: `chi --attach` or `chi -p` with stdin from a
|
|
496
|
-
pipe, a here-string or a file (`chi-answer-piped`), and `curl`/`wget` to a
|
|
513
|
+
pipe, a here-string or a file, not `/dev/null` (`chi-answer-piped`), and `curl`/`wget` to a
|
|
497
514
|
session's `/answer` route (`chi-answer-http`), both once or for the session.
|
|
498
515
|
`chi answer` itself isn't asked about. It denies writes into `.git/hooks`.
|
|
499
516
|
|
|
@@ -591,7 +608,13 @@ else goes to chi's own rules. It needs nah installed; see its README for the set
|
|
|
591
608
|
|
|
592
609
|
Text matching on shell commands stops accidents, not a model set on getting
|
|
593
610
|
around it: `sh -c`, base64, a script written earlier, `git -C` variants and
|
|
594
|
-
aliases can get past a regex
|
|
611
|
+
aliases can get past a regex, and these rules are built for trusted models (a
|
|
612
|
+
model set on getting around them is chi-nah's job). A heredoc counts as data
|
|
613
|
+
only for the consumers listed under [Rules in config.yml](#rules-in-configyml)
|
|
614
|
+
(`cat`/`tee` to stdout, `git commit`/`git tag` `-F -`, `gh`'s body and notes
|
|
615
|
+
files, a `"$(cat <<'EOF' … EOF)"` message): anything else keeps its body in
|
|
616
|
+
the text a rule sees, and a function or alias shadowing a reader in the same
|
|
617
|
+
command isn't seen at all. `outside_repo` covers the file tools and git
|
|
595
618
|
only: `sed -i`, `cp` or `cat > file` into another folder from the shell aren't
|
|
596
619
|
asked about, and neither is git run through `sh -c`, a script, an alias, `make`
|
|
597
620
|
or a folder in a variable. `!cmd` lines typed by you are not checked. The only
|
data/docs/plugins.md
CHANGED
|
@@ -858,6 +858,10 @@ next turn never see it, and no hook fires. It returns the answer text.
|
|
|
858
858
|
It goes through the host's OpenAI API (`/v1/chat/completions`), which
|
|
859
859
|
llama.cpp serves on a native host too.
|
|
860
860
|
|
|
861
|
+
Each call counts in the session's side requests, under the bundle's name: `/stats`' `side requests:` line and the
|
|
862
|
+
web's ctx tooltip (`2 (btw 1, recap 1) · …`), with its tokens and its cost (reported, else estimated from the target
|
|
863
|
+
model's price), never in the session's own totals.
|
|
864
|
+
|
|
861
865
|
## Calling services: `ctx.http`
|
|
862
866
|
|
|
863
867
|
```ruby
|
|
@@ -923,7 +927,8 @@ Every call also takes `cancel:`, a `Samagotchi::CancellationController`, which r
|
|
|
923
927
|
- **Stop.** A call made for the turn (a tool, a hook's handler) stops when the turn is stopped, and a call inside an
|
|
924
928
|
init task when chi shuts down; a call from your own thread, a `chi.service` or an anytime command has only its
|
|
925
929
|
timeout unless you pass `cancel:`. After the turn has ended (an `:after_turn` or `:session_end` hook of a
|
|
926
|
-
stopped turn) a call is not cancelled by that stop.
|
|
930
|
+
stopped turn) a call is not cancelled by that stop. A service's start block counts as the service's even when its
|
|
931
|
+
first `value` comes from a handler.
|
|
927
932
|
- **The log.** Each call writes one `http` line to the debug log: INFO (ERROR when it fails) with `bundle=`,
|
|
928
933
|
`purpose=plugin`, the method, the URL **without its query**, the status and the time. Never headers or bodies.
|
|
929
934
|
A plugin that polls a dead service therefore logs an ERROR per poll.
|
|
@@ -1164,8 +1169,8 @@ bundle.shutdown
|
|
|
1164
1169
|
`bundle.set(cancelled: true)` makes a call without `cancel:` raise `HttpCancelled`.
|
|
1165
1170
|
- **A hook that raises fails the call** (`Testing::HookFailed`, with the hook's error) where chi would log it and
|
|
1166
1171
|
go on; `bundle.lenient { … }` collects them in `bundle.errors` instead.
|
|
1167
|
-
- **Sandbox.** A test runs with temp config and
|
|
1168
|
-
log), `HOME`, `CLAUDE_CONFIG_DIR` and `CODEX_HOME`, no git config of yours, no Keychain and none of your
|
|
1172
|
+
- **Sandbox.** A test runs with temp config, state, data and cache homes (config.yml, memories, `ctx.data_dir`, the
|
|
1173
|
+
debug log), `HOME`, `CLAUDE_CONFIG_DIR` and `CODEX_HOME`, no git config of yours, no Keychain and none of your
|
|
1169
1174
|
`SAMAGOTCHI_*` settings; `sandbox.config(hash)` writes its config.yml.
|
|
1170
1175
|
- **RSpec:** `require "samagotchi/testing/rspec"`; example groups tagged `:bundle` (a spec under a bundle's own
|
|
1171
1176
|
`spec/` is tagged by itself) get `load_bundle(dir)`, `shipped_bundle(name)` and `sandbox`, each example in its
|
data/docs/sessions.md
CHANGED
|
@@ -51,6 +51,14 @@ chi sessions stats ID [--format text|json] # cost, tokens and progres
|
|
|
51
51
|
|
|
52
52
|
`chi sessions stats ID` prints what `/stats` prints in a terminal, headed by `<id8> <status> <model>`: a live worker answers its Bridge's `GET stats` (no model call, and the command never starts a worker); without one, or when the worker does not answer in time, the same snapshot is rebuilt from the session's saved `analytics.json` (and the line says so when a worker was live). `--format json` prints one object `{session_id, status, live, metrics}` with the snapshot as `metrics` (its `turn_records` / `tool_records` are the recent ones only: a live worker's newest finished turn and any not yet saved, the disk snapshot's newest saved turn; `analytics.json` holds them all); a session with no `analytics.json` yet reports `metrics: null` (`(no metrics yet)` in text) and still exits 0. An unknown or ambiguous id exits 1 with the same wording `chi sessions stop` uses.
|
|
53
53
|
|
|
54
|
+
`analytics.json` also has a `side` block when the session made side requests (the idle recap, `/btw` and other
|
|
55
|
+
plugins' `ctx.ask_model`): `requests`, `prompt_sum`, `completion_sum`, `cached_sum`, `cache_write_sum`,
|
|
56
|
+
`reasoning_sum`, `cost_sum` (the reported cost; null until one was), `cost_estimate_sum` (from the target host's
|
|
57
|
+
price; 0 without one) and `by_kind` (`{recap: {requests, cost_sum, cost_estimate_sum}, btw: {…}}`, in first-seen
|
|
58
|
+
order: `recap`, else the asking bundle's name). It has no records: the block itself is saved (at once between turns,
|
|
59
|
+
by the turn's end during one) and loaded; it is never in `tokens` or `model_segments`. `chi sessions stats --format
|
|
60
|
+
json` has it in `metrics.side`; a file without it (older, or no side requests) has none.
|
|
61
|
+
|
|
54
62
|
Each turn record in `analytics.json` names the model the turn ran on as `model_ref` (the asked `host:model`, from its first generation; `model` stays the name the server says it served); a turn stopped before it asked a model has none. The first turn after a `/model` switch (or a hook's) to another model also records what its first generation prefilled on the new model: `switch_prefill_tokens` (the prompt less the cached reads; none under 128 tokens, or for a generation with estimated counts) and `switch_prefill_cost_estimate` (from the new model's configured price, no completion; none without a price). A session saved before this has no `model_ref` on its records.
|
|
55
63
|
|
|
56
64
|
A session whose worker waits for an answer (a question, a hook's question, a guardrail approval, or the step-limit question of a turn that ran out of steps, which waits between turns) reads `waiting` in the status column, and json has `waiting: "question"|"approval"|"hook"|"continue"` and `waiting_id`, the question's id for `chi answer --question` (both null otherwise). A delegate's approval its parent relayed to its own card (see *Delegating*) reads `waiting (in parent ab12)`, json `relayed_to: "ab12…"` (the parent's short id, else null). Only while a worker runs it: a question a dead worker left in the file shows the saved status. Answer it with `chi answer`, the web or `chi --attach ID`; `chi sessions stop ID` frees the worker instead.
|
data/docs/testing.md
CHANGED
|
@@ -83,6 +83,25 @@ The kit's own specs, in `spec/samagotchi/testing/`, keep it and the bundles hone
|
|
|
83
83
|
bundle's `Boundary::EXCEPTIONS` entry, each with why. A new internal reference fails it, and so does an exception
|
|
84
84
|
no longer used. Fix the reference (the kit or a stable API), or add the exception with its why.
|
|
85
85
|
|
|
86
|
+
## Guardrail cases corpus
|
|
87
|
+
|
|
88
|
+
`spec/fixtures/guardrails/cases.jsonl` holds one labelled tool call per line, run by
|
|
89
|
+
`spec/guardrails/cases_corpus_spec.rb` against a fixed rule set (`spec/fixtures/guardrails/rules.yml`) or the
|
|
90
|
+
shipped guardrails bundle's rules. A case:
|
|
91
|
+
|
|
92
|
+
- `id` (required, unique, kebab-case), `tool` (required: `execute`, `task_create`, `write`, `edit` or `read`),
|
|
93
|
+
`command` (a shell tool) or `path` (a file tool), `cwd` (optional: the call's `cwd:`, relative to the spec's
|
|
94
|
+
temp repo), `expect` (required), `rule` (a rule id), `rules` (optional: `fixture`, the default, or
|
|
95
|
+
`guardrails-bundle`), `source` (required: where the case came from: `heredoc-fp`, `heredoc-bypass`,
|
|
96
|
+
`chi-answer-piped`) and `note` (optional: why the label is what it is).
|
|
97
|
+
- `expect` is one of two kinds: `match` / `no_match` say whether rule `rule` matches the call (`Rule#matches?`),
|
|
98
|
+
and `allow` / `ask` / `deny` the whole rule set's decision (`Rules#check`, not the Gate's core checks); with
|
|
99
|
+
`rule` given, an ask or deny must also be decided by that rule.
|
|
100
|
+
|
|
101
|
+
Add a case with every guardrail false positive or miss you fix, under the `source` it came from. Never relabel
|
|
102
|
+
a case to make a spec pass: a case that goes red means the code is wrong. The spec also keeps the corpus
|
|
103
|
+
well formed (unique ids, known keys and labels, a rule that exists in its set).
|
|
104
|
+
|
|
86
105
|
## While you work: affected specs
|
|
87
106
|
|
|
88
107
|
```sh
|
|
@@ -280,7 +280,7 @@ module Samagotchi
|
|
|
280
280
|
# neither url: nor host: is Anthropic's own API (HostConfig's rule).
|
|
281
281
|
def location(fields)
|
|
282
282
|
url = fields["url"].to_s.strip
|
|
283
|
-
url = ANTHROPIC_URL if url.empty? && anthropic?(fields) && fields["host"].to_s.strip.empty? && fields["port"].to_s.strip.empty?
|
|
283
|
+
url = HostConfig::ANTHROPIC_URL if url.empty? && anthropic?(fields) && fields["host"].to_s.strip.empty? && fields["port"].to_s.strip.empty?
|
|
284
284
|
unless url.empty?
|
|
285
285
|
uri = URI.parse(url)
|
|
286
286
|
path = uri.path.to_s.chomp("/")
|
|
@@ -337,8 +337,8 @@ module Samagotchi
|
|
|
337
337
|
@exit = usage_error(e.message)
|
|
338
338
|
return nil
|
|
339
339
|
end
|
|
340
|
-
explain_name(base, name, reason)
|
|
341
340
|
outcome = writer.write(name: name, fields: fields, model: model, dry_run: options[:dry_run])
|
|
341
|
+
explain_name(base, name, reason) unless outcome.kind == :exists
|
|
342
342
|
code = report(outcome, model)
|
|
343
343
|
return code unless INSTALLS_BUNDLES.include?(outcome.kind)
|
|
344
344
|
|
|
@@ -176,12 +176,12 @@ rules:
|
|
|
176
176
|
# ── answering chi's own questions around chi answer ───────────────────
|
|
177
177
|
# A parent agent answers a child's approval with chi answer, which holds it
|
|
178
178
|
# to guardrails.parent_approvals. These shapes go around that: chi --attach
|
|
179
|
-
# or chi -p with stdin from a pipe, a here-string or a file
|
|
180
|
-
# those as a parent too, but an older
|
|
181
|
-
# Bridge's or the web's answer route.
|
|
179
|
+
# or chi -p with stdin from a pipe, a here-string or a file other than
|
|
180
|
+
# /dev/null (a chi after 0.12 treats those as a parent too, but an older
|
|
181
|
+
# child doesn't), and a POST to the Bridge's or the web's answer route.
|
|
182
182
|
- id: chi-answer-piped
|
|
183
183
|
tool: shell
|
|
184
|
-
command: '(\|[^;&|\n]*\bchi\s([^;&|\n]*\s)?(--attach|--prompt|-p)\b|\bchi\s([^;&|\n]*\s)?(--attach|--prompt|-p)\b[^;&|\n]*<)'
|
|
184
|
+
command: '(\|[^;&|\n]*\bchi\s([^;&|\n]*\s)?(--attach|--prompt|-p)\b|\bchi\s([^;&|\n]*\s)?(--attach|--prompt|-p)\b[^;&|\n]*<(?!\s*/dev/null(?![\w./-])))'
|
|
185
185
|
verdict: ask
|
|
186
186
|
scopes: [once, session]
|
|
187
187
|
reason: answers chi's questions from a pipe or a file, around chi answer
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: guardrails
|
|
3
|
-
version: 0.9.
|
|
3
|
+
version: 0.9.1
|
|
4
4
|
scope: system
|
|
5
5
|
description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, git hooks, chi's own config and attached context from the shell, chi context add --cmd, chi broadcast, answering chi around chi answer, removing a memory, writing a memory that reaches the system prompt (identity, model notes) or a model overlay; in strict mode also git rebase and writes and git in another checkout outside the session's repo), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
|
|
6
6
|
trust_level: reviewed
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: slow-commands
|
|
3
|
-
version: 0.2.
|
|
3
|
+
version: 0.2.3
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Remembers commands that ran past the execute limit in a project; the next identical execute is denied once a turn with advice to run it with task_create and task_wait; /slow-commands lists and forgets them"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
plugin:
|
|
8
8
|
file: plugin.rb
|
|
9
|
-
sha256: sha256:
|
|
9
|
+
sha256: sha256:249615f1d8529ab020ea9872bb42b00463594f20a9ec44fecc4e0f58d34dce91
|
|
10
10
|
# Execute::HANDED_OVER, read when the plugin loads (0.59.0); after_tool_call's
|
|
11
11
|
# call:, elapsed_ms: and result: (0.57.0)
|
|
12
12
|
requires_chi: ">= 0.59.0"
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
# `env X=… cmd` is `X=… cmd`, an `env` with -C/-i/-u (or any other option)
|
|
22
22
|
# stays as typed. The entry's `shown` is the last run as typed. Input that ends inside a quote, $(…)
|
|
23
23
|
# or heredoc is never recorded, nor one with a $(…), backticks or a heredoc
|
|
24
|
-
#
|
|
24
|
+
# a data reader reads as data (ShellLex: cat, tee, git commit -F -, gh …
|
|
25
|
+
# --body-file -): ShellLex makes those one placeholder, so different
|
|
25
26
|
# commands would share a key.
|
|
26
27
|
#
|
|
27
28
|
# after_tool_call records: timed out (killed at the limit), handed over as
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -552,6 +552,30 @@ module Samagotchi
|
|
|
552
552
|
@session_observer.notify(type: :recap_ready, recap: recap, generation: generation, covered: covered)
|
|
553
553
|
end
|
|
554
554
|
|
|
555
|
+
# Count one side request (a recap, a plugin's ctx.ask_model: IdleClient's
|
|
556
|
+
# on_usage) in the session's side line, apart from its turns' totals.
|
|
557
|
+
# Folded and announced as :side_usage under the event lock, so the side
|
|
558
|
+
# blocks reach the UIs in fold order (the lock order event lock →
|
|
559
|
+
# metrics mutex is the existing one); then, between turns, saved to
|
|
560
|
+
# analytics.json (a running turn's end or checkpoint saves it), unless
|
|
561
|
+
# the session is gone (no session file: a late recap thread must not
|
|
562
|
+
# recreate a deleted session's folder; the folder itself may not exist
|
|
563
|
+
# yet before a first turn). Safe from any thread: the recap's, a
|
|
564
|
+
# plugin's, the turn's.
|
|
565
|
+
# @param usage [SideUsage]
|
|
566
|
+
# @return [void]
|
|
567
|
+
def note_side_usage(usage)
|
|
568
|
+
synchronize_events do
|
|
569
|
+
block = @metrics.add_side(usage)
|
|
570
|
+
@session_observer.notify(type: :side_usage, kind: usage.kind, side: block)
|
|
571
|
+
end
|
|
572
|
+
sid = @session&.id
|
|
573
|
+
return if sid.nil? || turn_running?
|
|
574
|
+
return unless File.exist?(Session.session_file(sid, state_dir: session_state_dir))
|
|
575
|
+
|
|
576
|
+
@metrics.persist(state_dir: session_state_dir)
|
|
577
|
+
end
|
|
578
|
+
|
|
555
579
|
# @return [SessionMetrics] the per-session analytics collector
|
|
556
580
|
attr_reader :metrics
|
|
557
581
|
|
|
@@ -2544,9 +2568,9 @@ module Samagotchi
|
|
|
2544
2568
|
declared_tools: -> { toolkit_explained.declared.map(&:name) },
|
|
2545
2569
|
use_toolkit: ->(name) { use_toolkit(name) },
|
|
2546
2570
|
cancel_controller: ->(handler: false) { plugin_cancel_controller(handler: handler) },
|
|
2547
|
-
ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:, model: nil|
|
|
2571
|
+
ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:, model: nil, source: nil|
|
|
2548
2572
|
ask_side_model(request, timeout: timeout, max_tokens: max_tokens, cancel_controller: cancel_controller,
|
|
2549
|
-
model: model)
|
|
2573
|
+
model: model, source: source)
|
|
2550
2574
|
},
|
|
2551
2575
|
model_name: -> { effective_model_ref },
|
|
2552
2576
|
model_key: -> { @model_key },
|
|
@@ -2591,13 +2615,15 @@ module Samagotchi
|
|
|
2591
2615
|
# IdleClient, so it shares nothing with the turn's backend. The client
|
|
2592
2616
|
# is kept while the target and timeout stay (what it learned, e.g. a
|
|
2593
2617
|
# refused thinking-off, holds for the next request); the session's id
|
|
2594
|
-
# goes with the request.
|
|
2618
|
+
# goes with the request. Its usage counts in the session's side line
|
|
2619
|
+
# (#note_side_usage) under +source+, the asking bundle's name.
|
|
2595
2620
|
# @return [String] the answer
|
|
2596
|
-
def ask_side_model(request, timeout:, max_tokens:, cancel_controller:, model: nil)
|
|
2621
|
+
def ask_side_model(request, timeout:, max_tokens:, cancel_controller:, model: nil, source: nil)
|
|
2597
2622
|
target = model.nil? ? session_model_recap_target : configured_side_target(model)
|
|
2598
2623
|
client = side_model_client(target, timeout)
|
|
2599
2624
|
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
2600
|
-
answer = client.ask(request, max_tokens: max_tokens, cancel_controller: cancel_controller, session_id: @session&.id
|
|
2625
|
+
answer = client.ask(request, max_tokens: max_tokens, cancel_controller: cancel_controller, session_id: @session&.id,
|
|
2626
|
+
kind: source.to_s.empty? ? "ask" : source.to_s, on_usage: method(:note_side_usage))
|
|
2601
2627
|
Log.info(:plugins, "ask_model", model: target.label, answer_model: answer.model, chars: answer.text.length,
|
|
2602
2628
|
ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round)
|
|
2603
2629
|
answer.text
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module Guardrails
|
|
5
|
+
# Which shell commands read their stdin as data and never run it: cat and
|
|
6
|
+
# tee (to stdout), and a git or gh command taking its message from stdin.
|
|
7
|
+
# Pure word predicates over one simple command's words (the lexer's
|
|
8
|
+
# ShellLex). Everything else is an unknown consumer and stays matched.
|
|
9
|
+
module DataReaders
|
|
10
|
+
# Commands that print what they read.
|
|
11
|
+
SINKS = %w[cat tee].freeze
|
|
12
|
+
# What a heredoc body that a data reader reads becomes (the same value
|
|
13
|
+
# as ShellLex::HEREDOC).
|
|
14
|
+
HEREDOC = "\0HEREDOC"
|
|
15
|
+
# A word that is a redirection (>, >>, 2>&1, <; the same as
|
|
16
|
+
# ShellLex::REDIRECT).
|
|
17
|
+
REDIRECT = /\A\d*[<>]/
|
|
18
|
+
# git's global options that take the next word.
|
|
19
|
+
GIT_OPTIONS_WITH_VALUE = %w[-C -c --git-dir --work-tree --namespace].freeze
|
|
20
|
+
# A >& target that names an fd instead of a file: a number or -.
|
|
21
|
+
FD_TARGET = /\A(\d+|-)\z/
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# The command word among a simple command's words: the NAME= words and
|
|
26
|
+
# the redirections dropped, and with a redirection written with a space
|
|
27
|
+
# (> /tmp/x) its target word too. nil when there is none.
|
|
28
|
+
def command_word(words)
|
|
29
|
+
index = command_index(words)
|
|
30
|
+
words[index] if index
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Where command_word's word is in +words+ (nil when there is none).
|
|
34
|
+
def command_index(words)
|
|
35
|
+
i = 0
|
|
36
|
+
while i < words.size
|
|
37
|
+
word = words[i]
|
|
38
|
+
if word.match?(REDIRECT)
|
|
39
|
+
i += 1 if word.match?(/\A\d*[<>]{1,2}\z/)
|
|
40
|
+
elsif !word.match?(/\A[A-Za-z_]\w*=/)
|
|
41
|
+
return i
|
|
42
|
+
end
|
|
43
|
+
i += 1
|
|
44
|
+
end
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Whether a cat or tee command sends its output to a file (a file
|
|
49
|
+
# written can be run later): a stdout redirection whose target isn't
|
|
50
|
+
# /dev/null, a dangling >, or a tee argument that is no option,
|
|
51
|
+
# redirection, heredoc or /dev/null.
|
|
52
|
+
def writes_file?(words)
|
|
53
|
+
return true if stdout_file?(words)
|
|
54
|
+
|
|
55
|
+
File.basename(command_word(words).to_s) == "tee" && tee_file?(words)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Whether one of +words+ sends stdout to a file: a >, >>, 1>, 1>>, >& or
|
|
59
|
+
# 1>& redirection (its target in the word, or in the next word when the
|
|
60
|
+
# operator ends it) whose target names a file and isn't /dev/null. A dup
|
|
61
|
+
# to an fd writes no file: >&2, >&-, 2>&1, and a spaced ">& 2" or
|
|
62
|
+
# ">& -". A >& with any other target does write one: bash reads
|
|
63
|
+
# ">& file" and ">&file" as "&>file".
|
|
64
|
+
def stdout_file?(words)
|
|
65
|
+
words.each_with_index do |word, i|
|
|
66
|
+
match = word.match(/\A1?(>>?)(&?)(.*)\z/m)
|
|
67
|
+
next unless match
|
|
68
|
+
|
|
69
|
+
amp = match[2]
|
|
70
|
+
target = match[3]
|
|
71
|
+
target = words[i + 1] if target.empty? # "> file", ">& 2"
|
|
72
|
+
return true if target.nil? # a dangling > or >&
|
|
73
|
+
next if target == "/dev/null"
|
|
74
|
+
next if !amp.empty? && target.match?(FD_TARGET)
|
|
75
|
+
|
|
76
|
+
return true
|
|
77
|
+
end
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Whether a tee command writes a file of its own (an argument that
|
|
82
|
+
# isn't an option, a redirection, a heredoc, a body or /dev/null).
|
|
83
|
+
def tee_file?(words)
|
|
84
|
+
verb = command_index(words)
|
|
85
|
+
return false unless verb
|
|
86
|
+
|
|
87
|
+
i = verb + 1
|
|
88
|
+
while i < words.size
|
|
89
|
+
word = words[i]
|
|
90
|
+
if word.match?(REDIRECT)
|
|
91
|
+
i += word.match?(/\A\d*[<>]{1,2}\z/) ? 2 : 1
|
|
92
|
+
next
|
|
93
|
+
end
|
|
94
|
+
return true if filename_word?(word)
|
|
95
|
+
|
|
96
|
+
i += 1
|
|
97
|
+
end
|
|
98
|
+
false
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Whether a tee argument names a file: not an option, a heredoc's tag,
|
|
102
|
+
# a body word (HEREDOC, SUBST, a script) or /dev/null.
|
|
103
|
+
def filename_word?(word)
|
|
104
|
+
!word.start_with?("-", "<<") && !word.include?("\0") && !word.include?("\n") && word != "/dev/null"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Whether a simple command reads its stdin as a text file it never
|
|
108
|
+
# runs: cat or tee, git commit/tag -F - (--file -), gh pr|issue … -F -
|
|
109
|
+
# (--body-file -) or gh release … -F - (--notes-file -).
|
|
110
|
+
def data_reader?(words)
|
|
111
|
+
args = arguments(words)
|
|
112
|
+
verb = File.basename(args.first.to_s)
|
|
113
|
+
return true if SINKS.include?(verb)
|
|
114
|
+
|
|
115
|
+
case verb
|
|
116
|
+
when "git" then git_reader?(args)
|
|
117
|
+
when "gh" then gh_reader?(args)
|
|
118
|
+
else false
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Whether word +index+ of a simple command is the value of a message
|
|
123
|
+
# option of a data reader: git commit/tag -m/--message, gh pr|issue …
|
|
124
|
+
# -b/--body/-t/--title, gh release … -n/--notes/-t/--title. A
|
|
125
|
+
# "$(cat <<'EOF' … EOF)" there is a message, not code.
|
|
126
|
+
def message_arg?(words, index)
|
|
127
|
+
return false unless index.positive?
|
|
128
|
+
|
|
129
|
+
option = words[index - 1]
|
|
130
|
+
args = arguments(words)
|
|
131
|
+
case File.basename(args.first.to_s)
|
|
132
|
+
when "git" then git_message_option?(args, option)
|
|
133
|
+
when "gh" then gh_message_option?(args, option)
|
|
134
|
+
else false
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# A simple command's arguments: the NAME= words, the redirections (with
|
|
139
|
+
# their spaced targets), the <<TAG words and the heredoc bodies left
|
|
140
|
+
# out.
|
|
141
|
+
def arguments(words)
|
|
142
|
+
args = []
|
|
143
|
+
i = 0
|
|
144
|
+
while i < words.size
|
|
145
|
+
word = words[i]
|
|
146
|
+
if word.match?(REDIRECT)
|
|
147
|
+
i += word.match?(/\A\d*[<>]{1,2}\z/) ? 2 : 1
|
|
148
|
+
next
|
|
149
|
+
end
|
|
150
|
+
args << word unless word.match?(/\A[A-Za-z_]\w*=/) || heredoc_word?(word)
|
|
151
|
+
i += 1
|
|
152
|
+
end
|
|
153
|
+
args
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Whether a word is a heredoc's <<TAG or one of its bodies.
|
|
157
|
+
def heredoc_word?(word)
|
|
158
|
+
word.match?(REDIRECT) || word.include?("\0") || word.include?("\n")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# The words after git's global options ([-C dir …] commit), nil when
|
|
162
|
+
# they run out.
|
|
163
|
+
def git_arguments(args)
|
|
164
|
+
i = 1
|
|
165
|
+
i += GIT_OPTIONS_WITH_VALUE.include?(args[i]) ? 2 : 1 while i < args.size && args[i].start_with?("-")
|
|
166
|
+
args[i..]
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def git_reader?(args)
|
|
170
|
+
rest = git_arguments(args)
|
|
171
|
+
return false unless rest
|
|
172
|
+
|
|
173
|
+
subcommand, *opts = rest
|
|
174
|
+
return false unless %w[commit tag].include?(subcommand)
|
|
175
|
+
|
|
176
|
+
stdin_file?(opts, "-F", "--file")
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def gh_reader?(args)
|
|
180
|
+
case args[1]
|
|
181
|
+
when "pr", "issue"
|
|
182
|
+
%w[create comment edit review].include?(args[2]) && stdin_file?(args.drop(3), "-F", "--body-file")
|
|
183
|
+
when "release"
|
|
184
|
+
%w[create edit].include?(args[2]) && stdin_file?(args.drop(3), "-F", "--notes-file")
|
|
185
|
+
else
|
|
186
|
+
false
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Whether +opts+ take their text from stdin: "-F -", "--file -",
|
|
191
|
+
# "-F-" or "--file=-".
|
|
192
|
+
def stdin_file?(opts, short, long)
|
|
193
|
+
opts.each_cons(2).any? { |a, b| [short, long].include?(a) && b == "-" } ||
|
|
194
|
+
opts.include?("#{short}-") || opts.include?("#{long}=-")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def git_message_option?(args, option)
|
|
198
|
+
rest = git_arguments(args)
|
|
199
|
+
rest && %w[commit tag].include?(rest.first) && %w[-m --message].include?(option)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def gh_message_option?(args, option)
|
|
203
|
+
case args[1]
|
|
204
|
+
when "pr", "issue"
|
|
205
|
+
%w[create comment edit review].include?(args[2]) && %w[-b --body -t --title].include?(option)
|
|
206
|
+
when "release"
|
|
207
|
+
%w[create edit].include?(args[2]) && %w[-n --notes -t --title].include?(option)
|
|
208
|
+
else
|
|
209
|
+
false
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -13,7 +13,9 @@ module Samagotchi
|
|
|
13
13
|
# a name may be a glob ("mcp_*", "mcp_{git,gh}_*"); it
|
|
14
14
|
# matches the call's name or the tool a plugin tool's call
|
|
15
15
|
# acts as (Targets#acts_as: an MCP tool behind call_tool)
|
|
16
|
-
# command: a Ruby regex on a shell tool's command
|
|
16
|
+
# command: a Ruby regex on a shell tool's command, the body of a
|
|
17
|
+
# heredoc its command only reads as data left out
|
|
18
|
+
# (Targets#rule_command, ShellLex.code_text)
|
|
17
19
|
# path: "outside_repo", or a glob on the resolved paths (absolute
|
|
18
20
|
# or "**/…" globs match the absolute path; others the path
|
|
19
21
|
# relative to the repo root, the cwd outside one)
|
|
@@ -61,7 +63,7 @@ module Samagotchi
|
|
|
61
63
|
return false unless targets
|
|
62
64
|
return false unless for_model?(targets.model_name, targets.model_key, -> { targets.small_model? })
|
|
63
65
|
return false if tools && !tool_matches?(targets.tool) && !(targets.acts_as && tool_matches?(targets.acts_as))
|
|
64
|
-
return false if command && !(targets.
|
|
66
|
+
return false if command && !(targets.rule_command && command.match?(targets.rule_command))
|
|
65
67
|
return false if path && !path_matches?(targets)
|
|
66
68
|
return false if git && !targets.git_outside_repo?
|
|
67
69
|
return false if touches && !targets.touches_chi?
|