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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +66 -1
- data/bin/chi +5 -1
- data/docs/architecture.md +7 -2
- data/docs/cli.md +40 -1
- data/docs/configuration.md +177 -2
- data/docs/internals/engine.md +19 -4
- data/docs/internals/participants.md +28 -8
- data/docs/internals/prompt-caching.md +5 -0
- data/docs/internals/remote-sessions.md +6 -2
- 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/bridge.rb +25 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +4 -0
- 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/proxy_trace.rb +45 -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 +52 -10
- 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 +246 -32
- data/lib/samagotchi/web/grants.rb +6 -3
- data/lib/samagotchi/web/info_probe.rb +5 -1
- data/lib/samagotchi/web/link_codes.rb +146 -0
- data/lib/samagotchi/web/participant_access.rb +9 -2
- data/lib/samagotchi/web/participant_review.rb +1 -1
- data/lib/samagotchi/web/participant_view.rb +12 -5
- data/lib/samagotchi/web/participants_admin.rb +13 -6
- data/lib/samagotchi/web/proxy_gate.rb +174 -0
- data/lib/samagotchi/web/public/app.js +35 -3
- data/lib/samagotchi/web/public/auth_lost.js +76 -0
- data/lib/samagotchi/web/public/bubbles.js +29 -7
- data/lib/samagotchi/web/public/data.js +29 -5
- 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 +47 -10
- 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/token_fragment.js +57 -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/public_url.rb +87 -0
- data/lib/samagotchi/web/running.rb +39 -4
- data/lib/samagotchi/web/server.rb +138 -22
- data/lib/samagotchi/web/tunnel/tailscale.rb +325 -0
- data/lib/samagotchi/web/tunnel.rb +133 -0
- data/lib/samagotchi/worker_sidecar.rb +4 -3
- 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,
|
|
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]
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -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"
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -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)
|
|
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
|
|
@@ -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]
|
|
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
|
-
|
|
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: "
|
|
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.
|