samagotchi 0.152.0 → 0.153.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 +18 -1
- data/docs/bundle-authoring.md +1 -1
- data/docs/bundles.md +84 -0
- data/docs/cli.md +3 -3
- data/docs/configuration.md +19 -14
- data/docs/guardrails.md +7 -5
- data/docs/hooks.md +4 -3
- data/docs/internals/anthropic.md +3 -3
- data/docs/internals/attached-context.md +3 -0
- data/docs/internals/engine.md +9 -5
- data/docs/internals/participants.md +1 -1
- data/docs/internals/prompt-caching.md +7 -3
- data/docs/internals/web-frontend.md +20 -17
- data/docs/plugins.md +29 -15
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +1 -1
- data/docs/testing.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/bundles/web-search/manifest.yml +13 -0
- data/lib/samagotchi/bundles/web-search/plugin.rb +174 -0
- data/lib/samagotchi/engine.rb +10 -5
- data/lib/samagotchi/guardrails/review_boundary.rb +4 -3
- data/lib/samagotchi/llm_context_stale.rb +2 -2
- data/lib/samagotchi/message_parts.rb +3 -2
- data/lib/samagotchi/toolkit.rb +8 -5
- data/lib/samagotchi/toolkit_binding.rb +30 -5
- data/lib/samagotchi/toolkit_report_command.rb +9 -5
- data/lib/samagotchi/toolkit_suggest.rb +12 -6
- data/lib/samagotchi/tools/builtin_calls.rb +14 -10
- data/lib/samagotchi/tools/deferred_tools.rb +2 -2
- data/lib/samagotchi/tools/registry.rb +44 -20
- data/lib/samagotchi/tools/tool_index.rb +27 -21
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +61 -357
- data/lib/samagotchi/web/public/composer_mode.js +110 -0
- data/lib/samagotchi/web/public/owner_claim.js +81 -0
- data/lib/samagotchi/web/public/owner_verify.js +112 -0
- data/lib/samagotchi/web/public/session_fields.js +3 -3
- data/lib/samagotchi/web/public/strip_grip.js +3 -3
- data/lib/samagotchi/web/public/strip_hidden.js +65 -0
- data/lib/samagotchi/web/public/version_notices.js +132 -0
- data/lib/samagotchi/worker.rb +6 -0
- metadata +8 -1
data/docs/plugins.md
CHANGED
|
@@ -244,8 +244,13 @@ Web only: the terminals type the command (`/claude validate <text>`).
|
|
|
244
244
|
|
|
245
245
|
### `chi.tool(name, description, params:, schema:, label:, preview:, targets:, refs:) { |args, ctx| … }`
|
|
246
246
|
|
|
247
|
-
This adds a tool the model can call.
|
|
248
|
-
|
|
247
|
+
This adds a tool the model can call. A session's toolkit decides whether it
|
|
248
|
+
is declared, as for a built-in ([Toolkits](configuration.md#toolkits)): `full`
|
|
249
|
+
(and any kit on `base: full`) declares it, in the system prompt and in the
|
|
250
|
+
chat path's `tools:`, after chi's own tools; a kit that doesn't list it (`lean`,
|
|
251
|
+
`child`) leaves it **reachable**, found with `find_tools` as `chi/<name>` and
|
|
252
|
+
run by `call_tool` as the tool itself ([the chi group](#chitool_sourcecall-acts_as-label-wait-prefix));
|
|
253
|
+
`add:` declares it there, `remove:` takes it away.
|
|
249
254
|
|
|
250
255
|
- `name`: a–z first, then a–z, 0–9 and `_`, up to 48 characters.
|
|
251
256
|
- `params`: `{ name => property }`, where a property is a JSON Schema
|
|
@@ -462,6 +467,12 @@ end
|
|
|
462
467
|
tool raises `ArgumentError` at once, as `chi.tool` does, and nothing is
|
|
463
468
|
staged.
|
|
464
469
|
- Inside `register`, use `chi.tool`: `replace_tools` raises there.
|
|
470
|
+
- Whether the session needs `find_tools` and `call_tool` is set by the tools
|
|
471
|
+
registered when its worker started. A tool a set adds on a kit that
|
|
472
|
+
leaves it out is reachable when the kit declares them anyway (`lean` does,
|
|
473
|
+
for its left-out built-ins); on a kit that declares neither it stays out
|
|
474
|
+
of reach until the user declares it (`/toolkit add NAME`), with one warn
|
|
475
|
+
notice by your bundle's name.
|
|
465
476
|
|
|
466
477
|
### `chi.on(event, priority: 100) { |event, ctx| … }`
|
|
467
478
|
|
|
@@ -684,11 +695,12 @@ two tools in all, whatever the number of sources: `find_tools(query,
|
|
|
684
695
|
group?)` (a keyword search that answers each match's `inputSchema`) and
|
|
685
696
|
`call_tool(tool, args)` (calls one as `<group>/<tool>`; its row label is the
|
|
686
697
|
first source's `label`). The index has one group of its own, **`chi`**: the
|
|
687
|
-
built-ins the session's toolkit leaves out but
|
|
688
|
-
([toolkits](configuration.md#toolkits)), each with its
|
|
698
|
+
built-ins and plugin tools (`chi.tool`) the session's toolkit leaves out but
|
|
699
|
+
doesn't remove ([toolkits](configuration.md#toolkits)), each with its
|
|
700
|
+
registered schema; never `find_tools` and `call_tool` themselves.
|
|
689
701
|
The two tools are registered once, after the plugins load, and declared per
|
|
690
|
-
toolkit when it reaches a source group or has a reachable
|
|
691
|
-
with no source declares neither). Nothing a
|
|
702
|
+
toolkit when it reaches a source group or has a reachable tool, as the tools
|
|
703
|
+
stood then (`full` with no source declares neither). Nothing a
|
|
692
704
|
source publishes later changes the request's tools, only what a search
|
|
693
705
|
answers, so the prompt cache keeps: the search tool's description lists the
|
|
694
706
|
source groups as they were at load, and a `chi` line first, per toolkit.
|
|
@@ -703,24 +715,25 @@ session's toolkit. A source's
|
|
|
703
715
|
group named `chi`, or named like an earlier group, is left out of search,
|
|
704
716
|
lookup and the description, with one warning per name.
|
|
705
717
|
|
|
706
|
-
**A
|
|
718
|
+
**A chi tool through `call_tool` runs as that tool.** `call_tool(tool:
|
|
707
719
|
"chi/delegate", args: {…})` (or a bare `delegate` while it's reachable) is
|
|
708
720
|
turned into the `delegate` call itself before the guardrails, so rules on
|
|
709
721
|
`delegate`, approvals (keyed `delegate:…`, the same as a direct call's),
|
|
710
722
|
`before_tool_call`/`after_tool_call` hooks and the row see `delegate`, with
|
|
711
723
|
`call[:via] == "call_tool"`; the row reads "delegate (via call_tool)", live
|
|
712
|
-
and after a reload.
|
|
713
|
-
`
|
|
714
|
-
|
|
715
|
-
|
|
724
|
+
and after a reload. A plugin tool's the same: `chi/web_search` runs as
|
|
725
|
+
`web_search`, with its `label:`, `preview:` and `targets:`. `tool:
|
|
726
|
+
call_tool` rules see MCP calls and misses only. A `chi/` name the toolkit
|
|
727
|
+
removes or declares, or any chi tool once `call_tool` itself is removed, is
|
|
728
|
+
refused by its name before the guardrails (no hook, no card).
|
|
716
729
|
|
|
717
730
|
**On a Messages host the model calls them by name too.** With `api:
|
|
718
731
|
anthropic` (not Fireworks' dialect), every tool `call_tool` reaches goes in
|
|
719
732
|
the request's tools deferred (`defer_loading`: its name only, until
|
|
720
|
-
something loads it): a built-in by its name, a source's tool by its
|
|
733
|
+
something loads it): a built-in or plugin tool by its name, a source's tool by its
|
|
721
734
|
`acts_as` (`mcp_fs_read`; one with a refusal, an acts_as two tools share, or
|
|
722
735
|
a name another tool has stays out). A direct call of one runs whether
|
|
723
|
-
anything loaded it or not: a built-in as itself (as if declared), a
|
|
736
|
+
anything loaded it or not: a built-in or plugin tool as itself (as if declared), a
|
|
724
737
|
source's tool as the `call_tool` call it stands for, so its rules,
|
|
725
738
|
approvals, hooks and row are `call_tool`'s. Elsewhere such a call is
|
|
726
739
|
refused with the `call_tool` route, as before.
|
|
@@ -865,9 +878,9 @@ full label, `plugin.rb (bundle my-bundle)`.
|
|
|
865
878
|
| `ctx.http` | an HTTP client for calling services (`get`, `post_json`, `request`): see [Calling services](#calling-services-ctxhttp) |
|
|
866
879
|
| `ctx.sessions` | fork, send to, read, list and stop other sessions: see [Other sessions](#other-sessions-ctxsessions) |
|
|
867
880
|
| `ctx.toolkit` | the name of the session's toolkit (`full` for a session from before toolkits); nil with no session |
|
|
868
|
-
| `ctx.declared_tools` | the tool names the session's next turn declares (its toolkit resolved, with its own adds and removes): ask this, not the name, whether a tool is there |
|
|
881
|
+
| `ctx.declared_tools` | the tool names the session's next turn declares (its toolkit resolved, with its own adds and removes): ask this, not the name, whether a tool is there. Your bundle's own `chi.tool` tools are in it only on a kit that declares them (`full`, an `add:`); elsewhere they are reachable, not declared (the testing kit answers every tool, as on full, unless `bundle.set(declared_tools: […])`) |
|
|
869
882
|
| `ctx.use_toolkit(name)` | put the session on toolkit `name` (yours from `chi.toolkit`, or chi's): saved, applied at the next turn's start and announced (`toolkit> toolkit: NAME from the next turn's start`, the web's tools chip follows). Only before the session's first model reply and outside a turn (the declared tools start every request, and a plugin doesn't ask first); otherwise it returns false and changes nothing: tell the user to run `/toolkit NAME`. An unknown name raises ArgumentError. Needs chi 0.66.0 |
|
|
870
|
-
| `ctx.load_tools(names)` | load tools the session reaches but doesn't declare, so the model calls them by name: built-ins (`"delegate"`, `"chi/delegate"`), MCP tools (`"mcp_fs_read"`, `"fs/read"`). Returns the names it staged (the wire names); a declared tool, one loaded before, a removed or unknown one is skipped (an unknown one logged), so calling it every turn is fine. From a `before_turn` hook it loads for that turn; anywhere else (mid-turn too) from the next turn's start. Call it from a hook: before the session's first turn its toolkit isn't applied yet and nothing resolves. **A hint, not access**: the tools keep their guardrails and approvals, and only a host that takes tool changes (`api: anthropic`, not Fireworks) gets one, as a system message after the user's (`[tools loaded: delegate (your-bundle)]` in the session, a notice line live); elsewhere nothing is sent or saved, and the tools stay reachable with `call_tool`. A hook may switch the model after you call it, so the return value can't promise delivery. Raises ArgumentError for anything but an Array. Needs chi 0.99.0 |
|
|
883
|
+
| `ctx.load_tools(names)` | load tools the session reaches but doesn't declare, so the model calls them by name: built-ins and plugin tools (`"delegate"`, `"chi/delegate"`, `"chi/web_search"`), MCP tools (`"mcp_fs_read"`, `"fs/read"`). Returns the names it staged (the wire names); a declared tool, one loaded before, a removed or unknown one is skipped (an unknown one logged), so calling it every turn is fine. From a `before_turn` hook it loads for that turn; anywhere else (mid-turn too) from the next turn's start. Call it from a hook: before the session's first turn its toolkit isn't applied yet and nothing resolves. **A hint, not access**: the tools keep their guardrails and approvals, and only a host that takes tool changes (`api: anthropic`, not Fireworks) gets one, as a system message after the user's (`[tools loaded: delegate (your-bundle)]` in the session, a notice line live); elsewhere nothing is sent or saved, and the tools stay reachable with `call_tool`. A hook may switch the model after you call it, so the return value can't promise delivery. Raises ArgumentError for anything but an Array. Needs chi 0.99.0 |
|
|
871
884
|
| `ctx.context` | attach outside text to this session and list it: see [Attached context](#attached-context-ctxcontext) |
|
|
872
885
|
| `ctx.cache_confirm(line, changed:)` | inside a command: nil to apply a prompt-cache-restarting change now, or a confirm to return as `[text, confirm]`: see [Commands that ask](#commands-that-ask-ctxcache_confirm). Needs chi 0.132.0 |
|
|
873
886
|
|
|
@@ -1497,6 +1510,7 @@ bundle.judgments.runs("progress").last["asked"] # the runs as chi saves
|
|
|
1497
1510
|
bundle.sessions.on(:children) { |all:| [] } # ctx.sessions, scripted and recorded
|
|
1498
1511
|
bundle.http.stub(:post, "http://127.0.0.1:9000/classify", json: { label: "safe" }) # ctx.http, never the network
|
|
1499
1512
|
bundle.set(running: false) # the session's values: cwd:, messages:, cancelled: …
|
|
1513
|
+
bundle.set(declared_tools: %w[read]) # ctx.declared_tools (default: every tool, as on full)
|
|
1500
1514
|
bundle.set(cost: 2.5) # ctx.cost: reported USD, or { reported:, estimated:, side:, known: }
|
|
1501
1515
|
bundle.set(usage: { tool_calls: 40, active_ms: 600_000 }) # ctx.usage: { turns:, tool_calls:, tool_calls_by_tool:, steps:, active_ms:, tool_ms_by_tool: }
|
|
1502
1516
|
bundle.set(turn_author: "p:p_0123abcd") # a participant's turn: ctx.turn_author, and author: on every event
|
data/docs/releasing.md
CHANGED
|
@@ -13,7 +13,7 @@ can run the whole release; the user approves the notes before the tag and the
|
|
|
13
13
|
and `rake release:check` enforce it). The system bundle upgrades itself when
|
|
14
14
|
chi starts.
|
|
15
15
|
- **Every other shipped bundle** (btw, check-in, guardrails, known-names,
|
|
16
|
-
loop-guard, mcp, skills, slow-commands, agents-md, cost-cap, work-cap, source-links, github-pr, coordinator, claude-peer, codex-peer, ruby, model-notes, decision-card, and the profiles core and dev) has its own semver in its
|
|
16
|
+
loop-guard, mcp, skills, slow-commands, agents-md, cost-cap, work-cap, source-links, github-pr, coordinator, claude-peer, codex-peer, ruby, model-notes, decision-card, web-search, and the profiles core and dev) has its own semver in its
|
|
17
17
|
`manifest.yml` and, when it needs a newer chi, a `requires_chi:` line. They never upgrade by themselves: users run
|
|
18
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:`
|
data/docs/sessions.md
CHANGED
|
@@ -102,7 +102,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
|
|
|
102
102
|
- `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.
|
|
103
103
|
- 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 sessions start in that folder; the header chip says `<project> · all`, and `all` opens the same place as `?scope=all` (every session; a new session there starts in chi web's start folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the start folder's project). A bare `/` (the LAN link, the Home Screen app) is the start folder's project too: the folder chi web runs in, or `web.start_dir` / `chi web --start-dir DIR`; outside a repo it shows every session. A pushed notification opens its session in that session's own project (`/?dir=<its project>#/s/<id>`; `?scope=all` outside a repo). 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, and the grip on the chat's top edge — the same thin handle as the composer's, its twin — puts them away and brings them back: a drag up (or a click, or Enter/Space on it) hides them, a drag down (or a click) shows them again, and the choice is remembered. A hidden strip leaves its status in the top bar (`N sessions · ● 1 live · ● 1 waiting`, from the cards), and a click on that pill opens All sessions, without showing the strip first; there the pill shows whatever the strip's state, pressed, and a click on it goes back to the chat (as Esc does). In a window under 900 px wide, where the strip scrolls sideways, the grip stays under it, outside the scroller; a window under 700 px tall hides the strip by itself, so a question or approval card in the stage keeps room, and the grip shows it 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; just `▸ 2 delegates` while the rows are open: they carry the marks then) 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`; its header is the top bar's right end: a search over preview, id and status, the count, an `archived` toggle and Select (on a phone a row of its own under the bar). Esc or the pressed pill returns; where the sessions are stored is a faint line under the list. A card is its title on one line (an untitled session reads `Untitled` over its first message), a line under it (its recap, else its first message) and its meta row, with one mark per state and no status dot: a waiting card has its warn outline and its `question` (or `approval`…) chip, an errored one an `error` badge, a session a worker runs the `live` badge (not on a waiting card: waiting means a worker), and idle, completed and stopped ones are unmarked; a family's and a chain's rows the same. 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` (`3 / 14` while a search narrows it), 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 session. 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 plugin's refs on an answer (an `after_turn` hook's, see [Refs on the answer](hooks.md#refs-on-the-answer-display-only)) are paired with its links and text whatever the rows touched: they can link a file the session didn't touch, or an issue or a commit, and add their own actions to the ref's menu. A plugin tool's row carries the refs its `refs:` gives (see [Refs on a tool's row](plugins.md#refs-on-a-tools-row)): its title opens the first, and its `▾` menu adds the others as `<ref> · <action>` (the URL on hover); their URL actions work on a phone too. 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's id copies the full session id on click ("Session id copied"), the id every CLI command takes (`chi --attach ID`, `chi send ID`). 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 ⚙ chip, after the model, holds the session's thinking level, reasoning history and LLM context: `⚙` alone while the session follows the model for all three, else `⚙` and what the session set, in the accent colour (`⚙ think low · ctx stale,forget`; a phone shows ⚙ with a dot; the tooltip gives each value and its source). Its panel is the start page's ⚙ panel for this session: a Thinking row (`/thinking`), on a chat host a Reasoning row (the reasoning history, `/reasoning-history`), and a Context row with its Apply and budget fields (`/llm-context`); a tap runs that row's command at once in the worker (one reply in the conversation, as a typed one) and changes that field alone, the Context row's `default` resets the strategy, apply rule and budget (`/llm-context reset`), and a tap while a turn runs is queued for after it (the panel's note says so). `GET /api/sessions/:id`'s `session.thinking`, `session.reasoning_history` (null on a native host) and `session.llm_context` carry the values (the live worker's, else resolved from the session file), and a `command_ran` that changed them carries the new ones. After ⚙, the 🧰 chip names the session's toolkit: `🧰` alone on the configured default (`toolkit.default`, `session.toolkit_default`), `🧰 lean` on another (see [Toolkits](configuration.md#toolkits); `/toolkit` changes it). A `/thinking` or `/reasoning-history` change typed in the composer that restarts the prompt cache asks first: the reply bubble has an **Apply (restarts the cache)** button that sends its `--yes` line (the ⚙ panel 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)`); after a `/model` switch the info bar's tooltip adds a `per model:` row per run of turns on one model above those totals (`splash:qwen3-35b · 6 turns · 96.0k in / 9.0k out · ~$0.02`, a switch's prefill under its row: `└ switch prefill 58.0k uncached prompt ≈$0.01`; the timing's `model_segments`, replaced by each `generation_completed`'s; the session cards stay as they were), and right after the switch its ctx % is the new model's window's; 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 session starts on, and a fresh chain link's (see [Session chains](#session-chains); there a pick shows as a `/model` command bubble in the link); 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`; `toolkits`: `{default, suggest, kits: [{name, description?}]}`, the start page's 🧰 list: the configured `toolkit.default`, `suggest` `on` only when a new session's first message may get a suggested kit (`toolkit.suggest.mode: on`, a System 1 host, two described kits; else `audit` or `off`), and the built-in and config kits a new session can start on, without `review`, `child`, `undecided`, a kit `groups:` refuses or one that doesn't resolve (a base that is a bundle kit), the default always listed; left out when it can't be read), and `POST /api/sessions` takes `model` (blank means the default). Beside it, ⚙ opens the new session's rarely used settings (thinking and LLM context, following each `GET /api/models` row's `thinking` and `llm_context`, and the review files; see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)), and 🧰 its toolkit: **Auto** (with `toolkit.suggest.mode: on`: the first message may get a suggested kit, else the default) or a kit from the payload's `toolkits`, sent as `POST /api/sessions`'s `toolkit` (see [configuration.md](configuration.md#toolkits)); a refused kit (`400 toolkit_refused`) is said in the list, opened again. What is set shows on the row as an accent chip (`think high`, `tools: lean`; a dot on ⚙ or 🧰 on a phone); each is for the next new session only, never remembered. 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).
|
|
104
104
|
- 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).
|
|
105
|
-
- 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; the chip and rows as lit-html templates), `keep_focus.js` (lit reuses a list's nodes by position: a redraw keeps the focus on the same item by its `data-id` / `data-name`), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `#N` chip and rows as lit-html templates, the info bar's ← → links, a refused continue, and the fresh-link rule), `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), `bubbles.js` (the history's bubbles between the turns' rows: output chunks, end lines, every client's prompt bubbles by `enqueued_id` with their queued/steered badges, the idle recap, guardrail warnings, hooks' notices, plugins' steers, command and note bubbles; lit-html templates, put in the history with the sticky follow by `createBubbles`), `cards_place.js` (where plugin cards and a snapshot's notices go in the history: a card added or updated in place by its id, a running turn's into its step, a snapshot's each above the first turn after it, a finished turn's notice in its step's rows and its question resolved where it was asked; a card's action buttons), `format.js` (pure formatters such as `previewOf`), `plain_line.js` (markdown out of the plain one-line places: the stage headline, a closed step's title, an answered question card's kept-reply line), `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), `delivery_line.js` (the composer's row above the text field in a session, always there so the composer keeps its height: the key hint while idle, and the delivery line while a turn runs: where a message typed now goes in, from the step's phase, clock and size, whether "cut in now" can land, and the queue link for a worker with the `delivery` feature, as a pure `deliveryLine`, its lit-html template and `createDeliveryControl`, which ticks it and keeps a choice armed from a link; see docs/cli.md, "Typing during a turn"), `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), `stage_view.js` (the stage view: the running turn pinned above the composer, with Stop turn in its status row while it runs, then handed off into the history; its pure rules and texts in `stage_model.js`, its live slots (headline, now row, trail, ticks) in `stage_slots.js`, its cap and held height in `stage_frame.js`, the in-use watch that times the hand-off in `stage_watch.js`), `info_bar.js` (the session bar's info line as a lit-html template over a plain model, three groups with hairlines between them, on one line above a phone's width (the folder gives way first): the setup (id, status, model, the ⚙ and 🧰 chips, notes, the memory or working-set chip), the relations (chain links, the parent as `↑ <id>`, delegates, participants; only when there is one) and the telemetry (folder, ctx, speed, the session's time, worker badge)), `settings_chip.js` (the session bar's ⚙ chip and its panel over `settings_panel.js`: a tap runs that row's command, one field), `toolkit_chip.js` (the session bar's 🧰 chip: the kit against `toolkit.default`), `info_bar_model.js` (that model and the bar's buttons' states, built from the page's session state, its list and its in-flight continues and restarts as pure functions; `app.js`'s `updateInfoBar` applies them every second while a turn runs), `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), `ref_scan.js` (the `path:line` refs in an answer's or a step's text that name a file the session's rows touched), `answer_refs.js` (the refs a plugin attached to an answer, its `display_refs`: bound to the answer's bubble, paired with its links and text, bound with `bindRef`), `markdown_place.js` (where rendered markdown and its refs go: a text as markdown HTML, its `path:line` refs, a final answer's rendering), `images.js` (image chips in the composer: paste, drop and the image button, the upload before a turn, the thumbnails and the lightbox), `composer_grip.js` (the composer's grip and fold: the drag, tap and keys, the fold per visit), `composer_mount.js` (the composer mounted on a page's markup: the message box and its height, the image chips from a paste, a drop or the image button, the grip and fold, ↑/↓ over the prompt history and the keys; the page's own parts come in as hooks: what Enter and Send do, keys taken first, Esc, where the history comes from, the info bar's drop while folded, typing anywhere to open it; its styles, the card it sits in (`.composer-card`, its footer and its line above the text field) included, are the shared `composer.css`, linked after `tokens.css` by `index.html` and `participant.html`), `strip_grip.js` (the latest-sessions strip's grip, the composer grip's twin: hides and shows the strip), `review_history.js` (a participant's ↑/↓ list on the participant page: their own comments, in localStorage per grant, never the owner's `/api/history`; `participant.js` mounts `composer_mount.js` with it, no `/` list, no delivery choices, images off), `storage.js` (the page's per-browser choices in localStorage, guarded: a choice that can't be read or written is not remembered), `unqueue.js` (the × on a message queued for after the turn: takes it back, its text into an empty composer or a toast), `toasts.js` (the page's toast stack: a timer each, sticky ones until acted on, the same text reusing its item), `toast_place.js` (where the toast sits: above the composer card, the stage and an open model panel), `dock_reserve.js` (what floats over the history: the history's bottom reserve (`--dock-h`) at the composer card's and the showing stage's height, a reader at the end kept there, and the toast's place measured for `toast_place.js`), `notice_links.js` (a hook notice line's http(s) URLs as links), `start_page.js` (the start page's model picker, its ⚙ panel's and 🧰 list's choices for the next new session only and their override chips; a fresh chain link's picker and its `/model`), `start_settings.js` (the start page's ⚙ panel: thinking and LLM context as rows of segments, applied at once, the Apply and budget disclosure, the review files' view; the override chips' words and ⚙'s dot), `settings_panel.js` (the panel's rows as lit-html templates, shared: the segments, the disclosure, a hidden row's sent-anyway line, the review line; the caller passes its rows and applies the picks), `start_toolkit.js` (the start page's 🧰 list: Auto with suggest on, the kits, the default marked; a pick closes it; its override chip and dot), `session_actions.js` (Continue, Archive and its Undo, Delete and Stop, and the start page when the open session is gone), `send_turn.js` (the composer's send: a new session first on the start page, the route, the echo, the image upload, the `/turn` ack, a fast failure's restore and each error's place). Unit-tested via `npm test` (`spec/web/public/*.test.js`).
|
|
105
|
+
- 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; the chip and rows as lit-html templates), `keep_focus.js` (lit reuses a list's nodes by position: a redraw keeps the focus on the same item by its `data-id` / `data-name`), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `#N` chip and rows as lit-html templates, the info bar's ← → links, a refused continue, and the fresh-link rule), `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), `bubbles.js` (the history's bubbles between the turns' rows: output chunks, end lines, every client's prompt bubbles by `enqueued_id` with their queued/steered badges, the idle recap, guardrail warnings, hooks' notices, plugins' steers, command and note bubbles; lit-html templates, put in the history with the sticky follow by `createBubbles`), `cards_place.js` (where plugin cards and a snapshot's notices go in the history: a card added or updated in place by its id, a running turn's into its step, a snapshot's each above the first turn after it, a finished turn's notice in its step's rows and its question resolved where it was asked; a card's action buttons), `format.js` (pure formatters such as `previewOf`), `plain_line.js` (markdown out of the plain one-line places: the stage headline, a closed step's title, an answered question card's kept-reply line), `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), `delivery_line.js` (the composer's row above the text field in a session, always there so the composer keeps its height: the key hint while idle, and the delivery line while a turn runs: where a message typed now goes in, from the step's phase, clock and size, whether "cut in now" can land, and the queue link for a worker with the `delivery` feature, as a pure `deliveryLine`, its lit-html template and `createDeliveryControl`, which ticks it and keeps a choice armed from a link; see docs/cli.md, "Typing during a turn"), `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), `stage_view.js` (the stage view: the running turn pinned above the composer, with Stop turn in its status row while it runs, then handed off into the history; its pure rules and texts in `stage_model.js`, its live slots (headline, now row, trail, ticks) in `stage_slots.js`, its cap and held height in `stage_frame.js`, the in-use watch that times the hand-off in `stage_watch.js`), `info_bar.js` (the session bar's info line as a lit-html template over a plain model, three groups with hairlines between them, on one line above a phone's width (the folder gives way first): the setup (id, status, model, the ⚙ and 🧰 chips, notes, the memory or working-set chip), the relations (chain links, the parent as `↑ <id>`, delegates, participants; only when there is one) and the telemetry (folder, ctx, speed, the session's time, worker badge)), `settings_chip.js` (the session bar's ⚙ chip and its panel over `settings_panel.js`: a tap runs that row's command, one field), `toolkit_chip.js` (the session bar's 🧰 chip: the kit against `toolkit.default`), `info_bar_model.js` (that model and the bar's buttons' states, built from the page's session state, its list and its in-flight continues and restarts (the restarting set is `version_notices.js`'s) as pure functions; `app.js`'s `updateInfoBar` applies them every second while a turn runs), `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), `ref_scan.js` (the `path:line` refs in an answer's or a step's text that name a file the session's rows touched), `answer_refs.js` (the refs a plugin attached to an answer, its `display_refs`: bound to the answer's bubble, paired with its links and text, bound with `bindRef`), `markdown_place.js` (where rendered markdown and its refs go: a text as markdown HTML, its `path:line` refs, a final answer's rendering), `images.js` (image chips in the composer: paste, drop and the image button, the upload before a turn, the thumbnails and the lightbox), `composer_grip.js` (the composer's grip and fold: the drag, tap and keys, the fold per visit), `composer_mount.js` (the composer mounted on a page's markup: the message box and its height, the image chips from a paste, a drop or the image button, the grip and fold, ↑/↓ over the prompt history and the keys; the page's own parts come in as hooks: what Enter and Send do, keys taken first, Esc, where the history comes from, the info bar's drop while folded, typing anywhere to open it; its styles, the card it sits in (`.composer-card`, its footer and its line above the text field) included, are the shared `composer.css`, linked after `tokens.css` by `index.html` and `participant.html`), `strip_grip.js` (the latest-sessions strip's grip, the composer grip's twin: hides and shows the strip), `strip_hidden.js` (the strip hidden or shown: the saved choice, the short window's own hide, the grip and the body's class), `review_history.js` (a participant's ↑/↓ list on the participant page: their own comments, in localStorage per grant, never the owner's `/api/history`; `participant.js` mounts `composer_mount.js` with it, no `/` list, no delivery choices, images off), `storage.js` (the page's per-browser choices in localStorage, guarded: a choice that can't be read or written is not remembered), `unqueue.js` (the × on a message queued for after the turn: takes it back, its text into an empty composer or a toast), `toasts.js` (the page's toast stack: a timer each, sticky ones until acted on, the same text reusing its item), `toast_place.js` (where the toast sits: above the composer card, the stage and an open model panel), `dock_reserve.js` (what floats over the history: the history's bottom reserve (`--dock-h`) at the composer card's and the showing stage's height, a reader at the end kept there, and the toast's place measured for `toast_place.js`), `notice_links.js` (a hook notice line's http(s) URLs as links), `start_page.js` (the start page's model picker, its ⚙ panel's and 🧰 list's choices for the next new session only and their override chips; a fresh chain link's picker and its `/model`), `start_settings.js` (the start page's ⚙ panel: thinking and LLM context as rows of segments, applied at once, the Apply and budget disclosure, the review files' view; the override chips' words and ⚙'s dot), `settings_panel.js` (the panel's rows as lit-html templates, shared: the segments, the disclosure, a hidden row's sent-anyway line, the review line; the caller passes its rows and applies the picks), `start_toolkit.js` (the start page's 🧰 list: Auto with suggest on, the kits, the default marked; a pick closes it; its override chip and dot), `session_actions.js` (Continue, Archive and its Undo, Delete and Stop, and the start page when the open session is gone), `send_turn.js` (the composer's send: a new session first on the start page, the route, the echo, the image upload, the `/turn` ack, a fast failure's restore and each error's place). Unit-tested via `npm test` (`spec/web/public/*.test.js`).
|
|
106
106
|
- 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).
|
|
107
107
|
- The web session page's message box folds away for room to read. Drag the grip on its top edge (the pill that shows on hover; always on a phone) down, past the box by 32 px: the pill turns accent while releasing would hide it, and on release the box folds and the info bar stays as the page's bottom edge. The grip stays as a handle on the info bar's top edge, drawn always: pull it up (8 px opens it, and the box follows the pointer in the same gesture), click or tap it (opens it and focuses the box), or just type on the page (the key lands in the box) to bring it back; Enter or Space on the focused grip folds or opens too. A draft (text or images, which folding never clears) colours the handle with the accent, a session waiting on you with the attention colour; its tooltip says so. Stop turn isn't the message box's: it sits in the running turn's stage, at the right edge of its status row beside the fold caret (collapsed stage or not), while the turn runs, and goes when the turn ends A turn the page knows runs but has nothing of to draw (a reload whose session payload carries no current turn) shows that status row alone, `running` and Stop turn. The fold is remembered per browser (`chi_composer_collapsed`), an archived session opens folded for the visit, and opening it there, like the page opening it itself (a restored line, a quote, continue →'s opener), is for that visit only. The fold keeps the height the grip set (`chi_composer_floor`): the box opens again at the height it had, and only a double-click on the grip forgets it. That height never takes more than half of the window the box is drawn in: a floor saved in a bigger window is held to that half in a smaller one (a rotated phone, a resized window, another device on the same origin), and the saved floor itself stays, so the height comes back when the room does. A click on the expanded grip hides the box as well: it folds 250 ms after the press, so a second click inside that window is read as a double-click and resets the height instead (a drag, Enter or Space act at once, and a drag after a click takes the wait back). The start page's grip only resizes.
|
|
108
108
|
|
data/docs/testing.md
CHANGED
|
@@ -27,7 +27,7 @@ It also checks `spec/e2e/` for two things: `no-unused-vars`, and the Playwright
|
|
|
27
27
|
Wait for the state (a locator's `expect`, `data-phase`, an animation's end through `waitForFunction`); time the page
|
|
28
28
|
keeps (a pointer's dwell, a hold) runs on Playwright's `page.clock` (`install`, then `pauseAt` and `runFor`: the
|
|
29
29
|
stage's hand-off dwell in stage.spec.js); a pause that is the behaviour outside the page (a slow post held in a
|
|
30
|
-
route) carries `// eslint-disable-line no-restricted-syntax -- why`. And in the
|
|
30
|
+
route) carries `// eslint-disable-line no-restricted-syntax -- why`. And in the seven
|
|
31
31
|
modules that hold the owner page's session state, one more: no write into `current`, `store.current` or `state()`
|
|
32
32
|
in place (`samagotchi/session-writes`; the store is the write path, see internals/web-frontend.md, The session store).
|
|
33
33
|
|
|
@@ -56,7 +56,7 @@ exception's text changes between Ruby versions: write the string yourself).
|
|
|
56
56
|
The web specs run in plain node. One that loads lit-html, itself or through a module it imports (`turn_block.js`,
|
|
57
57
|
`turn_html.js`, `card.js`, `copy.js`, `images.js`, `scope.js`, `command_view.js`, `diff_view.js`, `read_view.js`,
|
|
58
58
|
`question_cards.js`, `stream_handlers.js`, `stage_view.js`, `bubbles.js`, `cards_place.js`, the chips, `info_bar.js`, `info_bar_model.js`, `lists_view.js`, `context_chips.js`, `families_view.js`,
|
|
59
|
-
`chains.js`, `working_set_view.js`, `working_set_rail.js`, `unqueue.js`, `toasts.js`, `notice_links.js`, `start_page.js`, `start_settings.js`, `settings_panel.js`, `start_toolkit.js`, `session_actions.js`, `send_turn.js`, `composer_mount.js`, `dock_reserve.js`,
|
|
59
|
+
`chains.js`, `working_set_view.js`, `working_set_rail.js`, `unqueue.js`, `toasts.js`, `notice_links.js`, `start_page.js`, `start_settings.js`, `settings_panel.js`, `start_toolkit.js`, `session_actions.js`, `send_turn.js`, `composer_mount.js`, `dock_reserve.js`, `version_notices.js`, `owner_claim.js`, `owner_verify.js`,
|
|
60
60
|
`vendor/lit-html.js`) needs a DOM: `import "./dom_shim.js";` as its first import (happy-dom, a devDependency only,
|
|
61
61
|
never shipped; lit reads `document` as it loads). The shim's `markupOf(template)`,
|
|
62
62
|
`htmlOf(nodes)` and `canon(html)` compare markup without lit's marker comments; the DOM writes text with only `&`, `<`, `>`
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: web-search
|
|
3
|
+
version: 0.1.0
|
|
4
|
+
scope: system
|
|
5
|
+
description: "web_search: the model searches the web through your SearXNG (its JSON API) and gets titles, URLs and snippets to read with web_fetch"
|
|
6
|
+
trust_level: reviewed
|
|
7
|
+
plugin:
|
|
8
|
+
file: plugin.rb
|
|
9
|
+
sha256: sha256:ff79881a370a10dfd1762b5fecb02833dd30c8c5e1f8578e3f1f1a214d6f78a5
|
|
10
|
+
# settings: is new in chi 0.143.0 (ctx.http in 0.80.0)
|
|
11
|
+
requires_chi: ">= 0.143.0"
|
|
12
|
+
# config.yml bundles: web-search: (another key warns; an older chi ignores this)
|
|
13
|
+
settings: {url: string, max_results: number, timeout: number, language: string, safesearch: number, categories: string, engines: string}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The web-search bundle (docs/bundles.md, The web-search bundle): one tool,
|
|
4
|
+
# web_search, that asks a SearXNG instance through its JSON API and returns
|
|
5
|
+
# a short list of results (title, URL, snippet). The model reads the ones it
|
|
6
|
+
# wants with web_fetch. The query goes to that SearXNG and on to the engines
|
|
7
|
+
# it is configured with.
|
|
8
|
+
#
|
|
9
|
+
# Settings (config.yml, bundles: web-search:), read at each call from
|
|
10
|
+
# ctx.settings, never at load:
|
|
11
|
+
# url: http://127.0.0.1:8888 the SearXNG root (http/https with a host)
|
|
12
|
+
# max_results: 8 results listed, 1..20
|
|
13
|
+
# timeout: 10 seconds, ctx.http's per-read timeout
|
|
14
|
+
# language: en optional, sent only when set
|
|
15
|
+
# safesearch: 0 optional, 0/1/2
|
|
16
|
+
# categories: general optional, comma-separated
|
|
17
|
+
# engines: duckduckgo,brave optional, comma-separated (SearXNG drops
|
|
18
|
+
# names it doesn't know)
|
|
19
|
+
#
|
|
20
|
+
# SearXNG needs `json` in search.formats (else it answers 403) and, for a
|
|
21
|
+
# local instance, server.limiter: false (else it may answer 429).
|
|
22
|
+
require "json"
|
|
23
|
+
require "uri"
|
|
24
|
+
|
|
25
|
+
class Plugin
|
|
26
|
+
DEFAULT_URL = "http://127.0.0.1:8888"
|
|
27
|
+
DEFAULT_MAX = 8
|
|
28
|
+
MAX_RESULTS = 20
|
|
29
|
+
DEFAULT_TIMEOUT = 10
|
|
30
|
+
SNIPPET_CHARS = 300
|
|
31
|
+
TIME_RANGES = %w[day week month year].freeze
|
|
32
|
+
# Optional settings sent as query parameters, in this order, only when set.
|
|
33
|
+
OPTIONAL = %w[language safesearch categories engines].freeze
|
|
34
|
+
|
|
35
|
+
DESCRIPTION = "Search the web (through the user's SearXNG) and return a numbered list of results: title, URL and " \
|
|
36
|
+
"a short snippet. Snippets are partial and may be stale: when the details matter, read a result's " \
|
|
37
|
+
"URL with web_fetch before answering instead of answering from snippets alone. Use page for more " \
|
|
38
|
+
"results and time_range for recent ones."
|
|
39
|
+
|
|
40
|
+
# A setting that can't be used: the tool's answer, before any request.
|
|
41
|
+
class SettingError < StandardError; end
|
|
42
|
+
|
|
43
|
+
def initialize(_settings); end
|
|
44
|
+
|
|
45
|
+
def register(chi)
|
|
46
|
+
chi.tool "web_search", DESCRIPTION,
|
|
47
|
+
params: {
|
|
48
|
+
query: { type: "string", description: "The search terms", required: true },
|
|
49
|
+
time_range: { type: "string", enum: TIME_RANGES, description: "Only results from the last day, week, month or year" },
|
|
50
|
+
page: { type: "integer", description: "The results page, 1 for the first (the default)" }
|
|
51
|
+
},
|
|
52
|
+
label: "searching", preview: ->(args) { args["query"].to_s } do |args, ctx|
|
|
53
|
+
search(args, ctx)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def search(args, ctx)
|
|
60
|
+
query = args["query"].to_s.strip
|
|
61
|
+
return "Error: query is required" if query.empty?
|
|
62
|
+
|
|
63
|
+
settings = ctx.settings
|
|
64
|
+
root = root_url(settings["url"])
|
|
65
|
+
page = page_of(args["page"])
|
|
66
|
+
res = ctx.http.get("#{root}/search?#{URI.encode_www_form(pairs(query, page, args["time_range"], settings))}",
|
|
67
|
+
timeout: timeout_of(settings["timeout"]))
|
|
68
|
+
answer(res, query, page, max_of(settings["max_results"]))
|
|
69
|
+
rescue SettingError => e
|
|
70
|
+
"Error: #{e.message}"
|
|
71
|
+
rescue Samagotchi::Plugin::HttpCancelled
|
|
72
|
+
"Error: search cancelled"
|
|
73
|
+
rescue Samagotchi::Plugin::HttpError => e
|
|
74
|
+
"Error: SearXNG is not reachable: #{e.message}. Is it running? (bundles: web-search: url)"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def answer(res, query, page, max)
|
|
78
|
+
return status_error(res.status) unless res.ok?
|
|
79
|
+
|
|
80
|
+
data = begin
|
|
81
|
+
JSON.parse(res.body.scrub)
|
|
82
|
+
rescue JSON::ParserError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
return "Error: SearXNG's answer isn't JSON (is url the SearXNG root?)" unless data.is_a?(Hash)
|
|
86
|
+
|
|
87
|
+
listing(data, query, page, max)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def status_error(status)
|
|
91
|
+
case status
|
|
92
|
+
when 403 then "Error: SearXNG refused the JSON format (403): add json to search.formats in its settings.yml"
|
|
93
|
+
when 429 then "Error: SearXNG rate-limited the request (429): turn its limiter off for a local instance (server.limiter: false)"
|
|
94
|
+
else "Error: SearXNG answered #{status}"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def listing(data, query, page, max)
|
|
99
|
+
results = Array(data["results"]).select { |entry| entry.is_a?(Hash) && !squash(entry["url"]).empty? }.first(max)
|
|
100
|
+
if results.empty?
|
|
101
|
+
failed = Array(data["unresponsive_engines"]).map { |pair| squash(Array(pair).first) }.reject(&:empty?)
|
|
102
|
+
return [%(No results for "#{query}".), (failed.empty? ? nil : "Engines that failed: #{failed.join(", ")}")].compact.join("\n")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
lines = [%(SearXNG results for "#{query}" (page #{page}):)]
|
|
106
|
+
if (first = first_answer(data["answers"]))
|
|
107
|
+
lines << "Answer: #{first}"
|
|
108
|
+
end
|
|
109
|
+
results.each_with_index { |entry, index| lines.concat(result_lines(entry, index + 1)) }
|
|
110
|
+
suggestions = Array(data["suggestions"]).map { |text| squash(text) }.reject(&:empty?)
|
|
111
|
+
lines << "Suggestions: #{suggestions.join(", ")}" unless suggestions.empty?
|
|
112
|
+
lines.join("\n")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def result_lines(entry, number)
|
|
116
|
+
url = squash(entry["url"])
|
|
117
|
+
title = squash(entry["title"])
|
|
118
|
+
snippet = cut(squash(entry["content"]))
|
|
119
|
+
["#{number}. #{title.empty? ? url : title}", " #{url}", (snippet.empty? ? nil : " #{snippet}")].compact
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# answers: Strings (older SearXNG) or Hashes with the text under "answer".
|
|
123
|
+
def first_answer(answers)
|
|
124
|
+
Array(answers).each do |item|
|
|
125
|
+
text = cut(squash(item.is_a?(Hash) ? item["answer"] : item))
|
|
126
|
+
return text unless text.empty?
|
|
127
|
+
end
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def pairs(query, page, time_range, settings)
|
|
132
|
+
list = [["q", query], %w[format json], ["pageno", page.to_s]]
|
|
133
|
+
list << ["time_range", time_range.to_s] if TIME_RANGES.include?(time_range.to_s)
|
|
134
|
+
OPTIONAL.each do |key|
|
|
135
|
+
value = settings[key]
|
|
136
|
+
list << [key, value.to_s] unless value.nil? || value.to_s.strip.empty?
|
|
137
|
+
end
|
|
138
|
+
list
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def root_url(value)
|
|
142
|
+
url = (value.nil? ? DEFAULT_URL : value.to_s).strip
|
|
143
|
+
uri = URI.parse(url)
|
|
144
|
+
raise SettingError, "bundles: web-search: url must be an http(s) URL" unless %w[http https].include?(uri.scheme) && !uri.host.to_s.empty?
|
|
145
|
+
|
|
146
|
+
url.chomp("/")
|
|
147
|
+
rescue URI::InvalidURIError
|
|
148
|
+
raise SettingError, "bundles: web-search: url must be an http(s) URL"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def page_of(value)
|
|
152
|
+
page = begin
|
|
153
|
+
Integer(value)
|
|
154
|
+
rescue ArgumentError, TypeError, FloatDomainError
|
|
155
|
+
1
|
|
156
|
+
end
|
|
157
|
+
[page, 1].max
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def timeout_of(value) = value.is_a?(Numeric) && value.positive? ? value : DEFAULT_TIMEOUT
|
|
161
|
+
|
|
162
|
+
def max_of(value)
|
|
163
|
+
max = begin
|
|
164
|
+
Integer(value)
|
|
165
|
+
rescue ArgumentError, TypeError, FloatDomainError
|
|
166
|
+
DEFAULT_MAX
|
|
167
|
+
end
|
|
168
|
+
max.clamp(1, MAX_RESULTS)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def squash(value) = value.to_s.scrub.gsub(/\s+/, " ").strip
|
|
172
|
+
|
|
173
|
+
def cut(text) = text.length > SNIPPET_CHARS ? "#{text[0, SNIPPET_CHARS - 1]}…" : text
|
|
174
|
+
end
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -345,7 +345,7 @@ module Samagotchi
|
|
|
345
345
|
@prompt_sections = Plugin::PromptSections.new
|
|
346
346
|
load_plugins if load
|
|
347
347
|
# Registered from what the sources published while they loaded, and
|
|
348
|
-
# with no plugins too: a toolkit's left-out
|
|
348
|
+
# with no plugins too: a toolkit's left-out tools are reachable
|
|
349
349
|
# either way (declared per toolkit, Tools::Registry).
|
|
350
350
|
@tool_index.declare(@tools, failures: @plugin_failures)
|
|
351
351
|
end
|
|
@@ -3099,10 +3099,14 @@ module Samagotchi
|
|
|
3099
3099
|
private :stage_tools
|
|
3100
3100
|
|
|
3101
3101
|
# Apply the staged tool sets, on the turn thread, before the turn's
|
|
3102
|
-
# system prompt is built (PluginTasks#apply_staged_tools!).
|
|
3102
|
+
# system prompt is built (PluginTasks#apply_staged_tools!). A changed
|
|
3103
|
+
# set may change the chi group the search tool's description counts
|
|
3104
|
+
# (a plugin tool the toolkit leaves out).
|
|
3103
3105
|
# @return [Boolean] whether the tools changed
|
|
3104
3106
|
def apply_staged_tools!
|
|
3105
|
-
@plugin_tasks.apply_staged_tools!
|
|
3107
|
+
changed = @plugin_tasks.apply_staged_tools!
|
|
3108
|
+
@tool_index.refresh! if changed
|
|
3109
|
+
changed
|
|
3106
3110
|
end
|
|
3107
3111
|
public :apply_staged_tools!
|
|
3108
3112
|
|
|
@@ -3231,14 +3235,15 @@ module Samagotchi
|
|
|
3231
3235
|
end.freeze
|
|
3232
3236
|
default_snapshot = ToolkitSuggest::Candidate.new(name: default.name.dup.freeze, description: nil,
|
|
3233
3237
|
rules: ToolkitSuggest.rules(default.resolve(kits)))
|
|
3234
|
-
|
|
3238
|
+
registered = @tools.entries(toolkit: nil).reject { |entry| entry.source == Tools::ToolIndex::SOURCE }
|
|
3239
|
+
core_tools, plugin_tools = registered.partition(&:core?).map { |entries| entries.map { |entry| entry.name.dup.freeze }.freeze }
|
|
3235
3240
|
state = ToolkitSuggest.question(
|
|
3236
3241
|
default: default, kits: kits, project: File.basename(session.project_root.to_s),
|
|
3237
3242
|
prompt: turn.prompt
|
|
3238
3243
|
).last
|
|
3239
3244
|
snapshot = ToolkitSuggest::Snapshot.new(
|
|
3240
3245
|
session_id: session.id.dup.freeze, turn_number: ToolkitSuggest.turn_number(session.messages),
|
|
3241
|
-
default: default_snapshot, candidates: snapshots, core_tools: core_tools,
|
|
3246
|
+
default: default_snapshot, candidates: snapshots, core_tools: core_tools, plugin_tools: plugin_tools,
|
|
3242
3247
|
state: { project: state[:project].dup.freeze, prompt: state[:prompt].dup.freeze }.freeze
|
|
3243
3248
|
)
|
|
3244
3249
|
threshold = Config.get("toolkit.suggest.threshold")
|
|
@@ -22,8 +22,9 @@ module Samagotchi
|
|
|
22
22
|
# resolves (a symlink in the project to ~/.ssh is ~/.ssh);
|
|
23
23
|
# memory_read: the project scope only, named (no fallback to system);
|
|
24
24
|
# anything else, call_tool's real target included: a call_tool of a
|
|
25
|
-
# built-in reaches the gate as
|
|
26
|
-
# so what is left of call_tool is
|
|
25
|
+
# chi tool (a built-in or a plugin's chi/<name>) reaches the gate as
|
|
26
|
+
# that tool (ToolRunner#unwrapped), so what is left of call_tool is
|
|
27
|
+
# an MCP tool, denied in v1.
|
|
27
28
|
# The declared tools stay the session's (no prompt rebuild per author),
|
|
28
29
|
# so a participant turn's model may still try a write: it is denied
|
|
29
30
|
# here, with the header's "This turn may only read" to keep it rare.
|
|
@@ -82,7 +83,7 @@ module Samagotchi
|
|
|
82
83
|
end
|
|
83
84
|
end
|
|
84
85
|
|
|
85
|
-
#
|
|
86
|
+
# An MCP tool behind call_tool names what it acts as.
|
|
86
87
|
def tool_label(tool, targets)
|
|
87
88
|
acts_as = targets&.acts_as
|
|
88
89
|
acts_as && acts_as != tool ? "#{tool} (#{acts_as})" : tool
|
|
@@ -197,8 +197,8 @@ module Samagotchi
|
|
|
197
197
|
return texts ? [[refs.first, call, texts.first.text]] : []
|
|
198
198
|
end
|
|
199
199
|
|
|
200
|
-
# A call_tool of a built-in ran as it: its
|
|
201
|
-
#
|
|
200
|
+
# A call_tool of a chi tool (a built-in or a plugin's) ran as it: its
|
|
201
|
+
# run's lead names the tool (BuiltinCalls.unwrap_saved).
|
|
202
202
|
calls = batch.map(&:last)
|
|
203
203
|
calls = calls.zip(ToolResponse.runs(entry[:content], calls.size)).map do |call, run|
|
|
204
204
|
Tools::BuiltinCalls.unwrap_saved(Tools::BuiltinCalls.wrap_saved(call, run&.text), run&.text)
|
|
@@ -240,8 +240,9 @@ module Samagotchi
|
|
|
240
240
|
# +tool_id+: its output's ToolIds id, with its ✂ mark in +marks+.
|
|
241
241
|
def tool_part(call, output, registry, shown = nil, images = nil, label: nil, diff: nil, refs: nil, cwd: nil,
|
|
242
242
|
tool_id: nil, marks: {})
|
|
243
|
-
# A built-in reached through call_tool
|
|
244
|
-
# row, with via (its saved params and label
|
|
243
|
+
# A chi tool (a built-in or a plugin's) reached through call_tool
|
|
244
|
+
# reloads as that tool's row, with via (its saved params and label
|
|
245
|
+
# are the tool's).
|
|
245
246
|
# A direct call of an MCP tool reloads as the call_tool row it ran as.
|
|
246
247
|
call = Tools::BuiltinCalls.unwrap_saved(Tools::BuiltinCalls.wrap_saved(call, output), output)
|
|
247
248
|
name = call[:name].to_s
|
data/lib/samagotchi/toolkit.rb
CHANGED
|
@@ -249,10 +249,13 @@ module Samagotchi
|
|
|
249
249
|
Kit.new(name: REVIEW, tools: REVIEW_TOOLS, groups: [])
|
|
250
250
|
].to_h { |kit| [kit.name, kit] }.freeze
|
|
251
251
|
|
|
252
|
-
# What a toolkit declares, the Registry's filter: every
|
|
253
|
-
#
|
|
254
|
-
# an LLM context layer gates
|
|
255
|
-
# +added+ declares any registered
|
|
252
|
+
# What a toolkit declares, the Registry's filter: every registered tool,
|
|
253
|
+
# built-ins and plugin tools (chi.tool) alike (+all_core+, full), or the
|
|
254
|
+
# built-ins in +core+, and every tool an LLM context layer gates
|
|
255
|
+
# (forget_outputs), but for +removed+; +added+ declares any registered
|
|
256
|
+
# tool. A tool it leaves out without removing is reachable through the
|
|
257
|
+
# tool index (Registry#reachable_names). The index's own tools
|
|
258
|
+
# (find_tools, call_tool) are the Registry's to declare. +fallback+: why the session's
|
|
256
259
|
# own toolkit couldn't be resolved (this is FULL then), nil when it was.
|
|
257
260
|
# +prompt+: the kit's prompt text (its own or its base's), nil without.
|
|
258
261
|
# +groups+: :all or the sorted source group names/globs it reaches.
|
|
@@ -272,7 +275,7 @@ module Samagotchi
|
|
|
272
275
|
# @param entry [Tools::Registry::Entry]
|
|
273
276
|
def declares?(entry)
|
|
274
277
|
return false if removed.include?(entry.name)
|
|
275
|
-
return true if added.include?(entry.name) || !entry.
|
|
278
|
+
return true if added.include?(entry.name) || !entry.layer.nil?
|
|
276
279
|
|
|
277
280
|
all_core || core.include?(entry.name)
|
|
278
281
|
end
|
|
@@ -38,6 +38,8 @@ module Samagotchi
|
|
|
38
38
|
# config ones (#kits); Plugin::Registries appends to it.
|
|
39
39
|
@plugin_kits = []
|
|
40
40
|
@warned = []
|
|
41
|
+
# The tools #warn_stranded said once.
|
|
42
|
+
@stranded = []
|
|
41
43
|
end
|
|
42
44
|
|
|
43
45
|
# @return [Array<Toolkit::Kit>] the plugins' kits (Plugin::Registries' toolkits:)
|
|
@@ -192,13 +194,36 @@ module Samagotchi
|
|
|
192
194
|
resolved = nil if resolved.everything?
|
|
193
195
|
tools = @tools.call
|
|
194
196
|
tools.reaching_kit = ->(group) { reaching_kit(group) }
|
|
195
|
-
|
|
197
|
+
changed = resolved != tools.toolkit
|
|
198
|
+
if changed
|
|
199
|
+
tools.toolkit = resolved
|
|
200
|
+
@refresh_index.call
|
|
201
|
+
@reset_prompt.call
|
|
202
|
+
end
|
|
203
|
+
warn_stranded(tools)
|
|
204
|
+
changed
|
|
205
|
+
end
|
|
196
206
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
207
|
+
# "web_x isn't declared on toolkit lean, and call_tool can't reach it
|
|
208
|
+
# this session: it arrived after the session's tools were set ·
|
|
209
|
+
# /toolkit add web_x declares it"
|
|
210
|
+
def self.stranded_notice(tool, toolkit)
|
|
211
|
+
"#{tool} isn't declared on toolkit #{toolkit}, and call_tool can't reach it this session: it arrived after " \
|
|
212
|
+
"the session's tools were set · /toolkit add #{tool} declares it"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# A notice (by its bundle, once per tool and worker) for each plugin
|
|
216
|
+
# tool that arrived mid-session on a kit that leaves it out without
|
|
217
|
+
# the tool index to reach it (Tools::Registry#stranded_names).
|
|
218
|
+
def warn_stranded(tools)
|
|
219
|
+
return unless tools.respond_to?(:stranded_names)
|
|
220
|
+
|
|
221
|
+
(tools.stranded_names - @stranded).each do |name|
|
|
222
|
+
@stranded << name
|
|
223
|
+
@notify.call(self.class.stranded_notice(name, tools.toolkit.name), :warn, tools[name]&.source.to_s, extra: {})
|
|
224
|
+
end
|
|
201
225
|
end
|
|
226
|
+
private :warn_stranded
|
|
202
227
|
|
|
203
228
|
# The first kit that resolves and reaches source group +group+, the
|
|
204
229
|
# user's own (config's, the plugins') before the built-ins, so a hint
|