samagotchi 0.108.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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -1
  3. data/bin/chi +5 -1
  4. data/docs/architecture.md +7 -2
  5. data/docs/cli.md +40 -1
  6. data/docs/configuration.md +177 -2
  7. data/docs/internals/engine.md +19 -4
  8. data/docs/internals/participants.md +28 -8
  9. data/docs/internals/prompt-caching.md +5 -0
  10. data/docs/internals/remote-sessions.md +6 -2
  11. data/docs/plugins.md +1 -1
  12. data/docs/sessions.md +3 -1
  13. data/docs/sub-agent.md +1 -1
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  15. data/lib/samagotchi/bridge.rb +25 -0
  16. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  17. data/lib/samagotchi/config.rb +4 -0
  18. data/lib/samagotchi/context_note.rb +7 -3
  19. data/lib/samagotchi/engine.rb +20 -9
  20. data/lib/samagotchi/idle_recap.rb +1 -1
  21. data/lib/samagotchi/idle_reminders.rb +1 -1
  22. data/lib/samagotchi/kernel_loop.rb +12 -7
  23. data/lib/samagotchi/llm/chat_loop.rb +9 -2
  24. data/lib/samagotchi/message_time.rb +104 -0
  25. data/lib/samagotchi/proxy_trace.rb +45 -0
  26. data/lib/samagotchi/reminder_queue.rb +20 -3
  27. data/lib/samagotchi/reply_wait/poll.rb +39 -1
  28. data/lib/samagotchi/run_options.rb +4 -1
  29. data/lib/samagotchi/session_access/contract.rb +1 -1
  30. data/lib/samagotchi/session_access/local.rb +52 -10
  31. data/lib/samagotchi/session_inbox.rb +7 -3
  32. data/lib/samagotchi/steer.rb +5 -3
  33. data/lib/samagotchi/terminal_ui/attached_loop.rb +70 -9
  34. data/lib/samagotchi/terminal_ui/between_turns.rb +8 -3
  35. data/lib/samagotchi/terminal_ui/formatting.rb +13 -5
  36. data/lib/samagotchi/terminal_ui/gap_clock.rb +78 -0
  37. data/lib/samagotchi/terminal_ui/reline_seam.rb +20 -2
  38. data/lib/samagotchi/terminal_ui.rb +40 -5
  39. data/lib/samagotchi/tool_activity.rb +11 -0
  40. data/lib/samagotchi/turn_checkpoint.rb +6 -1
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/web/app.rb +246 -32
  43. data/lib/samagotchi/web/grants.rb +6 -3
  44. data/lib/samagotchi/web/info_probe.rb +5 -1
  45. data/lib/samagotchi/web/link_codes.rb +146 -0
  46. data/lib/samagotchi/web/participant_access.rb +9 -2
  47. data/lib/samagotchi/web/participant_review.rb +1 -1
  48. data/lib/samagotchi/web/participant_view.rb +12 -5
  49. data/lib/samagotchi/web/participants_admin.rb +13 -6
  50. data/lib/samagotchi/web/proxy_gate.rb +174 -0
  51. data/lib/samagotchi/web/public/app.js +35 -3
  52. data/lib/samagotchi/web/public/auth_lost.js +76 -0
  53. data/lib/samagotchi/web/public/bubbles.js +29 -7
  54. data/lib/samagotchi/web/public/data.js +29 -5
  55. data/lib/samagotchi/web/public/format.js +72 -6
  56. data/lib/samagotchi/web/public/index.html +2 -0
  57. data/lib/samagotchi/web/public/participant.html +2 -0
  58. data/lib/samagotchi/web/public/participant.js +47 -10
  59. data/lib/samagotchi/web/public/participant_model.js +25 -2
  60. data/lib/samagotchi/web/public/stream_handlers.js +32 -4
  61. data/lib/samagotchi/web/public/time_gaps.js +98 -0
  62. data/lib/samagotchi/web/public/token_fragment.js +57 -0
  63. data/lib/samagotchi/web/public/tokens.css +2 -2
  64. data/lib/samagotchi/web/public/turn_events.js +3 -1
  65. data/lib/samagotchi/web/public/turn_html.js +15 -8
  66. data/lib/samagotchi/web/public/turn_view.js +8 -3
  67. data/lib/samagotchi/web/public_url.rb +87 -0
  68. data/lib/samagotchi/web/running.rb +39 -4
  69. data/lib/samagotchi/web/server.rb +138 -22
  70. data/lib/samagotchi/web/tunnel/tailscale.rb +325 -0
  71. data/lib/samagotchi/web/tunnel.rb +133 -0
  72. data/lib/samagotchi/worker_sidecar.rb +4 -3
  73. metadata +12 -1
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, or a message's to an idle session) 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 |
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]
@@ -26,6 +26,7 @@ require_relative "turn_origin"
26
26
  require_relative "participants"
