samagotchi 0.33.0 → 0.35.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 +91 -1
- data/README.md +6 -1
- data/bin/chi +3 -0
- data/docs/broadcast.md +129 -0
- data/docs/cli.md +8 -0
- data/docs/configuration.md +2 -0
- data/docs/context.md +5 -0
- data/docs/guardrails.md +35 -3
- data/docs/hooks.md +25 -4
- data/docs/memory.md +17 -1
- data/docs/plugins.md +78 -3
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +15 -9
- data/lib/samagotchi/bridge/card_store.rb +1 -1
- data/lib/samagotchi/bridge/turn_notice.rb +1 -1
- data/lib/samagotchi/broadcast/recipients.rb +63 -0
- data/lib/samagotchi/broadcast/scope_card.rb +94 -0
- data/lib/samagotchi/broadcast/tags.rb +154 -0
- data/lib/samagotchi/broadcast_command.rb +228 -0
- data/lib/samagotchi/bundles/coordinator/manifest.yml +14 -0
- data/lib/samagotchi/bundles/coordinator/plugin.rb +114 -0
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +60 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +11 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
- data/lib/samagotchi/bundles/mcp/plugin.rb +10 -11
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +32 -12
- data/lib/samagotchi/bundles/source-links/manifest.yml +4 -3
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/delegated.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/bundles/system/self_map.md +8 -2
- data/lib/samagotchi/children_status.rb +143 -0
- data/lib/samagotchi/config.rb +6 -0
- data/lib/samagotchi/cut_policy.rb +58 -0
- data/lib/samagotchi/engine.rb +45 -108
- data/lib/samagotchi/engine_peers.rb +30 -0
- data/lib/samagotchi/git_head.rb +24 -0
- data/lib/samagotchi/guardrail_wiring.rb +27 -2
- data/lib/samagotchi/guardrails/approvals.rb +7 -0
- data/lib/samagotchi/guardrails/child_boundary.rb +103 -0
- data/lib/samagotchi/guardrails/context.rb +32 -0
- data/lib/samagotchi/guardrails/shared_git.rb +124 -0
- data/lib/samagotchi/guardrails/verdict.rb +4 -3
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +11 -5
- data/lib/samagotchi/kernel_loop.rb +9 -22
- data/lib/samagotchi/llm/chat_loop.rb +11 -22
- data/lib/samagotchi/log_subscriber.rb +1 -1
- data/lib/samagotchi/memory_bundle/builder.rb +14 -1
- data/lib/samagotchi/memory_bundle/installer.rb +6 -2
- data/lib/samagotchi/memory_bundle/manifest.rb +26 -5
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
- data/lib/samagotchi/memory_paths.rb +10 -0
- data/lib/samagotchi/note_command.rb +13 -13
- data/lib/samagotchi/note_delivery.rb +42 -0
- data/lib/samagotchi/plugin/api.rb +2 -2
- data/lib/samagotchi/plugin/context.rb +4 -4
- data/lib/samagotchi/plugin/sessions.rb +45 -1
- data/lib/samagotchi/process_group.rb +2 -5
- data/lib/samagotchi/session_manager.rb +4 -3
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/steer_cut.rb +125 -0
- data/lib/samagotchi/system_prompt.rb +21 -8
- data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -2
- data/lib/samagotchi/terminal_ui/status_row.rb +23 -2
- data/lib/samagotchi/terminal_ui.rb +14 -0
- data/lib/samagotchi/tool_declarations.rb +9 -5
- data/lib/samagotchi/tools/builtins.rb +2 -2
- data/lib/samagotchi/tools/delegate.rb +103 -14
- data/lib/samagotchi/tools/delegate_wait.rb +55 -13
- data/lib/samagotchi/tools/send_note.rb +5 -11
- data/lib/samagotchi/turn_note.rb +9 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +3 -0
- data/lib/samagotchi/web/capabilities.rb +19 -0
- data/lib/samagotchi/web/public/activity.js +26 -6
- data/lib/samagotchi/web/public/app.js +515 -288
- data/lib/samagotchi/web/public/card.js +12 -3
- data/lib/samagotchi/web/public/command_view.js +2 -3
- data/lib/samagotchi/web/public/families_view.js +171 -0
- data/lib/samagotchi/web/public/format.js +52 -0
- data/lib/samagotchi/web/public/index.html +94 -21
- data/lib/samagotchi/web/public/live_timing.js +154 -0
- data/lib/samagotchi/web/public/live_turn.js +44 -0
- data/lib/samagotchi/web/public/sessions_list.js +228 -32
- data/lib/samagotchi/web/public/strip.js +5 -2
- data/lib/samagotchi/web/public/turn_html.js +107 -0
- data/lib/samagotchi/web/public/turn_model.js +5 -4
- data/lib/samagotchi/web/public/turn_view.js +43 -129
- data/lib/samagotchi/web/session_summary.rb +4 -0
- data/lib/samagotchi/worker.rb +173 -309
- data/lib/samagotchi/worker_inbound.rb +251 -0
- data/lib/samagotchi/worker_wakes.rb +120 -0
- metadata +24 -1
data/docs/sessions.md
CHANGED
|
@@ -60,14 +60,14 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
|
|
|
60
60
|
|
|
61
61
|
**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.
|
|
62
62
|
|
|
63
|
-
`--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 unless the list is itself run as one (`SAMAGOTCHI_ENV=test`, `RACK_ENV=test` or `CI` set: then they show, marked `[test]`; `chi note --all` likewise). `--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`).
|
|
63
|
+
`--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 unless the list is itself run as one (`SAMAGOTCHI_ENV=test`, `RACK_ENV=test` or `CI` set: then they show, marked `[test]`; `chi note --all` likewise). `--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, delegate, 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; `delegate`: true for a child the `delegate` tool started, false for a fork, which has a `parent_id` too), `--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`).
|
|
64
64
|
|
|
65
65
|
**Ordering:**
|
|
66
66
|
|
|
67
67
|
- `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.
|
|
68
|
-
- Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new chats start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new chat there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own project). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat (`hide ▴` puts them away behind a `▾ N sessions` pill, remembered; in a window under 900 px wide, where the strip scrolls sideways, `▴ hide` sits in the top bar where the pill comes back; a window under 700 px tall hides them by itself, so a question or approval card in the stage keeps room, and the pill shows them until the window is tall again); "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. 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. The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new chat starts on; it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first; 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, sampling?}], warning?}`; `sampling` is the model's configured request parameters as `/model` words them, the row's tooltip in the list; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). 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 choice; 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).
|
|
68
|
+
- Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new chats start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new chat there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own project). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat (`hide ▴` puts them away behind a `▾ N sessions` pill, remembered; in a window under 900 px wide, where the strip scrolls sideways, `▴ hide` sits in the top bar where the pill comes back; a window under 700 px tall hides them by itself, so a question or approval card in the stage keeps room, and the pill shows them until the window is tall again). A parent and its delegates are one card there: its `▸ 2 delegates · 1 live · 1 waiting` chip (the waiting part in the attention colour, the card's border too) opens their rows in a popover under it (a row per delegate at any depth, indented; a click opens that one, Esc or a click outside closes it, and the strip's order holds while it is open). A fork is a card of its own, and so is a delegate whose parent isn't listed (archived, another project), with its `↳ <parent>` chip; the pill counts cards, and every live or waiting session. "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). There a parent's card holds its delegates the same way, the chip opening their rows inside the card (each with an archive button on hover), open by itself while one of them waits on you; the count says `14 (+7 delegates)`, a search that matches a delegate shows its parent's card with the list open and the matching rows marked, and select mode picks cards (archiving a parent takes its delegates). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). The info bar copies `chi --attach <id>` for a terminal. 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. The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new chat starts on; it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first; 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, sampling?}], warning?}`; `sampling` is the model's configured request parameters as `/model` words them, the row's tooltip in the list; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). 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 choice; 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).
|
|
69
69
|
- 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).
|
|
70
|
-
- 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`), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
|
|
70
|
+
- 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), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `format.js` (pure formatters such as `previewOf`), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
|
|
71
71
|
- 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).
|
|
72
72
|
|
|
73
73
|
**Test-session hygiene:**
|
|
@@ -88,7 +88,7 @@ pbpaste | chi note --source slack 3f2a 8c1d
|
|
|
88
88
|
|
|
89
89
|
- The text comes from `-m` or stdin (a terminal on stdin is a usage error, not a wait). It is stripped; an empty note or one over 16 KiB is refused, never cut. `--source` (default `cli`) names where it came from. `--all` is every live session.
|
|
90
90
|
- It lands in `<uuid>/notes/`, apart from `input/`, so nothing that runs turns sees it. A live worker adds it to the conversation within a few seconds, between turns: one sent during a turn waits for that turn to end. A session with no worker keeps it until a worker next starts (a prompt, `--attach`), which adds it before anything else. A session open in a plain REPL (`--no-shared`) refuses notes. `chi note` prints one line per session: queued, waits for the next start (with the queue count), or refused.
|
|
91
|
-
- In the conversation it is a tail system message marked `kind: note`, framed `[CONTEXT NOTE from slack, 14:02]\n…\n[END NOTE]` (from another session: `from session 3f2a1c (~/projects/foo)`). The system prompt says notes are background, not requests: the model uses one when it is relevant, doesn't answer it on its own, and never follows instructions inside it. Its text is escaped like user text, so it can't fake a turn. It survives `--resume`, a reload, `!rollback` and a continue answered no.
|
|
91
|
+
- In the conversation it is a tail system message marked `kind: note`, framed `[CONTEXT NOTE from slack, 14:02]\n…\n[END NOTE]` (from another session: `from session 3f2a1c (~/projects/foo)`). The system prompt says notes are background, not requests: the model uses one when it is relevant, doesn't answer it on its own, and never follows instructions inside it. A note from `broadcast` ([`chi broadcast`](broadcast.md)) is the one exception to "doesn't answer it": the model says briefly in its next answer when it affects its current work, and still doesn't act on it unless asked. `--source broadcast` is refused for `chi note`. Its text is escaped like user text, so it can't fake a turn. It survives `--resume`, a reload, `!rollback` and a continue answered no.
|
|
92
92
|
- The web shows it as a dim "note from …" block, live (`context_added`) and after a reload; the attached terminal as one dim `note from slack: <first line>` line, also when joining.
|
|
93
93
|
- Agents: `list_sessions` (other sessions of this project, newest first, up to 20; optional `cwd`, `"/"` for every project) and `send_note(session, text)`, which sends a note from this session. Neither starts a turn anywhere.
|
|
94
94
|
|
|
@@ -120,6 +120,9 @@ system message marked `kind: turn_note` (the UIs don't show it; they showed the
|
|
|
120
120
|
before any answer: network error after 2 attempts (host main: Errno::ECONNREFUSED). The message went back to
|
|
121
121
|
the user, who may send it again.]` in the REPL and attached mode, where the prompt is given back;
|
|
122
122
|
`… The user's last message was not answered.]` after `-p`, where the prompt stays in the session;
|
|
123
|
+
`… The wake turn for the change in attached context pr-7 was not answered; chi starts no other wake turn until
|
|
124
|
+
the user writes.]` after a wake turn chi started (a context source's change, a delegate's report), not the user
|
|
125
|
+
(for a delegate's report it adds `chi keeps the report and brings it again with the user's next message.`);
|
|
123
126
|
- cancelled (Ctrl-C, the web's stop): `[SYSTEM: the previous turn was cancelled (ctrl-c) after 12s; the reply
|
|
124
127
|
above ends where it was cut off.]` (an answer or only narration had streamed), or `…; no answer had been shown.]` when only thinking had streamed;
|
|
125
128
|
- ended with nothing visible: `[SYSTEM: the previous turn ended with no visible answer (thinking only, or
|
|
@@ -179,10 +182,12 @@ chi send --wait 3fa2 # sends nothing: waits fo
|
|
|
179
182
|
|
|
180
183
|
## Delegating
|
|
181
184
|
|
|
182
|
-
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; an unknown host comes back as the tool's `Error:` line; an id the host's saved model list doesn't have starts the child with a `Warning: host 'h' doesn't list model 'x' (did you mean: …?)` line in the result). 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
|
|
185
|
+
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; an unknown host comes back as the tool's `Error:` line; an id the host's saved model list doesn't have starts the child with a `Warning: host 'h' doesn't list model 'x' (did you mean: …?)` line in the result). 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 (folded into its parent's card on the strip and in all sessions, whose `▸ N delegates` chip lists the delegates at any depth, see Web UI above; a card of its own with a `↳` chip when its parent isn't listed; `delegated by` in its info bar; the parent's info bar carries a `⑂ N` chip counting its delegates, with how many run, wait, are done, failed, stopped or idle in the tooltip and the waiting ones named in the attention colour; a click opens all sessions), the terminal's status row (a parent's `⑂ 2 running · 1 waiting`, see [Status Line](cli.md#status-line)) 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.
|
|
183
186
|
|
|
184
|
-
- `delegate(task, model:, session:, wait:, timeout:)`: starts a child and, by default, waits for its reply
|
|
185
|
-
- `
|
|
187
|
+
- `delegate(task, model:, session:, cwd:, wait:, timeout:)`: starts a child and, by default, waits for its reply. `wait: false` is the usual way for work that takes a while, several children side by side, or to keep talking with the user: it returns at once, the parent ends its turn or works on, and the reply comes back by itself as a *delegate report* (below), in a turn of its own if the parent is idle. The tool descriptions tell the model so, and to wait (`wait: true`, `delegate_result`) only when its turn can't go on without the reply. `timeout`, default 600 s, leaves a slow child running; 0 checks once without waiting. With `session:` (a child's id or prefix) the task goes to that child as a follow-up instead, delivered like `chi send` (client id `delegate:<parent short id>`, shown as `delegate>` in the child's UIs); only this session's children take one.
|
|
188
|
+
- `cwd:` starts the child in another folder of the parent's repository: a linked worktree (`git worktree add ../<repo>-<task> -b <branch>`, which the model runs with `execute` first, under guardrails; chi runs no git here) or a subfolder; a relative path is taken from the parent's folder. The folder must exist, have the parent's project root (the same common git dir, `MemoryPaths.project_root`) and be in a work tree: a git dir (`.git`, `.git/objects`, a bare layout's `.bare`) or a bare layout's container is refused (`git rev-parse --is-inside-work-tree`, read-only, decides). Outside git only a subfolder of the parent's folder is allowed. Otherwise the tool returns `Error: cwd must be a folder of this session's repository …` or `… is not in a work tree …`. A follow-up (`session:`) refuses `cwd:`: the child keeps its folder. The child's guardrails measure from its own worktree, and project memories, `list_sessions` and the web's project are the same as the parent's.
|
|
189
|
+
- `delegate_result(session:, timeout:)`: waits for a child's next reply: the one named, or the newest running child (`timeout` as for `delegate`: 0 checks once and returns at once). The reply comes back once; a later call waits for the next one. After `wait: false` it isn't needed: the report brings the reply.
|
|
190
|
+
- A wait that timed out or was canceled still gets its delegate report: neither moves the parent's cursor, so the child's reply comes as a report when its turn ends. The result says so (`chi brings its reply here by itself as a delegate report …`; with `queue`, that it joins the next step or turn) and that `delegate_result` waits again only if the turn needs the reply now. A parent that gets no reports (`session.delegate_reports: off`, a `--no-shared` REPL, `--non-interactive`) is told `delegate_result <id> waits again` instead, and `wait: false`'s result says `delegate_result waits for its reply`.
|
|
186
191
|
- What a wait returns, `session: <id>` then `status:` and the text, with the status words `chi send --wait --format json` uses: `answered` with the reply after a `---` line; `question` as soon as the child opens a question (the model's own, or a hook's), with the question's text, options and the `chi answer` command for it (nothing is answered for it: `chi answer`, attach or use the web, then wait again); `failed`, `canceled` or `no_answer` when the child's turn ended with nothing to say (its session shows what happened); `error` (its worker crashed) or `stopped` (`chi sessions stop`) at once; `worker_gone` when the child has had no worker for 15 seconds (it crashed without saying so, or idle-exited): `delegate` with `session:` starts it again; `running` on timeout, or when the parent's own turn is canceled (the child keeps running; the next turn can wait again). A reply saved before the child's worker idle-exited is returned at once.
|
|
187
192
|
- **A child's step-limit question goes back to the parent model**, not to its user: `delegate_result` returns `status: question` with the `chi answer` commands for Continue and Stop, and says the model may instead send a narrower follow-up (`delegate session:`, which drops the question) or report back. A parent's `chi answer` follows `turn.parent_continue` (see [chi as a sub-agent](sub-agent.md#the-step-limit)).
|
|
188
193
|
- **A child's approvals go to the parent's user** (the approval relay). While the parent waits (`delegate`, `delegate_result`), a guardrail approval the child opens reopens as the parent session's own approval: the same card in the parent's web page and attached terminal, with the same facts and diff, a line naming the delegate and its task (`delegate ab12 · fix the flaky spec…`, linked to its session) and the header `Approve delegate ab12's tool call?`. The options are the child's, by index; "for the session" says it is the delegate's session (the session scope keys on the child; repo and rule approvals are one shared store). The user answers once there; the parent's model never sees the approval, only one line per relayed approval in the tool's result, before the reply: `approval relayed to your user: execute: git push origin main → allowed once` (or `denied ("why")`, `dismissed (denied)`, `answered on the child`, `not delivered (the child's worker is gone)`, `refused by the child …`, `still open (your turn was stopped)`). The time the card is open doesn't count against `timeout`. While the parent waits on one child, its other running children's approvals are relayed too, oldest first, one card at a time (`+1 more delegate waiting`); each outcome comes in that child's next result.
|
|
@@ -194,9 +199,10 @@ The `delegate` tool hands a task to a **child session**: an ordinary chi session
|
|
|
194
199
|
- Parent mid-turn: the report joins the turn at its next iteration boundary (a "delegate report" bubble in the web, `delegate report> ab12cd34 answered: …` in the attached terminal).
|
|
195
200
|
- Parent idle: it runs a turn for the report (a *wake turn*, `last_turn.origin: "delegate_report"`) within the worker's 5 s tick, or wakes a worker for it when the parent's has idle-exited. Several reports that wait together are one turn and one message. A stopped, archived or scratch parent, or one open in a `--no-shared` REPL, is not woken: the rings wait on disk and join the next turn it runs (the user's message first). A worker just started (`chi send` to a stopped parent) runs no wake turn in its first 2 s, so the message it was started for goes first.
|
|
196
201
|
- The parent's cursor per child (the newest reply it was given and the child as it was then) is in its `delegates.json`, shared with `delegate_result` and `wait: true`: a reply the model already had is never reported again, also after the parent's worker restarts. A ring is deleted only when the turn that took its report is kept; a turn that fails leaves it for the next one (and the report isn't handed back to the composer).
|
|
197
|
-
- Limits: `session.max_wakes` (default `10`) wake turns in a row with no human input; past it the reports wait for the next message and the UIs show `N delegate reports waiting; they join your next message`. A wake turn that fails pauses wakes until the next human input. A continue offer waiting in the parent takes the reports into its continue turn instead.
|
|
202
|
+
- Limits: `session.max_wakes` (default `10`) wake turns in a row with no human input; past it the reports wait for the next message and the UIs show `N delegate reports waiting; they join your next message`. A wake turn that fails pauses wakes until the next human input; its reports' rings stay, so they join that input's turn, and the failure note tells the model so. A continue offer waiting in the parent takes the reports into its continue turn instead.
|
|
198
203
|
- `session.delegate_reports`: `wake` (default), `queue` (no wake turns: reports join the parent's next turn) or `off` (no rings; only `delegate_result` asks, as before). Env `SAMAGOTCHI_SESSION_DELEGATE_REPORTS`, `SAMAGOTCHI_SESSION_MAX_WAKES`.
|
|
199
204
|
- The web's bell rings at a wake turn's end however short (nobody started it, so nobody watched it). The text a model writes about a report mid-turn, before its next tool call, stays visible after the report's bubble once the turn ends.
|
|
200
205
|
- 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.
|
|
206
|
+
- **A child asks before it changes anything outside its folder** (the work tree top of the folder it started in): a write or edit there, mutating git in another checkout, a command that isn't read-only run from outside it or naming the main checkout or a sibling worktree. It's a core guardrail, on in every mode and without the guardrails bundle, and answered once or for the child's session ([Guardrails](guardrails.md#a-delegate-childs-boundary)); the approval relays to the parent's user as any other.
|
|
201
207
|
- 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.
|
|
202
|
-
- The link is the session's `parent_id`, written before the worker starts, so a respawn keeps it (and `delegate: true`, which a fork's `parent_id` doesn't come with; a child saved before that field is known by its `system/delegated` memory); `GET /api/sessions
|
|
208
|
+
- The link is the session's `parent_id`, written before the worker starts, so a respawn keeps it (and `delegate: true`, which a fork's `parent_id` doesn't come with; a child saved before that field is known by its `system/delegated` memory); `GET /api/sessions`, `/api/sessions/:id` and the web hub's `session` frames carry it and `delegate` (a fork is `false`; the web parent's children chip counts delegates only); `SessionManager.children_of` lists a parent's children.
|
|
@@ -201,7 +201,7 @@ module Samagotchi
|
|
|
201
201
|
end
|
|
202
202
|
|
|
203
203
|
def add_notice(event)
|
|
204
|
-
notice = event.slice(:type, :hook, :text, :level)
|
|
204
|
+
notice = event.slice(:type, :hook, :text, :level, :fallback_for)
|
|
205
205
|
if event[:between_turns] || !@running
|
|
206
206
|
push(notice.merge(in_turn: false, turns: @turns_done))
|
|
207
207
|
else
|
|
@@ -10,7 +10,7 @@ module Samagotchi
|
|
|
10
10
|
# here, and draw it with the handler that drew it live, where it was.
|
|
11
11
|
module TurnNotice
|
|
12
12
|
FIELDS = {
|
|
13
|
-
hook_notice: %i[hook text level],
|
|
13
|
+
hook_notice: %i[hook text level fallback_for],
|
|
14
14
|
empty_answer_retry: %i[iteration attempt of stopped_by malformed],
|
|
15
15
|
steer_cut: %i[iteration source],
|
|
16
16
|
question_requested: %i[pending_question],
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "../session"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
# Loaded on first use: session_manager requires terminal_ui, which
|
|
8
|
+
# requires KernelLoop and so the tools (a require cycle otherwise).
|
|
9
|
+
autoload :SessionManager, File.expand_path("../session_manager", __dir__)
|
|
10
|
+
|
|
11
|
+
module Broadcast
|
|
12
|
+
# A session a broadcast may concern.
|
|
13
|
+
# @!attribute project [String, nil] its git project's root
|
|
14
|
+
# @!attribute live [Boolean] a worker runs it now
|
|
15
|
+
# @!attribute owner [String, nil] "worker", "tui" (a chi REPL, which
|
|
16
|
+
# takes no notes) or nil
|
|
17
|
+
Recipient = Data.define(:id, :short_id, :project, :cwd, :desc, :live, :owner) do
|
|
18
|
+
def repl? = owner == "tui"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Who a broadcast goes to: the user's own sessions, not delegate
|
|
22
|
+
# children (their parent relays what concerns them) and not scratch
|
|
23
|
+
# ones, that a worker or a chi REPL runs now or that ended a turn in
|
|
24
|
+
# the last +active_hours+. Forks count: they are the user's sessions.
|
|
25
|
+
# Archived sessions don't; test runs only when chi runs as one.
|
|
26
|
+
module Recipients
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# @return [Array<Recipient>] newest first
|
|
30
|
+
def list(state_dir:, active_hours:, include_tests: Session.test_session_env?, now: Time.now)
|
|
31
|
+
cutoff = now - (active_hours.to_f * 3600)
|
|
32
|
+
SessionManager.session_summaries(include_tests: include_tests, state_dir: state_dir).filter_map do |row|
|
|
33
|
+
next if row[:delegate] || row[:scratch]
|
|
34
|
+
next unless row[:live] || row[:owner] == "tui" || active_since?(row, cutoff, state_dir)
|
|
35
|
+
|
|
36
|
+
Recipient.new(id: row[:id], short_id: row[:short_id], project: row[:project], cwd: row[:cwd],
|
|
37
|
+
desc: row[:desc], live: row[:live], owner: row[:owner])
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The user's last turn there ended after +cutoff+. updated_at moves on
|
|
42
|
+
# every save (a note absorbed, a resume), so it counts only for a
|
|
43
|
+
# session with no turn recorded; it is never older than the last
|
|
44
|
+
# turn's end, so an older one rules the session out unread.
|
|
45
|
+
def active_since?(row, cutoff, state_dir)
|
|
46
|
+
return false unless (time(row[:updated_at]) || Time.at(0)) >= cutoff
|
|
47
|
+
|
|
48
|
+
session = Session.summary_from_file(Session.session_file(row[:id], state_dir: state_dir))
|
|
49
|
+
last_turn = session&.last_turn
|
|
50
|
+
ended = last_turn.is_a?(Hash) ? time(last_turn["ended_at"] || last_turn[:ended_at]) : nil
|
|
51
|
+
(ended || time(row[:updated_at])) >= cutoff
|
|
52
|
+
end
|
|
53
|
+
private_class_method :active_since?
|
|
54
|
+
|
|
55
|
+
def time(value)
|
|
56
|
+
value && Time.iso8601(value.to_s)
|
|
57
|
+
rescue ArgumentError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
private_class_method :time
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require_relative "../session"
|
|
5
|
+
require_relative "../context_note"
|
|
6
|
+
require_relative "../context_sources"
|
|
7
|
+
require_relative "../git_head"
|
|
8
|
+
require_relative "../recap_store"
|
|
9
|
+
require_relative "tags"
|
|
10
|
+
|
|
11
|
+
module Samagotchi
|
|
12
|
+
module Broadcast
|
|
13
|
+
# What a broadcast knows about one recipient, from local state only:
|
|
14
|
+
# where it works, what it was started for, its recap, its last prompt
|
|
15
|
+
# and its tags. Built fresh for each broadcast, never stored; P2's
|
|
16
|
+
# triage model reads #to_s.
|
|
17
|
+
# @!attribute folder [String, nil] the session's folder relative to its
|
|
18
|
+
# project ("../app-fix" for a worktree), nil when it is the project
|
|
19
|
+
# root; outside a project the whole path, ~ for home
|
|
20
|
+
# @!attribute tags [Array<Tag>]
|
|
21
|
+
ScopeCard = Data.define(:id, :project, :folder, :branch, :title, :tags, :started, :recap, :recent) do
|
|
22
|
+
def to_s
|
|
23
|
+
where = [("folder #{folder}" if folder), ("branch #{branch}" if branch)].compact.join(", ")
|
|
24
|
+
lines = ["project: #{project || "none"}#{" (#{where})" unless where.empty?}"]
|
|
25
|
+
lines << "title: #{title}" unless title.to_s.empty?
|
|
26
|
+
lines << "tags: #{tags.map(&:label).join(" · ")}" unless tags.empty?
|
|
27
|
+
lines << "started: #{started}" unless started.to_s.empty?
|
|
28
|
+
lines << "recap: #{recap}" unless recap.to_s.empty?
|
|
29
|
+
lines << "recent: #{recent}" unless recent.to_s.empty?
|
|
30
|
+
lines.join("\n")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Builds a ScopeCard for a Recipient.
|
|
35
|
+
module ScopeCards
|
|
36
|
+
STARTED_CHARS = 300
|
|
37
|
+
RECAP_CHARS = 400
|
|
38
|
+
RECENT_CHARS = 200
|
|
39
|
+
|
|
40
|
+
module_function
|
|
41
|
+
|
|
42
|
+
# @param recipient [Recipient]
|
|
43
|
+
# @param ticket [Regexp] the ticket pattern (Tags.ticket_regexp)
|
|
44
|
+
# @return [ScopeCard]
|
|
45
|
+
def build(recipient, state_dir:, ticket:)
|
|
46
|
+
session = Session.load(recipient.id, state_dir: state_dir)
|
|
47
|
+
prompts = user_texts(session.messages)
|
|
48
|
+
branch = GitHead.branch(session.working_directory)
|
|
49
|
+
sources = ContextSources.attached(session.id, project_root: session.project_root, state_dir: state_dir)
|
|
50
|
+
.map { |attached| [attached.name, attached.source.hint] }
|
|
51
|
+
ScopeCard.new(id: session.id, project: recipient.project && File.basename(recipient.project),
|
|
52
|
+
folder: folder(session.working_directory, recipient.project), branch: branch,
|
|
53
|
+
title: recipient.desc, tags: Tags.of_session(branch: branch, messages: prompts, sources: sources,
|
|
54
|
+
ticket: ticket),
|
|
55
|
+
started: cut(prompts.first, STARTED_CHARS),
|
|
56
|
+
recap: cut(RecapStore.read(Session.session_dir(session.id, state_dir: state_dir))&.dig(:text),
|
|
57
|
+
RECAP_CHARS),
|
|
58
|
+
recent: cut(session.last_prompt, RECENT_CHARS))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The user's own messages, as text (an image part leaves its text).
|
|
62
|
+
def user_texts(messages)
|
|
63
|
+
messages.filter_map do |message|
|
|
64
|
+
next unless message[:role].to_s == "user"
|
|
65
|
+
|
|
66
|
+
content = message[:content]
|
|
67
|
+
text = if content.is_a?(Array)
|
|
68
|
+
content.filter_map { |part| part.is_a?(Hash) ? part[:text] || part["text"] : nil }.join("\n")
|
|
69
|
+
else
|
|
70
|
+
content.to_s
|
|
71
|
+
end
|
|
72
|
+
text unless text.strip.empty?
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
private_class_method :user_texts
|
|
76
|
+
|
|
77
|
+
def folder(cwd, project)
|
|
78
|
+
return ContextNote.home_relative(cwd) if project.nil?
|
|
79
|
+
return nil if cwd.to_s == project.to_s
|
|
80
|
+
|
|
81
|
+
Pathname.new(cwd.to_s).relative_path_from(Pathname.new(project.to_s)).to_s
|
|
82
|
+
rescue ArgumentError
|
|
83
|
+
cwd
|
|
84
|
+
end
|
|
85
|
+
private_class_method :folder
|
|
86
|
+
|
|
87
|
+
def cut(text, limit)
|
|
88
|
+
line = text.to_s.gsub(/\s+/, " ").strip
|
|
89
|
+
line.length > limit ? "#{line[0, limit - 1]}…" : line
|
|
90
|
+
end
|
|
91
|
+
private_class_method :cut
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
module Broadcast
|
|
7
|
+
# A cheap, deterministic marker found in a note or about a session: a
|
|
8
|
+
# ticket id, a pull request, a link. A tag the note and a session share
|
|
9
|
+
# delivers the note there without asking a model (the fast path).
|
|
10
|
+
# @!attribute kind [String] "ticket", "pr" or "link"
|
|
11
|
+
# @!attribute value [String] "PAY-123"; "#42" or "acme/shop#42"; a
|
|
12
|
+
# link's host and path ("notion.so/team/checkout-v2")
|
|
13
|
+
# @!attribute from [String] where it was found: "note", "branch",
|
|
14
|
+
# "messages" (the user's prompts) or "context <name>" (an attached
|
|
15
|
+
# context source)
|
|
16
|
+
Tag = Data.define(:kind, :value, :from) do
|
|
17
|
+
def label = "#{kind} #{value}"
|
|
18
|
+
|
|
19
|
+
# Same kind and value; a pr with no repo matches the same number in
|
|
20
|
+
# any repo.
|
|
21
|
+
def matches?(other)
|
|
22
|
+
return false unless kind == other.kind
|
|
23
|
+
return value == other.value unless kind == "pr"
|
|
24
|
+
|
|
25
|
+
number, repo = Tags.pr_parts(value)
|
|
26
|
+
other_number, other_repo = Tags.pr_parts(other.value)
|
|
27
|
+
number == other_number && (repo.nil? || other_repo.nil? || repo == other_repo)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A tag of the note and the session tag it matched.
|
|
32
|
+
Match = Data.define(:note_tag, :session_tag) do
|
|
33
|
+
# For the broadcast's output: "ticket PAY-123 matches (branch)".
|
|
34
|
+
def reason = "#{note_tag.label} matches (#{session_tag.from})"
|
|
35
|
+
|
|
36
|
+
# For the note the session gets: "ticket PAY-123 matches your branch".
|
|
37
|
+
def because
|
|
38
|
+
tag = session_tag
|
|
39
|
+
case tag.from
|
|
40
|
+
when "branch" then "#{note_tag.label} matches your branch"
|
|
41
|
+
when "messages" then "#{note_tag.label} is in your user's messages too"
|
|
42
|
+
else "#{note_tag.label} matches your attached #{tag.from}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Tags from text, a branch and attached context, and the first match
|
|
48
|
+
# between a note's and a session's.
|
|
49
|
+
module Tags
|
|
50
|
+
DEFAULT_TICKET_PATTERN = '\b[A-Z][A-Z0-9]+-\d+\b'
|
|
51
|
+
# Words the default ticket pattern would take that are no ticket.
|
|
52
|
+
NOT_TICKETS = %w[UTF SHA ISO IEC RFC GPT AES RSA ECMA ES HTTP TLS SSL MD IPV X].freeze
|
|
53
|
+
URL_RE = %r{https?://[^\s<>()\[\]{}"'`|]+}
|
|
54
|
+
PR_URL_RE = %r{\A(?:www\.)?github\.com/([^/]+/[^/]+)/pull/(\d+)}i
|
|
55
|
+
PR_TEXT_RE = /\bPR\s?#(\d+)\b/i
|
|
56
|
+
PR_SOURCE_RE = /\Apr-(\d+)\z/
|
|
57
|
+
# The order matches are looked for in: the most specific first.
|
|
58
|
+
KINDS = %w[ticket pr link].freeze
|
|
59
|
+
|
|
60
|
+
module_function
|
|
61
|
+
|
|
62
|
+
# broadcast.ticket_pattern as a Regexp; nil, empty or invalid → the
|
|
63
|
+
# default (an invalid one is reported through +warn+).
|
|
64
|
+
# @return [Regexp]
|
|
65
|
+
def ticket_regexp(pattern, warn: nil)
|
|
66
|
+
return Regexp.new(DEFAULT_TICKET_PATTERN) if pattern.to_s.strip.empty?
|
|
67
|
+
|
|
68
|
+
Regexp.new(pattern.to_s)
|
|
69
|
+
rescue RegexpError => e
|
|
70
|
+
warn&.call("broadcast.ticket_pattern #{pattern.inspect} is not a valid pattern (#{e.message}); using the default")
|
|
71
|
+
Regexp.new(DEFAULT_TICKET_PATTERN)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [Array<Tag>] the tags in +text+ (a note, the user's prompts)
|
|
75
|
+
def of_text(text, from:, ticket: ticket_regexp(nil))
|
|
76
|
+
text = text.to_s
|
|
77
|
+
found = tickets(text, ticket, from: from)
|
|
78
|
+
found += text.scan(PR_TEXT_RE).map { |(number)| Tag.new(kind: "pr", value: "##{number}", from: from) }
|
|
79
|
+
text.scan(URL_RE).each { |url| found += of_url(url, from: from) }
|
|
80
|
+
found.uniq
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# A session's tags: its branch (matched whatever its case: branches
|
|
84
|
+
# are often lowercase), the user's messages, its attached context
|
|
85
|
+
# (a pr-<n> source and every source's URL hint).
|
|
86
|
+
# @param sources [Array<Array(String, String)>] [name, hint] pairs
|
|
87
|
+
# @return [Array<Tag>]
|
|
88
|
+
def of_session(branch:, messages:, sources:, ticket: ticket_regexp(nil))
|
|
89
|
+
found = tickets(branch.to_s, Regexp.new(ticket.source, ticket.options | Regexp::IGNORECASE), from: "branch")
|
|
90
|
+
found += messages.flat_map { |text| of_text(text, from: "messages", ticket: ticket) }
|
|
91
|
+
sources.each do |name, hint|
|
|
92
|
+
from = "context #{name}"
|
|
93
|
+
from_hint = hint.to_s.scan(URL_RE).flat_map { |url| of_url(url, from: from) }
|
|
94
|
+
number = name.to_s[PR_SOURCE_RE, 1]
|
|
95
|
+
# pr-<n> with no PR URL in its hint: the number alone, any repo
|
|
96
|
+
found << Tag.new(kind: "pr", value: "##{number}", from: from) if number && from_hint.none? { |t| t.kind == "pr" }
|
|
97
|
+
found += from_hint
|
|
98
|
+
end
|
|
99
|
+
found.uniq { |tag| [tag.kind, tag.value] }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The first session tag a note tag matches, tickets first, then pull
|
|
103
|
+
# requests, then links.
|
|
104
|
+
# @return [Match, nil]
|
|
105
|
+
def match(note_tags, session_tags)
|
|
106
|
+
KINDS.each do |kind|
|
|
107
|
+
note_tags.each do |tag|
|
|
108
|
+
next unless tag.kind == kind
|
|
109
|
+
|
|
110
|
+
hit = session_tags.find { |other| tag.matches?(other) }
|
|
111
|
+
return Match.new(note_tag: tag, session_tag: hit) if hit
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# "acme/shop#42" → ["42", "acme/shop"]; "#42" → ["42", nil].
|
|
118
|
+
def pr_parts(value)
|
|
119
|
+
repo, number = value.to_s.split("#", 2)
|
|
120
|
+
[number, repo.to_s.empty? ? nil : repo.downcase]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def tickets(text, regexp, from:)
|
|
124
|
+
text.to_enum(:scan, regexp).filter_map do
|
|
125
|
+
id = Regexp.last_match(0).upcase
|
|
126
|
+
next if NOT_TICKETS.include?(id.split("-", 2).first)
|
|
127
|
+
|
|
128
|
+
Tag.new(kind: "ticket", value: id, from: from)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
private_class_method :tickets
|
|
132
|
+
|
|
133
|
+
# A link's host and path, without the scheme, www., the query, the
|
|
134
|
+
# fragment or a trailing slash; a pull request URL is a pr tag too. A
|
|
135
|
+
# bare host ("https://github.com/") says too little to be a tag.
|
|
136
|
+
def of_url(url, from:)
|
|
137
|
+
uri = URI.parse(url.sub(/[.,;:!?]+\z/, ""))
|
|
138
|
+
host = uri.host.to_s.downcase.delete_prefix("www.")
|
|
139
|
+
path = uri.path.to_s.chomp("/")
|
|
140
|
+
return [] if host.empty? || path.empty?
|
|
141
|
+
|
|
142
|
+
link = "#{host}#{path}"
|
|
143
|
+
tags = [Tag.new(kind: "link", value: link, from: from)]
|
|
144
|
+
if (pr = link.match(PR_URL_RE))
|
|
145
|
+
tags.unshift(Tag.new(kind: "pr", value: "#{pr[1].downcase}##{pr[2]}", from: from))
|
|
146
|
+
end
|
|
147
|
+
tags
|
|
148
|
+
rescue URI::Error
|
|
149
|
+
[]
|
|
150
|
+
end
|
|
151
|
+
private_class_method :of_url
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|