samagotchi 0.2.0 → 0.4.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 +198 -1
- data/README.md +56 -4
- data/bin/chi +118 -50
- data/docs/cli.md +184 -9
- data/docs/configuration.md +333 -47
- data/docs/desktop.md +45 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +208 -5
- data/docs/plugins.md +68 -2
- data/docs/releasing.md +23 -13
- data/docs/sessions.md +45 -17
- data/lib/samagotchi/answer_display.rb +95 -0
- data/lib/samagotchi/archive_store.rb +90 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
- data/lib/samagotchi/bootstrap/probe.rb +262 -0
- data/lib/samagotchi/bootstrap_command.rb +347 -0
- data/lib/samagotchi/bridge/pending_card.rb +89 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
- data/lib/samagotchi/bridge.rb +13 -1
- data/lib/samagotchi/bridge_client.rb +6 -2
- data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
- data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
- data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
- data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/self_map.md +8 -2
- data/lib/samagotchi/client.rb +81 -19
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +252 -48
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
- data/lib/samagotchi/desktop/macos/Images.swift +113 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
- data/lib/samagotchi/desktop/macos.rb +59 -8
- data/lib/samagotchi/desktop_command.rb +6 -3
- data/lib/samagotchi/edit_preview.rb +82 -0
- data/lib/samagotchi/empty_answer_retry.rb +43 -0
- data/lib/samagotchi/engine.rb +434 -140
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +35 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +24 -5
- data/lib/samagotchi/host_registry.rb +9 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_recap.rb +5 -1
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +73 -94
- data/lib/samagotchi/live_versions.rb +59 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +132 -29
- data/lib/samagotchi/llm/errors.rb +41 -9
- data/lib/samagotchi/llm/http.rb +57 -17
- data/lib/samagotchi/llm/openai_chat.rb +17 -30
- data/lib/samagotchi/log_subscriber.rb +18 -3
- data/lib/samagotchi/memory_bundle/installer.rb +65 -63
- data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
- data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
- data/lib/samagotchi/memory_bundle/status.rb +4 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
- data/lib/samagotchi/model_profile.rb +24 -1
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/sessions.rb +3 -1
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/reply_wait.rb +126 -0
- data/lib/samagotchi/sampling_settings.rb +58 -0
- data/lib/samagotchi/self_report.rb +18 -3
- data/lib/samagotchi/send_command.rb +252 -11
- data/lib/samagotchi/session.rb +52 -11
- data/lib/samagotchi/session_archive_command.rb +107 -0
- data/lib/samagotchi/session_commands.rb +46 -7
- data/lib/samagotchi/session_manager.rb +115 -25
- data/lib/samagotchi/session_metrics.rb +222 -106
- data/lib/samagotchi/steer.rb +72 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
- data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
- data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
- data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui.rb +134 -247
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +115 -0
- data/lib/samagotchi/tool_activity.rb +3 -1
- data/lib/samagotchi/tool_runner.rb +34 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/builtins.rb +15 -4
- data/lib/samagotchi/tools/delegate_wait.rb +26 -69
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/execute.rb +52 -14
- data/lib/samagotchi/tools/task_runtime.rb +19 -0
- data/lib/samagotchi/tools/task_wait.rb +27 -3
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- data/lib/samagotchi/turn_note.rb +60 -6
- data/lib/samagotchi/update_command.rb +308 -0
- data/lib/samagotchi/update_hint.rb +59 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +7 -9
- data/lib/samagotchi/web/app.rb +91 -7
- data/lib/samagotchi/web/message_parts.rb +8 -3
- data/lib/samagotchi/web/public/activity.js +13 -1
- data/lib/samagotchi/web/public/annotate_presets.js +26 -0
- data/lib/samagotchi/web/public/annotations.js +13 -0
- data/lib/samagotchi/web/public/app.js +472 -111
- data/lib/samagotchi/web/public/card.js +5 -3
- data/lib/samagotchi/web/public/chat_view.js +13 -1
- data/lib/samagotchi/web/public/copy.js +20 -4
- data/lib/samagotchi/web/public/ctx.js +15 -0
- data/lib/samagotchi/web/public/data.js +23 -6
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/format.js +9 -0
- data/lib/samagotchi/web/public/index.html +60 -3
- data/lib/samagotchi/web/public/notify.js +175 -0
- data/lib/samagotchi/web/public/question_card.js +5 -2
- data/lib/samagotchi/web/public/sessions_list.js +7 -0
- data/lib/samagotchi/web/public/timing.js +39 -14
- data/lib/samagotchi/web/public/turn_events.js +75 -5
- data/lib/samagotchi/web/public/turn_view.js +49 -8
- data/lib/samagotchi/web/server.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +24 -1
- data/lib/samagotchi/worker.rb +16 -4
- metadata +31 -1
data/docs/hooks.md
CHANGED
|
@@ -50,7 +50,7 @@ The plugin class must respond to `#call(event)` — duck-typed, no base class re
|
|
|
50
50
|
|-------|--------------|---------------|
|
|
51
51
|
| `:session_start` | First turn of the session | `{ type: :session_start, session_id: "..." }` |
|
|
52
52
|
| `:before_turn` | Before each turn starts | `{ type: :before_turn, session_id: "...", prompt: "..." (nil on a continue), messages: [...] (the history before this turn) }` |
|
|
53
|
-
| `:after_turn` | After a turn completed or was cancelled (not after one that failed) | `{ type: :after_turn, status: "completed" \| "canceled", messages: [...] (the conversation the turn stored; a cancelled or empty turn ends it with a `kind: turn_note` system message, and a context line is `kind: context`, see [sessions.md](sessions.md#notes-a-turn-leaves-for-the-model)) }` |
|
|
53
|
+
| `:after_turn` | After a turn completed or was cancelled (not after one that failed) | `{ type: :after_turn, status: "completed" \| "canceled", present: (see [Presenting the answer](#presenting-the-answer-display-only)), messages: [...] (the conversation the turn stored; a cancelled or empty turn ends it with a `kind: turn_note` system message, and a context line is `kind: context`, see [sessions.md](sessions.md#notes-a-turn-leaves-for-the-model)) }` |
|
|
54
54
|
| `:before_generation` | Before each LLM API call (both loops) | `{ type: :before_generation, iteration: N }` |
|
|
55
55
|
| `:after_generation` | After LLM returns (both loops) | `{ type: :after_generation, iteration: N, response: "...", messages: [...] (the conversation as sent) }` |
|
|
56
56
|
| `:before_tool_call` | Before tool dispatch (and before `tool_call_started`) | `{ type: :before_tool_call, iteration: N, call: {...}, params: "...", guardrail: Verdict, context: {...}, targets: {...}, blocked: false, block_reason: nil }` |
|
|
@@ -59,7 +59,7 @@ The plugin class must respond to `#call(event)` — duck-typed, no base class re
|
|
|
59
59
|
|
|
60
60
|
Every event also carries the hook runtime (next section): `hook:` (the label
|
|
61
61
|
of the hook about to run) and the callables `notify:`, `ask_user:`,
|
|
62
|
-
`stop_turn:`.
|
|
62
|
+
`stop_turn:`, `steer:`.
|
|
63
63
|
|
|
64
64
|
`messages:` is a **read-only copy**: a frozen array of copied message hashes
|
|
65
65
|
(`{role:, content:, …}`). A hook that mutates it, or its strings, gets
|
|
@@ -69,8 +69,8 @@ cheap).
|
|
|
69
69
|
## What a hook can do: the runtime
|
|
70
70
|
|
|
71
71
|
Besides reading (and, on `:before_tool_call`, voting on) its event, a hook
|
|
72
|
-
can talk to the user
|
|
73
|
-
event:
|
|
72
|
+
can talk to the user, and to the running turn, through four callables the
|
|
73
|
+
registry puts on every event:
|
|
74
74
|
|
|
75
75
|
```ruby
|
|
76
76
|
class Watchful
|
|
@@ -94,6 +94,14 @@ class Watchful
|
|
|
94
94
|
# that call, and the rest of the batch is denied; from :after_turn or
|
|
95
95
|
# :session_end it does nothing (false).
|
|
96
96
|
event[:stop_turn].call("too many iterations without progress") if event[:iteration] > 20
|
|
97
|
+
when :after_tool_call
|
|
98
|
+
# Put text into the running turn, as the user's steering does: at the
|
|
99
|
+
# loop's next boundary it joins the conversation as its own user
|
|
100
|
+
# message, and every UI shows a nudge line ("<bundle> nudged: …").
|
|
101
|
+
# True when queued; false with no turn, and from :after_turn or
|
|
102
|
+
# :session_end. A steer that arrives after the model's final answer is
|
|
103
|
+
# dropped (logged), not merged: it never keeps a finished turn going.
|
|
104
|
+
event[:steer].call("Say briefly what you have found so far.") if event[:iteration] == 30
|
|
97
105
|
end
|
|
98
106
|
end
|
|
99
107
|
end
|
|
@@ -103,11 +111,55 @@ end
|
|
|
103
111
|
known-names)` for a bundle hook, `audit.rb (config)` for a config hook,
|
|
104
112
|
`turn hook` for one registered at runtime.
|
|
105
113
|
|
|
114
|
+
A steer is saved in the session as `{role: "user", kind: "steer", source:
|
|
115
|
+
"<bundle>", content: "…"}`; the model reads only its text, as a user turn.
|
|
116
|
+
Its `source` is the hook's bundle (a config or turn hook's label otherwise).
|
|
117
|
+
|
|
106
118
|
Timing: a notice from `:after_turn` or `:session_end` shows after the turn's
|
|
107
119
|
end line. A question from `:before_tool_call` shows **before** the tool
|
|
108
120
|
line (the gate runs first), so its text should name the call. The notices
|
|
109
121
|
are also logged (`turn` tag, `hook_notice`).
|
|
110
122
|
|
|
123
|
+
## Presenting the answer (display only)
|
|
124
|
+
|
|
125
|
+
`:after_turn` carries one more callable, `present:`. It changes how the
|
|
126
|
+
turn's answer is **shown**, never what the model said: the block gets the
|
|
127
|
+
current display text (the answer's content until a hook changed it) and
|
|
128
|
+
returns the new one.
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
class Shout
|
|
132
|
+
def call(event)
|
|
133
|
+
return unless event[:type] == :after_turn
|
|
134
|
+
|
|
135
|
+
event[:present].call { |text| text.gsub(/\bTODO\b/, "**TODO**") }
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
- The result is kept as `display` on the answer's model message in the
|
|
141
|
+
session file. The model never sees it: the prompts and chat requests take
|
|
142
|
+
the fields they send, and the copies of the conversation given to hooks
|
|
143
|
+
(`messages:`), plugins (`ctx.messages`) and the recap leave it out.
|
|
144
|
+
- Calls chain in hook order (bundle hooks by priority, then config hooks,
|
|
145
|
+
then turn hooks): each block gets what the one before returned. The call
|
|
146
|
+
returns the display text after it.
|
|
147
|
+
- A block that raises, returns something other than a String, or returns
|
|
148
|
+
more than 200 000 characters leaves the display as it was (logged as
|
|
149
|
+
`present_rejected` with the hook's label).
|
|
150
|
+
- It works on the stored conversation's last message only when that is the
|
|
151
|
+
model's answer: after a cancelled, failed or empty turn there is none, and
|
|
152
|
+
the call returns nil without running the block.
|
|
153
|
+
- **The web** renders `display` instead of the answer (markdown, sanitised
|
|
154
|
+
like every answer: raw HTML is escaped, only http(s)/mailto links are
|
|
155
|
+
kept), on a live turn and after a reload. Its copy button copies the
|
|
156
|
+
display text. The page learns about it from an `answer_display` event
|
|
157
|
+
that comes after `turn_completed` (the hooks run after the turn ended).
|
|
158
|
+
- **Terminals** (the REPL, the attached TUI) have printed the answer by then
|
|
159
|
+
and do not change it; use `event[:notify]` for something they should show.
|
|
160
|
+
|
|
161
|
+
A plugin gets the same from `chi.on(:after_turn) { |event, ctx| event[:present].call { … } }`.
|
|
162
|
+
|
|
111
163
|
## Settings
|
|
112
164
|
|
|
113
165
|
A hook class whose `initialize` takes an argument gets its settings: **one
|
|
@@ -299,7 +351,7 @@ Notes:
|
|
|
299
351
|
- Ordering: bundle hooks fire by `(priority, bundle_name, hook_name)` (lower priority first), then plain `config.yml` hooks in registration order.
|
|
300
352
|
- Settings: a hook class with `initialize(settings = {})` gets the bundle's section of `config.yml` `bundles:` (see [Settings](#settings)).
|
|
301
353
|
- A bundle can also ship a `plugin.rb` whose `chi.on(event)` blocks are bundle hooks too, next to commands and tools; see [Plugins](plugins.md).
|
|
302
|
-
- Shipped bundles: `chi bundle install guardrails` (rules, see [Guardrails](guardrails.md#the-guardrails-bundle)) and `chi bundle install known-names` (a hook, see [Guardrails](guardrails.md#the-known-names-bundle)), `chi bundle install btw` (a plugin: `/btw`, see [Plugins](plugins.md#the-btw-bundle)), `chi bundle install mcp` (a plugin: tools from MCP servers, see [Plugins](plugins.md#the-mcp-bundle))
|
|
354
|
+
- Shipped bundles: `chi bundle install guardrails` (rules, see [Guardrails](guardrails.md#the-guardrails-bundle)) and `chi bundle install known-names` (a hook, see [Guardrails](guardrails.md#the-known-names-bundle)), `chi bundle install source-links` (a hook: announces source refs, see [The source-links bundle](#the-source-links-bundle)), `chi bundle install btw` (a plugin: `/btw`, see [Plugins](plugins.md#the-btw-bundle)), `chi bundle install mcp` (a plugin: tools from MCP servers, see [Plugins](plugins.md#the-mcp-bundle)) `chi bundle install loop-guard` (a plugin: breaks tool-call loops, see [Plugins](plugins.md#the-loop-guard-bundle)) and `chi bundle install check-in` (a plugin: checks on a long turn, see [Plugins](plugins.md#the-check-in-bundle)).
|
|
303
355
|
- Installing a bundle executes its hook code at `Engine` startup. Only install bundles you trust, as you would a gem. Hooks are **not** executed at install time (copy-only); they are `module_eval`'d at `Engine.new` inside per-bundle `Samagotchi::Bundles::<name>` namespaces (no top-level `require` collisions). Keep hook files side-effect-free at load time; do work in `#call` — top-level side effects (require, IO, `at_exit`, global assignment) run once per `Engine.new` (class redefinition is idempotent).
|
|
304
356
|
|
|
305
357
|
Lifecycle:
|
|
@@ -307,3 +359,154 @@ Lifecycle:
|
|
|
307
359
|
- `chi bundle install <source>` copies `hooks/*.rb` to `~/.config/samagotchi/memories/.bundles/<name>/hooks/` and persists metadata + `trust_level` + `source_commit` (git HEAD) to provenance.
|
|
308
360
|
- `Engine.new` loads `config.yml` hooks first, then bundle hooks via `Provenance.each_installed_holding_hooks` → `Hooks::BundleLoader.load`. Bundle hooks are process-scoped (they survive the per-turn `clear_hooks`; only plain hooks are cleared). Experimental bundles emit a one-line startup warning.
|
|
309
361
|
- `chi bundle status`, `diff`, `uninstall`, `build` are hook-aware (counts, metadata, removal).
|
|
362
|
+
|
|
363
|
+
## The source-links bundle
|
|
364
|
+
|
|
365
|
+
```sh
|
|
366
|
+
chi bundle install source-links
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
installs one `after_turn` hook and a short memory. When the model's answer
|
|
370
|
+
mentions a known source ref — a JIRA ticket, a GitHub issue, an internal
|
|
371
|
+
wiki page — the web links it in the answer (below), and every UI shows one
|
|
372
|
+
line right after the message:
|
|
373
|
+
|
|
374
|
+
```
|
|
375
|
+
sources: JIRA JIRA-123 → https://myjira.com/browse/JIRA-123, JIRA JIRA-10 → https://myjira.com/browse/JIRA-10
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
The note is **not part of the conversation**: it is an event shown to the
|
|
379
|
+
user, never stored in the session file. A UI replays it while the session's
|
|
380
|
+
worker lives (a page reload keeps it; a stopped worker loses it). Only the
|
|
381
|
+
model's final answer is scanned (the last `role: "model"` message), and only
|
|
382
|
+
the first 20 000 characters of it. A ref that is already a link is skipped —
|
|
383
|
+
inside a bare URL (`https://x.com/JIRA-123`), in a markdown link's target, or
|
|
384
|
+
in a markdown link's label when the target names the same ref
|
|
385
|
+
(`[JIRA-123](https://x.com/JIRA-123)`) — while `see https://x.com JIRA-123`
|
|
386
|
+
and `[fix for JIRA-123](https://github.com/o/r/pull/9)` still link. A ref
|
|
387
|
+
glued to URL punctuation (`/browse/JIRA-1`, `?key=JIRA-1`, `JIRA-1/foo`) is
|
|
388
|
+
skipped too; `Ticket:JIRA-5` and `#JIRA-123` are ordinary plain text and do
|
|
389
|
+
link. The line names each URL once (compared case-insensitively: `#12` and
|
|
390
|
+
`dm1try/samagotchi#12` may be one issue, and with `case_insensitive: true`
|
|
391
|
+
`JIRA-1` and `jira-1` are one ticket), in first-occurrence order, whatever
|
|
392
|
+
order the sources are configured in. With no sources configured the hook is a
|
|
393
|
+
silent no-op.
|
|
394
|
+
|
|
395
|
+
```yaml
|
|
396
|
+
bundles:
|
|
397
|
+
source-links:
|
|
398
|
+
sources:
|
|
399
|
+
- name: JIRA
|
|
400
|
+
prefix: JIRA # simple form: \bJIRA-(\d+)\b
|
|
401
|
+
base_url: https://myjira.com/browse/
|
|
402
|
+
- name: GitHub
|
|
403
|
+
pattern: '\bGH-(\d+)\b' # full form: a regex
|
|
404
|
+
url: 'https://github.com/org/repo/issues/{match}'
|
|
405
|
+
case_insensitive: false # optional, default false
|
|
406
|
+
max: 10 # optional: refs per line, default 10
|
|
407
|
+
note: false # optional: no sources line, default true
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
**In the web** the refs are also links in the answer itself: each
|
|
411
|
+
occurrence becomes `[JIRA-123](https://myjira.com/browse/JIRA-123)` through
|
|
412
|
+
[`event[:present]`](#presenting-the-answer-display-only), so the model's
|
|
413
|
+
text stays as it was, and the links survive a reload and a stopped worker
|
|
414
|
+
(they are the answer's `display` in the session file). The same skip rules
|
|
415
|
+
apply, and a ref in code (a `` `span` `` or a fenced block) or anywhere in a
|
|
416
|
+
markdown link is left as it is; past the first 20 000 characters the answer
|
|
417
|
+
is unchanged. The terminals see only the line; `note: false` drops it and
|
|
418
|
+
keeps the web links.
|
|
419
|
+
|
|
420
|
+
The `prefix:` form compiles to `\b<prefix>-(\d+)\b` and the URL is
|
|
421
|
+
`base_url` + the full ref text (`JIRA-123`). The `pattern:` form takes a
|
|
422
|
+
regex and a `url:` template with placeholders (below). `case_insensitive:
|
|
423
|
+
true` adds the `/i` flag. Past `max` refs the line ends with `… +N more`.
|
|
424
|
+
|
|
425
|
+
Each regex is compiled with a per-regex timeout (0.5 s, per match attempt),
|
|
426
|
+
so a catastrophic pattern is abandoned instead of hanging the turn: that
|
|
427
|
+
source is skipped whole (its partial matches are discarded) with a warning,
|
|
428
|
+
and the others still report. An entry with neither `prefix:` nor `pattern:`,
|
|
429
|
+
or a pattern that does not compile, is skipped with a warning at load. The
|
|
430
|
+
hook is `on_error: log`: a bug in it warns and the turn is unaffected. As with
|
|
431
|
+
every bundle hook, a running worker picks it up after its next start.
|
|
432
|
+
|
|
433
|
+
### Placeholders, and the project's own repo
|
|
434
|
+
|
|
435
|
+
A `url:` template (the `pattern:` form only; `base_url:` is always
|
|
436
|
+
`base_url` + the ref) can use:
|
|
437
|
+
|
|
438
|
+
| placeholder | value | when it can't be filled |
|
|
439
|
+
|---|---|---|
|
|
440
|
+
| `{match}` | the first capture group, else the whole ref | never: it falls back to the ref |
|
|
441
|
+
| `{1}` … `{9}` | a numbered capture group | the group didn't take part: the ref is **not linked** |
|
|
442
|
+
| `{name}` | a named capture group `(?<name>…)` | the group didn't take part: **not linked** |
|
|
443
|
+
| `{repo}`, `{host}` | the named group `repo` / `host` when the pattern has one and it took part; else the project's git remote | neither: **not linked** |
|
|
444
|
+
|
|
445
|
+
A ref with a placeholder that can't be filled is left out of both the line
|
|
446
|
+
and the answer: no URL is built with a hole in it (another source on the same
|
|
447
|
+
ref can still link it). A `{word}` or `{N}` that is none of the above (not a
|
|
448
|
+
group of the pattern, or `{7}` in a two-group pattern) stays as literal text,
|
|
449
|
+
with one warning when the source is loaded. Every value is percent-encoded
|
|
450
|
+
(everything outside `A-Za-z0-9-._~`, `/` included), except that `{repo}`
|
|
451
|
+
keeps its `/` between segments (GitLab's `group/sub/proj`); a `{repo}` with an
|
|
452
|
+
empty, `.` or `..` segment counts as unfilled.
|
|
453
|
+
|
|
454
|
+
So one source links both `#12` in this project and a cross-repo ref:
|
|
455
|
+
|
|
456
|
+
```yaml
|
|
457
|
+
bundles:
|
|
458
|
+
source-links:
|
|
459
|
+
sources:
|
|
460
|
+
- name: GitHub
|
|
461
|
+
# `#12` → this project's repo; `owner/repo#12` → that repo
|
|
462
|
+
pattern: '(?<![\w/&])(?:(?<repo>[A-Za-z0-9][\w-]*/[\w.-]*\w))?#(?<num>\d+)\b'
|
|
463
|
+
url: 'https://github.com/{repo}/issues/{num}'
|
|
464
|
+
remote_host: github.com
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
```
|
|
468
|
+
sources: GitHub #12 → https://github.com/dm1try/samagotchi/issues/12, GitHub rails/rails#5 → https://github.com/rails/rails/issues/5
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
GitHub redirects `/issues/N` to `/pull/N` and back, so one URL covers issues
|
|
472
|
+
and pull requests. The lookbehind keeps `{`, `x/#1` and a partial
|
|
473
|
+
`b/c#1` inside `a/b/c#1` out; code and URLs are skipped as always. `PR #12`
|
|
474
|
+
links, `PR#12` doesn't (a `#` right after a letter). The pattern is loose on
|
|
475
|
+
purpose: a bare `#\d+` also matches "step #2", and `and/or#5` or `TCP/IP#3`
|
|
476
|
+
read as qualified refs. A stricter variant wants `PR #`, `issue #` or a
|
|
477
|
+
qualified ref:
|
|
478
|
+
|
|
479
|
+
```yaml
|
|
480
|
+
pattern: '(?<![\w/&])(?:(?<repo>[A-Za-z0-9][\w-]*/[\w.-]*\w)#|\b(?:PR|[Ii]ssue) #)(?<num>\d+)\b'
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
**Where `{repo}` and `{host}` come from.** Without a named group that took
|
|
484
|
+
part, they come from `git remote get-url <remote>` run in the session's
|
|
485
|
+
working directory (the worker's; the in-process REPL's is the terminal's
|
|
486
|
+
current directory). `remote:` picks the remote, default `origin` — a fork
|
|
487
|
+
sets `remote: upstream`. Git applies `insteadOf` rewrites and includes, and a
|
|
488
|
+
worktree reports its main repo's remote. The URL forms understood are
|
|
489
|
+
`https://`, `http://`, `ssh://`, `git://` (credentials and port dropped) and
|
|
490
|
+
scp-like `[user@]host:owner/repo`; the repo is the path without a trailing
|
|
491
|
+
`.git` or `/`. A local path, `file://`, no git, no repo or no such remote
|
|
492
|
+
leaves the ref unlinked, silently (a debug log line only). Git is asked only
|
|
493
|
+
when a hit needs it — a JIRA source or a qualified ref never runs it — and
|
|
494
|
+
the answer, even "none", is remembered for the worker's life: a remote
|
|
495
|
+
changed mid-session counts after the worker's next start.
|
|
496
|
+
|
|
497
|
+
`remote_host:` (a host or a list, compared case-insensitively) applies the
|
|
498
|
+
remote-derived links only when the remote's host is one of them, so a
|
|
499
|
+
`https://github.com/{repo}/…` template never points a GitLab project's `#12`
|
|
500
|
+
at github.com. A qualified ref is linked whatever the local remote is. An SSH
|
|
501
|
+
alias (`git@github-work:o/r.git` from a multi-account `~/.ssh/config`) or an
|
|
502
|
+
`insteadOf` mirror reports its own host; list it too:
|
|
503
|
+
`remote_host: [github.com, github-work]`.
|
|
504
|
+
|
|
505
|
+
Two Ruby regex notes. With named groups in a pattern, a plain `(…)` doesn't
|
|
506
|
+
capture and gets no number, so `{1}` is the first *named* group, and so is
|
|
507
|
+
`{match}` (in the example above `{match}` is the repo part): use either named
|
|
508
|
+
or numbered groups in one pattern, and named placeholders with named groups.
|
|
509
|
+
And a pattern with a `host` (or `repo`) group lets the model's text choose the
|
|
510
|
+
link's domain (or repo): the escaping rules out URL injection, but the choice
|
|
511
|
+
of the target is the model's.
|
|
512
|
+
|
data/docs/plugins.md
CHANGED
|
@@ -305,7 +305,9 @@ end
|
|
|
305
305
|
|
|
306
306
|
This is a bundle hook, the same as a `hooks/*.rb` file. See
|
|
307
307
|
[hooks.md](hooks.md#hook-events) for the events and for what `event[:notify]`,
|
|
308
|
-
`event[:ask_user]` and `event[:
|
|
308
|
+
`event[:ask_user]`, `event[:stop_turn]` and `event[:steer]` do; on `:after_turn`,
|
|
309
|
+
`event[:present]` sets how the answer is shown in the web
|
|
310
|
+
([Presenting the answer](hooks.md#presenting-the-answer-display-only)). Its label is
|
|
309
311
|
`plugin.rb (bundle my-bundle)`. The block may take only the event. If it
|
|
310
312
|
raises, the error is logged and the hook is skipped.
|
|
311
313
|
|
|
@@ -435,6 +437,8 @@ session's life, and each read gives the session as it is now.
|
|
|
435
437
|
| `ctx.card(title:, body: "", actions: [], level: :info, id: nil)` | a card in every UI, returning its id: see [Cards](#cards) |
|
|
436
438
|
| `ctx.ask_user(question:, options:, header: nil, allow_freeform: false)` | a question, like a hook's `event[:ask_user]` |
|
|
437
439
|
| `ctx.cancelled?` | whether the running turn was cancelled (a long tool should stop) |
|
|
440
|
+
| `ctx.steer(text)` | put text into the running turn, like a hook's `event[:steer]`: its own user message at the loop's next boundary, shown as `my-bundle> nudged: …`. Returns true when queued, false with no turn running (it never starts one; that is `ctx.sessions`' send). Dropped (logged) if the model answers or the turn ends first. Safe from any thread: an anytime command, a hook, your own |
|
|
441
|
+
| `ctx.stop_turn(reason)` | stop the running turn after a warn notice with the reason, like a hook's `event[:stop_turn]`; true when it stopped one now |
|
|
438
442
|
| `ctx.ask_model(messages:, prompt:, …)` | a side answer from the session's model: see [Side answers](#side-answers-ctxask_model) |
|
|
439
443
|
| `ctx.sessions` | fork, send to and read other sessions: see [Other sessions](#other-sessions-ctxsessions) |
|
|
440
444
|
|
|
@@ -460,7 +464,11 @@ ctx.card(id: id, title: "Build finished", body: "no warnings left") # replaces
|
|
|
460
464
|
- `actions:` are up to 6 `{label:, command:}`. A command is a line the
|
|
461
465
|
session runs as if the user typed it: `/hello again`, `/model x`, a
|
|
462
466
|
plugin's own command. The web shows a button; the terminal shows
|
|
463
|
-
`→ /hello again`, to type.
|
|
467
|
+
`→ /hello again`, to type. A button's command leaves no echo: the web
|
|
468
|
+
sends it with `card: true` (`POST /api/sessions/:id/command`), its
|
|
469
|
+
`command_queued` and `command_ran` carry `card: true`, and no UI shows
|
|
470
|
+
its line; the web shows a bubble only when the command answers with
|
|
471
|
+
text or fails.
|
|
464
472
|
- `level:` is `:info` or `:warn` (the warning colour).
|
|
465
473
|
- `id:` names an earlier card to replace. Without one a new id is made. The
|
|
466
474
|
web updates the card in place; the terminal prints it again, marked
|
|
@@ -748,6 +756,64 @@ Not caught (yet):
|
|
|
748
756
|
- alternating calls (A, B, A, B) that each return something new;
|
|
749
757
|
- thinking that goes in circles inside one long generation.
|
|
750
758
|
|
|
759
|
+
## The check-in bundle
|
|
760
|
+
|
|
761
|
+
`chi bundle install check-in` installs the bundle shipped with chi. It is
|
|
762
|
+
written only against this API (`lib/samagotchi/bundles/check-in/plugin.rb`):
|
|
763
|
+
`chi.on` hooks, `ctx.card`, `ctx.steer`, `ctx.stop_turn` and one anytime
|
|
764
|
+
command. It has no memory file.
|
|
765
|
+
|
|
766
|
+
A turn can run a long time on its own, reading and searching, without saying
|
|
767
|
+
what it has found. check-in counts the turn's tool calls, and when there are
|
|
768
|
+
`after` of them with no answer yet (then every `every` more) it checks in:
|
|
769
|
+
|
|
770
|
+
- **`mode: ask`** (the default): a card, one per turn, updated in place at
|
|
771
|
+
each check-in: "50 tool calls, no answer yet", how long the turn has run
|
|
772
|
+
and its last few tools, and three actions:
|
|
773
|
+
- **Nudge** (`/checkin nudge`): puts `message` into the running turn
|
|
774
|
+
(`ctx.steer`); the model reads it at its next step and answers or says
|
|
775
|
+
what is left. Every UI shows `check-in> nudged: …`.
|
|
776
|
+
- **Keep going** (`/checkin later`): closes the card until the next
|
|
777
|
+
check-in.
|
|
778
|
+
- **Stop** (`/checkin stop`): stops the turn.
|
|
779
|
+
|
|
780
|
+
When the turn ends the card loses its actions ("The turn ended after N tool
|
|
781
|
+
calls"), so no stale buttons stay. In the attached terminal the actions are
|
|
782
|
+
`→ /checkin nudge` lines to type; the command runs beside the turn.
|
|
783
|
+
- **`mode: nudge`**: nudges the model by itself, with a notice line.
|
|
784
|
+
- **`mode: notify`**: a notice line only.
|
|
785
|
+
|
|
786
|
+
The count is per turn: a new turn (a prompt, a continue, a reminder) starts
|
|
787
|
+
from zero; steering merged into the running turn doesn't reset it. The polling
|
|
788
|
+
tools are not counted. A nudge that arrives after the model's final answer is
|
|
789
|
+
dropped (logged), never restarting a turn that is done; the card's "Nudged the
|
|
790
|
+
model at N tool calls" then becomes "The answer came first; nudge not sent."
|
|
791
|
+
|
|
792
|
+
```yaml
|
|
793
|
+
# config.yml
|
|
794
|
+
bundles:
|
|
795
|
+
check-in:
|
|
796
|
+
after: 50 # tool calls in one turn before the first check-in
|
|
797
|
+
every: 50 # then again every this many more
|
|
798
|
+
mode: ask # ask | nudge | notify
|
|
799
|
+
message: "You've made {calls} tool calls in this turn without answering. Say briefly what you've found so far and what's left, then answer now or continue."
|
|
800
|
+
ignore_tools: [task_wait, task_get, delegate_result]
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
`{calls}` in `message` is the count. The default asks for what has been found
|
|
804
|
+
and what is left, not "how's it going?": a small model tends to answer that
|
|
805
|
+
with a line and carry on unchanged.
|
|
806
|
+
|
|
807
|
+
`/checkin` (anytime) for this session, until its worker restarts:
|
|
808
|
+
|
|
809
|
+
| | |
|
|
810
|
+
|---|---|
|
|
811
|
+
| `/checkin` | on or off, the mode, the threshold and this turn's count |
|
|
812
|
+
| `/checkin on` / `off` | check in, or not |
|
|
813
|
+
| `/checkin 30` | check in after 30 tool calls, then every 30 |
|
|
814
|
+
| `/checkin mode ask` / `nudge` / `notify` | the mode |
|
|
815
|
+
| `/checkin nudge` / `later` / `stop` | the card's actions, also by hand |
|
|
816
|
+
|
|
751
817
|
## Shutdown
|
|
752
818
|
|
|
753
819
|
When the REPL exits, or a session's worker exits (an idle exit, `/exit`, a
|
data/docs/releasing.md
CHANGED
|
@@ -15,13 +15,13 @@ can run the whole release; the user approves the notes before the tag and the
|
|
|
15
15
|
- **Every other shipped bundle** (btw, guardrails, known-names, loop-guard,
|
|
16
16
|
mcp) has its own semver in its `manifest.yml` and, when it needs a newer chi,
|
|
17
17
|
a `requires_chi:` line. They never upgrade by themselves: users run
|
|
18
|
-
`chi bundle upgrade NAME`. So:
|
|
18
|
+
`chi update` (all of them, with the gem) or `chi bundle upgrade NAME`. So:
|
|
19
19
|
- a change to a bundle's files bumps that bundle's `version:`
|
|
20
20
|
(`rake bundles:check` fails otherwise, once there's a tag to compare with);
|
|
21
21
|
- a bundle that uses something new in chi raises its `requires_chi` in the
|
|
22
22
|
same change; `release:bump` never touches `requires_chi`;
|
|
23
|
-
- the release notes
|
|
24
|
-
bundle
|
|
23
|
+
- the release notes say "run `chi update`" and name the bundles whose
|
|
24
|
+
version moved (what changed in each), not per-bundle upgrade steps.
|
|
25
25
|
- Pre-1.0: config and commands may change in a minor version (0.2 → 0.3); a
|
|
26
26
|
patch version (0.2.0 → 0.2.1) is fixes only.
|
|
27
27
|
|
|
@@ -58,9 +58,9 @@ The agent does each step and stops where the user has to say yes.
|
|
|
58
58
|
1. **Start from main, up to date and green.** `git checkout main && git pull`;
|
|
59
59
|
CI on main is green.
|
|
60
60
|
2. **Draft the notes.** `bundle exec rake release:draft_changelog`, then edit
|
|
61
|
-
`## [Unreleased]` in CHANGELOG.md into short user-facing lines.
|
|
62
|
-
`chi
|
|
63
|
-
last tag (`git diff vPREV -- lib/samagotchi/bundles/*/manifest.yml`).
|
|
61
|
+
`## [Unreleased]` in CHANGELOG.md into short user-facing lines. End with
|
|
62
|
+
"Update with `chi update`", naming the bundles whose version moved since
|
|
63
|
+
the last tag (`git diff vPREV -- lib/samagotchi/bundles/*/manifest.yml`).
|
|
64
64
|
Pick the version: fixes only → patch, anything else → minor.
|
|
65
65
|
3. **The user approves the notes and the version.** Show them the section.
|
|
66
66
|
4. **Bump.** `bundle exec rake "release:bump[X.Y.Z]"`, review `git diff`.
|
|
@@ -125,11 +125,21 @@ GitHub release as such (`gh release edit vX.Y.Z --prerelease` or edit its
|
|
|
125
125
|
notes: "yanked: …"), add a `### Fixed` line under Unreleased, and release the
|
|
126
126
|
next patch version.
|
|
127
127
|
|
|
128
|
-
##
|
|
128
|
+
## CI on Linux
|
|
129
129
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
130
|
+
The suite runs the same on Linux CI as on macOS, with no CI-only skips. A few
|
|
131
|
+
things differ there, and a new spec that trips on them fails only on CI:
|
|
132
|
+
|
|
133
|
+
- `CI` set marks every new session a test run, and `--all`, `list_sessions`
|
|
134
|
+
and friends leave test runs out. A spec that lists sessions makes them with
|
|
135
|
+
`test_run: false`.
|
|
136
|
+
- The gems live under `vendor/bundle`: a child `ruby` started with a bare env
|
|
137
|
+
(`unsetenv_others: true`) needs `GEM_HOME`/`GEM_PATH` to find nokogiri.
|
|
138
|
+
- Ruby 3.3's zlib raises `Zlib::BufError` when a thread interrupt lands in a
|
|
139
|
+
deflate (ruby/zlib#57, fixed in Ruby 3.4's zlib): build gems in a child
|
|
140
|
+
process, as `spec/gem_contents_spec.rb` does.
|
|
141
|
+
|
|
142
|
+
To run it locally, use Docker with the Ruby build CI uses
|
|
143
|
+
(`ruby-X.Y.Z-ubuntu-24.04-x64.tar.gz` from ruby/ruby-builder's releases) on
|
|
144
|
+
`ubuntu:24.04` with `LANG=C.UTF-8`, `zip` and `git`, and run
|
|
145
|
+
`CI=1 BUNDLE_PATH=… bundle exec rspec`.
|
data/docs/sessions.md
CHANGED
|
@@ -23,6 +23,8 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
|
|
|
23
23
|
|
|
24
24
|
**Deleting a session:** `chi sessions delete ID...`, `/exit --delete` in a terminal and the Web UI's delete all go through `SessionManager.delete_session`: it resolves a unique prefix, removes `<id>.json` and the whole `<id>/` dir, and returns what it removed. A session a plain REPL has open (`owner.lock` kind `tui`) is always refused. One a worker runs is refused unless the caller asks to stop it: then it stops the worker as `chi sessions stop` does and deletes once `owner.lock` is free (`--force` on the CLI, 10 s; the web always, 2 s; `/exit --delete` after the worker agreed to exit, 10 s). A worker that outlives the wait leaves the session in place. The web's route is `DELETE /api/sessions/:id` (200 `{status: "deleted", session_id, stopped}`; 409 `owned_by_tui` or `still_stopping`; 404).
|
|
25
25
|
|
|
26
|
+
<a id="archiving-a-session"></a>**Archiving a session:** `chi sessions archive ID...`, `/archive` in a terminal and the Web UI's `archive` go through `SessionManager.archive_session`: it writes `<id>/archived` (`{"archived_at": …}`; not a session.json field, which every save rewrites) for the session and its delegated children. `Session.list` leaves archived sessions out unless `include_archived:`, and everything built on it follows: `chi sessions list` (`--archived` shows them, `[archived]`), the summaries (`--live`, json/tsv, the desktop panel, the agent's `list_sessions`) and the retention prune, which neither deletes nor counts them against `max_count`. `children_of` (max_children, `delegate_result`) still sees them, and `--resume`/`--attach ID`, `chi send`/`chi note ID` reach them as before. Refused: a turn running in the session or in one of its children (naming the child; 409 `busy` on the web), a plain REPL owning it (`owned_by_tui`), a `chi scratch` session (`scratch`). A live idle worker is stopped first, and the marker is written even if it is still shutting down. Input a human typed brings a session back (`{"unarchived_at": …}`): a prompt from the web, an attached or plain terminal or `chi send` (origin `web:*`, `tui:*`, `cli:send`, none), steering merged into a running turn, a `/continue` answer or a question answered; a delegate's follow-up, a plugin's turn, a reminder, the idle recap, a note and just opening it don't. A child's input brings only the child back. The retention sweep ages an unarchived session from when it was unarchived, so unarchiving an old session doesn't let the next sweep take it. `/archive` leaves the session first, as `/exit` does, then archives it: in an attached terminal the worker is asked to exit (one that stays up for another UI is stopped by the archive; a running turn refuses it), and an empty session is discarded instead; `chi scratch` refuses it. `chi sessions unarchive ID...` and the web's `unarchive` bring back the whole family. Web routes: `POST /api/sessions/:id/archive` (200 `{status, session_id, archived, stopped, discarded}`, ids) and `POST /api/sessions/:id/unarchive` (200 `{status, session_id, unarchived}`). The session hub and `GET /api/sessions` keep archived sessions, `archived: true` in their summaries: the page leaves them out of the strip, its count and the list at render, and "include archived" by the all-sessions search shows them, dimmed with an `archived` badge. The info bar reads `archive · stop · delete` (`unarchive` on an archived one); archiving the open session stays on it, with an Undo toast.
|
|
27
|
+
|
|
26
28
|
**Lazy sweep:** automatic prune runs at most once per 24h on `GET /api/sessions` (Web). No background thread or cron. Manual prune is always available.
|
|
27
29
|
|
|
28
30
|
**CLI:**
|
|
@@ -31,6 +33,8 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
|
|
|
31
33
|
chi sessions list [--sort updated_at|created_at] [--order desc|asc] [--limit N] [--scope=all]
|
|
32
34
|
chi sessions list [--live] [--cwd PATH] [--limit N] [--format text|json|tsv] [--scope=all]
|
|
33
35
|
chi sessions stop ID
|
|
36
|
+
chi sessions archive ID... # hide from every list, keep for good; unarchive ID... undoes it
|
|
37
|
+
chi sessions list --archived # archived sessions too, marked [archived]
|
|
34
38
|
chi sessions delete [--force] ID... # for good; --force stops a live worker first
|
|
35
39
|
chi sessions prune [--dry-run] [--days N] [--keep N] [--keep-status running,...] [--test-only]
|
|
36
40
|
chi sessions clean [--dry-run] [--days N] # test sessions: all, or older than N days
|
|
@@ -48,11 +52,26 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
|
|
|
48
52
|
|
|
49
53
|
`--dry-run` is the safe preview. Web has no prune endpoint; use the CLI.
|
|
50
54
|
|
|
51
|
-
`chi sessions list` shows each session's saved recap, its first sentence without the "The user was…" opening (as on the web cards), in place of its last prompt, cut to 60 characters; a session with no recap shows its last prompt. A delegated session's row ends with `↳ <parent's short id>` (plain and `--live`; `--format json` has `parent_id`); rows keep their `updated_at` order, so a parent may be on another page than its children.
|
|
55
|
+
`chi sessions list` shows each session's saved recap, its first sentence without the "The user was…" opening (as on the web cards), in place of its last prompt, cut to 60 characters; a session with no recap shows its last prompt. A delegated session's row ends with `↳ <parent's short id>` (plain and `--live`; `--format json` has `parent_id`); rows keep their `updated_at` order, so a parent may be on another page than its children. After the status, `ctx 12%` says how full the context was after the session's last counted turn (from its `analytics.json`; blank when no turn had a server count or the window is unknown; `ctx_pct` in `--format json`). The web's session cards show it quietly too (`12%`), and a session's ctx meter shows it on load, before its next turn streams.
|
|
52
56
|
|
|
53
57
|
**Projects:** a session belongs to the git project it was started in: the repository, whichever worktree or subfolder of it (the same project root memories use). `chi sessions list` (with `--live` and `--format` too) and `chi web` show the current folder's project; `--scope=all` shows every session, and so does a folder in no repo (`~`). `--cwd PATH` is a folder filter instead of the project. The project is stored with the session (`project_root` in its JSON, `project` in `--format json`), so a session keeps it after its worktree is deleted; sessions older than that are placed by their folder, and one whose folder is gone shows in `--scope=all` only. Retention, `--resume`/`--attach ID`, `chi send`/`chi note ID` and `chi note --all` (every live session) are not scoped. The agent's `list_sessions` lists its own project's sessions; `cwd: "/"` lists every one.
|
|
54
58
|
|
|
55
|
-
`--live`, `--cwd` and `--format` make `list` a picker for scripts (`SessionManager.session_summaries`): `--live` keeps the sessions a worker runs now (the owner lock, not the saved status; a session open in a plain REPL is left out), `--cwd PATH` those in PATH or below, and test runs are left out. `--live` shows 10 unless `--limit` says otherwise; filters apply before the limit. `--format json` prints `[{id, short_id, desc, cwd, updated_at, live, busy, owner, recap}]` (`owner`: `"worker"`, `"tui"` for a plain REPL, which takes no notes or messages, or null; `recap`: the first sentence of the session's recap, or null), `--format tsv` one `id<TAB>desc` line per session, where `desc` is `<folder> · <last prompt>` cut to 60 characters (the text form of `--live`/`--cwd` shows `<folder> · <recap>` when there is one; tsv and json keep `desc`).
|
|
59
|
+
`--live`, `--cwd` and `--format` make `list` a picker for scripts (`SessionManager.session_summaries`): `--live` keeps the sessions a worker runs now (the owner lock, not the saved status; a session open in a plain REPL is left out), `--cwd PATH` those in PATH or below, and test runs are left out. `--live` shows 10 unless `--limit` says otherwise; filters apply before the limit. `--format json` prints `[{id, short_id, desc, cwd, project, updated_at, live, busy, owner, recap, parent_id, archived, scratch, ctx_pct}]` (`owner`: `"worker"`, `"tui"` for a plain REPL, which takes no notes or messages, or null; `recap`: the first sentence of the session's recap, or null), `--format tsv` one `id<TAB>desc` line per session, where `desc` is `<folder> · <last prompt>` cut to 60 characters (the text form of `--live`/`--cwd` shows `<folder> · <recap>` when there is one; tsv and json keep `desc`).
|
|
60
|
+
|
|
61
|
+
**Ordering:**
|
|
62
|
+
|
|
63
|
+
- `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.
|
|
64
|
+
- 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; "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). 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). The info bar copies `chi --attach <id>` for a terminal. The start page's model picker (bottom left of the composer, `host:model ▾`) chooses the model a new chat starts on: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id}], warning?}`; 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). 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 command line (`/model …`) still starts the session as its `prompt`. The browser remembers the last choice; a running session's model is in the info bar and changes only with `/model`.
|
|
65
|
+
- 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).
|
|
66
|
+
- 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), `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`). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
|
|
67
|
+
- 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).
|
|
68
|
+
|
|
69
|
+
**Test-session hygiene:**
|
|
70
|
+
|
|
71
|
+
- New sessions set `test_run:true` when `SAMAGOTCHI_ENV=test` or `RACK_ENV=test` or `CI` is set (explicit flag, `metadata_version` 2). Old sessions without the flag load as `test_run:false`.
|
|
72
|
+
- Test runs are tagged and obey the same retention. `chi sessions clean` deletes every test session whatever its age (`--days N`: only those older than N days); a live worker or a `keep_status` status still keeps one. `chi sessions prune --test-only` applies the usual age and count rules to test sessions only.
|
|
73
|
+
- A `chi scratch` session (`"scratch": true`) is deleted when its REPL ends; one a killed process left behind is deleted by the next sweep, `prune` or `clean`, whatever its age or status, once nobody owns it. `list` shows it as `[scratch]` (every text form; `scratch: true` in `--format json`); `chi web` never shows one. See [CLI: Scratch sessions](cli.md#scratch-sessions).
|
|
74
|
+
- For ad-hoc manual QA use `SAMAGOTCHI_ENV=test XDG_STATE_HOME=/tmp/chi-test-$USER chi ...` to isolate from real state; the flag also marks sessions that `chi web` or an attached `chi` spawn (their workers inherit the environment), so `clean` finds them if they land in the real state.
|
|
56
75
|
|
|
57
76
|
## Context notes
|
|
58
77
|
|
|
@@ -116,8 +135,9 @@ cancel during its thinking, it said it had never been asked.
|
|
|
116
135
|
`chi send` is the other half of `chi note`: the text goes in as your message, the same as typing it in the attached terminal or the web composer, so a turn runs.
|
|
117
136
|
|
|
118
137
|
```sh
|
|
119
|
-
chi send [-m TEXT] (ID|PREFIX)...
|
|
138
|
+
chi send [-m TEXT] [--image PATH]... (ID|PREFIX)...
|
|
120
139
|
chi send -m "is this the same bug?" 3fa2 # a message
|
|
140
|
+
chi send --image shot.png -m "why is this red?" 3fa2 # with an image
|
|
121
141
|
pbpaste | chi send -m "is this the same bug?" 3fa2 # the clipboard quoted above the message
|
|
122
142
|
pbpaste | chi send 3fa2 # the clipboard is the message
|
|
123
143
|
```
|
|
@@ -126,10 +146,31 @@ pbpaste | chi send 3fa2 # the clipboard is the messa
|
|
|
126
146
|
- 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.
|
|
127
147
|
- 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.
|
|
128
148
|
- 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`).
|
|
129
|
-
-
|
|
149
|
+
- `--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 is a usage error (exit 2) and nothing is sent. 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: a text-only one fails the turn in the session (the line here still says `sent`). 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.
|
|
150
|
+
- One line per session: `sent`, `sent with 2 images`, `sent (the running turn picks it up)`, `sent (started its worker)`, `refused: …` or `failed: …`. Exit 0 when all were sent, 1 when any was refused, failed or not found, 2 for a usage error. There is no `--all`.
|
|
130
151
|
|
|
131
152
|
The Automator action above works for messages too: swap its last line for `pbpaste | "$chi" send -m "what do you make of this?" $(print -r -- "$picked" | cut -f1)`.
|
|
132
153
|
|
|
154
|
+
### Starting a session
|
|
155
|
+
|
|
156
|
+
`chi send --new` starts a new session with the message, the way the web start page does: a worker runs it, so it is in `chi web` and `chi sessions list` at once and streams live there; `chi --attach ID` joins it. `--wait` blocks until the answer and prints it, which makes it an agent's one-shot the user can watch (unlike `chi -p … --non-interactive`, which runs in-process and saves the session only at the end).
|
|
157
|
+
|
|
158
|
+
```sh
|
|
159
|
+
chi send --new -m "review the diff on feat/x" # prints "<id> started", returns at once
|
|
160
|
+
git diff | chi send --new -m "review this" # stdin is quoted context, as above
|
|
161
|
+
chi send --new --wait -m "review the diff on feat/x" # blocks; stdout is the answer
|
|
162
|
+
chi send --wait -m "and the tests?" 3fa2 # a follow-up in the same session, waits too
|
|
163
|
+
chi send --wait 3fa2 # sends nothing: waits for its next reply
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
- `--new` takes no ids (one new session per call). `--dir DIR` is its folder, and so its project (default the current one); `--model M` its model (default the configured one; the name isn't checked up front, a wrong one fails the worker's first turn).
|
|
167
|
+
- `--new` prints `<full id> started` on stdout. With `--wait` that line (and the `sent` line for an existing session) goes to stderr, so stdout is exactly the answer: the reply of the turn the message started, in full. It is the worker's `output/` file, the same one `delegate` reads; a turn that ends with only tool calls and no text has none.
|
|
168
|
+
- `--wait` takes one session: `--new` or one id. A session with a running turn is refused (`busy: a turn is running; wait or attach`, exit 1): the message would run after it, and its reply would come back as the answer.
|
|
169
|
+
- Exit codes with `--wait`: 0 answered; 3 the turn waits for an answer from you (a question or a guardrail approval), with the line `waiting for an answer: …; open it: chi --attach ID or the web`, and the session keeps waiting; 1 the turn ended without a reply (canceled, failed or empty), the worker failed or vanished, the session was stopped, or `--timeout S` passed; 130 on Ctrl-C, which leaves the turn running (`still running: chi --attach ID`). There is no default timeout.
|
|
170
|
+
- `--wait ID` with no message (no `-m`, nothing piped) sends nothing: it waits for the session's next reply and prints and exits as above. A running session is fine (that is the point), so after exit 3 or 130 an agent waits again this way while the user answers in the web or `chi --attach`; the question pending when it starts doesn't count again. An idle session with no worker waits until something wakes one. With `--new`, or two ids, it is a usage error (exit 2).
|
|
171
|
+
- The 16 KiB cap applies: `git diff | chi send --new …` on a big diff is refused; name the branch in the message instead and let the session read it.
|
|
172
|
+
- A session nobody attaches to stalls at its first guardrail ask until someone opens it; left alone it idle-exits after `session.idle_exit_minutes` like any worker. These are ordinary sessions: delete them like any other.
|
|
173
|
+
|
|
133
174
|
## Delegating
|
|
134
175
|
|
|
135
176
|
The `delegate` tool hands a task to a **child session**: an ordinary chi session in a worker of its own, started in the parent's `working_directory` with the task, verbatim, as its first user message, on the parent's model unless the call names one (`model:` takes a name or an alias, resolved as `--model` is; nothing checks the host serves it, so an unknown one fails the child's first turn). Children run in parallel and only their **final reply** comes back to the parent: the child's newest `output/<timestamp>.txt` file, which the worker writes at the end of each turn that produced visible text, cut head-and-tail like an `execute` result. Nothing of the child's trace enters the parent's context. Nothing is hidden: a child shows in `chi sessions list` (`↳ <parent>`), the web (a `↳` chip on its card, `delegated by` in its info bar, listed right after its parent in the all-sessions view) and the `list_sessions` tool (`child`; the parent shows as `parent` in the child's own list); the user can `chi --attach <child id>` and steer it mid-run, and `chi send` and `send_note` reach it like any session.
|
|
@@ -140,16 +181,3 @@ The `delegate` tool hands a task to a **child session**: an ordinary chi session
|
|
|
140
181
|
- Every child starts with the shipped `delegated` system memory preloaded (`preloaded_memory_names: ["system/delegated"]`; `--mute delegated` would hide it): put everything in the final reply, don't delegate further, don't write memories or change config, stay in the folder. Its system prompt also says `Delegated by session <parent id>`. The parent's own `--mute` list does not carry over.
|
|
141
182
|
- Limits: a child cannot `delegate` (depth 1; the tool refuses), and one session may have at most `session.max_children` (default `4`, env `SAMAGOTCHI_SESSION_MAX_CHILDREN`) children running at a time, counted from the session files (a finished or idle-exited child does not count; one whose worker is still starting does, for 15 s). A refused call creates no session. Nothing stops children with the parent: `chi sessions stop ID` does.
|
|
142
183
|
- The link is the session's `parent_id`, written before the worker starts, so a respawn keeps it; `GET /api/sessions` and `/api/sessions/:id` carry it, and `SessionManager.children_of` lists a parent's children.
|
|
143
|
-
|
|
144
|
-
**Ordering:**
|
|
145
|
-
|
|
146
|
-
- `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.
|
|
147
|
-
- 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; "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). 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). The info bar copies `chi --attach <id>` for a terminal. The start page's model picker (bottom left of the composer, `host:model ▾`) chooses the model a new chat starts on: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id}], warning?}`; 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). The browser remembers the last choice; a running session's model is in the info bar and changes only with `/model`.
|
|
148
|
-
- 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), `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`). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
|
|
149
|
-
- 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).
|
|
150
|
-
|
|
151
|
-
**Test-session hygiene:**
|
|
152
|
-
|
|
153
|
-
- New sessions set `test_run:true` when `SAMAGOTCHI_ENV=test` or `RACK_ENV=test` or `CI` is set (explicit flag, `metadata_version` 2). Old sessions without the flag load as `test_run:false`.
|
|
154
|
-
- Test runs are tagged and obey the same retention. `chi sessions clean` deletes every test session whatever its age (`--days N`: only those older than N days); a live worker or a `keep_status` status still keeps one. `chi sessions prune --test-only` applies the usual age and count rules to test sessions only.
|
|
155
|
-
- For ad-hoc manual QA use `SAMAGOTCHI_ENV=test XDG_STATE_HOME=/tmp/chi-test-$USER chi ...` to isolate from real state; the flag also marks sessions that `chi web` or an attached `chi` spawn (their workers inherit the environment), so `clean` finds them if they land in the real state.
|