27
27
  require_relative "image_store"
28
28
  require_relative "log"
29
+ require_relative "proxy_trace"
29
30
  require_relative "version"
30
31
  require_relative "worker_sidecar"
31
32
  require_relative "relay_verifier"
@@ -120,6 +121,8 @@ module Samagotchi
120
121
  @connection_threads = []
121
122
  @stopped = false
122
123
  @mutex = Monitor.new
124
+ @proxied_log_mutex = Mutex.new
125
+ @proxied_logged_at = nil
123
126
  @open_streams = 0
124
127
  # Open streams by the client_id a stream named (`?client_id=`); web
125
128
  # tabs, through the `chi web` proxy, name none.
@@ -438,6 +441,10 @@ module Samagotchi
438
441
  break
439
442
  elsif browser_request?(headers)
440
443
  write_json(io, 403, nil, { error: "cross_origin", detail: "the bridge answers chi's own clients only" })
444
+ elsif proxied_request?(headers)
445
+ note_proxied_refused(request[:path])
446
+ write_json(io, 403, nil, { error: "proxied", detail: "the bridge answers chi's own clients on this machine, " \
447
+ "never a request through a proxy" })
441
448
  elsif route.nil?
442
449
  write_json(io, 404, { "Allow" => "GET, POST" },
443
450
  { error: "not_found", path: request[:path] })
@@ -524,6 +531,24 @@ module Samagotchi
524
531
  # no Sec-Fetch-Site. A browser page always sends one of them (another
525
532
  # website's text/plain POST needs no preflight), and a DNS-rebound page
526
533
  # also has a foreign Host. Headers are the raw ones, lowercased.
534
+ # A request through a proxy (an auth proxy forwarding every port, as a
535
+ # work pod's does) carries a trace chi's own clients never send
536
+ # (ProxyTrace: X-Forwarded-*, Forwarded, X-Real-IP, X-Auth-Request-*…,
537
+ # underscore twins too): refused, whoever signed in to the proxy. chi
538
+ # web's stream proxy forwards Last-Event-ID only (ProxyStreamBody).
539
+ def proxied_request?(headers) = headers.keys.any? { |name| ProxyTrace.header?(name) }
540
+
541
+ PROXIED_LOG_INTERVAL = 60.0
542
+
543
+ # At most once a minute: a scan through a forward would fill the log.
544
+ def note_proxied_refused(path)
545
+ now = monotonic_now
546
+ due = @proxied_log_mutex.synchronize do
547
+ (@proxied_logged_at.nil? || now - @proxied_logged_at >= PROXIED_LOG_INTERVAL).tap { |fresh| @proxied_logged_at = now if fresh }
548
+ end
549
+ Log.warn(:bridge, "proxied_refused", path: path) if due
550
+ end
551
+
527
552
  def browser_request?(headers)
528
553
  return true if headers.key?("origin")
