samagotchi 0.59.0 → 0.60.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +49 -1
  3. data/README.md +3 -2
  4. data/bin/chi +23 -2
  5. data/docs/architecture.md +4 -2
  6. data/docs/cli.md +9 -3
  7. data/docs/configuration.md +62 -12
  8. data/docs/hooks.md +10 -4
  9. data/docs/internals/engine.md +16 -8
  10. data/docs/internals/gemma4-contract.md +1 -0
  11. data/docs/internals/prompt-caching.md +3 -2
  12. data/docs/memory.md +1 -1
  13. data/docs/plugins.md +50 -1
  14. data/docs/sessions.md +1 -1
  15. data/docs/sub-agent.md +2 -2
  16. data/docs/testing.md +4 -2
  17. data/lib/samagotchi/bootstrap_command.rb +2 -1
  18. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  19. data/lib/samagotchi/bundles/agents-md/manifest.yml +11 -0
  20. data/lib/samagotchi/bundles/agents-md/plugin.rb +198 -0
  21. data/lib/samagotchi/bundles/core/manifest.yml +2 -1
  22. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  23. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  24. data/lib/samagotchi/config.rb +33 -5
  25. data/lib/samagotchi/continue_offer.rb +5 -2
  26. data/lib/samagotchi/engine.rb +63 -14
  27. data/lib/samagotchi/iteration_limit.rb +3 -2
  28. data/lib/samagotchi/kernel_loop.rb +35 -9
  29. data/lib/samagotchi/llm/backend.rb +6 -1
  30. data/lib/samagotchi/llm/chat_loop.rb +11 -6
  31. data/lib/samagotchi/llm/model_result.rb +11 -2
  32. data/lib/samagotchi/llm/native_backend.rb +3 -2
  33. data/lib/samagotchi/log_subscriber.rb +2 -1
  34. data/lib/samagotchi/plugin/context.rb +16 -3
  35. data/lib/samagotchi/prompt_files.rb +127 -0
  36. data/lib/samagotchi/ref.rb +124 -0
  37. data/lib/samagotchi/session.rb +15 -3
  38. data/lib/samagotchi/session_metrics.rb +8 -2
  39. data/lib/samagotchi/session_setup.rb +11 -5
  40. data/lib/samagotchi/system_prompt.rb +71 -15
  41. data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -9
  42. data/lib/samagotchi/terminal_ui/formatting.rb +14 -0
  43. data/lib/samagotchi/terminal_ui.rb +17 -5
  44. data/lib/samagotchi/turn_flow.rb +22 -3
  45. data/lib/samagotchi/version.rb +1 -1
  46. data/lib/samagotchi/web/public/favicon.svg +1 -0
  47. data/lib/samagotchi/web/public/index.html +1 -0
  48. data/lib/samagotchi/web/public/refs.js +107 -15
  49. data/lib/samagotchi/worker.rb +1 -0
  50. metadata +6 -1
data/docs/sessions.md CHANGED
@@ -70,7 +70,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
70
70
  - `Session.list` / `SessionManager.list_sessions` / `GET /api/sessions?sort=&order=&limit=&offset=` default to `updated_at desc` (newest activity first). Also supports `created_at`, `asc`. `X-Total-Count` header when paginated.
