samagotchi 0.58.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +86 -1
- data/README.md +3 -2
- data/bin/chi +23 -2
- data/docs/architecture.md +6 -4
- data/docs/cli.md +14 -7
- data/docs/configuration.md +96 -31
- data/docs/hooks.md +12 -6
- data/docs/internals/background-tasks.md +7 -0
- data/docs/internals/engine.md +20 -12
- data/docs/internals/gemma4-contract.md +1 -0
- data/docs/internals/prompt-caching.md +4 -3
- data/docs/internals/tool-guardrails.md +2 -1
- data/docs/memory.md +1 -1
- data/docs/plugins.md +75 -6
- data/docs/sessions.md +2 -2
- data/docs/sub-agent.md +2 -2
- data/docs/testing.md +4 -2
- data/lib/samagotchi/bootstrap_command.rb +2 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -0
- data/lib/samagotchi/bridge.rb +6 -2
- data/lib/samagotchi/bundles/agents-md/manifest.yml +11 -0
- data/lib/samagotchi/bundles/agents-md/plugin.rb +198 -0
- data/lib/samagotchi/bundles/core/manifest.yml +2 -1
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +5 -4
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +169 -16
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/config.rb +44 -10
- data/lib/samagotchi/continue_offer.rb +5 -2
- data/lib/samagotchi/engine.rb +89 -15
- data/lib/samagotchi/host_config.rb +3 -1
- data/lib/samagotchi/host_registry.rb +2 -1
- data/lib/samagotchi/iteration_limit.rb +3 -2
- data/lib/samagotchi/kernel_loop.rb +35 -9
- data/lib/samagotchi/llm/backend.rb +6 -1
- data/lib/samagotchi/llm/chat_loop.rb +11 -6
- data/lib/samagotchi/llm/model_result.rb +11 -2
- data/lib/samagotchi/llm/native_backend.rb +3 -2
- data/lib/samagotchi/log_subscriber.rb +2 -1
- data/lib/samagotchi/model_settings.rb +4 -2
- data/lib/samagotchi/plugin/context.rb +16 -3
- data/lib/samagotchi/process_group.rb +76 -3
- data/lib/samagotchi/prompt_files.rb +127 -0
- data/lib/samagotchi/ref.rb +124 -0
- data/lib/samagotchi/send_command.rb +1 -1
- data/lib/samagotchi/session.rb +15 -3
- data/lib/samagotchi/session_commands.rb +15 -3
- data/lib/samagotchi/session_metrics.rb +8 -2
- data/lib/samagotchi/session_setup.rb +11 -5
- data/lib/samagotchi/steer_cut.rb +12 -8
- data/lib/samagotchi/steer_cut_after.rb +69 -0
- data/lib/samagotchi/system_prompt.rb +74 -18
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -9
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +23 -5
- data/lib/samagotchi/terminal_ui.rb +18 -6
- data/lib/samagotchi/tool_activity.rb +18 -1
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tools/execute.rb +318 -70
- data/lib/samagotchi/tools/task_get.rb +2 -0
- data/lib/samagotchi/tools/task_runtime.rb +96 -13
- data/lib/samagotchi/tools/task_wait.rb +23 -5
- data/lib/samagotchi/turn_flow.rb +22 -3
- data/lib/samagotchi/utf8_default.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/favicon.svg +1 -0
- data/lib/samagotchi/web/public/index.html +1 -0
- data/lib/samagotchi/web/public/read_view.js +3 -1
- data/lib/samagotchi/web/public/refs.js +107 -15
- data/lib/samagotchi/web/public/task_stop.js +25 -7
- data/lib/samagotchi/worker.rb +1 -0
- metadata +7 -1
data/docs/internals/engine.md
CHANGED
|
@@ -16,25 +16,28 @@ Engine#run_turn → backend.complete(...) → KernelLoop#run (native) or ChatLoo
|
|
|
16
16
|
|
|
17
17
|
```
|
|
18
18
|
Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:, max_tool_output_chars:,
|
|
19
|
-
pending_input:, continue:, origin:, images:, id:)
|
|
19
|
+
pending_input:, continue:, origin:, images:, id:, resumed:, resume_from:)
|
|
20
20
|
│
|
|
21
21
|
├── begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id:)
|
|
22
|
+
│ # (then, on a continue, turn.resumed = resumed (a step-limit Continue) and
|
|
23
|
+
│ # turn.resume_from = resume_from (its run's start; nil: it starts cold))
|
|
22
24
|
│ # @session = session, session.status = RUNNING, used memories absorbed,
|
|
23
25
|
│ # TurnState#begin!(controller:, sink:) (a new CancellationController when none is given),
|
|
24
26
|
│ # a recap in flight invalidated, GuardrailWiring#begin_turn(origin)
|
|
25
27
|
│ # Returns a Turn {session, prompt (nil on a continue), continue, on_event, controller,
|
|
26
|
-
│ # origin, started_at, id (the caller's id:, else a new uuid)}
|
|
28
|
+
│ # origin, started_at, id (the caller's id:, else a new uuid), resumed, resume_from}
|
|
27
29
|
│
|
|
28
30
|
├── Client.swap_probe_cancel(turn.controller) # a Stop cuts this turn's /props probes
|
|
29
31
|
│
|
|
30
32
|
├── prepare_turn(turn, images)
|
|
31
33
|
│ # Emits :turn_started {session_id, prompt, turn_id, continue: true (only on a continue),
|
|
32
|
-
│ #
|
|
34
|
+
│ # resumed: true (only on a step-limit Continue: resumed: given),
|
|
35
|
+
│ # images (only when there are any), cut_after (the model's cut wait, s)}
|
|
33
36
|
│ # refresh_profile! (may ask the server's /props once)
|
|
34
37
|
│ # turn.settings = LLM::TurnSettings (vision, sampling, thinking; model_name is set in generate)
|
|
35
38
|
│ # announces guardrail load failures, starts and awaits plugin init tasks, applies staged tools
|
|
36
39
|
│ # fires the :session_start hook on the Engine's first turn, then the :before_turn hook
|
|
37
|
-
│ # (a frozen copy of the history,
|
|
40
|
+
│ # (a frozen copy of the history, the prompt, and resumed)
|
|
38
41
|
│ # turn_messages: system head + history + due reminders (:reminder_injected) + the user message
|
|
39
42
|
│ # Returns false when a Stop came during the probes or the init wait, else true
|
|
40
43
|
│
|
|
@@ -128,10 +131,13 @@ parses tool calls out of the generated text.
|
|
|
128
131
|
|
|
129
132
|
```
|
|
130
133
|
KernelLoop#run(messages, max_iterations:, on_stream_event:, cancel_controller:,
|
|
131
|
-
model_name:, max_tool_output_chars:, pending_input:)
|
|
134
|
+
model_name:, max_tool_output_chars:, pending_input:, reasoning_since:)
|
|
132
135
|
│
|
|
133
136
|
│ turn = start_turn(...) # turn.conversation = prepare_conversation(messages): a copy,
|
|
134
|
-
│ # old model messages without their thought blocks
|
|
137
|
+
│ # old model messages without their thought blocks, but a resumed
|
|
138
|
+
│ # run's (a step-limit Continue's reasoning_since:, #thought_floor:
|
|
139
|
+
│ # all of them, a steer merged mid-run included; only a user entry
|
|
140
|
+
│ # added after the run's last reply starts them past itself)
|
|
135
141
|
│ each iteration:
|
|
136
142
|
│ 1. Queued input (user lines, plugin steers) goes in at the boundary (Steer.inject!)
|
|
137
143
|
│ 2. Format the conversation as prompt text (Prompt.format_with_images); a context
|
|
@@ -179,8 +185,10 @@ The differences from the native loop:
|
|
|
179
185
|
`Session#reasoning_history`, `/reasoning-history`): `all` on every model message, `turn` past
|
|
180
186
|
`ChatLoop#reasoning_boundary`: the later of the last query (a `role: "user"` entry: a prompt, a steer, merged input,
|
|
181
187
|
the chat template's `last_query_index`; or a `turn_start` entry) and the Run's start (`Run`'s `@start`, the
|
|
182
|
-
conversation length at `#complete`), so a continue turn (reminder, context wake,
|
|
183
|
-
nothing of the run before.
|
|
188
|
+
conversation length at `#complete`), so a continue turn (reminder, context wake, delegate report wake) replays
|
|
189
|
+
nothing of the run before. A step-limit Continue's Run starts where the run it resumes did: `#complete`'s
|
|
190
|
+
`reasoning_since:` (the Engine's `resume_from`, checked against the messages' length in `#resumed_run_start`) is
|
|
191
|
+
the Run's `@start`. `Run#prompt_chars` counts it (`#reasoning_chars`). The native loop differs on a steer:
|
|
184
192
|
`KernelLoop#inject_pending_input!` doesn't re-sanitize, so native keeps the turn's reasoning across a mid-turn
|
|
185
193
|
steer, where the chat loop starts again (as the template does).
|
|
186
194
|
- A 400 about reasoning (`BadRequest#reasoning_refused?`) for a request that sent reasoning back asks the step again
|
|
@@ -298,12 +306,12 @@ What the running generation streams, fed by the Engine's stream handler: whether
|
|
|
298
306
|
`cut`: `/cut TEXT`, `chi send --cut`) may cut it. `SteerCut#cut_for_steer` answers `:now`, `:waits` or `:off`; the
|
|
299
307
|
Bridge acks it as `cut`.
|
|
300
308
|
A generation is cuttable while it has streamed only thinking, is still thinking, and has thought for at least
|
|
301
|
-
`steer.cut_after` seconds (`cuttable?(min_age)`). It has `started!`, `chunk!`, `retrying!` and `finished!`.
|
|
309
|
+
the model's cut wait (`SteerCutAfter.resolve`: `steer_cut_after` per models:/hosts: entry, else `steer.cut_after`) seconds (`cuttable?(min_age)`). It has `started!`, `chunk!`, `retrying!` and `finished!`.
|
|
302
310
|
|
|
303
311
|
### WaitingSteer
|
|
304
312
|
|
|
305
313
|
A message that may cut the generation but came too early (the generation hadn't been thinking long enough). It
|
|
306
|
-
waits until the thinking passes
|
|
314
|
+
waits until the thinking passes the model's cut wait (the Engine checks on each thinking chunk), until a drain hands
|
|
307
315
|
the message to the model at a boundary, or until the turn ends (`clear!` in `release_turn`).
|
|
308
316
|
|
|
309
317
|
## Events
|
|
@@ -322,7 +330,7 @@ Turn events (the main ones; `Events` in `events.rb` keeps the shared sets):
|
|
|
322
330
|
|
|
323
331
|
| Event | When | Payload |
|
|
324
332
|
|---|---|---|
|
|
325
|
-
| `:turn_started` | `prepare_turn` | `{session_id, prompt, turn_id, continue?, images
|
|
333
|
+
| `:turn_started` | `prepare_turn` | `{session_id, prompt, turn_id, continue?, resumed?, images?, cut_after}` (`resumed`: a step-limit Continue) |
|
|
326
334
|
| `:reminder_injected` | due reminders went into the turn | `{reminders}` |
|
|
327
335
|
| `:context_status` | every request (unless `context.status` is off), the current estimate before it | `{iteration, status, usage, bucket, source}` (internals/context-telemetry.md) |
|
|
328
336
|
| `:generation_started` | a request begins | `{iteration, context_window_tokens, context_window_source, price}` (`price`: the turn's `ModelPrice#to_h`, nil without one; native adds `profile`, `profile_source`) |
|
|
@@ -495,7 +503,7 @@ thought after the cue, kept in the model message so the next prompt starts with
|
|
|
495
503
|
5. **A stable system prompt for the cache.** `SystemPrompt#build` caches the prompt per Engine, keyed by
|
|
496
504
|
`[chat, thinking, layers]` (chat/native, the thinking level, the turn's LLM context layers), and is reset
|
|
497
505
|
only on a model switch, a tools change or a profile change. The stable part (base prompt, identity,
|
|
498
|
-
memories,
|
|
506
|
+
memories, AGENTS.md, memory indexes) comes before the volatile tail (model, working directory, session).
|
|
499
507
|
prompt-caching.md covers this in detail.
|
|
500
508
|
|
|
501
509
|
## Key files
|
|
@@ -18,6 +18,7 @@ Thought handling follows the Gemma guidance:
|
|
|
18
18
|
- When thinking mode is active, the model may emit internal reasoning as `<|channel>thought ... <channel|>`.
|
|
19
19
|
- Standard multi-turn: prior model thoughts are stripped from conversation history before the next turn.
|
|
20
20
|
- Function/tool-calling exception: during a single turn that includes tool calls, thoughts are not stripped between those tool-call rounds.
|
|
21
|
+
- A step-limit Continue is that same turn going on: the run it resumes keeps its thoughts (`KernelLoop#run`'s `reasoning_since:`, from the Engine's `resume_from`), from its start, a steer merged inside that run included (nothing re-sanitizes within a run, so the prompt before the limit had them); only a user entry added after the run's last reply (a `!cmd`'s output) starts them past itself. Every other turn (a prompt, a reminder, a wake) strips them as above.
|
|
21
22
|
- Final model output returned to the caller is thought-stripped.
|
|
22
23
|
- While a generation streams, the thought channel goes to the stream's `thinking:` lane, not `text:` (`ThoughtStreamSplitter.for_profile`, fresh per generation), so the UIs show it as thinking and `:generation_progress` hooks count it in `thinking_chars`.
|
|
23
24
|
|
|
@@ -29,10 +29,11 @@ right-hand column happens.
|
|
|
29
29
|
│ [native thinking token] base prompt │
|
|
30
30
|
│ rg guidance │ /thinking level,
|
|
31
31
|
│ system identity (+ model overlay) │ /model, editing a
|
|
32
|
-
│ model notes (model_notes_* matching the model) │ memory /
|
|
32
|
+
│ model notes (model_notes_* matching the model) │ memory / AGENTS.md
|
|
33
33
|
│ explicit (preloaded) memories │
|
|
34
|
-
│
|
|
35
|
-
│
|
|
34
|
+
│ AGENTS.md (root → cwd) │ (picked up by a new
|
|
35
|
+
│ appended files (append_system_prompt:, --append-system-prompt) │ process or a rebuild)
|
|
36
|
+
│ project + system memory indexes │
|
|
36
37
|
│ ◄── remote Claude: cache_control breakpoint #1 │
|
|
37
38
|
├──────────────────────────────────────────────────────────────────────────┤
|
|
38
39
|
│ SYSTEM, volatile tail (per session, least → most changing) │
|
|
@@ -39,7 +39,8 @@ Configuration (config.yml `execute.*` keys and CLI flags too, as for `read`):
|
|
|
39
39
|
- `SAMAGOTCHI_EXECUTE_TRUNCATE_AT_BYTES` (default `65536`): output above this size is truncated.
|
|
40
40
|
- `SAMAGOTCHI_EXECUTE_PREVIEW_BYTES` (default `12288`): total preview budget split across head and tail.
|
|
41
41
|
- `SAMAGOTCHI_EXECUTE_TELEMETRY_THRESHOLD_PCT` (default `80`): include estimated output token impact only when threshold is crossed.
|
|
42
|
-
- `SAMAGOTCHI_EXECUTE_TIMEOUT_SEC` (`execute.timeout_sec`, default `120`): a command running
|
|
42
|
+
- `SAMAGOTCHI_EXECUTE_TIMEOUT_SEC` (`execute.timeout_sec`, default `120`): what a command still running then gets is `execute.on_timeout`.
|
|
43
|
+
- `SAMAGOTCHI_EXECUTE_ON_TIMEOUT` (`execute.on_timeout`, default `task`): `task` hands the command over as a background task, not killed; the result starts `Still running at the 120s limit: moved to a background task, not killed.` (`Execute::HANDED_OVER`), then its `task_id:` and the tails of stdout and stderr so far, and `task_wait` waits for it. `kill` stops it and answers `Error: command timed out after …s` (`Execute::TIMEOUT_PREFIX`) with the hint to use `task_create`. The `!cmd` bang always kills. See [Background tasks](background-tasks.md).
|
|
43
44
|
|
|
44
45
|
Implementation note:
|
|
45
46
|
|
data/docs/memory.md
CHANGED
|
@@ -269,7 +269,7 @@ profiles, meta bundles that install a set of them:
|
|
|
269
269
|
|
|
270
270
|
| profile | bundles |
|
|
271
271
|
|---|---|
|
|
272
|
-
| `core` | loop-guard, check-in, guardrails, slow-commands: the recommended safety set |
|
|
272
|
+
| `core` | loop-guard, check-in, guardrails, slow-commands, agents-md: the recommended safety set |
|
|
273
273
|
| `dev` | known-names, mcp, btw, skills, source-links, github-pr, coordinator |
|
|
274
274
|
|
|
275
275
|
`chi bootstrap` installs `core` and, on a terminal, offers `dev`. An existing
|
data/docs/plugins.md
CHANGED
|
@@ -515,7 +515,8 @@ full label, `plugin.rb (bundle my-bundle)`.
|
|
|
515
515
|
| `ctx.log` | `ctx.log.info(:event, key: value)`: debug-log records tagged `plugins`, with `bundle=<bundle>` |
|
|
516
516
|
| `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 |
|
|
517
517
|
| `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 |
|
|
518
|
-
| `ctx.
|
|
518
|
+
| `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 |
|
|
519
|
+
| `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) |
|
|
519
520
|
| `ctx.card(title:, body: "", actions: [], level: :info, id: nil)` | a card in every UI, returning its id: see [Cards](#cards) |
|
|
520
521
|
| `ctx.ask_user(question:, options:, header: nil, allow_freeform: false)` | a single-select question through the question flow: `{selected:, freeform:, selected_indices:}`, or nil (no one to ask, cancelled, bad options) |
|
|
521
522
|
| `ctx.cancelled?` | whether the running turn was cancelled (a long tool should stop) |
|
|
@@ -1178,12 +1179,14 @@ bootstrap` installs it with the `core` profile). It is written only against
|
|
|
1178
1179
|
this API (`lib/samagotchi/bundles/slow-commands/plugin.rb`), with `chi.on`
|
|
1179
1180
|
hooks and a `/slow-commands` command, and has no memory file.
|
|
1180
1181
|
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1182
|
+
At its limit (120 s, `execute.timeout_sec`) `execute` hands a command still
|
|
1183
|
+
running over as a background task (`execute.on_timeout: task`, the default),
|
|
1184
|
+
or kills it (`kill`). A full test suite or a build takes longer, so each run
|
|
1185
|
+
first blocks the turn for those two minutes (and, in kill mode, loses them,
|
|
1186
|
+
then starts again with `task_create` and `task_wait`); the next session does
|
|
1187
|
+
the same. slow-commands remembers such a command for the project:
|
|
1185
1188
|
|
|
1186
|
-
- An `execute` that **timed out
|
|
1189
|
+
- An `execute` that **timed out** or was **handed over** at the limit, or ran **`warn_ratio` of the limit** or
|
|
1187
1190
|
longer (default 0.8, so 96 s of 120), is recorded as slow. A later run of
|
|
1188
1191
|
it under half the limit **with exit 0** drops the entry; a fast failure (a
|
|
1189
1192
|
load error) doesn't, since it says nothing about how long the suite takes.
|
|
@@ -1203,6 +1206,10 @@ does the same. slow-commands remembers such a command for the project:
|
|
|
1203
1206
|
- **Once per command per turn**: the same `execute` again in that turn runs.
|
|
1204
1207
|
The model insisting is a signal (maybe the suite got faster), and that run
|
|
1205
1208
|
records the new truth. A new turn denies again.
|
|
1209
|
+
- In kill mode (`execute.on_timeout: kill`), a **`timeout N` past the limit**
|
|
1210
|
+
is denied up front, with no history: execute stops the command first, so
|
|
1211
|
+
`timeout` never fires. In task mode it runs on as a task until `timeout`
|
|
1212
|
+
fires. `timeout`'s exit codes (124, 137) mean nothing special here.
|
|
1206
1213
|
|
|
1207
1214
|
A command is the same when its project and its text match. The project is
|
|
1208
1215
|
the session's git checkout (else its working directory). The text is the
|
|
@@ -1216,6 +1223,20 @@ one with a `$(…)`, backticks or a heredoc `cat` or `tee` reads (the key
|
|
|
1216
1223
|
can't tell two of those apart). A heredoc script (`python3 - <<'EOF'`)
|
|
1217
1224
|
keeps its body in the key, so each script is its own command.
|
|
1218
1225
|
|
|
1226
|
+
Wrappers a model adds by habit don't make a new command: at the start of
|
|
1227
|
+
each simple command (so after a leading `cd X &&` too), `time [-p]` and
|
|
1228
|
+
`timeout`/`gtimeout` with its options and duration are left out, so
|
|
1229
|
+
`timeout 300 bundle exec rspec` and `bundle exec rspec` are one entry. `X=…`
|
|
1230
|
+
assignments stay, sorted (`SPEC=… rake spec` is not `rake spec`), except
|
|
1231
|
+
`NO_COLOR`, `FORCE_COLOR` and `TERM` (`CI` stays: jest or vitest without it
|
|
1232
|
+
can sit in watch mode); a plain `env X=… cmd` is `X=…
|
|
1233
|
+
cmd`, and an `env` with an option (`-C`, `-i`, `-u`) stays as typed. The
|
|
1234
|
+
list shows each entry as it was last typed.
|
|
1235
|
+
|
|
1236
|
+
Since 0.2.0 (chi 0.59.0) a hand-over counts as timed out, and the advice's
|
|
1237
|
+
duration also reads a handed-over task, whose `exit_code` file is in the
|
|
1238
|
+
state dir (the record's `exit_code_path`).
|
|
1239
|
+
|
|
1219
1240
|
Entries live in `$XDG_STATE_HOME/samagotchi/plugins/slow-commands/commands.json`,
|
|
1220
1241
|
shared by every session (another session's record counts at once) and
|
|
1221
1242
|
forgotten after `ttl_days`.
|
|
@@ -1234,6 +1255,54 @@ bundles:
|
|
|
1234
1255
|
| `/slow-commands` | this project's slow commands, numbered |
|
|
1235
1256
|
| `/slow-commands forget 2` / `forget all` | drop one (a wrong entry) or all of them |
|
|
1236
1257
|
|
|
1258
|
+
## The agents-md bundle
|
|
1259
|
+
|
|
1260
|
+
`chi bundle install agents-md` (or the `core` profile) installs the bundle shipped
|
|
1261
|
+
with chi (`lib/samagotchi/bundles/agents-md/plugin.rb`): two `chi.on` hooks
|
|
1262
|
+
(`:before_turn` rebuilds the steered set, `:after_tool_call` steers), and no memory file.
|
|
1263
|
+
|
|
1264
|
+
chi's system prompt holds only the `AGENTS.md` chain from the checkout top down
|
|
1265
|
+
to the session's cwd, so an `AGENTS.md` in a directory the session doesn't run
|
|
1266
|
+
in (say `pkg/inner/` while the session runs in `pkg/`) is never seen. The
|
|
1267
|
+
bundle closes that gap in history, not in the system prompt: the first `read`,
|
|
1268
|
+
`write` or `edit` (its `tools` setting) of a file under a directory with an
|
|
1269
|
+
`AGENTS.md` steers that file into the running turn, at the loop's next
|
|
1270
|
+
boundary, as its own user message — with a first line that says which
|
|
1271
|
+
directory it belongs to:
|
|
1272
|
+
|
|
1273
|
+
```
|
|
1274
|
+
AGENTS.md for pkg/inner/ (applies to work under pkg/inner/):
|
|
1275
|
+
<content>
|
|
1276
|
+
```
|
|
1277
|
+
|
|
1278
|
+
- It steers the **outermost candidates first** (each directory from the
|
|
1279
|
+
touched file's directory up to, but not including, the checkout top), so a
|
|
1280
|
+
deep file can bring in several `AGENTS.md`s at once, outermost to nearest.
|
|
1281
|
+
- It **never re-injects**: each directory steers once per session, and a
|
|
1282
|
+
`before_turn` pass over the conversation rebuilds that set from the steers
|
|
1283
|
+
already there. A steer the model answered past (never injected) is absent,
|
|
1284
|
+
so it fires again on the next touch; a resume, a woken worker or a fork
|
|
1285
|
+
pick the set up from their messages. It never stops generation or thinking.
|
|
1286
|
+
- **Scope:** the touched file's own git root must be the session's checkout:
|
|
1287
|
+
a file in another repository, a nested repository or a submodule is
|
|
1288
|
+
ignored. Paths compare by `File.realpath`, so a symlinked cwd (a macOS
|
|
1289
|
+
`/tmp`) still matches.
|
|
1290
|
+
- Directories whose `AGENTS.md` is already in the system prompt (`ctx.prompt_files`:
|
|
1291
|
+
the chain and appended files) are skipped, as is a file at the checkout top
|
|
1292
|
+
(its `AGENTS.md` is the chain's first).
|
|
1293
|
+
|
|
1294
|
+
```yaml
|
|
1295
|
+
# config.yml
|
|
1296
|
+
bundles:
|
|
1297
|
+
agents-md:
|
|
1298
|
+
tools: [read, write, edit] # the calls that count as a touch
|
|
1299
|
+
max_chars: 16000 # the content is cut here, with a "[cut at N
|
|
1300
|
+
# of M chars: read <absolute path> for the rest]" last line
|
|
1301
|
+
```
|
|
1302
|
+
|
|
1303
|
+
`skip_agents_md: true` disables the bundle, as it does the chain's loading
|
|
1304
|
+
(see [Configuration](configuration.md#project-specific-description)).
|
|
1305
|
+
|
|
1237
1306
|
## The skills bundle
|
|
1238
1307
|
|
|
1239
1308
|
`chi bundle install skills` (or the `dev` profile) installs the bundle shipped with chi
|
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:**
|
|
@@ -158,7 +158,7 @@ pbpaste | chi send 3fa2 # the clipboard is the messa
|
|
|
158
158
|
```
|
|
159
159
|
|
|
160
160
|
- With both stdin and `-m`, stdin is context: each line becomes a `> ` quote (as web annotations quote a selection), then a blank line, then the message. With only one, it goes in as is. A terminal on stdin is ignored. Both empty is a usage error; over 16 KiB is refused, never cut.
|
|
161
|
-
- It goes through the same path as the web composer (`SessionManager.deliver_turn`): the worker's Bridge when it is up, so every attached UI shows it as your message (client id `cli:send`, shown like any user message); the input file when the worker is on its way out. During a running turn it is merged into that turn at the next step, like a message typed then, and cuts nothing. `--cut` asks for a cut, as `/cut TEXT` in a terminal does: a generation that has streamed only thinking for `steer.cut_after` seconds is cut (now, or once its thinking passes that), and the step starts again with the message. To an idle session `--cut` sends normally. `--cut --wait` into a running turn is refused like any `--wait` there (`busy: a turn is running`).
|
|
161
|
+
- It goes through the same path as the web composer (`SessionManager.deliver_turn`): the worker's Bridge when it is up, so every attached UI shows it as your message (client id `cli:send`, shown like any user message); the input file when the worker is on its way out. During a running turn it is merged into that turn at the next step, like a message typed then, and cuts nothing. `--cut` asks for a cut, as `/cut TEXT` in a terminal does: a generation that has streamed only thinking for `steer.cut_after` seconds (per host/model: `steer_cut_after`) is cut (now, or once its thinking passes that), and the step starts again with the message. To an idle session `--cut` sends normally. `--cut --wait` into a running turn is refused like any `--wait` there (`busy: a turn is running`).
|
|
162
162
|
- A session with no worker gets one started (like `--attach` or the web composer). A session open in a plain REPL (`--no-shared`) refuses it. Only sessions on this machine: Bridges listen on 127.0.0.1.
|
|
163
163
|
- Fire and forget: it returns once the message is queued and never prints the answer; that shows in whatever is attached. A guardrail "ask" waits for a UI to answer it, so with nothing attached the turn stalls there until one attaches (`chi --attach ID`).
|
|
164
164
|
- `--image PATH` (repeatable, up to 20) sends images with the message, as the web composer's chips do. Each file is read once before anything is sent (bmp, tiff and heic converted to png, large ones downscaled, as for `@path`); a missing file or one that isn't an image stops the send (exit 1; with `--wait --format json`, an `error` line) and nothing is sent; more than 20 is a usage error (exit 2). Each session gets its own copy in `<session>/images/`. Text is still required (`-m` or stdin; context alone counts), also with `--wait`. The session's model must see images: one known not to (the host's model list says text-only, `vision: false` in `models:` or `hosts:`, a native llama.cpp without a vision model) is refused before anything is sent, with the reason (`<id> refused: gemma can't take images (…); send text only or switch the model (/model)`); the other sessions of the same send still get it, and the exit is 1. With `--new` nothing is started. When chi can't tell (a native host whose `/props` doesn't answer, a list that doesn't say), the message is sent as before and a text-only model fails the turn in the session. A running turn doesn't take images mid-turn: the message runs as the next turn, and the line says `(runs after the current turn)`. With `--new` the session starts idle with the message as its preview, then the message goes in as its first turn once its worker is up (`<id> started with 1 image`); if the worker doesn't come up in 5 s the session is kept, with its id on the `failed:` line.
|
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
|
|
59
|
-
answers waits as a question of kind `continue` (`"limit":
|
|
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 `
|
|
111
|
-
2026-10-
|
|
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
|
|
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,8 +239,10 @@ 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]
|
|
245
|
+
@turn[:cut_after] = event[:cut_after] if event[:cut_after]
|
|
243
246
|
when :input_merged
|
|
244
247
|
@merged_origins = Array(event[:origins])
|
|
245
248
|
dequeue(@merged_origins)
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -29,7 +29,6 @@ require_relative "version"
|
|
|
29
29
|
require_relative "worker_sidecar"
|
|
30
30
|
require_relative "relay_verifier"
|
|
31
31
|
require_relative "relay_watcher"
|
|
32
|
-
require_relative "tools/task_create"
|
|
33
32
|
require_relative "tools/task_runtime"
|
|
34
33
|
|
|
35
34
|
module Samagotchi
|
|
@@ -581,9 +580,13 @@ module Samagotchi
|
|
|
581
580
|
[{}, 200, { status: record["status"], stop_reason: record["stop_reason"], task_id: task_id }]
|
|
582
581
|
end
|
|
583
582
|
|
|
583
|
+
# The tasks this session started: its history's (created_ids_in) and
|
|
584
|
+
# this turn's rows, a task_create's or an execute handed over at its
|
|
585
|
+
# limit (only after its HANDED_OVER line: its output can print a
|
|
586
|
+
# "task_id:" line of its own).
|
|
584
587
|
def own_task_ids
|
|
585
588
|
running = Array(@accumulator.current_turn&.dig(:parts)).filter_map do |part|
|
|
586
|
-
part[:
|
|
589
|
+
Tools::TaskRuntime.created_id(part[:tool], part[:output]) if part[:kind] == "tool"
|
|
587
590
|
end
|
|
588
591
|
Tools::TaskRuntime.created_ids_in(@engine.messages_checkpoint) | running
|
|
589
592
|
end
|
|
@@ -873,6 +876,7 @@ module Samagotchi
|
|
|
873
876
|
cut = @engine.cut_for_steer(Steer.source_for_client(client_id), epoch: epoch) if images.empty? && Delivery.cut?(delivery)
|
|
874
877
|
ack = { status: "accepted", enqueued_id: enqueued_id, session_id: @session_id, delivery: delivery }
|
|
875
878
|
ack[:cut] = cut.to_s if cut
|
|
879
|
+
ack[:cut_after] = @engine.steer_cut_after if cut == :waits
|
|
876
880
|
[{}, 202, ack]
|
|
877
881
|
end
|
|
878
882
|
|
|
@@ -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
|