529
554
  return true if headers.key?("sec-fetch-site") && headers["sec-fetch-site"].downcase != "none"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.108.0
3
+ version: 0.110.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -316,6 +316,10 @@ module Samagotchi
316
316
  Entry.new(key: "web.participants.expires", yaml_path: %w[web participants expires], type: :string, default: "7d", expose: %i[env config], lifecycle: :next_matching_turn_display_poll_write),
317
317
  Entry.new(key: "web.participants.cap", yaml_path: %w[web participants cap], type: :float, default: 2.0, expose: %i[env config], lifecycle: :next_matching_turn_display_poll_write),
318
318
  Entry.new(key: "web.participants.queue", yaml_path: %w[web participants queue], type: :integer, default: 3, expose: %i[env config], lifecycle: :next_matching_turn_display_poll_write),
319
+ # chi web behind an auth proxy (Web::PublicUrl, docs/configuration.md "Behind an auth proxy").
320
+ Entry.new(key: "web.public_url", yaml_path: %w[web public_url], type: :string, default: nil, expose: %i[env config], lifecycle: :web_start),
321
+ Entry.new(key: "web.proxy.identity_header", yaml_path: %w[web proxy identity_header], type: :string, default: nil, expose: %i[env config], lifecycle: :web_start),
322
+ Entry.new(key: "web.proxy.owner", yaml_path: %w[web proxy owner], type: :string, default: nil, expose: %i[env config], lifecycle: :web_start),
319
323
 
320
324
  Entry.new(key: "no_interrupt", yaml_path: %w[no_interrupt], type: :bool, default: false, expose: %i[env config cli], lifecycle: :next_cli_run),
321
325
  Entry.new(key: "no_default_input", yaml_path: %w[no_default_input], type: :bool, default: false, expose: %i[env config cli], lifecycle: :next_cli_run),
@@ -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) }.compact
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)
@@ -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
- def emit_recap(recap:, generation:, covered: nil)
590
- @session_observer.notify(type: :recap_ready, recap: recap, generation: generation, covered: covered)
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
@@ -504,7 +504,7 @@ module Samagotchi
504
504
  model: served || job[:model], created_at: Time.now.utc.iso8601 }
505
505
  @mutex.synchronize { @state = saved }
506
506
  save(saved)
507
- @engine.emit_recap(recap: text, generation: job[:generation], covered: job[:covered])
507
+ @engine.emit_recap(recap: text, generation: job[:generation], covered: job[:covered], created_at: saved[:created_at])
508
508
  rescue StandardError
509
509
  @in_flight = nil
510
510
  end
@@ -8,7 +8,7 @@ module Samagotchi
8
8
  #
9
9
  # Two delivery paths cooperate:
10
10
  # 1. Every turn injects what is due (ReminderQueue#inject!, from
11
- # Engine#run_turn) as [SYSTEM: REMINDERS DUE] and marks it fired.
11
+ # Engine#run_turn) as [SYSTEM: REMINDERS DUE <local time>] and marks it fired.
12
12
  # 2. Synthetic turns: when this job's tick finds due reminders while
13
13
  # idle, it fires @auto_turn_callback, which queues their names
14
14
  # (Engine#note_due_reminders) for the worker's or the REPL's
@@ -33,6 +33,7 @@ require_relative "llm_context_forget"
33
33
  require_relative "working_set"
34
34
  require_relative "llm_context_notice"
35
35
  require_relative "answer_display"
36
+ require_relative "message_time"
36
37
  require_relative "llm/model_result"
37
38
  require_relative "llm/turn_settings"
38
39
  require_relative "llm/stream_drops"
@@ -360,7 +361,8 @@ module Samagotchi
360
361
  return after_malformed(turn, generation, reason)
361
362
  end
362
363
 