71
71
  - Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new chats start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new chat there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own project). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat (`hide ▴` puts them away behind a `▾ N sessions` pill, remembered; in a window under 900 px wide, where the strip scrolls sideways, `▴ hide` sits in the top bar where the pill comes back; a window under 700 px tall hides them by itself, so a question or approval card in the stage keeps room, and the pill shows them until the window is tall again). A parent and its delegates are one card there: its `▸ 2 delegates · 1 live · 1 waiting` chip (the waiting part in the attention colour, the card's border too) opens their rows in a popover under it (a row per delegate at any depth, indented; a click opens that one, Esc or a click outside closes it, and the strip's order holds while it is open). A fork is a card of its own, and so is a delegate whose parent isn't listed (archived, another project), with its `↳ <parent>` chip; the pill counts cards, and every live or waiting session. "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). There a parent's card holds its delegates the same way, the chip opening their rows inside the card (each with an archive button on hover), open by itself while one of them waits on you; the count says `14 (+7 delegates)`, a search that matches a delegate shows its parent's card with the list open and the matching rows marked, and select mode picks cards (archiving a parent takes its delegates). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). A file in a tool row (a read, edit or write's title) and its diff lines are refs: hover one and click to open the current file in your editor at that line, on this machine only (`web.editor`; the browser's "always allow" for the page's port and VS Code's "allow opening local files" are asked once; see [Opening files from chi web](configuration.md#opening-files-from-chi-web)). So are an edit or write approval card's paths (at the first line the dry run changes) and its diff lines, before you approve, and the numbered lines of a read's output: `lines 40–60` under it opens the text as read, numbered, when the output is the file as-is (not the head/tail preview of a big file, an error or an image; no numbers after a cut). A `path:line` (or `path:line-line`) in an answer or a step's text is one too once the turn has ended, and after a reload, when it names a file a row or card on the page touched: the path under the session's folder, else the one touched file it ends with (a bare `app.js:12` when only one touched file is named so; two such files make it none); a path without a line counts only when it is a touched file exactly. Code blocks, links, bare URLs and inline code with more than the ref in it are left alone, and a file the session didn't touch is never linked; a link whose text is such a ref alone (github-pr's link to the PR diff) becomes a ref too, and its plain click still goes to the PR. A ref with more than one target shows a small `▾` on hover: it opens a menu of them, in order **PR diff** (a link's), **Open in editor**, **Show in turn** and **Copy path**, and a plain click runs the first (a link's own, else the editor, else show in turn). **Show in turn** scrolls to the tool row that touched the file (looked for in the ref's own step, then its own turn, then the rows before it, then the nearest after it; there, the last one showing the ref's line, else the last one), opens the steps, the stage's steps and the row's diff (or the read showing the line) and flashes the row and the line; an answer's or a step's ref and an approval card's path have it, not a row's own title or lines. It needs no editor, so a viewer without one (a phone on the LAN) gets an answer's refs with **Show in turn** as their click; a row's title and diff lines stay plain text there. On a touch screen a tap opens the menu. A ref with a target is a tab stop: Enter runs its first target, ↓ opens the menu (↑/↓ move, Enter picks, Esc closes back to the ref). Diff and read lines have their click only. The info bar copies `chi --attach <id>` for a terminal. A session whose prompt carried model notes has a `notes: deepseek` chip there (the names without `model_notes_`; the tooltip lists each with its scope and size; `session.prompt_notes` in `GET /api/sessions/:id`, the live worker's, else the session file's; see [Memory](memory.md#model-notes)). Its `llm ctx` chip names the LLM context strategy the next turn runs under (`llm ctx stale,forget · session`, in the accent colour when the session set its own; the tooltip gives each value's source), and its popover sets the session's own strategy, apply rule and budget, or follows the model again, by running `/llm-context` in the worker (the reply shows as a command bubble); `GET /api/sessions/:id`'s `session.llm_context` carries the values (the live worker's, else resolved from the session file), and a `command_ran` that changed them carries the new ones. Its `think` chip does the same for the thinking level (`/thinking`), and on a chat host its `reasoning` chip for the reasoning history (`reasoning all · session`, `/reasoning-history`; `session.reasoning_history`, null on a native host). A `/thinking` or `/reasoning-history` change that restarts the prompt cache asks first: the reply bubble has an **Apply (restarts the cache)** button that sends its `--yes` line (the chips' Set sends that line itself; see [Changes that restart the prompt cache](configuration.md#changes-that-restart-the-prompt-cache)). It shows the context used (`ctx 42%`) and the last generation's decode speed (`87 tok/s`, `~64 tok/s` when estimated; see `/stats` in [cli.md](cli.md#session-commands)); hovering `ctx` there or on a session card lists the session's tokens in (summed over every request, with the cached share), out (with reasoning) and the cost when the provider reports one, for this session only (one estimated from a `hosts.<name>.models` price shows with `~`: `cost: ~$0.54 ($0.42 reported, ~$0.12 estimated)`), and the memory indexes its prompt holds (`memory index: ~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)`; see [Memory](memory.md#the-indexs-size)). The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new chat starts on; it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first (an id the host declares under `hosts.<name>.models` first, noted `config`, its tooltip `served by config (hosts.<name>.models)`; a host that failed to list its models keeps those ids, greyed and noted `host down`, still pickable; a remembered or default model stays chosen even then, greyed on the button); typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id, configured?, unavailable?, sampling?, llm_context?}], warning?}`; `configured: true` marks an id declared under `hosts.<name>.models`, listed first under its host, `unavailable: true` one whose host failed to list its models; `sampling` is the model's configured request parameters as `/model` words them, the row's tooltip in the list; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). Beside it, an `llm ctx` chip shows the LLM context the picked model starts under (each `GET /api/models` row's `llm_context`, as the info bar's); its form sets one for the new chat only, sent as `POST /api/sessions`'s `llm_context` and never remembered (see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last model picked; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
72
72
  - Web notifications: when a session needs you while the tab is hidden or behind another window (a question or a guardrail approval, a plugin card with buttons during a turn such as check-in's, a failed turn, a turn done after 10 s or more; a delegated session only for its questions, a canceled turn never), the page title counts it, `(N) Chi`, until you come back to the tab. The bell at the right of the top bar also turns on OS notifications ("needs an answer", "needs approval", "needs you", "turn failed", "done in 42 s", under the session's first message; a click opens the session): its first click asks the browser for the permission, and the choice is kept per browser. Any session of the page's scope counts, not only the open one. What was already so when the page loaded never counts, and several chi tabs show one notification per event. A chi tab in front tells the others (a `BroadcastChannel`), so while you look at one, the ones behind neither notify nor count what it shows; coming back to a tab clears those events in the other tabs' titles too. The hub's `session` frames carry what this needs: `pending_question` (`{id, kind}`, kind `question` or `approval`), `pending_card` (`{id, bundle}`: the running turn's card with actions, from `pending_card.json` that the worker keeps in the session's folder while one is open; a card without actions, such as loop-guard's, never counts) and `last_turn` (`{outcome, ended_at, seconds, origin}`, saved in the session's JSON as the engine ends each turn).
73
- - The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `day N` chip and rows, the info bar's ← → links, a refused continue), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `format.js` (pure formatters such as `previewOf`), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
73
+ - The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `day N` chip and rows, the info bar's ← → links, a refused continue), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `format.js` (pure formatters such as `previewOf`), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, a plugin's ref data (label, http(s) URL actions) bound in a WeakMap by `bindRef`, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
74
74
  - Selecting a session in the web UI is read-only: `GET /api/sessions/:id` never spawns a worker (it reads a live worker's snapshot when one runs, else the session file). A prompt (`POST /turn`) or a command (`POST /command`) wakes the worker, and `/stream` briefly waits for a freshly-spawned bridge before answering. A caught-up SSE reconnect holds the stream open; `reset` markers are only sent for reconnects behind the ring window, or with a cursor from another worker (event ids are `<event_seq>-<epoch>`, one epoch per worker).
75
75
 
76
76
  **Test-session hygiene:**
data/docs/sub-agent.md CHANGED
@@ -55,8 +55,8 @@ command, folder, rule and reason, or `continue`, the step-limit question below.
55
55
 
56
56
  ## The step limit
57
57
 
58
- A turn that runs out of steps (`turn.max_iterations`, default 100) before it
59
- answers waits as a question of kind `continue` (`"limit": 100`, options
58
+ A turn that runs out of steps (`turn.max_iterations`, default 300) before it
59
+ answers waits as a question of kind `continue` (`"limit": 300`, options
60
60
  `Continue` and `Stop`). `answer_with` continues it:
61
61
  `chi answer ID --question QID --option Continue`, and the continued turn's
62
62
  reply (or its next question) comes back on that same wait. With
data/docs/testing.md CHANGED
@@ -107,8 +107,10 @@ sessions by model and by the model notes their prompt carried (the session file'
107
107
  prints the numbers a model note is meant to move: the place of the first edit (an `edit` or `write` call) and of the
108
108
  first commit (an `execute` call that runs `git [options] commit`) among the tool calls, commits per 100 steps (model
109
109
  requests), `execute` calls with a bare `&`, the longest run of calls with no edit, and what the session needed from
110
- outside: Continues (the turns in its `analytics.json` marked `continue: true` — the mark a file written after
111
- 2026-10-09 carries; an older file has none, and its turns with no prompt of their own are counted instead), steers (lines that reached a
110
+ outside: Continues (the step-limit Continues: the turns in its `analytics.json` marked `resumed: true`, the mark a
111
+ file written after 2026-10-11 carries; a file from 2026-10-09 on has `continue: true` only, which a reminder's and a
112
+ wake's turn carry too, and that is counted instead, record by record in a file that spans both; an older file has neither, and its turns with no prompt of their
113
+ own are counted), steers (lines that reached a
112
114
  running turn from the user, `chi send` or a parent, and a Continue's text), follow-up prompts and plugin nudges. A
113
115
  group has the medians of the places and runs, commits per 100 steps pooled and the counts summed. `--help` defines
114
116
  each column; `--model GLOB`, `--since DATE` and `--min-steps N` filter, `--json` gives JSON. It reads only and prints
@@ -41,7 +41,8 @@ module Samagotchi
41
41
  With no config.yml it writes one; an existing one gets a hosts:
42
42
  entry added (after a backup), and its other lines stay as they are.
43
43
  Then it installs the system bundle and the core bundles (loop-guard,
44
- check-in, guardrails), and on a terminal offers the dev bundles.
44
+ check-in, guardrails, slow-commands, agents-md), and on a terminal
45
+ offers the dev bundles.
45
46
  TEXT
46
47
 
47
48
  FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS) do |f|
@@ -118,6 +118,7 @@ module Samagotchi
118
118
  return [] unless turn
119
119
 
120
120
  started = { type: :turn_started, prompt: turn[:prompt], origin: turn[:origin], continue: !!turn[:continue] }
121
+ started[:resumed] = true if turn[:resumed]
121
122
  started[:started_at] = started_at if started_at
122
123
  events = [with_images(started, turn[:images])]
123
124
  text_iteration = nil # a text part is open for this iteration
@@ -238,6 +239,7 @@ module Samagotchi
238
239
  dequeue([event[:origin]])
239
240
  @turn = { prompt: event[:prompt], origin: event[:origin], continue: !!event[:continue],
240
241
  parts: [], pending_question: nil }
242
+ @turn[:resumed] = true if event[:resumed]
241
243
  @tool_started_at = {}
242
244
  @turn[:images] = event[:images] if event[:images]
243
245
  @turn[:cut_after] = event[:cut_after] if event[:cut_after]
@@ -0,0 +1,11 @@
1
+ ---
2
+ name: agents-md
3
+ version: 0.1.0
4
+ scope: system
5
+ description: "Steers a nested AGENTS.md into the conversation the first time a tool touches a file under its directory"
6
+ trust_level: reviewed
7
+ plugin:
8
+ file: plugin.rb
9
+ sha256: sha256:fba1b4b48fa99299aa4c5b56999f5083513f7eb22d85f3fd2a5c2dbd0d97c707
10
+ # ctx.prompt_files, read when the plugin loads (0.60.0)
11
+ requires_chi: ">= 0.60.0"
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The agents-md bundle (docs/plugins.md, The agents-md bundle): a nested
4
+ # AGENTS.md of the session's checkout is steered into the conversation the
5
+ # first time a tool touches a file under its directory.
6
+ #
7
+ # chi's system prompt holds only the AGENTS.md chain from the checkout top
8
+ # down to the session's cwd (SystemPrompt#prompt_files), so an AGENTS.md in
9
+ # a directory the session doesn't run in (pkg/inner/AGENTS.md while the
10
+ # session runs in pkg/) is never seen. Working on a nested file usually
11
+ # means its directory's rules apply, so the first ok `read`, `write` or
12
+ # `edit` (settings tools) of such a file steers the nearest AGENTS.md into
13
+ # the running turn, at the loop's next boundary, as its own user message:
14
+ #
15
+ # AGENTS.md for pkg/inner/ (applies to work under pkg/inner/):
16
+ # <content>
17
+ #
18
+ # It is history, not system prompt: the prompt cache is untouched, and a
19
+ # resume, a woken worker or a fork (which copies the messages) never
20
+ # re-injects — a before_turn pass over event[:messages] replaces the loaded
21
+ # set with the dirs whose steer is already in the conversation. A steer
22
+ # that was queued but dropped (the model answered first) is not in the
23
+ # messages, so it fires again on the next touch. It never stops generation
24
+ # or thinking; a plugin steer can't cut (SteerCut#cut_for_steer).
25
+ #
26
+ # Paths are compared by File.realpath, falling back to File.expand_path for
27
+ # a path that doesn't exist: the session's cwd can be /tmp/x on macOS while
28
+ # git and the prompt's files give /private/tmp/x. The touched file's own git
29
+ # root must equal the session's checkout (ctx.repo_root): a file in another
30
+ # repository, a nested repository or a submodule is ignored, and the walk up
31
+ # stops at that root. Directories whose AGENTS.md is already in the system
32
+ # prompt (ctx.prompt_files: the chain and appended files) or already steered
33
+ # are skipped.
34
+ #
35
+ # Settings (config.yml, bundles: agents-md:):
36
+ # tools: [read, write, edit] the calls that count as a touch
37
+ # max_chars: 16000 the content is cut at this many chars, the
38
+ # last line "[cut at N of M chars: read <path>
39
+ # for the rest]" naming the file by its absolute path
40
+ #
41
+ # skip_agents_md: true (top-level) disables the plugin, as it does the
42
+ # chain's loading.
43
+
44
+ require "samagotchi/config"
45
+ require "samagotchi/guardrails/context"
46
+
47
+ class Plugin
48
+ FILE_NAME = "AGENTS.md"
49
+ DEFAULT_TOOLS = %w[read write edit].freeze
50
+ DEFAULT_MAX_CHARS = 16_000
51
+ # The marker on the steer's first line (and what #rebuild matches):
52
+ # <rel dir>/ is relative to the checkout top, so a dir is unique.
53
+ MARKER = %r{\AAGENTS\.md for (.+)/ \(applies to work under \1/\):\n}
54
+
55
+ def initialize(settings = {})
56
+ tools = settings["tools"]
57
+ @tools = if tools.nil?
58
+ DEFAULT_TOOLS
59
+ elsif tools.is_a?(Array)
60
+ tools.map(&:to_s)
61
+ else
62
+ tools.to_s.split(/\s+/).reject(&:empty?)
63
+ end
64
+ chars = settings["max_chars"]
65
+ chars = chars.to_i if chars
66
+ @max_chars = chars.is_a?(Integer) && chars.positive? ? chars : DEFAULT_MAX_CHARS
67
+ @git = Samagotchi::Guardrails::GitInfo.new
68
+ # session_id => {rel dir => true}: the dirs steered this session.
69
+ @loaded = Hash.new { |hash, id| hash[id] = {} }
70
+ @lock = Mutex.new
71
+ end
72
+
73
+ def register(chi)
74
+ # Replaces the loaded set from the conversation: a dropped steer fires
75
+ # again on the next touch, a resume or a fork rebuilds from its messages.
76
+ chi.on(:before_turn) { |event, ctx| rebuild(event, ctx) }
77
+ chi.on(:after_tool_call) { |event, ctx| on_tool(event, ctx) }
78
+ end
79
+
80
+ # An ok touch of a file under a directory with a nested AGENTS.md steers
81
+ # each candidate (the file's directory up to, not including, the checkout
82
+ # top; outermost first) once per session.
83
+ def on_tool(event, ctx)
84
+ return if Samagotchi::Config.get("skip_agents_md") == true
85
+ return unless @tools.include?(event[:tool].to_s)
86
+ return unless event[:status].to_s == "ok"
87
+
88
+ call = event[:call] || {}
89
+ # The path a hook sees (docs/hooks.md): read on content:, write/edit on
90
+ # path:, expanded against the session's cwd.
91
+ raw = (event[:tool].to_s == "read" ? call[:content] : call[:path]).to_s.strip
92
+ return if raw.empty?
93
+
94
+ path = resolve(raw, base: ctx.cwd)
95
+ top = resolve(ctx.repo_root)
96
+ return if top.nil?
97
+
98
+ # Scope: the file's own git root must be the session's checkout (a
99
+ # nested repository, a submodule or another repository is ignored).
100
+ file_root = resolve(@git.root(File.dirname(path)))
101
+ return unless file_root && file_root == top
102
+
103
+ prompt_files = Set.new(ctx.prompt_files.map { |file| resolve(file) })
104
+ count = 0
105
+ candidate_dirs(path, top).reverse_each do |dir|
106
+ agents = File.join(dir, FILE_NAME)
107
+ next unless File.file?(agents)
108
+ next if prompt_files.include?(resolve(agents)) # in the system prompt already
109
+ next if loaded(ctx).key?(relative_dir(dir, top))
110
+
111
+ text = steer_text(dir, top, agents)
112
+ next unless text
113
+
114
+ # Queued: the dir is loaded; a steer dropped before it was injected
115
+ # (the model answered first) is not queued, so it fires again later.
116
+ count += 1 if ctx.steer(text) && (loaded(ctx)[relative_dir(dir, top)] = true)
117
+ end
118
+ ctx.log.info(:agents_md_steered, file: File.basename(path), n: count) if count.positive?
119
+ end
120
+
121
+ private
122
+
123
+ # The dirs between +path+'s directory and +top+, file's directory first
124
+ # (the caller reverses for outermost-first).
125
+ def candidate_dirs(path, top)
126
+ dirs = []
127
+ dir = File.dirname(path)
128
+ prefix = top.end_with?("/") ? top : "#{top}/"
129
+ while dir != top && dir.start_with?(prefix)
130
+ dirs << dir
131
+ parent = File.dirname(dir)
132
+ break if parent == dir
133
+
134
+ dir = parent
135
+ end
136
+ dirs
137
+ end
138
+
139
+ # +dir+ relative to +top+ ("." for +top+ itself), or nil when it isn't under it.
140
+ def relative_dir(dir, top)
141
+ prefix = top.end_with?("/") ? top : "#{top}/"
142
+ return "." if dir == top
143
+ return nil unless dir.start_with?(prefix)
144
+
145
+ dir.delete_prefix(prefix)
146
+ end
147
+
148
+ # The steer: the marker first line (what #rebuild matches), the content,
149
+ # cut at max_chars with a pointer at the end. Nil when there is nothing to say.
150
+ def steer_text(dir, top, agents)
151
+ content = File.read(agents, encoding: "UTF-8")
152
+ return nil if content.strip.empty?
153
+
154
+ rel_dir = relative_dir(dir, top)
155
+ header = "AGENTS.md for #{rel_dir}/ (applies to work under #{rel_dir}/):"
156
+ return "#{header}\n#{content}" if content.length <= @max_chars
157
+
158
+ body = content[0...@max_chars]
159
+ "#{header}\n#{body}\n[cut at #{@max_chars} of #{content.length} chars: read #{agents} for the rest]"
160
+ rescue SystemCallError, EncodingError
161
+ nil
162
+ end
163
+
164
+ # Replaces the loaded set with the dirs whose steer the conversation
165
+ # already carries (a user message, kind steer, source agents-md, the
166
+ # marker on its first line).
167
+ def rebuild(event, ctx)
168
+ id = event[:session_id] || ctx.session_id
169
+ found = {}
170
+ Array(event[:messages]).each do |message|
171
+ next unless message.is_a?(Hash)
172
+ next unless (message[:role] || message["role"]).to_s == "user"
173
+ next unless (message[:kind] || message["kind"]).to_s == "steer"
174
+ next unless (message[:source] || message["source"]).to_s == "agents-md"
175
+
176
+ if (match = MARKER.match((message[:content] || message["content"]).to_s))
177
+ found[match[1]] = true
178
+ end
179
+ end
180
+ @lock.synchronize { @loaded[id] = found }
181
+ end
182
+
183
+ # +path+ (relative ones against +base+) by File.realpath, falling back to
184
+ # File.expand_path when it doesn't exist; nil for an empty one.
185
+ def resolve(path, base: nil)
186
+ path = path.to_s
187
+ return nil if path.empty?
188
+
189
+ path = File.expand_path(path, base) if base && !File.absolute_path?(path)
190
+ File.realpath(path)
191
+ rescue SystemCallError
192
+ File.expand_path(path, base || Dir.pwd)
193
+ end
194
+
195
+ def loaded(ctx)
196
+ @lock.synchronize { @loaded[ctx.session_id] }
197
+ end
198
+ end
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: core
3
- version: 0.2.0
3
+ version: 0.3.0
4
4
  scope: system
5
5
  description: "Recommended safety set"
6
6
  trust_level: reviewed
@@ -10,3 +10,4 @@ includes:
10
10
  - check-in
11
11
  - guardrails
12
12
  - slow-commands
13
+ - agents-md
@@ -121,7 +121,7 @@ A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cov
121
121
  1. **Read** the current file via `read` tool (or `ConfigFile.global_path`). If `File.file?` false, start from `{}`.
122
122
  2. `YAML.safe_load` (permitted_classes: [], aliases: false). If data nil or not Hash, treat as `{}` or raise with path.
123
123
  3. Mutate the intended **nested** key in the raw hash. Preserve all other keys byte-for-byte where possible. Example for default model: `raw_data["default"] ||= {}; raw_data["default"]["model"] = "new-model"`.
124
- 4. **Validate** (see below) before writing. Also run `Samagotchi::Config.validate_yaml_sections` — it returns one `config: unknown key '…' (did you mean '…'?)` per key chi doesn't read (every config-exposed `Config::ENTRIES` key is known as written, including the section-less `max_tool_output_chars` and `skip_agents_md`; names under `hosts:`/`models:`/`model_aliases:`/`hooks:`/`bundles:`/`memories:` are free-form, host and model entries are checked against `Config::MAP_ENTRY_KEYS`). An empty list means no warning at start.
124
+ 4. **Validate** (see below) before writing. Also run `Samagotchi::Config.validate_yaml_sections` — it returns one `config: unknown key '…' (did you mean '…'?)` per key chi doesn't read (every config-exposed `Config::ENTRIES` key is known as written, including the section-less `max_tool_output_chars` and `skip_agents_md`; names under `hosts:`/`models:`/`model_aliases:`/`hooks:`/`bundles:`/`memories:`/`append_system_prompt:` are free-form, host and model entries are checked against `Config::MAP_ENTRY_KEYS`). An empty list means no warning at start.
125
125
  5. **Write**: change only the lines you mean to, with the edit tool, so the user's comments and layout stay; `YAML.dump` of the whole hash drops every comment, so rewrite the file only when it has none worth keeping. From Ruby, write atomically with `Samagotchi::AtomicFile.write(path, text)` (a unique temp file renamed over it), never through a fixed `"#{path}.tmp"` that another chi may be writing at the same time.
126
126
  6. Nothing else to update: config.yml values are never copied into `ENV`, and every `Config.get` reads the file again when it changed, so the next read (and every worker started after the write) sees the new value with origin `:file`. What a running worker set up at its start (`hosts:`, bundle settings) waits for its restart; guardrail rules and `model_aliases:` are read again live. CLI overrides (`--model`, `--recap-model`, …) win over the file until the process exits; a worker gets its spawner's CLI settings through its env (`Config.cli_env`).
127
127
 
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.59.0
3
+ version: 0.60.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:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
8
  self_map.md: sha256:dfc19266259a9af1e0d769b7e0f67996b740b0c9921fdf3dadad973f6e5c144a
9
- config_modification_protocol.md: sha256:44a35f59baf44202972c9250989daa50eb978ab2a1967159e622c74ddd9c736c
9
+ config_modification_protocol.md: sha256:16403da5dd97a335e19d9d15bb1ba4390237bf9f4869e35d63c172a8828b5ae0
10
10
  memory_guide.md: sha256:d16919387c42d229498f470fc4078245a15faee872480ffbeffe2e27897624e4
11
11
  delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
@@ -223,7 +223,7 @@ module Samagotchi
223
223
  Entry.new(key: "steer.cut_after", yaml_path: %w[steer cut_after], type: :integer, default: 20, expose: %i[env config]),
224
224
  # A turn's step limit: model ↔ tool rounds before it stops and offers to continue (IterationLimit); integer ≥ 1.
225
225
  # --no-interrupt turns get max(1000, this). No CLI flag: --no-interrupt is the flag.
226
- Entry.new(key: "turn.max_iterations", yaml_path: %w[turn max_iterations], type: :integer, default: 100, expose: %i[env config]),
226
+ Entry.new(key: "turn.max_iterations", yaml_path: %w[turn max_iterations], type: :integer, default: 300, expose: %i[env config]),
227
227
  # Whether a parent agent (chi answer) may answer Continue to a session's step-limit question; false: Stop only.
228
228
  # config.yml only, like guardrails.parent_approvals: a parent must not decide it from its own environment.
229
229
  Entry.new(key: "turn.parent_continue", yaml_path: %w[turn parent_continue], type: :bool, default: true, expose: %i[config]),
@@ -272,10 +272,10 @@ module Samagotchi
272
272
  Entry.new(key: "skip_agents_md", yaml_path: %w[skip_agents_md], type: :bool, default: false, expose: %i[env config])
273
273
  ].freeze
274
274
 
275
- # Top-level maps read by their own code, whose entry names and contents
276
- # are the user's: ConfigFile.model_aliases / preloaded_memories,
277
- # Hooks::Loader, Engine#read_bundle_settings.
278
- FREE_FORM_MAPS = %w[model_aliases hooks bundles memories].freeze
275
+ # Top-level maps and lists read by their own code, whose entry names and
276
+ # contents are the user's: ConfigFile.model_aliases / preloaded_memories /
277
+ # appended_prompt_files, Hooks::Loader, Engine#read_bundle_settings.
278
+ FREE_FORM_MAPS = %w[model_aliases hooks bundles memories append_system_prompt].freeze
279
279
  # Maps of named entries and the keys an entry may hold
280
280
  # (ConfigFile.hosts_config, ConfigFile.model_settings).
281
281
  MAP_ENTRY_KEYS = {
@@ -987,6 +987,34 @@ module Samagotchi
987
987
  []
988
988
  end
989
989
 
990
+ # Resolve the `append_system_prompt:` list: files whose text goes into
991
+ # the system prompt after AGENTS.md (SystemPrompt). A YAML list of
992
+ # strings or a single string; ~ is expanded, a relative entry is kept as
993
+ # written (resolved against the cwd the prompt is built in). [] when
994
+ # the key is absent or malformed.
995
+ # @return [Array<String>]
996
+ def appended_prompt_files(env: ENV, path: global_path(env: env))
997
+ data = read_yaml(env: env, path: path)
998
+ raw = data["append_system_prompt"] if data.is_a?(Hash)
999
+ items = case raw
1000
+ when Array then raw
1001
+ when String then [raw]
1002
+ else []
1003
+ end
1004
+ items.select { |v| v.is_a?(String) }.map(&:strip).reject(&:empty?).filter_map { |v| expand_appended_entry(v) }
1005
+ rescue StandardError
1006
+ []
1007
+ end
1008
+
1009
+ # An append_system_prompt: entry with ~ expanded; nil (skipped, logged)
1010
+ # for a ~user that doesn't exist, so the other entries still load.
1011
+ def expand_appended_entry(entry)
1012
+ entry.start_with?("~") ? File.expand_path(entry) : entry
1013
+ rescue ArgumentError => e
1014
+ Samagotchi::Log.warn(:config, "appended_prompt_file_skipped", entry: entry, error: e.message) if defined?(Samagotchi::Log)
1015
+ nil
1016
+ end
1017
+
990
1018
  # Parse a model string that may be qualified as "host_alias:model"
991
1019
  # Returns [host_alias_or_nil, bare_model] (ModelRef.split)
992
1020
  def parse_host_qualified_model(raw, hosts: nil)
@@ -111,13 +111,15 @@ module Samagotchi
111
111
  end
112
112
 
113
113
  # The continue offer was answered yes: resume the conversation without a
114
- # user message, with the iteration limit the offer's turn had. A failure
114
+ # user message, with the iteration limit the offer's turn had, from the
115
+ # run that turn started (TurnFlow#resume_from: its reasoning stays). A failure
115
116
  # keeps the offer, as the REPL does ("continue prompt preserved"), and
116
117
  # asks again, one before the turn began too (logged, no :turn_failed).
117
118
  # The answer's text is carried into the turn as it begins; a turn that
118
119
  # never began leaves it to no other.
119
120
  def run_continue_turn(command)
120
121
  offer = @turn_flow.offer
122
+ resume_from = @turn_flow.resume_from
121
123
  close("answered")
122
124
  steer = @continue_steer
123
125
  @continue_steer = nil
@@ -129,7 +131,8 @@ module Samagotchi
129
131
  Log.warn(:worker, "turn_not_begun", continue: true, error: e.class.name, msg: e.message)
130
132
  return announce_offer(offer)
131
133
  end
132
- @run_turn.call(nil, continue: true, origin: { client_id: command.client_id }.compact,
134
+ @run_turn.call(nil, continue: true, origin: { client_id: command.client_id }.compact, resumed: true,
135
+ resume_from: resume_from,
133
136
  max_iterations: @max_iterations.call(offer[:no_interrupt])) do |result, error|
134
137
  if error
135
138
  announce_offer(offer)
@@ -105,11 +105,13 @@ module Samagotchi
105
105
  # @param memories [Array<String>] explicit --memory preload list (merged with the config.yml `memories:` baseline)
106
106
  # @param muted_memories [Array<String>] --mute list: memories hidden from this session (not in the
107
107
  # prompt's index, dropped from the preloads, refused by memory_read); a mute wins over a preload
108
+ # @param appended_prompt_files [Array<String>] --append-system-prompt's files (absolute paths; a session's
109
+ # appended_prompt_files): their text goes into the system prompt after config.yml's append_system_prompt:
108
110
  # @param plugins [Boolean] false: load no bundle plugins (a throwaway Engine for a prompt)
109
111
  # @param scratch [Boolean] a `chi scratch` session: memory writes are refused, and there is no
110
112
  # delegate (a child would outlive it) nor plugin fork
111
113
  def initialize(client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, model_name: nil, memories: [], muted_memories: [], kernel: nil, recap: nil, reminders: nil,
112
- plugins: true, scratch: false, model_typed: nil)
114
+ plugins: true, scratch: false, model_typed: nil, appended_prompt_files: [])
113
115
  # The turn as other threads see it (flag, cancel controller, sink,
114
116
  # steers) and the idle layer's activity clock. Built first: a plugin
115
117
  # may steer or ask whether a turn runs while it loads.
@@ -205,7 +207,7 @@ module Samagotchi
205
207
  @prompt_builder = SystemPrompt.new(profile: -> { self.profile }, tools: -> { @tools }, session: -> { @session },
206
208
  thinking: -> { turn_thinking }, model: -> { model_identity },
207
209
  model_notes: -> { model_notes }, memories: memories,
208
- muted_memory_names: @muted_memory_names)
210
+ muted_memory_names: @muted_memory_names, appended_files: appended_prompt_files)
209
211
  @session = nil
210
212
  @session_observer = SessionObserver.new
211
213
  @metrics = SessionMetrics.new
@@ -1013,7 +1015,8 @@ module Samagotchi
1013
1015
  snapshot = snapshot.merge(served_model: served, served_model_for: served_for,
1014
1016
  served_expected_by: served_expected_by(served, target: target), llm_context: llm_context_summary,
1015
1017
  thinking: thinking_explained&.summary,
1016
- reasoning_history: reasoning_history_explained&.summary, prompt_notes: prompt_notes.map(&:to_h), price_host: target.entry.name)
1018
+ reasoning_history: reasoning_history_explained&.summary, prompt_notes: prompt_notes.map(&:to_h),
1019
+ prompt_files: @prompt_builder.prompt_files, price_host: target.entry.name)
1017
1020
  unless snapshot.dig(:context, :window_tokens)
1018
1021
  window = current_context_window(target)
1019
1022
  if window
@@ -1518,7 +1521,7 @@ module Samagotchi
1518
1521
  private :hook_source
1519
1522
 
1520
1523
  # What a notice may stand in for (hook_notify's fallback_for:).
1521
- NOTICE_FALLBACKS = %i[display].freeze
1524
+ NOTICE_FALLBACKS = %i[display editor].freeze
1522
1525
 
1523
1526
  # One line to the user (:hook_notice). During a turn it is a turn
1524
1527
  # event: the turn's sink (the REPL) and the observers (bridge, log).
@@ -1527,9 +1530,10 @@ module Samagotchi
1527
1530
  #
1528
1531
  # +fallback_for+ names what the line stands in for (NOTICE_FALLBACKS):
1529
1532
  # :display, the answer's display (AnswerDisplay), for a UI that doesn't
1530
- # render its links. A UI that does leaves the line out; the others, the
1531
- # log among them, show it. An unknown value is dropped, so the line
1532
- # shows everywhere.
1533
+ # render its links; :editor, a ref the viewer could open in its editor
1534
+ # (Web::Capabilities#editor), for a UI or a viewer that can't. A UI that
1535
+ # does leaves the line out; the others, the log among them, show it. An
1536
+ # unknown value is dropped, so the line shows everywhere.
1533
1537
  def hook_notify(text, level, hook, fallback_for: nil)
1534
1538
  level = (level || :info).to_sym
1535
1539
  level = :info unless %i[info warn].include?(level)
@@ -1801,8 +1805,14 @@ module Samagotchi
1801
1805
  # +id+: names the turn on :turn_started (its metrics record's id) and on
1802
1806
  # its saved prompt, so a UI pairs the two whatever left the conversation.
1803
1807
  # +limit+: the iteration limit #generate ran it with.
1808
+ # +resumed+: a step-limit Continue (run_turn's resumed:).
1809
+ # +resume_from+: where its reasoning starts, the run it resumes
1810
+ # (run_turn's resume_from:); nil for every other turn, and for a
1811
+ # Continue that starts cold (after a model change).
1804
1812
  Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
1805
- :settings, :id, :limit) do
1813
+ :settings, :id, :limit, :resumed, :resume_from) do
1814
+ def resumed? = resumed ? true : false
1815
+
1806
1816
  # The turn's boundary events carry the origin only when there is one,
1807
1817
  # so payloads stay unchanged for callers that don't pass it.
1808
1818
  def tag(event) = origin ? event.merge(origin: origin) : event
@@ -1832,6 +1842,14 @@ module Samagotchi
1832
1842
  # @param continue [Boolean] resume the conversation without appending a
1833
1843
  # user prompt (continue after the iteration limit, reminder turns);
1834
1844
  # +prompt+ is ignored and :turn_started carries `continue: true`
1845
+ # @param resumed [Boolean] a step-limit Continue (with continue:):
1846
+ # :turn_started carries `resumed: true`, so do the before_turn payload
1847
+ # and the turn's analytics record. false for every other continue (a
1848
+ # reminder, a wake).
1849
+ # @param resume_from [Integer, nil] a step-limit Continue's: where the
1850
+ # run it resumes started (ModelResult#run_start, kept in the offer by
1851
+ # TurnFlow), so it keeps that run's reasoning; nil starts cold (a
1852
+ # model change while the offer stood, every other turn).
1835
1853
  # @param origin [Hash, nil] who queued the turn ({client_id:, enqueued_id:});
1836
1854
  # when given, the turn's boundary events (:turn_started, :turn_completed,
1837
1855
  # :turn_canceled, :turn_failed) carry it as `origin:`
@@ -1854,9 +1872,13 @@ module Samagotchi
1854
1872
  # re-raises; a provider error adds error_kind:, retryable:, host: and a
1855
1873
  # one-line summary:.
1856
1874
  def run_turn(session, prompt, on_event: nil, max_iterations: nil, cancel_controller: nil, max_tool_output_chars: nil, pending_input: nil, continue: false, origin: nil,
1857
- images: [], id: nil)
1875
+ images: [], id: nil, resumed: false, resume_from: nil)
1858
1876
  turn = begin_turn(session, prompt, on_event: on_event, cancel_controller: cancel_controller, origin: origin,
1859
1877
  continue: continue, id: id)
1878
+ if continue
1879
+ turn.resumed = resumed
1880
+ turn.resume_from = resume_from
1881
+ end
1860
1882
  begin
1861
1883
  # A Stop cuts the /props probes this thread makes for the turn
1862
1884
  # (window, served model, vision) instead of waiting their timeout.
@@ -1939,6 +1961,7 @@ module Samagotchi
1939
1961
  bind_metrics(session)
1940
1962
  turn_started = { type: :turn_started, session_id: session.id, prompt: turn.prompt, turn_id: turn.id }
1941
1963
  turn_started[:continue] = true if turn.continue
1964
+ turn_started[:resumed] = true if turn.resumed?
1942
1965
  turn_started[:images] = image_refs unless image_refs.empty?
1943
1966
  turn_started[:cut_after] = steer_cut_after
1944
1967
  emit_event(turn.on_event, turn.tag(turn_started))
@@ -1980,9 +2003,9 @@ module Samagotchi
1980
2003
  end
1981
2004
 
1982
2005
  # Fire :before_turn hook, with a read-only copy of the history so far
1983
- # and the prompt (nil on a continue).
2006
+ # and the prompt (nil on a continue); resumed: a step-limit Continue.
1984
2007
  @hooks.fire(:before_turn, { type: :before_turn, session_id: session.id, prompt: turn.prompt,
1985
- messages: hook_messages(session.messages) })
2008
+ resumed: turn.resumed?, messages: hook_messages(session.messages) })
1986
2009
 
