samagotchi 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -1
  3. data/docs/architecture.md +24 -9
  4. data/docs/cli.md +4 -3
  5. data/docs/guardrails.md +5 -3
  6. data/docs/hooks.md +44 -7
  7. data/docs/internals/gemma4-contract.md +1 -0
  8. data/docs/plugins.md +36 -13
  9. data/docs/sessions.md +1 -1
  10. data/lib/samagotchi/bootstrap_command.rb +29 -29
  11. data/lib/samagotchi/bridge/card_store.rb +4 -4
  12. data/lib/samagotchi/bridge/pending_card.rb +2 -2
  13. data/lib/samagotchi/bridge/sse_writer.rb +22 -28
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +92 -5
  15. data/lib/samagotchi/bridge.rb +59 -112
  16. data/lib/samagotchi/bridge_client.rb +11 -28
  17. data/lib/samagotchi/bundle_command.rb +158 -212
  18. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/check-in/plugin.rb +3 -4
  20. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +13 -4
  21. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/loop-guard/plugin.rb +10 -10
  24. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  25. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -4
  26. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/skills/plugin.rb +18 -9
  28. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +0 -1
  29. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  31. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  32. data/lib/samagotchi/cli/command.rb +51 -0
  33. data/lib/samagotchi/cli/flags.rb +116 -0
  34. data/lib/samagotchi/config.rb +70 -55
  35. data/lib/samagotchi/config_text_edit.rb +107 -0
  36. data/lib/samagotchi/desktop_command.rb +24 -18
  37. data/lib/samagotchi/edit_preview.rb +2 -2
  38. data/lib/samagotchi/engine.rb +20 -75
  39. data/lib/samagotchi/events.rb +23 -0
  40. data/lib/samagotchi/guardrail_wiring.rb +2 -5
  41. data/lib/samagotchi/hooks/bundle_loader.rb +13 -30
  42. data/lib/samagotchi/hooks/loader.rb +23 -33
  43. data/lib/samagotchi/hooks/registry.rb +41 -0
  44. data/lib/samagotchi/hooks.rb +0 -3
  45. data/lib/samagotchi/host_registry.rb +15 -0
  46. data/lib/samagotchi/idle_reminders.rb +0 -7
  47. data/lib/samagotchi/image_store.rb +22 -0
  48. data/lib/samagotchi/kernel_loop.rb +12 -16
  49. data/lib/samagotchi/live_versions.rb +2 -13
  50. data/lib/samagotchi/llm/native_tool_normalizer.rb +10 -227
  51. data/lib/samagotchi/memory_bundle/builder.rb +1 -14
  52. data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
  53. data/lib/samagotchi/memory_bundle/installer.rb +1 -27
  54. data/lib/samagotchi/memory_bundle/provenance.rb +2 -6
  55. data/lib/samagotchi/memory_bundle/status.rb +1 -14
  56. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -2
  57. data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -24
  58. data/lib/samagotchi/memory_paths.rb +10 -0
  59. data/lib/samagotchi/note_command.rb +23 -37
  60. data/lib/samagotchi/owner_lock.rb +14 -3
  61. data/lib/samagotchi/plugin/api.rb +11 -17
  62. data/lib/samagotchi/plugin/context.rb +70 -15
  63. data/lib/samagotchi/plugin/loader.rb +2 -3
  64. data/lib/samagotchi/sampling_settings.rb +3 -5
  65. data/lib/samagotchi/self_report.rb +6 -9
  66. data/lib/samagotchi/send_command.rb +33 -52
  67. data/lib/samagotchi/session.rb +88 -92
  68. data/lib/samagotchi/session_archive_command.rb +8 -14
  69. data/lib/samagotchi/session_commands.rb +3 -21
  70. data/lib/samagotchi/session_delete_command.rb +15 -29
  71. data/lib/samagotchi/session_manager.rb +27 -13
  72. data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -80
  73. data/lib/samagotchi/terminal_ui/attached_view.rb +1 -10
  74. data/lib/samagotchi/terminal_ui/between_turns.rb +133 -0
  75. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  76. data/lib/samagotchi/terminal_ui/formatting.rb +51 -1
  77. data/lib/samagotchi/terminal_ui/status_row.rb +32 -0
  78. data/lib/samagotchi/terminal_ui/thinking_line.rb +3 -3
  79. data/lib/samagotchi/terminal_ui.rb +156 -234
  80. data/lib/samagotchi/thinking.rb +2 -3
  81. data/lib/samagotchi/thought_stream_splitter.rb +10 -9
  82. data/lib/samagotchi/tool_call_parser.rb +56 -292
  83. data/lib/samagotchi/tools/builtin_calls.rb +146 -0
  84. data/lib/samagotchi/tools/builtins.rb +2 -1
  85. data/lib/samagotchi/tools/edit.rb +21 -29
  86. data/lib/samagotchi/tools/memory.rb +1 -11
  87. data/lib/samagotchi/tools/send_note.rb +1 -1
  88. data/lib/samagotchi/update_command.rb +32 -22
  89. data/lib/samagotchi/version.rb +1 -1
  90. data/lib/samagotchi/vision_support.rb +4 -5
  91. data/lib/samagotchi/web/app.rb +89 -151
  92. data/lib/samagotchi/web/public/activity.js +1 -11
  93. data/lib/samagotchi/web/public/annotate_bar.js +120 -0
  94. data/lib/samagotchi/web/public/app.js +158 -785
  95. data/lib/samagotchi/web/public/chunk_router.js +7 -6
  96. data/lib/samagotchi/web/public/command_list.js +99 -0
  97. data/lib/samagotchi/web/public/data.js +22 -66
  98. data/lib/samagotchi/web/public/format.js +0 -23
  99. data/lib/samagotchi/web/public/index.html +0 -2
  100. data/lib/samagotchi/web/public/model_pick.js +0 -7
  101. data/lib/samagotchi/web/public/notifications.js +169 -0
  102. data/lib/samagotchi/web/public/question_cards.js +352 -0
  103. data/lib/samagotchi/web/public/session_state.js +34 -0
  104. data/lib/samagotchi/web/public/timing.js +1 -1
  105. data/lib/samagotchi/web/public/turn_events.js +11 -2
  106. data/lib/samagotchi/web/public/turn_model.js +0 -14
  107. data/lib/samagotchi/web/session_hub.rb +6 -4
  108. data/lib/samagotchi/web/session_summary.rb +5 -3
  109. data/lib/samagotchi/worker.rb +59 -53
  110. data/lib/samagotchi/worker_sidecar.rb +27 -1
  111. metadata +12 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9290117f26040b725e12c5cd18bf2a8858a573f34e2323e5a56517c030f95d7f
4
- data.tar.gz: 7e5d1e2e8a285424b907172038f6bab1045b1388129241cc1a32cb26132437ae
3
+ metadata.gz: 7fbd9765613c81776d1a8d62e1edacd50cc426843dcae07d2c0e6a11ebe80972
4
+ data.tar.gz: 8ed4b10801de4650ded6cd223d7723055acd8d0fa1ed3e98af36fe2cb174c403
5
5
  SHA512:
