samagotchi 0.28.0 → 0.30.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -1
  3. data/docs/cli.md +4 -4
  4. data/docs/hooks.md +1 -1
  5. data/docs/plugins.md +1 -1
  6. data/docs/sessions.md +3 -3
  7. data/lib/samagotchi/bundle_command.rb +4 -2
  8. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  9. data/lib/samagotchi/bundles/mcp/plugin.rb +4 -3
  10. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +17 -4
  11. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/command_steps.rb +35 -15
  14. data/lib/samagotchi/engine.rb +4 -3
  15. data/lib/samagotchi/guardrails/shell_lex.rb +79 -5
  16. data/lib/samagotchi/host_registry.rb +4 -0
  17. data/lib/samagotchi/kernel_loop.rb +27 -7
  18. data/lib/samagotchi/llm/chat_loop.rb +12 -6
  19. data/lib/samagotchi/memory_bundle/installer.rb +14 -0
  20. data/lib/samagotchi/model_list_store.rb +117 -0
  21. data/lib/samagotchi/model_profile.rb +43 -0
  22. data/lib/samagotchi/send_command.rb +5 -3
  23. data/lib/samagotchi/session.rb +5 -0
  24. data/lib/samagotchi/session_commands.rb +6 -2
  25. data/lib/samagotchi/session_manager.rb +9 -2
  26. data/lib/samagotchi/session_metrics.rb +6 -3
  27. data/lib/samagotchi/terminal_ui/formatting.rb +7 -4
  28. data/lib/samagotchi/terminal_ui.rb +35 -5
  29. data/lib/samagotchi/tool_activity.rb +38 -8
  30. data/lib/samagotchi/tools/builtins.rb +10 -7
  31. data/lib/samagotchi/tools/delegate.rb +14 -10
  32. data/lib/samagotchi/tools/execute.rb +3 -1
  33. data/lib/samagotchi/tools/task_runtime.rb +21 -0
  34. data/lib/samagotchi/turn_note.rb +10 -0
  35. data/lib/samagotchi/version.rb +1 -1
  36. data/lib/samagotchi/web/public/activity.js +2 -1
  37. data/lib/samagotchi/web/public/app.js +23 -11
  38. data/lib/samagotchi/web/public/command_view.js +19 -5
  39. data/lib/samagotchi/web/public/index.html +51 -25
  40. data/lib/samagotchi/web/public/sessions_list.js +21 -4
  41. data/lib/samagotchi/web/public/stage_model.js +23 -2
  42. data/lib/samagotchi/web/public/stage_view.js +16 -11
  43. data/lib/samagotchi/web/public/task_stop.js +11 -2
  44. data/lib/samagotchi/web/public/timing.js +12 -7
  45. data/lib/samagotchi/web/public/turn_events.js +3 -1
  46. data/lib/samagotchi/web/public/turn_model.js +29 -15
  47. data/lib/samagotchi/web/public/turn_view.js +70 -11
  48. data/lib/samagotchi/worker.rb +24 -7
  49. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 51c7c1a00eec47f70f15b04381eb6b60f5bd6c66c8dd11fbe05ef045c41d5219
4
- data.tar.gz: 00cbfcba1b3b21ef54c9d54bd9de349a90a19314cb5613e9daa94ca51c8c04fe
3
+ metadata.gz: 7af50a3a7e90170a9d6b4f3f5ab5914a0ab536ea121b498779109dabfb7c0b51
4
+ data.tar.gz: 4ddba29c753f1a6900956cc5abbbef70c3d912286e252c4c9adf08534a773368
5
5
  SHA512:
6
- metadata.gz: 11f14c97b088efe1c1eafafb63604f44c84a7a7a79d738db5f6344191b6fa9948ceab37acd0f8daba2a85840b3f272bfcd767bd2147b0da7abbc23a04936bba4
7
- data.tar.gz: e3136af1decc273b092f66756e2994ad0760a626cb9b007a4a4ad2f68ee85bdf6f37aab3f5a0bde85597f677bde3fde32826763c9b44001e941945abc39dfa5d
6
+ metadata.gz: 497f4731e7a4ca9c1bd5073bea8e897506d37a5fcdfd15ea394f5837edfc85d76fbaade739ec4f6de998f7da5a982c3a8a9c1d4674b1e218a50ab14fe673291b
7
+ data.tar.gz: 0c6428021607e8540dcb21801510eca0295be3fba2de0e09eb4c2fd3c2c362326301065b52c5bc7745e006768522312d1934659da4cacc93254560402c767e76
data/CHANGELOG.md CHANGED
@@ -8,6 +8,87 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.30.0] - 2026-10-05
12
+
13
+ ### Added
14
+
15
+ - chi saves the model ids each host last listed (`~/.local/state/samagotchi/model_lists.json`), so a command that
16
+ starts a session without listing the hosts itself knows what they serve.
17
+ - `chi send --new --model` and the `delegate` tool's `model:` warn about a model id the named host's saved list
18
+ doesn't have: `host 'box' doesn't list model 'gemma-smal' (did you mean: gemma-small?); started it anyway`, on
19
+ stderr / in the tool's result. The session still starts: some hosts serve ids they don't list (a one-model
20
+ llama.cpp server takes any name, OpenRouter's `:nitro`). A name that names no host, a host with no saved list, and
21
+ a list over a week old are not checked.
22
+
23
+ ### Changed
24
+
25
+ - Web: an open step whose label is its narration's first line or its first call's title shows only its call count,
26
+ so it doesn't repeat the words its body starts with; closed, the label is unchanged.
27
+ - Web stage: an answered turn says its step count once (in the cloud's chip) and its time once (in the status row).
28
+ - Web stage: `execute` and the task tools are blue in the running-tool row and the ticks, as in the tool rows under
29
+ them; purple is thinking's colour only (other tools' ticks are a quieter blue-grey).
30
+ - Web stage: `edit` and `write` are blue in the running-tool row and the ticks too, as in the tool rows; green is a
31
+ finished call's `✓` and an answered turn's only.
32
+
33
+ ### Fixed
34
+
35
+ - A command the user's Stop killed (or kept from starting) is saved as `stopped`, not `error`: the web shows it with
36
+ ■ and `stopped`, and the turn's `(N failed)` and the session's tool errors no longer count it.
37
+ - Web: the stop-task confirm names a task from an earlier turn by its command (the wait row's title), not its id.
38
+ - Web: a canceled turn's end line (the Stop, Ctrl-C, a hook, the session's stop) reads `■ canceled (stopped)` in the
39
+ stopped calls' amber, live and after a reload, not as a red error; a worker that exited mid-turn stays red `✕`.
40
+ - Web stage: a call the Stop cut has an amber tick and an amber `■` in the trail, as its `■ STOPPED` row, instead of
41
+ its tool kind's colour.
42
+ - A call still running when its turn is canceled is saved in the session's tool records as `stopped` (was
43
+ `canceled`), the word its row uses; one cut by a failed turn stays `canceled`.
44
+ - A canceled turn's last step keeps the thinking it streamed, so reloading it shows the thought instead of an empty
45
+ step (both loops).
46
+ - A plain REPL (`--no-shared`) keeps its session's `pending_card.json` as a worker does, so `chi web` marks a session
47
+ waiting on an open card it showed.
48
+ - source-links: a ref that names its own repo (`other/repo#12`) takes `{host}` from the git remote only when the
49
+ source's `remote_host:` lists that host, so it can't link to the local checkout's host.
50
+ - Web stage: the running-tool row no longer reads `thinking…` once the turn has ended (answered, canceled or
51
+ failed): it is idle, with no word.
52
+ - The REPL and attached mode end a canceled turn with an amber `■` (`■ turn canceled (Ctrl-C)`, `■ turn stopped by
53
+ loop-guard`), as the web does, instead of `✕`: a stop someone chose, not an error; a failed turn keeps `✕`.
54
+ - Web stage: a call a guardrail blocked has a red tick and a red `✕` in the trail, as its `✕ BLOCKED` row, instead
55
+ of its tool kind's colour.
56
+ - A command step with more than one heredoc (`-f body="$(cat <<EOF …)" -f title="$(cat <<EOF …)"`) cuts every body
57
+ out of its text, and its chip says so: `EOF · 3 lines +1`, with all of them on hover.
58
+ - A session worker survives a failed save between turns too (its first prompt, a command, taken notes, a dropped
59
+ question): it logs it and keeps going; notes whose save failed are kept and saved on the next pass.
60
+
61
+ Update with `chi update` (source-links 0.3.4, mcp 0.4.4) and restart `chi web`.
62
+
63
+ ## [0.29.0] - 2026-10-05
64
+
65
+ ### Added
66
+
67
+ - Web: a session card shows `looped` when loop-guard stopped its last turn (`stopped by <name>` for another hook),
68
+ like `chi sessions list`'s `[looped]`; the all-sessions search finds them by those words.
69
+ - `chi bundle install` warns when a bundle needs a newer chi and its guardrail rules won't load (it already warned
70
+ about its hooks and plugin).
71
+
72
+ ### Changed
73
+
74
+ - Web: a tool row shows its status as a mark (✓, ✕, ■, a spinner while it runs) instead of the word, so its command,
75
+ output and diff start right under the tool name; error, stopped and blocked calls keep their word after the title.
76
+ An open step's body sits indented under its summary.
77
+ - Web: a step with a failed call says so in its collapsed label, `3 tool calls (1 failed)`, with a red chevron; the
78
+ turn's summary counts failed calls below 3 calls too.
79
+ - Web: a `task_wait`, `task_get` or `task_stop` row names its task by the task's command instead of its id, a wait with
80
+ how long it waits (`bundle exec rspec · up to 600s`), also for a task started in an earlier turn; the id line stays
81
+ on hover.
82
+
83
+ ### Fixed
84
+
85
+ - Guardrails: `> /tmp/x cat <<EOF` (a redirection written with a space before the command) is read as a heredoc of
86
+ data like `cat > /tmp/x <<EOF`, and a `)` inside quotes or a heredoc body no longer ends a `$(…)` early.
87
+ - `!cmd` no longer hands a `--model` worker's model to a chi it runs as the default model.
88
+ - A session worker survives a failed save at the end of a turn (it logs it) instead of crashing.
89
+ - A turn that ends with an empty answer removes only its own retry note, not an earlier turn's cancel or failure note.
90
+ - Web: streamed chunks from llama.cpp no longer carry its whole echoed prompt to every web client.
91
+
11
92
  ## [0.28.0] - 2026-10-04
