samagotchi 0.2.0 → 0.3.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 +116 -1
- data/README.md +40 -4
- data/bin/chi +86 -19
- data/docs/cli.md +108 -5
- data/docs/configuration.md +229 -45
- data/docs/desktop.md +6 -0
- data/docs/hooks.md +126 -5
- data/docs/plugins.md +68 -2
- data/docs/releasing.md +18 -8
- data/docs/sessions.md +30 -4
- data/lib/samagotchi/answer_display.rb +95 -0
- data/lib/samagotchi/archive_store.rb +90 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
- data/lib/samagotchi/bootstrap/probe.rb +262 -0
- data/lib/samagotchi/bootstrap_command.rb +347 -0
- data/lib/samagotchi/bridge/pending_card.rb +89 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +14 -3
- data/lib/samagotchi/bridge.rb +9 -0
- data/lib/samagotchi/bridge_client.rb +6 -2
- data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
- data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +358 -0
- data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
- data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +8 -2
- data/lib/samagotchi/client.rb +72 -13
- data/lib/samagotchi/config.rb +196 -36
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +13 -7
- data/lib/samagotchi/desktop/macos/Panel.swift +71 -19
- data/lib/samagotchi/empty_answer_retry.rb +43 -0
- data/lib/samagotchi/engine.rb +233 -36
- data/lib/samagotchi/guardrails/approval.rb +9 -0
- data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +24 -5
- data/lib/samagotchi/host_registry.rb +4 -3
- data/lib/samagotchi/idle_recap.rb +5 -1
- data/lib/samagotchi/kernel_loop.rb +47 -15
- data/lib/samagotchi/llm/chat_loop.rb +59 -20
- data/lib/samagotchi/llm/errors.rb +21 -3
- data/lib/samagotchi/llm/http.rb +42 -13
- data/lib/samagotchi/llm/openai_chat.rb +12 -4
- data/lib/samagotchi/log_subscriber.rb +18 -3
- data/lib/samagotchi/model_profile.rb +1 -1
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/sessions.rb +3 -1
- data/lib/samagotchi/reply_wait.rb +126 -0
- data/lib/samagotchi/sampling_settings.rb +58 -0
- data/lib/samagotchi/self_report.rb +1 -0
- data/lib/samagotchi/send_command.rb +153 -7
- data/lib/samagotchi/session.rb +52 -11
- data/lib/samagotchi/session_archive_command.rb +107 -0
- data/lib/samagotchi/session_commands.rb +11 -2
- data/lib/samagotchi/session_manager.rb +114 -9
- data/lib/samagotchi/session_metrics.rb +222 -106
- data/lib/samagotchi/steer.rb +72 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
- data/lib/samagotchi/terminal_ui/event_renderer.rb +13 -8
- data/lib/samagotchi/terminal_ui/formatting.rb +31 -8
- data/lib/samagotchi/terminal_ui/input_support.rb +3 -0
- data/lib/samagotchi/terminal_ui.rb +77 -4
- data/lib/samagotchi/tool_activity.rb +3 -1
- data/lib/samagotchi/tools/builtins.rb +15 -4
- data/lib/samagotchi/tools/delegate_wait.rb +26 -69
- data/lib/samagotchi/tools/execute.rb +52 -14
- data/lib/samagotchi/tools/task_runtime.rb +19 -0
- data/lib/samagotchi/tools/task_wait.rb +27 -3
- data/lib/samagotchi/turn_note.rb +60 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +2 -6
- data/lib/samagotchi/web/app.rb +88 -4
- data/lib/samagotchi/web/public/activity.js +10 -1
- data/lib/samagotchi/web/public/annotate_presets.js +26 -0
- data/lib/samagotchi/web/public/annotations.js +13 -0
- data/lib/samagotchi/web/public/app.js +437 -88
- data/lib/samagotchi/web/public/card.js +5 -3
- data/lib/samagotchi/web/public/chat_view.js +10 -1
- data/lib/samagotchi/web/public/copy.js +20 -4
- data/lib/samagotchi/web/public/ctx.js +15 -0
- data/lib/samagotchi/web/public/data.js +21 -6
- data/lib/samagotchi/web/public/format.js +9 -0
- data/lib/samagotchi/web/public/index.html +38 -2
- data/lib/samagotchi/web/public/notify.js +175 -0
- data/lib/samagotchi/web/public/question_card.js +2 -1
- data/lib/samagotchi/web/public/sessions_list.js +7 -0
- data/lib/samagotchi/web/public/timing.js +39 -14
- data/lib/samagotchi/web/public/turn_events.js +46 -0
- data/lib/samagotchi/web/public/turn_view.js +47 -7
- data/lib/samagotchi/web/server.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +24 -1
- data/lib/samagotchi/worker.rb +11 -0
- metadata +20 -1
data/docs/plugins.md
CHANGED
|
@@ -305,7 +305,9 @@ end
|
|
|
305
305
|
|
|
306
306
|
This is a bundle hook, the same as a `hooks/*.rb` file. See
|
|
307
307
|
[hooks.md](hooks.md#hook-events) for the events and for what `event[:notify]`,
|
|
308
|
-
`event[:ask_user]` and `event[:
|
|
308
|
+
`event[:ask_user]`, `event[:stop_turn]` and `event[:steer]` do; on `:after_turn`,
|
|
309
|
+
`event[:present]` sets how the answer is shown in the web
|
|
310
|
+
([Presenting the answer](hooks.md#presenting-the-answer-display-only)). Its label is
|
|
309
311
|
`plugin.rb (bundle my-bundle)`. The block may take only the event. If it
|
|
310
312
|
raises, the error is logged and the hook is skipped.
|
|
311
313
|
|
|
@@ -435,6 +437,8 @@ session's life, and each read gives the session as it is now.
|
|
|
435
437
|
| `ctx.card(title:, body: "", actions: [], level: :info, id: nil)` | a card in every UI, returning its id: see [Cards](#cards) |
|
|
436
438
|
| `ctx.ask_user(question:, options:, header: nil, allow_freeform: false)` | a question, like a hook's `event[:ask_user]` |
|
|
437
439
|
| `ctx.cancelled?` | whether the running turn was cancelled (a long tool should stop) |
|
|
440
|
+
| `ctx.steer(text)` | put text into the running turn, like a hook's `event[:steer]`: its own user message at the loop's next boundary, shown as `my-bundle> nudged: …`. Returns true when queued, false with no turn running (it never starts one; that is `ctx.sessions`' send). Dropped (logged) if the model answers or the turn ends first. Safe from any thread: an anytime command, a hook, your own |
|
|
441
|
+
| `ctx.stop_turn(reason)` | stop the running turn after a warn notice with the reason, like a hook's `event[:stop_turn]`; true when it stopped one now |
|
|
438
442
|
| `ctx.ask_model(messages:, prompt:, …)` | a side answer from the session's model: see [Side answers](#side-answers-ctxask_model) |
|
|
439
443
|
| `ctx.sessions` | fork, send to and read other sessions: see [Other sessions](#other-sessions-ctxsessions) |
|
|
440
444
|
|
|
@@ -460,7 +464,11 @@ ctx.card(id: id, title: "Build finished", body: "no warnings left") # replaces
|
|
|
460
464
|
- `actions:` are up to 6 `{label:, command:}`. A command is a line the
|
|
461
465
|
session runs as if the user typed it: `/hello again`, `/model x`, a
|
|
462
466
|
plugin's own command. The web shows a button; the terminal shows
|
|
463
|
-
`→ /hello again`, to type.
|
|
467
|
+
`→ /hello again`, to type. A button's command leaves no echo: the web
|
|
468
|
+
sends it with `card: true` (`POST /api/sessions/:id/command`), its
|
|
469
|
+
`command_queued` and `command_ran` carry `card: true`, and no UI shows
|
|
470
|
+
its line; the web shows a bubble only when the command answers with
|
|
471
|
+
text or fails.
|
|
464
472
|
- `level:` is `:info` or `:warn` (the warning colour).
|
|
465
473
|
- `id:` names an earlier card to replace. Without one a new id is made. The
|
|
466
474
|
web updates the card in place; the terminal prints it again, marked
|
|
@@ -748,6 +756,64 @@ Not caught (yet):
|
|
|
748
756
|
- alternating calls (A, B, A, B) that each return something new;
|
|
749
757
|
- thinking that goes in circles inside one long generation.
|
|
750
758
|
|
|
759
|
+
## The check-in bundle
|
|
760
|
+
|
|
761
|
+
`chi bundle install check-in` installs the bundle shipped with chi. It is
|
|
762
|
+
written only against this API (`lib/samagotchi/bundles/check-in/plugin.rb`):
|
|
763
|
+
`chi.on` hooks, `ctx.card`, `ctx.steer`, `ctx.stop_turn` and one anytime
|
|
764
|
+
command. It has no memory file.
|
|
765
|
+
|
|
766
|
+
A turn can run a long time on its own, reading and searching, without saying
|
|
767
|
+
what it has found. check-in counts the turn's tool calls, and when there are
|
|
768
|
+
`after` of them with no answer yet (then every `every` more) it checks in:
|
|
769
|
+
|
|
770
|
+
- **`mode: ask`** (the default): a card, one per turn, updated in place at
|
|
771
|
+
each check-in: "50 tool calls, no answer yet", how long the turn has run
|
|
772
|
+
and its last few tools, and three actions:
|
|
773
|
+
- **Nudge** (`/checkin nudge`): puts `message` into the running turn
|
|
774
|
+
(`ctx.steer`); the model reads it at its next step and answers or says
|
|
775
|
+
what is left. Every UI shows `check-in> nudged: …`.
|
|
776
|
+
- **Keep going** (`/checkin later`): closes the card until the next
|
|
777
|
+
check-in.
|
|
778
|
+
- **Stop** (`/checkin stop`): stops the turn.
|
|
779
|
+
|
|
780
|
+
When the turn ends the card loses its actions ("The turn ended after N tool
|
|
781
|
+
calls"), so no stale buttons stay. In the attached terminal the actions are
|
|
782
|
+
`→ /checkin nudge` lines to type; the command runs beside the turn.
|
|
783
|
+
- **`mode: nudge`**: nudges the model by itself, with a notice line.
|
|
784
|
+
- **`mode: notify`**: a notice line only.
|
|
785
|
+
|
|
786
|
+
The count is per turn: a new turn (a prompt, a continue, a reminder) starts
|
|
787
|
+
from zero; steering merged into the running turn doesn't reset it. The polling
|
|
788
|
+
tools are not counted. A nudge that arrives after the model's final answer is
|
|
789
|
+
dropped (logged), never restarting a turn that is done; the card's "Nudged the
|
|
790
|
+
model at N tool calls" then becomes "The answer came first; nudge not sent."
|
|
791
|
+
|
|
792
|
+
```yaml
|
|
793
|
+
# config.yml
|
|
794
|
+
bundles:
|
|
795
|
+
check-in:
|
|
796
|
+
after: 50 # tool calls in one turn before the first check-in
|
|
797
|
+
every: 50 # then again every this many more
|
|
798
|
+
mode: ask # ask | nudge | notify
|
|
799
|
+
message: "You've made {calls} tool calls in this turn without answering. Say briefly what you've found so far and what's left, then answer now or continue."
|
|
800
|
+
ignore_tools: [task_wait, task_get, delegate_result]
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
`{calls}` in `message` is the count. The default asks for what has been found
|
|
804
|
+
and what is left, not "how's it going?": a small model tends to answer that
|
|
805
|
+
with a line and carry on unchanged.
|
|
806
|
+
|
|
807
|
+
`/checkin` (anytime) for this session, until its worker restarts:
|
|
808
|
+
|
|
809
|
+
| | |
|
|
810
|
+
|---|---|
|
|
811
|
+
| `/checkin` | on or off, the mode, the threshold and this turn's count |
|
|
812
|
+
| `/checkin on` / `off` | check in, or not |
|
|
813
|
+
| `/checkin 30` | check in after 30 tool calls, then every 30 |
|
|
814
|
+
| `/checkin mode ask` / `nudge` / `notify` | the mode |
|
|
815
|
+
| `/checkin nudge` / `later` / `stop` | the card's actions, also by hand |
|
|
816
|
+
|
|
751
817
|
## Shutdown
|
|
752
818
|
|
|
753
819
|
When the REPL exits, or a session's worker exits (an idle exit, `/exit`, a
|
data/docs/releasing.md
CHANGED
|
@@ -125,11 +125,21 @@ GitHub release as such (`gh release edit vX.Y.Z --prerelease` or edit its
|
|
|
125
125
|
notes: "yanked: …"), add a `### Fixed` line under Unreleased, and release the
|
|
126
126
|
next patch version.
|
|
127
127
|
|
|
128
|
-
##
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
128
|
+
## CI on Linux
|
|
129
|
+
|
|
130
|
+
The suite runs the same on Linux CI as on macOS, with no CI-only skips. A few
|
|
131
|
+
things differ there, and a new spec that trips on them fails only on CI:
|
|
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`.
|
|
136
|
+
- The gems live under `vendor/bundle`: a child `ruby` started with a bare env
|
|
137
|
+
(`unsetenv_others: true`) needs `GEM_HOME`/`GEM_PATH` to find nokogiri.
|
|
138
|
+
- Ruby 3.3's zlib raises `Zlib::BufError` when a thread interrupt lands in a
|
|
139
|
+
deflate (ruby/zlib#57, fixed in Ruby 3.4's zlib): build gems in a child
|
|
140
|
+
process, as `spec/gem_contents_spec.rb` does.
|
|
141
|
+
|
|
142
|
+
To run it locally, use Docker with the Ruby build CI uses
|
|
143
|
+
(`ruby-X.Y.Z-ubuntu-24.04-x64.tar.gz` from ruby/ruby-builder's releases) on
|
|
144
|
+
`ubuntu:24.04` with `LANG=C.UTF-8`, `zip` and `git`, and run
|
|
145
|
+
`CI=1 BUNDLE_PATH=… bundle exec rspec`.
|
data/docs/sessions.md
CHANGED
|
@@ -23,6 +23,8 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
|
|
|
23
23
|
|
|
24
24
|
**Deleting a session:** `chi sessions delete ID...`, `/exit --delete` in a terminal and the Web UI's delete all go through `SessionManager.delete_session`: it resolves a unique prefix, removes `<id>.json` and the whole `<id>/` dir, and returns what it removed. A session a plain REPL has open (`owner.lock` kind `tui`) is always refused. One a worker runs is refused unless the caller asks to stop it: then it stops the worker as `chi sessions stop` does and deletes once `owner.lock` is free (`--force` on the CLI, 10 s; the web always, 2 s; `/exit --delete` after the worker agreed to exit, 10 s). A worker that outlives the wait leaves the session in place. The web's route is `DELETE /api/sessions/:id` (200 `{status: "deleted", session_id, stopped}`; 409 `owned_by_tui` or `still_stopping`; 404).
|
|
25
25
|
|
|
26
|
+
<a id="archiving-a-session"></a>**Archiving a session:** `chi sessions archive ID...`, `/archive` in a terminal and the Web UI's `archive` go through `SessionManager.archive_session`: it writes `<id>/archived` (`{"archived_at": …}`; not a session.json field, which every save rewrites) for the session and its delegated children. `Session.list` leaves archived sessions out unless `include_archived:`, and everything built on it follows: `chi sessions list` (`--archived` shows them, `[archived]`), the summaries (`--live`, json/tsv, the desktop panel, the agent's `list_sessions`) and the retention prune, which neither deletes nor counts them against `max_count`. `children_of` (max_children, `delegate_result`) still sees them, and `--resume`/`--attach ID`, `chi send`/`chi note ID` reach them as before. Refused: a turn running in the session or in one of its children (naming the child; 409 `busy` on the web), a plain REPL owning it (`owned_by_tui`), a `chi scratch` session (`scratch`). A live idle worker is stopped first, and the marker is written even if it is still shutting down. Input a human typed brings a session back (`{"unarchived_at": …}`): a prompt from the web, an attached or plain terminal or `chi send` (origin `web:*`, `tui:*`, `cli:send`, none), steering merged into a running turn, a `/continue` answer or a question answered; a delegate's follow-up, a plugin's turn, a reminder, the idle recap, a note and just opening it don't. A child's input brings only the child back. The retention sweep ages an unarchived session from when it was unarchived, so unarchiving an old session doesn't let the next sweep take it. `/archive` leaves the session first, as `/exit` does, then archives it: in an attached terminal the worker is asked to exit (one that stays up for another UI is stopped by the archive; a running turn refuses it), and an empty session is discarded instead; `chi scratch` refuses it. `chi sessions unarchive ID...` and the web's `unarchive` bring back the whole family. Web routes: `POST /api/sessions/:id/archive` (200 `{status, session_id, archived, stopped, discarded}`, ids) and `POST /api/sessions/:id/unarchive` (200 `{status, session_id, unarchived}`). The session hub and `GET /api/sessions` keep archived sessions, `archived: true` in their summaries: the page leaves them out of the strip, its count and the list at render, and "include archived" by the all-sessions search shows them, dimmed with an `archived` badge. The info bar reads `archive · stop · delete` (`unarchive` on an archived one); archiving the open session stays on it, with an Undo toast.
|
|
27
|
+
|
|
26
28
|
**Lazy sweep:** automatic prune runs at most once per 24h on `GET /api/sessions` (Web). No background thread or cron. Manual prune is always available.
|
|
27
29
|
|
|
28
30
|
**CLI:**
|
|
@@ -31,6 +33,8 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
|
|
|
31
33
|
chi sessions list [--sort updated_at|created_at] [--order desc|asc] [--limit N] [--scope=all]
|
|
32
34
|
chi sessions list [--live] [--cwd PATH] [--limit N] [--format text|json|tsv] [--scope=all]
|
|
33
35
|
chi sessions stop ID
|
|
36
|
+
chi sessions archive ID... # hide from every list, keep for good; unarchive ID... undoes it
|
|
37
|
+
chi sessions list --archived # archived sessions too, marked [archived]
|
|
34
38
|
chi sessions delete [--force] ID... # for good; --force stops a live worker first
|
|
35
39
|
chi sessions prune [--dry-run] [--days N] [--keep N] [--keep-status running,...] [--test-only]
|
|
36
40
|
chi sessions clean [--dry-run] [--days N] # test sessions: all, or older than N days
|
|
@@ -48,11 +52,11 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
|
|
|
48
52
|
|
|
49
53
|
`--dry-run` is the safe preview. Web has no prune endpoint; use the CLI.
|
|
50
54
|
|
|
51
|
-
`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 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.
|
|
55
|
+
`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 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.
|
|
52
56
|
|
|
53
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.
|
|
54
58
|
|
|
55
|
-
`--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, updated_at, live, busy, owner, recap}]` (`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. `--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`).
|
|
56
60
|
|
|
57
61
|
## Context notes
|
|
58
62
|
|
|
@@ -130,6 +134,26 @@ pbpaste | chi send 3fa2 # the clipboard is the messa
|
|
|
130
134
|
|
|
131
135
|
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)`.
|
|
132
136
|
|
|
137
|
+
### Starting a session
|
|
138
|
+
|
|
139
|
+
`chi send --new` starts a new session with the message, the way the web start page does: a worker runs it, so it is in `chi web` and `chi sessions list` at once and streams live there; `chi --attach ID` joins it. `--wait` blocks until the answer and prints it, which makes it an agent's one-shot the user can watch (unlike `chi -p … --non-interactive`, which runs in-process and saves the session only at the end).
|
|
140
|
+
|
|
141
|
+
```sh
|
|
142
|
+
chi send --new -m "review the diff on feat/x" # prints "<id> started", returns at once
|
|
143
|
+
git diff | chi send --new -m "review this" # stdin is quoted context, as above
|
|
144
|
+
chi send --new --wait -m "review the diff on feat/x" # blocks; stdout is the answer
|
|
145
|
+
chi send --wait -m "and the tests?" 3fa2 # a follow-up in the same session, waits too
|
|
146
|
+
chi send --wait 3fa2 # sends nothing: waits for its next reply
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
- `--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).
|
|
150
|
+
- `--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.
|
|
151
|
+
- `--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.
|
|
152
|
+
- Exit codes with `--wait`: 0 answered; 3 the turn waits for an answer from you (a question or a guardrail approval), with the line `waiting for an answer: …; open it: chi --attach ID or the web`, and the session keeps waiting; 1 the turn ended without a reply (canceled, failed or empty), the worker failed or vanished, the session was stopped, or `--timeout S` passed; 130 on Ctrl-C, which leaves the turn running (`still running: chi --attach ID`). There is no default timeout.
|
|
153
|
+
- `--wait ID` with no message (no `-m`, nothing piped) sends nothing: it waits for the session's next reply and prints and exits as above. A running session is fine (that is the point), so after exit 3 or 130 an agent waits again this way while the user answers in the web or `chi --attach`; the question pending when it starts doesn't count again. An idle session with no worker waits until something wakes one. With `--new`, or two ids, it is a usage error (exit 2).
|
|
154
|
+
- The 16 KiB cap applies: `git diff | chi send --new …` on a big diff is refused; name the branch in the message instead and let the session read it.
|
|
155
|
+
- A session nobody attaches to stalls at its first guardrail ask until someone opens it; left alone it idle-exits after `session.idle_exit_minutes` like any worker. These are ordinary sessions: delete them like any other.
|
|
156
|
+
|
|
133
157
|
## Delegating
|
|
134
158
|
|
|
135
159
|
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.
|
|
@@ -144,12 +168,14 @@ The `delegate` tool hands a task to a **child session**: an ordinary chi session
|
|
|
144
168
|
**Ordering:**
|
|
145
169
|
|
|
146
170
|
- `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.
|
|
147
|
-
- 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 browser remembers the last choice; a running session's model is in the info bar and changes only with `/model`.
|
|
148
|
-
-
|
|
171
|
+
- 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`.
|
|
172
|
+
- 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).
|
|
173
|
+
- 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`).
|
|
149
174
|
- 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).
|
|
150
175
|
|
|
151
176
|
**Test-session hygiene:**
|
|
152
177
|
|
|
153
178
|
- New sessions set `test_run:true` when `SAMAGOTCHI_ENV=test` or `RACK_ENV=test` or `CI` is set (explicit flag, `metadata_version` 2). Old sessions without the flag load as `test_run:false`.
|
|
154
179
|
- Test runs are tagged and obey the same retention. `chi sessions clean` deletes every test session whatever its age (`--days N`: only those older than N days); a live worker or a `keep_status` status still keeps one. `chi sessions prune --test-only` applies the usual age and count rules to test sessions only.
|
|
180
|
+
- A `chi scratch` session (`"scratch": true`) is deleted when its REPL ends; one a killed process left behind is deleted by the next sweep, `prune` or `clean`, whatever its age or status, once nobody owns it. `list` shows it as `[scratch]` (every text form; `scratch: true` in `--format json`); `chi web` never shows one. See [CLI: Scratch sessions](cli.md#scratch-sessions).
|
|
155
181
|
- For ad-hoc manual QA use `SAMAGOTCHI_ENV=test XDG_STATE_HOME=/tmp/chi-test-$USER chi ...` to isolate from real state; the flag also marks sessions that `chi web` or an attached `chi` spawn (their workers inherit the environment), so `clean` finds them if they land in the real state.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "log"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# How a turn's answer is shown, apart from what the model said: an
|
|
7
|
+
# after_turn hook (or a plugin's on(:after_turn)) calls event[:present]
|
|
8
|
+
# with a block that gets the current display text and returns the new
|
|
9
|
+
# one. The result is kept as `display` on the answer's model message in
|
|
10
|
+
# session.json; the web renders it instead of `content`. It is display
|
|
11
|
+
# only: nothing that talks to a model reads it (the payload builders take
|
|
12
|
+
# the fields they send, and the copies handed to hooks, plugins and the
|
|
13
|
+
# recap leave it out, see .strip).
|
|
14
|
+
#
|
|
15
|
+
# The target is the stored conversation's last message when it is the
|
|
16
|
+
# model's answer; a turn that ended any other way (cancelled, failed,
|
|
17
|
+
# empty: a turn note is last) has none and event[:present] does nothing.
|
|
18
|
+
class AnswerDisplay
|
|
19
|
+
KEY = :display
|
|
20
|
+
# A display text longer than this is refused (the display stays as it
|
|
21
|
+
# was): a hook must not bloat session.json or the page.
|
|
22
|
+
MAX_CHARS = 200_000
|
|
23
|
+
|
|
24
|
+
# A message as a model-facing reader may see it: without `display`.
|
|
25
|
+
# The same object when it has none.
|
|
26
|
+
def self.strip(message)
|
|
27
|
+
return message unless message.is_a?(Hash) && (message.key?(KEY) || message.key?(KEY.to_s))
|
|
28
|
+
|
|
29
|
+
message.reject { |key, _| key.to_s == KEY.to_s }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.strip_all(messages)
|
|
33
|
+
Array(messages).map { |message| strip(message) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Hash, nil] the message event[:present] changes
|
|
37
|
+
attr_reader :target
|
|
38
|
+
# @return [String, nil] the display text so far
|
|
39
|
+
attr_reader :text
|
|
40
|
+
|
|
41
|
+
# @param messages [Array<Hash>] the conversation the turn stored
|
|
42
|
+
def initialize(messages)
|
|
43
|
+
last = Array(messages).last
|
|
44
|
+
@target = last if last.is_a?(Hash) && field(last, :role).to_s == "model"
|
|
45
|
+
@original = @target && (field(@target, KEY) || field(@target, :content)).to_s
|
|
46
|
+
@text = @original
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# True when a hook set a display text other than what was shown before.
|
|
50
|
+
def changed?
|
|
51
|
+
!@target.nil? && @text != @original
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# event[:present]: call it with a block; the block gets the current
|
|
55
|
+
# display text (the answer's content until a hook changed it) and
|
|
56
|
+
# returns the new one. A block that raises, returns something other than
|
|
57
|
+
# a String, or returns more than MAX_CHARS leaves the text unchanged
|
|
58
|
+
# (logged, named by event[:hook]).
|
|
59
|
+
# @param event [Hash] the after_turn event (for its :hook label)
|
|
60
|
+
# @return [Proc] returns the display text after the call, or nil when
|
|
61
|
+
# the turn has no answer to present
|
|
62
|
+
def presenter(event)
|
|
63
|
+
lambda do |&block|
|
|
64
|
+
next nil unless @target
|
|
65
|
+
next @text unless block
|
|
66
|
+
|
|
67
|
+
apply(block, event[:hook])
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def apply(block, hook)
|
|
74
|
+
value = begin
|
|
75
|
+
block.call(@text.dup)
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
return reject(hook, "raised #{e.class}: #{e.message}")
|
|
78
|
+
end
|
|
79
|
+
return reject(hook, "returned #{value.class}, not a String") unless value.is_a?(String)
|
|
80
|
+
return reject(hook, "returned #{value.length} characters (max #{MAX_CHARS})") if value.length > MAX_CHARS
|
|
81
|
+
|
|
82
|
+
@text = value.dup
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def reject(hook, why)
|
|
86
|
+
Log.warn(:hooks, "present_rejected", echo: "[samagotchi:hooks] #{hook}: present #{why}; display unchanged",
|
|
87
|
+
hook: hook.to_s)
|
|
88
|
+
@text
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def field(message, key)
|
|
92
|
+
message.key?(key) ? message[key] : message[key.to_s]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
require_relative "session"
|
|
8
|
+
|
|
9
|
+
module Samagotchi
|
|
10
|
+
# A session's archive marker, <session dir>/archived (JSON):
|
|
11
|
+
# {"archived_at": t} archived: hidden from every list, kept by the
|
|
12
|
+
# retention sweep, not counted in its max_count
|
|
13
|
+
# {"unarchived_at": t} archived once, then unarchived: the sweep ages
|
|
14
|
+
# the session from max(updated_at, unarchived_at)
|
|
15
|
+
# A separate file, not a session.json field: Session#save rewrites that
|
|
16
|
+
# file from memory (and bumps updated_at) at every turn.
|
|
17
|
+
module ArchiveStore
|
|
18
|
+
FILE = "archived"
|
|
19
|
+
# Input a human typed: a web tab, a chi TUI, `chi send`, and nil (a
|
|
20
|
+
# worker's initial prompt; a web client may send none). Delegates,
|
|
21
|
+
# plugins and reminders are not (an allowlist, so a new automatic
|
|
22
|
+
# origin stays out).
|
|
23
|
+
USER_CLIENT_PREFIXES = %w[web: tui:].freeze
|
|
24
|
+
USER_CLIENT_IDS = ["cli:send"].freeze
|
|
25
|
+
|
|
26
|
+
# @return [Hash, nil] the marker (string keys), nil when none or unreadable
|
|
27
|
+
def self.read(session_dir)
|
|
28
|
+
data = JSON.parse(File.read(File.join(session_dir, FILE)))
|
|
29
|
+
data.is_a?(Hash) ? data : nil
|
|
30
|
+
rescue StandardError
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.archived?(session_dir)
|
|
35
|
+
read(session_dir)&.key?("archived_at") == true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [Time, nil] when the session was last unarchived
|
|
39
|
+
def self.unarchived_at(session_dir)
|
|
40
|
+
value = read(session_dir)&.fetch("unarchived_at", nil)
|
|
41
|
+
value && Time.iso8601(value.to_s)
|
|
42
|
+
rescue ArgumentError
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [Boolean] whether the marker was written (false: the session
|
|
47
|
+
# file is gone, so its dir is never recreated)
|
|
48
|
+
def self.archive(session_id, state_dir:)
|
|
49
|
+
write(session_id, { "archived_at" => Time.now.iso8601(3) }, state_dir: state_dir)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Boolean] whether it was archived (and is not now)
|
|
53
|
+
def self.unarchive(session_id, state_dir:)
|
|
54
|
+
return false unless archived?(Session.session_dir(session_id, state_dir: state_dir))
|
|
55
|
+
|
|
56
|
+
write(session_id, { "unarchived_at" => Time.now.iso8601(3) }, state_dir: state_dir)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Whether +client_id+ (a turn's origin) is a human's input.
|
|
60
|
+
def self.user_input?(client_id)
|
|
61
|
+
return true if client_id.nil?
|
|
62
|
+
|
|
63
|
+
id = client_id.to_s
|
|
64
|
+
USER_CLIENT_IDS.include?(id) || USER_CLIENT_PREFIXES.any? { |prefix| id.start_with?(prefix) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# A human's input came into the session: it is back in the lists. Its
|
|
68
|
+
# children stay archived. Never raises.
|
|
69
|
+
# @return [Boolean] whether it was archived
|
|
70
|
+
def self.user_input(session_id, state_dir:)
|
|
71
|
+
return false if session_id.nil?
|
|
72
|
+
|
|
73
|
+
unarchive(session_id, state_dir: state_dir)
|
|
74
|
+
rescue StandardError
|
|
75
|
+
false
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.write(session_id, record, state_dir:)
|
|
79
|
+
return false unless File.exist?(File.join(state_dir, "#{session_id}#{Session::FILE_EXT}"))
|
|
80
|
+
|
|
81
|
+
dir = Session.session_dir(session_id, state_dir: state_dir)
|
|
82
|
+
FileUtils.mkdir_p(dir)
|
|
83
|
+
path = File.join(dir, FILE)
|
|
84
|
+
File.write("#{path}.tmp", JSON.generate(record))
|
|
85
|
+
File.rename("#{path}.tmp", path)
|
|
86
|
+
true
|
|
87
|
+
end
|
|
88
|
+
private_class_method :write
|
|
89
|
+
end
|
|
90
|
+
end
|