samagotchi 0.109.0 → 0.110.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 +34 -1
- data/bin/chi +5 -1
- data/docs/architecture.md +1 -1
- data/docs/cli.md +40 -1
- data/docs/configuration.md +97 -3
- data/docs/internals/engine.md +19 -4
- data/docs/internals/participants.md +6 -3
- data/docs/internals/prompt-caching.md +5 -0
- data/docs/plugins.md +1 -1
- data/docs/sessions.md +3 -1
- data/docs/sub-agent.md +1 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/context_note.rb +7 -3
- data/lib/samagotchi/engine.rb +20 -9
- data/lib/samagotchi/idle_recap.rb +1 -1
- data/lib/samagotchi/idle_reminders.rb +1 -1
- data/lib/samagotchi/kernel_loop.rb +12 -7
- data/lib/samagotchi/llm/chat_loop.rb +9 -2
- data/lib/samagotchi/message_time.rb +104 -0
- data/lib/samagotchi/reminder_queue.rb +20 -3
- data/lib/samagotchi/reply_wait/poll.rb +39 -1
- data/lib/samagotchi/run_options.rb +4 -1
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +11 -5
- data/lib/samagotchi/session_inbox.rb +7 -3
- data/lib/samagotchi/steer.rb +5 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +70 -9
- data/lib/samagotchi/terminal_ui/between_turns.rb +8 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +13 -5
- data/lib/samagotchi/terminal_ui/gap_clock.rb +78 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +20 -2
- data/lib/samagotchi/terminal_ui.rb +40 -5
- data/lib/samagotchi/tool_activity.rb +11 -0
- data/lib/samagotchi/turn_checkpoint.rb +6 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +15 -6
- data/lib/samagotchi/web/participant_view.rb +12 -5
- data/lib/samagotchi/web/public/app.js +11 -3
- data/lib/samagotchi/web/public/bubbles.js +29 -7
- data/lib/samagotchi/web/public/format.js +72 -6
- data/lib/samagotchi/web/public/index.html +2 -0
- data/lib/samagotchi/web/public/participant.html +2 -0
- data/lib/samagotchi/web/public/participant.js +30 -7
- data/lib/samagotchi/web/public/participant_model.js +25 -2
- data/lib/samagotchi/web/public/stream_handlers.js +32 -4
- data/lib/samagotchi/web/public/time_gaps.js +98 -0
- data/lib/samagotchi/web/public/tokens.css +2 -2
- data/lib/samagotchi/web/public/turn_events.js +3 -1
- data/lib/samagotchi/web/public/turn_html.js +15 -8
- data/lib/samagotchi/web/public/turn_view.js +8 -3
- data/lib/samagotchi/web/running.rb +39 -4
- data/lib/samagotchi/web/server.rb +87 -31
- data/lib/samagotchi/web/tunnel/tailscale.rb +325 -0
- data/lib/samagotchi/web/tunnel.rb +133 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '097197f78e75225202f6e84b40f7449f063416402e3ac1820015a824c82fc64b'
|
|
4
|
+
data.tar.gz: 7ac596f01db65fc81761acccfade8c99c5543b963539083652cbb3022923ae13
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3b5aca203f09584369fe954da86514837eabd3addf2e3bc91f3f74ae1202f726b4170c99c1b591a999ffb8c23da7ce3f51d2f746ecc81a84fabef376c238b7fb
|
|
7
|
+
data.tar.gz: d4df1f1ba87e37f81af6440c55c1aa3d0749c7abc734a53d9ec7356a35e9a513684495feb550138032f67c0e493cdacc97ce69ea29407fa407b1a07d7b22a20d
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,38 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.110.0] - 2026-10-24
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Experimental: `chi web --tunnel tailscale`: chi web on your phone anywhere on your tailnet over https (Tailscale
|
|
16
|
+
Serve on this machine's `ts.net` name, never Funnel), with your Tailscale login as the owner (or
|
|
17
|
+
`web.proxy.owner`), for this run only (nothing written to config.yml). It checks Tailscale, the certificate and
|
|
18
|
+
what Serve already has on port 443 before it changes anything, adopts a serve left by an earlier run, refuses
|
|
19
|
+
anything else on 443, prints the owner link's QR code, warns when Tailnet Lock is off, and turns the serve off on
|
|
20
|
+
exit (only while 443 still serves exactly what it set). The proxied start line (`web.public_url`) also shows its
|
|
21
|
+
link's QR code at a terminal now. See docs/configuration.md (Tailscale Serve).
|
|
22
|
+
- Sessions record when each message happened: prompts, lines merged into a turn, reminders, notes and the model's
|
|
23
|
+
replies carry `at` in the session file (never sent to the model); plugins see it in `ctx.messages`.
|
|
24
|
+
- Reminders tell the model the local time they came due (`[SYSTEM: REMINDERS DUE 2026-10-24 14:32 local]`), so it
|
|
25
|
+
can reason about how long ago something happened.
|
|
26
|
+
- Terminal (attached and the REPL): a dim `── 14:32 · 47 min later ──` divider before what comes after 10 minutes
|
|
27
|
+
or more of quiet, a `── last activity 14:32 · 47 min ago ──` line when attaching after such a gap, and the recap's
|
|
28
|
+
time in its head (`recap 14:32>`). See docs/cli.md (Time gaps).
|
|
29
|
+
- Web: a dim `── 14:32 · 47 min later ──` line in the history where 10 minutes or more of quiet passed between
|
|
30
|
+
turns, notes or reminders (live and after a reload, the participant's review page too), each prompt, answer and
|
|
31
|
+
note's full time on hover, and the recap's time in its label (`recap · 14:32`).
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- `chi send --wait` with no message on a running turn that has no turn id (a session's first prompt, a reminder
|
|
36
|
+
turn), and a `chi send --wait -m` whose message merged into the running turn, end `canceled` (`worker_stopped`)
|
|
37
|
+
when that turn's worker dies and the next worker settles it, instead of waiting until `--timeout`.
|
|
38
|
+
- Attached TUI: joining an older session (one with no saved record of the call) shows a call the guardrails denied
|
|
39
|
+
as `blocked`, not `error`.
|
|
40
|
+
|
|
41
|
+
Update with `chi update` (the system bundle 0.110.0), then restart `chi web`.
|
|
42
|
+
|
|
11
43
|
## [0.109.0] - 2026-10-24
|
|
12
44
|
|
|
13
45
|
### Added
|
|
@@ -4196,7 +4228,8 @@ and long-lived sessions.
|
|
|
4196
4228
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
4197
4229
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
4198
4230
|
|
|
4199
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
4231
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.110.0...HEAD
|
|
4232
|
+
[0.110.0]: https://github.com/dm1try/samagotchi/compare/v0.109.0...v0.110.0
|
|
4200
4233
|
[0.109.0]: https://github.com/dm1try/samagotchi/compare/v0.108.0...v0.109.0
|
|
4201
4234
|
[0.108.0]: https://github.com/dm1try/samagotchi/compare/v0.107.0...v0.108.0
|
|
4202
4235
|
[0.107.0]: https://github.com/dm1try/samagotchi/compare/v0.106.0...v0.107.0
|
data/bin/chi
CHANGED
|
@@ -120,6 +120,10 @@ if options[:new_token] && !web
|
|
|
120
120
|
warn "Error: --new-token goes with chi web (chi web --new-token)"
|
|
121
121
|
exit 1
|
|
122
122
|
end
|
|
123
|
+
if options[:tunnel] && !web
|
|
124
|
+
warn "Error: --tunnel goes with chi web (chi web --tunnel tailscale)"
|
|
125
|
+
exit 2
|
|
126
|
+
end
|
|
123
127
|
if options[:sets] && web
|
|
124
128
|
warn "Error: --set is a new session's (chi --set, chi send --new --set), not chi web's"
|
|
125
129
|
exit 2
|
|
@@ -208,7 +212,7 @@ if options[:web]
|
|
|
208
212
|
markdown = Samagotchi::Config.get("web.markdown") if markdown.nil?
|
|
209
213
|
view = Samagotchi::Config.get("web.view")
|
|
210
214
|
exit Samagotchi::Web::Server.launch(port: port, scope: options[:scope] || "project", open_browser: options[:web_open],
|
|
211
|
-
markdown: markdown, view: view, new_token: options[:new_token],
|
|
215
|
+
markdown: markdown, view: view, new_token: options[:new_token], tunnel: options[:tunnel],
|
|
212
216
|
working_set: Samagotchi::Config.get("web.working_set"),
|
|
213
217
|
annotate_presets: Samagotchi::Config.get("web.annotate_presets"),
|
|
214
218
|
editor: Samagotchi::Config.get("web.editor"))
|
data/docs/architecture.md
CHANGED
|
@@ -700,7 +700,7 @@ A map of the parts the overview above doesn't name (paths under `lib/samagotchi/
|
|
|
700
700
|
| LLM context strategies | `llm_context_strategy.rb`, `llm_context_view.rb`, `llm_context_stale.rb`, `llm_context_forget.rb` | What a request sends of the history (none, stale, forget) ([internals/llm-context-forget.md](internals/llm-context-forget.md)). |
|
|
701
701
|
| Session chains | `session_continue.rb` (`SessionContinue`), `session_chain.rb` (`SessionChain`) | A session that continues an earlier one. `SessionChain.opener_for` gives the text web `continue →` prefills in the new link: the previous link's own first typed message when that link continues one itself, else nil (the 201's `opener`; not saved) ([sessions.md](sessions.md)). |
|
|
702
702
|
| Review sessions | `review_start.rb` (`ReviewStart`), `session_review.rb` (`SessionReview`, the session's `review` field), `review_command.rb` (`ReviewCommand`, `chi review`), `review_apply.rb` (`ReviewApply`) | A session made to be shared: idle, its subjects project files checked by `Guardrails::ReviewPaths`, a fork of a working session from its recap (`--from`); Apply quotes a participant's review turn into the owner's composer ([sessions.md](sessions.md#review-sessions), [internals/participants.md](internals/participants.md)). |
|
|
703
|
-
| Behind an auth proxy | `web/public_url.rb` (`Web::PublicUrl`: `web.public_url`, `web.proxy.identity_header`, `web.proxy.owner`), `web/proxy_gate.rb` (`Web::ProxyGate`, mixed into App; `RawHeaderNames`, prepended to WEBrick's request by `Web::Server`), `web/link_codes.rb` (`Web::LinkCodes`: the links' one-time codes), `proxy_trace.rb` (`ProxyTrace`), `web/public/token_fragment.js`, `web/public/auth_lost.js` | Experimental: chi web behind an auth proxy. Every request needs the token; a proxied one's owner also needs the identity header (one clean value under one name); the `__Host-` cookie, an absolute `Location` from the public URL, https links with a one-time code in the fragment; the page's lost-sign-in banner. Bridges refuse proxy traces ([configuration.md](configuration.md#behind-an-auth-proxy)). |
|
|
703
|
+
| Behind an auth proxy | `web/public_url.rb` (`Web::PublicUrl`: `web.public_url`, `web.proxy.identity_header`, `web.proxy.owner`), `web/proxy_gate.rb` (`Web::ProxyGate`, mixed into App; `RawHeaderNames`, prepended to WEBrick's request by `Web::Server`), `web/link_codes.rb` (`Web::LinkCodes`: the links' one-time codes), `proxy_trace.rb` (`ProxyTrace`), `web/public/token_fragment.js`, `web/public/auth_lost.js`, `web/tunnel.rb` + `web/tunnel/tailscale.rb` (`Web::Tunnel`, `Web::Tunnel::Tailscale`: `chi web --tunnel tailscale`, Tailscale Serve set for one run and the PublicUrl it makes) | Experimental: chi web behind an auth proxy. Every request needs the token; a proxied one's owner also needs the identity header (one clean value under one name); the `__Host-` cookie, an absolute `Location` from the public URL, https links with a one-time code in the fragment; the page's lost-sign-in banner. Bridges refuse proxy traces ([configuration.md](configuration.md#behind-an-auth-proxy)). |
|
|
704
704
|
| Participants | `participants.rb` (`Participants`, the `participants.json` sidecar), `share_command.rb` (`ShareCommand`, `chi share`), `session_participants.rb`, `participants_watch.rb`, `web/grants.rb` (`Web::Grants`), `web/principal.rb` (`Web::Principal`), `web/participant_access.rb` (`Web::ParticipantAccess`: the participant's gate and routes), `web/participants_admin.rb` (`Web::ParticipantsAdmin`: the owner's invite, list, revoke, cap and Apply routes), `web/participant_review.rb` + `web/participant_view.rb` (`Web::ParticipantReview`, `Web::ParticipantView`), `web/participant_stream_filter.rb`, `guardrails/review_boundary.rb` + `guardrails/review_paths.rb` | The people or agents an owner invites to review one session through chi web: their grants and gate, what they read, their read-only review turns ([internals/participants.md](internals/participants.md), [guardrails.md](guardrails.md)). |
|
|
705
705
|
| Analytics | `analytics_file.rb` (`AnalyticsFile`), `session_metrics/totals.rb` (`SessionMetrics::Totals`), `session_metrics/model_segment.rb` (`SessionMetrics::ModelSegment`) | A session's `analytics.json` (`SessionMetrics#persist`); the sums over its turn records (`Totals`: tokens, cost, speeds, kept as running counters) and their split per model after a `/model` switch (`ModelSegment`: a run of turns on one model, the `model_segments` rows); `session_metrics/cost.rb` (`SessionMetrics::Cost`): the spend `ctx.cost` reads; `session_metrics/usage.rb` (`SessionMetrics::Usage`): the work `ctx.usage` reads. `session_metrics/side_totals.rb` (`SessionMetrics::SideTotals`) and `side_usage.rb` (`SideUsage`): the side requests' sums, apart from the turns'. |
|
|
706
706
|
| Images / vision | `image_store.rb` (`ImageStore`), `vision_support.rb`, `vision_context.rb` | Stored prompt images and whether a model takes them. |
|
data/docs/cli.md
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
- A session id can be shortened to any unique prefix (like git): `chi --attach 2ea8`. `--resume`, `--attach`, `sessions stop`, `sessions archive` and `sessions delete` take one; an ambiguous prefix lists the sessions it matches.
|
|
14
14
|
- `chi web [--port 4567] [--open] [--scope=all]` — start the Web UI (single localhost port session control plane) on this git project's sessions (`--scope=all`, or a folder in no repo: every session); if a chi web already runs on the port, print (with `--open`, open) its page for this folder and exit. Something else on the port (an older chi web too) exits 1 with "port N is in use"
|
|
15
15
|
- `chi web --web-host lan` — the Web UI on your home network too, for your phone: a link with an access token and its QR code (see [chi web on your phone](#chi-web-on-your-phone)); `chi web --new-token` replaces the token
|
|
16
|
+
- `chi web --tunnel tailscale` — the Web UI over https on your tailnet, for your phone anywhere: Tailscale Serve (never Funnel) on this machine's `<machine>.<tailnet>.ts.net` name for this run, the owner your Tailscale login, turned off on exit (see [Tailscale Serve](configuration.md#tailscale-serve-your-tailnet)); `--tunnel` without `web` exits 2
|
|
16
17
|
- `chi web --web-markdown` — opt in to sanitized Markdown rendering for completed assistant messages, the stage's asking text and a resolved question card's reply
|
|
17
18
|
- `chi web --web-view turn` — draw turns with the turn view (each turn as one block of steps, the running one at the bottom of the history) instead of the default stage view (the running turn pinned above the composer); `?view=stage|turn` on the page URL overrides it (see [Web views](#web-views))
|
|
18
19
|
- `chi web --no-web-working-set` — turns the stage view's working set off (on by default; `--web-working-set` turns a config's `web.working_set: false` back on): what each turn brought into the model's context, in the stage and under each finished turn; `?working_set=1|0` on the page URL overrides it (see [The working set](#the-working-set))
|
|
@@ -1012,6 +1013,16 @@ steps saved before that have none, so they show no thinking. An `edit` or
|
|
|
1012
1013
|
made (up to 120 lines or 8 KB), live and after a reload (see
|
|
1013
1014
|
[Guardrails](guardrails.md#ask) for the diff an approval shows first).
|
|
1014
1015
|
|
|
1016
|
+
**When things happened**, in both views and on a participant's review page: where 10 minutes or
|
|
1017
|
+
more of quiet passed between the end of one activity and the start of the next (a turn, a context
|
|
1018
|
+
note, a reminder), the history has one dim centred line, `── 14:32 · 47 min later ──`; the date is
|
|
1019
|
+
added when the day changed or the turn wasn't today (`── Sat 24 Oct 09:10 · 13 h later ──`).
|
|
1020
|
+
Nothing marks active work: a long turn followed at once by the next has no line. Hovering a prompt,
|
|
1021
|
+
an answer, a note or a reminder shows its full local time, and the recap's label says when it was
|
|
1022
|
+
written (`recap · 14:32`). The times are the history entries' `at` (see
|
|
1023
|
+
[sessions.md](sessions.md)); a session saved before them takes its turns' times from their timing
|
|
1024
|
+
records, and shows no hover times.
|
|
1025
|
+
|
|
1015
1026
|
### The working set
|
|
1016
1027
|
|
|
1017
1028
|
The stage view shows what a turn brought into the model's context: the files
|
|
@@ -1164,6 +1175,9 @@ access token", paste the token there (the part of the link after
|
|
|
1164
1175
|
office guest network), and run `chi web --new-token` if a link leaks.
|
|
1165
1176
|
- On http the browser has no notifications: the bell is hidden on the phone,
|
|
1166
1177
|
and the tab title still counts what needs you.
|
|
1178
|
+
- Off the home Wi-Fi: `chi web --tunnel tailscale` gives chi web an https
|
|
1179
|
+
name on your tailnet (Tailscale Serve) for its run and prints its link's QR
|
|
1180
|
+
code; see [Tailscale Serve](configuration.md#tailscale-serve-your-tailnet).
|
|
1167
1181
|
|
|
1168
1182
|
### Remote sessions
|
|
1169
1183
|
|
|
@@ -1332,7 +1346,10 @@ chi share revoke --all # every link to every session
|
|
|
1332
1346
|
- `chi --remote studio share …` invites on studio: the link names studio's LAN address. Locally, the link names
|
|
1333
1347
|
this machine's running chi web, on the port it listens on (it records it in `$XDG_STATE_HOME/samagotchi/web.json`;
|
|
1334
1348
|
`web.port` when none answers there); without one on the LAN, chi says the link opens on this machine only (as
|
|
1335
|
-
the participant there too: a participant's link never opens as the owner).
|
|
1349
|
+
the participant there too: a participant's link never opens as the owner). With `web.public_url` the link is
|
|
1350
|
+
`https://<public url>/#code=…`; with `chi web --tunnel tailscale` too (its `ts.net` name, which web.json records
|
|
1351
|
+
while that chi web answers on its port: a chi web on another port takes web.json over, and the link falls back to
|
|
1352
|
+
127.0.0.1). A tunnel link opens only on a device on your tailnet; the grant still decides.
|
|
1336
1353
|
- The owner's own token and the `chi web --new-token` rotation are separate: rotating it ends no participant's link.
|
|
1337
1354
|
- **In the attached terminal.** A participant's message reads `Alex ▸ review> is step 3 right?`, the name in that
|
|
1338
1355
|
participant's own colour (queued, running, and the last exchange when you join); yours stay as they were. A line
|
|
@@ -1433,6 +1450,28 @@ working on, what came of it and what is still open, not a turn-by-turn log.
|
|
|
1433
1450
|
- It uses the session's own model unless `recap:` names one;
|
|
1434
1451
|
`recap: false` turns it off. See configuration.md.
|
|
1435
1452
|
|
|
1453
|
+
## Time gaps (terminal)
|
|
1454
|
+
|
|
1455
|
+
Both terminals (attached and the REPL) show when things happened without a
|
|
1456
|
+
time on every line:
|
|
1457
|
+
|
|
1458
|
+
- A dim divider marks a quiet gap of 10 minutes or more, before what comes
|
|
1459
|
+
next: `── 14:32 · 47 min later ──`, the date added when the day changed
|
|
1460
|
+
(`── Sat 24 Oct 09:10 · 13 h later ──`). The gap runs from the end of the
|
|
1461
|
+
last activity (a turn's end, a reminder, a context note, a recap) to the
|
|
1462
|
+
start of the next (a prompt, yours above its line or another UI's, a
|
|
1463
|
+
reminder turn, a note, a recap), so nothing is marked during a turn or
|
|
1464
|
+
between turns run back to back. A command (`/stats`, `!cmd`) is no
|
|
1465
|
+
activity.
|
|
1466
|
+
- Attaching to a session whose last activity was 10 minutes ago or more
|
|
1467
|
+
says when, after the join block: `── last activity 14:32 · 47 min ago ──`
|
|
1468
|
+
(from the newest timed message, else the saved recap's time). With
|
|
1469
|
+
`--resume` the REPL marks the gap before the first prompt instead.
|
|
1470
|
+
- A recap names the local time it was written: `recap 14:32>`, `recap 14:32
|
|
1471
|
+
(before the last 2 turns)>`.
|
|
1472
|
+
- Only live activity and the join: neither terminal replays the history, so
|
|
1473
|
+
older gaps aren't marked. `--non-interactive` prints none.
|
|
1474
|
+
|
|
1436
1475
|
## Tool Activity Log
|
|
1437
1476
|
|
|
1438
1477
|
Samagotchi now prints a concise, human-friendly tool activity log in normal
|
data/docs/configuration.md
CHANGED
|
@@ -1591,8 +1591,9 @@ web:
|
|
|
1591
1591
|
- Only a viewer on this machine opens the editor: a loopback request, or this machine on its own LAN address
|
|
1592
1592
|
(`web.host: lan`, the LAN URL or its QR code opened here). A phone gets the targets that need no editor (an
|
|
1593
1593
|
answer's ref shows its row in the turn; see the refs in [sessions.md](sessions.md)); a row's title stays plain
|
|
1594
|
-
text there. An SSH tunnel or a local reverse proxy (`tailscale serve`) looks like loopback
|
|
1595
|
-
|
|
1594
|
+
text there. An SSH tunnel or a local reverse proxy (`tailscale serve`) looks like loopback unless `web.public_url`
|
|
1595
|
+
is set (then a request through it is never this machine's viewer), so without it a remote viewer behind one gets an
|
|
1596
|
+
editor target that can't open.
|
|
1596
1597
|
- `GET /api/sessions/:id` (and its `?tail=1`) carries `capabilities.editor` (true for such a viewer) and
|
|
1597
1598
|
`editor_url`, the template (null otherwise).
|
|
1598
1599
|
|
|
@@ -1668,12 +1669,105 @@ one.
|
|
|
1668
1669
|
| nginx `auth_request` + oauth2-proxy (`--set-xauthrequest`) | `X-Auth-Request-Email` | Only as nginx sets it from the auth reply (`auth_request_set $email $upstream_http_x_auth_request_email; proxy_set_header X-Auth-Request-Email $email;`), which replaces a client's copy; nginx drops underscore spellings by default. Checked with nginx 1.27. |
|
|
1669
1670
|
| Caddy `forward_auth` (`copy_headers X-Auth-Request-Email`) | the copied header | Replaced from the auth reply; Caddy passes an underscore spelling through, which chi refuses. Checked with Caddy 2.8. |
|
|
1670
1671
|
| Cloudflare Access | `Cf-Access-Authenticated-User-Email` | Not checked yet: verify with an echo. chi trusts the header only; it doesn't validate the `Cf-Access-Jwt-Assertion` token. |
|
|
1671
|
-
| Tailscale Serve | `Tailscale-User-Login` |
|
|
1672
|
+
| Tailscale Serve | `Tailscale-User-Login` | Replaced, not appended (`Tailscale-User-Name`, `X-Forwarded-For`, `X-Forwarded-Host` too); `X-Forwarded-For` = the requesting device's tailnet IP, Host = the public name. Tailnet only. Checked with Tailscale 1.104.2 (macOS). Not checked: Funnel and tagged-device requests; never use Funnel. See [Tailscale Serve](#tailscale-serve-your-tailnet). |
|
|
1672
1673
|
| Authelia, Authentik (behind nginx, Caddy, Traefik) | `Remote-Email` (Authelia), `X-Authentik-Email` (Authentik) | Not checked yet; only as the front proxy copies it from the auth reply (the nginx and Caddy rows). |
|
|
1673
1674
|
| A TLS proxy with no sign-in (nginx, Caddy, an SSH tunnel) | leave unset | The token alone decides. |
|
|
1674
1675
|
|
|
1675
1676
|
Keep the state dir (`web-token`, the grants) out of any folder others can read, such as a team's shared folder.
|
|
1676
1677
|
|
|
1678
|
+
### Tailscale Serve (your tailnet)
|
|
1679
|
+
|
|
1680
|
+
chi web on your phone away from the home Wi-Fi: [Tailscale](https://tailscale.com) Serve gives this Mac an https
|
|
1681
|
+
name on your tailnet, `https://<machine>.<tailnet>.ts.net`, with a real certificate, and tells chi web who is asking
|
|
1682
|
+
(your Tailscale login, next to chi's token). Only devices on your tailnet reach it: with Tailscale off on the phone the
|
|
1683
|
+
name doesn't load. By hand, on the Mac that runs chi web (or steps 1–2, then
|
|
1684
|
+
[`chi web --tunnel tailscale`](#chi-web---tunnel-tailscale) does 3–5 for one run):
|
|
1685
|
+
|
|
1686
|
+
1. Install Tailscale on the Mac and the phone and sign both in with the same login. **Rename the machines first**
|
|
1687
|
+
(admin console → Machines): HTTPS certificates go into the public Certificate Transparency log, so the
|
|
1688
|
+
`<machine>.<tailnet>.ts.net` name is public once a certificate is issued, and a later rename puts a new name there.
|
|
1689
|
+
2. Turn on HTTPS certificates (admin console → DNS; they need MagicDNS on).
|
|
1690
|
+
3. Serve chi web's port on the tailnet, never on the internet:
|
|
1691
|
+
|
|
1692
|
+
```sh
|
|
1693
|
+
tailscale serve --bg 4567
|
|
1694
|
+
```
|
|
1695
|
+
|
|
1696
|
+
On macOS the app's CLI is `/Applications/Tailscale.app/Contents/MacOS/Tailscale` unless you installed `tailscale`
|
|
1697
|
+
on your PATH. `tailscale serve status` shows what it serves; `tailscale serve --https=443 off` turns it off. Turn
|
|
1698
|
+
it off when you stop using it: while it is on, whatever listens on 127.0.0.1:4567 is reachable from your tailnet.
|
|
1699
|
+
4. Tell chi web its public name and whose login is the owner (config.yml; `chi web` reads it at start):
|
|
1700
|
+
|
|
1701
|
+
```yaml
|
|
1702
|
+
web:
|
|
1703
|
+
public_url: https://chi-host.example-tailnet.ts.net
|
|
1704
|
+
proxy:
|
|
1705
|
+
identity_header: Tailscale-User-Login
|
|
1706
|
+
owner: owner@example.com # your Tailscale login
|
|
1707
|
+
```
|
|
1708
|
+
|
|
1709
|
+
Your login: `tailscale whois <the phone's 100.x IP>` (the User block), or `tailscale status --json`
|
|
1710
|
+
(`User["<Self.UserID>"].LoginName`), or start without `owner` and read it off the 401 page's detail ("owner token
|
|
1711
|
+
with identity …"). A login made with Sign in with Apple is a private-relay address (`…@privaterelay.appleid.com`):
|
|
1712
|
+
it is stable, use it as it is. Every device signed in with that login (the phone, a laptop, an iPad) is the owner,
|
|
1713
|
+
each with the token too.
|
|
1714
|
+
5. `chi web` prints `Proxied: https://chi-host.example-tailnet.ts.net/#code=chc_…`; open that link on the phone (copy,
|
|
1715
|
+
AirDrop or type it). The code works once, within 10 minutes (`chi web` again prints a new one); the phone then
|
|
1716
|
+
keeps the `__Host-chi_token` cookie.
|
|
1717
|
+
|
|
1718
|
+
#### `chi web --tunnel tailscale`
|
|
1719
|
+
|
|
1720
|
+
Steps 3–5 in one command, after steps 1–2 (Tailscale signed in, HTTPS certificates on):
|
|
1721
|
+
|
|
1722
|
+
```sh
|
|
1723
|
+
chi web --tunnel tailscale
|
|
1724
|
+
```
|
|
1725
|
+
|
|
1726
|
+
```
|
|
1727
|
+
Chi Web on http://127.0.0.1:4567/ (public: …)
|
|
1728
|
+
Proxied: https://chi-host.example-tailnet.ts.net/#code=chc_… ← opens chi web as you once, within 10 minutes (…)
|
|
1729
|
+
(owner owner@example.com by Tailscale-User-Login, and the token; …)
|
|
1730
|
+
via Tailscale Serve (tailnet only), owner owner@example.com (this machine's Tailscale login)
|
|
1731
|
+
Tailnet Lock is off: a device Tailscale's servers add to your tailnet could reach this page; tailscale lock init turns it on (…)
|
|
1732
|
+
<the link's QR code>
|
|
1733
|
+
```
|
|
1734
|
+
|
|
1735
|
+
- It checks before it changes anything: Tailscale is connected, this machine has its `ts.net` name and a certificate
|
|
1736
|
+
for it, and what Serve already has on port 443. Nothing there: it runs
|
|
1737
|
+
`tailscale serve --bg --https=443 http://127.0.0.1:<port>`. Exactly that one handler for this port (a leftover of an
|
|
1738
|
+
earlier run killed hard, or step 3 done by hand): it adopts it. Anything else on 443 (another port, other paths, TCP
|
|
1739
|
+
forwarding, Funnel) is refused and left alone. Serve's other ports are never touched.
|
|
1740
|
+
- The owner is `web.proxy.owner` if config.yml has it, else this machine's own Tailscale login (a tagged machine has
|
|
1741
|
+
none: set `web.proxy.owner`). `public_url` and `identity_header` are the tunnel's for this run, never written to
|
|
1742
|
+
config.yml: a `web.public_url` or `web.proxy.identity_header` that says otherwise (in config.yml or the environment)
|
|
1743
|
+
is refused, so a config from step 4 with this machine's name works as it is.
|
|
1744
|
+
- On exit (Ctrl-C, a kill, closing the terminal) it runs `tailscale serve --https=443 off`, but only while 443 still
|
|
1745
|
+
serves exactly what it set; if you changed 443 meanwhile it leaves it and says so. Killed hard (`kill -9`, a power
|
|
1746
|
+
loss) the serve stays on: Serve answers 502 for the name while nothing listens on the port, and whatever later
|
|
1747
|
+
listens on 127.0.0.1:<port> is reachable from your tailnet; the next `chi web --tunnel tailscale` adopts it and
|
|
1748
|
+
turns it off on exit, a plain `chi web` doesn't look.
|
|
1749
|
+
- A chi web already on the port is refused ("stop that one first"): `--tunnel` starts its own.
|
|
1750
|
+
- The `tailscale` it runs: `SAMAGOTCHI_TAILSCALE_CLI` if set, else `tailscale` on PATH, else the macOS app's
|
|
1751
|
+
`/Applications/Tailscale.app/Contents/MacOS/Tailscale`.
|
|
1752
|
+
- Tailnet Lock off is a warning line, never a refusal.
|
|
1753
|
+
|
|
1754
|
+
- **Never Funnel** (`tailscale funnel`): it puts the name on the public internet. This recipe was checked on the
|
|
1755
|
+
tailnet only; Funnel requests weren't.
|
|
1756
|
+
- Recommended: **Tailnet Lock** (`tailscale lock init`, see Tailscale's docs): new devices then need a signature from a
|
|
1757
|
+
device you trust before they join, not only Tailscale's coordination server's say-so. chi web works either way.
|
|
1758
|
+
- What Serve sends (checked with Tailscale 1.104.2 on macOS): it **replaces** a client's `Tailscale-User-Login`,
|
|
1759
|
+
`Tailscale-User-Name`, `X-Forwarded-For` and `X-Forwarded-Host` (never appends); `X-Forwarded-For` is the
|
|
1760
|
+
requesting device's tailnet IP, Host the public name, and chi web's peer is 127.0.0.1. A process on this Mac can
|
|
1761
|
+
still reach 127.0.0.1:4567 directly with headers of its own: there the token decides, as with any proxy.
|
|
1762
|
+
- While chi web isn't running Serve answers 502 for the name (checked). Not checked yet: requests from a tagged
|
|
1763
|
+
device (expect no user login, so refused with `identity_header` set), and whether the serve stays on across a
|
|
1764
|
+
reboot.
|
|
1765
|
+
- A participant's link (`chi share`, the Invite dialog) is a `https://<machine>.<tailnet>.ts.net/…` link too: it opens
|
|
1766
|
+
only on a device on your tailnet (or one you shared this machine with in Tailscale); the grant still decides.
|
|
1767
|
+
- Without Serve, a plain tailnet IP (`web.host: 100.x.y.z`) works like the LAN (see
|
|
1768
|
+
[chi web on your phone](cli.md#chi-web-on-your-phone)): token only, plain http in the browser (WireGuard encrypts
|
|
1769
|
+
the path) so no `Secure` cookie and no notifications, and no identity.
|
|
1770
|
+
|
|
1677
1771
|
## All settings
|
|
1678
1772
|
|
|
1679
1773
|
Every setting below takes the three forms described in
|
data/docs/internals/engine.md
CHANGED
|
@@ -25,12 +25,14 @@ Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:,
|
|
|
25
25
|
│ # TurnState#begin!(controller:, sink:) (a new CancellationController when none is given),
|
|
26
26
|
│ # a recap in flight invalidated, GuardrailWiring#begin_turn(origin)
|
|
27
27
|
│ # Returns a Turn {session, prompt (nil on a continue), continue, on_event, controller,
|
|
28
|
-
│ # origin, started_at, id (the caller's id:, else a new uuid), resumed, resume_from
|
|
28
|
+
│ # origin, started_at, id (the caller's id:, else a new uuid), resumed, resume_from,
|
|
29
|
+
│ # wall (MessageTime.now)}
|
|
29
30
|
│
|
|
30
31
|
├── Client.swap_probe_cancel(turn.controller) # a Stop cuts this turn's /props probes
|
|
31
32
|
│
|
|
32
33
|
├── prepare_turn(turn, images)
|
|
33
|
-
│ # Emits :turn_started {session_id, prompt, turn_id,
|
|
34
|
+
│ # Emits :turn_started {session_id, prompt, turn_id, at (the turn's wall time, Turn#wall: read once,
|
|
35
|
+
│ # the user message's and the reminders' stamp too), continue: true (only on a continue),
|
|
34
36
|
│ # resumed: true (only on a step-limit Continue: resumed: given),
|
|
35
37
|
│ # images (only when there are any), cut_after (the model's cut wait, s)}
|
|
36
38
|
│ # refresh_profile! (may ask the server's /props once)
|
|
@@ -491,8 +493,8 @@ Turn events (the main ones; `Events` in `events.rb` keeps the shared sets):
|
|
|
491
493
|
|
|
492
494
|
| Event | When | Payload |
|
|
493
495
|
|---|---|---|
|
|
494
|
-
| `:turn_started` | `prepare_turn` | `{session_id, prompt, turn_id, continue?, resumed?, images?, cut_after}` (`resumed`: a step-limit Continue) |
|
|
495
|
-
| `:reminder_injected` | due reminders went into the turn | `{reminders}` |
|
|
496
|
+
| `:turn_started` | `prepare_turn` | `{session_id, prompt, turn_id, at, continue?, resumed?, images?, cut_after}` (`resumed`: a step-limit Continue; `at`: the turn's wall time, its prompt's stamp) |
|
|
497
|
+
| `:reminder_injected` | due reminders went into the turn | `{reminders, at}` (`at`: the turn's wall time, the reminder message's stamp) |
|
|
496
498
|
| `:context_status` | every request (unless `context.status` is off), the current estimate before it; and outside any turn after `/model` (or when a session is loaded on another model than its last turn ran on), to the observers only (`Engine#announce_switched_context!`) | `{iteration, status, usage, bucket, source}`; the switch's form has no `iteration`: `{usage, bucket, context_window_tokens, context_window_source}`, with `usage.source: "switch"`, and `usage` nil when the new window doesn't resolve (internals/context-telemetry.md) |
|
|
497
499
|
| `:generation_started` | a request begins | `{iteration, context_window_tokens, context_window_source, price, model_ref}` (`price`: the turn's `ModelPrice#to_h`, nil without one; `model_ref`: the turn's model, asked `host:model`, added by the Engine's stream handler from the model `#generate` resolved after the `before_turn` hooks, so a hook's switch is the one named; native adds `profile`, `profile_source`) |
|
|
498
500
|
| `:generation_chunk` | each streamed chunk | `{iteration, content, text, thinking, payload, tool_call?}` (SSE clients get the Bridge's projection instead: `{iteration, text, thinking, tool_call?, counts?}`, architecture.md) |
|
|
@@ -655,6 +657,19 @@ logged). Both are display only: `AnswerDisplay::DISPLAY_ONLY` lists `display`, `
|
|
|
655
657
|
after_generation), while `KernelLoop#sanitize_history` and `ChatLoop#plain` copy them so the stored conversation keeps
|
|
656
658
|
them across turns. `Web::App#messages_for_display` sends `display_refs` re-validated (`AnswerRefs.for_display`).
|
|
657
659
|
|
|
660
|
+
History stamps: user prompts, merged input and steers, reminders, context and task notes and the model's replies carry
|
|
661
|
+
`at` (`MessageTime::KEY`, `message_time.rb`: a local-offset ISO time with milliseconds), when the entry happened. A
|
|
662
|
+
turn reads the wall clock once (`Turn#wall`, `MessageTime.now`): `:turn_started`'s `at`, its prompt's and its
|
|
663
|
+
reminders' stamps; `Steer::Merge#messages` stamps a merge at the boundary, `ContextNote.message` a note with its
|
|
664
|
+
`created_at` (else now), `KernelLoop#iterate` / `#cancelled_result` and the chat loop's `Run#with_thinking` /
|
|
665
|
+
`Run#canceled` each reply. Tool results, `!cmd` messages and TurnNote notes carry none. Like `display`, `at` must
|
|
666
|
+
survive the two rebuilds: both model branches of `KernelLoop#sanitize_history` and `ChatLoop#plain` copy it (an
|
|
667
|
+
allow-list that drops a key loses it for good on the next turn; `spec/message_time_history_spec.rb` checks both
|
|
668
|
+
loops). It is display-only and never reaches the wire (prompt-caching.md, rule 7), but it is not in
|
|
669
|
+
`AnswerDisplay::DISPLAY_ONLY`: hooks and `ctx.messages` see it. `:recap_ready` carries the saved recap's
|
|
670
|
+
`created_at` (`Engine#emit_recap(created_at:)`, from `IdleRecap#collect`). The UIs' gap dividers come from
|
|
671
|
+
`MessageTime.gap_line` (10 min, `GAP_SECONDS`).
|
|
672
|
+
|
|
658
673
|
`:question_asked` (`QuestionAsked`, `question_asked.rb`) fires from the two Engine seams where a question of the
|
|
659
674
|
model's opens, never from `QuestionDesk`: `Engine#request_question` (the kernel's `question_handler`, after
|
|
660
675
|
`AskUserQuestion.validate`; not with `interface == :non_interactive`) and the guardrail wiring's `ask:` lambda
|
|
@@ -58,7 +58,9 @@ participant** and **one role**, until it expires or is revoked.
|
|
|
58
58
|
|
|
59
59
|
`App#principal_for` resolves every request to a `Web::Principal`:
|
|
60
60
|
|
|
61
|
-
0. With `web.public_url` (`Web::ProxyGate`
|
|
61
|
+
0. With `web.public_url` (`Web::ProxyGate`; `chi web --tunnel tailscale` sets the same for its run, never in config:
|
|
62
|
+
public_url = the machine's `ts.net` name, identity_header `Tailscale-User-Login`, owner `web.proxy.owner` else this
|
|
63
|
+
machine's Tailscale login, `Web::Tunnel::Tailscale`), a **proxied** request (a proxy trace: `X-Forwarded-*`, `Forwarded`,
|
|
62
64
|
`X-Real-IP`, a known identity header, the configured one, or the public Host) is `#proxied_principal`'s: the owner
|
|
63
65
|
only with the owner's token **and**, when `web.proxy.identity_header` is set, that header being one clean value
|
|
64
66
|
(not repeated, no comma, not empty, under one name with no underscore: the raw header names) equal to
|
|
@@ -204,12 +206,13 @@ made by `chi share revoke` in another process; the page then reads a 401 and sho
|
|
|
204
206
|
D5 (a): prompts and replies with their authors, one-line tool rows, turn boundaries, the queue and waiting states.
|
|
205
207
|
Not tool output or arguments, thinking, approvals' and questions' content, memories (text or names), context notes,
|
|
206
208
|
plugin steers, automatic and delegate input, cards, command output, the working set (the user decided: not), cost,
|
|
207
|
-
timing,
|
|
209
|
+
timing (durations, records: only the wall time `at` of each prompt, reply and turn start, for the page's gap lines
|
|
210
|
+
and hover times, D6 of the session-time-gaps plan), host paths or the owner's prompt history. Every read a participant reaches goes through one module,
|
|
208
211
|
`Web::ParticipantView` (`lib/samagotchi/web/participant_view.rb`), whose every answer is a new hash built from named
|
|
209
212
|
fields: a field added to an event later reaches nobody until it is named there.
|
|
210
213
|
|
|
211
214
|
- **Events** (`.event`, the stream). `EVENTS` names the types a participant gets, each rebuilt by its own method:
|
|
212
|
-
`turn_started` (turn id, the author and name, `mine` for their own, the prompt only when a person sent the turn:
|
|
215
|
+
`turn_started` (turn id, its wall time `at`, the author and name, `mine` for their own, the prompt only when a person sent the turn:
|
|
213
216
|
`ClientId.human?`, else `automatic`; `continue`), the turn's end (`turn_completed`, `turn_canceled`, `turn_failed`:
|
|
214
217
|
type and turn id), `generation_chunk` (its visible `text` only; a thinking-only chunk is dropped),
|
|
215
218
|
`generation_retrying` (`restarted`), `tool_call_started` (the tool's name, and for a file tool the path relative to
|
|
@@ -219,6 +219,11 @@ is prefilled once. The native and the chat loop use the same plan.
|
|
|
219
219
|
`CacheConfirm.needed?` asks only for `:full`, `:provider` and `:history`, when the effective level changes and
|
|
220
220
|
the session has a model reply. A new setting with a cost (a `/model` across templates, `/llm-context` turning
|
|
221
221
|
forget on) adds its classifier and goes through the same step.
|
|
222
|
+
7. **No wall-clock time rendered at wire time.** A history entry's `at` (`MessageTime::KEY`, when it happened) is
|
|
223
|
+
display-only: no wire format reads it, and a guard spec checks that `ChatLoop#wire_messages` and
|
|
224
|
+
`Prompt.format_with_images` give the same bytes with and without it (`spec/message_time_history_spec.rb`). A
|
|
225
|
+
time the model should read is baked into the message's content when the message is made (a context note's
|
|
226
|
+
frame, the reminders' header), so a replay sends the same bytes whatever the time zone or DST.
|
|
222
227
|
|
|
223
228
|
## Server-side settings worth knowing
|
|
224
229
|
|
data/docs/plugins.md
CHANGED
|
@@ -756,7 +756,7 @@ full label, `plugin.rb (bundle my-bundle)`.
|
|
|
756
756
|
| `ctx.settings` | the bundle's settings, frozen: config.yml's `bundles: <bundle>:` with the session's own `--set` values laid over it ([Per-session settings](#per-session-settings)); the config alone until the session is bound (in `register` and `chi.ctx`) |
|
|
757
757
|
| `ctx.data_dir` | `$XDG_STATE_HOME/samagotchi/plugins/<bundle>/`, created on first use. A session's own state goes in `sessions/<id>.json` or `sessions/<id>/` there: chi removes it when the session is deleted, discarded or pruned |
|
|
758
758
|
| `ctx.log` | `ctx.log.info(:event, key: value)`: debug-log records tagged `plugins`, with `bundle=<bundle>` |
|
|
759
|
-
| `ctx.messages` | the conversation, as a frozen copy, without the system prompt. While a turn runs, a session worker's (attached, web) adds that turn so far: its prompt, the model's text and the lines merged into it (no tool calls or thinking); the REPL's is the conversation before that turn |
|
|
759
|
+
| `ctx.messages` | the conversation, as a frozen copy, without the system prompt. While a turn runs, a session worker's (attached, web) adds that turn so far: its prompt, the model's text and the lines merged into it (no tool calls or thinking); the REPL's is the conversation before that turn. An entry may carry `at`, when it happened (docs/sessions.md, Message times) |
|
|
760
760
|
| `ctx.messages_partial?` | whether `ctx.messages` leaves out a running turn (the REPL mid-turn), so a plugin can say what its answer is about |
|
|
761
761
|
| `ctx.prompt_files` | the files whose text is in the session's system prompt now: the AGENTS.md chain (the work tree top down to the cwd) and the appended files (`append_system_prompt:`, `--append-system-prompt`); absolute paths, in prompt order, `[]` before the first build |
|
|
762
762
|
| `ctx.notify(text, level: :info, fallback_for: nil)` | one line to the user, labelled by the bundle (`my-bundle> …`). Every UI shows it, during a turn (a tool, a hook) or between turns (a command). `fallback_for: :display` marks a line that repeats the answer's display (`event[:present]`), which a UI that renders the display leaves out (`fallback_for: :editor`: a file the viewer could open from a ref in its editor): see [Hooks](hooks.md#what-a-hook-can-do-the-runtime). The keyword needs chi 0.35.0 (it raises on an older chi) |
|
data/docs/sessions.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/sessions/<uuid>.json` (XDG-aware via `XDG_STATE_HOME`) plus a sidecar dir `<uuid>/` with `input/`/`notes/`/`output/`/`owner.lock`/`bridge.json` (`Session.session_dir`), and `tool_output/` once a long `execute` result was cut: its whole output, which the cut result names (`ToolOutputFiles`, the newest 50 kept; see [Tool output guardrails](internals/tool-guardrails.md#the-runners-cut-max_tool_output_chars)). Besides the messages and turn state, the JSON keeps `used_memory_names` (what the session read), `preloaded_memory_names` (`--memory`) and `muted_memory_names` (`--mute`), the last two written before the worker starts so a respawn builds the same prompt, `prompt_notes`, the model notes the system prompt carried at its last build (`[{name, scope, chars, digest}]`, see [Memory](memory.md#model-notes)), `parent_id`, the session that delegated this one (see *Delegating*; `null` otherwise), and `continues`, the session this one continues (the previous link of a chain, see *Session chains*; `null` otherwise); files from before those keys read as empty lists, no parent and no previous link. `model_name` is the resolved model ref (an alias applied, `host:id` when it names a host), so a resumed session keeps its model when an alias is retargeted; `model_typed` is the name it was given as when that differs (an alias), for the `models:` lookup after a resume (`null` otherwise).
|
|
4
4
|
|
|
5
|
+
**Message times:** an entry of `messages` may carry `at`, when it happened: a local-offset ISO time with milliseconds (`2026-10-24T14:32:05.120+02:00`). User prompts, lines merged into a turn and steers, reminders, context and task notes, and the model's replies have one (a note's is its `created_at`); tool results, `!cmd` messages and chi's own turn notes don't, nor does anything saved before chi stamped messages (no backfill). It is for the UIs only (the gap dividers, a bubble's time): it never goes to the model, so it changes nothing in the requests or their prompt cache. The one time the model reads is the reminders': due reminders go into a turn as a system message headed `[SYSTEM: REMINDERS DUE 2026-10-24 14:32 local]` (the turn's local time; `ReminderQueue#inject!`), written into the saved content then, so a later request sends the same text.
|
|
6
|
+
|
|
5
7
|
**Input files:** a message for a session's worker waits in `input/<timestamp>.json` (`SessionInbox.write_input`: `prompt`, `client_id`, `enqueued_id`, and `images`, `no_interrupt` and `delivery` when they apply; `delivery` is written only for `"queue"`). While a turn runs, the worker merges them at each step boundary in name order, except a queued one or one with images: those wait for the turn's end and run one turn each, in name order, and a file after them still merges (`Worker#pending_input_drain`). A queue is sent only to a worker whose `bridge.json` names the `delivery` feature; an older one would steer it, so `SessionManager.deliver_turn` refuses it (409 `delivery_unavailable`), and any other 4xx the Bridge answers is a refusal too, never a fallback file (except a 413, a body over its size limit: that one still goes in as a file, its `delivery` kept). A session command recognized in a file runs without a turn; its outcome is kept at `input/settled/<enqueued_id>` (`completed`, `failed` for a command error, `canceled` with `cancel_reason` `busy` or `dropped` for one refused mid-turn or dropped as the worker left; `origin: command`) so `chi send --wait` ends instead of waiting for a turn that will not run, its detail saying `the command failed` or `the command was refused (busy)` rather than a turn's words. **Reply files:** a turn that ends with visible text leaves its reply in `output/<timestamp>.txt`, or `output/<timestamp>-<enqueued_id>.txt` when the turn ran an input file with an id (`SessionInbox.write_output`), and the session's `last_turn` keeps that `enqueued_id`; so a wait for one queued message (`chi send --queue --wait`, `ReplyWait`'s `turn:`) finds its own turn's reply or end, never another turn's. The names still sort by time, and every reader takes `*.txt`.
|
|
6
8
|
|
|
7
9
|
**Removing a queued message:** a message queued for after the running turn (`chi send --queue`, the web's Alt+Enter, `/queue TEXT` in a terminal), or one with images (it waits for the turn's end too), can be taken back while it still waits: the worker's Bridge (`POST /session/:id/turn/dequeue {enqueued_id}`, the `unqueue` feature) deletes its input file and every UI drops it (`:turn_dequeued`). It is a race with the turn's end, decided by the same rename the worker uses to take the file: once a turn took it the answer is `409 already_started` (it runs), and an id the worker no longer knows is `404 unknown_enqueued_id`. A plain message sent without `--queue` is not removable (`409 not_queued`): a running turn's next step may take it at any moment. A wait for its turn (`chi send --queue --wait`, a delegate follow-up's wait) ends at once with `removed` (the tombstone `input/removed/<enqueued_id>` says so), and a `chi -p` run whose queued prompt went ends with exit 1. The `200` carries the removed `prompt`: the web and the attached terminal put it back into the composer to edit (not its images). The images a removed message named stay in the session's `images/`. Not removable: a queued session command (`/queue /model x`: it waits in the worker, not in `input/`), a message in a stopped worker's inbox (the next worker runs it), and the plain REPL's (`--no-shared`) own queue.
|
|
@@ -29,7 +31,7 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
|
|
|
29
31
|
|
|
30
32
|
**A worker outlives whoever started it:** a spawned worker leaves its spawner's process tree (it is forked again, in a session of its own, and reparented to init), so closing the terminal of a `chi web`, or a tool tearing down a background `chi send --wait` and its children, no longer takes the worker and its turn with it.
|
|
31
33
|
|
|
32
|
-
**A worker killed mid-turn:** while a turn runs, its worker keeps it on disk (`TurnCheckpoint`): the session file has the turn so far (the prompt from the turn's start, then the finished tool steps and lines merged into it) and `interrupted_turn: {id, started_at, at, iteration, enqueued_id}` (`enqueued_id`: the input the turn runs, so any process can tell which turn is running; a continue turn carries its offer turn's), written as the turn starts, at the loop's first iteration boundary and then at most every 10 s (`updated_at` moves with it). The turn's end saves the real messages without the mark. A worker that died without ending its turn (SIGKILL, a crash of the process) leaves the mark, and loses what came after its last written boundary: at most the last 10 s of steps, plus the step in flight. Each checkpoint write also saves the turn's metrics so far to `analytics.json` (tokens, speeds, timings, its finished tool calls), as the record a killed turn leaves: canceled, `cancellation_reason: worker_stopped`; the turn's own end writes over it. The next worker to start for the session (a `chi send`, `--resume`, `--attach`, the web), or a `--no-shared` REPL resuming it, settles the turn before anything else (`TurnCheckpoint.settle`, log `interrupted_turn_settled`): a note `the previous turn was cancelled (worker-stopped) after Ns` goes at the tail, the status goes idle and the mark goes; `last_turn` stays the previous turn's (a new one saved before the turn a `chi send --wait` woke the worker for would end that wait at once); the dead turn's end is recorded under its `enqueued_id` instead, in `<id>/input/settled/<enqueued_id>` (a `last_turn` record: canceled, `cancel_reason: worker_stopped`), which only a wait for that very turn reads (`chi send --queue --wait`, a `chi send --wait -m` to an idle session or `--new`, `chi send --wait ID --turn`, a continue's wait): it ends `canceled` with `cancel_reason: worker_stopped`, detail "its worker exited mid-turn", instead of timing out. A reply the turn wrote before its worker died is still its answer. The session shows the turn's prompt and steps with that note; the dead turn is not run again, and the model reads on its next turn that it was cut.
|
|
34
|
+
**A worker killed mid-turn:** while a turn runs, its worker keeps it on disk (`TurnCheckpoint`): the session file has the turn so far (the prompt from the turn's start, then the finished tool steps and lines merged into it) and `interrupted_turn: {id, started_at, at, iteration, enqueued_id}` (`enqueued_id`: the input the turn runs, so any process can tell which turn is running; a continue turn carries its offer turn's), written as the turn starts, at the loop's first iteration boundary and then at most every 10 s (`updated_at` moves with it). The turn's end saves the real messages without the mark. A worker that died without ending its turn (SIGKILL, a crash of the process) leaves the mark, and loses what came after its last written boundary: at most the last 10 s of steps, plus the step in flight. Each checkpoint write also saves the turn's metrics so far to `analytics.json` (tokens, speeds, timings, its finished tool calls), as the record a killed turn leaves: canceled, `cancellation_reason: worker_stopped`; the turn's own end writes over it. The next worker to start for the session (a `chi send`, `--resume`, `--attach`, the web), or a `--no-shared` REPL resuming it, settles the turn before anything else (`TurnCheckpoint.settle`, log `interrupted_turn_settled`): a note `the previous turn was cancelled (worker-stopped) after Ns` goes at the tail, the status goes idle and the mark goes; `last_turn` stays the previous turn's (a new one saved before the turn a `chi send --wait` woke the worker for would end that wait at once); the dead turn's end is recorded under its `enqueued_id` instead, in `<id>/input/settled/<enqueued_id>` (a `last_turn` record: canceled, `cancel_reason: worker_stopped`), which only a wait for that very turn reads (`chi send --queue --wait`, a `chi send --wait -m` to an idle session or `--new`, `chi send --wait ID --turn`, a continue's wait): it ends `canceled` with `cancel_reason: worker_stopped`, detail "its worker exited mid-turn", instead of timing out. A wait with no turn id that saw the turn running ends the same way when the settle comes (`chi send --wait ID` with no message on a turn with no id, such as a session's first prompt or a reminder turn; a message merged into the turn as a steer), unless an input still waits or was claimed since: an unmerged message runs as its own turn next, and that turn's end is the answer. A reply the turn wrote before its worker died is still its answer. The session shows the turn's prompt and steps with that note; the dead turn is not run again, and the model reads on its next turn that it was cut.
|
|
33
35
|
|
|
34
36
|
**Stopping a worker:** `chi sessions stop ID` marks the session stopped (an empty `<id>/stopped` file, not a session.json field, which a worker saving at that moment would write over; a resume removes it), sends its worker TERM and waits (up to 10 s) until the worker has let go of `owner.lock`. A running turn is canceled over the Bridge first, with reason `session_stopped` (the turn's line ends "(session stopped)", its note says `cancelled (session-stopped)`, and a command it was running says "stopped as the session was stopped"; a bare signal to the worker makes it "stopped by a signal to chi"; an older worker takes the reason as `manual`); the TERM stops whatever still runs as a Stop with reason `signal` and saves it (a spawned worker traps TERM, HUP and INT: `WorkerSignals`; a turn not ended within 3 s, 0.5 s after a stop or a second signal, is cut and the session saved as it was), and a stopped worker still saves its kept turn, since the marker, not the saved status, says stopped. Any TERM, HUP or INT does the same to a worker outside a stop: it leaves (log `worker stop reason=signal`) and the next send wakes a new one, so a `chi --resume ID` right after spawns a fresh worker. That is also how to restart a worker still running an older chi, which the attached terminal reports when a command or a question dismiss gets a 404, and the web when an answer, a dismiss, a command or a cancel does (`501 not_supported`; `BridgeClient.stale_worker_message`). A command this chi has that the older worker doesn't (`/llm-context`, the web's `llm ctx` chip, against a 0.39.0 worker) says the same, not `not a session command` (`SessionCommands.builtin_name`); a typo keeps its `400 unknown_command`. The web's `POST /api/sessions/:id/stop` waits the same way, for 2 s (the page's Stop shows the stop at once), or for the body's `wait` up to the same 10 s (`SessionManager::STOP_WAIT`): `chi --remote NAME sessions stop` sends it, so it waits as long as here. A command sent during a turn that waits for its end (`/model X`, `!cmd`; see *Typing during a turn* in [cli.md](cli.md)) and hasn't run when the worker leaves (a stop, a crash) is answered `command_ran {status: "dropped"}`, so no UI keeps showing it waiting.
|
|
35
37
|
|
data/docs/sub-agent.md
CHANGED
|
@@ -72,7 +72,7 @@ Exit codes, both commands:
|
|
|
72
72
|
| 3 | `waiting_for_owner` | (a participant's wait, `chi --remote NAME send --wait` with their link's token) their review turn waits on the session's owner: a question or an approval it raised, or the owner's step-limit Continue; wait again |
|
|
73
73
|
| 4 | `running` | `--timeout` passed; the turn goes on, wait again |
|
|
74
74
|
| 1 | `refused` | (a participant's wait) their queued review turn was refused before it ran: `cancel_reason` `revoked` (their link was revoked or expired) or `cap_reached` (they spent their cap); no turn ran for it |
|
|
75
|
-
| 1 | `failed`, `canceled`, `limit`, `removed`, `no_answer`, `error`, `worker_gone`, `stopped` | `detail` says what happened (`limit`: the turn ran out of steps and nobody can be asked, e.g. an older worker; `chi send ID -m '/continue yes'` continues it, except under `guardrails.parent_answers: hard`, where a parent's `/continue yes` is refused as its `chi answer --option Continue` is; `removed`: the `--queue` message waited for was taken back before it ran, no turn ran for it). `failed` adds `error_kind` (the provider error's kind: `credits`, `credits_held`, `rate_limited`, `server`, `auth`, `connection`, …) and `retryable` when known; `canceled` adds `cancel_reason` (`user`: a Stop in the web; `session_stopped`: a session stop, `chi sessions stop` or the web's; `worker_stopped`: the turn waited for (a `--queue` message's,
|
|
75
|
+
| 1 | `failed`, `canceled`, `limit`, `removed`, `no_answer`, `error`, `worker_gone`, `stopped` | `detail` says what happened (`limit`: the turn ran out of steps and nobody can be asked, e.g. an older worker; `chi send ID -m '/continue yes'` continues it, except under `guardrails.parent_answers: hard`, where a parent's `/continue yes` is refused as its `chi answer --option Continue` is; `removed`: the `--queue` message waited for was taken back before it ran, no turn ran for it). `failed` adds `error_kind` (the provider error's kind: `credits`, `credits_held`, `rate_limited`, `server`, `auth`, `connection`, …) and `retryable` when known; `canceled` adds `cancel_reason` (`user`: a Stop in the web; `session_stopped`: a session stop, `chi sessions stop` or the web's; `worker_stopped`: the turn waited for (a `--queue` message's, a message's to an idle session, the running turn a message merged into or a plain `--wait` saw running) died with its worker (detail "its worker exited mid-turn"), settled by the next one; `hook`; `ctrl_c`; `manual`) and `stopped_by` (the hook that stopped it, e.g. `loop-guard`) when known |
|
|
76
76
|
| 2 | (no JSON) | usage error, or an option the question doesn't offer |
|
|
77
77
|
| 130 | `running` | Ctrl-C; the turn goes on |
|
|
78
78
|
|
|
@@ -46,7 +46,7 @@ module Samagotchi
|
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
# @return [Hash, nil] a copy of the turn in progress: prompt, origin,
|
|
49
|
-
# continue, ordered parts (generation / thinking / text / tool /
|
|
49
|
+
# continue, at (its wall time), ordered parts (generation / thinking / text / tool /
|
|
50
50
|
# input / steer / reminder / notice), pending_question and the last event_seq folded
|
|
51
51
|
# in. A finished tool part keeps the live row's action and duration_ms
|
|
52
52
|
# too, a running one the time it has run so far (elapsed_ms), so a
|
|
@@ -275,6 +275,8 @@ module Samagotchi
|
|
|
275
275
|
@turn = { prompt: event[:prompt], origin: event[:origin], continue: !!event[:continue],
|
|
276
276
|
parts: [], pending_question: nil }
|
|
277
277
|
@turn[:resumed] = true if event[:resumed]
|
|
278
|
+
# Its wall time (MessageTime): a joining page's gap divider.
|
|
279
|
+
@turn[:at] = event[:at] if event[:at]
|
|
278
280
|
@tool_started_at = {}
|
|
279
281
|
@working_set = nil
|
|
280
282
|
@turn[:images] = event[:images] if event[:images]
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "time"
|
|
4
4
|
require_relative "paths"
|
|
5
|
+
require_relative "message_time"
|
|
5
6
|
|
|
6
7
|
module Samagotchi
|
|
7
8
|
# A context note: background text pushed into a session from outside (the
|
|
@@ -13,7 +14,8 @@ module Samagotchi
|
|
|
13
14
|
# The message keys a note carries besides role and content; everything
|
|
14
15
|
# that copies messages field by field must keep them.
|
|
15
16
|
# context_source: the attached context source a note is about
|
|
16
|
-
# (ContextAbsorber), so the UIs can tie it to its chip.
|
|
17
|
+
# (ContextAbsorber), so the UIs can tie it to its chip. A note's wall
|
|
18
|
+
# time (MessageTime::KEY) is every entry's, kept beside these.
|
|
17
19
|
KEYS = %i[kind note_id source from_session from_cwd context_source].freeze
|
|
18
20
|
HEADER = "CONTEXT NOTE"
|
|
19
21
|
# A finished background task's note (TaskFinishNote): a header of its own.
|
|
@@ -27,14 +29,16 @@ module Samagotchi
|
|
|
27
29
|
# Takes SessionInbox.read_note's keys. +turn_start+ / +turn_id+: the
|
|
28
30
|
# note starts a turn chi runs for it (a context wake), as a wake turn's
|
|
29
31
|
# delegate report does (Steer.turn_prompt?). +header+: the frame's
|
|
30
|
-
# (HEADERS), not kept as a key: the content carries it.
|
|
32
|
+
# (HEADERS), not kept as a key: the content carries it. Its wall time
|
|
33
|
+
# (MessageTime::KEY) is +created_at+'s, else now.
|
|
31
34
|
# @return [Hash] the conversation message
|
|
32
35
|
def message(note_id:, text:, source:, created_at: nil, from_session: nil, from_cwd: nil, context_source: nil,
|
|
33
36
|
turn_start: nil, turn_id: nil, header: HEADER, **)
|
|
34
37
|
{ role: "system", kind: KIND, note_id: note_id, source: source, from_session: from_session,
|
|
35
38
|
from_cwd: from_cwd, context_source: context_source, turn_start: (true if turn_start == true), turn_id: turn_id,
|
|
36
39
|
content: frame(text, source: source, created_at: created_at, from_session: from_session,
|
|
37
|
-
from_cwd: from_cwd, header: header)
|
|
40
|
+
from_cwd: from_cwd, header: header),
|
|
41
|
+
MessageTime::KEY => MessageTime.iso_of(created_at) || MessageTime.iso(MessageTime.now) }.compact
|
|
38
42
|
end
|
|
39
43
|
|
|
40
44
|
def frame(text, source:, created_at: nil, from_session: nil, from_cwd: nil, header: HEADER)
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -78,6 +78,7 @@ require_relative "thinking"
|
|
|
78
78
|
require_relative "reasoning_history"
|
|
79
79
|
require_relative "answer_display"
|
|
80
80
|
require_relative "answer_refs"
|
|
81
|
+
require_relative "message_time"
|
|
81
82
|
require_relative "edit_preview"
|
|
82
83
|
require_relative "question_desk"
|
|
83
84
|
require_relative "question_asked"
|
|
@@ -585,9 +586,12 @@ module Samagotchi
|
|
|
585
586
|
|
|
586
587
|
# Emit a :recap_ready event (additive slot) carrying the generated recap
|
|
587
588
|
# and the generation id an observer uses to reject an invalidated recap.
|
|
588
|
-
# +covered+ counts the session messages it summarizes
|
|
589
|
-
|
|
590
|
-
|
|
589
|
+
# +covered+ counts the session messages it summarizes; +created_at+ is
|
|
590
|
+
# the saved recap's (UTC ISO; the UIs show its local time).
|
|
591
|
+
def emit_recap(recap:, generation:, covered: nil, created_at: nil)
|
|
592
|
+
event = { type: :recap_ready, recap: recap, generation: generation, covered: covered }
|
|
593
|
+
event[:created_at] = created_at if created_at
|
|
594
|
+
@session_observer.notify(event)
|
|
591
595
|
end
|
|
592
596
|
|
|
593
597
|
# Count one side request (a recap, a plugin's ctx.ask_model: IdleClient's
|
|
@@ -1997,8 +2001,10 @@ module Samagotchi
|
|
|
1997
2001
|
# +resume_from+: where its reasoning starts, the run it resumes
|
|
1998
2002
|
# (run_turn's resume_from:); nil for every other turn, and for a
|
|
1999
2003
|
# Continue that starts cold (after a model change).
|
|
2004
|
+
# +wall+: the wall time it started (MessageTime.now, once): the
|
|
2005
|
+
# :turn_started event's at and its user message's (and reminders') stamp.
|
|
2000
2006
|
Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
|
|
2001
|
-
:settings, :id, :limit, :resumed, :resume_from) do
|
|
2007
|
+
:settings, :id, :limit, :resumed, :resume_from, :wall) do
|
|
2002
2008
|
def resumed? = resumed ? true : false
|
|
2003
2009
|
|
|
2004
2010
|
# The turn's boundary events carry the origin only when it names a
|
|
@@ -2009,6 +2015,9 @@ module Samagotchi
|
|
|
2009
2015
|
|
|
2010
2016
|
# Seconds since the turn started (the cancel note says how long it ran).
|
|
2011
2017
|
def elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
|
|
2018
|
+
|
|
2019
|
+
# The wall time's stamp text (MessageTime::KEY).
|
|
2020
|
+
def at = MessageTime.iso(wall)
|
|
2012
2021
|
end
|
|
2013
2022
|
private_constant :Turn
|
|
2014
2023
|
|
|
@@ -2141,7 +2150,7 @@ module Samagotchi
|
|
|
2141
2150
|
|
|
2142
2151
|
Turn.new(session: session, prompt: continue ? nil : prompt, continue: continue, on_event: on_event,
|
|
2143
2152
|
controller: effective_controller, origin: origin, started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
|
|
2144
|
-
id: id || SecureRandom.uuid)
|
|
2153
|
+
id: id || SecureRandom.uuid, wall: MessageTime.now)
|
|
2145
2154
|
end
|
|
2146
2155
|
private :begin_turn
|
|
2147
2156
|
|
|
@@ -2155,7 +2164,7 @@ module Samagotchi
|
|
|
2155
2164
|
image_refs, image_error = turn_image_refs(session, images)
|
|
2156
2165
|
# Emit turn_started event
|
|
2157
2166
|
bind_metrics(session)
|
|
2158
|
-
turn_started = { type: :turn_started, session_id: session.id, prompt: turn.prompt, turn_id: turn.id }
|
|
2167
|
+
turn_started = { type: :turn_started, session_id: session.id, prompt: turn.prompt, turn_id: turn.id, at: turn.at }
|
|
2159
2168
|
turn_started[:continue] = true if turn.continue
|
|
2160
2169
|
turn_started[:resumed] = true if turn.resumed?
|
|
2161
2170
|
turn_started[:images] = image_refs unless image_refs.empty?
|
|
@@ -2316,16 +2325,18 @@ module Samagotchi
|
|
|
2316
2325
|
# system prompt invalidates cache for the entire prefix.
|
|
2317
2326
|
# Not into a participant's turn: the owner's reminders are the owner's,
|
|
2318
2327
|
# and would be used up there under the review boundary.
|
|
2319
|
-
due_reminders = turn.origin.participant? ? [] : @reminder_queue.inject!(turn.messages)
|
|
2328
|
+
due_reminders = turn.origin.participant? ? [] : @reminder_queue.inject!(turn.messages, now: turn.wall)
|
|
2320
2329
|
if due_reminders.any?
|
|
2321
2330
|
emit_event(turn.on_event, {
|
|
2322
2331
|
type: :reminder_injected,
|
|
2323
|
-
reminders: due_reminders
|
|
2332
|
+
reminders: due_reminders,
|
|
2333
|
+
at: turn.at
|
|
2324
2334
|
})
|
|
2325
2335
|
end
|
|
2326
2336
|
|
|
2327
2337
|
unless turn.continue
|
|
2328
|
-
user_message = { role: "user", content: turn.prompt, turn_id: turn.id, **author_fields(turn.origin.author)
|
|
2338
|
+
user_message = { role: "user", content: turn.prompt, turn_id: turn.id, **author_fields(turn.origin.author),
|
|
2339
|
+
MessageTime::KEY => turn.at }
|
|
2329
2340
|
user_message[:images] = image_refs unless image_refs.empty?
|
|
2330
2341
|
turn.messages << user_message
|
|
2331
2342
|
session.last_prompt = turn.prompt
|