samagotchi 0.29.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -1
- data/docs/cli.md +4 -4
- data/docs/hooks.md +1 -1
- data/docs/plugins.md +1 -1
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +4 -3
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +17 -4
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/command_steps.rb +35 -15
- data/lib/samagotchi/host_registry.rb +4 -0
- data/lib/samagotchi/kernel_loop.rb +19 -6
- data/lib/samagotchi/llm/chat_loop.rb +12 -6
- data/lib/samagotchi/model_list_store.rb +117 -0
- data/lib/samagotchi/model_profile.rb +43 -0
- data/lib/samagotchi/send_command.rb +5 -3
- data/lib/samagotchi/session.rb +5 -0
- data/lib/samagotchi/session_manager.rb +9 -2
- data/lib/samagotchi/session_metrics.rb +6 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -4
- data/lib/samagotchi/terminal_ui.rb +35 -5
- data/lib/samagotchi/tool_activity.rb +6 -2
- data/lib/samagotchi/tools/delegate.rb +14 -10
- data/lib/samagotchi/tools/execute.rb +3 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/activity.js +2 -1
- data/lib/samagotchi/web/public/app.js +9 -7
- data/lib/samagotchi/web/public/command_view.js +19 -5
- data/lib/samagotchi/web/public/index.html +14 -6
- data/lib/samagotchi/web/public/stage_model.js +18 -0
- data/lib/samagotchi/web/public/stage_view.js +16 -11
- data/lib/samagotchi/web/public/task_stop.js +11 -2
- data/lib/samagotchi/web/public/timing.js +12 -7
- data/lib/samagotchi/web/public/turn_events.js +3 -1
- data/lib/samagotchi/web/public/turn_model.js +16 -11
- data/lib/samagotchi/web/public/turn_view.js +26 -3
- data/lib/samagotchi/worker.rb +21 -13
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7af50a3a7e90170a9d6b4f3f5ab5914a0ab536ea121b498779109dabfb7c0b51
|
|
4
|
+
data.tar.gz: 4ddba29c753f1a6900956cc5abbbef70c3d912286e252c4c9adf08534a773368
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 497f4731e7a4ca9c1bd5073bea8e897506d37a5fcdfd15ea394f5837edfc85d76fbaade739ec4f6de998f7da5a982c3a8a9c1d4674b1e218a50ab14fe673291b
|
|
7
|
+
data.tar.gz: 0c6428021607e8540dcb21801510eca0295be3fba2de0e09eb4c2fd3c2c362326301065b52c5bc7745e006768522312d1934659da4cacc93254560402c767e76
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,58 @@ 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
|
+
|
|
11
63
|
## [0.29.0] - 2026-10-05
|
|
12
64
|
|
|
13
65
|
### Added
|
|
@@ -1500,7 +1552,8 @@ and long-lived sessions.
|
|
|
1500
1552
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
1501
1553
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
1502
1554
|
|
|
1503
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
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
|
|
1504
1557
|
[0.29.0]: https://github.com/dm1try/samagotchi/compare/v0.28.0...v0.29.0
|
|
1505
1558
|
[0.28.0]: https://github.com/dm1try/samagotchi/compare/v0.27.0...v0.28.0
|
|
1506
1559
|
[0.27.0]: https://github.com/dm1try/samagotchi/compare/v0.26.0...v0.27.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,
|
|
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
|
|
856
|
-
`by a hook` for a hook's). A turn a bundle's plugin stopped names it:
|
|
857
|
-
|
|
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): "
|
|
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
|
@@ -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
|
|
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;
|
|
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.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: mcp
|
|
3
|
-
version: 0.4.
|
|
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:
|
|
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
|
|
22
|
-
# image/png
|
|
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
|
|
44
|
-
#
|
|
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
|
-
|
|
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
|
+
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:
|
|
11
|
+
sha256: sha256:c93269964ebc824b5f15ab60db81a458facd50de61446c550f254ea4c68964e2
|
|
12
12
|
event: after_turn
|
|
13
13
|
on_error: log
|
|
14
14
|
priority: 90
|
|
@@ -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,
|
|
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
|
-
#
|
|
130
|
-
# the slice with
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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,
|
|
167
|
+
[slice, heredocs]
|
|
148
168
|
end
|
|
149
169
|
|
|
150
170
|
# The body's line count, its terminator line not counted.
|
|
@@ -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 }
|
|
@@ -197,7 +197,8 @@ module Samagotchi
|
|
|
197
197
|
emit(turn, type: :generation_cancelled, iteration: turn.iteration, reason: e.reason,
|
|
198
198
|
stopped_by: turn.cancel_controller&.stopped_by)
|
|
199
199
|
return cancelled_result(turn.conversation, tool_activity: turn.tool_activity, reason: e.reason,
|
|
200
|
-
partial_assistant_text: turn.buffer
|
|
200
|
+
partial_assistant_text: turn.buffer,
|
|
201
|
+
streamed_thinking: turn.streamed_thinking)
|
|
201
202
|
end
|
|
202
203
|
finish(turn)
|
|
203
204
|
rescue StandardError => e
|
|
@@ -239,8 +240,8 @@ module Samagotchi
|
|
|
239
240
|
# context tracker and the retry budget, the tool activity, the visible
|
|
240
241
|
# text streamed so far (salvaged on a cancel), the Qwen recovery state,
|
|
241
242
|
# the prefill, and what the caller gave.
|
|
242
|
-
Turn = Struct.new(:conversation, :context, :empty_retry, :tool_activity, :buffer, :
|
|
243
|
-
:prefill, :pending_tool_calls, :model_name, :pending_input, :on_stream_event,
|
|
243
|
+
Turn = Struct.new(:conversation, :context, :empty_retry, :tool_activity, :buffer, :streamed_thinking, :qwen_attempts,
|
|
244
|
+
:qwen_partial, :prefill, :pending_tool_calls, :model_name, :pending_input, :on_stream_event,
|
|
244
245
|
:cancel_controller, :cap, :emit, :iteration, :empty_steps, :ended_empty, keyword_init: true)
|
|
245
246
|
# One request: the prompt and its images as sent, the images' token
|
|
246
247
|
# estimate, and the window it was measured against.
|
|
@@ -258,7 +259,8 @@ module Samagotchi
|
|
|
258
259
|
conversation = prepare_conversation(messages)
|
|
259
260
|
Turn.new(
|
|
260
261
|
conversation: conversation, context: ContextStatus.new(conversation: conversation),
|
|
261
|
-
empty_retry: EmptyAnswerRetry.new, empty_steps: [], tool_activity: [], buffer: +"",
|
|
262
|
+
empty_retry: EmptyAnswerRetry.new, empty_steps: [], tool_activity: [], buffer: +"", streamed_thinking: +"",
|
|
263
|
+
qwen_attempts: 0, qwen_partial: nil,
|
|
262
264
|
# Qwen with thinking off: an empty thought after the cue, so the model
|
|
263
265
|
# answers at once. Kept in the turn's model messages, so each tool-loop
|
|
264
266
|
# prompt starts with what the server already has cached.
|
|
@@ -329,6 +331,9 @@ module Samagotchi
|
|
|
329
331
|
generation = Generation.new(usage: nil, served_model: nil, streamed_thinking: 0)
|
|
330
332
|
fire_hook(:before_generation, { type: :before_generation, iteration: turn.iteration }) if @hooks
|
|
331
333
|
buffer_mark = turn.buffer.length
|
|
334
|
+
# A cancel salvages only this generation's thinking: the earlier
|
|
335
|
+
# steps' is on their own messages already.
|
|
336
|
+
turn.streamed_thinking = +""
|
|
332
337
|
generation.response = with_generation(turn.cancel_controller) do |generation_controller|
|
|
333
338
|
request_generation(
|
|
334
339
|
request.prompt,
|
|
@@ -342,6 +347,7 @@ module Samagotchi
|
|
|
342
347
|
generation.usage = nil
|
|
343
348
|
generation.cache = nil
|
|
344
349
|
generation.streamed_thinking = 0
|
|
350
|
+
turn.streamed_thinking = +""
|
|
345
351
|
generation.finish_reason = nil
|
|
346
352
|
turn.emit.call({ type: :generation_retrying, iteration: turn.iteration }.merge(retry_event)) if turn.on_stream_event
|
|
347
353
|
},
|
|
@@ -368,6 +374,7 @@ module Samagotchi
|
|
|
368
374
|
generation.finish_reason = chunk[:finish_reason] if chunk[:finish_reason]
|
|
369
375
|
split = stream_splitter.feed(chunk[:content])
|
|
370
376
|
turn.buffer << split[:text]
|
|
377
|
+
turn.streamed_thinking << split[:thinking].to_s
|
|
371
378
|
generation.streamed_thinking += split[:thinking].to_s.length
|
|
372
379
|
return unless turn.on_stream_event
|
|
373
380
|
|
|
@@ -625,7 +632,7 @@ module Samagotchi
|
|
|
625
632
|
ModelProfile.required_model_name(override)
|
|
626
633
|
end
|
|
627
634
|
|
|
628
|
-
def cancelled_result(conversation, tool_activity:, reason:, partial_assistant_text: "")
|
|
635
|
+
def cancelled_result(conversation, tool_activity:, reason:, partial_assistant_text: "", streamed_thinking: "")
|
|
629
636
|
partial = partial_assistant_text.to_s.strip
|
|
630
637
|
conversation = duplicate_conversation(conversation)
|
|
631
638
|
# Salvage the already-streamed visible reply (thought/tool_call lanes
|
|
@@ -633,7 +640,13 @@ module Samagotchi
|
|
|
633
640
|
# cannot leak) so a follow-up steering message continues with the model's
|
|
634
641
|
# half-finished work in context instead of losing it.
|
|
635
642
|
unless partial.empty?
|
|
636
|
-
|
|
643
|
+
message = { role: "model", content: "#{partial}\n[interrupted]", interrupted: true }
|
|
644
|
+
# The thinking this generation streamed goes with it (the chat loop's
|
|
645
|
+
# with_thinking key, which MessageParts reads and the prompt doesn't):
|
|
646
|
+
# without it, reloading the cut step shows the empty answer it isn't.
|
|
647
|
+
thinking = streamed_thinking.to_s
|
|
648
|
+
message[:thinking] = thinking unless thinking.strip.empty?
|
|
649
|
+
conversation << message
|
|
637
650
|
end
|
|
638
651
|
LLM::ModelResult.new(
|
|
639
652
|
text: "",
|
|
@@ -338,8 +338,8 @@ module Samagotchi
|
|
|
338
338
|
return canceled(iteration, @cancel_controller.reason) if @cancel_controller&.cancelled?
|
|
339
339
|
|
|
340
340
|
inject_pending_input(iteration)
|
|
341
|
-
response, partial = generate(iteration)
|
|
342
|
-
return canceled(iteration, response, partial) if partial
|
|
341
|
+
response, partial, thought = generate(iteration)
|
|
342
|
+
return canceled(iteration, response, partial, thought) if partial
|
|
343
343
|
|
|
344
344
|
if response.cut
|
|
345
345
|
outcome = after_cut(iteration, response)
|
|
@@ -468,7 +468,7 @@ module Samagotchi
|
|
|
468
468
|
messages: AnswerDisplay.strip_all(@conversation).map(&:dup).freeze })
|
|
469
469
|
[response, nil]
|
|
470
470
|
rescue RequestCancelled => e
|
|
471
|
-
[e.reason, streamed]
|
|
471
|
+
[e.reason, streamed, thought]
|
|
472
472
|
end
|
|
473
473
|
|
|
474
474
|
# The request runs under the generation's own controller (the turn
|
|
@@ -593,11 +593,17 @@ module Samagotchi
|
|
|
593
593
|
end
|
|
594
594
|
|
|
595
595
|
# The text streamed before the cancel stays, marked [interrupted],
|
|
596
|
-
# as the native loop's salvage does
|
|
597
|
-
|
|
596
|
+
# as the native loop's salvage does; the reasoning this generation
|
|
597
|
+
# streamed rides along on the message (with_thinking's key).
|
|
598
|
+
def canceled(iteration, reason, partial = "", thinking = "")
|
|
598
599
|
emit(type: :generation_cancelled, iteration: iteration, reason: reason, stopped_by: @cancel_controller&.stopped_by)
|
|
599
600
|
visible = @loop.strip_model_thought(partial.to_s).strip
|
|
600
|
-
|
|
601
|
+
unless visible.empty?
|
|
602
|
+
message = { role: "model", content: "#{visible}\n[interrupted]", interrupted: true }
|
|
603
|
+
thought = thinking.to_s
|
|
604
|
+
message[:thinking] = thought unless thought.strip.empty?
|
|
605
|
+
@conversation << message
|
|
606
|
+
end
|
|
601
607
|
conversation = @loop.plain(@conversation)
|
|
602
608
|
conversation.last[:interrupted] = true unless visible.empty?
|
|
603
609
|
ModelResult.new(text: "", conversation: conversation, canceled: true,
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
require_relative "atomic_file"
|
|
7
|
+
require_relative "log"
|
|
8
|
+
require_relative "paths"
|
|
9
|
+
|
|
10
|
+
module Samagotchi
|
|
11
|
+
# The model ids each host last listed, saved in
|
|
12
|
+
# <state dir>/model_lists.json for the processes that spawn a worker but
|
|
13
|
+
# never list a host themselves: `chi send --new --model` and the
|
|
14
|
+
# `delegate` tool warn about an unknown model id up front from it
|
|
15
|
+
# (ModelProfile.model_warning) instead of only the worker's first turn
|
|
16
|
+
# failing. It is a cache of the hosts' own lists, so it never raises: no
|
|
17
|
+
# file, an unreadable one or bad JSON reads as no list at all. Only a
|
|
18
|
+
# successful listing writes (a host that is down keeps the ids we know),
|
|
19
|
+
# and a list older than TTL_SECONDS is no evidence about the host any
|
|
20
|
+
# more.
|
|
21
|
+
#
|
|
22
|
+
# { "<host name>" => { "ids" => ["…"], "at" => <epoch seconds> } }
|
|
23
|
+
module ModelListStore
|
|
24
|
+
FILE = "model_lists.json"
|
|
25
|
+
# When a saved list is too old to say what a host serves: a model may
|
|
26
|
+
# have been added or removed since.
|
|
27
|
+
TTL_SECONDS = 7 * 24 * 60 * 60
|
|
28
|
+
|
|
29
|
+
# One host's saved list: the ids it listed and when (+at+, epoch
|
|
30
|
+
# seconds).
|
|
31
|
+
Saved = Data.define(:host, :ids, :at) do
|
|
32
|
+
def age(now: Time.now.to_i) = now.to_i - at.to_i
|
|
33
|
+
|
|
34
|
+
# Too old to check an id against (ModelProfile.model_warning): a model
|
|
35
|
+
# may have been added or removed since.
|
|
36
|
+
def stale?(now: Time.now.to_i) = age(now: now) > ModelListStore::TTL_SECONDS
|
|
37
|
+
|
|
38
|
+
# A host's own list spells its ids, and resolution matches them by
|
|
39
|
+
# case (the registry's model index is downcased): so does this.
|
|
40
|
+
def known?(id) = ids.any? { |known| known.casecmp?(id.to_s.strip) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
module_function
|
|
44
|
+
|
|
45
|
+
# @return [String] <state home>/samagotchi/model_lists.json
|
|
46
|
+
def path(env: ENV)
|
|
47
|
+
File.join(Paths.state_dir(env: env), FILE)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Every saved list by host name, the lists as [Saved] (empty without a
|
|
51
|
+
# file, with bad JSON, or with nothing readable in it).
|
|
52
|
+
# @return [Hash{String => Saved}]
|
|
53
|
+
def read(env: ENV)
|
|
54
|
+
all(env: env).each_with_object({}) do |(name, entry), acc|
|
|
55
|
+
list = saved_from(name, entry)
|
|
56
|
+
acc[name] = list if list
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# One host's saved list.
|
|
61
|
+
# @return [Saved, nil] nil without a file, with bad JSON, or with no
|
|
62
|
+
# entry for the host
|
|
63
|
+
def find(host, env: ENV)
|
|
64
|
+
read(env: env)[host.to_s.strip.downcase]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Save the ids +host+ just listed, keeping every other host's entry.
|
|
68
|
+
# Best effort: a store that can't be written never fails the listing
|
|
69
|
+
# that found the ids. An empty list is not saved (it is no evidence the
|
|
70
|
+
# host serves nothing: keep the ids we know).
|
|
71
|
+
# @return [Saved, nil] what was saved, nil when nothing was
|
|
72
|
+
def save(host, ids, at: Time.now.to_i, env: ENV)
|
|
73
|
+
name = host.to_s.strip.downcase
|
|
74
|
+
list = Array(ids).map { |id| id.to_s.strip }.reject(&:empty?).uniq
|
|
75
|
+
return nil if name.empty? || list.empty?
|
|
76
|
+
|
|
77
|
+
file = path(env: env)
|
|
78
|
+
FileUtils.mkdir_p(File.dirname(file))
|
|
79
|
+
# Under the lock, so two workers listing a host at once (or two
|
|
80
|
+
# threads of one registry) never drop each other's entries.
|
|
81
|
+
File.open("#{file}.lock", File::RDWR | File::CREAT, 0o600) do |lock|
|
|
82
|
+
lock.flock(File::LOCK_EX)
|
|
83
|
+
entries = all(env: env)
|
|
84
|
+
entries[name] = { "ids" => list, "at" => at.to_i }
|
|
85
|
+
AtomicFile.write(file, JSON.pretty_generate(entries) + "\n")
|
|
86
|
+
end
|
|
87
|
+
Saved.new(host: name, ids: list, at: at.to_i)
|
|
88
|
+
rescue StandardError => e
|
|
89
|
+
Log.warn(:model, "model_list_save_failed", host: host.to_s, error: e.class.name, msg: e.message.to_s[0, 200])
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The whole file, host names downcased: {name => entry}.
|
|
94
|
+
# @return [Hash]
|
|
95
|
+
def all(env: ENV)
|
|
96
|
+
data = JSON.parse(File.read(path(env: env)))
|
|
97
|
+
return {} unless data.is_a?(Hash)
|
|
98
|
+
|
|
99
|
+
data.each_with_object({}) do |(name, entry), acc|
|
|
100
|
+
acc[name.to_s.strip.downcase] = entry if entry.is_a?(Hash)
|
|
101
|
+
end
|
|
102
|
+
rescue StandardError
|
|
103
|
+
{}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# One +entry+ of the file as a Saved, or nil when it holds no ids or no
|
|
107
|
+
# time.
|
|
108
|
+
def saved_from(name, entry)
|
|
109
|
+
ids = Array(entry["ids"]).map { |id| id.to_s.strip }.reject(&:empty?)
|
|
110
|
+
at = entry["at"].to_i
|
|
111
|
+
return nil if ids.empty? || at <= 0
|
|
112
|
+
|
|
113
|
+
Saved.new(host: name, ids: ids, at: at)
|
|
114
|
+
end
|
|
115
|
+
private_class_method :saved_from
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -179,6 +179,49 @@ module Samagotchi
|
|
|
179
179
|
raise UnknownHost, "unknown host '#{host}' in model '#{ref}'#{hint}; the configured hosts are #{names.join(", ")}"
|
|
180
180
|
end
|
|
181
181
|
|
|
182
|
+
# A warning when +model_name+ resolves to a model id a host's saved
|
|
183
|
+
# list doesn't have (ModelListStore: what a `chi models`, a /models or
|
|
184
|
+
# the web's GET /api/models last listed for that host), else nil.
|
|
185
|
+
# Called where a session is spawned without listing the hosts itself
|
|
186
|
+
# (`chi send --new --model`, the delegate tool), so a typo is
|
|
187
|
+
# named before the worker's first turn fails on it. Only a warning: some
|
|
188
|
+
# hosts serve ids they don't list (a one-model llama.cpp server takes any
|
|
189
|
+
# name, OpenRouter's :nitro variants), so the session starts anyway.
|
|
190
|
+
# Never asks a host.
|
|
191
|
+
#
|
|
192
|
+
# Only a ref that names a host is checked (its alias resolved first, as
|
|
193
|
+
# ModelRef.parse does), and only against that host's own list: a bare id
|
|
194
|
+
# goes to whichever host lists it (HostRegistry#host_for_model), so no
|
|
195
|
+
# single list can judge it. Nothing is checked either without a saved
|
|
196
|
+
# list for the host or with one older than ModelListStore::TTL_SECONDS
|
|
197
|
+
# (a week: the host may serve different models by now).
|
|
198
|
+
# @param hosts [Hash, nil] the hosts a prefix may name (a HostRegistry's
|
|
199
|
+
# entries); config.yml's by default
|
|
200
|
+
# @param lists [#read] ModelListStore by default
|
|
201
|
+
# @return [String, nil]
|
|
202
|
+
def self.model_warning(model_name, env: ENV, hosts: nil, lists: nil)
|
|
203
|
+
require_relative "config"
|
|
204
|
+
require_relative "model_list_store"
|
|
205
|
+
hosts ||= Samagotchi::ConfigFile.hosts_config(env: env)
|
|
206
|
+
lists ||= Samagotchi::ModelListStore
|
|
207
|
+
parsed = Samagotchi::ConfigFile.model_ref(model_name, env: env, hosts: hosts)
|
|
208
|
+
host = parsed.host_name
|
|
209
|
+
return nil unless host
|
|
210
|
+
|
|
211
|
+
list = lists.read(env: env)[host.to_s.strip.downcase]
|
|
212
|
+
return nil if list.nil? || list.stale? || list.known?(parsed.id)
|
|
213
|
+
|
|
214
|
+
unknown_model_message(parsed.id, host, list.ids)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# The warning for an id the host doesn't list, with up to three close
|
|
218
|
+
# ids (Config.near_names) when there are any.
|
|
219
|
+
def self.unknown_model_message(id, host, ids)
|
|
220
|
+
near = Samagotchi::Config.near_names(id, ids).first(3)
|
|
221
|
+
hint = near.empty? ? "" : " (did you mean: #{near.join(", ")}?)"
|
|
222
|
+
"host '#{host}' doesn't list model '#{id}'#{hint}; started it anyway; `chi models` lists what the hosts serve"
|
|
223
|
+
end
|
|
224
|
+
|
|
182
225
|
# The prefix of "box:x" when box is a host config.yml has with
|
|
183
226
|
# enabled: false (left out of +hosts+), else nil.
|
|
184
227
|
def self.disabled_host_prefix(ref, hosts, env)
|