363
- turn.conversation << { role: "model", content: request.prefill + generation.response.to_s }
364
+ turn.conversation << { role: "model", content: request.prefill + generation.response.to_s,
365
+ MessageTime::KEY => MessageTime.iso(MessageTime.now) }
364
366
  # Profile-specific parse (incl. Qwen unterminated-block recovery); the
365
367
  # returned fragment (non-nil only for Qwen) is fed back on the next
366
368
  # iteration if the model opened a tool-call block it did not close.
@@ -867,7 +869,8 @@ module Samagotchi
867
869
  # cannot leak) so a follow-up steering message continues with the model's
868
870
  # half-finished work in context instead of losing it.
869
871
  unless partial.empty?
870
- message = { role: "model", content: "#{partial}\n[interrupted]", interrupted: true }
872
+ message = { role: "model", content: "#{partial}\n[interrupted]", interrupted: true,
873
+ MessageTime::KEY => MessageTime.iso(MessageTime.now) }
871
874
  # The thinking this generation streamed goes with it (the chat loop's
872
875
  # with_thinking key, which MessageParts reads and the prompt doesn't):
873
876
  # without it, reloading the cut step shows the empty answer it isn't.
@@ -1088,16 +1091,18 @@ module Samagotchi
1088
1091
 
1089
1092
  # +keep_thought_from+: model messages from this index on keep their
1090
1093
  # thought blocks (a resumed run's); nil strips every one.
1094
+ # A model message is rebuilt from an allow-list: `display`,
1095
+ # `display_refs` and its wall time (MessageTime::KEY) ride along so the
1096
+ # stored conversation keeps them; the prompt formatter never reads them
1097
+ # (AnswerDisplay, AnswerRefs, MessageTime).
1091
1098
  def sanitize_history(messages, keep_thought_from: nil)
1092
1099
  messages.each_with_index.map do |m, index|
1093
1100
  if m[:role] == "model" && keep_thought_from && index >= keep_thought_from
1094
- { role: m[:role], content: m[:content].to_s, display: m[:display], display_refs: m[:display_refs] }.compact
1101
+ { role: m[:role], content: m[:content].to_s, display: m[:display], display_refs: m[:display_refs],
1102
+ MessageTime::KEY => m[MessageTime::KEY] }.compact
1095
1103
  elsif m[:role] == "model"
1096
- # `display` and `display_refs` ride along so the stored
1097
- # conversation keeps them; the prompt formatter never reads them
1098
- # (AnswerDisplay, AnswerRefs).
1099
1104
  { role: m[:role], content: strip_thought_blocks(m[:content].to_s), display: m[:display],
1100
- display_refs: m[:display_refs] }.compact
1105
+ display_refs: m[:display_refs], MessageTime::KEY => m[MessageTime::KEY] }.compact
1101
1106
  else
1102
1107
  m.dup
1103
1108
  end
@@ -30,6 +30,7 @@ require_relative "../empty_answer_retry"
30
30
  require_relative "../steer"
31
31
  require_relative "../reasoning_history"
32
32
  require_relative "../turn_note"
33
+ require_relative "../message_time"
33
34
  require_relative "../thinking"
34
35
 
35
36
  module Samagotchi
@@ -432,7 +433,8 @@ module Samagotchi
432
433
  # note's marker (TurnNote.empty, never sent), and an answer's display
433
434
  # and display_refs (AnswerDisplay, AnswerRefs; never sent), a
434
435
  # find_tools result's loaded names and a tool change's TOOL_CHANGE
435
- # (sent only to a wire that loads tools).
436
+ # (sent only to a wire that loads tools), and an entry's wall time
437
+ # (MessageTime::KEY, display-only, never sent).
436
438
  def plain(conversation)
437
439
  conversation.map do |entry|
438
440
  content = entry[:content].is_a?(Array) ? entry[:content] : entry[:content].to_s
@@ -454,6 +456,7 @@ module Samagotchi
454
456
  Steer::AUTHOR_KEYS.each { |key| message[key] = entry[key] if entry[key] }