12
93
 
13
94
  ### Added
@@ -1471,7 +1552,9 @@ and long-lived sessions.
1471
1552
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
1472
1553
  hotkey panel that send selected text or the clipboard to your sessions.
1473
1554
 
1474
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.28.0...HEAD
1555
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.30.0...HEAD
1556
+ [0.30.0]: https://github.com/dm1try/samagotchi/compare/v0.29.0...v0.30.0
1557
+ [0.29.0]: https://github.com/dm1try/samagotchi/compare/v0.28.0...v0.29.0
1475
1558
  [0.28.0]: https://github.com/dm1try/samagotchi/compare/v0.27.0...v0.28.0
1476
1559
  [0.27.0]: https://github.com/dm1try/samagotchi/compare/v0.26.0...v0.27.0
1477
1560
  [0.26.0]: https://github.com/dm1try/samagotchi/compare/v0.25.0...v0.26.0
data/docs/cli.md CHANGED
@@ -849,12 +849,12 @@ While a turn runs, you can cancel it without exiting the process:
849
849
  Behavior notes:
850
850
 
851
851
  - Cancellation returns control to the prompt immediately; what you typed there stays.
852
- - The turn ends with one line, `✕ turn canceled (Ctrl-C) · 3.1s`, and for a prompt turn a dim
852
+ - The turn ends with one line, `■ turn canceled (Ctrl-C) · 3.1s` (`■`, a stop someone chose, in amber), and for a prompt turn a dim
853
853
  `partial progress kept; !rollback restores the pre-turn state` under it (a canceled continue is back where it
854
854
  started). A failed turn ends with `✕ turn failed: <summary> · 2.0s` and a dim `prompt restored for retry`. The
