samagotchi 0.114.0 → 0.116.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -1
  3. data/bin/chi +2 -0
  4. data/docs/cli.md +11 -3
  5. data/docs/configuration.md +7 -0
  6. data/docs/guardrails.md +188 -1
  7. data/docs/internals/web-push.md +3 -2
  8. data/docs/sessions.md +2 -2
  9. data/lib/samagotchi/bridge/turn_accumulator.rb +4 -0
  10. data/lib/samagotchi/bridge.rb +26 -0
  11. data/lib/samagotchi/bridge_client.rb +7 -0
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/config.rb +15 -0
  14. data/lib/samagotchi/engine.rb +6 -1
  15. data/lib/samagotchi/guardrail_wiring.rb +166 -2
  16. data/lib/samagotchi/guardrails/approval.rb +20 -0
  17. data/lib/samagotchi/guardrails/targets.rb +23 -2
  18. data/lib/samagotchi/guardrails/verdict.rb +23 -3
  19. data/lib/samagotchi/guardrails_command.rb +329 -0
  20. data/lib/samagotchi/memory_bundle/index_updater.rb +13 -0
  21. data/lib/samagotchi/session_access/contract.rb +1 -1
  22. data/lib/samagotchi/session_commands.rb +79 -2
  23. data/lib/samagotchi/session_metrics.rb +7 -1
  24. data/lib/samagotchi/system_one/answer.rb +78 -0
  25. data/lib/samagotchi/system_one/approval_question.rb +113 -0
  26. data/lib/samagotchi/system_one/client.rb +89 -0
  27. data/lib/samagotchi/system_one/errors.rb +14 -0
  28. data/lib/samagotchi/system_one/fake_backend.rb +45 -0
  29. data/lib/samagotchi/system_one/http_backend.rb +129 -0
  30. data/lib/samagotchi/system_one/log.rb +133 -0
  31. data/lib/samagotchi/system_one/question.rb +86 -0
  32. data/lib/samagotchi/system_one/record.rb +83 -0
  33. data/lib/samagotchi/system_one/self_protect.rb +90 -0
  34. data/lib/samagotchi/system_one/similar.rb +69 -0
  35. data/lib/samagotchi/system_one.rb +25 -0
  36. data/lib/samagotchi/terminal_ui/attached_loop.rb +15 -4
  37. data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -0
  38. data/lib/samagotchi/terminal_ui/formatting.rb +14 -0
  39. data/lib/samagotchi/title_command.rb +13 -4
  40. data/lib/samagotchi/version.rb +1 -1
  41. data/lib/samagotchi/web/app.rb +1 -0
  42. data/lib/samagotchi/web/public/activity.js +10 -0
  43. data/lib/samagotchi/web/public/annotate_bar.js +22 -2
  44. data/lib/samagotchi/web/public/app.js +4 -0
  45. data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
  46. data/lib/samagotchi/web/public/composer_fold.js +8 -8
  47. data/lib/samagotchi/web/public/composer_grip.js +65 -22
  48. data/lib/samagotchi/web/public/composer_size.js +30 -2
  49. data/lib/samagotchi/web/public/data.js +10 -0
  50. data/lib/samagotchi/web/public/favicon.svg +1 -1
  51. data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
  52. data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
  53. data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
  54. data/lib/samagotchi/web/public/index.html +7 -0
  55. data/lib/samagotchi/web/public/notify.js +4 -2
  56. data/lib/samagotchi/web/public/system_one_wrong.js +32 -0
  57. data/lib/samagotchi/web/public/timing.js +9 -5
  58. data/lib/samagotchi/web/public/turn_events.js +1 -0
  59. data/lib/samagotchi/web/public/turn_html.js +13 -2
  60. data/lib/samagotchi/web/push/attention.rb +3 -1
  61. data/lib/samagotchi/web/session_control.rb +22 -0
  62. data/lib/samagotchi/web/session_show.rb +35 -5
  63. metadata +15 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a59aa63d1ccf6a6114afcaf5b1cf19088cb482989c08acb3aa5bfede3a67358d
4
- data.tar.gz: 3fe5245e7c864ca0b814d9b187b402d5658ffd88aa5d895881482f4b40e9391f
3
+ metadata.gz: 2e86ef8d1890939908a44f84be426b6132992e9483af840e9438ad2d59829da9
4
+ data.tar.gz: adce5380c4f88f152c7aab62c26176d38b6ee6f1e268abe24b7d4b8850c1bb4e
5
5
  SHA512:
6
- metadata.gz: 412b5e6a15d73f4817729879e71aa3f2813ae73705039a8db561cbafffa0e246f6cf0f37760ebf346144c88369174a42cd902f36d31f3c02703345c99d18505f
7
- data.tar.gz: 25d265a240a37194b5670afeb6d4784b55b43ddb33a857039da16ffc1a1184d79a6a4b30e7111a8c0698f538c130cd2c930545c03e3474a9bbf814bd22dbb48f
6
+ metadata.gz: c794e9f67ee56a67ec85fa0ec801919d3d0917b62b477491b12ca6475d0678402833b1191d973f791edfb7345c0cbee16509ecb8dcf3be1a5345ead097dfeb0a
7
+ data.tar.gz: 7d3da8e7edf45917785b14bf521eaee4559a829c631570a0336a4fcce00953b62a46a7e8f28ae4ffcb8130bae6b42b944e0783b8dbde35f918489a4504937ee8
data/CHANGELOG.md CHANGED
@@ -8,6 +8,75 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.116.0] - 2026-10-25
12
+
13
+ ### Added
14
+
15
+ - System 1's judgment now sees more of the ask it is given: its state carries up to five of the user's **earlier
16
+ answered asks** that look like this one (`SystemOne::Similar` — same rule and repository first, then the same rule,
17
+ then the ask whose logged call shares the most words with this one, ties newest first), and a `memory_write` ask
18
+ names the entry and its scope instead of only a path, with that entry's index description for a removal — the
19
+ `memory-remove` ask was scoring flat because the judge could not tell which memory was going. The judgments log is
20
+ read once per ask, shared by the examples and the record gate. See
21
+ [Guardrails](docs/guardrails.md#system-1-experimental).
22
+ - `/guardrails wrong [N]` appends a `should_have_asked` label to this session's Nth newest unlabelled
23
+ System 1 auto-allow (default 1), so its rule asks again until its record is rebuilt.
24
+ - `/guardrails` shows this session's recent System 1 auto-allows and each rule's record summary when System 1
25
+ is enabled or has judgments.
26
+ - chi web's owner can flag an auto-allowed tool row as **should have asked**; the worker appends the label only
27
+ for a judgment belonging to that session, and participants cannot reach the route; a flagged row stays marked
28
+ after a reload.
29
+ - System 1's auto-allowed calls are marked on their tool row in the terminal (` · auto-allowed (System 1, p=0.97)`) and
30
+ the web (an `auto-allowed · System 1` label, its rule and p on hover).
31
+ - **System 1 (experimental)**: `Samagotchi::SystemOne` talks the TypeSafe judgment API (`POST <host>/v1/systemone`) for
32
+ the questions a guardrail ask would ask, with the new `guardrails.system_one.*` settings (`mode`, `host`, `model`,
33
+ `rules`, `threshold`, `min_record`, `timeout_ms`) and `script/system-one-probe HOST [MODEL]`, which prints each
34
+ question's probability and latency. In `audit` mode every approval a user is about to see is now judged
35
+ first and written, with the answer they gave, to `guardrails/judgments.jsonl` beside the approvals store. See
36
+ [Guardrails](docs/guardrails.md#system-1-experimental).
37
+ - `chi guardrails replay [--dry-run] [--threshold T] [--model M]` runs System 1 over the approvals this machine's
38
+ sessions already answered — read from their `cards.json` sidecars — and writes each judgment and the user's answer
39
+ to `guardrails/judgments.jsonl`, so a rule's record starts with the asks the user really decided on. It prints a
40
+ per-rule table (asks, allowed, denied, auto at the threshold, false allows, no judgment) and skips unanswered
41
+ asks, relay cards and question ids a previous replay logged; a parent agent's answer is not the user's, so only
42
+ its deny is replayed, and only where the parent could have allowed. Exits 2 when `guardrails.system_one.mode`
43
+ is off or no host is set. See [Guardrails](docs/guardrails.md#seeding-the-record-chi-guardrails-replay).
44
+ - **System 1 `on` mode answers for the user**: an ask is allowed once, without a question, when its rule is in
45
+ `guardrails.system_one.rules`, the judgment's probability is at `guardrails.system_one.threshold` (0.8 now, was
46
+ 0.95), and that rule's record is clean — `guardrails.system_one.min_record` (5) asks at the threshold the user
47
+ allowed, and no false allow among its last 20 labelled asks. Calls that write chi's own config.yml, hooks,
48
+ installed bundles or the guardrails store are never auto-allowed, nor `chi-answer-piped`, `chi-answer-http` or
49
+ `chi-broadcast`; memory files are fair game. Nothing is stored in `approvals.json`, no card is opened and no
50
+ notification is sent, so the auto-allowed tool row is its only trace. See
51
+ [Guardrails](docs/guardrails.md#answering-for-the-user-on-mode).
52
+
53
+ ### Changed
54
+
55
+ - chi web's composer grip hides the message box with a click as well as a drag: a click on the expanded grip folds it
56
+ 250 ms after the press, so a second click inside that window is a double-click and resets the height instead (a
57
+ drag, Enter or Space still act at once). A fold no longer forgets the height the grip set (`chi_composer_floor`):
58
+ the box opens again at the height it had, and only a double-click forgets it. See [Sessions](docs/sessions.md).
59
+ - chi's icon (the browser tab and the Home Screen app) is a deeper blue to purple, so the white χ stands out on any
60
+ wallpaper. On a phone, remove the Home Screen app and add it again to see it: iOS keeps the old icon.
61
+
62
+ ## [0.115.0] - 2026-10-25
63
+
64
+ ### Changed
65
+
66
+ - chi web's notifications — the browser's own and the phone's push — name a session just as its card does: with no
67
+ title, the recap's first sentence now comes after the auto title and before the first message, instead of jumping
68
+ straight to the first message.
69
+ - chi web on a touch screen: the Annotate bar docks just above the composer instead of under the selection, where iOS's
70
+ own selection menu (Copy, Look Up…) was drawn over it; its pills are larger to tap and wrap to a second row.
71
+
72
+ ### Fixed
73
+
74
+ - `/title` with no argument now says what the session's card shows: the auto title its recap saved (even when this
75
+ session's recap is off), or the recap's first sentence when the recap has no title. Both said "shows the first
76
+ message".
77
+
78
+ Update with `chi update` (the system bundle 0.115.0), then restart `chi web`.
79
+
11
80
  ## [0.114.0] - 2026-10-25
12
81
 
13
82
  ### Added
@@ -4336,7 +4405,9 @@ and long-lived sessions.
4336
4405
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
4337
4406
  hotkey panel that send selected text or the clipboard to your sessions.
4338
4407
 
4339
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.114.0...HEAD
4408
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.116.0...HEAD
4409
+ [0.116.0]: https://github.com/dm1try/samagotchi/compare/v0.115.0...v0.116.0
4410
+ [0.115.0]: https://github.com/dm1try/samagotchi/compare/v0.114.0...v0.115.0
4340
4411
  [0.114.0]: https://github.com/dm1try/samagotchi/compare/v0.113.0...v0.114.0
4341
4412
  [0.113.0]: https://github.com/dm1try/samagotchi/compare/v0.112.0...v0.113.0
4342
4413
  [0.112.0]: https://github.com/dm1try/samagotchi/compare/v0.111.1...v0.112.0
data/bin/chi CHANGED
@@ -68,6 +68,8 @@ SUBCOMMANDS = {
68
68
  "memory" => ["samagotchi/memory_command", "MemoryCommand"],
69
69
  # install, upgrade, uninstall, status, diff, list, build (--scope, --force, …)
70
70
  "bundle" => ["samagotchi/bundle_command", "BundleCommand"],
71
+ # the System 1 record: replay the asks this machine's sessions already answered
72
+ "guardrails" => ["samagotchi/guardrails_command", "GuardrailsCommand"],
71
73
  # other machines' chi web, for chi --remote NAME (add, ls, rm)
72
74
  "remote" => ["samagotchi/remote_command", "RemoteCommand"]
73
75
  }.freeze
data/docs/cli.md CHANGED
@@ -36,6 +36,8 @@
36
36
  - `chi update [--dry-run] [--no-gem] [--no-bundles] [--no-desktop]` — update an installed chi: the gem, the system bundle, the shipped bundles you installed and the desktop helper, in one table (see [Updating](#updating))
37
37
  - `chi bundle install|upgrade|uninstall|status|diff|list|build|trash|new|test|check` — manage memory bundles (see [Bundle hooks](hooks.md#bundle-hooks-unified-workflow-bundle)); `list` shows the installed ones and the ones shipped with chi, which `install <name>` installs; `core` and `dev` are profiles that install a set of them (see [Bundle profiles](memory.md#bundle-profiles-core-and-dev), [Guardrails](guardrails.md), [Plugins](plugins.md#the-btw-bundle), [the mcp bundle](plugins.md#the-mcp-bundle) [the loop-guard bundle](plugins.md#the-loop-guard-bundle), [the check-in bundle](plugins.md#the-check-in-bundle) and [the skills bundle](plugins.md#the-skills-bundle)). `trash` lists and empties the bundle trash (moved files from uninstalls/upgrades). A usage error (an unknown subcommand or flag, a missing argument, a bad `build --scope`) exits 2, as for the other subcommands. Plain `chi` and `chi web` exit 1 on an unknown flag, a flag missing its value or a stray argument. `new`, `test` and `check` are for writing one: see [Writing a bundle](#writing-a-bundle-new-test-check)
38
38
 
39
+ - `chi guardrails replay [--dry-run] [--threshold T] [--model M]` — seed System 1's record from the approvals this machine's sessions already answered: each ask is judged by the configured model and written to `guardrails/judgments.jsonl` with the answer the user gave, then a per-rule table prints (asks, allowed, denied, would-be auto-allows, false allows, asks with no judgment). A parent agent's answer is not the user's: only its deny is replayed, and only where the parent could have allowed. `--dry-run` judges without writing, `--threshold` overrides `guardrails.system_one.threshold` for the table, `--model` the configured model for this run. Exit 2 when `guardrails.system_one.mode` is off or no `host` is set; a usage error exits 2 (see [System 1](guardrails.md#system-1-experimental))
40
+
39
41
  - `chi bundle try PATH -p TEXT [options]` — install a bundle in a temporary, filtered sandbox, run one real model turn,
40
42
  and print its trace (see [Trying a bundle](#trying-a-bundle)).
41
43
 
@@ -721,8 +723,9 @@ mid-turn too; a change waits for the turn's end. No chip: its reply is where the
721
723
  web shows it. See [Guardrails](guardrails.md#answers-from-a-parent-agent).
722
724
 
723
725
  `/title` shows the session's title (`title: Coordinator`, or without one what names
724
- the session: `title: none (shows the auto title: …)`, the recap's, or `title: none
725
- (shows the first message: …)`); `/title TEXT` names the session (whitespace collapsed,
726
+ the session, read from the same `recap.json` the cards read: `title: none (shows the
727
+ auto title: …)`, `title: none (shows the recap: …)` when its recap has no title, or
728
+ `title: none (shows the first message: …)` with no recap); `/title TEXT` names the session (whitespace collapsed,
726
729
  cut at 80 characters), `/title --clear` drops the name (anything after `--clear`
727
730
  is refused with the usage line). It is saved at once,
728
731
  mid-turn too, and the lists, cards, top bar and tab show it in place of the first
@@ -1130,6 +1133,9 @@ for a note under it. Next to it sit quick replies, by default `Agreed` and
1130
1133
  `Could you please elaborate?`: a click quotes the selection the same way
1131
1134
  with that text already written as the note. It only fills the composer,
1132
1135
  never sends, so you can collect several quotes and edit before sending.
1136
+ On a touch screen (a phone, a tablet) the bar sits just above the composer
1137
+ instead of next to the selection, where the system's own Copy menu and the
1138
+ selection handles would cover it; several pills wrap to a second row.
1133
1139
 
1134
1140
  The list is `web.annotate_presets`, `|`-separated (at most five; a preset
1135
1141
  can't contain `|`; a YAML list works too):
@@ -1424,7 +1430,9 @@ In interactive assist mode, you can switch the request model without restarting:
1424
1430
  - `/model <name> --alias <alias>`: create alias for current effective model (alias value may be bare or `host:model`).
1425
1431
  - `/model`: show the effective model (and default when diverged: `runtime model: <effective> (default: <default>, profile=<name>, <source>)`, e.g. `profile=qwen36, server (chat_template)`). When the session's prompt carries model notes the line ends with `; notes: model_notes_deepseek (system, 612 chars)`; a switch's `runtime model set to …` names the new model's ([Memory](memory.md#model-notes)).
1426
1432
  - `/model clear` (or `default`/`none`/`off`): clear the session override, reverting to the configured default.
1427
- - `/guardrails`: the guardrail rules (by source), what failed to load, and your stored approvals, numbered; `/guardrails revoke N` removes approval N (see [Guardrails](guardrails.md)).
1433
+ - `/guardrails`: the guardrail rules (by source), what failed to load, your stored approvals, and System 1's
1434
+ recent auto-allows; `/guardrails revoke N` removes approval N and `/guardrails wrong [N]` flags an auto-allow
1435
+ this session should have been asked about (see [Guardrails](guardrails.md)).
1428
1436
  - `/models`: list model ids aggregated across all `hosts:` (an alias shows next to its id: a bare alias on every host listing the id, a `host:model` one only under its host; grouped `host (host:port):` with per-host `unreachable` warnings, e.g. an unset `api_key_env`; lists cached 60s, 10 minutes for a remote host; lazy — no startup prefill). Ids the host declares under `hosts.<name>.models` come first, as `rr/x (config)`, and don't count toward the cap; an unreachable host keeps them, under its warning, as `rr/x (config, host down)`. At most 20 ids per host, then `… and N more`; `/models <text>` lists every id containing `<text>` (any case), e.g. `/models qwen` on OpenRouter.
1429
1437
 
1430
1438
  Notes:
@@ -1886,6 +1886,13 @@ column says so, a `--kebab-name` flag. The maps (`hosts:`, `models:`,
1886
1886
  | `guardrails.mode` | `auto` | | Which rules vote: `auto` leaves out the rules tagged `modes: [strict]` (in the guardrails bundle: `git rebase`, writes and git outside the session's repo); `strict` runs them all. An unknown value warns and is `auto`. config.yml only. See [Guardrails](guardrails.md#modes). |
1887
1887
  | `guardrails.small_models` | `auto` | | Which models get the `models: small` rules: `auto` (32B or less by the size in the name, an MoE's active size), a list of globs, or `[]`. config.yml only. See [Guardrails](guardrails.md#rules-for-some-models). |
1888
1888
  | `guardrails.parent_answers` | `soft` | | Which options a parent agent (`chi answer`) may pick on a session's question, by their effect: `hard` (a deny, a Stop, a plain answer), `standard` (+ Continue and other spending options), `soft` (+ "Allow once"); never a wider scope or an allow on chi's own config. An invalid value warns and is `hard`. A session's own `parent_answers` (`chi --parent-answers`, `/parent-answers`) may only tighten it. Replaces `guardrails.parent_approvals` and `turn.parent_continue`. config.yml only: no environment variable (a parent can still pick the config dir with `XDG_CONFIG_HOME`). A convention for an honest parent, not a security boundary. See [Guardrails](guardrails.md#answers-from-a-parent-agent). |
1889
+ | `guardrails.system_one.mode` | `off` | | System 1, the cheap judgment that answers an ask before the user sees it: `off` (the default), `audit` (judge every ask, log it, never decide), `on` (also allowed to allow a call once, behind the gates in [System 1](guardrails.md#system-1)). An unknown value warns and is `off`. Experimental. config.yml only. See [Guardrails](guardrails.md#system-1). |
1890
+ | `guardrails.system_one.host` | none | | The `hosts:` entry serving `/v1/systemone` (a local Splash, or the hosted Jev API); unset: no judgment. config.yml only. |
1891
+ | `guardrails.system_one.model` | none | | The judgment model; unset uses the host's first model, and a host that names none has no judgment. config.yml only. |
1892
+ | `guardrails.system_one.rules` | none | | The rule ids System 1 may answer for in `on` mode: `"git-push|git-rebase"` or a YAML list, parsed like `guardrails.small_models`. Nothing is auto-allowed while it is unset; the `chi-answer-piped`, `chi-answer-http` and `chi-broadcast` rules never are, however they are listed. config.yml only. |
1893
+ | `guardrails.system_one.threshold` | `0.8` | | The probability an auto-allow needs. config.yml only. |
1894
+ | `guardrails.system_one.min_record` | `5` | | How many clean records a rule needs before System 1 is trusted for it: labelled asks of that rule at the threshold the user allowed. config.yml only. |
1895
+ | `guardrails.system_one.timeout_ms` | `2000` | | Milliseconds one judgment may take; a slower one is no judgment and the ask goes to the user. config.yml only. |
1889
1896
  | `log.file` | state dir | yes | See "Debug Log File". |
1890
1897
  | `log.disable` | `false` | yes | No file logging. |
1891
1898
  | `log.level` | `info` | yes | `debug`, `info`, `warn`, `error`. |
data/docs/guardrails.md CHANGED
@@ -97,7 +97,12 @@ matches its exact folder.
97
97
  A stored approval only relaxes an ask; a deny rule is never approvable.
98
98
  A file that doesn't parse is moved aside to `approvals.json.corrupt-<UTC time>`
99
99
  with one warning, and chi starts with no stored approvals (more asks, nothing lost).
100
- `/guardrails` lists the rules and approvals; `/guardrails revoke N` removes one.
100
+ `/guardrails` lists the rules and approvals; `/guardrails revoke N` removes
101
+ approval N. When System 1 is enabled or has judgments, it also shows this
102
+ session's recent auto-allows and each rule's record summary. In System 1 `on`
103
+ mode, `/guardrails wrong [N]` flags the Nth newest auto-allowed call in this
104
+ session as one System 1 should have asked about (default 1). The flag is
105
+ appended to the judgments log; the rule asks again until its record is rebuilt.
101
106
  The file tools can't write the store.
102
107
 
103
108
  ### Answers from a parent agent
@@ -613,6 +618,188 @@ A rule in config.yml that uses a key this chi doesn't know yet (`modes:`,
613
618
  that doesn't parse denies every tool call (see Failing closed). Bundles say
614
619
  which chi they need (`requires_chi`), so their rules don't hit this.
615
620
 
621
+ ## System 1 (experimental)
622
+
623
+ System 1 is a cheap classifier that judges an approval — "would the user
624
+ allow this?" — before it reaches them, so the asks they always allow stop
625
+ reaching them. With `mode: audit` every ask a person is about to see is
626
+ judged and logged and **nothing about the ask changes**: the user is asked
627
+ exactly as they would have been, and their answer is what settles the call.
628
+ With `mode: on` an ask that passes every gate is allowed **once** without
629
+ reaching them — see [Answering for the user](#answering-for-the-user-on-mode).
630
+
631
+ `Samagotchi::SystemOne` (lib/samagotchi/system_one/) speaks the TypeSafe
632
+ "System One" judgment API: `POST <host>/v1/systemone` with
633
+ `{model, state, questions}` and answers of type `noul` (a probability),
634
+ `choice` or `score`. The host is a `hosts:` entry with `api: openai` — a
635
+ local Splash server, or the hosted Jev API — and its key is that host's own
636
+ `api_key_env:`.
637
+
638
+ ```yaml
639
+ guardrails:
640
+ system_one:
641
+ mode: off # off (default) | audit (judge, log, never decide) | on
642
+ host: splash # the hosts: entry serving /v1/systemone
643
+ model: Qwen3.8-Flash-Next-GSQ-RCO-GGUF:Q2_0 # else the host's first model
644
+ rules: [git-push, git-rebase] # the rule ids it may answer for
645
+ threshold: 0.8 # the probability an auto-allow needs
646
+ min_record: 5 # clean records a rule needs before it is trusted
647
+ timeout_ms: 2000 # a slower judgment is no judgment
648
+ ```
649
+
650
+ `SystemOne::Client.from_config(host_registry: ...)` reads these on every
651
+ call, so a config.yml change takes effect on the next ask. `mode: off` (the
652
+ default) or no `host` means there is no client at all. Anything else is no
653
+ judgment and one `Log.warn(:guardrails, "system_one_failed", …)`: an
654
+ unreachable host, a host that isn't `api: openai`, a status other than 200,
655
+ a body that isn't JSON, a question with no answer, a request longer than
656
+ `timeout_ms`. A nil means "ask the user", which is what the caller would
657
+ have done anyway.
658
+
659
+ ### What the audit writes
660
+
661
+ With a client, `GuardrailWiring#request_approval` asks System 1 one `noul`
662
+ question, `allow`, before the user is asked. Its instruction is a constant
663
+ in code (`SystemOne::ApprovalQuestion::INSTRUCTIONS`), reviewed like a
664
+ prompt; the state is the ask's own facts — the rule and its source, the
665
+ reason, the tool, the command (or the paths) cut to 2000 characters, the
666
+ edit's diff, the cwd, the repo and branch, and the session's title when it
667
+ has one. A memory ask also names what it is about: the entry and its scope,
668
+ and for a removal the description that entry's index line carries
669
+ (`Targets#memory`, read into the card by `Guardrails::Approval`): a path
670
+ alone tells the judge too little. The state then carries up to five of the
671
+ user's **earlier asks they answered** (`SystemOne::Similar`) — this rule in
672
+ this repository first, then this rule anywhere, then any answered ask, ranked
673
+ by how many words the logged call shares with this one, ties going to the
674
+ newest. Reading the log is best-effort, so a log that cannot be read simply
675
+ offers no examples. A participant's review turn is never judged: there is no
676
+ user there to ask. The judgment runs before the card appears, so an ask is
677
+ slower by up to `timeout_ms` (2000 ms by default).
678
+
679
+ Each judgment is a line in `judgments.jsonl`, beside `approvals.json` in
680
+ `$XDG_STATE_HOME/samagotchi/guardrails/` (`SystemOne::Log`): 0600, appended
681
+ under an exclusive `flock` (several workers write it), and rewritten —
682
+ keeping the newest half of its lines — once it passes 5 MB. A line is never
683
+ rewritten to add its label:
684
+
685
+ ```json
686
+ {"kind":"judgment","id":"…","at":"…","session_id":"…","rule":"git-push","source":"config","tool":"execute","call":"git push origin main","call_digest":"<sha256 of the whole call>","repo_name":"samagotchi","p":0.97,"threshold":0.8,"model":"Qwen3.8-Flash","latency_ms":180,"decision":"audit","origin":"live"}
687
+ {"kind":"label","id":"…","at":"…","answer":"allow_once"}
688
+ ```
689
+
690
+ `decision` is what the judgment led to: `audit` (audit mode), `asked` (on
691
+ mode, the user was asked), `auto` (on mode, System 1 answered for the user).
692
+ A judgment that never arrived is logged too, with `p` null, so the failures
693
+ count as well. The label line, keyed by the judgment's id, is what the user
694
+ did: `allow_once`, `allow_wide` (any scope wider than once), `deny`, or
695
+ `none` for an ask that was cancelled. **A parent agent's answer is not the
696
+ user's: its allow labels nothing. Its deny is a label only where the parent
697
+ could have allowed instead** — the ask is not `user_only` and the session's
698
+ parent level permits a permit (`Guardrails::ParentPolicy`); a parent that was
699
+ not allowed to allow says nothing about what the user would have done. An
700
+ auto-allow has no label at all, since nobody was asked.
701
+ `SystemOne::Log#entries` reads the file and returns the judgments joined with
702
+ their last label.
703
+
704
+ Everything about the log is best-effort: a write that fails is one
705
+ `Log.warn(:guardrails, "system_one_log_failed", …)` and the approval goes on
706
+ as if System 1 were not there.
707
+
708
+ `ruby script/system-one-probe HOST [MODEL]` asks three fixed approval-like
709
+ questions through the client and prints each probability and the latency of
710
+ the call — how a host is checked before any of this is turned on.
711
+
712
+ ### Answering for the user: `on` mode
713
+
714
+ In `on` mode an ask is allowed **once**, without reaching the user, when every
715
+ gate below passes, in this order:
716
+
717
+ 1. the mode is `on` (in `audit` the judgment decides nothing);
718
+ 2. the ask's rule is in `guardrails.system_one.rules` — `"git-push|git-rebase"`
719
+ or a YAML list, read like `guardrails.small_models`. Nothing is auto-allowed
720
+ by default: the user names the rules they want;
721
+ 3. the rule is not one of the three that let an agent answer chi's own
722
+ questions — `chi-answer-piped`, `chi-answer-http`, `chi-broadcast` — which
723
+ are never auto-allowed, even when listed
724
+ (`SystemOne::SelfProtect::NEVER_AUTO`);
725
+ 4. the call does not change chi itself (`SystemOne::SelfProtect.refuse?`): no
726
+ edit or write whose paths include config.yml in chi's config dir, anything
727
+ under the hooks dir, the installed bundles dir or the guardrails store (where
728
+ `approvals.json` and `judgments.jsonl` live), and no `execute` or
729
+ `task_create` command naming any of them — the `~/.config/samagotchi`
730
+ spelling counts too. The memories dir is deliberately not one of these:
731
+ reading or appending a memory file can be auto-allowed. A protected rule
732
+ (`Verdict::PROTECTED_RULES`) may be listed by name and System 1 may answer
733
+ it, but a call that really writes config.yml or the hooks is refused here;
734
+ 5. the judgment's `p` is at least `guardrails.system_one.threshold` (0.8);
735
+ 6. the rule's record is clean (`SystemOne::Record`, read from the log once per
736
+ ask): at least `guardrails.system_one.min_record` (5) of its asks judged at
737
+ or above the threshold that the user allowed, and none of its **last 20
738
+ labelled** asks judged at the threshold that the user denied or later
739
+ flagged as one System 1 should have asked about. Asks with no label, no
740
+ probability, or a `none` label count for neither side.
741
+
742
+ Then the judgment's `decision` is `auto`, the verdict settles as
743
+ `settle!(:allow, decided_by: "system one")` with scope `once`, and nothing is
744
+ written to `approvals.json` — the next call is judged again. **No question is
745
+ opened, so no card appears, no `:question_asked` hook fires, and no
746
+ notification is sent.** The verdict's activity carries
747
+ `note: "auto-allowed by System 1 (p=0.97)"`, `judgment_id` and `p`; the line
748
+ that shows it in the terminal and the web is P3b, and is not rendered yet, so
749
+ today an auto-allowed call simply looks like a call that never needed
750
+ approval. If the judgment line cannot be written to the log, the ask goes to
751
+ the user.
752
+
753
+ A delegate child answers from its own session's settings: its System 1 can
754
+ auto-allow a call even when the child's `parent_answers: hard` bars a parent
755
+ from allowing it — the record gate is about the rule, not about who answers.
756
+
757
+ To correct an auto-allow after the fact, run `/guardrails` to see this
758
+ session's recent unlabelled auto-allows, newest first, then
759
+ `/guardrails wrong [N]` to label one. In chi web, the owner can instead click
760
+ **should have asked** on that tool row; participants cannot use that route.
761
+ The label is appended, not written over the judgment. `should_have_asked`
762
+ makes the rule's record fail the false-allow check, so the rule asks again
763
+ until enough later clean asks rebuild its record.
764
+
765
+ ### Seeding the record: `chi guardrails replay`
766
+
767
+ A rule's record starts empty, so `chi guardrails replay` (this machine only)
768
+ judges the asks the sessions here already answered and writes them to the same
769
+ log, with the user's answer as the label:
770
+
771
+ ```sh
772
+ chi guardrails replay [--dry-run] [--threshold T] [--model M]
773
+ ```
774
+
775
+ It reads every session's sidecar, `$XDG_STATE_HOME/samagotchi/sessions/<id>/cards.json`
776
+ (the file `Bridge::CardStore` saves), and takes the entries of type `question`
777
+ whose `pending_question.approval` is there and that have an `answer` or were
778
+ `cancelled`. A label is the scope the user took — `allow_once` for "Allow once",
779
+ `allow_wide` for a wider one, `deny` for anything else, `none` for a cancelled
780
+ ask. Skipped: a question no one answered, a relay card, which carries no
781
+ approval of its own, and a question id a previous replay already logged — so
782
+ running it twice adds nothing. A parent agent's answer is not the user's: its
783
+ allow is skipped, and its deny is replayed only where the parent could have
784
+ allowed (the card is not `user_only` and the session's parent level permits a
785
+ permit). A cards file that doesn't parse is skipped with one warning line,
786
+ never a failed run.
787
+
788
+ Each ask is judged exactly as a live one is (`SystemOne::ApprovalQuestion`),
789
+ and written as a `judgment` line with `decision` and `origin` both `replay`,
790
+ its `question_id` and the card's `created_at` as `asked_at`, followed by its
791
+ label. Then a table, one row per rule sorted by how many asks it had, and a
792
+ total: the asks, how many the user allowed, denied, how many at the threshold
793
+ System 1 would have auto-allowed, how many of those the user denied (the false
794
+ allows), and how many got no judgment. `--threshold` overrides
795
+ `guardrails.system_one.threshold` for the table; `--model` overrides the model
796
+ for this run; `--dry-run` judges and prints without writing a line. Without a
797
+ client (`mode: off`, or no `host`) it exits 2 saying what to set.
798
+
799
+ Two limits on what the replay can see: `CardStore` keeps only the last 20
800
+ questions per session, and a `--no-shared` REPL session never writes a
801
+ `cards.json` at all, so its asks are not replayable.
802
+
616
803
  ## The guardrails bundle
617
804
 
618
805
  ```sh
@@ -146,8 +146,9 @@ the top and in `pending_question` / `pending_card`, strings in `last_turn`), `no
146
146
  cleaned up, and pushed only while still open. A failed or done turn has nothing to answer.
147
147
  - `Attention.closed_keys(prev, next)`: the question answered, replaced, newly relayed; the card resolved or
148
148
  replaced; both when the session is gone.
149
- - `Attention.text(summary, reason)`: notify.js `attentionText`'s words: the session's title, first preview or last
150
- prompt clipped to 61, and "needs approval" / "needs an answer" / "hit its step limit" / "needs you" / "turn
149
+ - `Attention.text(summary, reason)`: notify.js `attentionText`'s words: the session's name as its card has it
150
+ (title, auto title, the recap's first sentence, first preview, last prompt — the same order `format.js cardName`
151
+ uses) clipped to 61, and "needs approval" / "needs an answer" / "hit its step limit" / "needs you" / "turn
151
152
  failed" / "done in N s" (`last_turn["seconds"]` rounded). Never the question or the command.
152
153
 
153
154
  `Notifier#call(event)` is a `SessionHub` sink. **The hub calls it under its Monitor**, from the tick thread and also
data/docs/sessions.md CHANGED
@@ -94,7 +94,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
94
94
  - Web notifications: when a session needs you while the tab is hidden or behind another window (a question or a guardrail approval, a plugin card with buttons during a turn such as check-in's, a failed turn, a turn done after 10 s or more; a delegated session only for its questions, a canceled turn never), the page title counts it, `(N) Chi`, until you come back to the tab. The bell at the right of the top bar also turns on OS notifications ("needs an answer", "needs approval", "needs you", "turn failed", "done in 42 s", under the session's first message; a click opens the session): its first click asks the browser for the permission, and the choice is kept per browser. Any session of the page's scope counts, not only the open one. What was already so when the page loaded never counts, and several chi tabs show one notification per event. A chi tab in front tells the others (a `BroadcastChannel`), so while you look at one, the ones behind neither notify nor count what it shows; coming back to a tab clears those events in the other tabs' titles too. The hub's `session` frames carry what this needs: `pending_question` (`{id, kind}`, kind `question` or `approval`), `pending_card` (`{id, bundle}`: the running turn's card with actions, from `pending_card.json` that the worker keeps in the session's folder while one is open; a card without actions, such as loop-guard's, never counts) and `last_turn` (`{outcome, ended_at, seconds, origin}`, saved in the session's JSON as the engine ends each turn).
95
95
  - The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover; the chip and rows as lit-html templates), `keep_focus.js` (lit reuses a list's nodes by position: a redraw keeps the focus on the same item by its `data-id` / `data-name`), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `#N` chip and rows as lit-html templates, the info bar's ← → links, a refused continue, and the fresh-link rule), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `bubbles.js` (the history's bubbles between the turns' rows: output chunks, end lines, every client's prompt bubbles by `enqueued_id` with their queued/steered badges, the idle recap, guardrail warnings, hooks' notices, plugins' steers, command and note bubbles; lit-html templates, put in the history with the sticky follow by `createBubbles`), `cards_place.js` (where plugin cards and a snapshot's notices go in the history: a card added or updated in place by its id, a running turn's into its step, a snapshot's each above the first turn after it, a finished turn's notice in its step's rows and its question resolved where it was asked; a card's action buttons), `format.js` (pure formatters such as `previewOf`), `plain_line.js` (markdown out of the plain one-line places: the stage headline, a closed step's title, an answered question card's kept-reply line), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `delivery_line.js` (the composer's row above the text field in a session, always there so the composer keeps its height: the key hint while idle, and the delivery line while a turn runs: where a message typed now goes in, from the step's phase, clock and size, whether "cut in now" can land, and the queue link for a worker with the `delivery` feature, as a pure `deliveryLine`, its lit-html template and `createDeliveryControl`, which ticks it and keeps a choice armed from a link; see docs/cli.md, "Typing during a turn"), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `stage_view.js` (the stage view: the running turn pinned above the composer, then handed off into the history; its pure rules and texts in `stage_model.js`, its live slots (headline, now row, trail, ticks) in `stage_slots.js`, its cap and held height in `stage_frame.js`, the in-use watch that times the hand-off in `stage_watch.js`), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `info_bar_model.js` (that model and the bar's buttons' states, built from the page's session state, its list and its in-flight continues and restarts as pure functions; `app.js`'s `updateInfoBar` applies them every second while a turn runs), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, a plugin's ref data (label, http(s) URL actions) bound in a WeakMap by `bindRef`, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu), `ref_scan.js` (the `path:line` refs in an answer's or a step's text that name a file the session's rows touched), `answer_refs.js` (the refs a plugin attached to an answer, its `display_refs`: bound to the answer's bubble, paired with its links and text, bound with `bindRef`), `markdown_place.js` (where rendered markdown and its refs go: a text as markdown HTML, its `path:line` refs, a final answer's rendering), `images.js` (image chips in the composer: paste, drop and the image button, the upload before a turn, the thumbnails and the lightbox), `composer_grip.js` (the composer's grip and fold: the drag, tap and keys, the fold per visit), `strip_grip.js` (the latest-sessions strip's grip, the composer grip's twin: hides and shows the strip), `storage.js` (the page's per-browser choices in localStorage, guarded: a choice that can't be read or written is not remembered), `unqueue.js` (the × on a message queued for after the turn: takes it back, its text into an empty composer or a toast), `toasts.js` (the page's toast stack: a timer each, sticky ones until acted on, the same text reusing its item), `toast_place.js` (where the toast sits: above the composer card, the stage and an open model panel), `dock_reserve.js` (what floats over the history: the history's bottom reserve (`--dock-h`) at the composer card's and the showing stage's height, a reader at the end kept there, and the toast's place measured for `toast_place.js`), `notice_links.js` (a hook notice line's http(s) URLs as links), `start_page.js` (the start page's model picker and its LLM context and thinking chips, a choice for the next new session only; a fresh chain link's picker and its `/model`), `session_actions.js` (Continue, Archive and its Undo, Delete and Stop, and the start page when the open session is gone), `send_turn.js` (the composer's send: a new session first on the start page, the route, the echo, the image upload, the `/turn` ack, a fast failure's restore and each error's place). Unit-tested via `npm test` (`spec/web/public/*.test.js`).
96
96
  - Selecting a session in the web UI is read-only: `GET /api/sessions/:id` never spawns a worker (it reads a live worker's snapshot when one runs, else the session file). A prompt (`POST /turn`) or a command (`POST /command`) wakes the worker, and `/stream` briefly waits for a freshly-spawned bridge before answering. A caught-up SSE reconnect holds the stream open; `reset` markers are only sent for reconnects behind the ring window, or with a cursor from another worker (event ids are `<event_seq>-<epoch>`, one epoch per worker).
97
- - The web session page's message box folds away for room to read. Drag the grip on its top edge (the pill that shows on hover; always on a phone) down, past the box by 32 px: the pill turns accent while releasing would hide it, and on release the box folds and the info bar stays as the page's bottom edge. The grip stays as a handle on the info bar's top edge, drawn always: pull it up (8 px opens it, and the box follows the pointer in the same gesture), click or tap it (opens it and focuses the box), or just type on the page (the key lands in the box) to bring it back; Enter or Space on the focused grip folds or opens too. A draft (text or images, which folding never clears) colours the handle with the accent, a session waiting on you with the attention colour; its tooltip says so. Stop turn moves into the info bar while it is folded. The fold is remembered per browser (`chi_composer_collapsed`), an archived session opens folded for the visit, and opening it there, like the page opening it itself (a restored line, a quote, continue →'s opener), is for that visit only, and a fold forgets the height the grip set (`chi_composer_floor`): the box reopens at one line, or at its text's height. The start page's grip only resizes.
97
+ - The web session page's message box folds away for room to read. Drag the grip on its top edge (the pill that shows on hover; always on a phone) down, past the box by 32 px: the pill turns accent while releasing would hide it, and on release the box folds and the info bar stays as the page's bottom edge. The grip stays as a handle on the info bar's top edge, drawn always: pull it up (8 px opens it, and the box follows the pointer in the same gesture), click or tap it (opens it and focuses the box), or just type on the page (the key lands in the box) to bring it back; Enter or Space on the focused grip folds or opens too. A draft (text or images, which folding never clears) colours the handle with the accent, a session waiting on you with the attention colour; its tooltip says so. Stop turn moves into the info bar while it is folded. The fold is remembered per browser (`chi_composer_collapsed`), an archived session opens folded for the visit, and opening it there, like the page opening it itself (a restored line, a quote, continue →'s opener), is for that visit only. The fold keeps the height the grip set (`chi_composer_floor`): the box opens again at the height it had, and only a double-click on the grip forgets it. A click on the expanded grip hides the box as well: it folds 250 ms after the press, so a second click inside that window is read as a double-click and resets the height instead (a drag, Enter or Space act at once, and a drag after a click takes the wait back). The start page's grip only resizes.
98
98
 
99
99
  **Test-session hygiene:**
100
100
 
@@ -237,7 +237,7 @@ chi send --new --continues last:3fa2 -m "new day" # the next link of 3fa2's
237
237
 
238
238
  ## Session titles
239
239
 
240
- A session is named by its first message until you give it a title: `/title Coordinator` (in the REPL, an attached terminal, the web composer or `chi send -m "/title Coordinator" ID`; a parent agent may name its delegates the same way). The title is saved in the session file (`title`), whitespace collapsed and cut at 80 characters, and runs mid-turn too, at once. `/title` alone shows it (`title: none (shows the auto title: Parser tab handling)` or `title: none (shows the first message: hey chi …)` without one); `/title --clear` drops it, and the first message names the session again. A session whose first line was the command itself (`chi send --new -m "/title X"`) takes its preview from its first real message.
240
+ A session is named by its first message until you give it a title: `/title Coordinator` (in the REPL, an attached terminal, the web composer or `chi send -m "/title Coordinator" ID`; a parent agent may name its delegates the same way). The title is saved in the session file (`title`), whitespace collapsed and cut at 80 characters, and runs mid-turn too, at once. `/title` alone shows it, and without one says what the card shows — `title: none (shows the auto title: Parser tab handling)`, `title: none (shows the recap: Fixing the login page.)` when the recap has no title, or `title: none (shows the first message: hey chi …)` with no recap; `/title --clear` drops the title, and what the lists show comes back. A session whose first line was the command itself (`chi send --new -m "/title X"`) takes its preview from its first real message.
241
241
 
242
242
  Without a title, a session is named by its **auto title** once its recap has one, else by its recap's first sentence (see [Session recap](cli.md#session-recap); its subject phrase stripped), else by its first message: on its card (which leaves out its recap line when the name is that same sentence), the families and chain rows, the top bar and the browser tab, the delete confirm and the search, the tab's and the phone's notifications, and `chi sessions list` (plain and `--live`; json has `auto_title`, tsv and json's `desc` keep the first message). The auto title is 2-6 words the recap's model writes on the line before the recap, in the same request (`Title: …`, split off by `RecapTitle`; the recap text never holds it). It is saved in the session's `recap.json` (`title`), so it goes when a stale recap is dropped; each later recap is shown the earlier title and keeps it unless the focus moved, and a reply without a usable one (not 2-6 words, or naming the user or the assistant) keeps the earlier title. A hand title always wins and is never overwritten. With the recap off no new auto title is written; a saved one still shows. The auto title is for display only: never in a model request (the `list_sessions` tool, `ctx.sessions`, a parent's children, `chi broadcast` keep the title, else the first message), nor on the terminal's status row, nor on a participant's page (the title or the review's subjects).
243
243
 
@@ -192,6 +192,7 @@ module Samagotchi
192
192
  activity = { tool: part[:tool], status: part[:status], params: part[:params], **title }
193
193
  activity[:action] = part[:action] if part[:action]
194
194
  activity[:no_match] = true if part[:no_match]
195
+ activity[:guardrail] = part[:guardrail] if part[:guardrail]
195
196
  completed = { type: :tool_call_completed, **call, output: part[:output], output_truncated: !!part[:output_truncated],
196
197
  activity: activity }
197
198
  completed[:duration_ms] = part[:duration_ms] unless part[:duration_ms].nil?
@@ -333,6 +334,9 @@ module Samagotchi
333
334
  capped = output.length > @max_output_chars
334
335
  tool[:status] = (event.dig(:activity, :status) || "ok").to_s
335
336
  tool[:no_match] = true if event.dig(:activity, :no_match)
337
+ # Only System 1's auto-allow is drawn on the row (the web's label).
338
+ guardrail = event.dig(:activity, :guardrail)
339
+ tool[:guardrail] = guardrail if guardrail.is_a?(Hash) && guardrail[:decided_by] == "system one"
336
340
  tool[:output] = capped ? ToolOutputCut.preview(output, cap: @max_output_chars, tool: event[:tool]) : output.dup
337
341
  tool[:output_truncated] = capped || !!event[:output_truncated]
338
342
  tool[:images] = event[:images] if event[:images]
@@ -19,6 +19,7 @@ require_relative "bridge/sse_writer"
19
19
  require_relative "bridge/turn_accumulator"
20
20
  require_relative "session"
21
21
  require_relative "engine"
22
+ require_relative "system_one/log"
22
23
  require_relative "session_commands"
23
24
  require_relative "queued_command"
24
25
  require_relative "steer"
@@ -584,6 +585,7 @@ module Samagotchi
584
585
  %w[POST cancel] => :handle_cancel,
585
586
  %w[POST answer] => :handle_answer,
586
587
  %w[POST question/dismiss] => :handle_dismiss_question,
588
+ %w[POST guardrails/wrong] => :handle_system_one_wrong,
587
589
  %w[POST turn] => :handle_post_turn,
588
590
  %w[POST turn/dequeue] => :handle_dequeue_turn,
589
591
  %w[POST command] => :handle_command,
@@ -730,6 +732,30 @@ module Samagotchi
730
732
  end
731
733
  end
732
734
 
735
+ # POST /session/:id/guardrails/wrong {id}: append a false-allow label
736
+ # only for this session's System 1 auto-allow. Unknown ids are not
737
+ # distinguishable from judgments belonging to another session.
738
+ def handle_system_one_wrong(session_id, body)
739
+ parsed = parse_json(body)
740
+ return [{ "Allow" => "POST" }, 400, { error: "invalid_json" }] unless parsed.is_a?(Hash)
741
+
742
+ judgment_id = fetched(parsed, "id").to_s.strip
743
+ return [{ "Allow" => "POST" }, 400, { error: "missing_fields", detail: "id required" }] if judgment_id.empty?
744
+
745
+ log = SystemOne::Log.new(dir: Guardrails::Approvals.dir_for(@state_dir))
746
+ entry = log.entries.find do |judgment|
747
+ judgment["id"] == judgment_id &&
748
+ judgment["session_id"] == session_id &&
749
+ judgment["decision"] == "auto"
750
+ end
751
+ return [{}, 404, { error: "unknown_judgment" }] unless entry
752
+ return [{}, 200, { status: "labelled", id: judgment_id }] if entry["answer"] == "should_have_asked"
753
+ return [{}, 409, { error: "already_labelled" }] unless entry["answer"].nil?
754
+ return [{}, 500, { error: "label_failed", detail: "could not write the System 1 label" }] unless log.label(judgment_id, "should_have_asked")
755
+
756
+ [{}, 200, { status: "labelled", id: judgment_id }]
757
+ end
758
+
733
759
  # What a parent's relay may say about this session's approval
734
760
  # (BridgeClient#relay): opened and closed only change how the question
735
761
  # shows (QuestionDesk#annotate); answered makes this worker ask the
@@ -145,6 +145,13 @@ module Samagotchi
145
145
  post("question/dismiss", { id: id, deadline: deadline }, read_body: true)
146
146
  end
147
147
 
148
+ # POST /session/:id/guardrails/wrong: mark this session's System 1
149
+ # auto-allow as one it should have asked about; 404 when unknown.
150
+ # @return [Response]
151
+ def mark_system_one_wrong(judgment_id:)
152
+ post("guardrails/wrong", { id: judgment_id }, read_body: true)
153
+ end
154
+
148
155
  # POST /session/:id/turn/dequeue: take back a queued message (one that
149
156
  # waits for the running turn's end) before it runs. 200 {status:
150
157
  # "removed", enqueued_id, prompt}; 409 already_started (a turn took
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.114.0
3
+ version: 0.116.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -173,6 +173,21 @@ module Samagotchi
173
173
  # (Guardrails::ParentPolicy): hard = deny and plain answers; standard = + spend (Continue); soft = + permit (Allow
174
174
  # once). An invalid value is hard, the strictest.
175
175
  Entry.new(key: "guardrails.parent_answers", yaml_path: %w[guardrails parent_answers], type: :enum, default: "soft", expose: %i[config], enum_values: %w[hard standard soft], invalid: "hard", lifecycle: :next_tool_or_approval),
176
+ # System 1 (the cheap judgment model that answers the asks a user always allows). Client and settings; in `on`
177
+ # mode GuardrailWiring also reads rules, threshold and min_record for the auto-allow (docs/guardrails.md).
178
+ # off (the default) | audit (judge every ask, log it, never decide) | on. An unknown value warns and is off.
179
+ Entry.new(key: "guardrails.system_one.mode", yaml_path: %w[guardrails system_one mode], type: :enum, default: "off", expose: %i[config], enum_values: %w[off audit on], lifecycle: :next_tool_or_approval),
180
+ # The hosts: entry serving /v1/systemone (Splash, or the hosted Jev API); unset: no judgment.
181
+ Entry.new(key: "guardrails.system_one.host", yaml_path: %w[guardrails system_one host], type: :string, default: nil, expose: %i[config], lifecycle: :next_tool_or_approval),
182
+ # The judgment model; unset: the host's first model, and no judgment if it names none.
183
+ Entry.new(key: "guardrails.system_one.model", yaml_path: %w[guardrails system_one model], type: :string, default: nil, expose: %i[config], lifecycle: :next_tool_or_approval),
184
+ # The rule ids System 1 may answer for ("a|b", or a YAML list, parsed like guardrails.small_models).
185
+ Entry.new(key: "guardrails.system_one.rules", yaml_path: %w[guardrails system_one rules], type: :string, default: nil, expose: %i[config], lifecycle: :next_tool_or_approval),
186
+ # The probability an auto-allow needs, and how many clean records a rule needs before it is trusted.
187
+ Entry.new(key: "guardrails.system_one.threshold", yaml_path: %w[guardrails system_one threshold], type: :float, default: 0.8, expose: %i[config], lifecycle: :next_tool_or_approval),
188
+ Entry.new(key: "guardrails.system_one.min_record", yaml_path: %w[guardrails system_one min_record], type: :integer, default: 5, expose: %i[config], lifecycle: :next_tool_or_approval),
189
+ # How long one judgment may take, in milliseconds; a judgment that doesn't arrive asks the user.
190
+ Entry.new(key: "guardrails.system_one.timeout_ms", yaml_path: %w[guardrails system_one timeout_ms], type: :integer, default: 2000, expose: %i[config], lifecycle: :next_tool_or_approval),
176
191
 
177
192
  Entry.new(key: "log.file", yaml_path: %w[log file], type: :string, default: nil, expose: %i[env config cli], lifecycle: :process_start),
178
193
  Entry.new(key: "log.disable", yaml_path: %w[log disable], type: :bool, default: false, expose: %i[env config cli], lifecycle: :process_start),
@@ -401,7 +401,12 @@ module Samagotchi
401
401
  cancelled: -> { !!active_cancel_controller&.cancelled? },
402
402
  # An approval of the model's call: :question_asked first (QuestionAsked).
403
403
  ask: ->(fields) { open_question(question_asked(QuestionAsked::KIND_APPROVAL, fields)) },
404
- notify: ->(message) { guardrail_notify(message) }
404
+ notify: ->(message) { guardrail_notify(message) },
405
+ # System 1 (experimental, audit phase): the judgment client is read
406
+ # from config on every ask, and the running turn's cancel controller
407
+ # closes a judgment that is no longer wanted.
408
+ system_one: -> { SystemOne::Client.from_config(host_registry: @host_registry) },
409
+ cancel_controller: -> { active_cancel_controller }
405
410
  )
406
411
  end
407
412
  private :build_guardrail_wiring