455
457
  message[ToolResponse::LOADED] = entry[ToolResponse::LOADED] if entry[ToolResponse::LOADED].is_a?(Array)
456
458
  message[TOOL_CHANGE] = entry[TOOL_CHANGE] if entry[TOOL_CHANGE].is_a?(Hash)
459
+ message[MessageTime::KEY] = entry[MessageTime::KEY] if entry[MessageTime::KEY]
457
460
  message
458
461
  end
459
462
  end
@@ -704,7 +707,10 @@ module Samagotchi
704
707
  # (reasoning_history). A wire that sends its blocks back keeps them
705
708
  # as +anthropic_content+ (ChatResponse#provider_content: string
706
709
  # keys, as received, so a resumed session sends the same bytes).
710
+ # Every reply built from a response comes through here, so it is
711
+ # also where a reply gets its wall time (MessageTime::KEY).
707
712
  def with_thinking(message, response)
713
+ MessageTime.stamp(message, MessageTime.now)
708
714
  reasoning = response.reasoning.to_s
709
715
  message = message.merge(thinking: reasoning) unless reasoning.strip.empty?
710
716
  blocks = response.provider_content
@@ -1038,7 +1044,8 @@ module Samagotchi
1038
1044
  emit(type: :generation_cancelled, iteration: iteration, reason: reason, stopped_by: @cancel_controller&.stopped_by)
1039
1045
  visible = @loop.strip_model_thought(partial.to_s).strip
1040
1046
  unless visible.empty?
1041
- message = { role: "model", content: "#{visible}\n[interrupted]", interrupted: true }
1047
+ message = { role: "model", content: "#{visible}\n[interrupted]", interrupted: true,
1048
+ MessageTime::KEY => MessageTime.iso(MessageTime.now) }
1042
1049
  thought = thinking.to_s
1043
1050
  message[:thinking] = thought unless thought.strip.empty?
