samagotchi 0.141.0 → 0.142.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 +46 -1
- data/README.md +1 -1
- data/bin/chi +2 -0
- data/docs/architecture.md +12 -283
- data/docs/bundle-authoring.md +1 -0
- data/docs/cli.md +15 -7
- data/docs/configuration.md +57 -18
- data/docs/context.md +2 -2
- data/docs/guardrails.md +20 -11
- data/docs/hooks.md +9 -7
- data/docs/internals/attached-context.md +226 -0
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bridge.md +380 -0
- data/docs/internals/bundles.md +1 -0
- data/docs/internals/context-telemetry.md +1 -1
- data/docs/internals/engine.md +24 -18
- data/docs/internals/participants.md +13 -12
- data/docs/internals/remote-sessions.md +1 -1
- data/docs/internals/{tool-guardrails.md → tool-output-limits.md} +21 -2
- data/docs/memory.md +31 -1
- data/docs/plugins.md +122 -1
- data/docs/sessions.md +50 -26
- data/docs/system-one.md +122 -0
- data/docs/testing.md +3 -1
- data/lib/samagotchi/bridge.rb +2 -0
- data/lib/samagotchi/bundles/check-in/manifest.yml +6 -3
- data/lib/samagotchi/bundles/check-in/plugin.rb +101 -4
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/config.rb +74 -14
- data/lib/samagotchi/context_candidate_choice.rb +8 -0
- data/lib/samagotchi/context_candidates_log.rb +25 -1
- data/lib/samagotchi/context_judge.rb +6 -2
- data/lib/samagotchi/engine.rb +68 -7
- data/lib/samagotchi/extension_load.rb +3 -2
- data/lib/samagotchi/guardrail_wiring.rb +36 -31
- data/lib/samagotchi/guardrails/chi_dirs.rb +7 -4
- data/lib/samagotchi/guardrails/path_resolve.rb +1 -1
- data/lib/samagotchi/guardrails/protected_paths.rb +7 -4
- data/lib/samagotchi/guardrails_command.rb +3 -3
- data/lib/samagotchi/judgments_command.rb +155 -0
- data/lib/samagotchi/kernel_loop.rb +13 -1
- data/lib/samagotchi/llm/chat_loop.rb +74 -10
- data/lib/samagotchi/llm/errors.rb +7 -5
- data/lib/samagotchi/llm/stray_tool_call.rb +61 -0
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/memory_bundle/entry_size.rb +100 -0
- data/lib/samagotchi/plugin/api.rb +33 -2
- data/lib/samagotchi/plugin/context.rb +49 -1
- data/lib/samagotchi/plugin/judgments.rb +72 -0
- data/lib/samagotchi/plugin/loader.rb +3 -2
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/self_report.rb +36 -0
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_commands.rb +3 -0
- data/lib/samagotchi/session_manager.rb +13 -2
- data/lib/samagotchi/session_retention.rb +10 -0
- data/lib/samagotchi/side_judgments.rb +12 -1
- data/lib/samagotchi/system_one/answer.rb +63 -11
- data/lib/samagotchi/system_one/approval_label.rb +56 -0
- data/lib/samagotchi/system_one/approval_question.rb +2 -0
- data/lib/samagotchi/system_one/client.rb +43 -17
- data/lib/samagotchi/system_one/fake_backend.rb +1 -1
- data/lib/samagotchi/system_one/http_backend.rb +12 -4
- data/lib/samagotchi/system_one/judgment_settings.rb +61 -0
- data/lib/samagotchi/system_one/judgments.rb +260 -0
- data/lib/samagotchi/system_one/log.rb +4 -2
- data/lib/samagotchi/system_one/recording.rb +117 -0
- data/lib/samagotchi/system_one/runs.rb +179 -0
- data/lib/samagotchi/system_one/runs_store.rb +230 -0
- data/lib/samagotchi/system_one.rb +9 -0
- data/lib/samagotchi/testing/bundle.rb +9 -2
- data/lib/samagotchi/testing/host.rb +2 -1
- data/lib/samagotchi/testing/judgments.rb +185 -0
- data/lib/samagotchi/testing/recorder.rb +5 -0
- data/lib/samagotchi/testing/recording_api.rb +1 -1
- data/lib/samagotchi/tool_output_cut.rb +26 -6
- data/lib/samagotchi/tool_response.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +63 -4
- data/lib/samagotchi/toolkit_suggest.rb +11 -2
- data/lib/samagotchi/tools/memory.rb +28 -0
- data/lib/samagotchi/turn_note.rb +9 -0
- data/lib/samagotchi/version.rb +1 -1
- metadata +16 -2
data/docs/configuration.md
CHANGED
|
@@ -149,7 +149,7 @@ or `chi web` restart.
|
|
|
149
149
|
|---|---|---|
|
|
150
150
|
| `default.model` | New session | Existing sessions keep their saved model; change one with `/model`. |
|
|
151
151
|
| `default.input`, `no_default_input`, `no_interrupt` | Next CLI run | These set up that run's first prompt or turn limit. |
|
|
152
|
-
| `default.max_tokens`, `max_tool_output_chars`, `read.*`, `execute.*` except `execute.description`, `image.*` | Next matching request or tool use | An operation already in progress keeps its resolved limits. |
|
|
152
|
+
| `default.max_tokens`, `max_tool_output_chars`, `memory.read_max_chars`, `read.*`, `execute.*` except `execute.description`, `image.*` | Next matching request or tool use | An operation already in progress keeps its resolved limits. |
|
|
153
153
|
| `server.host`, `server.port`, `hosts.*` | Worker start | Includes host addresses, APIs, declared models and host-level settings. The web model picker refreshes `hosts:` on file change; a running worker does not. |
|
|
154
154
|
| `server.open_timeout`, `server.read_timeout`, `server.first_token_timeout` | Worker start | The worker's clients retain the timeouts set when they were built. |
|
|
155
155
|
| `model_aliases` | Next model lookup | A session keeps its resolved model unless it is explicitly switched. |
|
|
@@ -166,9 +166,10 @@ or `chi web` restart.
|
|
|
166
166
|
| `toolkit.kits`, `execute.description`, `llm_context.policy` | Worker start | These are held by the engine's toolkit or tool registry. |
|
|
167
167
|
| `toolkit.suggest.mode`, `toolkit.suggest.threshold`, `toolkit.suggest.defer_turns`, `toolkit.suggest.defer_tokens` | Worker start | Suggestion settings are config-only and are resolved when the worker runs its audit or on-mode ask. |
|
|
168
168
|
| `skip_agents_md` | Prompt rebuild | A running engine caches its built prompt; a new worker or `/model` rebuild uses the changed value. |
|
|
169
|
-
| `context.*` except `context.every_seconds`, `llm_context.*` except `llm_context.policy`, `steer.cut_after`, `memory.index_warn_tokens`, `status.line`, `web.participants.*` | Next matching turn, display, poll or write | Readers resolve these settings when they perform that operation. Session-specific LLM context and thinking values remain saved in the session. A participant's grant keeps the expiry and cap it was issued with; `web.participants.*` is what the next invite gets, and the cap and queue of a grant from before caps. |
|
|
169
|
+
| `context.*` except `context.every_seconds`, `llm_context.*` except `llm_context.policy`, `steer.cut_after`, `memory.index_warn_tokens`, `memory.entry_warn_tokens`, `status.line`, `web.participants.*` | Next matching turn, display, poll or write | Readers resolve these settings when they perform that operation. Session-specific LLM context and thinking values remain saved in the session. A participant's grant keeps the expiry and cap it was issued with; `web.participants.*` is what the next invite gets, and the cap and queue of a grant from before caps. |
|
|
170
170
|
| `context.every_seconds` | Next context poll | The poller checks it for each source that has no explicit interval. |
|
|
171
171
|
| `guardrails.*` | Next tool call or approval | Guardrail rules and disable lists are reloaded when the config file changes; rule settings take effect on that evaluation. `guardrails.parent_answers` is checked when a parent answers a question. |
|
|
172
|
+
| `system_one.host`, `system_one.model`, `system_one.timeout_ms`, `system_one.runs`, `system_one.judgments` | Next tool call or approval | Each ask reads them. |
|
|
172
173
|
| `hooks`, `bundles` | Worker start | Hooks, plugins and their bundle settings load with the engine. |
|
|
173
174
|
| `recap.*` | Worker start | The engine builds its recap job at start, and again when it binds a session with its own `recap.enabled` or `recap.sentences` (`--set`, see "Per-session settings") and when `/recap on\|off\|default` changes the session's own value. |
|
|
174
175
|
| `session.shared` | Next `chi` invocation | It selects attached-worker or in-process mode for that invocation. |
|
|
@@ -185,7 +186,7 @@ or `chi web` restart.
|
|
|
185
186
|
|
|
186
187
|
### Per-session settings
|
|
187
188
|
|
|
188
|
-
A few core keys can be set for one session: `chi --set KEY=VALUE` (or `chi send --new --set KEY=VALUE`, or
|
|
189
|
+
A few core keys can be set for one session (the registry entries marked `session: true`, `Config::Entry#session?`): `chi --set KEY=VALUE` (or `chi send --new --set KEY=VALUE`, or
|
|
189
190
|
`POST /api/sessions` `settings`) at its creation, and `recap.enabled` later too with `/recap on`, `/recap off` or `/recap default`
|
|
190
191
|
(at once; `default` removes the session's own value, see [Session recap](cli.md#session-recap)). The value is saved in the session file (`config_settings`), so a
|
|
191
192
|
worker respawn, a continue and a fork keep it; a delegate child starts without it, on config.yml's. It goes first:
|
|
@@ -1526,14 +1527,14 @@ toolkit:
|
|
|
1526
1527
|
`undecided` for greetings or check-ins. Only a default-origin session with at least two described candidates is
|
|
1527
1528
|
asked; chosen or inherited toolkits, delegate children, review sessions and participant turns are skipped. After
|
|
1528
1529
|
`undecided`, later owner prompts may be asked again within both defer limits. The default is `off`. System 1's
|
|
1529
|
-
host, model and timeout come from `
|
|
1530
|
+
host, model and timeout come from `system_one.*`; the suggestion threshold and defer limits are its own
|
|
1530
1531
|
settings below. Suggestions are
|
|
1531
1532
|
kept in `toolkit_suggestions.jsonl`; `chi toolkit report` grades them against the session's tool calls. One ask
|
|
1532
1533
|
runs at a time (a turn that comes while one still runs goes unasked, with an info line), and a kit that doesn't
|
|
1533
1534
|
resolve (a missing base, a cycle) is left out of the candidates. A worker that exits waits about a second for a
|
|
1534
1535
|
running ask, so a one-shot `-p --non-interactive` run may end before its suggestion is logged.
|
|
1535
1536
|
- **On mode.** `toolkit.suggest.mode: on` asks the same question on the same eligible turns, but the turn waits for
|
|
1536
|
-
the answer before it declares any tool, for at most `
|
|
1537
|
+
the answer before it declares any tool, for at most `system_one.timeout_ms` from the ask's start (it
|
|
1537
1538
|
starts right after the turn does, so plugin and MCP start-up hide most of it). A described kit picked with
|
|
1538
1539
|
confidence at or above `toolkit.suggest.threshold` becomes the session's toolkit, saved with `toolkit_origin:
|
|
1539
1540
|
suggested`, and the turn's first request declares it; the notice reads
|
|
@@ -1541,7 +1542,7 @@ toolkit:
|
|
|
1541
1542
|
pick, `undecided`, no answer, a failing System 1, which warns once as `[samagotchi:toolkit]`) keeps the default
|
|
1542
1543
|
kit. No answer in time or none at all says so in a warn notice, `toolkit: full (no suggestion: System 1 took over
|
|
1543
1544
|
5000 ms) · /toolkit to change` (or `System 1 gave no answer`), and a timeout logs a `toolkit_suggest_timeout`
|
|
1544
|
-
warning: a slow System 1 host wants a larger `
|
|
1545
|
+
warning: a slow System 1 host wants a larger `system_one.timeout_ms`. A Stop during the wait (or before it, even with the answer in) ends the turn before its first request, and
|
|
1545
1546
|
nothing switches. After an `undecided`, a later prompt within the defer limits may switch the session; that notice
|
|
1546
1547
|
says `from this turn`, and that request re-reads the whole prompt (system prompt, tools and conversation, which
|
|
1547
1548
|
the defer limits keep small) without `/toolkit`'s cache confirmation. `/toolkit` or `ctx.use_toolkit` marks the
|
|
@@ -1591,7 +1592,7 @@ toolkit:
|
|
|
1591
1592
|
- **A new web chat: 🧰.** The start page's 🧰 (beside ⚙; a bottom sheet on a phone) picks the new session's toolkit.
|
|
1592
1593
|
**Auto**, checked by default, sends nothing: the session is on `toolkit.default` with `toolkit_origin: default`, so
|
|
1593
1594
|
with `toolkit.suggest.mode: on` its first message may switch it to a suggested kit. Auto is listed only when that
|
|
1594
|
-
can happen as far as chi web can tell (on mode, a `
|
|
1595
|
+
can happen as far as chi web can tell (on mode, a `system_one.host`, two described config kits); bundle
|
|
1595
1596
|
and memory kits can add candidates it doesn't see. A kit picked is sent as `POST /api/sessions`'s `toolkit` and
|
|
1596
1597
|
saved as `chosen`: no suggestion, the default kit included (its row says `default`); without Auto, the default's
|
|
1597
1598
|
row is the one checked and sends nothing. The list holds the built-ins and config kits a new session can start
|
|
@@ -1640,6 +1641,21 @@ toolkit:
|
|
|
1640
1641
|
changes what saved sessions on it send after an upgrade: the next request reads the prompt again once, as a
|
|
1641
1642
|
tool's schema change does. So can an edit to `toolkit.kits`: a session's worker reads the kits once when it starts, so a running session keeps its
|
|
1642
1643
|
tools and the edit reaches it at its next worker start (after an idle exit, `chi sessions restart`).
|
|
1644
|
+
- **Why a session is on its kit: `toolkit_origin`.** The session file records `default` (the configured kit),
|
|
1645
|
+
`chosen` (a user-selected kit, `/toolkit` and `ctx.use_toolkit` included), `inherited` (a continue or a fork) or
|
|
1646
|
+
`suggested` (`toolkit.suggest.mode: on` switched it). A file from before the field reads as `chosen`, so an old
|
|
1647
|
+
default session is never newly suggested a kit; only a `default` session is ever suggested one. A delegate child
|
|
1648
|
+
gets the kit its `delegate` call names (`toolkit:`, any kit the parent knows), else `toolkit.child`; a kit its worker
|
|
1649
|
+
can't resolve declares every tool.
|
|
1650
|
+
- **Loads saved in the conversation.** A `find_tools` result on a chat host saves the tools its hit loads as
|
|
1651
|
+
`loaded: ["list_sessions", "mcp_fs_read"]` on its `tool_response` entry (wire names: a built-in's, an MCP tool's
|
|
1652
|
+
`mcp_<server>_<tool>`), whatever the host: a later turn on `api: anthropic` (after a `/model` switch, a resume) sends
|
|
1653
|
+
the load as references, any other host ignores it. A plugin's `ctx.load_tools` leaves a system entry
|
|
1654
|
+
`{"role": "system", "kind": "tool_change", "content": "[tools loaded: delegate (bundle)]", "tool_change": {"add":
|
|
1655
|
+
["delegate"], "by": "bundle"}}` after the turn's user message, only on a host that takes tool changes
|
|
1656
|
+
(`api: anthropic`; [wire details](internals/anthropic.md#tool-changes-ctxload_tools)). It is never the system head,
|
|
1657
|
+
counts as conversation, is hidden in the web and TUI histories and goes to no other wire. Either way the names stay
|
|
1658
|
+
deferred for the session's life, across respawns, `--resume` and forks.
|
|
1643
1659
|
|
|
1644
1660
|
## LLM context: the forget layer
|
|
1645
1661
|
|
|
@@ -1762,7 +1778,7 @@ its own). The first target is a long session such as a day-long coordinator, whe
|
|
|
1762
1778
|
|
|
1763
1779
|
`context.candidates.mode: audit` asks System 1 whether a session's work is about each candidate a bundle proposed
|
|
1764
1780
|
(see [The judge](context.md#the-judge)); `on` also acts on the answer: attaches at `threshold`, a card from
|
|
1765
|
-
`offer_threshold` ([On mode](context.md#on-mode)). It uses `
|
|
1781
|
+
`offer_threshold` ([On mode](context.md#on-mode)). It uses `system_one.host` and `.model`, but its own mode: System
|
|
1766
1782
|
1's `mode` (approvals) can stay `off`. Jev, the hosted judgment model, is served through OpenRouter
|
|
1767
1783
|
(`/api/v1/systemone`), so OpenRouter can be the System 1 host:
|
|
1768
1784
|
|
|
@@ -1772,10 +1788,9 @@ hosts:
|
|
|
1772
1788
|
url: https://openrouter.ai/api/v1
|
|
1773
1789
|
api: openai
|
|
1774
1790
|
api_key_env: OPENROUTER_API_KEY
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
model: typesafe/jev-1.14
|
|
1791
|
+
system_one:
|
|
1792
|
+
host: openrouter
|
|
1793
|
+
model: typesafe/jev-1.14
|
|
1779
1794
|
context:
|
|
1780
1795
|
candidates:
|
|
1781
1796
|
mode: audit
|
|
@@ -1785,6 +1800,27 @@ A remote host is asked beside the turn. A System 1 host that is the model's own
|
|
|
1785
1800
|
turn's last request, and the turn-end warm-up follows them, re-warming the prefix they evicted (see
|
|
1786
1801
|
[prompt caching](internals/prompt-caching.md)).
|
|
1787
1802
|
|
|
1803
|
+
|
|
1804
|
+
## Bundles' judgments
|
|
1805
|
+
|
|
1806
|
+
A bundle can declare its own cheap System 1 questions ([Judgments](plugins.md#judgments-chijudgment-and-ctxjudge)). Each is off until
|
|
1807
|
+
you set its mode under its full name, `<bundle>.<name>`, in `system_one.judgments`:
|
|
1808
|
+
|
|
1809
|
+
```yaml
|
|
1810
|
+
system_one:
|
|
1811
|
+
host: splash
|
|
1812
|
+
judgments:
|
|
1813
|
+
check-in.progress:
|
|
1814
|
+
mode: audit # off (the default) | audit | on
|
|
1815
|
+
host: openrouter # optional, this judgment alone; else system_one.host
|
|
1816
|
+
model: typesafe/jev-1.14 # optional, this judgment alone; else system_one.model, or on a host of its own that host's first model
|
|
1817
|
+
```
|
|
1818
|
+
|
|
1819
|
+
A name no loaded bundle declares gets a notice at the session's first turn. Core's judgments (`approvals.*`, `context.*`, `guardrails.*`,
|
|
1820
|
+
`toolkit.*`) take no settings here and warn if named: `SystemOne::Record` and `Similar` don't look at the model, so a
|
|
1821
|
+
model swapped for approvals alone would inherit a clean record and could auto-allow.
|
|
1822
|
+
A bundle's judgments on a hosted host aren't under any cost cap: they stay out of the session's spend, as all of System 1's do.
|
|
1823
|
+
|
|
1788
1824
|
## Opening files from chi web
|
|
1789
1825
|
|
|
1790
1826
|
In `chi web`, a file tool's row (read, edit, write) and the lines of its diff are refs to that file. They look like
|
|
@@ -2190,12 +2226,13 @@ column says so, a `--kebab-name` flag. The maps (`hosts:`, `models:`,
|
|
|
2190
2226
|
| `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). |
|
|
2191
2227
|
| `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). |
|
|
2192
2228
|
| `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-experimental)). An unknown value warns and is `off`. Experimental. config.yml only. See [Guardrails](guardrails.md#system-1-experimental). |
|
|
2193
|
-
| `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. |
|
|
2194
|
-
| `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. |
|
|
2195
2229
|
| `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. |
|
|
2196
2230
|
| `guardrails.system_one.threshold` | `0.8` | | The probability an auto-allow needs. config.yml only. |
|
|
2197
2231
|
| `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. |
|
|
2198
|
-
| `
|
|
2232
|
+
| `system_one.host` | none | | The `hosts:` entry serving `/v1/systemone` (a local Splash, or the hosted Jev API); unset: no judgment, by any use of System 1, so nothing auto-allows and every ask goes to the user. Moved from `guardrails.system_one.host`: while any of host, model and timeout_ms is still under `guardrails.system_one`, chi warns and nothing is judged. config.yml only. |
|
|
2233
|
+
| `system_one.model` | none | | The judgment model; unset uses the host's first model, and a host that names none has no judgment. Moved from `guardrails.system_one.model`. config.yml only. |
|
|
2234
|
+
| `system_one.timeout_ms` | `2000` | | Milliseconds one judgment may take; a slower one is no judgment and the ask goes to the user. Moved from `guardrails.system_one.timeout_ms`. config.yml only. |
|
|
2235
|
+
| `system_one.runs` | `on` | | Save each System 1 ask and what came of it, under the state dir, as long as sessions last (see [System 1's saved runs](system-one.md)); `off` stops saving. config.yml only. |
|
|
2199
2236
|
| `log.file` | state dir | yes | See "Debug Log File". |
|
|
2200
2237
|
| `log.disable` | `false` | yes | No file logging. |
|
|
2201
2238
|
| `log.level` | `info` | yes | `debug`, `info`, `warn`, `error`. |
|
|
@@ -2218,13 +2255,13 @@ column says so, a `--kebab-name` flag. The maps (`hosts:`, `models:`,
|
|
|
2218
2255
|
| `context.every_seconds` | `300` | | How often an attached context source's command runs when it has no `--every` (seconds, at least 30). See [Attached context](context.md). |
|
|
2219
2256
|
| `context.wake` | `true` | yes | A source whose update says `wake: true` may start a turn in a live, idle session (one per source per 10 minutes, a peer source up to `context.peer_turns` in a row, within `session.max_wakes`); so may a session's pushed source's first push. `false`: updates wait as notes for the next turn. See [Waking](context.md#waking). |
|
|
2220
2257
|
| `context.peer_turns` | `3` | yes | Wake turns in a row a peer agent's answers may start (a peer bundle's outbox source, such as `codex-outbox`), with no message typed in the web or a TUI in between (`chi send` doesn't count); within `session.max_wakes`. Then answers wait as notes, and the UIs hear it once. An answer whose summary ends its verdicts with `VERDICT OK` wakes once to close the round, counted toward `session.max_wakes` only. `0`: a peer source wakes like any other. See [Waking](context.md#waking). |
|
|
2221
|
-
| `context.candidates.mode` | `off` | | The candidate judge: `off` (the default: no conversation text goes to a System 1 host), `audit` (asks System 1 whether the session's work is about each proposed candidate and logs it in `context_candidates.jsonl`; attaches nothing), `on` (the same asks, acted on: attached at `threshold`, a card from `offer_threshold`; see [On mode](context.md#on-mode)). Its host and model are `
|
|
2258
|
+
| `context.candidates.mode` | `off` | | The candidate judge: `off` (the default: no conversation text goes to a System 1 host), `audit` (asks System 1 whether the session's work is about each proposed candidate and logs it in `context_candidates.jsonl`; attaches nothing), `on` (the same asks, acted on: attached at `threshold`, a card from `offer_threshold`; see [On mode](context.md#on-mode)). Its host and model are `system_one.host` / `.model`. config.yml only. See [The judge](context.md#the-judge). |
|
|
2222
2259
|
| `context.candidates.threshold` | `0.8` | | The probability at which the judge attaches a candidate in on mode (a judged line's `would: attach`). config.yml only. |
|
|
2223
2260
|
| `context.candidates.offer_threshold` | `0.6` | | The probability from which on mode shows the candidate's card, once (`would: offer`, below `threshold`). config.yml only. |
|
|
2224
2261
|
| `context.candidates.reask_after_turns` | `3` | | Owner prompts after a judged ask before the judge's one re-ask of that candidate. config.yml only. |
|
|
2225
2262
|
| `toolkit.default` | `full` | | The toolkit a new session is stored on: the tools its prompts declare (`full`, `lean`, `child`, one of `toolkit.kits` or a bundle's). `chi --toolkit` picks one per session. See "Toolkits". |
|
|
2226
2263
|
| `toolkit.child` | `full` | | A delegate child's toolkit, unless its `delegate` call names one (`toolkit:`). See "Toolkits". |
|
|
2227
|
-
| `toolkit.suggest.mode` | `off` | | `audit` asks System 1 for a toolkit suggestion without changing the session; `on` waits for it (up to `
|
|
2264
|
+
| `toolkit.suggest.mode` | `off` | | `audit` asks System 1 for a toolkit suggestion without changing the session; `on` waits for it (up to `system_one.timeout_ms`) on an eligible turn and starts the session on a confidently suggested kit (Toolkits, On mode). The System 1 host, model and timeout come from `system_one.*`. config.yml only. |
|
|
2228
2265
|
| `toolkit.suggest.threshold` | `0.5` | | Minimum confidence for a non-default toolkit suggestion. config.yml only. |
|
|
2229
2266
|
| `toolkit.suggest.defer_turns` | `3` | | Maximum owner-prompt number for another ask after `undecided` (the owner's own prompts: not steers, notes, reminders or `!` command output). config.yml only. |
|
|
2230
2267
|
| `toolkit.suggest.defer_tokens` | `5000` | | Maximum estimated conversation size for another ask after `undecided` (characters / 4). config.yml only. |
|
|
@@ -2235,7 +2272,9 @@ column says so, a `--kebab-name` flag. The maps (`hosts:`, `models:`,
|
|
|
2235
2272
|
| `hosts.<name>.models.<id>.served` | none | | The models the host may answer that id with (exact ids, any case), or `any`: no served-model warning for those. See "Models a host serves but doesn't list". |
|
|
2236
2273
|
| `hosts.<name>.models.<id>.price` | none | | The model's price on that host, USD per 1M tokens: `input`, `output`, optional `cache_read` and `cache_write`. Estimates the cost a provider doesn't report. See "Prices". |
|
|
2237
2274
|
| `hosts.<name>.remote` | by address | | `true`/`false`: treat the host as a remote provider or a local server. See "Remote or local". |
|
|
2238
|
-
| `
|
|
2275
|
+
| `memory.entry_warn_tokens` | `5000` | yes | The tokens one memory may hold (every read of it pays them; about 20K characters at the default `context.chars_per_token`) before a `memory_write`, `write` or `edit` that takes it over gets a note, once per crossing, asking the model to tighten it; `0`: no note. A memory that did not exist counts as 0 tokens. Not for a description-only write, a remove, `index`, a model overlay or a `model_notes_*` memory. Independent of `memory.index_warn_tokens`. See [Memory](memory.md). |
|
|
2276
|
+
| `memory.read_max_chars` | `0` | yes | The most characters one `memory_read` result holds. `0`: no fixed cap, a memory is read whole (`max_tool_output_chars` does not apply to `memory_read`, in any form: one name, a comma list, a blank name). A positive value cuts a longer result and ends it with `[cut: N of M chars; the rest is in the memory's .md file in <dir>: read it with start_line]`. `memory:` is its own section. See [Memory](memory.md). |
|
|
2277
|
+
| `max_tool_output_chars` | `10000` | yes | Characters of each tool output kept in the conversation (both loops). A longer one is cut: an `execute` result keeps its first and last lines with a gap line between them and is saved whole in the session's folder (`sessions/<id>/tool_output/`; the gap line names the file, and the model reads it there instead of running the command again); any other tool's keeps its head and ends with `[cut: N of M chars; read it in parts]`. `memory_read` is the exception: `memory.read_max_chars` caps it. See [Tool output guardrails](internals/tool-output-limits.md#the-runners-cut-max_tool_output_chars). A top-level key (see below). |
|
|
2239
2278
|
| `cache.warmup` | `auto` | | `auto`: after a turn, send the next turn's prompt (up to the next message) to a local llama.cpp host on the native loop, so the next turn prefills only its message; never a remote or `api: openai` host. `off`: never. See [prompt caching](internals/prompt-caching.md#the-turn-end-warm-up). |
|
|
2240
2279
|
| `cache.ttl` | `5m` | | How long Anthropic keeps the prompt-cache breakpoints of a Claude model on a remote host: `5m` (Anthropic's default) or `1h`. A 1h cache write costs 2× the input price (5m: 1.25×), a read the same 0.1×. See "Remote or local". |
|
|
2241
2280
|
| `cache.key` | `off` | | `session`: send the session id as `prompt_cache_key` to OpenAI's API and OpenRouter, which route and cache by it; no other host gets it. `off`: none. |
|
data/docs/context.md
CHANGED
|
@@ -432,8 +432,8 @@ about each proposed candidate — "Is this session's work about the
|
|
|
432
432
|
candidate?" — and logs the answer, so you can see how it would do before
|
|
433
433
|
anything depends on it; `audit` attaches nothing. With `on` the answer acts
|
|
434
434
|
([On mode](#on-mode)). The default is `off`: no conversation text goes to a System 1 host unless you
|
|
435
|
-
turn this on, whatever `guardrails.system_one` says. The host and model are
|
|
436
|
-
`
|
|
435
|
+
turn this on, whatever `guardrails.system_one.mode` says. The host and model are
|
|
436
|
+
`system_one.host` / `.model` (Jev through OpenRouter: see
|
|
437
437
|
[The candidate judge](configuration.md#the-candidate-judge)).
|
|
438
438
|
|
|
439
439
|
```yaml
|
data/docs/guardrails.md
CHANGED
|
@@ -283,8 +283,9 @@ Built in, for `write`, `edit` and `memory_write` (symlinks resolved; see
|
|
|
283
283
|
[How paths resolve](#how-paths-resolve)):
|
|
284
284
|
|
|
285
285
|
- deny: the approval store's dir, installed bundles (`memories/.bundles/`),
|
|
286
|
-
|
|
287
|
-
source's command runs later, outside the gate; `chi context` changes them)
|
|
286
|
+
[attached context](context.md) sources (`<state dir>/context/`: a
|
|
287
|
+
source's command runs later, outside the gate; `chi context` changes them)
|
|
288
|
+
and [System 1's saved runs](system-one.md) (`<state dir>/system_one/`);
|
|
288
289
|
- ask (once or for the session): `config.yml`, `home.json` (the [chi home](configuration.md#the-chi-home): the Ruby every chi launch boots), the chi shim (`$XDG_STATE_HOME/samagotchi/bin/chi`, the `chi` every tool runs: `chi-shim`) and the plain hooks dir.
|
|
289
290
|
|
|
290
291
|
`execute` can still reach them; the guardrails bundle asks about shell
|
|
@@ -745,7 +746,7 @@ repo is in count as outside.
|
|
|
745
746
|
|
|
746
747
|
`touches: chi_dirs` (shell tools) matches a command that names a path in chi's
|
|
747
748
|
own folders: the config folder (`config.yml`, `memories/`), the hooks folder,
|
|
748
|
-
the approval store, installed bundles (`memories/.bundles`), the session repo's
|
|
749
|
+
the approval store, attached context, System 1's saved runs, installed bundles (`memories/.bundles`), the session repo's
|
|
749
750
|
git hooks (`core.hooksPath` honoured) or any `.git/hooks` (a submodule's `.git/modules/<name>/hooks` too)
|
|
750
751
|
outside a tmp folder.
|
|
751
752
|
Each word is read as a path against the folder the command is in by then
|
|
@@ -1045,25 +1046,32 @@ reaching them — see [Answering for the user](#answering-for-the-user-on-mode).
|
|
|
1045
1046
|
`Samagotchi::SystemOne` (lib/samagotchi/system_one/) speaks the TypeSafe
|
|
1046
1047
|
"System One" judgment API: `POST <host>/v1/systemone` with
|
|
1047
1048
|
`{model, state, questions}` and answers of type `noul` (a probability),
|
|
1048
|
-
`choice` or `score`.
|
|
1049
|
+
`choice` or `score`. A `score` answer's number is not a level: it is the
|
|
1050
|
+
probability-weighted level index (Σ index × its probability), so a two-level
|
|
1051
|
+
question's score is exactly the probability of its second level. The response
|
|
1052
|
+
also sends `legend` (level index => name) and `probabilities` per index; chi
|
|
1053
|
+
keeps the score as sent and reads the level with the most probability as
|
|
1054
|
+
`answer.level`. A score that isn't a number, or is past the last level, is no
|
|
1055
|
+
answer. The host is a `hosts:` entry with `api: openai` — a
|
|
1049
1056
|
local Splash server, or the hosted Jev API — and its key is that host's own
|
|
1050
1057
|
`api_key_env:`.
|
|
1051
1058
|
|
|
1052
1059
|
```yaml
|
|
1060
|
+
system_one: # what every use of System 1 shares
|
|
1061
|
+
host: splash # the hosts: entry serving /v1/systemone
|
|
1062
|
+
model: Qwen3.8-Flash-Next-GSQ-RCO-GGUF:Q2_0 # else the host's first model
|
|
1063
|
+
timeout_ms: 2000 # a slower judgment is no judgment
|
|
1053
1064
|
guardrails:
|
|
1054
|
-
system_one:
|
|
1065
|
+
system_one: # approvals' own
|
|
1055
1066
|
mode: off # off (default) | audit (judge, log, never decide) | on
|
|
1056
|
-
host: splash # the hosts: entry serving /v1/systemone
|
|
1057
|
-
model: Qwen3.8-Flash-Next-GSQ-RCO-GGUF:Q2_0 # else the host's first model
|
|
1058
1067
|
rules: [git-push, git-rebase] # the rule ids it may answer for
|
|
1059
1068
|
threshold: 0.8 # the probability an auto-allow needs
|
|
1060
1069
|
min_record: 5 # clean records a rule needs before it is trusted
|
|
1061
|
-
timeout_ms: 2000 # a slower judgment is no judgment
|
|
1062
1070
|
```
|
|
1063
1071
|
|
|
1064
|
-
`SystemOne::Client.from_config(host_registry:
|
|
1072
|
+
`SystemOne::Client.from_config(host_registry:, mode:)` reads these on every
|
|
1065
1073
|
call, so a config.yml change takes effect on the next ask. `mode: off` (the
|
|
1066
|
-
default) or no `host` means there is no client at all. Anything else is no
|
|
1074
|
+
default) or no `system_one.host` means there is no client at all. Anything else is no
|
|
1067
1075
|
judgment and one `Log.warn(:guardrails, "system_one_failed", …)`: an
|
|
1068
1076
|
unreachable host, a host that isn't `api: openai`, a status other than 200,
|
|
1069
1077
|
a body that isn't JSON, a question with no answer, a request longer than
|
|
@@ -1118,7 +1126,8 @@ offers no examples. A participant's turn (review or pair) is never judged:
|
|
|
1118
1126
|
there is no user there to ask, and its asks go to the owner. The judgment runs before the card appears, so an ask is
|
|
1119
1127
|
slower by up to `timeout_ms` (2000 ms by default).
|
|
1120
1128
|
|
|
1121
|
-
Each
|
|
1129
|
+
Each ask is also saved whole, with what the user answered, as a [run](system-one.md); the line's `id` is the
|
|
1130
|
+
run's. Each judgment is a line in `judgments.jsonl`, beside `approvals.json` in
|
|
1122
1131
|
`$XDG_STATE_HOME/samagotchi/guardrails/` (`SystemOne::Log`): 0600, appended
|
|
1123
1132
|
under an exclusive `flock` (several workers write it), and rewritten —
|
|
1124
1133
|
keeping the newest half of its lines — once it passes 5 MB. A line is never
|
data/docs/hooks.md
CHANGED
|
@@ -62,7 +62,7 @@ The plugin class must respond to `#call(event)` — duck-typed, no base class re
|
|
|
62
62
|
| `:generation_progress` | While the response streams, in batches (see [Watching the stream](#watching-the-stream)) | `{ type: :generation_progress, iteration: N, thinking: "..." (new since the last fire), text: "..." (new visible text), thinking_chars: N, text_chars: N (this generation so far), elapsed_ms: N, restarted: true (only on the first fire after a dropped stream was asked again) }` |
|
|
63
63
|
| `:before_tool_call` | Before tool dispatch (and before `tool_call_started`) | `{ type: :before_tool_call, iteration: N, call: {...}, params: "...", guardrail: Verdict, context: {...}, targets: {...}, blocked: false, block_reason: nil }` |
|
|
64
64
|
| `:question_asked` | A question of the model's own (`ask_user_question`) or an approval of its tool call is about to open, on the turn's thread (not a hook's or plugin's `ask_user`, a relayed delegate approval, the step-limit question, a `--non-interactive` run or a stored approval: none of those fire it) | `{ type: :question_asked, kind: "question" \| "approval", text: "..." (the reply that asks, as the asking slot shows it: no thoughts, no tool-call markup), question: {...} (the question's fields, frozen: question/options/header…, an approval's approval: facts too; no id yet), refs: (see [Refs on a question](#refs-on-a-question-display-only)) }` |
|
|
65
|
-
| `:after_tool_call` | After tool execution | `{ type: :after_tool_call, iteration: N, tool: "read", output: "..." (the text the model gets: capped at max_tool_output_chars; a cut one has a [cut: …] line, at its end, or for an execute result between its head and its tail, the whole output saved in the session's tool_output/ folder), status: "ok" \| "error" \| "blocked" \| "stopped", call: {...} (the call that ran, as the gate left it), elapsed_ms: N (the tool's own run, without an approval wait; nil for a denied call), result: "..." (the tool's own text: no "[read]" lead, no "ran as:" line, never cut) }`; `stopped` is a `task_wait` the turn's Stop ended (the task runs on) or whose task was stopped. Match on `result:`, not `output:`: it is what the tool said (`Error: command timed out after 120s` for an execute killed at its limit in kill mode, `Samagotchi::Tools::Execute::TIMEOUT_PREFIX`; `Still running at the 120s limit: …` for one handed over as a task, `Samagotchi::Tools::Execute::HANDED_OVER`) |
|
|
65
|
+
| `:after_tool_call` | After tool execution | `{ type: :after_tool_call, iteration: N, tool: "read", output: "..." (the text the model gets: capped at max_tool_output_chars (a memory_read's cap is memory.read_max_chars, 0: none); a cut one has a [cut: …] line, at its end, or for an execute result between its head and its tail, the whole output saved in the session's tool_output/ folder), status: "ok" \| "error" \| "blocked" \| "stopped", call: {...} (the call that ran, as the gate left it), elapsed_ms: N (the tool's own run, without an approval wait; nil for a denied call), result: "..." (the tool's own text: no "[read]" lead, no "ran as:" line, never cut) }`; `stopped` is a `task_wait` the turn's Stop ended (the task runs on) or whose task was stopped. Match on `result:`, not `output:`: it is what the tool said (`Error: command timed out after 120s` for an execute killed at its limit in kill mode, `Samagotchi::Tools::Execute::TIMEOUT_PREFIX`; `Still running at the 120s limit: …` for one handed over as a task, `Samagotchi::Tools::Execute::HANDED_OVER`) |
|
|
66
66
|
| `:session_end` | After each turn `:after_turn` fires for, after it (turn-level lifecycle) | `{ type: :session_end, session_id: "..." }` |
|
|
67
67
|
|
|
68
68
|
Every event also carries the hook runtime (next section): `hook:` (the label
|
|
@@ -76,12 +76,14 @@ cheap).
|
|
|
76
76
|
|
|
77
77
|
## Participants' turns
|
|
78
78
|
|
|
79
|
-
A [participant's](internals/participants.md#turns) review turn fires the same hooks, and
|
|
80
|
-
`author: "p:<id>"` (the owner's carry no `author:` key, as before); a plugin reads the
|
|
81
|
-
(nil for the owner's turns and between turns).
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
79
|
+
A [participant's](internals/participants.md#turns) turn — a review turn or a pair turn — fires the same hooks, and
|
|
80
|
+
every event of it carries `author: "p:<id>"` (the owner's carry no `author:` key, as before); a plugin reads the
|
|
81
|
+
same as `ctx.turn_author` (nil for the owner's turns and between turns). The mode does not change that: `author:`
|
|
82
|
+
comes from who wrote the turn, not from `ParticipantMode`. **A hook runs with the owner's power whatever the
|
|
83
|
+
author**: it may read, write and run what the owner's chi may, and `notify`, `ask_user` and `steer` reach the
|
|
84
|
+
owner. A bundle that acts on a turn's text (a prompt rewrite, a fetch of what it names) should check `author:`
|
|
85
|
+
first: the text may be a participant's. The participant's boundary (the review or pair one) checks a tool call
|
|
86
|
+
after the hooks, on the call they leave.
|
|
85
87
|
|
|
86
88
|
## What a hook can do: the runtime
|
|
87
89
|
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# Attached context: the mechanism
|
|
2
|
+
|
|
3
|
+
For an agent changing the layer. What a user sees and configures (the commands, the note wording, the wake rules,
|
|
4
|
+
the judge's thresholds, the log and the report) is in [../context.md](../context.md); this page is how the code
|
|
5
|
+
gets a source's text into the prompt, and what each class owns. Not to be confused with `ContextStatus` /
|
|
6
|
+
`ContextUsage` / `ContextWindow` ([context-telemetry.md](context-telemetry.md): token counts) or the LLM context
|
|
7
|
+
strategies ([llm-context-forget.md](llm-context-forget.md)).
|
|
8
|
+
|
|
9
|
+
## From a source to the prompt
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
source (command | push | URL provider) ContextSources: <name>.json + <name>.snapshot.json
|
|
13
|
+
│ ContextPoller (worker thread) runs a command source via ContextFetch;
|
|
14
|
+
│ chi context push / refresh write the snapshot from another process
|
|
15
|
+
▼
|
|
16
|
+
snapshot (text, revision, serial, summary, wake_serial, hint)
|
|
17
|
+
│ Worker#pass, between turns: WorkerInbound#absorb_context
|
|
18
|
+
▼
|
|
19
|
+
ContextAbsorber#pending → Batch of Deliveries (one note per source that is new / changed / failing / gone)
|
|
20
|
+
│ Engine#add_context_note: a tail system message (kind "note", ContextNote), announced as :context_added
|
|
21
|
+
│ session saved, then ContextAbsorber#commit writes subscriptions.json
|
|
22
|
+
▼
|
|
23
|
+
the next request: the note sits after the history; the model reads the text with the context_read tool
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Three rules hold the shape:
|
|
27
|
+
|
|
28
|
+
- **Never the stable prefix.** A note is a tail system message, appended between turns (never mid-turn, never
|
|
29
|
+
before a session's first turn: `conversation_started?`, or `before_turn:` when a prompt is about to run). The
|
|
30
|
+
system prompt carries only the standing line that explains `[CONTEXT NOTE …]`. History stays append-only
|
|
31
|
+
([prompt-caching.md](prompt-caching.md)). Only the pointer (name, why, hint, summary) is in the note; the text is
|
|
32
|
+
read on demand by `Tools::ContextRead`, which reads snapshots only and never runs a command.
|
|
33
|
+
- **Deliver at least once, record after save.** `absorb_context` adds the notes, saves the session, and only then
|
|
34
|
+
`commit`s the subscriptions. A crash in between re-delivers, and `Engine#add_context_note` skips a `note_id` the
|
|
35
|
+
conversation already holds. Ids are deterministic: `ctx-<name>-<serial>-<revision[0,12]>` (the serial makes A, B, A
|
|
36
|
+
three notes), `ctx-<name>-error-<error_since digits>`, `ctx-<name>-detached-<seen[0,12]>`.
|
|
37
|
+
- **Polling is not activity.** The poller and the absorb never hold a worker up (`WorkerIdleExit`).
|
|
38
|
+
|
|
39
|
+
## ContextSources
|
|
40
|
+
|
|
41
|
+
The store (`context_sources.rb`), one root `<state dir>/context/`, apart from the session folders; its header
|
|
42
|
+
comment is the file map and [../context.md](../context.md#storage) lists it for users.
|
|
43
|
+
|
|
44
|
+
- **Records** (`Data`/value objects): `Source` (definition; `cmd` nil = pushed; `movable` nil derives from
|
|
45
|
+
`push?`), `Snapshot` (last good text, `revision` = SHA-256 of the text, `serial` counting revisions, `error` and
|
|
46
|
+
`error_since`, `wake_serial` = the newest revision that asked to wake, `fetched_at` last success, `checked_at` last
|
|
47
|
+
try), `Subscription` (what one session has had: `seen`/`seen_serial`, `read`, `error_seen`, `wakes_at`),
|
|
48
|
+
`Candidate`, `Left`, `Moved`/`Stayed`.
|
|
49
|
+
- **Locations.** A `Location` is a folder of sources: a project's (`projects/<project_key>/`, shared by the
|
|
50
|
+
project's workers) or a session's (`sessions/<id>/`, which also holds `subscriptions.json`, the `muted/` and
|
|
51
|
+
`declined/` markers, `candidates/`, `left.json`). `ContextSources.attached(session_id, project_root:)` is what a
|
|
52
|
+
session sees: its own, then the project's it doesn't shadow. Names are `NAME_RE`; `subscriptions` and `left` are
|
|
53
|
+
reserved (`RESERVED_NAMES`) because their files share the folder.
|
|
54
|
+
- **Writes are file replaces** (`AtomicFile`). `Location#locked(name)` takes `<name>.lock`, the lock a fetch holds
|
|
55
|
+
for its whole command; `record_text` and `record_error` run under it, and `remove` leaves the lock file so a fetch
|
|
56
|
+
still running finds its source gone (`ContextFetch` then writes nothing). Removing a *session* source leaves a
|
|
57
|
+
`declined/<name>` marker (hint inside), which is what stops an auto-attach from bringing it back.
|
|
58
|
+
- **The output contract** is `ContextSources.parse_output` (JSON object with a string `text` is
|
|
59
|
+
`{text, summary, wake, hint}`; anything else is all text; empty or over `TEXT_MAX_BYTES` is `Invalid`), the same for
|
|
60
|
+
a command's stdout and a push. `ContextProviders` resolves a URL to a command source from an installed bundle's
|
|
61
|
+
manifest `context_providers:` (read from its provenance, so the CLI and chi web resolve without loading plugins);
|
|
62
|
+
`{url}` is filled at attach, `{bundle_dir}` and `{ruby}` when the command runs.
|
|
63
|
+
- **Protected.** The root is a `ProtectedPaths` entry: the file tools cannot write it, because a source's command
|
|
64
|
+
runs later outside the guardrails gate with the worker's environment ([../context.md](../context.md#safety)).
|
|
65
|
+
|
|
66
|
+
## ContextPoller and ContextFetch
|
|
67
|
+
|
|
68
|
+
`ContextPoller` is one thread in a worker (`Worker` starts it, `on_change: -> { @waker.wake }`). Every
|
|
69
|
+
`TICK_SECONDS` (5) it walks `ContextSources.attached`, skips pushed and muted sources, and runs each command source
|
|
70
|
+
whose last try (`checked_at`, so a failing source isn't re-run every tick) is older than its interval (`--every`,
|
|
71
|
+
else `context.every_seconds`, floored at `MIN_EVERY_SECONDS` 30). Its first pass uses the floor as the interval, so a
|
|
72
|
+
woken worker's first absorb says what changed while it was away. It calls `on_change` only for `:new` and `:error`.
|
|
73
|
+
|
|
74
|
+
`ContextFetch.fetch` runs one source: `sh -c` in its own process group (a timeout, the output cap or the caller's
|
|
75
|
+
cancel kills the group), cwd the session's folder (a project source: the project root, `project_cwd`), stdin closed,
|
|
76
|
+
`SAMAGOTCHI_CONTEXT_NAME` and `SAMAGOTCHI_CONTEXT_PREVIOUS` in the env. A non-blocking `flock` on `<name>.lock` is
|
|
77
|
+
the arbiter between workers and the CLI: the loser gets `:busy`, and `fresh_within` is checked again under the lock,
|
|
78
|
+
so a project source shared by several workers is fetched once per interval. The `Outcome` status says what happened
|
|
79
|
+
(`:new :same :error :busy :fresh :cancelled :gone`); a cancelled fetch records nothing (a leaving worker isn't the
|
|
80
|
+
source failing). `chi context refresh` and the candidates' first fetch use the same entry.
|
|
81
|
+
|
|
82
|
+
A pushed source has no poller. `chi context push` writes the snapshot from its own process; the worker finds it
|
|
83
|
+
through `ContextAbsorber#store_signature` (names and mtimes of the files in the two folders, `subscriptions.json`
|
|
84
|
+
left out so the worker's own write is not a change), compared on each pass.
|
|
85
|
+
|
|
86
|
+
## ContextAbsorber
|
|
87
|
+
|
|
88
|
+
Pure decision, no I/O of its own beyond the store. `pending` returns `nil` when the signature hasn't moved since the
|
|
89
|
+
last `commit`, else a `Batch` of `Delivery` values (`name`, `note`, `subscription`, `wake_note`, `peer`, `agreed`):
|
|
90
|
+
|
|
91
|
+
| State of source vs the session's `Subscription` | Delivery |
|
|
92
|
+
|---|---|
|
|
93
|
+
| has text, `seen` nil | *Attached* note |
|
|
94
|
+
| `seen != revision` | *Updated* note, `changes = serial - seen_serial` |
|
|
95
|
+
| error newer than `error_seen` | *Couldn't refresh*, once per run of failures |
|
|
96
|
+
| subscription with no source left | *Detached* if the session was told of it, else dropped quietly |
|
|
97
|
+
| no text yet | nothing |
|
|
98
|
+
|
|
99
|
+
A muted source is skipped. Notes name the source as third-party text; the exact wording lives in the class constants
|
|
100
|
+
(`WAKE_LAST_LINE`, `PEER_WAKE_LINE`, `PEER_AGREED_LINE` were tuned in spikes: change one only with an eval).
|
|
101
|
+
|
|
102
|
+
**Wake notes.** A delivery also carries a `wake_note` (the same note with the wake last line) when its source asked
|
|
103
|
+
(`snapshot.wake_serial > sub.seen_serial`, so an earlier asking revision still counts) or, for a first text, only a
|
|
104
|
+
session's own pushed source (`first_text_wakes?`). Whether it *runs* is the worker's, not the absorber's:
|
|
105
|
+
`WorkerInbound#context_wake_for` applies `context.wake`, `WorkerWakes#context_open?` (no continue offer, not paused,
|
|
106
|
+
under `session.max_wakes`, past the start grace), and the per-source `CONTEXT_WAKE_WINDOW` (600 s, from
|
|
107
|
+
`Subscription#wakes_at`); `Worker#pass` calls it only with nothing queued and no exit requested. A failed or
|
|
108
|
+
stopped wake turn is rolled back (`unmark_wake`) to a plain note.
|
|
109
|
+
|
|
110
|
+
**Peers.** `PeerSources` (the peer bundles' in-process declaration, `chi.peer_source`) marks a pushed session source
|
|
111
|
+
as a peer's answers; `peer_label` then needs `context.peer_turns` > 0. A peer delivery has no 10-minute window; its
|
|
112
|
+
budget is `WorkerWakes#peer_open?` (reset only by typed input), and an answer whose summary ends in `VERDICT OK`
|
|
113
|
+
(`peer_agrees?`) wakes once with the closing line, counted toward `session.max_wakes` only.
|
|
114
|
+
|
|
115
|
+
## Candidates
|
|
116
|
+
|
|
117
|
+
`ContextCandidates` holds a session's proposed sources (`Candidate`, from `ctx.context.propose`, stored under
|
|
118
|
+
`candidates/<name>.json` and written once). **Status is derived, never stored**: *attached* while a source of its
|
|
119
|
+
name (or whose hint is its URL) is attached, *declined* while the declined marker names it, else *proposed*.
|
|
120
|
+
|
|
121
|
+
- **Mention.** `mention_in` / `pattern_for` implement the user-facing rules (whole tokens, `#N` ≥ 10 or after
|
|
122
|
+
"PR", branch names ≥ 6 chars with `/ - _`, exact URLs, fenced code blanked). Only `OwnerPrompt` texts count
|
|
123
|
+
(`owner_texts`), so a participant's message, a steer, a note or a reminder never attaches anything; a review
|
|
124
|
+
session skips all of it.
|
|
125
|
+
- **Where it runs.** `before_turn` (from `Worker#run_prompt` before the absorb, and the REPL's turn start): attach
|
|
126
|
+
each proposed candidate the owner's prompts so far mention (`attach` writes a session `Source` through
|
|
127
|
+
`source_for`: a URL resolves through `ContextProviders`, else the candidate's own `cmd`), then `first_fetch` them
|
|
128
|
+
together within `FIRST_FETCH_SECONDS` (10) so the note lands in this turn; cut short, the fetch records nothing and
|
|
129
|
+
the poller tries soon. `after_propose` does the same for a late proposal. The REPL has no absorber running, so
|
|
130
|
+
`deliver_notes` runs one.
|
|
131
|
+
- Forks and continues take no candidates.
|
|
132
|
+
|
|
133
|
+
## ContextJudge and ContextCandidateChoice
|
|
134
|
+
|
|
135
|
+
`ContextJudge` is System 1's "is this session's work about the candidate?" ([../guardrails.md](../guardrails.md#system-1-experimental)).
|
|
136
|
+
It builds a small neutral state per ask (`state(job)`: the candidate's title and describe, `conversation` = the first
|
|
137
|
+
substantive owner prompt and the latest, each bounded at `PROMPT_MAX`, and for a delegate child the parent's attached
|
|
138
|
+
titles), two constant `noul` questions (`about`, `task`: constant so the host's prefix cache holds and nothing a
|
|
139
|
+
plugin wrote is read as instructions), and `ask` writes a `judged` line. Never the branch, the checkout or the
|
|
140
|
+
source's why. `task_p < TASK_MIN` (0.5) leaves the candidate unjudged; `due?` decides asks from the log's `judged`
|
|
141
|
+
lines (never twice at one owner-prompt count; one re-ask after `reask_after_turns`; then done). No answer writes
|
|
142
|
+
nothing, so the next owner prompt asks again.
|
|
143
|
+
|
|
144
|
+
The driver is the Engine, not the judge: `Engine#start_context_judge` (an owner turn's start; skipped for participants,
|
|
145
|
+
review sessions and a Continue) and `judge_late_candidates` (a proposal after the prompts) call `context_judge_pass`.
|
|
146
|
+
It first logs status changes (`ContextCandidatesLog.look`), returns at `mode: off`, builds the due `Job`s, and hands
|
|
147
|
+
them to `SideJudgments` (`:context` kind: one thread, one batch per session at a time). On the model's own local
|
|
148
|
+
server (`system_one_shares_local_host?`) the pass is held in `@context_judge_after_turn` and started after the turn
|
|
149
|
+
(before the prompt warm-up, which waits for it); any other host is asked at once.
|
|
150
|
+
|
|
151
|
+
`ContextCandidateChoice.after_judged` acts on `fields["would"]` in `on` mode only: *attach* → `judge_attach` (a
|
|
152
|
+
session source with `JUDGE_WHY`, no probability in the model-visible why), *offer* → one card (`offer_card`, buttons
|
|
153
|
+
`/context attach NAME` and `/context decline NAME`, guarded by a `carded` log line). It re-checks that the candidate
|
|
154
|
+
is still proposed, since the user may have chosen while it was asked. Cards are shown through the Engine's
|
|
155
|
+
`context_choice` lambda, which holds them while a turn runs (`@context_cards_after_turn`). `choose` is the
|
|
156
|
+
`/context attach|decline` command's body (`SessionCommands`), shared by the card's buttons; it refuses in a review
|
|
157
|
+
session. An attach only writes the session folder: its note comes with the next between-turn delivery, never into a
|
|
158
|
+
running turn.
|
|
159
|
+
|
|
160
|
+
`ContextCandidatesLog` is `context_candidates.jsonl` beside the sessions folder (a `SideJudgments.log`: 0600, one
|
|
161
|
+
line under a lock, the oldest half dropped past 5 MB; written in every mode). `statuses` replays the lines;
|
|
162
|
+
`look` appends `by: "user"` lines for changes it sees (the user's own `chi context add`, a chip's ×); the kinds and the
|
|
163
|
+
report are in [../context.md](../context.md#the-log-and-the-report). `ContextReportCommand` is its only reader besides
|
|
164
|
+
`due?` and `carded?`.
|
|
165
|
+
|
|
166
|
+
## ContextMove and left.json
|
|
167
|
+
|
|
168
|
+
Movable context is the only code path that copies sources between sessions. `ContextMove.call` runs from
|
|
169
|
+
`SessionManager.spawn_session`'s `before_spawn` (a continue: `SessionContinue`; a plugin fork:
|
|
170
|
+
`Plugin::Sessions#fork` with `seed:` = the fork's messages), after the new session and its first note are saved and
|
|
171
|
+
**before its worker starts**, so the first absorb sees the sources. It calls `ContextSources.move`, then writes a
|
|
172
|
+
second note into the new session (`NOTE_SOURCE` "attached context", named by time so it sorts after the first). Best
|
|
173
|
+
effort: a failure is logged (`context_move_failed`) and the session starts without.
|
|
174
|
+
|
|
175
|
+
`ContextSources.move(from:, to:, mode:, seed:)` copies, by plain read (never `Location#locked`: a fetch holds it for
|
|
176
|
+
its whole command), each source for which `Source#movable?` is true: the source file, its snapshot, and a
|
|
177
|
+
`Subscription`; plus every `declined/` and `muted/` marker (the user's choices). Never `candidates/`, lock files, or a
|
|
178
|
+
source the new session already has. The subscription's content is what makes the first note right: a continue's says
|
|
179
|
+
nothing was seen (so the *Attached* note comes) at the snapshot's serial (so an old wake doesn't wake); a fork's is
|
|
180
|
+
what its seed last had, found from the newest note about the source (`seed_note`, via the `note_id` shape above), so
|
|
181
|
+
a source that changed since gets an *Updated* note.
|
|
182
|
+
|
|
183
|
+
`Moved` carries `moved` names and `stayed` (`Stayed`: name, `why` `:pushed` or `:not_movable`). Its `note_text` is the
|
|
184
|
+
second note; `words` is the few words `chi send --new --continues` prints; `to_h` / `from_h` are chi web's
|
|
185
|
+
`moved_context` answer (an older chi web sends none: `from_h(nil)` is nil).
|
|
186
|
+
|
|
187
|
+
**left.json.** For a *continue* the stayed sources are also written to the new link's `sessions/<id>/left.json`
|
|
188
|
+
(`Left`: `name`, `hint`, `from`; the hint is the snapshot's, since a pushed source's hint is where a peer's re-pair
|
|
189
|
+
pushes). `ctx.context.left_behind` reads it (`Plugin::AttachedContext#left_behind`) and `ctx.continues` names the
|
|
190
|
+
previous link. `Plugin::Peer#pair_again` uses both: from its quiet init task it posts one `moved` message to the
|
|
191
|
+
inbox left behind and records a marker under the bundle's `sessions/<id>/`, so it runs once. `Left#peer` /
|
|
192
|
+
`PEER_NAME` exist only to word the second note (a peer's inbox and outbox are one group: "chi asks Claude to pair
|
|
193
|
+
again" vs "Codex pairs again when it next answers"); `PeerSources` decides what a peer is. A fork writes no
|
|
194
|
+
`left.json`.
|
|
195
|
+
|
|
196
|
+
The refusal side: a push into a session that has been continued is refused with the next link's id
|
|
197
|
+
(`context_continued`, chi web 410, `chi context push` exit 1), from `SessionAccess` (`session_access.rb`) and chi web's push route (`web/session_access_routes.rb`).
|
|
198
|
+
|
|
199
|
+
## Gates and config
|
|
200
|
+
|
|
201
|
+
| Where | Gate |
|
|
202
|
+
|---|---|
|
|
203
|
+
| Absorb at all | the conversation has started (`conversation_started?`), or `before_turn: true`; the session isn't stopped on disk |
|
|
204
|
+
| A wake turn | `context.wake`; `WorkerWakes#context_open?`; `CONTEXT_WAKE_WINDOW`; nothing queued and no exit requested; peer: `context.peer_turns`, `peer_open?` |
|
|
205
|
+
| Mention attach | `OwnerPrompt.turn?` (owner author, not a review session) |
|
|
206
|
+
| Judge | `context.candidates.mode` `audit`/`on` (default `off`); a System 1 client configured; not a participant's turn, review session, scratch session or Continue |
|
|
207
|
+
| Command source defined by the agent | `chi-context-cmd` guardrails rule asks, always; a parent agent can't approve it for a child |
|
|
208
|
+
| Writes to the store | `ProtectedPaths` denies the file tools |
|
|
209
|
+
|
|
210
|
+
Keys, all in [../configuration.md](../configuration.md): `context.every_seconds`, `context.wake`,
|
|
211
|
+
`context.peer_turns`, `session.max_wakes`, `context.candidates.{mode,threshold,offer_threshold,reask_after_turns}`.
|
|
212
|
+
|
|
213
|
+
## Data files
|
|
214
|
+
|
|
215
|
+
| File | Written by | Read by |
|
|
216
|
+
|---|---|---|
|
|
217
|
+
| `context/{projects/<key>,sessions/<id>}/<name>.json` | `chi context add`, a provider, a plugin (`ctx.context.attach`), `ContextCandidates.attach`, `ContextMove` | poller, absorber, `context_read`, chi web |
|
|
218
|
+
| `<name>.snapshot.json` | `ContextFetch`, `chi context push`, `ContextMove` | absorber, `context_read`, chi web chips |
|
|
219
|
+
| `<name>.lock` | `ContextFetch` / `Location#locked` | same |
|
|
220
|
+
| `sessions/<id>/subscriptions.json` | the worker (`commit`, `context_read`) only | absorber, chi web (unread dot) |
|
|
221
|
+
| `sessions/<id>/{muted,declined}/<name>` | `chi context mute`, a removal, `ContextMove` | poller, absorber, candidates |
|
|
222
|
+
| `sessions/<id>/candidates/<name>.json` | `ctx.context.propose` | candidates, judge, `/context` |
|
|
223
|
+
| `sessions/<id>/left.json` | `ContextMove` (continue) | `ctx.context.left_behind` |
|
|
224
|
+
| `context_candidates.jsonl` (beside `sessions/`) | `ContextCandidatesLog` | judge (`due?`), choice (`carded?`), `chi context report` |
|
|
225
|
+
|
|
226
|
+
Specs: `spec/context_*_spec.rb`; a store under test uses an isolated `XDG_STATE_HOME`.
|
|
@@ -26,7 +26,7 @@ Behavior:
|
|
|
26
26
|
- The exit code: the command runs after an EXIT trap that writes the shell's status to the `exit_code` file (`TaskRuntime.wrapped_shell_command`, shared with `task_create`), so `exit N`, a `set -e` failure and a trailing `\` or `|` in the command all record what the shell exited with. The trap runs wherever the shell is when it exits (a `task_create` `cwd`, or a `cd` in the command), so `task_create`'s `exit_code_path` is absolute; its `output_path` stays relative (`tmp/tasks/<id>/output.log`, opened by chi in the worker's cwd before the spawn). A command that sets its own EXIT trap replaces it, and a shell killed by a signal skips it (zsh's sh mode, on TERM, INT and KILL): `execute` still reports the shell's status, and a `task_create` task or a handed-over execute gets its code from chi's waiter while the chi that started it runs (`TaskRuntime.record_exit_status`): the exit status, or `128 + the signal` (TERM → 143), with no new `stop_reason`. A record a refresh already finished without a code in between (`failed`, `process_ended_without_exit_code` or the not-chi's-process reason) is corrected, its `finished_at` kept; a stopped task (its stop flag on disk) is never touched. `process_ended_without_exit_code` is left only when the shell wrote nothing and the chi that started it was gone too.
|
|
27
27
|
- A stop of an `execute` (the user's Stop, the limit in kill mode) is done once the shell is reaped and nothing holds the logs: a child that ignores TERM after its shell died gets the KILL.
|
|
28
28
|
- A command still running at `execute.timeout_sec` is handed over as a task (`execute.on_timeout: task`, the default; `kill` stops it instead, and the `!cmd` bang always kills): the result starts `Still running at the 120s limit: moved to a background task, not killed.`, then `task_id: …` and the tails of both logs so far. A command is also handed over early when a person's message waits for the turn's next step (the `message_waiting:` probe, the same `Engine#input_waiting?` as `task_wait`'s): after `Execute::EARLY_HAND_OVER_FLOOR_SEC` (3 s: a short command ends first and its result is the better answer), in the next 0.2 s slice. The first line then reads `Still running at the 4s mark: a message from the user is waiting, so the command moved to a background task, not killed.` (the seconds it had run), and the line after `task_id:` starts `Read the message first.`; the rest is the same hand-over, and the task stays watched. Never in `kill` mode (`execute.on_timeout: kill`, a participant's pair turn, the `!cmd` bang): there the probe is not even read and the command runs to its limit and is killed. A command that can't be adopted at that moment (a record that can't be written) isn't killed early: it runs on to its limit. Everything that matches the first line builds on `Execute::HANDED_OVER` (`Still running at the`) and `Execute::HANDED_OVER_AFTER` (`120s limit:` or `4s mark:`): `TaskRuntime.handed_over_pattern`, `ToolActivity::HANDED_OVER_START`, `TaskWatch` (`limit_sec` for the limit's form, `early_sec` for the mark's, which the finish note words as `handed over by execute after 4s: a message from the user was waiting`) and the web's `task_stop.js`; the slow-commands bundle reads only the limit's form, which is the only one that is a timeout. A timeout during the 1.5 s background grace is a kill (the shell is gone, nothing to hand over). If the record can't be written, the command is killed. `TaskRuntime.adopt` writes a record for the process execute already started, with `handed_over_by: "execute"`. Its logs stay where execute wrote them, so the record's paths are absolute: `output_path` (stdout), `stderr_path` (stderr, a log of its own) and `exit_code_path` (a `task_create` record's `exit_code_path` is absolute too; its `output_path` is relative). `started_at` (and `created_at`) is the spawn time, not the hand-over's; `pid_started` is read at the hand-over, while execute's detach waiter is still running. From there it is an ordinary task: `task_wait`, `task_get`, `task_list` and `task_stop` treat it as any other. Its dir gets a `task` back-pointer file holding the record's absolute path. Once per process, the first `execute` sweeps `…/execute/`: a dir whose logs no process holds is removed when it has no back-pointer and is a day old (chi died mid-command), when its record is gone, or when it is 14 days old. The records live in each project's `tmp/tasks`, which the state dir can't list: hence the back-pointer.
|
|
29
|
-
- `task_wait` and `task_get` show `stderr_path` when the record has one; `task_wait`'s output tail is followed by a `stderr_tail:` of at most 10 lines when that log isn't empty. A `task_create` task has one log (stdout and stderr together) and no `stderr_path`. A `task_wait` result longer than `max_tool_output_chars` keeps its header and the tail's end, with a gap line naming `output_path`, where the whole log already is (and `stderr_path`, when the record has one) (`ToolOutputCut`'s `:head_tail_ref`; nothing is copied; see [Tool output guardrails](tool-
|
|
29
|
+
- `task_wait` and `task_get` show `stderr_path` when the record has one; `task_wait`'s output tail is followed by a `stderr_tail:` of at most 10 lines when that log isn't empty. A `task_create` task has one log (stdout and stderr together) and no `stderr_path`. A `task_wait` result longer than `max_tool_output_chars` keeps its header and the tail's end, with a gap line naming `output_path`, where the whole log already is (and `stderr_path`, when the record has one) (`ToolOutputCut`'s `:head_tail_ref`; nothing is copied; see [Tool output guardrails](tool-output-limits.md#the-runners-cut-max_tool_output_chars)).
|
|
30
30
|
- Which tasks are a session's own (a cancelled turn's note, a worker's restart hold, the finish wake below, the web's stop-task button): the ids its `task_create` results and its handed-over executes name (`TaskRuntime.created_ids_in`, `created_id`). An execute's id is read only after its first line (`Execute::HANDED_OVER`, through `TaskRuntime.handed_over_id`; the web has a JS twin in `task_stop.js`), not from a `task_id:` line inside a run, which the command can print. (A command that prints the `---` separator chi joins a turn's results with, then a hand-over line, still fakes one: a known gap.) Either result may start with the `ran as: …` line of a gate-corrected call.
|
|
31
31
|
- `task_get` returns metadata and `output_path`; use `read` for output contents.
|
|
32
32
|
- `task_wait` accepts `timeout`, `tail_lines` (maximum 100), and `done_pattern` (a regular expression string).
|