6
- metadata.gz: d70bd257f5b82a2057077e2142adf5601f4bc652a0e820c695b5ecff305156c0270815374054e41d6403ecb0ee4579fd75fda84f670094e99a21f7cf73e34b5c
7
- data.tar.gz: 961269d190e236ecbe3a3ce0dae6d18d86adf93f5c16aa8246dd91e10a43b73152c8595a4e6377491aaf86e74d8558b225893364dc6dd226634d6b94a7484504
6
+ metadata.gz: fd72e187dab11b52fa7ba403eeecd3ea29b43b2e2364b31cce773279cbbadb18c0539e30f16fed5e76ae849f82c31e76e300cb2f6a419699c127c7396cc14263
7
+ data.tar.gz: 4abd84f3c1a83d0afa57239709919c9753c4eb8037503532e5a373a6ef3d0e674cfee330d15a717c50c48b11e5adf2be6611178c13aaf26fa4237fb89ed030e4
data/CHANGELOG.md CHANGED
@@ -8,6 +8,71 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.10.1] - 2026-10-01
12
+
13
+ ### Fixed
14
+
15
+ - The `known-names` bundle no longer rejects a shell glob of a known name, such as `ls -d myrepo*` in a repo named
16
+ `myrepo`: a token with `*?[]{}` is not checked.
17
+ - The `skills` bundle no longer counts a call that a guardrail or the user denied as a failed skill step, so it
18
+ doesn't steer the model to fix the skill or say "a step failed" at the turn's end.
19
+
20
+ Update with `chi update` (bundles: known-names 0.1.3, skills 0.1.4).
21
+
22
+ ## [0.10.0] - 2026-10-01
23
+
24
+ ### Changed
25
+
26
+ - Built-in tool calls are built the same way for every model format (Gemma and Qwen native, OpenAI-style chat), so
27
+ they no longer differ by format. As part of that:
28
+ - `edit` takes the old and new text as two fields (`old_text`, `new_text`), so old text that contains `</old>` is
29
+ no longer cut short; `edit` without `new_text` is an error (it deleted the matched text; pass `""` to delete).
30
+ - `write` without `content` is an error with every model format (Gemma and Qwen emptied the file).
31
+ - `register_reminder` from a Qwen model without an interval no longer always fails.
32
+ - Plugins: `ctx.notify`, `ctx.ask_user`, `ctx.steer`, `ctx.stop_turn` and `ctx.stop_generation` are the one way a plugin
33
+ acts; inside a `chi.on` block they act for that event as `event[:x]` does (`ctx.stop_turn` in `:before_tool_call`
34
+ denies the call, `ctx.steer` from `:after_turn` is false), and as before anywhere else (commands, your own threads).
35
+ Plain hook files keep `event[:x]`.
36
+ - Attaching to a session mid-turn (`chi --attach`) shows the turn's finished tool calls as they looked live, with
37
+ what each did and how long it took (`tool> running command (execute …): ok (1.3s)`), when the worker runs this chi.
38
+ - `chi bundle` usage errors (unknown subcommand or flag, a missing argument) exit 2 like every other command, not 1.
39
+ - A config hook with `on_error: log` warns as `[samagotchi:hooks] <file> (config) failed: <error>`, the same shape as a
40
+ bundle hook's warning; a fail-closed bundle guardrail's deny reason reads `... (bundle <name>) raised <error>`.
41
+ - Bundles (`chi update`):
42
+ - known-names 0.1.2: corrects a near-miss name only in paths, `cwd` and commands, no longer inside text being
43
+ written to a file (`write`/`memory_write` content, `edit`'s old and new text, where a correction could stop the
44
+ edit matching).
45
+ - loop-guard 0.2.2: tells repeated `edit` calls apart by their old and new text; acts through `ctx`.
46
+ - skills 0.1.3 and mcp 0.3.2 (mcp now needs chi 0.8.0): write a skill's history and the MCP tool cache through a
47
+ unique temporary file, so two workers saving at once no longer collide.
48
+ - check-in 0.1.3 acts through `ctx`; source-links 0.3.1 drops dead code. They behave as before.
49
+
50
+ ### Fixed
51
+
52
+ - Web: a Gemma model on a native llama.cpp host streams its answer into the reply as it writes (it showed only when the
53
+ turn ended), with its thoughts in the thinking block; the attached terminal shows them as thinking and the answer as
54
+ writing.
55
+ - `execute` with a `cwd` now runs in that directory with Gemma and Qwen native tool calls too (the directory was
56
+ dropped and the command ran in the project root).
57
+ - `/model … --default` and `/model … --alias` keep the comments and layout of `config.yml`: they change one line
58
+ (or add one) instead of rewriting the whole file.
59
+ - `chi sessions stop` marks the session with a `stopped` file in its folder instead of rewriting its session file, so
60
+ a worker saving at that moment can no longer undo the stop (or lose its own save); a resume removes the file.
61
+ - The web's answer, dismiss, command and cancel calls fail the same way: a worker that doesn't answer in time is
62
+ 504 ("did not answer, so ... was not ..."; cancel said "not running"), a worker running an older chi is 501 with
63
+ how to restart it (an answer said "not running").
64
+ - A web page joining (or re-syncing with) a live session gets its status and pending question as of the same moment
65
+ as the messages; they could be a step newer.
66
+ - The web's `POST /api/sessions/:id/turn` refuses more than 20 images in one message, as a live worker already did.
67
+ - Web: switching sessions no longer briefly shows the previous session's "delegated by" link in the info bar.
68
+ - The terminal's "retrying (1/3 …)" line counts retries as the web does (it said "1/4", counting the first try).
69
+ - `chi self`'s thinking line finds a `models:` entry under the model an alias points at, as a turn does.
70
+ - A `required: true` config hook that raises something other than a StandardError (e.g. `NotImplementedError`) denies
71
+ the tool call like any other raise, instead of escaping the guardrail check.
72
+
73
+ Update with `chi update` (bundles: known-names 0.1.2, loop-guard 0.2.2, skills 0.1.3, mcp 0.3.2, check-in 0.1.3,
74
+ source-links 0.3.1).
75
+
11
76
  ## [0.9.0] - 2026-10-01
12
77
 
13
78
  ### Changed
@@ -513,7 +578,9 @@ and long-lived sessions.
513
578
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
514
579
  hotkey panel that send selected text or the clipboard to your sessions.
515
580
 
516
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.9.0...HEAD
581
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.10.1...HEAD
582
+ [0.10.1]: https://github.com/dm1try/samagotchi/compare/v0.10.0...v0.10.1
583
+ [0.10.0]: https://github.com/dm1try/samagotchi/compare/v0.9.0...v0.10.0
517
584
  [0.9.0]: https://github.com/dm1try/samagotchi/compare/v0.8.1...v0.9.0
