samagotchi 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/README.md +13 -2
  4. data/bin/chi +29 -39
  5. data/docs/cli.md +135 -73
  6. data/docs/configuration.md +15 -20
  7. data/docs/hooks.md +1 -1
  8. data/docs/memory.md +40 -0
  9. data/docs/plugins.md +50 -0
  10. data/docs/releasing.md +9 -6
  11. data/docs/sessions.md +3 -3
  12. data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
  13. data/lib/samagotchi/bridge/sse_writer.rb +0 -3
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  15. data/lib/samagotchi/bridge.rb +16 -11
  16. data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
  17. data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
  18. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -8
  19. data/lib/samagotchi/bundles/system/identity.md +5 -0
  20. data/lib/samagotchi/bundles/system/manifest.yml +5 -5
  21. data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
  22. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  23. data/lib/samagotchi/client.rb +16 -20
  24. data/lib/samagotchi/config.rb +40 -100
  25. data/lib/samagotchi/engine.rb +50 -354
  26. data/lib/samagotchi/kernel_loop.rb +33 -44
  27. data/lib/samagotchi/live_versions.rb +7 -1
  28. data/lib/samagotchi/llm/errors.rb +17 -0
  29. data/lib/samagotchi/llm/http.rb +4 -18
  30. data/lib/samagotchi/llm/openai_chat.rb +17 -0
  31. data/lib/samagotchi/model_profile.rb +4 -10
  32. data/lib/samagotchi/note_command.rb +2 -1
  33. data/lib/samagotchi/reply_wait.rb +48 -4
  34. data/lib/samagotchi/self_report.rb +20 -2
  35. data/lib/samagotchi/send_command.rb +84 -6
  36. data/lib/samagotchi/session.rb +4 -2
  37. data/lib/samagotchi/session_manager.rb +18 -37
  38. data/lib/samagotchi/system_prompt.rb +403 -0
  39. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
  40. data/lib/samagotchi/terminal_ui/attached_loop.rb +120 -83
  41. data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
  42. data/lib/samagotchi/terminal_ui/event_renderer.rb +23 -7
  43. data/lib/samagotchi/terminal_ui/formatting.rb +32 -22
  44. data/lib/samagotchi/terminal_ui/input_support.rb +3 -4
  45. data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
  46. data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
  47. data/lib/samagotchi/terminal_ui/surface.rb +1 -1
  48. data/lib/samagotchi/terminal_ui.rb +95 -690
  49. data/lib/samagotchi/thinking.rb +11 -0
  50. data/lib/samagotchi/tool_activity.rb +52 -2
  51. data/lib/samagotchi/tool_runner.rb +3 -0
  52. data/lib/samagotchi/tools/execute.rb +3 -3
  53. data/lib/samagotchi/tools/output_guardrails.rb +8 -7
  54. data/lib/samagotchi/tools/read.rb +4 -4
  55. data/lib/samagotchi/update_command.rb +2 -1
  56. data/lib/samagotchi/version.rb +1 -1
  57. data/lib/samagotchi/web/app.rb +170 -35
  58. data/lib/samagotchi/web/lan.rb +99 -0
  59. data/lib/samagotchi/web/message_parts.rb +15 -11
  60. data/lib/samagotchi/web/public/activity.js +7 -0
  61. data/lib/samagotchi/web/public/app.js +99 -54
  62. data/lib/samagotchi/web/public/chat_view.js +5 -1
  63. data/lib/samagotchi/web/public/index.html +163 -17
  64. data/lib/samagotchi/web/public/model_pick.js +136 -0
  65. data/lib/samagotchi/web/public/model_picker.js +224 -0
  66. data/lib/samagotchi/web/public/notify.js +10 -0
  67. data/lib/samagotchi/web/public/stage_model.js +110 -0
  68. data/lib/samagotchi/web/public/stage_view.js +580 -0
  69. data/lib/samagotchi/web/public/timing.js +6 -2
  70. data/lib/samagotchi/web/public/turn_events.js +9 -5
  71. data/lib/samagotchi/web/public/turn_model.js +11 -3
  72. data/lib/samagotchi/web/public/turn_view.js +74 -19
  73. data/lib/samagotchi/web/qr.rb +40 -0
  74. data/lib/samagotchi/web/server.rb +101 -11
  75. data/lib/samagotchi/web/token.rb +97 -0
  76. metadata +27 -3
  77. data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
@@ -28,7 +28,6 @@ server: # the model server when there is no hosts: map below
28
28
  host: 192.0.2.10
