samagotchi 0.96.0 → 0.97.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 +26 -1
- data/bin/chi +3 -0
- data/docs/architecture.md +2 -0
- data/docs/bundle-authoring.md +2 -1
- data/docs/cli.md +5 -1
- data/docs/configuration.md +13 -0
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +3 -1
- data/docs/memory.md +43 -3
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/bundle_command/check.rb +5 -3
- data/lib/samagotchi/bundle_command/try/run.rb +19 -4
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +49 -38
- data/lib/samagotchi/llm/errors.rb +100 -2
- data/lib/samagotchi/memory_bundle/profile.rb +14 -4
- data/lib/samagotchi/memory_command.rb +237 -0
- data/lib/samagotchi/plugin/api.rb +7 -3
- data/lib/samagotchi/send_command.rb +11 -11
- data/lib/samagotchi/session_inbox.rb +29 -0
- data/lib/samagotchi/session_manager.rb +11 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -0
- data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
- data/lib/samagotchi/terminal_ui.rb +1 -0
- data/lib/samagotchi/tools/memory.rb +1 -1
- data/lib/samagotchi/turn_checkpoint.rb +6 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/worker.rb +19 -5
- data/lib/samagotchi/worker_idle_exit.rb +16 -8
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8bec4407abc70e688b17f913e574b7b7968a13941e7d2f0b2599e8ebdcdd5954
|
|
4
|
+
data.tar.gz: b1f1b5d30b31953901a94849b22da5deeb7ff97b7b57fe17bda2f3c8d40ec41b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8d1d4b749231445533c0526e4fc87052294b70026ec9be742db89b474bb8bdb18079c95f67d77b8c7116f1d18d0c13be9b413eceea5e1f21343ee93925121756
|
|
7
|
+
data.tar.gz: 7d32ff1ed5d3d7fb3ccb57f304b96bdad8053671a0395db7408852b57a40130ce7fd76492a5048eba5bf7cb6ac7b94ff3b7dd0c178e536a1b9006815c10f07b9
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,30 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.97.0] - 2026-10-21
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- The terminal's status row (the REPL's and an attached one's) shows the session's title first when it has one, and
|
|
16
|
+
follows `/title` from any client without a restart. See [docs/cli.md](docs/cli.md#status-line).
|
|
17
|
+
- A model server's 404 that lists the models it serves (oMLX's "Model 'X' not found. Available models: …") ends with
|
|
18
|
+
`did you mean '<id>'?` when one listed id is close to the one asked for: the same name under a prefix
|
|
19
|
+
(`mlx-community--…`, `vendor/…`), another case, or a typo. The listed ids are read past the message's cut.
|
|
20
|
+
- `chi memory ls` lists the memories of both scopes (or `--scope project|system`; `--format json`) with their size,
|
|
21
|
+
date, bundle, description and model overlays, and `chi memory rm NAME [--scope …]` removes one as `memory_write`
|
|
22
|
+
`remove: true` does: the file and its overlays go to the bundle trash, the index line is dropped, a bundle's memory
|
|
23
|
+
is refused. A profile's "kept your own" line now points at `chi memory rm`. See
|
|
24
|
+
[docs/memory.md](docs/memory.md#listing-and-removing-from-the-shell).
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- `chi bundle try` exits 130 for a Ctrl-C in the child chi's first moments too (while its Ruby was still booting it
|
|
29
|
+
exited 1).
|
|
30
|
+
- `chi bundle upgrade core --dry-run` (or `dev`) names the memory files of a member it would install that you already
|
|
31
|
+
have, which the install keeps as yours (`model-notes: kept your own …`), as the real run does; it named none.
|
|
32
|
+
|
|
33
|
+
Update with `chi update` (the system bundle 0.97.0), then restart `chi web`.
|
|
34
|
+
|
|
11
35
|
## [0.96.0] - 2026-10-21
|
|
12
36
|
|
|
13
37
|
### Added
|
|
@@ -3703,7 +3727,8 @@ and long-lived sessions.
|
|
|
3703
3727
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
3704
3728
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
3705
3729
|
|
|
3706
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
3730
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.97.0...HEAD
|
|
3731
|
+
[0.97.0]: https://github.com/dm1try/samagotchi/compare/v0.96.0...v0.97.0
|
|
3707
3732
|
[0.96.0]: https://github.com/dm1try/samagotchi/compare/v0.95.0...v0.96.0
|
|
3708
3733
|
[0.95.0]: https://github.com/dm1try/samagotchi/compare/v0.94.0...v0.95.0
|
|
3709
3734
|
[0.94.0]: https://github.com/dm1try/samagotchi/compare/v0.93.0...v0.94.0
|
data/bin/chi
CHANGED
|
@@ -61,6 +61,8 @@ SUBCOMMANDS = {
|
|
|
61
61
|
"models" => ["samagotchi/models_command", "ModelsCommand"],
|
|
62
62
|
# the native "Send to chi" helper
|
|
63
63
|
"desktop" => ["samagotchi/desktop_command", "DesktopCommand"],
|
|
64
|
+
# list and remove memories (ls, rm; --scope, --format)
|
|
65
|
+
"memory" => ["samagotchi/memory_command", "MemoryCommand"],
|
|
64
66
|
# install, upgrade, uninstall, status, diff, list, build (--scope, --force, …)
|
|
65
67
|
"bundle" => ["samagotchi/bundle_command", "BundleCommand"]
|
|
66
68
|
}.freeze
|
|
@@ -106,6 +108,7 @@ parser = OptionParser.new do |opts|
|
|
|
106
108
|
chi context <add|push|ls|show|refresh|rm|mute|unmute> live external text attached to sessions
|
|
107
109
|
chi send [-m TEXT] ID...|--new a message into sessions, as if typed there
|
|
108
110
|
chi answer ID --question QID --option N answer a question a session waits on
|
|
111
|
+
chi memory <ls|rm> [--scope project|system] the memories; rm moves one to the bundle trash
|
|
109
112
|
chi bundle <install|upgrade|uninstall|status|diff|list|build|trash|new|test|check|try>
|
|
110
113
|
chi desktop <install|upgrade|uninstall|status> macOS "Send to chi" helper
|
|
111
114
|
chi models [--format json] [TEXT] the models --model takes, from every host
|
data/docs/architecture.md
CHANGED
|
@@ -339,6 +339,8 @@ process that already owns the `Engine`); every worker starts it, and it exposes:
|
|
|
339
339
|
deletes it, and the Bridge announces `:turn_dequeued`. `200 {status: "removed", enqueued_id, prompt}`, `409 already_started`
|
|
340
340
|
(a turn took it), `409 not_queued` (a next-step message: a drain may merge it any moment), `404
|
|
341
341
|
unknown_enqueued_id`. It never wakes the worker. The worker names it in its sidecar features (`unqueue`).
|
|
342
|
+
A worker's start prunes the `input/removed/` and `input/settled/` tombstones older than a week
|
|
343
|
+
(`SessionInbox::TOMBSTONE_MAX_AGE`, log `tombstones_pruned`); otherwise they go with the session dir.
|
|
342
344
|
- `POST /session/:id/relay` — the approval relay, on a delegated child's worker
|
|
343
345
|
(`{action, relay_id, question_id}`): `opened` / `closed` (with a `reason`) set or clear the
|
|
344
346
|
pending question's `relayed_to` mark (`QuestionDesk#annotate`, `:question_relay`); `answered`
|
data/docs/bundle-authoring.md
CHANGED
|
@@ -127,7 +127,8 @@ class, a hand-built `ctx`), that is a gap in the kit: say so to the user rather
|
|
|
127
127
|
of a checksum mismatch, and a line with no sha installs unchecked. `chi bundle check --fix` rewrites them; run it
|
|
128
128
|
after the last edit.
|
|
129
129
|
- **requires_chi.** Use the chi the user runs (`chi --version`). `check` warns when the plugin calls something
|
|
130
|
-
newer than the floor (`ctx.http` needs 0.80.0, `ctx.cost` 0.83.0).
|
|
130
|
+
newer than the floor (`ctx.http` needs 0.80.0, `ctx.cost` 0.83.0, `ctx.sessions.children` 0.34.0). A keyword added
|
|
131
|
+
later it can't see: `ctx.ask_model(model:)` needs 0.80.0, `ctx.sessions.send(delivery:)` 0.64.0.
|
|
131
132
|
- **trust_level.** Leave it out. `reviewed` / `trusted` are for someone who reviewed the bundle, not its author.
|
|
132
133
|
- **Restart.** A session loads bundles when it starts: after an install or a settings change, the user starts a
|
|
133
134
|
new session (or `chi sessions restart ID`).
|
data/docs/cli.md
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
- `chi context <add|push|ls|show|refresh|rm|mute|unmute> …` — attach live external text to sessions (a command chi runs every so often, or text pushed in); the model gets a short note when it changes and reads it with `context_read`; `chi context add <URL>` attaches a GitHub PR with the github-pr bundle (see [Attached context](context.md))
|
|
21
21
|
- `chi send [-m TEXT] [--image PATH]... [--cut | --queue] (ID|PREFIX)...` — send a message to sessions as if typed there: a turn starts (or a running one takes it at its next step; `--cut` asks it to cut long thinking for it; `--queue` runs it as a turn of its own after the running one, and `--queue --wait` waits for that turn's own answer); piped stdin goes above `-m` as quoted context, and `--image` attaches images (see [Sessions: Sending a message](sessions.md#sending-a-message)); `--new` starts a session with it instead (`--new --continues ID|last:ID` the next link of that session's chain, see [Session chains](sessions.md#session-chains)), and `--wait` prints the answer (`--wait ID` with no message waits for the next reply without sending — to an idle session whose last turn already ended, that ended turn's reply or end comes back; `--wait --turn T ID` with no message waits for exactly turn T, whether running, queued or last ended; `--format json` includes its `turn_id` when known, for waiting again after exit 3 or 4; exit 3 a question waits, its options on stderr; exit 4 `--timeout` passed with the turn still running; see [Starting a session](sessions.md#starting-a-session))
|
|
22
22
|
- `chi answer ID --question QID (--option N|LABEL)... [--text T] [--timeout S] [--format json]` (or `--dismiss`) — answer the question a session waits on (the one `chi send --wait` exited 3 with), then wait and print what comes next as `chi send --wait` does: the reply (0), the next question (3), still running after `--timeout` (4). `--option` is 1-based or the label, repeated on a multi-select question; `--text` is free text, or a Deny's reason; `--dismiss` leaves it unanswered and the model finishes its reply. A question no longer open (the web answered first) isn't answered again: it waits for that turn's reply. An option the question doesn't offer exits 2; a worker that is gone exits 1 (`send the task again: chi send --wait -m "…" ID`). What a parent may pick follows `guardrails.parent_answers`, or the session's own `parent_answers` when stricter (`soft`, the default: a deny, a plain answer, Continue and "Allow once"; `standard`: no allow; `hard`: no Continue either), never a wider scope or an allow on chi's own config; a refused pick exits 1 with what to answer instead (a convention, not a security boundary; see [Guardrails](guardrails.md#answers-from-a-parent-agent))
|
|
23
|
+
- `chi memory ls [--scope project|system] [--format text|json]` — the memories of both scopes (this folder's project first), one row each: scope, name, size, date, `from <bundle>`, the index description and its model overlays; `chi memory rm NAME [--scope project|system]` removes one as `memory_write` with `remove: true` does: its file and model overlays go to the bundle trash (the line names the dir; `chi bundle trash` lists and empties it) and its index line is dropped. NAME in both scopes without `--scope` exits 2 naming both; a missing NAME or a bundle's memory (`chi bundle uninstall` removes it) exits 1 (see [Memory: Listing and removing from the shell](memory.md#listing-and-removing-from-the-shell))
|
|
23
24
|
- `chi desktop install|upgrade|uninstall|status` — the macOS "Send to chi" helper: a Service and a ⌃⌥⌘N hotkey that send text or images to a session (live, stopped or new) as a message (⏎, a turn runs) or a context note (⌘⏎) (see [Desktop helper](desktop.md))
|
|
24
25
|
- `chi models [--format text|json] [--timeout S] [TEXT]` — list the models every configured host offers, as the names `--model` takes (see [Listing the models](#listing-the-models))
|
|
25
26
|
- `chi self` — print version, source dir (checkout or installed gem), config/memory/session paths, model/host and bundles. Run by a session's `execute`, the `model` row (and host, loop, profile, thinking, served model) is **that session's** model, labelled `(this session <id8>; default …)`; elsewhere it is the default, `(default)`. `model key` is the memory overlay key (`<name>.<key>.md`), and `model notes` the model notes a prompt of that model loads (`model_notes_deepseek (system, 612 chars)`, `none`; inside a session its `--mute` list is left out). `chi self --model` prints only the model a new session starts on, an alias resolved to its target (the desktop helper's hint)
|
|
@@ -1132,10 +1133,13 @@ The REPL and attached mode show one status row under the prompt, drawn when what
|
|
|
1132
1133
|
changes:
|
|
1133
1134
|
|
|
1134
1135
|
```
|
|
1135
|
-
status> model=Qwen3.6-35B | ↳ 3f2a1c9e | ctx=12.3% (under20) | mem: notes, cli_usage | muted: gh-helper
|
|
1136
|
+
status> Coordinator | model=Qwen3.6-35B | ↳ 3f2a1c9e | ctx=12.3% (under20) | mem: notes, cli_usage | muted: gh-helper
|
|
1136
1137
|
status> model=Qwen3.6-35B | ⑂ 2 running · 1 waiting | ctx=8.1% (under20) | mem: skill_coordinator
|
|
1137
1138
|
```
|
|
1138
1139
|
|
|
1140
|
+
- The session's title first, when it has one (`/title`; not the first message a session without one is named by),
|
|
1141
|
+
cut to 32 characters and to a third of a narrow row. It follows `/title` from any client (this terminal, another
|
|
1142
|
+
one, the web's `✎`, `chi send`) without a restart: the worker's `command_ran` for it carries the new title.
|
|
1139
1143
|
- `model=`: the model in use, `(default: …)` beside it when it isn't the config's default, and
|
|
1140
1144
|
`model=<served> (served; asked <name>)` when the server said it served another model (not for one its host's
|
|
1141
1145
|
`hosts.<name>.models.<id>.served` names; see [configuration](configuration.md#models-a-host-serves-but-doesnt-list)).
|
data/docs/configuration.md
CHANGED
|
@@ -187,6 +187,19 @@ An environment name used as a top-level key (`SAMAGOTCHI_DEFAULT_MODEL: my-model
|
|
|
187
187
|
the old flat form) is not read: it warns as an unknown key with the nested one
|
|
188
188
|
to use (`did you mean 'default.model'?`).
|
|
189
189
|
|
|
190
|
+
A few environment variables are not settings (no config.yml key, no flag):
|
|
191
|
+
|
|
192
|
+
- `SAMAGOTCHI_SKIP_SYSTEM_BUNDLE=1` (or `true`): don't install or upgrade the system bundle
|
|
193
|
+
(`samagotchi-system`) into the system memories; chi uses whatever is installed there. For isolated runs and
|
|
194
|
+
specs. `chi bundle try` drops it from its sandboxed child, so the sandbox gets the system bundle installed.
|
|
195
|
+
- `SAMAGOTCHI_BOOT_FALLBACK`: internal. A worker's boot line sets it when the newest installed chi failed to
|
|
196
|
+
load and the worker fell back to its starter's version; the worker reads and unsets it, and logs
|
|
197
|
+
`boot_fallback` with the error. See [Sessions](sessions.md) (Which chi a worker runs).
|
|
198
|
+
- `SAMAGOTCHI_DEFAULT_MODEL_FROM_CLI=1`: internal. Set beside `SAMAGOTCHI_DEFAULT_MODEL` for a worker a
|
|
199
|
+
`chi --model X` spawned: X is that run's model, which the chi its commands run must not inherit.
|
|
200
|
+
- `SAMAGOTCHI_LOG_STRICT=1`: an unknown log tag or a malformed log event raises instead of being dropped. The spec
|
|
201
|
+
suite sets it, and a bundle's tests keep it (`Samagotchi::Testing::Sandbox`).
|
|
202
|
+
|
|
190
203
|
## Model Server API
|
|
191
204
|
|
|
192
205
|
Chi talks to a model server over HTTP in one of two ways, set by the host's `api:`:
|
|
@@ -56,7 +56,7 @@ A task the model leaves running (it ends its turn: "I'll check when it's finishe
|
|
|
56
56
|
- **The wake turn**: the first finished task runs a continue turn from client `task:<id>` (`last_turn.origin: "task"`, labelled `task <id8> finished` in the TUI and the web; the web's bell rings at its end however short), whose first message is the note (`turn_start`, `turn_id`, written before the turn's checkpoint so a restore keeps it). It counts toward `session.max_wakes` with the other wakes (`WorkerWakes#task_open?`: no continue offer, not paused, past the 2 s start grace). Several tasks finishing together: one wake turn, the others plain notes before it.
|
|
57
57
|
- **A plain note instead** (the same text without the last two lines, no turn start), written at once, with `queue`, the budget spent (the UIs hear `N finished background tasks noted; the model reads them with your next message` once per human input, the flag shared with the other wakes), wakes paused, a continue offer waiting, input queued, an exit asked for, or a **note-only** task: one started or waited on in a turn the user stopped (reasons `user`, `manual`, `ctrl_c`), since a wake telling the model to carry on would undo that Stop. A prompt's turn takes the finished ones as plain notes first.
|
|
58
58
|
- **Failure**: a wake turn that fails and kept nothing, or is stopped and restored, turns its note back into the plain one (`WorkerInbound#unmark_wake`, as for a context wake) and pauses wakes until the user writes.
|
|
59
|
-
- **Idle exit**: with `wake`, a watched task holds the worker's idle exit (`WorkerIdleExit`'s `:background_tasks`) until its wake is taken: while it runs, and once it finished until the next idle pass takes it (`TaskWatch#holding`, in memory, no `ps`), so one that ends between the pass's look and the exit's check still wakes the session. A still-running one holds `/exit` too (`TaskWatch#running`; the 409 lists the tasks, `{id, command}`, with a `hint` on stopping them, and the attached terminal names them and says to use the web's stop-task button or ask for `task_stop`), as reminders do. A note-only task holds neither (no wake would come for it), nor does one whose note, or the look for it (the conversation's task ids unreadable), failed 3 times (`TaskWatch::MAX_FAILURES`: it is still tried at each look, but can't keep the worker up for good). With `queue` nothing holds: nothing would wake for the end, so keeping the worker up isn't worth it. As the worker leaves (any exit but a stop, a signal or an `/exit --delete`), the tasks that finished since the last look are written as plain notes; a task that ends after the worker left gets no note (the model sees its end at its next look, as before). A forgotten dev server keeps the worker up until it is stopped; a crash of that server wakes the session.
|
|
59
|
+
- **Idle exit**: with `wake`, a watched task holds the worker's idle exit (`WorkerIdleExit`'s `:background_tasks`) until its wake is taken: while it runs, and once it finished until the next idle pass takes it (`TaskWatch#holding`, in memory, no `ps`), so one that ends between the pass's look and the exit's check still wakes the session. A still-running one holds `/exit` too (`TaskWatch#running`, read once per request by `WorkerIdleExit#request_hold`; the 409 lists the tasks, `{id, command}`, with a `hint` on stopping them, and the attached terminal names them and says to use the web's stop-task button or ask for `task_stop`), as reminders do. A note-only task holds neither (no wake would come for it), nor does one whose note, or the look for it (the conversation's task ids unreadable), failed 3 times (`TaskWatch::MAX_FAILURES`: it is still tried at each look, but can't keep the worker up for good). With `queue` nothing holds: nothing would wake for the end, so keeping the worker up isn't worth it. As the worker leaves (any exit but a stop, a signal or an `/exit --delete`), the tasks that finished since the last look are written as plain notes; a task that ends after the worker left gets no note (the model sees its end at its next look, as before). A forgotten dev server keeps the worker up until it is stopped; a crash of that server wakes the session.
|
|
60
60
|
- **The tool descriptions**: a worker's `execute` and `task_create` say "if you end your turn before it finishes, chi tells you when it does" (left out under `off` and in the REPL), fixed when the worker builds its Engine.
|
|
61
61
|
|
|
62
62
|
Work that needs a model, not a shell command, goes to a child chi session instead: `delegate` / `delegate_result` have the same shape (start, then wait) and return only the child's final reply; see [Sessions: Delegating](../sessions.md#delegating).
|
data/docs/internals/bundles.md
CHANGED
|
@@ -39,7 +39,7 @@ user-facing side (what a bundle owns, profiles, needs) is in [memory.md](../memo
|
|
|
39
39
|
| `Provenance` | the `.bundles/<name>/` dir: `#write` the record + bases, `#record` → `InstalledBundle`, `.each_installed`, `.claimants` (which bundles own a file) |
|
|
40
40
|
| `InstalledBundle`, `BundleHook`, `PluginRef` | value objects for the record; tolerant of older records (missing keys read as empty) |
|
|
41
41
|
| `Builder` | `chi bundle build`: memories (+ the installed bundle's hooks, rules, plugin, scripts) → zip/tar/dir |
|
|
42
|
-
| `Profile` | meta bundles (`includes:`): install the members, record which; `InstallResult#kept_own` names a new member's memory files the user already had (`kept_own_line`, printed by `chi bundle`, `chi update` and `chi bootstrap`) |
|
|
42
|
+
| `Profile` | meta bundles (`includes:`): install the members, record which; `InstallResult#kept_own` names a new member's memory files the user already had, with other bytes (`kept_own_line`, printed by `chi bundle`, `chi update` and `chi bootstrap`); a dry run runs each member's Installer as a dry run too, so it names them as well |
|
|
43
43
|
| `SystemBundle`, `ShippedUpdate` | the built-in system bundle (`ensure!` at Engine start, `sync` in `chi update`) and `chi update`'s upgrade of shipped bundles; both drive the Installer with `upgrade: true` |
|
|
44
44
|
| `Uninstaller`, `Trash`, `Status`, `Listing` | remove, keep removed files, `chi bundle status`, `chi bundle list` |
|
|
45
45
|
| `IndexUpdater`, `IndexSync` | the managed `index.md` lines (Installer; and the write/edit tools for memories) |
|
data/docs/internals/engine.md
CHANGED
|
@@ -527,10 +527,12 @@ Engine initialization (in this order, among other things):
|
|
|
527
527
|
@event_routes (EventRoutes: where a card or a notice goes: held while the plugins load, an anytime command's, an init task's, a turn event, else announced or held)
|
|
528
528
|
@hook_routes (HookRoutes: notices (through @event_routes), hook questions, stop_turn/stop_generation, steer attribution)
|
|
529
529
|
@guardrail_wiring, @question_desk, @relay_desk (RelayDesk: a delegate's approval relay)
|
|
530
|
+
@thinking_notices (ThinkingNotices)
|
|
531
|
+
build_tools_and_plugins:
|
|
530
532
|
@extension_load = ExtensionLoad.new; @hooks = @extension_load.hooks (config.yml's, then the bundles')
|
|
531
533
|
@tools = Tools::Builtins.registry; @command_registry (SessionCommands' built-ins); @plugin_tasks
|
|
532
534
|
@toolkit_binding (ToolkitBinding: the kits, the plugins' among them, and the turn-start apply)
|
|
533
|
-
@
|
|
535
|
+
@tool_index = Tools::ToolIndex.new; @peer_sources = PeerSources.new
|
|
534
536
|
the bundles' plugins load (load_plugins → ExtensionLoad#load_plugins; each plugin's ctx is a
|
|
535
537
|
Plugin::Context over plugin_host, whose ctx.sessions it builds itself: the test kit passes its own, sessions:),
|
|
536
538
|
then (plugins or not) @tool_index.declare(@tools): find_tools + call_tool (source "index", not core),
|
data/docs/memory.md
CHANGED
|
@@ -18,8 +18,46 @@ Tool behavior:
|
|
|
18
18
|
- `memory_write`'s `description` is the managed `index.md` line's text: one line (whitespace and newlines collapse to single spaces), at most 200 characters (longer is refused, since every session's prompt for the repo carries it). A missing or blank one keeps the line's description.
|
|
19
19
|
- `memory_write` with `name`, `scope` and `description` but no `content` changes only that entry's index line (its size and date are refreshed; the date becomes today) and leaves the file as it is: a skill that keeps its status in a memory's description changes it without resending the body. The memory must exist in that scope; the form is refused for `index` and with `current_model_only`.
|
|
20
20
|
- `memory_write` with `name`, `scope` and `remove: true` (no content, description or `current_model_only`) removes a memory: its file and its model overlays (`<name>.<key>.md`, not a dotted memory with an index line of its own) move into one dir of the bundle trash, `memory-<name>-<time>` under `memories/.bundles/.trash/`, which `chi bundle trash` lists and empties, and its index line is dropped. When the file is already gone (deleted by hand), the dangling line is dropped alone. A memory a bundle owns (an installed bundle's record lists it, or its line says `· from <bundle>`) is refused: `chi bundle uninstall` removes it. `index` can't be removed. The guardrails bundle's `memory-remove` rule asks before every removal, once at a time (see [Guardrails](guardrails.md#the-guardrails-bundle)).
|
|
21
|
+
- `chi memory ls` and `chi memory rm NAME` are the user's side of the same (see [Listing and removing from the shell](#listing-and-removing-from-the-shell)).
|
|
21
22
|
- `write`/`edit` that change a `*.md` right in a scope dir refresh its managed `index.md` line (date, bytes; the description is kept), as `memory_write` does. A memory created with `write` gets a line with no description; any other `.md` written there gets a line too. Model overlays (`<name>.<key>.md` next to `<name>.md`), files in another project's folder and files changed by `execute` are left out.
|
|
22
23
|
|
|
24
|
+
## Listing and removing from the shell
|
|
25
|
+
|
|
26
|
+
`chi memory ls [--scope project|system] [--format text|json]` lists the
|
|
27
|
+
memories of both scopes (this folder's project first, then system; `--scope`
|
|
28
|
+
one), by name: scope, name, size in bytes (right-aligned), date, `from <bundle>` for a
|
|
29
|
+
bundle's memory, and the index description (cut at 60 characters; `--format
|
|
30
|
+
json` has it all, with `path`, `source`, `overlays`, `indexed` and `file`).
|
|
31
|
+
It reads what `memory_read` does: each scope's `index.md` lines and its
|
|
32
|
+
`*.md` files. A model overlay isn't a row of its own: its memory's row ends
|
|
33
|
+
in `[overlays: <key>, …]`. A file with no index line says `[no index line]`
|
|
34
|
+
(its date is the file's), a line whose file was deleted by hand `[no file]`.
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
project handoff 812 2026-10-21 Coordinator hand-off: where the work stands [overlays: qwen3-6-35b-a3b]
|
|
38
|
+
system identity 5120 2026-10-03 from samagotchi-system Who chi is and how it works with you
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`chi memory rm NAME [--scope project|system]` removes a memory exactly as
|
|
42
|
+
`memory_write` with `remove: true` does, and prints that line (the trash dir
|
|
43
|
+
it went to):
|
|
44
|
+
|
|
45
|
+
- the file and its model overlays (`NAME.<key>.md`) move into one dir of the
|
|
46
|
+
bundle trash, `memory-NAME-<time>`, which `chi bundle trash` lists and
|
|
47
|
+
empties; its index line is dropped (a dangling line alone, when the file is
|
|
48
|
+
already gone);
|
|
49
|
+
- without `--scope`, NAME must be in one scope: in both it exits 2 with
|
|
50
|
+
`chi memory rm: NAME is in both scopes: chi memory rm NAME --scope project, or --scope system`;
|
|
51
|
+
in neither, 1;
|
|
52
|
+
- a bundle's memory is refused (exit 1, `memory 'NAME' came from bundle B;
|
|
53
|
+
chi bundle uninstall B removes it`), as for `memory_write`: the bundle
|
|
54
|
+
owns it, and an upgrade would bring it back or merge into nothing;
|
|
55
|
+
- `NAME.<key>` removes that one model overlay and leaves its memory (a
|
|
56
|
+
bundle's overlay is refused like its memory);
|
|
57
|
+
- `index` can't be removed.
|
|
58
|
+
|
|
59
|
+
It isn't asked first: it is the user's command.
|
|
60
|
+
|
|
23
61
|
## Model-Specific Memory Overlays
|
|
24
62
|
|
|
25
63
|
Each memory entry may have a companion file named `<name>.<model-key>.md` in the same scope directory. When the entry is read under a matching model, the overlay body is appended automatically, separated by the standard `---` separator with a `Model-specific guidance (<key>):` header.
|
|
@@ -118,10 +156,12 @@ Working habits for this model:
|
|
|
118
156
|
(already exists; use --force to overwrite)`; through the `dev` profile
|
|
119
157
|
(`chi bundle install dev`, `chi bundle upgrade dev`, `chi update`) it is
|
|
120
158
|
`model-notes: kept your own model_notes_deepseek.md (not the bundle's; to
|
|
121
|
-
take the bundle's, remove yours with
|
|
122
|
-
bundle install model-notes)
|
|
159
|
+
take the bundle's, remove yours with chi memory rm model_notes_deepseek
|
|
160
|
+
--scope system, then chi bundle install model-notes)` (a dry run,
|
|
161
|
+
`chi bundle upgrade dev --dry-run`, names it too).
|
|
123
162
|
- **To take the bundle's version instead,** remove your note
|
|
124
|
-
(`
|
|
163
|
+
(`chi memory rm model_notes_deepseek --scope system`, or `memory_write`
|
|
164
|
+
with `remove: true`, moves it to the trash) and run
|
|
125
165
|
`chi bundle install model-notes` again: the install writes the missing
|
|
126
166
|
file and owns it from then on. Don't use `--force`: it overwrites every
|
|
127
167
|
file of the bundle with no trash copy.
|
data/docs/sessions.md
CHANGED
|
@@ -219,9 +219,9 @@ chi send --new --continues last:3fa2 -m "new day" # the next link of 3fa2's
|
|
|
219
219
|
|
|
220
220
|
## Session titles
|
|
221
221
|
|
|
222
|
-
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
|
|
222
|
+
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 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.
|
|
223
223
|
|
|
224
|
-
The title names the session wherever its first message did: `chi sessions list` (plain, `--live`, tsv and json's `desc`, before the recap; json has `title` too), `chi sessions delete`'s line, the `list_sessions` tool, `chi broadcast`, the desktop helper, a parent's children (the coordinator bundle's `/children` card, `ctx.sessions.children`; `ctx.sessions.read` too), and in the web the cards, the families and chain rows, the top bar and the browser tab, the notifications and the search. The web's info bar has `✎` (Rename this session): a small input prefilled with the title; Enter or a click elsewhere sends `/title TEXT` when it changed (empty: `/title --clear`), Esc closes it unsent. A session a chi REPL owns has no `✎`. A continue's new link keeps the title (see [Session chains](#session-chains)). An older `chi web` (or any chi from before the field) that saves a session drops its title: restart `chi web` after upgrading.
|
|
224
|
+
The title names the session wherever its first message did: `chi sessions list` (plain, `--live`, tsv and json's `desc`, before the recap; json has `title` too), `chi sessions delete`'s line, the `list_sessions` tool, `chi broadcast`, the desktop helper, a parent's children (the coordinator bundle's `/children` card, `ctx.sessions.children`; `ctx.sessions.read` too), and in the web the cards, the families and chain rows, the top bar and the browser tab, the notifications and the search; the terminal's status row too (the REPL's and an attached one's, first, see [Status Line](cli.md#status-line)). The web's info bar has `✎` (Rename this session): a small input prefilled with the title; Enter or a click elsewhere sends `/title TEXT` when it changed (empty: `/title --clear`), Esc closes it unsent. A session a chi REPL owns has no `✎`. A continue's new link keeps the title (see [Session chains](#session-chains)). An older `chi web` (or any chi from before the field) that saves a session drops its title: restart `chi web` after upgrading.
|
|
225
225
|
|
|
226
226
|
## Session chains
|
|
227
227
|
|
|
@@ -51,8 +51,9 @@ module Samagotchi
|
|
|
51
51
|
|
|
52
52
|
LIB_DIR = File.expand_path("../..", __dir__)
|
|
53
53
|
TRUSTED = %w[reviewed trusted].freeze
|
|
54
|
-
# A call of the plugin API
|
|
55
|
-
|
|
54
|
+
# A call of the plugin API (with one member after it: ctx.sessions.children),
|
|
55
|
+
# or a name SINCE lists, in the source.
|
|
56
|
+
API_CALL = /\b(?:chi|ctx)\.[a-z_]+(?:\.[a-z_]+)?\b|\bfallback_for:|\bSamagotchi::AtomicFile\b/
|
|
56
57
|
# A requirement's lower bound: >= X, > X, ~> X or = X.
|
|
57
58
|
LOWER_BOUND = /\A(?:>=|>|~>|=)?\s*(\d+(?:\.\d+)*)\z/
|
|
58
59
|
|
|
@@ -213,7 +214,8 @@ module Samagotchi
|
|
|
213
214
|
floor = lower_bound(manifest.requires_chi)
|
|
214
215
|
return unless floor && File.file?(path(manifest.plugin[:file]))
|
|
215
216
|
|
|
216
|
-
calls = File.read(path(manifest.plugin[:file])).scan(API_CALL).
|
|
217
|
+
calls = File.read(path(manifest.plugin[:file])).scan(API_CALL).flat_map { |call| [call, call.split(".").first(2).join(".")] }
|
|
218
|
+
.uniq.select { |call| Plugin::Api::SINCE.key?(call) }
|
|
217
219
|
call = calls.max_by { |c| Gem::Version.new(Plugin::Api::SINCE[c]) } or return
|
|
218
220
|
version = Plugin::Api::SINCE[call]
|
|
219
221
|
return unless Gem::Version.new(version) > floor
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "open3"
|
|
4
4
|
require "rbconfig"
|
|
5
|
+
require_relative "../../cli/exit"
|
|
5
6
|
require_relative "../../process_group"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
@@ -54,7 +55,7 @@ module Samagotchi
|
|
|
54
55
|
end
|
|
55
56
|
trap_state[:pid] = nil
|
|
56
57
|
out, err = finish_readers(readers, streams)
|
|
57
|
-
code = timed_out ? CLI::Exit::FAILED :
|
|
58
|
+
code = timed_out ? CLI::Exit::FAILED : exit_code(status, trap_state)
|
|
58
59
|
Result.new(exit_status: code, stdout: out, stderr: err, session_id: err[/^Session: (\S+)/, 1],
|
|
59
60
|
timed_out: timed_out)
|
|
60
61
|
end
|
|
@@ -91,13 +92,16 @@ module Samagotchi
|
|
|
91
92
|
end
|
|
92
93
|
|
|
93
94
|
def trap_forwarding
|
|
94
|
-
state = { pid: nil, wait: nil, pending: nil }
|
|
95
|
+
state = { pid: nil, wait: nil, pending: nil, interrupted: false }
|
|
95
96
|
old = {}
|
|
96
97
|
%w[INT TERM].each do |signal|
|
|
97
98
|
old[signal] = Signal.trap(signal) do
|
|
98
99
|
if state[:pid]
|
|
99
100
|
# Not once its waiter reaped it: that group may be gone.
|
|
100
|
-
|
|
101
|
+
if state[:wait]&.alive?
|
|
102
|
+
state[:interrupted] ||= signal == "INT"
|
|
103
|
+
ProcessGroup.signal(state[:pid], signal)
|
|
104
|
+
end
|
|
101
105
|
else
|
|
102
106
|
state[:pending] ||= signal
|
|
103
107
|
end
|
|
@@ -111,7 +115,10 @@ module Samagotchi
|
|
|
111
115
|
state[:pid] = pid
|
|
112
116
|
pending = state[:pending]
|
|
113
117
|
state[:pending] = nil
|
|
114
|
-
|
|
118
|
+
return unless pending
|
|
119
|
+
|
|
120
|
+
state[:interrupted] ||= pending == "INT"
|
|
121
|
+
ProcessGroup.signal(pid, pending)
|
|
115
122
|
end
|
|
116
123
|
|
|
117
124
|
def restore_traps(old)
|
|
@@ -171,6 +178,14 @@ module Samagotchi
|
|
|
171
178
|
readers.each(&:join)
|
|
172
179
|
end
|
|
173
180
|
|
|
181
|
+
# A Ctrl-C forwarded to the child is an interrupted run, however it
|
|
182
|
+
# ended: a Ruby still booting (before chi's INT handler) exits 1.
|
|
183
|
+
def exit_code(status, trap_state)
|
|
184
|
+
return CLI::Exit::INTERRUPTED if trap_state[:interrupted] && !status.success?
|
|
185
|
+
|
|
186
|
+
process_exit_status(status)
|
|
187
|
+
end
|
|
188
|
+
|
|
174
189
|
def process_exit_status(status)
|
|
175
190
|
status.exitstatus || (128 + status.termsig.to_i)
|
|
176
191
|
end
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -187,49 +187,13 @@ module Samagotchi
|
|
|
187
187
|
@question_desk = build_question_desk
|
|
188
188
|
# Built here, not on first use: the turn thread and the Bridge's read it.
|
|
189
189
|
@relay_desk = RelayDesk.new
|
|
190
|
-
@extension_load = ExtensionLoad.new(hook_failures: @guardrail_failures)
|
|
191
|
-
@hooks = @extension_load.hooks
|
|
192
|
-
# The tools this session offers (the prompts' declarations and the
|
|
193
|
-
# kernel's dispatch): the built-ins, per Engine. execute.description
|
|
194
|
-
# off: execute offers no description parameter. +task_finish_notes+:
|
|
195
|
-
# a worker's, whose finish wake reports a task left running (the
|
|
196
|
-
# Worker's TaskWatch): execute and task_create say so.
|
|
197
|
-
@tools = Tools::Builtins.registry(command_description: Config.get("execute.description") != false,
|
|
198
|
-
policy: Config.get(LLMContextStrategy::POLICY_SETTING),
|
|
199
|
-
task_finish_notes: task_finish_notes)
|
|
200
|
-
restrict_scratch_tools! if @scratch
|
|
201
|
-
# Likewise the slash commands its SessionCommands run.
|
|
202
|
-
@command_registry = SessionCommands.register_builtins(Commands::Registry.new)
|
|
203
|
-
# Installed bundles' plugins add commands, tools and hooks to these
|
|
204
|
-
# (docs/plugins.md); one that fails is announced with the load
|
|
205
|
-
# failures, and the rest still load.
|
|
206
|
-
# Their services (chi.service), which #shutdown stops, and the anytime
|
|
207
|
-
# commands running now (#spawn_anytime), which it waits for.
|
|
208
|
-
@services = Plugin::Services.new
|
|
209
|
-
@anytime_threads = []
|
|
210
|
-
# chi.init tasks (#add_init_task), started by #start_init_tasks!, and
|
|
211
|
-
# plugins' tool sets from chi.replace_tools until the turn thread
|
|
212
|
-
# applies them (#apply_staged_tools!). Built before the plugins load.
|
|
213
|
-
@plugin_tasks = build_plugin_tasks
|
|
214
|
-
# The session's toolkit: the kits (the plugins' too, chi.toolkit),
|
|
215
|
-
# what its next turn declares, the turn-start apply.
|
|
216
|
-
@toolkit_binding = build_toolkit_binding
|
|
217
190
|
# The notices a turn's thinking level leads to (once per session and host).
|
|
218
191
|
@thinking_notices = ThinkingNotices.new(session: -> { @session }, profile: -> { self.profile },
|
|
219
192
|
notify: ->(text, level, source) { hook_notify(text, level, source) })
|
|
220
|
-
# The tools plugins publish without declaring (chi.tool_source), and
|
|
221
|
-
# the search and call tools that reach them (Tools::ToolIndex).
|
|
222
|
-
@tool_index = Tools::ToolIndex.new
|
|
223
|
-
# The context sources the peer bundles declare as a peer agent's
|
|
224
|
-
# answers (chi.peer_source); the worker's ContextAbsorber asks it.
|
|
225
|
-
@peer_sources = PeerSources.new
|
|
226
193
|
@lifecycle_mutex = Mutex.new
|
|
227
194
|
@shut_down = false
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
# with no plugins too: a toolkit's left-out built-ins are reachable
|
|
231
|
-
# either way (declared per toolkit, Tools::Registry).
|
|
232
|
-
@tool_index.declare(@tools, failures: @plugin_failures)
|
|
195
|
+
# The built-ins, the commands, and the plugins that add to them.
|
|
196
|
+
build_tools_and_plugins(load: plugins, task_finish_notes: task_finish_notes)
|
|
233
197
|
guardrail_rules
|
|
234
198
|
# The Engine owns the reminder store; its kernel (built here, or a
|
|
235
199
|
# spec's) gets it, so tool calls via KernelLoop and reminder injection
|
|
@@ -277,6 +241,52 @@ module Samagotchi
|
|
|
277
241
|
|
|
278
242
|
# ── initialize's collaborators ──────────────────────────────────────────
|
|
279
243
|
|
|
244
|
+
# The Engine as the plugins' host: the tools and slash commands this
|
|
245
|
+
# session offers, what plugins add to them (services, init tasks, the
|
|
246
|
+
# toolkit, the tool index, peer sources), then the plugins themselves
|
|
247
|
+
# unless +load+ is false (see #initialize for when).
|
|
248
|
+
def build_tools_and_plugins(load:, task_finish_notes:)
|
|
249
|
+
@extension_load = ExtensionLoad.new(hook_failures: @guardrail_failures)
|
|
250
|
+
@hooks = @extension_load.hooks
|
|
251
|
+
# The tools this session offers (the prompts' declarations and the
|
|
252
|
+
# kernel's dispatch): the built-ins, per Engine. execute.description
|
|
253
|
+
# off: execute offers no description parameter. +task_finish_notes+:
|
|
254
|
+
# a worker's, whose finish wake reports a task left running (the
|
|
255
|
+
# Worker's TaskWatch): execute and task_create say so.
|
|
256
|
+
@tools = Tools::Builtins.registry(command_description: Config.get("execute.description") != false,
|
|
257
|
+
policy: Config.get(LLMContextStrategy::POLICY_SETTING),
|
|
258
|
+
task_finish_notes: task_finish_notes)
|
|
259
|
+
restrict_scratch_tools! if @scratch
|
|
260
|
+
# Likewise the slash commands its SessionCommands run.
|
|
261
|
+
@command_registry = SessionCommands.register_builtins(Commands::Registry.new)
|
|
262
|
+
# Installed bundles' plugins add commands, tools and hooks to these
|
|
263
|
+
# (docs/plugins.md); one that fails is announced with the load
|
|
264
|
+
# failures, and the rest still load.
|
|
265
|
+
# Their services (chi.service), which #shutdown stops, and the anytime
|
|
266
|
+
# commands running now (#spawn_anytime), which it waits for.
|
|
267
|
+
@services = Plugin::Services.new
|
|
268
|
+
@anytime_threads = []
|
|
269
|
+
# chi.init tasks (#add_init_task), started by #start_init_tasks!, and
|
|
270
|
+
# plugins' tool sets from chi.replace_tools until the turn thread
|
|
271
|
+
# applies them (#apply_staged_tools!). Built before the plugins load.
|
|
272
|
+
@plugin_tasks = build_plugin_tasks
|
|
273
|
+
# The session's toolkit: the kits (the plugins' too, chi.toolkit),
|
|
274
|
+
# what its next turn declares, the turn-start apply.
|
|
275
|
+
@toolkit_binding = build_toolkit_binding
|
|
276
|
+
# The tools plugins publish without declaring (chi.tool_source), and
|
|
277
|
+
# the search and call tools that reach them (Tools::ToolIndex).
|
|
278
|
+
@tool_index = Tools::ToolIndex.new
|
|
279
|
+
# The context sources the peer bundles declare as a peer agent's
|
|
280
|
+
# answers (chi.peer_source); the worker's ContextAbsorber asks it.
|
|
281
|
+
@peer_sources = PeerSources.new
|
|
282
|
+
load_plugins if load
|
|
283
|
+
# Registered from what the sources published while they loaded, and
|
|
284
|
+
# with no plugins too: a toolkit's left-out built-ins are reachable
|
|
285
|
+
# either way (declared per toolkit, Tools::Registry).
|
|
286
|
+
@tool_index.declare(@tools, failures: @plugin_failures)
|
|
287
|
+
end
|
|
288
|
+
private :build_tools_and_plugins
|
|
289
|
+
|
|
280
290
|
# The tools a scratch session doesn't offer, and its memory_write.
|
|
281
291
|
def restrict_scratch_tools!
|
|
282
292
|
# A scratch session's children would outlive it, and so would a
|
|
@@ -1260,6 +1270,7 @@ module Samagotchi
|
|
|
1260
1270
|
muted_memory_names: @muted_memory_names.dup,
|
|
1261
1271
|
prompt_notes: prompt_notes.map(&:to_h),
|
|
1262
1272
|
parent_id: @session&.parent_id,
|
|
1273
|
+
title: @session&.title,
|
|
1263
1274
|
model_name: effective_model_ref,
|
|
1264
1275
|
served_model: served_pair[0],
|
|
1265
1276
|
served_model_for: served_pair[1],
|
|
@@ -384,6 +384,24 @@ module Samagotchi
|
|
|
384
384
|
# Seconds to wait before retrying a CreditsHeld without a Retry-After.
|
|
385
385
|
CREDITS_SETTLE_DELAY = 20.0
|
|
386
386
|
RETRYABLE_SERVER_STATUSES = [500, 502, 503, 504, 529].freeze
|
|
387
|
+
# How much of a server's error message the summary keeps.
|
|
388
|
+
MESSAGE_LIMIT = 500
|
|
389
|
+
# The 404 model hint's bounds (the message is the server's): how much
|
|
390
|
+
# of the message it reads, the longest model id, how many listed ids.
|
|
391
|
+
HINT_SCAN_CHARS = 64 * 1024
|
|
392
|
+
HINT_ID_MAX = 200
|
|
393
|
+
HINT_IDS_MAX = 2000
|
|
394
|
+
# A typo the hint forgives: at most this many edits, fewer than a
|
|
395
|
+
# third of the name, in a name at least HINT_FUZZY_MIN long.
|
|
396
|
+
HINT_MAX_EDITS = 2
|
|
397
|
+
HINT_FUZZY_MIN = 6
|
|
398
|
+
# A 404's model it couldn't find: "Model 'X' not found", "model `X`
|
|
399
|
+
# does not exist", "model X-1 not found" (unquoted, an id-like word,
|
|
400
|
+
# checked after the match). Bounded quantifiers: a server's long token
|
|
401
|
+
# can't make the match quadratic (Ruby 3.3 has no regex memoization).
|
|
402
|
+
MODEL_NOT_FOUND_RE = /model\s+(?:["'`]([^"'`\s]{1,#{HINT_ID_MAX}})["'`]|([^"'`\s]{1,#{HINT_ID_MAX}}))\s+(?:was\s+)?(?:not found|does not exist)/i
|
|
403
|
+
# The ids a 404 lists after "Available models:".
|
|
404
|
+
AVAILABLE_MODELS_RE = /available models?:\s*(.+)\z/im
|
|
387
405
|
# The HTTP status an Anthropic error.type stands for, for an error
|
|
388
406
|
# object that carries no numeric code (a Messages stream's error event,
|
|
389
407
|
# an OpenAI error whose code is null or a name).
|
|
@@ -426,6 +444,7 @@ module Samagotchi
|
|
|
426
444
|
else
|
|
427
445
|
OutOfCredits.new(text, **options)
|
|
428
446
|
end
|
|
447
|
+
when 404 then bad_request(text, status, message, options.merge(hint: model_hint(error_message(body, limit: nil))))
|
|
429
448
|
when 400..499 then bad_request(text, status, message, options)
|
|
430
449
|
when 500..599
|
|
431
450
|
ServerError.new(text, retryable: RETRYABLE_SERVER_STATUSES.include?(status),
|
|
@@ -492,7 +511,7 @@ module Samagotchi
|
|
|
492
511
|
# upstream failure (OpenRouter) often says only "Provider returned
|
|
493
512
|
# error" and puts the upstream's words in error.metadata.raw and its
|
|
494
513
|
# name in metadata.provider_name; both are added when present.
|
|
495
|
-
def error_message(body)
|
|
514
|
+
def error_message(body, limit: MESSAGE_LIMIT)
|
|
496
515
|
parsed = parse_json(body.to_s)
|
|
497
516
|
message =
|
|
498
517
|
if parsed.is_a?(Hash)
|
|
@@ -502,9 +521,88 @@ module Samagotchi
|
|
|
502
521
|
message = body.to_s if message.nil? || message.to_s.strip.empty?
|
|
503
522
|
message = message.to_s.strip
|
|
504
523
|
message = with_upstream(message, parsed["error"]["metadata"]) if parsed.is_a?(Hash) && parsed["error"].is_a?(Hash)
|
|
505
|
-
|
|
524
|
+
return "(empty body)" if message.empty?
|
|
525
|
+
|
|
526
|
+
limit ? message[0, limit] : message
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
# "did you mean 'ID'?" for a 404 that names the model it couldn't find
|
|
530
|
+
# and lists the ones it has (oMLX: "Model 'X' not found. Available
|
|
531
|
+
# models: a, b, …"): the listed id that is X but for case, the one
|
|
532
|
+
# whose last segment (after "/" or "--") is X, else the one id a
|
|
533
|
+
# typo away (#typo_distance, whole or its last segment). Read from
|
|
534
|
+
# the message past the summary's cut (MESSAGE_LIMIT), within the
|
|
535
|
+
# HINT_* bounds; nil when there is no list, nothing close or a tie.
|
|
536
|
+
def model_hint(message)
|
|
537
|
+
message = message[0, HINT_SCAN_CHARS]
|
|
538
|
+
match = MODEL_NOT_FOUND_RE.match(message)
|
|
539
|
+
requested = match && (match[1] || match[2])
|
|
540
|
+
return nil unless requested && (match[1] || requested.match?(%r{[-/.:_\d]}))
|
|
541
|
+
|
|
542
|
+
list = message[AVAILABLE_MODELS_RE, 1]
|
|
543
|
+
return nil unless list
|
|
544
|
+
|
|
545
|
+
ids = list.split(/,\s*|\s+/, HINT_IDS_MAX + 1).first(HINT_IDS_MAX)
|
|
546
|
+
.map { |id| id.delete("[]\"'`").sub(/[.;]+\z/, "") }
|
|
547
|
+
.reject { |id| id.empty? || id.length > HINT_ID_MAX }.uniq
|
|
548
|
+
return nil if ids.empty? || ids.include?(requested)
|
|
549
|
+
|
|
550
|
+
close = closest_model_id(requested, ids)
|
|
551
|
+
close && "did you mean '#{close}'?"
|
|
506
552
|
end
|
|
507
553
|
|
|
554
|
+
# The id that is +requested+ but for case, or whose last segment is,
|
|
555
|
+
# else the one id a typo away (#typo_distance); nil on a tie.
|
|
556
|
+
def closest_model_id(requested, ids)
|
|
557
|
+
probe = requested.downcase
|
|
558
|
+
exact = ids.find { |id| id.downcase == probe } || ids.find { |id| last_segment(id) == probe }
|
|
559
|
+
return exact if exact
|
|
560
|
+
|
|
561
|
+
scored = ids.filter_map do |id|
|
|
562
|
+
distance = [id.downcase, last_segment(id)].uniq.filter_map { |name| typo_distance(probe, name) }.min
|
|
563
|
+
[distance, id] if distance
|
|
564
|
+
end
|
|
565
|
+
return nil if scored.empty?
|
|
566
|
+
|
|
567
|
+
best = scored.map(&:first).min
|
|
568
|
+
closest = scored.select { |distance, _| distance == best }
|
|
569
|
+
closest.size == 1 ? closest.first.last : nil
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
# The edit distance of +a+ and +b+ when it is a typo's: at most
|
|
573
|
+
# HINT_MAX_EDITS and under a third of the shorter one, which has
|
|
574
|
+
# HINT_FUZZY_MIN characters or more; else nil. A banded Levenshtein:
|
|
575
|
+
# linear in the length.
|
|
576
|
+
def typo_distance(a, b)
|
|
577
|
+
shorter = [a.length, b.length].min
|
|
578
|
+
return nil if shorter < HINT_FUZZY_MIN || (a.length - b.length).abs > HINT_MAX_EDITS
|
|
579
|
+
|
|
580
|
+
distance = banded_distance(a, b, HINT_MAX_EDITS)
|
|
581
|
+
distance if distance && distance * 3 < shorter
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# Levenshtein distance of +a+ and +b+ if at most +limit+, else nil;
|
|
585
|
+
# only cells within +limit+ of the diagonal are computed.
|
|
586
|
+
def banded_distance(a, b, limit)
|
|
587
|
+
far = limit + 1
|
|
588
|
+
prev = Hash.new(far)
|
|
589
|
+
(0..[limit, b.length].min).each { |j| prev[j] = j }
|
|
590
|
+
a.each_char.with_index(1) do |ca, i|
|
|
591
|
+
cur = Hash.new(far)
|
|
592
|
+
cur[0] = i if i <= limit
|
|
593
|
+
([i - limit, 1].max..[i + limit, b.length].min).each do |j|
|
|
594
|
+
cost = ca == b[j - 1] ? 0 : 1
|
|
595
|
+
cur[j] = [prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost, far].min
|
|
596
|
+
end
|
|
597
|
+
return nil if cur.empty? || cur.values.min > limit
|
|
598
|
+
|
|
599
|
+
prev = cur
|
|
600
|
+
end
|
|
601
|
+
prev[b.length] <= limit ? prev[b.length] : nil
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
def last_segment(id) = id.downcase.split(%r{/|--}).last
|
|
605
|
+
|
|
508
606
|
# error.metadata.reason of a JSON error body (OpenRouter), or nil.
|
|
509
607
|
def error_reason(body)
|
|
510
608
|
parsed = parse_json(body.to_s)
|