518
585
  [0.8.1]: https://github.com/dm1try/samagotchi/compare/v0.8.0...v0.8.1
519
586
  [0.8.0]: https://github.com/dm1try/samagotchi/compare/v0.7.0...v0.8.0
data/docs/architecture.md CHANGED
@@ -115,9 +115,9 @@ bin/chi ─▶ TerminalUI ─▶ Engine#run_turn ─▶ KernelLoop ──┬─
115
115
 
116
116
  ## Session retention & ordering
117
117
 
118
- - **Files:** `~/.local/state/samagotchi/sessions/<uuid>.json` + `<uuid>/input|output|owner.lock|bridge.json` (XDG-aware).
118
+ - **Files:** `~/.local/state/samagotchi/sessions/<uuid>.json` + `<uuid>/input|output|owner.lock|bridge.json` (XDG-aware), and the `<uuid>/stopped` and `<uuid>/archived` marker files.
119
119
  - **Single owner:** the process running a session's Engine (worker or in-process TUI) holds a flock on `owner.lock` (`OwnerLock`); a second owner backs off, and the web answers 409 for a TUI-owned session.
120
- - **Status:** `status` is turn state (`idle`/`running`); liveness is the lock.
120
+ - **Status:** `status` is turn state (`idle`/`running`); liveness is the lock. A stop is the `<uuid>/stopped` marker (`Session.mark_stopped`; a resume removes it), which `Session.load` and `.summary_from_file` read as status `stopped`, so no other process rewrites a worker's session file.
121
121
  - **Retention:** 14 days / 500 cap (env `SAMAGOTCHI_SESSION_RETENTION_DAYS`/`MAX_COUNT`, optional `KEEP_STATUS`), live-owner guard, only when `*.json` present; lazy sweep ≤1/24h from the session hub's full-probe tick (and on `GET /api/sessions`, which the page no longer calls) & `Dashboard#render_list`, manual via `bin/chi sessions prune --dry-run`.
122
122
  - **Ordering:** `Session.list(sort:,order:,limit:,offset:)` and `GET /api/sessions?sort=&order=&limit=&offset=` default `updated_at desc`; Web UI sort/filter/pagination.
123
123
  - **Test hygiene:** `test_run` flag when `SAMAGOTCHI_ENV=test`/`RACK_ENV=test`/`CI`, targetable via `prune --test-only` / `clean`.
@@ -208,7 +208,8 @@ process that already owns the `Engine`); every worker starts it, and it exposes:
208
208
  `?client_id=` names whose stream it is (`Bridge#open_streams_except`, used by `POST /exit`).
209
209
  - `POST /session/:id/turn` — fire-and-forget turn creation; returns `202` with an `enqueued_id`
210
210
  (delivery is at-least-once via the worker's file-IPC input path — it never calls `run_turn`
211
- across the HTTP boundary). Inspect results through the read surface, not the turn response.
211
+ across the HTTP boundary). Only the bridge's own session: another id, in the path or the body's
212
+ `session_id`, is `404 unknown_session`. Inspect results through the read surface, not the turn response.
212
213
  An optional `deadline` (epoch seconds; `BridgeClient` sends 5/6 of its read timeout ahead) makes
213
214
  a request read after it (a worker frozen by sleep or SIGSTOP) answer `408 deadline_passed` and
214
215
  not run: a client that timed out has said the message was not sent. `/answer`,
@@ -216,6 +217,10 @@ process that already owns the `Engine`); every worker starts it, and it exposes:
216
217
  log held, as a turn is), and the Bridge logs `turn_expired`, `answer_expired`, `dismiss_expired`
217
218
  or `command_expired`. The web app answers either kind of timeout with `504 worker_timeout`
218
219
  ("… so the command was not run"). `/cancel`, `/recap` and `/exit` take none.
220
+ The web relays answer, dismiss, command and cancel the same way (`App#relay`): a 408 or a read
221
+ timeout is `504 worker_timeout`; a Bridge 404 for a route an older worker lacks is
222
+ `501 not_supported` with the restart hint; a Bridge `404 unknown_session`, no bridge or a
223
+ refused connection is `503 not_live`.
219
224
  - `POST /session/:id/cancel` — cancel the running turn; `202`, or `409` when none runs.
220
225
  - `POST /session/:id/answer` — answer the pending question; `200`, `409` when another client
221
226
  answered first or it is gone, `400` for an invalid selection.
@@ -235,6 +240,10 @@ process that already owns the `Engine`); every worker starts it, and it exposes:
235
240
  the messages itself, then streams from its `event_seq`).
236
241
  - `OPTIONS *` — CORS preflight (`Access-Control-Allow-Origin: *`).
237
242
 
243
+ Every route goes through `Bridge#dispatch`: an id other than the bridge's own session is
244
+ `404 unknown_session`, and a handler that raises answers `500 bridge_error` (logged as
245
+ `handler_failed`) rather than dropping the connection.
246
+
238
247
  The per-session port is OS-assigned (bound to `0`) and published to a `bridge.json` sidecar
239
248
  for client discovery. `chi web`'s `GET /api/sessions/:id/stream` proxies this bridge