29
29
  port: 8081
30
30
  thinking:
31
- ui: spinner
32
31
  level: default # off | low | medium | high | default; see "Thinking"
33
32
 
34
33
  # Multi-host (optional): aggregated /models and per-model routing.
@@ -88,6 +87,11 @@ Behavior:
88
87
  `config: unknown key 'default.modle' (did you mean 'default.model'?)`.
89
88
  Names you choose under the maps below (host names, model ids) don't warn.
90
89
  - Environment variables and CLI flags win over config-file values.
90
+ - An edit to the file needs no restart of `chi web`: a new session's worker
91
+ reads the file when it starts, and a running chi takes a changed value the
92
+ next time it reads that setting (a worker keeps `hosts:` and what it set up
93
+ at its start until it is stopped). A worker gets the CLI flags of the chi
94
+ that started it through its environment.
91
95
  - Workers inherit hosts via `SAMAGOTCHI_HOSTS_JSON` propagated through `SessionManager.spawn_options`.
92
96
 
93
97
  This lets you run `chi` without repeating common defaults such as model
@@ -115,15 +119,9 @@ one shell (`SAMAGOTCHI_LOG_LEVEL=debug chi`); keep lasting choices in the file.
115
119
  Most settings also have a CLI flag: the dotted name in kebab case
116
120
  (`--server-read-timeout 900`); `chi --help` lists them.
117
121
 
118
- ### Legacy flat keys
119
-
120
- Older configs used the environment names as top-level keys
121
- (`SAMAGOTCHI_DEFAULT_MODEL: my-model`). They are still read, but every run
122
- warns (`config key 'SAMAGOTCHI_DEFAULT_MODEL' is legacy UPPER — use
123
- 'default.model'`). When a file has both, the nested key wins and the warning
124
- names both (`both 'SAMAGOTCHI_DEFAULT_MODEL' and 'default.model' are set;
125
- using 'default.model', remove the flat key`). Move each to its nested form (`default: {model: my-model}`) and delete
126
- the flat line. `/model --default` already writes the nested form.
122
+ An environment name used as a top-level key (`SAMAGOTCHI_DEFAULT_MODEL: my-model`,
123
+ the old flat form) is not read: it warns as an unknown key with the nested one
124
+ to use (`did you mean 'default.model'?`).
127
125
 
128
126
  ## Model Server Transport
129
127
 
@@ -409,7 +407,9 @@ What each backend gets:
409
407
  | native, `gemma4` | no `<\|think\|>` token at the start of the system prompt | no knob, one notice |
410
408
  | chat host (`api: openai`) | `chat_template_kwargs: {enable_thinking: false}` and `reasoning_effort: "none"` | `reasoning_effort: <level>` |
411
409
 