1044
1051
  @conversation << message
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Samagotchi
6
+ # When a history entry happened: its wall-clock stamp (KEY), and the
7
+ # dividers the UIs print when a quiet gap passes between two entries.
8
+ #
9
+ # The stamp is display-only: no wire format reads it, so the bytes the
10
+ # model gets (and its prompt cache) are the same with or without it. A
11
+ # time the model should read is baked into the message's content when the
12
+ # message is made (ReminderQueue's header), never rendered at wire time.
13
+ #
14
+ # Everything here is a pure function of the times passed in; the one wall
15
+ # clock is #now (a seam the specs pin to a fixed time).
16
+ module MessageTime
17
+ # The history entry key: a local-offset ISO 8601 time with milliseconds.
18
+ KEY = :at
19
+ # The quiet time after which the UIs mark a gap.
20
+ GAP_SECONDS = 600
21
+
22
+ DEFAULT_SOURCE = -> { Time.now }
23
+ private_constant :DEFAULT_SOURCE
24
+
25
+ class << self
26
+ # The wall clock: a callable returning a Time (nil: Time.now).
27
+ attr_writer :source
28
+
29
+ # @return [Time] now, by the wall clock seam
30
+ def now = (@source || DEFAULT_SOURCE).call
31
+
32
+ # Back to Time.now.
33
+ def reset! = @source = nil
34
+ end
35
+
36
+ module_function
37
+
38
+ # @param time [Time]
39
+ # @return [String] the stamp's text
40
+ def iso(time) = time.iso8601(3)
41
+
42
+ # The stamp of an ISO text from elsewhere (a note's created_at), or nil
43
+ # when it doesn't parse.
44
+ def iso_of(text)
45
+ text && iso(Time.iso8601(text.to_s))
46
+ rescue ArgumentError
47
+ nil
48
+ end
49
+
50
+ # +message+ with KEY set to +time+'s stamp, unless it has one already
51
+ # (a Symbol- or String-keyed message). Changes +message+ and returns it.
52
+ def stamp(message, time)
53
+ return message if message.key?(KEY) || message.key?(KEY.to_s)
54
+
55
+ message[KEY] = time.is_a?(String) ? time : iso(time)
56
+ message
57
+ end
58
+
59
+ # @return [String, nil] the stamp of +message+ (Symbol or String keys)
60
+ def of(message)
61
+ return nil unless message.is_a?(Hash)
62
+
63
+ message[KEY] || message[KEY.to_s]
64
+ end
65
+
66
+ # @return [String, nil] "14:32", local time
67
+ def clock(iso) = local(iso)&.strftime("%H:%M")
68
+
69
+ # @return [String, nil] "Sat 24 Oct 09:10", local time
70
+ def day_clock(iso) = local(iso)&.strftime("%a %-d %b %H:%M")
71
+
72
+ # The divider between activity that ended at +prev_iso+ and activity
73
+ # starting at +cur_iso+: nil below GAP_SECONDS (or when either time is
74
+ # unknown), else "── 14:32 · 47 min later ──", the date added when the
75
+ # local day changed.
76
+ def gap_line(prev_iso, cur_iso)
77
+ prev = local(prev_iso)
78
+ cur = local(cur_iso)
79
+ return nil unless prev && cur
80
+
81
+ seconds = cur - prev
82
+ return nil if seconds < GAP_SECONDS
83
+
84
+ time = prev.to_date == cur.to_date ? cur.strftime("%H:%M") : cur.strftime("%a %-d %b %H:%M")
85
+ "── #{time} · #{ago(seconds)} later ──"
86
+ end
87
+
88
+ # @return [String] "47 min", "3 h", "2 d" (whole units, rounded down;
89
+ # the web's relativeTime scale)
90
+ def ago(seconds)
91
+ seconds = seconds.to_f
92
+ return "#{[(seconds / 60).floor, 0].max} min" if seconds < 3600
93
+ return "#{(seconds / 3600).floor} h" if seconds < 86_400
94
+
95
+ "#{(seconds / 86_400).floor} d"
96
+ end
97
+
98
+ def local(iso)
99
+ iso && Time.iso8601(iso.to_s).localtime
100
+ rescue ArgumentError
101
+ nil
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # The request headers a proxy in front of chi adds and chi's own clients
5
+ # never send: chi web takes a request with one as proxied
6
+ # (Web::ProxyGate), a Bridge refuses it (Bridge#browser_request?). Names
7
+ # are compared lower-cased with "_" read as "-", so an underscore twin
8
+ # (X_Forwarded_For) counts too. See docs/configuration.md "Behind an auth
9
+ # proxy".
10
+ module ProxyTrace
11
+ # Any name starting with one of these: X-Forwarded-For/-Host/-Proto/
12
+ # -Email/-User…, oauth2-proxy's X-Auth-Request-*, Cloudflare's Cf-*,
13
+ # Tailscale Serve's Tailscale-User-*, Authentik's X-Authentik-*, Google
14
+ # IAP's X-Goog-*, Azure App Service's X-MS-Client-Principal*, AWS ALB's
15
+ # X-Amzn-Oidc-*, Envoy's X-Envoy-*.
16
+ PREFIXES = %w[x-forwarded- x-auth-request- cf- tailscale- x-authentik- remote- x-goog- x-ms-client-principal
17
+ x-amzn-oidc- x-envoy- x-original-forwarded-].freeze
18
+ # And these whole names.
19
+ NAMES = %w[forwarded x-real-ip via true-client-ip x-client-ip x-original-url x-original-uri].freeze
20
+
21
+ # The identity headers known setups send (docs/configuration.md's
22
+ # table): chi web's hint when the proxy sends one of them but not the
23
+ # one configured.
24
+ IDENTITY_HEADERS = %w[X-Forwarded-Email X-Auth-Request-Email Cf-Access-Authenticated-User-Email Tailscale-User-Login
25
+ Remote-Email X-Authentik-Email X-Goog-Authenticated-User-Email X-MS-Client-Principal-Name
26
+ X-Amzn-Oidc-Identity X-Forwarded-User X-Auth-Request-User Remote-User].freeze
27
+
28
+ module_function
29
+
30
+ # @param name [String] a header's name as sent (any case, - or _)
31
+ def header?(name)
32
+ key = normalize(name)
33
+ NAMES.include?(key) || PREFIXES.any? { |prefix| key.start_with?(prefix) }
34
+ end
35
+
36
+ def normalize(name) = name.to_s.downcase.tr("_", "-")
37
+
38
+ # The header name of a Rack env key (HTTP_X_FORWARDED_FOR →
39
+ # x-forwarded-for), nil for a key that isn't a header's.
40
+ def env_name(key)
41
+ key = key.to_s
42
+ key.start_with?("HTTP_") ? key.delete_prefix("HTTP_").downcase.tr("_", "-") : nil
43
+ end
44
+ end
45
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "monitor"
4
+ require_relative "message_time"
4
5
 