240
249
  (503 `not_live` when the worker is not running; full history of any session is served by
@@ -248,15 +257,15 @@ changes to every open tab over `GET /api/events` (SSE: a `snapshot` frame on eve
248
257
  then `session` for an upsert and `session_gone` for a removal, `: ping` while idle, no replay).
249
258
  Files stay the source of truth and workers don't know the hub. Its watcher is a 1 s tick that
250
259
  stats the sessions dir (every session writer goes tmp + rename, which bumps the dir's mtime) and
251
- each `<id>/` folder (recap.json, bridge.json), re-parsing only the files whose mtime or size
260
+ each `<id>/` folder (recap.json, bridge.json, the stopped and archived markers), re-parsing only the files whose mtime or size
252
261
  moved through `Session.summary_from_file`. Liveness is probed, since a killed owner leaves no
253
262
  file trace: the owner lock every tick for the sessions the projection believes owned, and every
254
263
  session every 10 s, so a `kill -9` shows within a second. The summary (`Web::SessionSummary`,
255
264
  shared with `/api/sessions` and the session view) carries `owner`, `project_root` and
256
265
  `bridge_up` (the sidecar is there *and* the lock is held: the page attaches its stream on it).
257
266
  `POST/DELETE /api/sessions` and `/stop` rescan the session before answering (`SessionHub#touch`).
258
- Without a hub (`App.new` alone) `/api/events` answers `503 no_hub` and the page falls back to
259
- fetching the list.
267
+ `GET /api/sessions` is the hub's projection too (its sort, paging and total). `Server` always builds
268
+ a hub; an `App.new` without one answers both routes `503 no_hub`.
260
269
 
261
270
  ## Model loops and adapters
262
271
 
@@ -269,9 +278,15 @@ Engine picks the loop from the effective model's host (`HostRegistry#resolve`):
269
278
 
270
279
  Both return an `LLM::ModelResult` and emit the same stream events; tool calls in
271
280
  both go through `ToolRunner` (events, hooks, veto, output cap) and
272
- `KernelLoop#dispatch_tool_call`. The chat loop's `generation_chunk` carries
273
- `thinking:` (the server's `reasoning_content`), `text:` and `content:` (both);
274
- its model turns keep their `tool_calls` and tool results their `tool_call_id`,
281
+ `KernelLoop#dispatch_tool_call`. Every format's built-in calls (Gemma, Qwen,
282
+ chat `tool_calls`) are built by one table, `Tools::BuiltinCalls`, from the
283
+ parsed `{name, args}` (derived from `TOOL_SCHEMAS`), so a call looks the same to
284
+ hooks and tools whichever model made it. Both loops' `generation_chunk` carries
285
+ `thinking:`, `text:` and `content:` (both): the chat loop's thinking is the
286
+ server's `reasoning_content`; the raw-prompt loop splits each generation's
287
+ stream with a fresh `ThoughtStreamSplitter` (Qwen's `<think>` and Gemma's
288
+ `<|channel>thought … <channel|>` are thinking, tool-call bodies are dropped
289
+ from `text:`). The chat loop's model turns keep their `tool_calls` and tool results their `tool_call_id`,
275
290
  so later requests and resumed sessions pair them. It has its own system prompt
276
291
  (no raw-prompt tool declarations; the tools go as JSON schemas).
277
292
 
data/docs/cli.md CHANGED
@@ -21,7 +21,7 @@
21
21
  - `chi desktop install|upgrade|uninstall|status` — the macOS "Send to chi" helper: a Service and a ⌃⌥⌘N hotkey that send text to live sessions as context notes (see [Desktop helper](desktop.md))
22
22
  - `chi self` — print version, source dir (checkout or installed gem), config/memory/session paths, model/host and bundles; `chi self --model` prints only the model a new session starts on (the desktop helper's hint)
23
23
  - `chi update [--dry-run] [--no-gem] [--no-bundles] [--no-desktop]` — update an installed chi: the gem, the system bundle, the shipped bundles you installed and the desktop helper, in one table (see [Updating](#updating))
24
- - `chi bundle install|upgrade|uninstall|status|diff|list|build` — manage memory bundles (see [Bundle hooks](hooks.md#bundle-hooks-unified-workflow-bundle)); `list` shows the installed ones and the ones shipped with chi, which `install <name>` installs; `core` and `dev` are profiles that install a set of them (see [Bundle profiles](memory.md#bundle-profiles-core-and-dev), [Guardrails](guardrails.md), [Plugins](plugins.md#the-btw-bundle), [the mcp bundle](plugins.md#the-mcp-bundle) [the loop-guard bundle](plugins.md#the-loop-guard-bundle), [the check-in bundle](plugins.md#the-check-in-bundle) and [the skills bundle](plugins.md#the-skills-bundle))
24
+ - `chi bundle install|upgrade|uninstall|status|diff|list|build` — manage memory bundles (see [Bundle hooks](hooks.md#bundle-hooks-unified-workflow-bundle)); `list` shows the installed ones and the ones shipped with chi, which `install <name>` installs; `core` and `dev` are profiles that install a set of them (see [Bundle profiles](memory.md#bundle-profiles-core-and-dev), [Guardrails](guardrails.md), [Plugins](plugins.md#the-btw-bundle), [the mcp bundle](plugins.md#the-mcp-bundle) [the loop-guard bundle](plugins.md#the-loop-guard-bundle), [the check-in bundle](plugins.md#the-check-in-bundle) and [the skills bundle](plugins.md#the-skills-bundle)). A usage error (an unknown subcommand or flag, a missing argument, a bad `build --scope`) exits 2, as for every other command
25
25
 
26
26
  ### First setup
27
27
 
@@ -586,9 +586,10 @@ In interactive assist mode, you can switch the request model without restarting:
586
586
 
587
587
  Notes:
588
588
 
589
- - The switch updates the request `model` field, routes to the matching host (`HostRegistry`, `lib/samagotchi/host_registry.rb:72`), and resolves the prompt profile again (config, the server's chat template, the name; see "Prompt profile" in configuration.md).
589
+ - The switch updates the request `model` field, routes to the matching host (`HostRegistry#resolve`), and resolves the prompt profile again (config, the server's chat template, the name; see "Prompt profile" in configuration.md).
590
590
  - Without `--default` the command is session-scoped and does not rewrite config files.
591
591
  - With `--default` the new default is written to `~/.config/samagotchi/config.yml` (honoring `XDG_CONFIG_HOME`) and takes effect for all new sessions; the current session's effective model is also updated immediately. Bare aliases and `host:model` are both valid.
592
+ - `--default` and `--alias` change only their one key's line in `config.yml` (`default.model`, or the alias under `model_aliases:`): comments and the rest of the file stay as written, a symlinked file is written through and keeps its mode. When the edited text wouldn't read back as the expected settings (a flow-style section, anchors), chi writes the whole file out from the parsed data instead, which drops its comments.
592
593
  - Worker sessions inherit `hosts:` via `SAMAGOTCHI_HOSTS_JSON`.
593
594
  - The idle recap uses the session's current model (a switch counts from the next recap), unless `recap: {host_ref, model}` pins one.
594
595
 
@@ -706,7 +707,7 @@ While a turn runs, one row above the prompt says what it is doing, the spinner f
706
707
  Qwen `TURN:` prefix and inline markdown are left out.
707
708
  - `| waiting for the first token… 5s` after 2 s with nothing streamed.
708
709
  - `| running execute…` while a tool runs (its `tool>` line prints when it ends).
709
- - `| retrying (1/4 in 0.5s): Errno::ECONNREFUSED` while a network error is retried.
710
+ - `| retrying (1/3 in 0.5s): Errno::ECONNREFUSED` while a network error is retried.
710
711
  - `| mcp: starting servers…` while a plugin's slow setup (an init task) runs, between turns too;
711
712
  `mcp> ✓ …` prints when it is done.
712
713
 
data/docs/guardrails.md CHANGED
@@ -222,7 +222,8 @@ local part, the repo folder name, and any names from config. A token in a
222
222
  call's command, `cwd` or paths (never a write's content) that is within one
223
223
  edit of a known name shorter than 10 characters, or two edits of a longer
224
224
  one, is a near miss. Tokens and names shorter than `min_length` (6) are
225
- skipped, as is a token that equals another known name.
225
+ skipped, as is a token that equals another known name. A token with a shell
226
+ glob (`*?[]{}`, as in `ls -d samagotchi*`) is not checked.
226
227
 
227
228
  By default (`mode: reject`) the call is denied with advice in place of the
228
229
  usual tail, so the model retries it corrected:
@@ -244,8 +245,9 @@ bundles:
244
245
  max_distance: 2 # default: 1 under 10 characters, else 2
245
246
  ```
246
247
 
247
- `mode: correct` rewrites the call (whole tokens, everywhere they appear) and
248
- says so; `mode: ask` shows the call with three choices, *Correct it and
248
+ `mode: correct` rewrites the call (whole tokens, everywhere they appear in
249
+ the command, the `cwd` and the paths; never in file text: a write's content
250
+ or an edit's `old_text`/`new_text`) and says so; `mode: ask` shows the call with three choices, *Correct it and
249
251
  run*, *Run as is*, *Deny*; with no one to ask (`--non-interactive`) or a
250
252
  dismissed question it rejects. A real near name (a folder `jondoe` next to
251
253
  user `johndoe`, a login one letter from another) is caught too: list it under
data/docs/hooks.md CHANGED
@@ -140,10 +140,10 @@ whole response), and none after the turn or the generation was cancelled.
140
140
  A 240k-char thinking gives about 120 fires.
141
141
 
142
142
  `thinking` holds what the model streamed as thinking: `reasoning_content`
143
- on the chat path, a Qwen `<think>` block on the raw-prompt path. Some
144
- thinking arrives as `text` instead: Gemma 4's on the raw-prompt path (it has
145
- no close marker), and a chat provider's that puts `<think>` inside the
146
- answer's content. Tool-call bodies are left out of `text`. With streaming off
143
+ on the chat path, a Qwen `<think>` block or a Gemma 4
144
+ `<|channel>thought … <channel|>` block on the raw-prompt path. Some
145
+ thinking arrives as `text` instead: a chat provider's that puts `<think>`
146
+ inside the answer's content. Tool-call bodies are left out of `text`. With streaming off
147
147
  (`stream: false` on a chat host) there are no chunks, so no fires.
148
148
 
149
149
  **Keep it fast.** The hook runs on the turn's thread, inside the HTTP read:
@@ -368,6 +368,43 @@ end
368
368
 
369
369
  Note: `:before_tool_call` can replace the `:call` hash to change what runs; `tool_call_started` (what the UIs show) and the rules see the final call.
370
370
 
371
+ ### The call a hook sees
372
+
373
+ `event[:call]` is the same hash for every model format (Gemma, Qwen, the
374
+ chat path): `name:`, `content:`, `path:`, `scope:`, plus the tool's other
375
+ parameters by their own names (symbol keys). `content:` holds the tool's
376
+ main argument (`""` when it has none), `path:` the parameter named in its
377
+ column, and an argument the model left out is `nil`. Text values are
378
+ stripped of surrounding whitespace, except file text, an edit's
379
+ `old_text`/`new_text`, `env` and `options`.
380
+
381
+ | Tool | `content:` from | `path:` from | Own fields |
382
+ |------|-----------------|--------------|------------|
383
+ | `execute` | `command` | — | `cwd` |
384
+ | `read` | `path` | — | `start_line`, `end_line` |
385
+ | `write` | — | `path` | `content` |
386
+ | `edit` | — | `path` | `old_text`, `new_text`, `start_line`, `end_line` |
387
+ | `memory_read` | `name` | — | `scope` |
388
+ | `memory_write` | — | `name` | `content`, `scope`, `description`, `current_model_only` |
389
+ | `task_create` | `command` | — | `cwd`, `env` |
390
+ | `task_get` | `id` or `task_id` | — | — |
391
+ | `task_list` | — | — | — |
392
+ | `task_stop` | `id` or `task_id` | — | — |
393
+ | `task_wait` | `id` or `task_id` | — | `timeout`, `tail_lines`, `done_pattern` |
394
+ | `web_fetch` | `url` | — | — |
395
+ | `register_reminder` | `name` | — | `description`, `interval_minutes` |
396
+ | `cancel_reminder` | `name` | — | — |
397
+ | `list_reminders` | — | — | — |
398
+ | `list_sessions` | — | — | `cwd` |
399
+ | `send_note` | `text` | — | `session` |
400
+ | `delegate` | `task` | — | `model`, `session`, `wait`, `timeout` |
401
+ | `delegate_result` | — | — | `session`, `timeout` |
402
+ | `ask_user_question` | `question` | — | `question`, `options`, `header`, `multi_select`, `allow_freeform` |
403
+
404
+ A `write`'s `content:` is the file text; an `edit` carries `old_text:` and
405
+ `new_text:` (its `content:` is `""`). A plugin or MCP tool's call has its
406
+ arguments whole on `args:` (string keys) instead.
407
+
371
408
  ## Bundle Hooks (unified workflow bundle)
372
409
 
373
410
  Bundles can ship executable guardrails alongside memories. A bundle with hooks lives as a directory with a `hooks/` subdirectory (flat, basename-keyed):
@@ -393,7 +430,7 @@ hooks:
393
430
  guardrails.rb:
394
431
  sha256: 1234...
395
432
  event: before_tool_call
396
- on_error: fail_closed # default for before_tool_call
433
+ on_error: fail_closed # skip (default) | log | fail_closed (before_tool_call only)
397
434
  priority: 10
398
435
  audit.rb:
399
436
  sha256: 5678...
@@ -410,12 +447,12 @@ Notes:
410
447
  - `event` is required for auto-registration; a hook with no event is skipped.
411
448
  - `sha256` is integrity (not authenticity). No signing in v1. Install records the sha256 of the copied file; at `Engine.new` a hook whose file differs is not loaded (reinstall the bundle after editing one by hand).
412
449
  - Hook code is the bundle author's source of truth: on upgrade, hooks are overwritten; if the installed file was locally modified, a warning is emitted (`was locally modified; overwriting`).
413
- - A raising `:before_tool_call` guardrail respects `on_error`: `fail_closed` denies the call (fail-closed), `log` warns, `skip` is silent.
450
+ - `on_error` defaults to `skip` for every event, `:before_tool_call` included: a raising hook is silent and the call goes ahead. `log` warns. `fail_closed` (only on `:before_tool_call`; on any other event it acts as `skip`) denies the call the hook raised on. Choose `fail_closed` for a hook that is a guardrail, whose failure should stop tool calls rather than let them through.
414
451
  - A `fail_closed` `:before_tool_call` hook is required: if it is missing, fails to load or its sha256 differs, chi denies every tool call until it is fixed.
415
452
  - A bundle can also ship YAML rules in `guardrails/*.yml`; see [Guardrails](guardrails.md#the-guardrails-bundle).
416
453
  - Ordering: bundle hooks fire by `(priority, bundle_name, hook_name)` (lower priority first), then plain `config.yml` hooks in registration order.
417
454
  - Settings: a hook class with `initialize(settings = {})` gets the bundle's section of `config.yml` `bundles:` (see [Settings](#settings)).
418
- - A bundle can also ship a `plugin.rb` whose `chi.on(event)` blocks are bundle hooks too, next to commands and tools; see [Plugins](plugins.md).
455
+ - A bundle can also ship a `plugin.rb` whose `chi.on(event)` blocks are bundle hooks too, next to commands and tools; see [Plugins](plugins.md). There `ctx.notify`, `ctx.steer` and the other `ctx` helpers act as this page's `event[:notify]`… do for the event the block runs for.
419
456
  - Shipped bundles (each in a profile: `core` or `dev`, see [Bundle profiles](memory.md#bundle-profiles-core-and-dev)): `chi bundle install guardrails` (rules, see [Guardrails](guardrails.md#the-guardrails-bundle)) and `chi bundle install known-names` (a hook, see [Guardrails](guardrails.md#the-known-names-bundle)), `chi bundle install source-links` (a hook: announces source refs, see [The source-links bundle](#the-source-links-bundle)), `chi bundle install btw` (a plugin: `/btw`, see [Plugins](plugins.md#the-btw-bundle)), `chi bundle install mcp` (a plugin: tools from MCP servers, see [Plugins](plugins.md#the-mcp-bundle)) `chi bundle install loop-guard` (a plugin: breaks tool-call loops, see [Plugins](plugins.md#the-loop-guard-bundle)), `chi bundle install check-in` (a plugin: checks on a long turn, see [Plugins](plugins.md#the-check-in-bundle)) and `chi bundle install skills` (a plugin: `/skill`, versions of skills, see [Plugins](plugins.md#the-skills-bundle)).
420
457
  - Installing a bundle executes its hook code at `Engine` startup. Only install bundles you trust, as you would a gem. Hooks are **not** executed at install time (copy-only); they are `module_eval`'d at `Engine.new` inside per-bundle `Samagotchi::Bundles::<name>` namespaces (no top-level `require` collisions). Keep hook files side-effect-free at load time; do work in `#call` — top-level side effects (require, IO, `at_exit`, global assignment) run once per `Engine.new` (class redefinition is idempotent).
421
458
 
@@ -19,5 +19,6 @@ Thought handling follows the Gemma guidance:
19
19
  - Standard multi-turn: prior model thoughts are stripped from conversation history before the next turn.
20
20
  - Function/tool-calling exception: during a single turn that includes tool calls, thoughts are not stripped between those tool-call rounds.
21
21
  - Final model output returned to the caller is thought-stripped.
22
+ - While a generation streams, the thought channel goes to the stream's `thinking:` lane, not `text:` (`ThoughtStreamSplitter.for_profile`, fresh per generation), so the UIs show it as thinking and `:generation_progress` hooks count it in `thinking_chars`.
22
23
 
23
24
  In short, raw thought blocks are treated as in-turn transient context, not durable history.
data/docs/plugins.md CHANGED
@@ -304,18 +304,33 @@ end
304
304
  ### `chi.on(event, priority: 100) { |event, ctx| … }`
305
305
 
306
306
  This is a bundle hook, the same as a `hooks/*.rb` file. See
307
- [hooks.md](hooks.md#hook-events) for the events and for what `event[:notify]`,
308
- `event[:ask_user]`, `event[:stop_turn]` and `event[:steer]` do; on `:after_turn`,
307
+ [hooks.md](hooks.md#hook-events) for the events; on `:after_turn`,
309
308
  `event[:present]` sets how the answer is shown in the web
310
309
  ([Presenting the answer](hooks.md#presenting-the-answer-display-only)). Its label is
311
310
  `plugin.rb (bundle my-bundle)`. The block may take only the event. If it
312
311
  raises, the error is logged and the hook is skipped.
313
312
 
313
+ To act from the block, use `ctx`: `ctx.notify`, `ctx.ask_user`,
314
+ `ctx.steer`, `ctx.stop_turn`, `ctx.stop_generation`. Inside the block they
315
+ act for **this event** (the `event[:notify]`… helpers that plain hooks in
316
+ [hooks.md](hooks.md) get are the same thing, and a plugin doesn't need them):
317
+
318
+ - `ctx.stop_turn` in a `:before_tool_call` block also denies the call the
319
+ event is about.
320
+ - From `:after_turn` and `:session_end` there is no turn left:
321
+ `ctx.steer`, `ctx.stop_turn` and `ctx.stop_generation` do nothing and
322
+ return false.
323
+ - In a `:generation_progress` block `ctx.ask_user` asks no one (nil).
324
+
325
+ This holds on the block's own thread while it runs. A thread the block
326
+ starts doesn't inherit the event: there the helpers act as from a command,
327
+ on the session as it is when called ([The context](#the-context-ctx)), so
328
+ `ctx.steer` from it reaches whatever turn is running then.
329
+
314
330
  #### Watching the stream
315
331
 
316
332
  `chi.on(:generation_progress)` sees a response while it streams, in batches
317
- (2000 chars or a second), and `event[:stop_generation]` (or
318
- `ctx.stop_generation`) cuts it while the turn goes on: the model is asked
333
+ (2000 chars or a second), and `ctx.stop_generation` cuts it while the turn goes on: the model is asked
319
334
  again. The rules (the thread it runs on, keeping it fast, what a cut does)
320
335
  are in [hooks.md](hooks.md#watching-the-stream).
321
336
 
@@ -328,7 +343,7 @@ class Plugin
328
343
  next if event[:thinking_chars] < LIMIT
329
344
 
330
345
  # Once per generation: a cut generation fires no more.
331
- if event[:stop_generation].call("it thought for over #{LIMIT / 1000}k chars")
346
+ if ctx.stop_generation("it thought for over #{LIMIT / 1000}k chars")
332
347
  ctx.notify("thinking too long (#{event[:elapsed_ms] / 1000} s): cut", level: :warn)
333
348
  end
334
349
  end
@@ -449,7 +464,14 @@ name order, so the first bundle keeps the name.
449
464
  ## The context: `ctx`
450
465
 
451
466
  Every handler gets the plugin's context. There is one per plugin for the
452
- session's life, and each read gives the session as it is now.
467
+ session's life, and each read gives the session as it is now. It is a
468
+ plugin's one way to talk to the user and to the turn: the helpers below act
469
+ on the session now from a command, a tool, an init task or your own thread,
470
+ and for the event inside a `chi.on` block (see `chi.on` above).
471
+
472
+ Everything a plugin shows (notices, nudges, stop notices, questions) is
473
+ named by its bundle in every UI (`my-bundle> …`); the debug log keeps the
474
+ full label, `plugin.rb (bundle my-bundle)`.
453
475
 
454
476
  | | |
455
477
  |---|---|
@@ -461,13 +483,13 @@ session's life, and each read gives the session as it is now.
461
483
  | `ctx.log` | `ctx.log.info(:event, key: value)`: debug-log records tagged `plugins`, with `bundle=<bundle>` |
462
484
  | `ctx.messages` | the conversation, as a frozen copy, without the system prompt. While a turn runs, a session worker's (attached, web) adds that turn so far: its prompt, the model's text and the lines merged into it (no tool calls or thinking); the REPL's is the conversation before that turn |
463
485
  | `ctx.messages_partial?` | whether `ctx.messages` leaves out a running turn (the REPL mid-turn), so a plugin can say what its answer is about |
464
- | `ctx.notify(text, level: :info)` | one line to the user, like a hook's `event[:notify]`, labelled by the bundle (`my-bundle> …`). Every UI shows it, during a turn (a tool, a hook) or between turns (a command) |
486
+ | `ctx.notify(text, level: :info)` | one line to the user, labelled by the bundle (`my-bundle> …`). Every UI shows it, during a turn (a tool, a hook) or between turns (a command) |
465
487
  | `ctx.card(title:, body: "", actions: [], level: :info, id: nil)` | a card in every UI, returning its id: see [Cards](#cards) |
466
- | `ctx.ask_user(question:, options:, header: nil, allow_freeform: false)` | a question, like a hook's `event[:ask_user]` |
488
+ | `ctx.ask_user(question:, options:, header: nil, allow_freeform: false)` | a single-select question through the question flow: `{selected:, freeform:, selected_indices:}`, or nil (no one to ask, cancelled, bad options) |
467
489
  | `ctx.cancelled?` | whether the running turn was cancelled (a long tool should stop) |
468
- | `ctx.steer(text)` | put text into the running turn, like a hook's `event[:steer]`: its own user message at the loop's next boundary, shown as `my-bundle> nudged: …`. Returns true when queued, false with no turn running (it never starts one; that is `ctx.sessions`' send). Dropped (logged) if the model answers or the turn ends first. Safe from any thread: an anytime command, a hook, your own |
469
- | `ctx.stop_turn(reason)` | stop the running turn after a warn notice with the reason, like a hook's `event[:stop_turn]`; true when it stopped one now |
470
- | `ctx.stop_generation(reason)` | cut the generation that is streaming, like a hook's `event[:stop_generation]`: the turn goes on and the model is asked again ([hooks.md](hooks.md#watching-the-stream)). Shows nothing: post your own notice. True when it cut one now |
490
+ | `ctx.steer(text)` | put text into the running turn: its own user message at the loop's next boundary, shown as `my-bundle> nudged: …`. Returns true when queued, false with no turn running (it never starts one; that is `ctx.sessions`' send). Dropped (logged) if the model answers or the turn ends first. Safe from any thread: an anytime command, a hook (false from `:after_turn`), your own |
491
+ | `ctx.stop_turn(reason)` | stop the running turn after a warn notice with the reason; true when it stopped one now. In a `:before_tool_call` block it also denies that call |
492
+ | `ctx.stop_generation(reason)` | cut the generation that is streaming: the turn goes on and the model is asked again ([hooks.md](hooks.md#watching-the-stream)). Shows nothing: post your own notice. True when it cut one now |
471
493
  | `ctx.ask_model(messages:, prompt:, …)` | a side answer from the session's model: see [Side answers](#side-answers-ctxask_model) |
472
494
  | `ctx.sessions` | fork, send to and read other sessions: see [Other sessions](#other-sessions-ctxsessions) |
473
495
 
@@ -915,7 +937,7 @@ with a line and carry on unchanged.
915
937
 
916
938
  `chi bundle install skills` (or the `dev` profile) installs the bundle shipped with chi
917
939
  (`lib/samagotchi/bundles/skills/plugin.rb`): one anytime command, `chi.on`
918
- hooks, `ctx.sessions.send`, `ctx.notify` and `event[:steer]`. It has no memory
940
+ hooks, `ctx.sessions.send`, `ctx.notify` and `ctx.steer`. It has no memory
919
941
  file; skills themselves work without it ([docs/memory.md](memory.md#skills)).
920
942
 
921
943
  | | |
@@ -951,7 +973,8 @@ A plain read keeps no version.
951
973
  skip it and go on without fixing the skill. In a turn that read a skill
952
974
  (`memory_read` of a `skill_*` name, or `read` of its file), the first failing
953
975
  tool call after it (an `execute` that exited non-zero, a tool error) steers
954
- the model once: *"A step of skill release failed. Find out why before skipping
976
+ the model once (a call a guardrail or the user denied doesn't count as a
977
+ failed step): *"A step of skill release failed. Find out why before skipping
955
978
  it; if the skill is out of date, fix it now: edit the step that changed in
956
979
  its file (or memory_write the whole skill) and add a Changelog line."* No
957
980
  steer once a skill read this turn has changed, by any tool. If the turn ends
data/docs/sessions.md CHANGED
@@ -19,7 +19,7 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
19
19
 
20
20
  **Attached by default:** plain `chi` and `chi --resume ID` run their session in a background worker and attach to it (`session.shared`, default `true`; env `SAMAGOTCHI_SESSION_SHARED`). The worker runs in the session's `working_directory`, so `!commands` and tools see the directory the session was started in. `session.shared: false` keeps the in-process REPL, `--no-shared` does for one run; a session the REPL has open can't be attached ("close it there first"). See [CLI: Sharing a session](cli.md#sharing-a-session).
21
21
 
22
- **Stopping a worker:** `chi sessions stop ID` marks the session stopped, sends its worker TERM and waits (up to 10 s) until the worker has let go of `owner.lock`, so a `chi --resume ID` right after spawns a fresh worker. That is also how to restart a worker still running an older chi, which the attached terminal and the web report when a command or a question dismiss gets a 404 (`BridgeClient.stale_worker_message`). The web's `POST /api/sessions/:id/stop` waits the same way, for 2 s.
22
+ **Stopping a worker:** `chi sessions stop ID` marks the session stopped (an empty `<id>/stopped` file, not a session.json field, which a worker saving at that moment would write over; a resume removes it), sends its worker TERM and waits (up to 10 s) until the worker has let go of `owner.lock`, so a `chi --resume ID` right after spawns a fresh worker. That is also how to restart a worker still running an older chi, which the attached terminal reports when a command or a question dismiss gets a 404, and the web when an answer, a dismiss, a command or a cancel does (`501 not_supported`; `BridgeClient.stale_worker_message`). The web's `POST /api/sessions/:id/stop` waits the same way, for 2 s.
23
23
 
24
24
  **Deleting a session:** `chi sessions delete ID...`, `/exit --delete` in a terminal and the Web UI's delete all go through `SessionManager.delete_session`: it resolves a unique prefix, removes `<id>.json` and the whole `<id>/` dir, and returns what it removed. A session a plain REPL has open (`owner.lock` kind `tui`) is always refused. One a worker runs is refused unless the caller asks to stop it: then it stops the worker as `chi sessions stop` does and deletes once `owner.lock` is free (`--force` on the CLI, 10 s; the web always, 2 s; `/exit --delete` after the worker agreed to exit, 10 s). A worker that outlives the wait leaves the session in place. The web's route is `DELETE /api/sessions/:id` (200 `{status: "deleted", session_id, stopped}`; 409 `owned_by_tui` or `still_stopping`; 404).
25
25
 
@@ -6,6 +6,8 @@ require_relative "model_profile"
6
6
  require_relative "bootstrap/probe"
7
7
  require_relative "bootstrap/config_writer"
8
8
  require_relative "bootstrap/bundles"
9
+ require_relative "cli/command"
10
+ require_relative "cli/flags"
9
11
 
10
12
  module Samagotchi
11
13
  # `chi bootstrap [TARGET]`: the first setup. It names a model server, works
@@ -15,6 +17,8 @@ module Samagotchi
15
17
  # system bundle and the core profile, and on a terminal asks about dev.
16
18
  # Prompts only on a terminal; a script gets the list and exit 2 instead.
17
19
  class BootstrapCommand
20
+ include CLI::Command
21
+
18
22
  USAGE = <<~TEXT
19
23
  Usage: chi bootstrap [TARGET] [--name NAME] [--model ID] [--key-env VAR] [--no-test] [--dry-run]
20
24
  Finds the model server at TARGET and writes config.yml for it.
@@ -37,6 +41,14 @@ module Samagotchi
37
41
  check-in, guardrails), and on a terminal offers the dev bundles.
38
42
  TEXT
39
43
 
44
+ FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS) do |f|
45
+ f.value "--name"
46
+ f.value "--model"
47
+ f.value "--key-env"
48
+ f.switch "--no-test"
49
+ f.switch "--dry-run"
50
+ end
51
+
40
52
  PICK_SHOWN = 20
41
53
  # A test request slower than this says it is waiting (a model loading).
42
54
  LOADING_AFTER = 3
@@ -66,8 +78,8 @@ module Samagotchi
66
78
  # @return [Integer] 0 written (or already there), 1 failed (a failed
67
79
  # test still writes the config), 2 usage or a choice to make
68
80
  def run
69
- options = parse or return 2
70
- return 0 if options[:help]
81
+ options = parse
82
+ return options if options.is_a?(Integer)
71
83
 
72
84
  key_env = options[:key_env]
73
85
  return fail!("#{key_env} is not set; export it first (the API key; chi only writes its name)") if key_env && unset?(key_env)
@@ -85,26 +97,21 @@ module Samagotchi
85
97
 
86
98
  private
87
99
 
100
+ def command_name = "chi bootstrap"
101
+ # A usage error's line ends with "(see chi bootstrap --help)".
102
+ def usage_on_error = nil
103
+
104
+ # @return [Hash, Integer] the options, or the exit status after the
105
+ # help or a usage error
88
106
  def parse
89
- options = {}
90
- until @argv.empty?
91
- arg = @argv.shift
92
- case arg
93
- when "-h", "--help", "help"
94
- @stdout.puts(USAGE)
95
- return { help: true }
96
- when "--name", "--model", "--key-env"
97
- options[arg.delete_prefix("--").tr("-", "_").to_sym] = @argv.shift or return usage_error("#{arg} needs a value")
98
- when /\A--(name|model|key-env)=(.+)\z/ then options[Regexp.last_match(1).tr("-", "_").to_sym] = Regexp.last_match(2)
99
- when "--no-test" then options[:no_test] = true
100
- when "--dry-run" then options[:dry_run] = true
101
- when /\A-/ then return usage_error("unknown option #{arg}")
102
- else
103
- return usage_error("one TARGET only (got #{options[:target]} and #{arg})") if options[:target]
107
+ parsed = parse_flags(FLAGS, @argv)
108
+ return parsed if parsed.is_a?(Integer)
104
109
 
105
- options[:target] = arg
106
- end
107
- end
110
+ options = parsed.options
111
+ target, extra = parsed.args
112
+ return usage_error("one TARGET only (got #{target} and #{extra})") if extra
113
+
114
+ options[:target] = target if target
108
115
  if options[:key_env] && !options[:key_env].match?(ConfigFile::ENV_NAME_RE)
109
116
  return usage_error("--key-env takes the variable's name (e.g. OPENROUTER_API_KEY), not the key")
110
117
  end
@@ -119,8 +126,7 @@ module Samagotchi
119
126
  candidates = begin
120
127
  Bootstrap::Probe.candidates(options[:target])
121
128
  rescue ArgumentError => e
122
- usage_error(e.message)
123
- @exit = 2
129
+ @exit = usage_error(e.message)
124
130
  return nil
125
131
  end
126
132
  result = @probe.classify_target(candidates, key_env: key_env)
@@ -251,8 +257,7 @@ module Samagotchi
251
257
  name = begin
252
258
  writer.host_name(Bootstrap::ConfigWriter.derived_name(result.candidate.host), requested: options[:name], model_ids: ids)
253
259
  rescue Bootstrap::ConfigWriter::Error => e
254
- usage_error(e.message)
255
- @exit = 2
260
+ @exit = usage_error(e.message)
256
261
  return nil
257
262
  end
258
263
  outcome = writer.write(name: name, fields: fields, model: model, dry_run: options[:dry_run])
@@ -408,10 +413,5 @@ module Samagotchi
408
413
  @exit = 1
409
414
  nil
410
415
  end
411
-
412
- def usage_error(message)
413
- @stderr.puts("chi bootstrap: #{message} (see chi bootstrap --help)")
414
- nil
415
- end
416
416
  end
417
417
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../events"
4
+
3
5
  module Samagotchi
4
6
  class Bridge
5
7
  # The last cards (Engine#show_card), hook notices (:hook_notice) and
@@ -25,9 +27,6 @@ module Samagotchi
25
27
  # A failed turn leaves no prompt, so its cards stay before the next one.
26
28
  class CardStore
27
29
  CAPACITY = 20
28
- # A failed turn's prompt goes back to the composer: no turn in the
29
- # conversation.
30
- TURN_ENDS = %i[turn_completed turn_canceled].freeze
31
30
 
32
31
  def initialize(capacity: CAPACITY)
33
32
  @capacity = capacity
@@ -79,7 +78,8 @@ module Samagotchi
79
78
  when :turn_failed
80
79
  @running = false
81
80
  settle_during(after_turn: false)
82
- when *TURN_ENDS
81
+ # The turns that stay in the conversation.
82
+ when *Events::TURN_KEPT
83
83
  @running = false
84
84
  @turns_done += 1
85
85
  settle_during(after_turn: true)
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require "fileutils"
5
5
  require_relative "../atomic_file"
6
+ require_relative "../events"
6
7
 
7
8
  module Samagotchi
8
9
  class Bridge
@@ -18,7 +19,6 @@ module Samagotchi
18
19
  # writes it, nor does a card between turns.
19
20
  class PendingCard
20
21
  FILE = "pending_card.json"
21
- TURN_ENDS = %i[turn_completed turn_canceled turn_failed].freeze
22
22
 
23
23
  # @param session_dir [String] the session's folder
24
24
  # @return [Hash, nil] {id:, bundle:}, nil without a readable file
@@ -58,7 +58,7 @@ module Samagotchi
58
58
  def fold(event)
59
59
  case event[:type]
60
60
  when :card then card(event)
61
- when *TURN_ENDS then remove
61
+ when *Events::TURN_END then remove
62
62
  end
63
63
  end
64
64