412
- On chat hosts: llama.cpp honours both off switches but ignores the effort; Splash takes `reasoning_effort` (off only
410
+ On chat hosts: llama.cpp honours both off switches but ignores the effort (when its `/props` says
411
+ `chat_template_caps.supports_reasoning_effort: false`, chi says so once per session and host, from the `/props`
412
+ answer the turn already fetched for the window); Splash takes `reasoning_effort` (off only
413
413
  through `none`) and scales with it; OpenRouter translates `reasoning_effort` per model (some can't turn thinking off:
414
414
  Qwen3-30B-A3B thinks anyway, gpt-oss refuses).
415
415
 
@@ -727,7 +727,7 @@ described in their own sections.
727
727
  | `session.max_children` | `4` | | Running delegated sessions one session may have. |
728
728
  | `session.retention_days` | `14` | yes | Delete sessions not updated for N days; `0` = forever. See [Sessions](sessions.md). |
729
729
  | `session.max_count` | `500` | yes | Keep the newest N; `0` = uncapped. |
730
- | `session.keep_status` | `running` | yes | Comma list of statuses never pruned. |
730
+ | `session.keep_status` | none | yes | Comma list of statuses never pruned (a session a worker or `chi` has open is never pruned anyway). |
731
731
  | `session.sweep_interval_hours` | `24` | yes | How often the retention sweep runs. |
732
732
  | `image.max_side` | `1568` | | See "Images". |
733
733
  | `image.max_bytes` | `3750000` | | See "Images". |
@@ -736,17 +736,12 @@ described in their own sections.
736
736
  | `log.file` | state dir | yes | See "Debug Log File". |
737
737
  | `log.disable` | `false` | yes | No file logging. |
738
738
  | `log.level` | `info` | yes | `debug`, `info`, `warn`, `error`. |
739
- | `status.line` | `on` | yes | The REPL status line, `on` or `off`. |
740
- | `status.width_mode` | `terminal_cap` | yes | `terminal_cap` (terminal width up to `max_width`) or `fixed`. |
741
- | `status.max_width` | `160` | yes | Cap for `terminal_cap`. |
742
- | `status.fixed_width` | `120` | yes | Width for `fixed`. |
739
+ | `status.line` | `on` | yes | The status row under the prompt (the REPL's and attached mode's), `on` or `off`. |
743
740
  | `context.status` | `true` | yes | Context-usage telemetry for the model. See [context telemetry](internals/context-telemetry.md). |
744
741
  | `context.window_tokens` | server's, else 256000 | yes | Context window when the server doesn't report one. |
745
742
  | `context.chars_per_token` | `4.0` | yes | Estimate ratio when the server reports no usage. |
746
743
  | `context.status_thresholds` | `20,40,60,80` | yes | Percentages that trigger a status. |
747
744
  | `context.status_cadence` | `0` | yes | Also every N rounds; `0` = thresholds only. |
748
- | `thinking.ui` | `spinner` | yes | `spinner` or `off`. |
749
- | `thinking.render_interval` | `0.08` | yes | Seconds between thinking redraws. |
750
745
  | `thinking.turn_preamble` | `true` | yes | Ask a `qwen36` model to open its thinking with a short `TURN:` line (the step label). |
751
746
  | `thinking.level` | `default` | `--thinking` | `off`, `low`, `medium`, `high` or `default` for every model; the flag and env outrank the `models:`/`hosts:` entries, the file's value doesn't. See "Thinking". |
752
747
  | `models.<key>.thinking`, `hosts.<name>.thinking` | none | | A model's or host's level. See "Thinking". |
@@ -766,9 +761,9 @@ described in their own sections.
766
761
  | `execute.preview_bytes` | `12288` | yes | |
767
762
  | `execute.telemetry_threshold_pct` | `80` | yes | |
768
763
  | `web.port` | `4567` | `--port` | `chi web`'s port. See [CLI](cli.md). |
769
- | `web.host` | `127.0.0.1` | yes | `127.0.0.1`, `::1` or `localhost`. |
764
+ | `web.host` | `127.0.0.1` | yes | `127.0.0.1`, `::1` or `localhost`; `lan` (this machine's private IPv4 address) or one of its IPv4 addresses also opens `chi web` to the network, with an access token (`chi web --new-token` replaces it). Anything else binds `127.0.0.1` with a warning. See [CLI: chi web on your phone](cli.md#chi-web-on-your-phone). |
770
765
  | `web.markdown` | `false` | yes | Render answers as Markdown in `chi web`. |
771
- | `web.turn_view` | `true` | yes | One block per turn; `false` = the row of bubbles. |
766
+ | `web.view` | `turn` | yes | How `chi web` draws a turn: `turn` (one block per turn), `stage` (the running turn pinned above the composer) or `chat` (the row of bubbles). See [CLI](cli.md#web-views). |
772
767
  | `web.annotate_presets` | `Agreed\|Could you please elaborate?` | yes | Quick replies next to Annotate in `chi web`, `\|`-separated (a YAML list works too); `""` in the file or on the CLI leaves only Annotate (an empty env value means the default). See [CLI](cli.md#web-annotate-presets). |
773
768
  | `history.file` | state dir | | Prompt history path. |
774
769
  | `no_interrupt` | `false` | `--no-interrupt` | Raise the tool-call limit of a turn to 1000; a top-level key. |
data/docs/hooks.md CHANGED
@@ -351,7 +351,7 @@ Notes:
351
351
  - Ordering: bundle hooks fire by `(priority, bundle_name, hook_name)` (lower priority first), then plain `config.yml` hooks in registration order.
352
352
  - Settings: a hook class with `initialize(settings = {})` gets the bundle's section of `config.yml` `bundles:` (see [Settings](#settings)).
353
353
  - 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).
354
- - Shipped bundles: `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)) and `chi bundle install check-in` (a plugin: checks on a long turn, see [Plugins](plugins.md#the-check-in-bundle)).
354
+ - Shipped bundles: `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)).
355
355
  - 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).
356
356
 
357
357
  Lifecycle:
data/docs/memory.md CHANGED
@@ -34,6 +34,46 @@ names of a comma list are read). Its file and index line are untouched.
34
34
  These startup index reads are harness-injected context assembly and are not