5
6
  module Samagotchi
6
7
  # The session's due reminders, on their way into a turn.
@@ -11,9 +12,16 @@ module Samagotchi
11
12
  # turn while they are pending. A turn injects whatever is due then
12
13
  # (#inject!), which marks it fired and clears the pending names.
13
14
  class ReminderQueue
15
+ # The reminders message's first line at +time+ (a Time in the zone to
16
+ # show: Time.now's is the local one).
17
+ def self.header(time) = "[SYSTEM: REMINDERS DUE #{time.strftime("%Y-%m-%d %H:%M")} local]"
18
+
14
19
  # @param store [ReminderStore, nil]
15
- def initialize(store:)
20
+ # @param now [#call] the wall clock (a Time) for the injected message's
21
+ # stamp, when #inject! is given none
22
+ def initialize(store:, now: -> { MessageTime.now })
16
23
  @store = store
24
+ @now = now
17
25
  @lock = Monitor.new
18
26
  @pending = []
19
27
  end
@@ -55,14 +63,23 @@ module Samagotchi
55
63
  # A tail message, not the head system prompt: changing the head would
56
64
  # make the model server re-evaluate the whole conversation's prefix
57
65
  # (its KV cache) every interval; a tail costs only the new suffix.
66
+ # The header names the local time the reminders came due at, for the
67
+ # model ("[SYSTEM: REMINDERS DUE 2026-10-24 14:32 local]"): baked into
68
+ # the content here, never rendered at wire time, so a replay sends the
69
+ # same bytes (the prompt cache) whatever the time zone or DST. The
70
+ # message also carries that time as its stamp (MessageTime::KEY,
71
+ # display-only).
58
72
  # @param messages [Array<Hash>] the turn's messages (appended to)
73
+ # @param now [Time, nil] the turn's wall time (nil: the clock's)
59
74
  # @return [Array<Hash>] the reminders injected (maybe none)
60
- def inject!(messages)
75
+ def inject!(messages, now: nil)
61
76
  reminders = due
62
77
  return [] if reminders.empty?
63
78
 
79
+ time = now || @now.call
64
80
  lines = reminders.map { |r| " #{r[:name]}: #{r[:description]} (interval: #{r[:interval_minutes]}m)" }.join("\n")
65
- messages << { role: "system", content: "[SYSTEM: REMINDERS DUE]\n#{lines}\n[END REMINDERS]" }
81
+ messages << { role: "system", content: "#{self.class.header(time)}\n#{lines}\n[END REMINDERS]",
82
+ MessageTime::KEY => MessageTime.iso(time) }
66
83
  @store.mark_fired_batch(reminders.map { |r| r[:name] })
67
84
  # Without this a turn's injection leaves a stale name, and the next
68
85
  # reminder turn would run with nothing due.