855
- REPL and attached mode say the same; the web says `✕ canceled (Ctrl-C)` (`stopped` for its Stop button,
856
- `by a hook` for a hook's). A turn a bundle's plugin stopped names it: `✕ turn stopped by loop-guard` (the web:
857
- `✕ stopped by loop-guard`).
855
+ REPL and attached mode say the same; the web says `■ canceled (Ctrl-C)` (`stopped` for its Stop button,
856
+ `by a hook` for a hook's). A turn a bundle's plugin stopped names it: `■ turn stopped by loop-guard` (the web:
857
+ `■ stopped by loop-guard`).
858
858
  - Visible text the canceled request had streamed stays in the conversation, marked `[interrupted]`, so the next
859
859
  message (or a continue) picks up from the half-finished reply; the canceled request's thinking and any unfinished
860
860
  tool call are dropped.
data/docs/hooks.md CHANGED
@@ -179,7 +179,7 @@ What a cut is, in detail:
179
179
  - It is an empty answer made early: it uses the `retry.empty_answer` budget
180
180
  ([configuration.md](configuration.md#llama-network-retry-behavior)). With
181
181
  no retry left (`retry.empty_answer: 0`, or already used) and nothing
182
- queued, the turn ends cancelled (hook): "✕ turn canceled (hook)".
182
+ queued, the turn ends cancelled (hook): "■ turn canceled (by a hook)".
183
183
  - It shows nothing by itself: post your own notice (`event[:notify]`) to say
184
184
  why. The bundle and the reason go into the note the model reads and the
185
185
  log (`generation_stopped`).
data/docs/plugins.md CHANGED
@@ -863,7 +863,7 @@ What happens (`action: retry`, the default):
863
863
  `loop-guard> thinking repeats itself ("Wait, the count of the letter r…", 3 sentences ×3, 4k chars, 8 s): cut`
864
864
  (a short run or a window says how many different sentences it held:
865
865
  "12 different sentences in 48"), and `↻ cut by loop-guard, asking again (1/1)`.
866
- 2. If the retry loops too, the turn is stopped ("stopped the turn: …", "✕
866
+ 2. If the retry loops too, the turn is stopped ("stopped the turn: …", "■
867
867
  turn stopped by loop-guard") with a card that quotes the repeated
868
868
  sentences.
869
869
  3. A loop is forgotten after `forget_after` good steps in a row (generations
data/docs/sessions.md CHANGED
@@ -56,7 +56,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
56
56
 
57
57
  `--dry-run` is the safe preview. `--keep N` keeps the N newest sessions that stay: one that goes anyway (a leftover scratch session, one left empty, one older than `--days`) takes no place in the count. Web has no prune endpoint; use the CLI.
58
58
 
59
- `chi sessions list` shows each session's saved recap, its first sentence without the "The user was…" opening (as on the web cards), in place of its last prompt, cut to 60 characters; a session with no recap shows its last prompt. A session whose last turn a hook stopped carries `[looped]` when the hook was loop-guard (the thinking watch's cut, or its stop) and `[stopped by <hook>]` for any other hook, after `[archived]` (`--format json` has it as `stopped_by`; from `Session#last_turn`, so it stays until a later turn ends differently). A delegated session's row ends with `↳ <parent's short id>` (plain and `--live`; `--format json` has `parent_id`); rows keep their `updated_at` order, so a parent may be on another page than its children. After the status, `ctx 12%` says how full the context was after the session's last counted turn (from its `analytics.json`; blank when no turn had a server count or the window is unknown; `ctx_pct` in `--format json`). The web's session cards show it quietly too (`12%`), and a session's ctx meter shows it on load, before its next turn streams.
59
+ `chi sessions list` shows each session's saved recap, its first sentence without the "The user was…" opening (as on the web cards), in place of its last prompt, cut to 60 characters; a session with no recap shows its last prompt. A session whose last turn a hook stopped carries `[looped]` when the hook was loop-guard (the thinking watch's cut, or its stop) and `[stopped by <hook>]` for any other hook, after `[archived]` (`--format json` has it as `stopped_by`; from `Session#last_turn`, so it stays until a later turn ends differently). The web's session cards wear the same marker as a badge: `looped` or `stopped by <hook>` after the status dot (a session that also waits on you shows only its waiting badge: the stop is behind the need), and the all-view's search matches its words too. A delegated session's row ends with `↳ <parent's short id>` (plain and `--live`; `--format json` has `parent_id`); rows keep their `updated_at` order, so a parent may be on another page than its children. After the status, `ctx 12%` says how full the context was after the session's last counted turn (from its `analytics.json`; blank when no turn had a server count or the window is unknown; `ctx_pct` in `--format json`). The web's session cards show it quietly too (`12%`), and a session's ctx meter shows it on load, before its next turn streams.
60
60
 
61
61
  **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.
62
62
 
@@ -168,7 +168,7 @@ chi send --wait -m "and the tests?" 3fa2 # a follow-up in the same
168
168
  chi send --wait 3fa2 # sends nothing: waits for its next reply
169
169
  ```
170
170
 
171
- - `--new` takes no ids (one new session per call). `--dir DIR` is its folder, and so its project (default the current one); `--model M` its model (default the configured one; the name isn't checked up front, a wrong one fails the worker's first turn).
171
+ - `--new` takes no ids (one new session per call). `--dir DIR` is its folder, and so its project (default the current one); `--model M` its model (default the configured one). A host config.yml doesn't have is refused before anything is spawned (exit 1). A `host:id` whose id that host's last saved list doesn't have (`~/.local/state/samagotchi/model_lists.json`, what `chi models` / `/models` / the web last listed for that host, at most a week old) gets one warning line on stderr, with up to three close ids as a hint (`did you mean: gemma-small?`), and the session starts anyway: some hosts serve ids they don't list (a one-model llama.cpp server takes any name). A bare id (no `host:`) isn't checked that way — routing sends it to whichever host lists it — and with no saved list for the host nothing is said; a wrong id then fails the worker's first turn.
172
172
  - `--new` prints `<full id> started` on stdout. With `--wait` that line (and the `sent` line for an existing session) goes to stderr, so stdout is exactly the answer: the reply of the turn the message started, in full. It is the worker's `output/` file, the same one `delegate` reads; a turn that ends with only tool calls and no text has none.
173
173
  - `--wait` takes one session: `--new` or one id. A session with a running turn is refused (`busy: a turn is running; wait or attach`, exit 1): the message would run after it, and its reply would come back as the answer.
174
174
  - Exit codes with `--wait`: 0 answered; 3 the turn waits for an answer from you (a question, a hook's question, a guardrail approval, or the step-limit question: see [chi as a sub-agent](sub-agent.md#the-step-limit)), and the session keeps waiting: stderr gets the whole question (`waiting for an answer (approval): …`, its text, the numbered options, whether free text is allowed) and how to answer it (`chi answer ID --question QID --option N`, or `chi --attach ID`, the web); 4 `--timeout S` passed with the turn still running (it goes on; wait again); 1 the turn ended without a reply (canceled, failed or empty), the worker failed or vanished, or the session was stopped; 130 on Ctrl-C, which leaves the turn running (`still running: chi --attach ID`). There is no default timeout.
@@ -179,7 +179,7 @@ chi send --wait 3fa2 # sends nothing: waits fo
179
179
 
180
180
  ## Delegating
181
181
 
182
- The `delegate` tool hands a task to a **child session**: an ordinary chi session in a worker of its own, started in the parent's `working_directory` with the task, verbatim, as its first user message, on the parent's model unless the call names one (`model:` takes a name or an alias, resolved as `--model` is; nothing checks the host serves it, so an unknown one fails the child's first turn). Children run in parallel and only their **final reply** comes back to the parent: the child's newest `output/<timestamp>.txt` file, which the worker writes at the end of each turn that produced visible text, cut head-and-tail like an `execute` result. Nothing of the child's trace enters the parent's context. Nothing is hidden: a child shows in `chi sessions list` (`↳ <parent>`), the web (a `↳` chip on its card, `delegated by` in its info bar, listed right after its parent in the all-sessions view) and the `list_sessions` tool (`child`; the parent shows as `parent` in the child's own list); the user can `chi --attach <child id>` and steer it mid-run, and `chi send` and `send_note` reach it like any session.
182
+ The `delegate` tool hands a task to a **child session**: an ordinary chi session in a worker of its own, started in the parent's `working_directory` with the task, verbatim, as its first user message, on the parent's model unless the call names one (`model:` takes a name or an alias, resolved as `--model` is; an unknown host comes back as the tool's `Error:` line; an id the host's saved model list doesn't have starts the child with a `Warning: host 'h' doesn't list model 'x' (did you mean: …?)` line in the result). Children run in parallel and only their **final reply** comes back to the parent: the child's newest `output/<timestamp>.txt` file, which the worker writes at the end of each turn that produced visible text, cut head-and-tail like an `execute` result. Nothing of the child's trace enters the parent's context. Nothing is hidden: a child shows in `chi sessions list` (`↳ <parent>`), the web (a `↳` chip on its card, `delegated by` in its info bar, listed right after its parent in the all-sessions view) and the `list_sessions` tool (`child`; the parent shows as `parent` in the child's own list); the user can `chi --attach <child id>` and steer it mid-run, and `chi send` and `send_note` reach it like any session.
183
183
 
184
184
  - `delegate(task, model:, session:, wait:, timeout:)`: starts a child and, by default, waits for its reply (`wait: false` returns at once, so several children can run side by side; `timeout`, default 600 s, leaves a slow child running; 0 checks once without waiting). With `session:` (a child's id or prefix) the task goes to that child as a follow-up instead, delivered like `chi send` (client id `delegate:<parent short id>`, shown as `delegate>` in the child's UIs); only this session's children take one.
185
185
  - `delegate_result(session:, timeout:)`: waits for a child's next reply: the one named, or the newest running child (`timeout` as for `delegate`: 0 checks once and returns at once). The reply comes back once; a later call waits for the next one.
@@ -36,7 +36,8 @@ module Samagotchi
36
36
  build [--scope system|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]
37
37
  Build local memories and installed hooks (and plugin) into a shareable bundle (dir or zip).
38
38
  --scope selects source dir (default: system). --out inferred from extension; default <name>.zip.
39
- FILES... optional allowlist of *.md basenames to include (default: all but installed bundles' ones).
39
+ FILES... optional allowlist of *.md basenames to include (default: all but installed bundles' ones);
40
+ a named memory brings its model overlays (<name>.<model-key>.md).
40
41
 
41
42
  trash [--empty] [--dry-run] [--older-than DAYS]
42
43
  List bundle trash (moved files from uninstalls/upgrades).
@@ -61,7 +62,8 @@ module Samagotchi
61
62
  build [--scope system|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]
62
63
  Build local memories and installed hooks into a shareable bundle (dir or zip).
63
64
  --scope selects source dir (default: system). --out inferred from extension; default <name>.zip.
64
- FILES... optional allowlist of *.md basenames to include (default: all but installed bundles' ones).
65
+ FILES... optional allowlist of *.md basenames to include (default: all but installed bundles' ones);
66
+ a named memory brings its model overlays (<name>.<model-key>.md).
65
67
 
66
68
  Examples:
67
69
  chi bundle build --scope system
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: mcp
3
- version: 0.4.3
3
+ version: 0.4.4
4
4
  scope: system
5
5
  description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session, its tools are mcp_<server>_<tool>; /mcp lists them"
6
6
  trust_level: reviewed
7
7
  files: {}
8
8
  plugin:
9
9
  file: plugin.rb
10
- sha256: sha256:079a42892f10822115235e1ced7ccd481f80c9ef54abb094f9a1c599bb03e8e2
10
+ sha256: sha256:05543038e2950a5391655184f57a1028133eda8ca05f9e46e5cd2eddc3e9e608
11
11
  requires_chi: ">= 0.8.0"
@@ -18,11 +18,12 @@
18
18
  # attach_image_paths: true # default: an image path in the text, in the
19
19
  # # temp dir or cwd, attaches it
20
20
  #
21
- # Image blocks in a result go to the model as images (text: "[image 1:
22
- # image/png, attached]"); so does an image file named in a text block,
21
+ # Image blocks in a result go to the model as images (the text keeps
22
+ # "[image 1: image/png]"); so does an image file named in a text block,
23
23
  # under the system temp dir or the server's cwd: the block is only its
24
24
  # absolute path (chrome-devtools-mcp --slim answers a screenshot that way),
25
- # or it says one ending in an image extension ("Saved it to /tmp/a.png.").
25
+ # or it says one ending in an image extension ("Saved it to /tmp/a.png."),
26
+ # noted as "[image 1: a.png]".
26
27
  # A server that doesn't start, answer or list its tools is skipped with a
27
28
  # notice; the rest of chi works. One that exits mid-session starts again on
28
29
  # its next call, at most MAX_RESTARTS times a session. /mcp lists the
@@ -40,8 +40,12 @@ require "open3"
40
40
  # A `url:` template takes {match} (group 1, else the whole ref), {1}…{9}
41
41
  # (numbered groups), {name} (named groups), and {repo}/{host}: the named
42
42
  # group when it took part, else the project's (Dir.pwd's) git remote, asked
43
- # of git once per worker. A ref with a placeholder that can't be filled is
44
- # not linked; a `{word}` that is none of these stays text (warned at load).
43
+ # of git once per worker. A {repo} the ref itself names ("other/repo#12")
44
+ # is that ref's own, so its {host} comes from the remote only when the
45
+ # source's `remote_host:` list has the remote's host: else the ref would
46
+ # link to this checkout's host with someone else's repo. A ref with a
47
+ # placeholder that can't be filled is not linked; a `{word}` that is none
48
+ # of these stays text (warned at load).
45
49
  #
46
50
  # The note is not stored in the conversation: it is an event, replayed by a
47
51
  # UI only while the session's worker lives (a reload keeps it; a stopped
@@ -457,7 +461,7 @@ class SourceLinks
457
461
  when /\A\d+\z/ then match[word.to_i]&.then { |value| escape_url(value) }
458
462
  when "repo", "host"
459
463
  value = match.names.include?(word) ? match[word] : nil
460
- value ||= remote_value(word, source)
464
+ value ||= remote_value(word, source, repo_from_match: match.names.include?("repo") ? match["repo"] : nil)
461
465
  word == "repo" ? escape_repo(value) : value&.then { |host| escape_url(host) }
462
466
  else match[word]&.then { |value| escape_url(value) }
463
467
  end
@@ -466,7 +470,16 @@ class SourceLinks
466
470
  # {repo} / {host} from the project's git remote (the source's `remote:`,
467
471
  # default origin); nil when there is none, or when its host is not one of
468
472
  # the source's `remote_host:` list.
469
- def remote_value(word, source)
473
+ #
474
+ # +repo_from_match+: the match's own {repo}, when it has one. Such a ref
475
+ # names its own repo (`other/repo#12`), not the local checkout's, so its
476
+ # {host} may only come from the remote when the source lists that remote's
477
+ # host (remote_host:): else the ref would link to the local remote's host
478
+ # with someone else's repo.
479
+ def remote_value(word, source, repo_from_match: nil)
480
+ derived_host = word == "host" && !repo_from_match.to_s.empty?
481
+ return nil if derived_host && source[:remote_hosts].empty?
482
+
470
483
  remote = project_remote(source[:remote])
471
484
  return nil unless remote
472
485
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: source-links
3
- version: 0.3.3
3
+ version: 0.3.4
4
4
  scope: system
5
5
  description: Links source refs (JIRA tickets, GitHub issues, …) in the model's answer — inline links in the web, and a one-line note after the turn (note false turns it off); sources are configured patterns
6
6
  trust_level: reviewed
@@ -8,7 +8,7 @@ files:
8
8
  source_links.md: sha256:aebd37f1e25491780994ecc4afc20ed6e8db32b724dc038302bb050177efada8
9
9
  hooks:
10
10
  source_links.rb:
11
- sha256: sha256:454d3512a4aa078b288ff6d0ed25867222a56ead1a57ef1eaa14918e631fec23
11
+ sha256: sha256:c93269964ebc824b5f15ab60db81a458facd50de61446c550f254ea4c68964e2
12
12
  event: after_turn
13
13
  on_error: log
14
14
  priority: 90
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.28.0
3
+ version: 0.30.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -9,7 +9,9 @@ module Samagotchi
9
9
  # command as the model wrote it. Rules, in order:
10
10
  #
11
11
  # - a heredoc's body leaves its step's text and becomes its +heredoc+
12
- # ({tag:, lines:}), also one inside a "$(cat <<'EOF' … EOF)" argument;
12
+ # ({tag:, lines:}), also one inside a "$(cat <<'EOF' … EOF)" argument; a
13
+ # step with more than one is cut of them all, and lists them as
14
+ # +heredocs+ (+heredoc+ stays the first; the web's chip names it, +N);
13
15
  # - steps are split at && || ; | |& & and newlines; a ( … ) subshell stays
14
16
  # one step; +op+ is the operator before a step (nil for the first);
15
17
  # - a leading "cd X &&" / "cd X;" becomes +cd+ (only the first);
@@ -23,8 +25,8 @@ module Samagotchi
23
25
  # parentheses) gives nil: the UI then shows the title and the raw
24
26
  # command.
25
27
  module CommandSteps
26
- Step = Data.define(:text, :op, :label, :limit, :heredoc) do
27
- def initialize(text:, op: nil, label: nil, limit: nil, heredoc: nil) = super
28
+ Step = Data.define(:text, :op, :label, :limit, :heredoc, :heredocs) do
29
+ def initialize(text:, op: nil, label: nil, limit: nil, heredoc: nil, heredocs: nil) = super
28
30
 
29
31
  def to_h = super.compact
30
32
  end
@@ -122,29 +124,47 @@ module Samagotchi
122
124
 
123
125
  slice = text[from...to]
124
126
  heredoc = body && { tag: bodies[body.last], lines: body_lines(text[body.last]) }
125
- slice, heredoc = inner_heredoc(slice) unless heredoc
126
- Step.new(text: slice.strip, op: op, heredoc: heredoc)
127
+ slice, heredocs = inner_heredocs(slice) if heredoc.nil?
128
+ Step.new(text: slice.strip, op: op, heredoc: heredoc || heredocs&.first,
129
+ heredocs: heredocs && heredocs.size > 1 ? heredocs : nil)
127
130
  end
128
131
 
129
- # A heredoc inside a $(…) in +slice+ (git commit -m "$(cat <<'EOF' …)"):
130
- # the slice with the body cut out, and the heredoc; or the slice as is.
131
- def inner_heredoc(slice)
132
- return [slice, nil] unless slice.include?("<<")
132
+ # Every heredoc inside a $(…) in +slice+ (git commit -m "$(cat <<'EOF'
133
+ # …)"): the slice with all their bodies cut out, and the heredocs ([] for
134
+ # none). One step can hold several ("-f body=$(cat <<EOF …)" and
135
+ # "-f title=$(cat <<EOF …)"), and the rest of the step is text the reader
136
+ # would otherwise see.
137
+ def inner_heredocs(slice)
138
+ heredocs = []
139
+ return [slice, heredocs] unless slice.include?("<<")
133
140
 
134
141
  at = 0
135
142
  while (open = slice.index("$(", at))
136
143
  lexer = Lex::Lexer.new(slice[(open + 2)..])
137
144
  lexer.tokens
138
145
  doc = lexer.heredoc_spans.first
139
- if doc
140
- from = open + 2 + doc[:span].begin - 1 # the newline before the body
141
- to = open + 2 + doc[:span].end
142
- to += 1 if slice[to] == "\n"
143
- return [slice[0...from] + slice[to..], { tag: doc[:tag], lines: body_lines(slice[(from + 1)...to]) }]
146
+ unless doc
147
+ at = open + 2
148
+ next
144
149
  end
150
+
151
+ from = open + 2 + doc[:span].begin - 1 # the newline before the body
152
+ to = open + 2 + doc[:span].end
153
+ to += 1 if slice[to] == "\n"
154
+ heredocs << { tag: doc[:tag], lines: body_lines(slice[(from + 1)...to]) }
155
+ cut = slice[0...from] + slice[to..]
156
+ # A span that cut nothing (it can't) would loop here: move past it.
157
+ if cut.length >= slice.length
158
+ at = open + 2
159
+ next
160
+ end
161
+
162
+ slice = cut
163
+ # The cut moved everything after it left; keep looking from the same
164
+ # spot, past this "$(".
145
165
  at = open + 2
146
166
  end
147
- [slice, nil]
167
+ [slice, heredocs]
148
168
  end
149
169
 
150
170
  # The body's line count, its terminator line not counted.
@@ -1942,11 +1942,12 @@ module Samagotchi
1942
1942
  # that way: the note, and nothing made up as its answer. The note's
1943
1943
  # marker is for the UIs (the notice, and the empty steps after a
1944
1944
  # reload); it goes on the result's conversation too (the REPL keeps it).
1945
- # A retry's nudge at the tail goes: this note says it all.
1945
+ # A retry's nudge at the tail goes: this note says it all. An earlier
1946
+ # turn's note stays: it says why that turn ended.
1946
1947
  if result.empty_answer?
1947
1948
  note = TurnNote.empty(retries: result.empty_retries, steps: result.empty_steps)
1948
- conversation&.replace(TurnNote.without_trailing(conversation))
1949
- saved = TurnNote.without_trailing(conversation || turn.session.messages)
1949
+ conversation&.replace(TurnNote.without_trailing_nudge(conversation))
1950
+ saved = TurnNote.without_trailing_nudge(conversation || turn.session.messages)
1950
1951
  conversation << note if conversation
1951
1952
  saved + [note]
1952
1953
  elsif result.canceled? && conversation
@@ -169,19 +169,76 @@ module Samagotchi
169
169
  add(SUBST, close + 1 - @i)
170
170
  end
171
171
 
172
+ # From $( to the ) that closes it, as one SUBST. A quote or a
173
+ # heredoc body inside it is skipped over: a ) in one of those
174
+ # doesn't close the substitution.
172
175
  def substitution
173
176
  depth = 0
174
177
  j = @i + 1
175
178
  loop do
176
- depth += 1 if @s[j] == "("
177
- depth -= 1 if @s[j] == ")"
178
- j += 1
179
- break if depth.zero? || j >= @s.size
179
+ break if j >= @s.size
180
+
181
+ c = @s[j]
182
+ if ["'", '"'].include?(c)
183
+ j = quote_end(j)
184
+ elsif c == "("
185
+ depth += 1
186
+ j += 1
187
+ elsif c == ")"
188
+ depth -= 1
189
+ j += 1
190
+ break if depth.zero?
191
+ elsif @s[j, 2] == "<<"
192
+ j = substitution_heredoc(j)
193
+ else
194
+ j += 1
195
+ end
180
196
  end
181
197
  @unterminated = true unless depth.zero?
182
198
  add(SUBST, j - @i)
183
199
  end
184
200
 
201
+ # The index after the quote starting at +j+, a backslash in a double
202
+ # quote escaping the next character; the text's end when it is never
203
+ # closed.
204
+ def quote_end(j)
205
+ quote = @s[j]
206
+ k = j + 1
207
+ while k < @s.size
208
+ if quote == '"' && @s[k] == "\\" then k += 2
209
+ elsif @s[k] == quote then return k + 1
210
+ else k += 1
211
+ end
212
+ end
213
+ k
214
+ end
215
+
216
+ # From a << at +j+ inside a $(…) past its body's terminator line
217
+ # (nothing is lexed: the body is only stepped over), or on to the
218
+ # next character when no tag follows or the terminator never comes
219
+ # (a << that is no heredoc, as arithmetic's 1<<3, stays scanned).
220
+ def substitution_heredoc(j)
221
+ dash = @s[j + 2] == "-"
222
+ k = j + (dash ? 3 : 2)
223
+ k += 1 while [" ", "\t"].include?(@s[k])
224
+ tag, _quoted, stop = heredoc_tag(k)
225
+ return j + 1 if tag.empty?
226
+
227
+ eol = @s.index("\n", stop)
228
+ return j + 1 if eol.nil?
229
+
230
+ k = eol + 1
231
+ while k < @s.size
232
+ eol = @s.index("\n", k) || @s.size
233
+ line = @s[k...eol]
234
+ line = line.sub(/\A\t+/, "") if dash
235
+ return [eol + 1, @s.size].min if line == tag
236
+
237
+ k = eol + 1
238
+ end
239
+ j + 1
240
+ end
241
+
185
242
  def comment
186
243
  @i = @s.index("\n", @i) || @s.size
187
244
  end
@@ -306,10 +363,27 @@ module Samagotchi
306
363
  def data_sink?(at)
307
364
  start = at
308
365
  start -= 1 while start.positive? && @tokens[start - 1][0] == :word
309
- verb = @tokens[start...at].map { |t| t[1] }.find { |w| !w.match?(/\A[A-Za-z_]\w*=/) && !w.match?(REDIRECT) }
366
+ verb = command_word(@tokens[start...at])
310
367
  next_op = @tokens[at..].find { |t| t[0] == :op }
311
368
  DATA_SINKS.include?(File.basename(verb.to_s)) && !["|", "|&"].include?(next_op&.dig(1))
312
369
  end
370
+
371
+ # The command word among the words before a heredoc's: the NAME=
372
+ # words and the redirections dropped, and with a redirection written
373
+ # with a space (> /tmp/x) its target word too.
374
+ def command_word(words)
375
+ i = 0
376
+ while i < words.size
377
+ word = words[i][1]
378
+ if word.match?(REDIRECT)
379
+ i += 1 if word.match?(/\A\d*[<>]{1,2}\z/)
380
+ elsif !word.match?(/\A[A-Za-z_]\w*=/)
381
+ return word
382
+ end
383
+ i += 1
384
+ end
385
+ nil
386
+ end
313
387
  end
314
388
  end
315
389
  end
@@ -5,6 +5,7 @@ require_relative "config"
5
5
  require_relative "log"
6
6
  require_relative "model_ref"
7
7
  require_relative "client"
8
+ require_relative "model_list_store"
8
9
  require_relative "llm/openai_chat"
9
10
 
10
11
  module Samagotchi
@@ -287,6 +288,9 @@ module Samagotchi
287
288
  models = list_models_for(entry)
288
289
  data = { host: entry.host, port: entry.port, transport: entry.transport, models: models, error: nil }
289
290
  @mutex.synchronize { @host_lists[name] = { data: data, at: @clock.call } }
291
+ # On disk too: a process that spawns a worker without listing
292
+ # (`chi send --new --model`, delegate) checks an id against it.
293
+ ModelListStore.save(name, models.map(&:id))
290
294
  rescue StandardError => e
291
295
  Log.warn(:model, "list_failed", host: name, error: e.class.name, msg: e.message.to_s[0, 500])
292
296
  data = { host: entry.host, port: entry.port, transport: entry.transport, models: [], error: e.message }