1987
2010
  turn_messages(turn, image_refs)
1988
2011
  true
@@ -2118,18 +2141,39 @@ module Samagotchi
2118
2141
  log_llm_context(explained)
2119
2142
 
2120
2143
  turn.limit = @no_interrupt ? IterationLimit.for(no_interrupt: true) : max_iterations || IterationLimit.for
2121
- backend.complete(
2144
+ # Where this run starts (ChatLoop::Run's start): an offer made at its
2145
+ # end keeps it, so a Continue resumes this run.
2146
+ run_start = turn.messages.length
2147
+ reasoning_since = resumed_run_start(turn, run_start)
2148
+ result = backend.complete(
2122
2149
  messages: turn.messages,
2123
2150
  max_iterations: turn.limit,
2124
2151
  on_stream_event: build_stream_event_handler(turn.on_event, cancel_controller: turn.controller),
2125
2152
  cancel_controller: turn.controller,
2126
2153
  model_name: bare_for_backend,
2127
2154
  max_tool_output_chars: max_tool_output_chars,
2128
- pending_input: turn_drain(pending_input)
2155
+ pending_input: turn_drain(pending_input),
2156
+ reasoning_since: reasoning_since
2129
2157
  )
2158
+ result.run_start = run_start
2159
+ result
2130
2160
  end
2131
2161
  private :generate
2132
2162
 
2163
+ # A step-limit Continue's resume_from, when it is inside the messages the
2164
+ # run gets (a wrong one only drops reasoning: it is ignored, logged).
2165
+ # Entries before it never move between the turns (#turn_messages adds
2166
+ # at the tail, and the system head is replaced in place).
2167
+ def resumed_run_start(turn, run_start)
2168
+ start = turn.resume_from
2169
+ return nil if start.nil?
2170
+ return start if start.is_a?(Integer) && start.between?(0, run_start)
2171
+
2172
+ Log.warn(:turn, "resume_start_invalid", resume_from: start, length: run_start)
2173
+ nil
2174
+ end
2175
+ private :resumed_run_start
2176
+
2133
2177
  # The turn's LLM context strategy and where it came from, in the turn
2134
2178
  # log: when it runs a layer, or the session set it (none too).
2135
2179
  def log_llm_context(explained)
@@ -2203,7 +2247,8 @@ module Samagotchi
2203
2247
  # replace). Runs under the event lock: it also adds the turn's note to
2204
2248
  # result.conversation, which the REPL keeps as-is.
2205
2249
  # A turn that ran out of iterations ends at its tool results, so a
2206
- # continue resumes from them rather than after a made-up reply.
2250
+ # continue resumes from them rather than after a made-up reply (and a
2251
+ # step-limit Continue from its run's start: ModelResult#run_start).
2207
2252
  def kept_messages(turn, result, seconds)
2208
2253
  conversation = result.conversation if result.conversation.is_a?(Array)
2209
2254
  # Nothing visible (no text) is a turn the model should know ended
@@ -2624,6 +2669,10 @@ module Samagotchi
2624
2669
  state_dir: -> { session_state_dir },
2625
2670
  scratch: -> { @scratch },
2626
2671
  setup: -> { SessionSetup.of(@session) },
2672
+ # The files whose text the built system prompt holds; [] until a
2673
+ # build and after a rebuild that drops them (SystemPrompt#prompt_files
2674
+ # returns [] itself: the || keeps a nil builder safe).
2675
+ prompt_files: -> { @prompt_builder&.prompt_files || [] },
2627
2676
  frontend: -> { interface }
2628
2677
  )
2629
2678
  end
@@ -10,8 +10,9 @@ module Samagotchi
10
10
  module IterationLimit
11
11
  KEY = "turn.max_iterations"
12
12
  # A turn's limit unless turn.max_iterations says otherwise, and the
13
- # loops' own default.
14
- DEFAULT = 100
13
+ # loops' own default. Loops are caught by the loop-guard bundle and a
14
+ # long turn shows check-in's card well before it; this catches the rest.
15
+ DEFAULT = 300
15
16
  # A --no-interrupt / --non-interactive turn's, at least: nobody is there
16
17
  # to answer a continue offer.
17
18
  NO_INTERRUPT = 1000