35
35
  rendered as `tool>` activity lines.
36
36
 
37
+ ## Skills
38
+
39
+ A **skill** is a memory named `skill_<name>` that holds the steps of a
40
+ repeatable task you and chi did together: a release, a deploy, a data fix.
41
+ chi is told about skills by the system bundle (`identity.md`, every turn, and
42
+ `memory_guide.md`), so this works without installing anything:
43
+
44
+ - **Saving.** Say "let's memorize this" or "save this as a skill" after the
45
+ task, and chi writes the skill with `memory_write` (project scope; system
46
+ when you ask, or when it isn't about this project) and shows it. On a vague
47
+ one ("I like how we did that") it may ask first.
48
+ - **Following.** The skill's index line (the `description:` of its
49
+ `memory_write`, "Release a new version of this repo: …") is in every
50
+ prompt, so next time chi reads the skill and follows it.
51
+ - **Updating.** When a step turned out different (a renamed script, an extra
52
+ step), chi fixes the skill in the same turn: those steps changed, the rest
53
+ kept, a dated Changelog line added. No confirmation.
54
+
55
+ A skill is plain Markdown, no frontmatter:
56
+
57
+ ```markdown
58
+ # Skill: release
59
+
60
+ ## Steps
61
+ 1. Run `scripts/verify.sh`; stop if it fails.
62
+ 2. …
63
+ ## Gotchas
64
+ - …
65
+ ## Changelog
66
+ - 2026-09-29 created
67
+ - 2026-09-30 step 1: check.sh was renamed to verify.sh
68
+ ```
69
+
70
+ It is a memory like any other: `memory_read`, `chi --mute skill_release`,
71
+ `chi bundle build` share it. The optional `skills` bundle (`chi bundle install
72
+ skills`, [docs/plugins.md](plugins.md#the-skills-bundle)) adds `/skill save`,
73
+ `/skill list`, `/skill show`, `/skill diff`, keeps older versions with a
74
+ one-line diff after each update, and nudges a model that skips a failing
75
+ step instead of fixing the skill.
76
+
37
77
  ## Bundles that need outside commands
38
78
 
39
79
  A bundle's memory can rely on a command chi doesn't ship, such as a GitHub
data/docs/plugins.md CHANGED
@@ -814,6 +814,56 @@ with a line and carry on unchanged.
814
814
  | `/checkin mode ask` / `nudge` / `notify` | the mode |
815
815
  | `/checkin nudge` / `later` / `stop` | the card's actions, also by hand |
816
816
 
817
+ ## The skills bundle
818
+
819
+ `chi bundle install skills` installs the bundle shipped with chi
820
+ (`lib/samagotchi/bundles/skills/plugin.rb`): one anytime command, `chi.on`
821
+ hooks, `ctx.sessions.send`, `ctx.notify` and `event[:steer]`. It has no memory
822
+ file; skills themselves work without it ([docs/memory.md](memory.md#skills)).
823
+
824
+ | | |
825
+ |---|---|
826
+ | `/skill save [name] [--system]` | sends this session a request to save what was just done as `skill_<name>` (chi picks a name when none is given), project scope unless `--system`. The request holds the skill's shape, so the result is the same with a model that never read the memory guide. It runs as a turn; sent while a turn runs, it joins that turn at its next step (the request says to finish the task first). An existing skill is updated. In a `--no-shared` REPL, which takes no messages, the command shows the request to send yourself |
827
+ | `/skill list` | the `skill_*` memories of both scopes, with the date and description from the index |
828
+ | `/skill show <name>` | one skill as saved (project first, as `memory_read` looks) |
829
+ | `/skill diff <name> [N]` | the skill now against its N-th newest older version (default 1: before the last change), unified |
830
+
831
+ **History.** Before `memory_write`, `write` or `edit` changes a
832
+ `skill_<name>.md` in a memories folder, the file as it was is kept under
833
+ `$XDG_STATE_HOME/samagotchi/plugins/skills/history/<scope>/<name>/` (`system`,
834
+ or `project-<project folder>`), the newest `history_keep`. It is state, not a
835
+ memory: `chi bundle build` and a synced `~/.config` never see it. After the
836
+ call a line says what happened:
837
+
838
+ ```
839
+ skills> skill release saved (project, 14 lines)
840
+ skills> skill release updated (+2 −1): 1. Run `scripts/verify.sh`; stop if it fails. · /skill diff release
841
+ ```
842
+
843
+ The line is the file on disk changing, whatever the tool answered; a denied
844
+ write shows nothing.
845
+
846
+ **The nudge** (`nudge: true`). Some models, finding a skill's step broken,
847
+ skip it and go on without fixing the skill. In a turn that read a skill
848
+ (`memory_read` of a `skill_*` name, or `read` of its file), the first failing
849
+ tool call after it (an `execute` that exited non-zero, a tool error) steers
850
+ the model once: *"A step of skill release failed. Find out why before skipping
851
+ it; if the skill is out of date, fix it now: memory_write the whole skill,
852
+ its title and every section as they were, that step fixed, a Changelog line
853
+ added."* If the turn ends with a failed step and the
854
+ skill not rewritten, one line says so: `skill release was followed, a step
855
+ failed, the skill wasn't updated`. A failure unrelated to the skill (a test
856
+ meant to fail) can set it off too: once per turn, and only after a skill was
857
+ read.
858
+
859
+ ```yaml
860
+ # config.yml
861
+ bundles:
862
+ skills:
863
+ history_keep: 20 # older versions kept per skill
864
+ nudge: true # steer once when a followed skill's step fails
865
+ ```
866
+
817
867
  ## Shutdown
818
868
 
819
869
  When the REPL exits, or a session's worker exits (an idle exit, `/exit`, a
data/docs/releasing.md CHANGED
@@ -12,9 +12,9 @@ can run the whole release; the user approves the notes before the tag and the
12
12
  and `lib/samagotchi/bundles/system/manifest.yml` are bumped together (a spec
13
13
  and `rake release:check` enforce it). The system bundle upgrades itself when
14
14
  chi starts.
15
- - **Every other shipped bundle** (btw, guardrails, known-names, loop-guard,
16
- mcp) has its own semver in its `manifest.yml` and, when it needs a newer chi,
17
- a `requires_chi:` line. They never upgrade by themselves: users run
15
+ - **Every other shipped bundle** (btw, check-in, guardrails, known-names,
16
+ loop-guard, mcp, skills, source-links) has its own semver in its
17
+ `manifest.yml` and, when it needs a newer chi, a `requires_chi:` line. They never upgrade by themselves: users run
18
18
  `chi update` (all of them, with the gem) or `chi bundle upgrade NAME`. So:
19
19
  - a change to a bundle's files bumps that bundle's `version:`
20
20
  (`rake bundles:check` fails otherwise, once there's a tag to compare with);
@@ -130,9 +130,12 @@ next patch version.
130
130
  The suite runs the same on Linux CI as on macOS, with no CI-only skips. A few
131
131
  things differ there, and a new spec that trips on them fails only on CI:
132
132
 
133
- - `CI` set marks every new session a test run, and `--all`, `list_sessions`
134
- and friends leave test runs out. A spec that lists sessions makes them with
135
- `test_run: false`.
133
+ - `CI` set marks every new session a test run, and `list_sessions` and
134
+ friends leave test runs out. A spec that lists sessions makes them with
135
+ `test_run: false`. `chi sessions list --live`/`--cwd`/`--format` and
136
+ `chi note --all` show test runs when they run as one (`CI` set counts): a
137
+ spec checking that they are hidden unsets `CI` or stubs
138
+ `Session.test_session_env?`.
136
139
  - The gems live under `vendor/bundle`: a child `ruby` started with a bare env
137
140
  (`unsetenv_others: true`) needs `GEM_HOME`/`GEM_PATH` to find nokogiri.
138
141
  - Ruby 3.3's zlib raises `Zlib::BufError` when a thread interrupt lands in a
data/docs/sessions.md CHANGED
@@ -56,12 +56,12 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
56
56
 
57
57
  **Projects:** a session belongs to the git project it was started in: the repository, whichever worktree or subfolder of it (the same project root memories use). `chi sessions list` (with `--live` and `--format` too) and `chi web` show the current folder's project; `--scope=all` shows every session, and so does a folder in no repo (`~`). `--cwd PATH` is a folder filter instead of the project. The project is stored with the session (`project_root` in its JSON, `project` in `--format json`), so a session keeps it after its worktree is deleted; sessions older than that are placed by their folder, and one whose folder is gone shows in `--scope=all` only. Retention, `--resume`/`--attach ID`, `chi send`/`chi note ID` and `chi note --all` (every live session) are not scoped. The agent's `list_sessions` lists its own project's sessions; `cwd: "/"` lists every one.
58
58
 
59
- `--live`, `--cwd` and `--format` make `list` a picker for scripts (`SessionManager.session_summaries`): `--live` keeps the sessions a worker runs now (the owner lock, not the saved status; a session open in a plain REPL is left out), `--cwd PATH` those in PATH or below, and test runs are left out. `--live` shows 10 unless `--limit` says otherwise; filters apply before the limit. `--format json` prints `[{id, short_id, desc, cwd, project, updated_at, live, busy, owner, recap, parent_id, archived, scratch, ctx_pct}]` (`owner`: `"worker"`, `"tui"` for a plain REPL, which takes no notes or messages, or null; `recap`: the first sentence of the session's recap, or null), `--format tsv` one `id<TAB>desc` line per session, where `desc` is `<folder> · <last prompt>` cut to 60 characters (the text form of `--live`/`--cwd` shows `<folder> · <recap>` when there is one; tsv and json keep `desc`).
59
+ `--live`, `--cwd` and `--format` make `list` a picker for scripts (`SessionManager.session_summaries`): `--live` keeps the sessions a worker runs now (the owner lock, not the saved status; a session open in a plain REPL is left out), `--cwd PATH` those in PATH or below, and test runs are left out unless the list is itself run as one (`SAMAGOTCHI_ENV=test`, `RACK_ENV=test` or `CI` set: then they show, marked `[test]`; `chi note --all` likewise). `--live` shows 10 unless `--limit` says otherwise; filters apply before the limit. `--format json` prints `[{id, short_id, desc, cwd, project, updated_at, live, busy, owner, recap, parent_id, archived, scratch, ctx_pct}]` (`owner`: `"worker"`, `"tui"` for a plain REPL, which takes no notes or messages, or null; `recap`: the first sentence of the session's recap, or null), `--format tsv` one `id<TAB>desc` line per session, where `desc` is `<folder> · <last prompt>` cut to 60 characters (the text form of `--live`/`--cwd` shows `<folder> · <recap>` when there is one; tsv and json keep `desc`).
60
60
 
61
61
  **Ordering:**
62
62
 
63
63
  - `Session.list` / `SessionManager.list_sessions` / `GET /api/sessions?sort=&order=&limit=&offset=` default to `updated_at desc` (newest activity first). Also supports `created_at`, `asc`. `X-Total-Count` header when paginated.
64
- - Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new chats start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new chat there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own project). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat; "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). The info bar copies `chi --attach <id>` for a terminal. The start page's model picker (bottom left of the composer, `host:model ▾`) chooses the model a new chat starts on: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id}], warning?}`; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first command line (`/model …`) still starts the session as its `prompt`. The browser remembers the last choice; a running session's model is in the info bar and changes only with `/model`.
64
+ - Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new chats start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new chat there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own project). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat; "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). The info bar copies `chi --attach <id>` for a terminal. The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new chat starts on; it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first; typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id}], warning?}`; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first command line (`/model …`) still starts the session as its `prompt`. The browser remembers the last choice; a running session's model is in the info bar and changes only with `/model`.
65
65
  - Web notifications: when a session needs you while the tab is hidden or behind another window (a question or a guardrail approval, a plugin card with buttons during a turn such as check-in's, a failed turn, a turn done after 10 s or more; a delegated session only for its questions, a canceled turn never), the page title counts it, `(N) Chi`, until you come back to the tab. The bell at the right of the top bar also turns on OS notifications ("needs an answer", "needs approval", "needs you", "turn failed", "done in 42 s", under the session's first message; a click opens the session): its first click asks the browser for the permission, and the choice is kept per browser. Any session of the page's scope counts, not only the open one. What was already so when the page loaded never counts, and several chi tabs show one notification per event. A chi tab in front tells the others (a `BroadcastChannel`), so while you look at one, the ones behind neither notify nor count what it shows; coming back to a tab clears those events in the other tabs' titles too. The hub's `session` frames carry what this needs: `pending_question` (`{id, kind}`, kind `question` or `approval`), `pending_card` (`{id, bundle}`: the running turn's card with actions, from `pending_card.json` that the worker keeps in the session's folder while one is open; a card without actions, such as loop-guard's, never counts) and `last_turn` (`{outcome, ended_at, seconds, origin}`, saved in the session's JSON as the engine ends each turn).
66
66
  - The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `format.js` (pure formatters such as `previewOf`). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
67
67
  - Selecting a session in the web UI is read-only: `GET /api/sessions/:id` never spawns a worker (it reads a live worker's snapshot when one runs, else the session file). A prompt (`POST /turn`) or a command (`POST /command`) wakes the worker, and `/stream` briefly waits for a freshly-spawned bridge before answering. A caught-up SSE reconnect holds the stream open; `reset` markers are only sent for reconnects behind the ring window, or with a cursor from another worker (event ids are `<event_seq>-<epoch>`, one epoch per worker).
@@ -146,7 +146,7 @@ pbpaste | chi send 3fa2 # the clipboard is the messa
146
146
  - It goes through the same path as the web composer (`SessionManager.deliver_turn`): the worker's Bridge when it is up, so every attached UI shows it as your message (client id `cli:send`, shown like any user message); the input file when the worker is on its way out. During a running turn it is merged into that turn at the next step, like a message typed then.
147
147
  - A session with no worker gets one started (like `--attach` or the web composer). A session open in a plain REPL (`--no-shared`) refuses it. Only sessions on this machine: Bridges listen on 127.0.0.1.
148
148
  - Fire and forget: it returns once the message is queued and never prints the answer; that shows in whatever is attached. A guardrail "ask" waits for a UI to answer it, so with nothing attached the turn stalls there until one attaches (`chi --attach ID`).
149
- - `--image PATH` (repeatable, up to 20) sends images with the message, as the web composer's chips do. Each file is read once before anything is sent (bmp, tiff and heic converted to png, large ones downscaled, as for `@path`); a missing file or one that isn't an image is a usage error (exit 2) and nothing is sent. Each session gets its own copy in `<session>/images/`. Text is still required (`-m` or stdin; context alone counts), also with `--wait`. The session's model must see images: a text-only one fails the turn in the session (the line here still says `sent`). A running turn doesn't take images mid-turn: the message runs as the next turn, and the line says `(runs after the current turn)`. With `--new` the session starts idle with the message as its preview, then the message goes in as its first turn once its worker is up (`<id> started with 1 image`); if the worker doesn't come up in 5 s the session is kept, with its id on the `failed:` line.
149
+ - `--image PATH` (repeatable, up to 20) sends images with the message, as the web composer's chips do. Each file is read once before anything is sent (bmp, tiff and heic converted to png, large ones downscaled, as for `@path`); a missing file or one that isn't an image is a usage error (exit 2) and nothing is sent. Each session gets its own copy in `<session>/images/`. Text is still required (`-m` or stdin; context alone counts), also with `--wait`. The session's model must see images: one known not to (the host's model list says text-only, `vision: false` in `models:` or `hosts:`, a native llama.cpp without a vision model) is refused before anything is sent, with the reason (`<id> refused: gemma can't take images (…); send text only or switch the model (/model)`); the other sessions of the same send still get it, and the exit is 1. With `--new` nothing is started. When chi can't tell (a native host whose `/props` doesn't answer, a list that doesn't say), the message is sent as before and a text-only model fails the turn in the session. A running turn doesn't take images mid-turn: the message runs as the next turn, and the line says `(runs after the current turn)`. With `--new` the session starts idle with the message as its preview, then the message goes in as its first turn once its worker is up (`<id> started with 1 image`); if the worker doesn't come up in 5 s the session is kept, with its id on the `failed:` line.
150
150
  - One line per session: `sent`, `sent with 2 images`, `sent (the running turn picks it up)`, `sent (started its worker)`, `refused: …` or `failed: …`. Exit 0 when all were sent, 1 when any was refused, failed or not found, 2 for a usage error. There is no `--all`.
151
151
 
152
152
  The Automator action above works for messages too: swap its last line for `pbpaste | "$chi" send -m "what do you make of this?" $(print -r -- "$picked" | cut -f1)`.
@@ -243,14 +243,13 @@ module Samagotchi
243
243
 
244
244
  section = data["default"]
245
245
  value = section.is_a?(Hash) ? section["model"] : nil
246
- value ||= data[ConfigFile::DEFAULT_MODEL_KEY]
247
246
  value.to_s.strip.empty? ? nil : value.to_s.strip
248
247
  end
249
248
 
250
249
  # Whether bare model names go somewhere today: a default.model or a
251
250
  # server: section. Then a new hosts: block keeps that route as `default`.
252
251
  def routed?
253
- configured_default_model || data.key?("server") || data.keys.any? { |key| key.to_s.start_with?("SAMAGOTCHI_SERVER_") }
252
+ configured_default_model || data.key?("server")
254
253
  end
255
254
 
256
255
  # The `default` hosts entry chi derives from server.* when the file has
@@ -187,9 +187,6 @@ module Samagotchi
187
187
  "Cache-Control: no-cache\r\n" \
188
188
  "Connection: keep-alive\r\n" \
189
189
  "X-Accel-Buffering: no\r\n" \
190
- "Access-Control-Allow-Origin: *\r\n" \
191
- "Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n" \
192
- "Access-Control-Allow-Headers: Content-Type, Last-Event-ID\r\n" \
193
190
  "\r\n"
194
191
  )
195
192
  io.flush
@@ -146,6 +146,7 @@ module Samagotchi
146
146
  part = { kind: "tool", iteration: event[:iteration], call_index: event[:call_index],
147
147
  tool: event[:tool], params: event[:params], status: "running" }
148
148
  part[:label] = event[:label] if event[:label]
149
+ part[:title] = event[:title] if event[:title]
149
150
  parts << part
150
151
  when :tool_call_completed
151
152
  tool = parts.reverse_each.find do |part|
@@ -305,8 +305,8 @@ module Samagotchi
305
305
  if request[:too_large]
306
306
  write_json(io, 413, { "Connection" => "close" }, { error: "too_large", detail: "request body over #{MAX_BODY_BYTES} bytes" })
307
307
  break
308
- elsif method == "OPTIONS"
309
- write_json(io, 204, cors, {})
308
+ elsif browser_request?(headers)
309
+ write_json(io, 403, nil, { error: "cross_origin", detail: "the bridge answers chi's own clients only" })
310
310
  elsif (m = stream_match(request[:path])) && method == "GET"
311
311
  cursor = reconnect_cursor(headers, request[:query])
312
312
  Log.debug(:bridge, "stream", method: method, path: request[:path], client_id: stream_client_id(request[:query]))
@@ -344,7 +344,7 @@ module Samagotchi
344
344
  payload, status, body = handle_snapshot(m[1])
345
345
  write_json(io, status, payload, body)
346
346
  else
347
- write_json(io, 404, { "Allow" => "GET, POST, OPTIONS" },
347
+ write_json(io, 404, { "Allow" => "GET, POST" },
348
348
  { error: "not_found", path: request[:path] })
349
349
  end
350
350
 
@@ -414,12 +414,18 @@ module Samagotchi
414
414
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
415
415
  end
416
416
 
417
- def cors
418
- {
419
- "Access-Control-Allow-Origin" => "*",
420
- "Access-Control-Allow-Methods" => "GET, POST, OPTIONS",
421
- "Access-Control-Allow-Headers" => "Content-Type, Last-Event-ID"
422
- }
417
+ LOOPBACK_NAMES = %w[127.0.0.1 [::1] localhost].freeze
418
+
419
+ # Only chi's Ruby clients talk to the Bridge, and they send no Origin and
420
+ # no Sec-Fetch-Site. A browser page always sends one of them (another
421
+ # website's text/plain POST needs no preflight), and a DNS-rebound page
422
+ # also has a foreign Host. Headers are the raw ones, lowercased.
423
+ def browser_request?(headers)
424
+ return true if headers.key?("origin")
425
+ return true if headers.key?("sec-fetch-site") && headers["sec-fetch-site"].downcase != "none"
426
+
427
+ host = headers["host"].to_s
428
+ !host.empty? && !LOOPBACK_NAMES.include?(host.downcase.sub(/:\d*\z/, ""))
423
429
  end
424
430
 
425
431
  def stream_match(path)
@@ -893,8 +899,7 @@ module Samagotchi
893
899
  "Content-Type" => "application/json",
894
900
  "Content-Length" => data.bytesize.to_s,
895
901
  "Connection" => "close",
896
- "Cache-Control" => "no-store",
897
- "Access-Control-Allow-Origin" => "*"
902
+ "Cache-Control" => "no-store"
898
903
  }.merge(extra_headers)
899
904
 
900
905
  io.write("HTTP/1.1 #{status} #{reason}\r\n")
@@ -0,0 +1,10 @@
1
+ ---
2
+ name: skills
3
+ version: 0.1.0
4
+ scope: system
5
+ description: "Skills (skill_<name> memories, the steps of a task done together): /skill save, list, show and diff; older versions kept with a short diff line on every update; a nudge when a followed skill's step fails"
6
+ trust_level: reviewed
7
+ plugin:
8
+ file: plugin.rb
9
+ sha256: sha256:76758d41e75db107e0a6f7ce35b9eac4944052746af16aeba30f38210cc436dc
10
+ requires_chi: ">= 0.4.0"