From 87535f2958eed15eaff3400182f53d1888c2cea9 Mon Sep 17 00:00:00 2001 From: Sebastian Gonzalez Date: Thu, 1 Oct 2026 11:34:29 -0700 Subject: [PATCH 1/2] chore(release): memhub v0.103.0 Promoted from XTraceAI/agent-plugins-internal by scripts/promote_ship.py. Source-RevId: ca5171337a94784c0f9975f2117d1719d4c1384b --- plugins/memhub/.claude-plugin/plugin.json | 4 +- plugins/memhub/.codex-plugin/plugin.json | 4 +- plugins/memhub/.cursor-plugin/plugin.json | 4 +- plugins/memhub/.mcp.json | 2 +- plugins/memhub/companion/animal.ts | 22 +- plugins/memhub/companion/animals/goose/art.ts | 7 +- .../memhub/companion/animals/goose/index.ts | 5 +- .../memhub/companion/animals/goose/script.ts | 73 +- .../companion/animals/hippo/big/script.ts | 79 +- .../memhub/companion/animals/hippo/index.ts | 8 +- .../companion/animals/hippo/small/script.ts | 79 +- plugins/memhub/companion/animals/index.ts | 3 +- .../memhub/companion/animals/penguin/art.ts | 6 +- .../memhub/companion/animals/penguin/index.ts | 6 +- .../companion/animals/penguin/script.ts | 55 +- plugins/memhub/companion/animals/shiba/art.ts | 207 +++ .../memhub/companion/animals/shiba/index.ts | 46 + .../companion/animals/shiba/scene-consts.ts | 11 + .../memhub/companion/animals/shiba/scene.ts | 47 + .../memhub/companion/animals/shiba/script.ts | 364 +++++ plugins/memhub/companion/hooks.json | 6 +- plugins/memhub/companion/proposed.ts | 74 + plugins/memhub/companion/register.ts | 670 +++++++- plugins/memhub/companion/rules.ts | 5 +- plugins/memhub/companion/screen.ts | 106 +- plugins/memhub/companion/selection.ts | 83 + .../memhub/companion/tests/animals.test.ts | 98 +- plugins/memhub/companion/tests/click.test.ts | 375 +++++ .../memhub/companion/tests/proposed.test.ts | 66 + plugins/memhub/companion/tests/rules.test.ts | 7 +- .../memhub/companion/tests/selection.test.ts | 90 ++ .../companion/tests/selection_vectors.ts | 29 + .../companion/tests/stop_announce.test.ts | 400 +++++ plugins/memhub/hooks/claude-hooks.json | 44 +- plugins/memhub/hooks/codex-hooks.json | 2 +- plugins/memhub/mcp.json | 2 +- plugins/memhub/plugin.json | 4 +- .../memhub/references/codex-hooks-bridge.json | 2 +- plugins/memhub/references/repo-brain.md | 65 +- plugins/memhub/references/spec-workflow.md | 10 +- plugins/memhub/scripts/_memhub_auth.py | 10 +- plugins/memhub/scripts/add_memory_gate.py | 11 +- plugins/memhub/scripts/atomic_write.py | 4 +- plugins/memhub/scripts/brain_brief.py | 322 ++-- plugins/memhub/scripts/brain_resolve.py | 232 +-- plugins/memhub/scripts/brief_identifiers.py | 162 +- plugins/memhub/scripts/capture.py | 14 +- plugins/memhub/scripts/codex_flush.py | 62 +- plugins/memhub/scripts/codex_hook_bridge.py | 63 +- plugins/memhub/scripts/cursor_flush.py | 65 +- plugins/memhub/scripts/directive_prefilter.py | 208 --- plugins/memhub/scripts/directive_recall.py | 769 --------- plugins/memhub/scripts/flush_session.py | 158 +- plugins/memhub/scripts/flush_turn.py | 100 +- plugins/memhub/scripts/harness_extract.py | 401 ++--- plugins/memhub/scripts/harness_stop.py | 1421 ++++------------- plugins/memhub/scripts/import_session.py | 140 +- plugins/memhub/scripts/mcp_http.py | 6 +- plugins/memhub/scripts/md_capture_flush.py | 63 +- plugins/memhub/scripts/portable_lock.py | 19 + plugins/memhub/scripts/reactive_prefilter.py | 44 - plugins/memhub/scripts/repo_identity.py | 5 +- plugins/memhub/scripts/room_map.py | 28 +- plugins/memhub/scripts/rule_decide.py | 168 ++ plugins/memhub/scripts/rule_judge_turn.py | 208 +++ plugins/memhub/scripts/rulebook_hook.py | 730 +++++++-- plugins/memhub/scripts/rulebook_verify.py | 10 + plugins/memhub/scripts/save_artifact.py | 27 +- plugins/memhub/scripts/served_state.py | 14 +- plugins/memhub/scripts/setup_codex_hooks.py | 2 +- plugins/memhub/scripts/state_sweep.py | 204 +++ plugins/memhub/scripts/transcript_filter.py | 40 +- plugins/memhub/skills/companion/SKILL.md | 45 +- plugins/memhub/skills/create-rule/SKILL.md | 355 ++-- .../memhub/skills/handoff-session/SKILL.md | 17 +- plugins/memhub/skills/import-session/SKILL.md | 64 +- plugins/memhub/skills/login/SKILL.md | 6 +- plugins/memhub/skills/onboard/SKILL.md | 50 +- plugins/memhub/skills/pr-babysit/SKILL.md | 45 +- plugins/memhub/skills/save-artifact/SKILL.md | 9 +- plugins/memhub/skills/search-memory/SKILL.md | 147 +- plugins/memhub/skills/setup/SKILL.md | 14 +- plugins/memhub/skills/spec-maintain/SKILL.md | 4 +- plugins/memhub/skills/start-rulebook/SKILL.md | 89 +- .../memhub/skills/start-rulebook/catalog.json | 259 +++ .../start-rulebook/scripts/mine_sessions.py | 74 +- .../scripts/starter_rulebook.py | 59 +- 87 files changed, 5922 insertions(+), 4195 deletions(-) create mode 100644 plugins/memhub/companion/animals/shiba/art.ts create mode 100644 plugins/memhub/companion/animals/shiba/index.ts create mode 100644 plugins/memhub/companion/animals/shiba/scene-consts.ts create mode 100644 plugins/memhub/companion/animals/shiba/scene.ts create mode 100644 plugins/memhub/companion/animals/shiba/script.ts create mode 100644 plugins/memhub/companion/proposed.ts create mode 100644 plugins/memhub/companion/selection.ts create mode 100644 plugins/memhub/companion/tests/click.test.ts create mode 100644 plugins/memhub/companion/tests/proposed.test.ts create mode 100644 plugins/memhub/companion/tests/selection.test.ts create mode 100644 plugins/memhub/companion/tests/selection_vectors.ts create mode 100644 plugins/memhub/companion/tests/stop_announce.test.ts delete mode 100644 plugins/memhub/scripts/directive_prefilter.py delete mode 100644 plugins/memhub/scripts/directive_recall.py delete mode 100644 plugins/memhub/scripts/reactive_prefilter.py create mode 100644 plugins/memhub/scripts/rule_decide.py create mode 100644 plugins/memhub/scripts/rule_judge_turn.py create mode 100644 plugins/memhub/scripts/state_sweep.py diff --git a/plugins/memhub/.claude-plugin/plugin.json b/plugins/memhub/.claude-plugin/plugin.json index 3aff75df..c1423663 100644 --- a/plugins/memhub/.claude-plugin/plugin.json +++ b/plugins/memhub/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "memhub", - "description": "Auto-capture Claude Code sessions into MemHub team memory. A Stop hook ships each turn as it happens \u2014 sending only the transcript bytes written since the last successful flush, so a session is captured even if it never reaches a clean exit \u2014 alongside flushes on commit/PR events (PostToolUse hooks) and a SessionEnd backstop. The memhub MCP runs tool-aware (agentic) extraction of facts, episodes, and artifacts with watermark-based delta dedup, batching extraction so per-turn capture doesn't fragment episodes. A PreToolUse hook surfaces situated team directives (lessons/procedures) before each Edit/Write/Bash by firing the memhub recall_directives symbol-tripwire on the in-flight call and injecting any hits as context (a client-side precision gate drops directives whose triggers don't concretely match the call, e.g. an over-broad repo-name trigger). Includes skills for: plugin install/health (setup), plugin authentication with its own hook credential (login), repo brain creation and seeding (onboard), terminal artifact uploads (save-artifact), on-demand session import (import-session), team-memory recall (search-memory), teammate session handoff (handoff-session), git-authored spec development with ownership frontmatter (spec), Rulebook rule authoring (create-rule), rules proposed from CLAUDE.md and past sessions in one run (start-rulebook), and PR babysitting (pr-babysit) \u2014 a hook arms a self-paced loop after `gh pr create` that fixes review-bot findings and saves the fixing process to the repo's agent brain. Rules live in RULEBOOKS \u2014 containers with their own membership, so a rule reaches exactly the people its book binds, and one person can be bound by several (their org's book plus their team's): the authoring skills resolve which book a rule lands in and offer to create one when there is none, the hooks cache every book that binds you, and when two books fire on one call the wider book's rule is the one the per-call cap keeps. A PostToolUse hook reminds authors when an edited file belongs to a git-authored spec. Spec ownership frontmatter replaces the retired artifact map; the backend maintains read-only mirrors and opens bootstrap or audit PRs. Sessions are named with the title their own host shows \u2014 Claude Code's generated title, or on Codex the thread_name Codex generated (from the rollout, else ~/.codex/session_index.jsonl), passed through verbatim so MemHub's sessions list matches the editor's; a session its host never named falls back to a title derived from its first prompt \u2014 and route into the repo's own agent brain via a per-user room cache (~/.config/memhub-plugin/rooms.json, never written into the repo) \u2014 resolved once by /memhub:onboard and read by every writer, including the two automatic capture paths, so team memory lands in the repo's room instead of personal memory. Session \u2194 PR linking: after a call that addresses GitHub (`gh pr \u2026`, a `curl`/`gh api` REST request, or a GitHub MCP tool) whose output names exactly one pull request, a PostToolUse hook asks the backend one question and injects one instruction \u2014 a session that ran `gh pr create` \u2014 or the GitHub MCP create tool \u2014 links itself unconditionally (opening it is work the session did, and a PR has many sessions), while every other GitHub call, a hand-rolled `curl` POST included, leaves the authorship judgment to the agent, and an org with no GitHub integration is told once that connecting it is what links sessions to shipped code. The hook is stateless apart from one short-lived negative answer (an enabled org with GitHub disconnected, cached 30 minutes per credential, and never inferred from a reply that merely says the feature is off) and degrades to silence on every failure. Two skills complete it: link-pr (link this or a named session by hand \u2014 also the answer for a PR opened by a script or CI helper, which is deliberately not detected) and find-contributing-sessions (map your own unlinked PRs, or the ones you name, to the sessions on this machine that wrote their code: scan local session history once, rank the candidates per PR by the evidence that matched, and link the ones you approve). Every rulebook fire is now disclosed to the user in a fixed shape on two channels: the hook's own terminal line leads with `\ud83d\udccf Rule fired: ` (`\u26d4\ufe0f` when a gate actually blocked the call) above the detail line it already showed, and the agent is told to echo that same byte-identical line at the top of its reply so the fire reaches the transcript everything downstream reads. Session start now says what rules ARE \u2014 standing instructions from teammates carrying CLAUDE.md's weight \u2014 before listing them. The agent records ONE work type for a pull request it links (feat, fix, chore, docs, perf, refactor, other), and only when the PR has none yet \u2014 the first label is permanent, and asking again would cost the link, not just the type. Spec workflows: spec-work guides implementation, spec-check freshly reviews changes, and spec-maintain offers local/cloud bootstrap and reviewable Git or Brain proposals through the shared spec entry point. A companion animal draws what the plugin is doing in the band above the prompt: asleep while the session is idle, awake and watching while Claude answers or the person types, and up announcing each Rulebook rule that fires \u2014 crossly when one blocked the call. It needs Claude Code's function-hook runtime, which /memhub:companion turns on and persists.", - "version": "0.88.0", + "description": "Auto-capture Claude Code sessions into MemHub \u2014 into the author's personal memory, never into a brain. A Stop hook ships each turn as it happens \u2014 sending only the transcript bytes written since the last successful flush, so a session is captured even if it never reaches a clean exit \u2014 alongside flushes on commit/PR events (PostToolUse hooks) and a SessionEnd backstop. The memhub MCP runs tool-aware (agentic) extraction of facts, episodes, and artifacts with watermark-based delta dedup, batching extraction so per-turn capture doesn't fragment episodes. Includes skills for: plugin install/health (setup), plugin authentication with its own hook credential (login), repo brain creation and seeding (onboard), terminal artifact uploads (save-artifact), on-demand session import (import-session), team-memory recall (search-memory), teammate session handoff (handoff-session), git-authored spec development with ownership frontmatter (spec), Rulebook rule authoring (create-rule), rules proposed from CLAUDE.md and past sessions in one run (start-rulebook), and PR babysitting (pr-babysit) \u2014 a hook arms a self-paced loop after `gh pr create` that fixes review-bot findings and saves a review record of the fixing process to the repo's agent brain. Rules live in RULEBOOKS \u2014 containers with their own membership, so a rule reaches exactly the people its book binds, and one person can be bound by several (their org's book plus their team's): the authoring skills resolve which book a rule lands in and offer to create one when there is none, the hooks cache every book that binds you, and when two books fire on one call the wider book's rule is the one the per-call cap keeps. A PostToolUse hook reminds authors when an edited file belongs to a git-authored spec. Spec ownership frontmatter replaces the retired artifact map; the backend maintains read-only mirrors and opens bootstrap or audit PRs. Sessions are named with the title their own host shows \u2014 Claude Code's generated title, or on Codex the thread_name Codex generated (from the rollout, else ~/.codex/session_index.jsonl), passed through verbatim so MemHub's sessions list matches the editor's; a session its host never named falls back to a title derived from its first prompt \u2014 and land in the author's personal memory, never in a brain. Artifacts and specs route into the repo's own agent brain via a per-user room cache (~/.config/memhub-plugin/rooms.json, never written into the repo) \u2014 resolved once by /memhub:onboard and read by the artifact writers, so team knowledge lands in the repo's room. Session \u2194 PR linking: after a call that addresses GitHub (`gh pr \u2026`, a `curl`/`gh api` REST request, or a GitHub MCP tool) whose output names exactly one pull request, a PostToolUse hook asks the backend one question and injects one instruction \u2014 a session that ran `gh pr create` \u2014 or the GitHub MCP create tool \u2014 links itself unconditionally (opening it is work the session did, and a PR has many sessions), while every other GitHub call, a hand-rolled `curl` POST included, leaves the authorship judgment to the agent, and an org with no GitHub integration is told once that connecting it is what links sessions to shipped code. The hook is stateless apart from one short-lived negative answer (an enabled org with GitHub disconnected, cached 30 minutes per credential, and never inferred from a reply that merely says the feature is off) and degrades to silence on every failure. Two skills complete it: link-pr (link this or a named session by hand \u2014 also the answer for a PR opened by a script or CI helper, which is deliberately not detected) and find-contributing-sessions (map your own unlinked PRs, or the ones you name, to the sessions on this machine that wrote their code: scan local session history once, rank the candidates per PR by the evidence that matched, and link the ones you approve). Every rulebook fire is now disclosed to the user in a fixed shape on two channels: the hook's own terminal line leads with `\ud83d\udccf Rule fired: ` (`\u26d4\ufe0f` when a gate actually blocked the call) above the detail line it already showed, and the agent is told to echo that same byte-identical line at the top of its reply so the fire reaches the transcript everything downstream reads. Session start now says what rules ARE \u2014 standing instructions from teammates carrying CLAUDE.md's weight \u2014 before listing them. The agent records ONE work type for a pull request it links (feat, fix, chore, docs, perf, refactor, other), and only when the PR has none yet \u2014 the first label is permanent, and asking again would cost the link, not just the type. Spec workflows: spec-work guides implementation, spec-check freshly reviews changes, and spec-maintain offers local/cloud bootstrap and reviewable Git or Brain proposals through the shared spec entry point. A companion animal draws what the plugin is doing in the band above the prompt, one per session picked from its session id (/goose, /hippo, /penguin and /shiba switch it for the session; `always` pins one for every session): asleep while the session is idle, awake and watching while Claude answers or the person types, and up announcing each Rulebook rule that fires \u2014 crossly when one blocked the call \u2014 or presenting a rule the harness just proposed with Activate / Reject / Later buttons (activating takes a rulebook admin; the animal says so to anyone else). Clicking the \u2665 beside it pets it, and a proposal's rule\u2197 opens that rule in MemHub Studio. It needs Claude Code's function-hook runtime, which /memhub:companion turns on and persists.", + "version": "0.103.0", "license": "Apache-2.0", "hooks": ["./hooks/claude-hooks.json", "./companion/hooks.json"], "author": { diff --git a/plugins/memhub/.codex-plugin/plugin.json b/plugins/memhub/.codex-plugin/plugin.json index 0b5ee8ee..2d69e3b5 100644 --- a/plugins/memhub/.codex-plugin/plugin.json +++ b/plugins/memhub/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "memhub", - "description": "XTrace MemHub team memory in Codex: automatic session capture, situated directive recall, artifact sync, plus skills for setup, login, onboarding, artifact upload, session import, search, handoff, spec-driven development, Rulebook rule authoring (create-rule) and a team's first Rulebook — tested starter rules fitted to the repo, and rules proposed from CLAUDE.md + past sessions (start-rulebook), and PR babysitting. A rule lives in a RULEBOOK \u2014 a container with its own membership, so it reaches exactly the people that book binds; the authoring skills resolve which book it lands in and offer to create one when there is none. Run memhub:setup to verify hook routing and authentication. Current desktop builds support bundled hooks; older hosts need the user bridge. Bundled handlers defer to matching installed user bridge handlers, which require trust. Codex's own threads are not captured as your sessions: spawned subagents, guardian action-reviews and memory consolidation are recognised from the rollout's thread_source and skipped by name, while every other kind \u2014 including a product surface we have not seen, since Codex's ThreadSource type is open-ended \u2014 is captured, because refusing an unknown kind would silently and unrecoverably drop real work. Every rule fire is reported under the same namespaced session id capture uploads, so a fire is linked to the Codex session it fired in. Session \u2194 PR linking: after a call that addresses GitHub (`gh pr \u2026`, a `curl`/`gh api` REST request, or a GitHub MCP tool) whose output names exactly one pull request, a PostToolUse hook asks the backend one question and injects one instruction \u2014 a session that ran `gh pr create` \u2014 or the GitHub MCP create tool \u2014 links itself unconditionally (opening it is work the session did, and a PR has many sessions), while every other GitHub call, a hand-rolled `curl` POST included, leaves the authorship judgment to the agent, and an org with no GitHub integration is told once that connecting it is what links sessions to shipped code. The hook is stateless apart from one short-lived negative answer (an enabled org with GitHub disconnected, cached 30 minutes per credential, and never inferred from a reply that merely says the feature is off) and degrades to silence on every failure. Two skills complete it: link-pr (link this or a named session by hand \u2014 also the answer for a PR opened by a script or CI helper, which is deliberately not detected) and find-contributing-sessions (map your own unlinked PRs, or the ones you name, to the sessions on this machine that wrote their code: scan local session history once, rank the candidates per PR by the evidence that matched, and link the ones you approve). The agent records ONE work type for a pull request it links (feat, fix, chore, docs, perf, refactor, other), and only when the PR has none yet \u2014 the first label is permanent, and asking again would cost the link, not just the type. Spec workflows: spec-work guides implementation, spec-check freshly reviews changes, and spec-maintain offers local/cloud bootstrap and reviewable Git or Brain proposals through the shared spec entry point. A companion animal draws what the plugin is doing in the band above the prompt: asleep while the session is idle, awake and watching while Claude answers or the person types, and up announcing each Rulebook rule that fires \u2014 crossly when one blocked the call. It needs Claude Code's function-hook runtime, which /memhub:companion turns on and persists.", - "version": "0.88.0", + "description": "XTrace MemHub team memory in Codex: automatic session capture, artifact sync, plus skills for setup, login, onboarding, artifact upload, session import, search, handoff, spec-driven development, Rulebook rule authoring (create-rule) and a team's first Rulebook \u2014 tested starter rules fitted to the repo, and rules proposed from CLAUDE.md + past sessions (start-rulebook), and PR babysitting. A rule lives in a RULEBOOK \u2014 a container with its own membership, so it reaches exactly the people that book binds; the authoring skills resolve which book it lands in and offer to create one when there is none. Run memhub:setup to verify hook routing and authentication. Current desktop builds support bundled hooks; older hosts need the user bridge. Bundled handlers defer to matching installed user bridge handlers, which require trust. Codex's own threads are not captured as your sessions: spawned subagents, guardian action-reviews and memory consolidation are recognised from the rollout's thread_source and skipped by name, while every other kind \u2014 including a product surface we have not seen, since Codex's ThreadSource type is open-ended \u2014 is captured, because refusing an unknown kind would silently and unrecoverably drop real work. Every rule fire is reported under the same namespaced session id capture uploads, so a fire is linked to the Codex session it fired in. Session \u2194 PR linking: after a call that addresses GitHub (`gh pr \u2026`, a `curl`/`gh api` REST request, or a GitHub MCP tool) whose output names exactly one pull request, a PostToolUse hook asks the backend one question and injects one instruction \u2014 a session that ran `gh pr create` \u2014 or the GitHub MCP create tool \u2014 links itself unconditionally (opening it is work the session did, and a PR has many sessions), while every other GitHub call, a hand-rolled `curl` POST included, leaves the authorship judgment to the agent, and an org with no GitHub integration is told once that connecting it is what links sessions to shipped code. The hook is stateless apart from one short-lived negative answer (an enabled org with GitHub disconnected, cached 30 minutes per credential, and never inferred from a reply that merely says the feature is off) and degrades to silence on every failure. Two skills complete it: link-pr (link this or a named session by hand \u2014 also the answer for a PR opened by a script or CI helper, which is deliberately not detected) and find-contributing-sessions (map your own unlinked PRs, or the ones you name, to the sessions on this machine that wrote their code: scan local session history once, rank the candidates per PR by the evidence that matched, and link the ones you approve). The agent records ONE work type for a pull request it links (feat, fix, chore, docs, perf, refactor, other), and only when the PR has none yet \u2014 the first label is permanent, and asking again would cost the link, not just the type. Spec workflows: spec-work guides implementation, spec-check freshly reviews changes, and spec-maintain offers local/cloud bootstrap and reviewable Git or Brain proposals through the shared spec entry point. A companion animal draws what the plugin is doing in the band above the prompt, one per session picked from its session id (/goose, /hippo, /penguin and /shiba switch it for the session; `always` pins one for every session): asleep while the session is idle, awake and watching while Claude answers or the person types, and up announcing each Rulebook rule that fires \u2014 crossly when one blocked the call \u2014 or presenting a rule the harness just proposed with Activate / Reject / Later buttons (activating takes a rulebook admin; the animal says so to anyone else). Clicking the \u2665 beside it pets it, and a proposal's rule\u2197 opens that rule in MemHub Studio. It needs Claude Code's function-hook runtime, which /memhub:companion turns on and persists.", + "version": "0.103.0", "license": "Apache-2.0", "author": { "name": "XTrace", diff --git a/plugins/memhub/.cursor-plugin/plugin.json b/plugins/memhub/.cursor-plugin/plugin.json index 0aa1d59e..b8b7afc1 100644 --- a/plugins/memhub/.cursor-plugin/plugin.json +++ b/plugins/memhub/.cursor-plugin/plugin.json @@ -1,8 +1,8 @@ { "name": "memhub", "displayName": "MemHub", - "description": "XTrace MemHub team memory in Cursor: search_memory / save_artifact / import_conversation tools plus skills for setup, login, onboarding, artifact upload, session import, recall, handoff, spec-driven development, Rulebook rule authoring (create-rule) and a team's first Rulebook — tested starter rules fitted to the repo, and rules proposed from CLAUDE.md + past sessions (start-rulebook), and PR babysitting. A rule lives in a RULEBOOK \u2014 a container with its own membership, so it reaches exactly the people that book binds; the authoring skills resolve which book it lands in and offer to create one when there is none. Sessions are captured automatically. Session \u2194 PR linking ships as skills on Cursor: link-pr and find-contributing-sessions. Cursor's shell hooks are observational \u2014 `afterShellExecution` defines no reply the agent can see \u2014 so the automatic post-`gh pr create` link that Claude Code and Codex get is not available here. The agent records ONE work type for a pull request it links (feat, fix, chore, docs, perf, refactor, other), and only when the PR has none yet \u2014 the first label is permanent, and asking again would cost the link, not just the type. Spec workflows: spec-work guides implementation, spec-check freshly reviews changes, and spec-maintain offers local/cloud bootstrap and reviewable Git or Brain proposals through the shared spec entry point. A companion animal draws what the plugin is doing in the band above the prompt: asleep while the session is idle, awake and watching while Claude answers or the person types, and up announcing each Rulebook rule that fires \u2014 crossly when one blocked the call. It needs Claude Code's function-hook runtime, which /memhub:companion turns on and persists.", - "version": "0.88.0", + "description": "XTrace MemHub team memory in Cursor: search_memory / save_artifact / import_conversation tools plus skills for setup, login, onboarding, artifact upload, session import, recall, handoff, spec-driven development, Rulebook rule authoring (create-rule) and a team's first Rulebook \u2014 tested starter rules fitted to the repo, and rules proposed from CLAUDE.md + past sessions (start-rulebook), and PR babysitting. A rule lives in a RULEBOOK \u2014 a container with its own membership, so it reaches exactly the people that book binds; the authoring skills resolve which book it lands in and offer to create one when there is none. Sessions are captured automatically. Session \u2194 PR linking ships as skills on Cursor: link-pr and find-contributing-sessions. Cursor's shell hooks are observational \u2014 `afterShellExecution` defines no reply the agent can see \u2014 so the automatic post-`gh pr create` link that Claude Code and Codex get is not available here. The agent records ONE work type for a pull request it links (feat, fix, chore, docs, perf, refactor, other), and only when the PR has none yet \u2014 the first label is permanent, and asking again would cost the link, not just the type. Spec workflows: spec-work guides implementation, spec-check freshly reviews changes, and spec-maintain offers local/cloud bootstrap and reviewable Git or Brain proposals through the shared spec entry point. A companion animal draws what the plugin is doing in the band above the prompt, one per session picked from its session id (/goose, /hippo, /penguin and /shiba switch it for the session; `always` pins one for every session): asleep while the session is idle, awake and watching while Claude answers or the person types, and up announcing each Rulebook rule that fires \u2014 crossly when one blocked the call \u2014 or presenting a rule the harness just proposed with Activate / Reject / Later buttons (activating takes a rulebook admin; the animal says so to anyone else). Clicking the \u2665 beside it pets it, and a proposal's rule\u2197 opens that rule in MemHub Studio. It needs Claude Code's function-hook runtime, which /memhub:companion turns on and persists.", + "version": "0.103.0", "license": "Apache-2.0", "author": { "name": "XTrace" diff --git a/plugins/memhub/.mcp.json b/plugins/memhub/.mcp.json index 9f27eb87..557a9378 100644 --- a/plugins/memhub/.mcp.json +++ b/plugins/memhub/.mcp.json @@ -4,7 +4,7 @@ "type": "http", "url": "https://api.memhub.xtrace.ai/mcp-server/mcp", "headers": { - "X-MemHub-Plugin-Version": "0.88.0" + "X-MemHub-Plugin-Version": "0.103.0" }, "headersHelper": "python3 -s -E \"${CLAUDE_PLUGIN_ROOT}/scripts/mcp_headers.py\"", "auth": { diff --git a/plugins/memhub/companion/animal.ts b/plugins/memhub/companion/animal.ts index f2cf2ca7..6f48ac30 100644 --- a/plugins/memhub/companion/animal.ts +++ b/plugins/memhub/companion/animal.ts @@ -5,8 +5,14 @@ import type { Canvas, RGB } from './pixels' -/** Why the animal is speaking; the bubble's colour follows it. */ -export type Tone = 'advice' | 'blocked' +/** + * Why the animal is speaking; the bubble's colour follows it. `proposed` is a + * new rule waiting to be activated or rejected: every animal presents it with + * a prelude of its own and holds it at least 8 s with the `proposed` tone, so + * the Activate / Reject / Later buttons can sit inside its bubble. An animal + * with no prelude still says it in the `proposed` tone, never as `advice`. + */ +export type Tone = 'advice' | 'blocked' | 'proposed' /** One character drawn over the pixels, at canvas pixel (x, y). */ export type Glyph = { x: number; y: number; ch: string; color: RGB } @@ -31,13 +37,13 @@ export type Painted = { } /** - * The seven poses the director asks for, each a generator of the animal's own + * The poses the director asks for, each a generator of the animal's own * frame type. Four are finite and play whole; `sleep` and `look` are endless - * and the director cuts them off; `speak` ends on its own. + * and the director cuts them off; `speak` and `pet` end on their own. * * The director only ever walks this ring: * - * enter → sleep ⇄ (wake → look) → rise → speak+ → leave → enter + * enter → sleep ⇄ (wake → look ⇄ pet) → rise → speak+ → leave → enter * * so every finite pose must end where the next one begins. */ @@ -63,6 +69,12 @@ export type Poses = { speak(text: string, n: number, tone: Tone): Generator /** The speaking position → offscreen, ready for `enter` again. */ leave(): Generator + /** + * Awake → awake: what a click on the animal does. 20–40 frames, starting + * and ending on the first frame `look` yields. Without it the director + * floats hearts over `look` instead. + */ + pet?(): Generator } /** diff --git a/plugins/memhub/companion/animals/goose/art.ts b/plugins/memhub/companion/animals/goose/art.ts index 2637dbe0..abe37cbc 100644 --- a/plugins/memhub/companion/animals/goose/art.ts +++ b/plugins/memhub/companion/animals/goose/art.ts @@ -84,7 +84,12 @@ export const EYES: Readonly>>> = { stern: { '1,3': 'K', '0,2': 'K' }, } export const BEAK_OPEN = { '0,1': 'O', '1,1': 'o' } -export const BLUSH = { '1,4': 'R' } +/** + * The blush stamps nothing on the head. The head is three pixels wide, and a + * pink pixel beside the eye at (1,3) read as a bloodshot eye, not a cheek. + * The goose shows it is pleased with hearts, sparkles and a hop instead. + */ +export const BLUSH: Readonly> = {} /** one solid cell on the chest */ export const BADGE = { '4,3': 'B', '5,3': 'B' } diff --git a/plugins/memhub/companion/animals/goose/index.ts b/plugins/memhub/companion/animals/goose/index.ts index 0c09e590..f6a22276 100644 --- a/plugins/memhub/companion/animals/goose/index.ts +++ b/plugins/memhub/companion/animals/goose/index.ts @@ -5,7 +5,7 @@ import type { Animal, Build, Glyph, Painted, Tone } from '../../animal' import { PAL } from './art' import { renderCanvas } from './scene' import { H, W } from './scene-consts' -import { BUBBLE_AT, type Frame, POSES, cycle, enter, leave, look, rise, sleep, speak, wake } from './script' +import { BUBBLE_AT, type Frame, POSES, cycle, enter, leave, look, pet, rise, sleep, speak, wake } from './script' const build: Build = { pixelSize: 1, @@ -20,6 +20,7 @@ const build: Build = { rise, speak: (text: string, _n: number, tone: Tone) => speak(text, tone), leave, + pet, }, render(st: Frame): Painted { @@ -29,7 +30,7 @@ const build: Build = { canvas: renderCanvas(st), glyphs, bubble: st.said - ? { text: st.said, shown: st.shown, tag: 'rule', tone: st.tone } + ? { text: st.said, shown: st.shown, tag: st.tone === 'proposed' ? 'new rule' : 'rule', tone: st.tone } : null, } }, diff --git a/plugins/memhub/companion/animals/goose/script.ts b/plugins/memhub/companion/animals/goose/script.ts index d63e5dfc..fe11c78e 100644 --- a/plugins/memhub/companion/animals/goose/script.ts +++ b/plugins/memhub/companion/animals/goose/script.ts @@ -34,6 +34,11 @@ export type Frame = { export const BUBBLE_AT = { column: 5, row: 10 } /** Frames the finished message stays up (>= 40). */ const HOLD_FRAMES = 44 +/** Frames a proposal stays up: long enough to read it and reach for a button. */ +const ASK_FRAMES = 8 * FPS +/** Where the standing goose's head is, and the row above it for a mark. */ +const HEAD_X = GX + 3 +const ABOVE_HEAD = GY - 2 /** One watching cycle in this many carries the glance; the rest only blink. */ const GLANCE_EVERY = 4 /** Frames offscreen before it walks back on — two seconds of empty grass. */ @@ -205,16 +210,52 @@ function* hunt(tone: Tone = 'blocked'): Generator { yield S(T) } +/** Two gold sparkles either side of the head, trading places each beat. */ +function sparkles(beat: number): Overlay[] { + return beat % 2 + ? [[HEAD_X - 3, ABOVE_HEAD, '*', 'Y'], [HEAD_X + 4, GY, '+', 'Y']] + : [[HEAD_X - 2, GY, '+', 'Y'], [HEAD_X + 3, ABOVE_HEAD, '*', 'Y']] +} + +/** + * The proposal prelude. Something new has arrived: the goose looks both ways, + * a gold `!` pops over its head, and it hops twice on the spot in a flurry of + * sparkles, blushing — a gift it cannot wait to hand over. Ends on the + * speaking position, so `speak` can type from there. + */ +function* present(tone: Tone = 'proposed'): Generator { + const T: Partial = { tone, badge: true } + yield* hold(S({ ...T, eyes: 'right' }), 4) + yield* hold(S({ ...T, eyes: 'left' }), 4) + yield* hold(S({ ...T, blush: true, overlays: [[HEAD_X, ABOVE_HEAD, '!', 'Y']] }), 8) + for (let beat = 0; beat < 4; beat++) { + yield* hold(S({ ...T, body: beat % 2 ? 'flapD' : 'flapU', blush: true, overlays: sparkles(beat) }), 3) + } + yield* hold(S({ ...T, blush: true }), 3) +} + +/** The asking hold: a `?` bobbing over the head, the odd blink and glance. */ +function* ask(base: Partial, text: string): Generator { + for (let g = 0; g < ASK_FRAMES; g++) { + const bob = Math.floor(g / 10) % 2 ? ABOVE_HEAD - 2 : ABOVE_HEAD + const eyes: Eyes = g % 60 === 50 || g % 60 === 51 ? 'shut' : g % 120 >= 80 && g % 120 < 92 ? 'left' : 'open' + yield S({ ...base, eyes, blush: true, said: text, shown: text.length, overlays: [[HEAD_X, bob, '?', 'Y']] }) + } +} + /** * Type it out, then hold it long enough to read. Starts and ends on the same * neutral frame. `advice` keeps a steady eye; `blocked` stages the hunt - * first, then says it with a stern brow. + * first, then says it with a stern brow; `proposed` presents it with a hop + * and a blush, then holds it longer with a `?`, asking for an answer. */ export function* speak(text: string, tone: Tone = 'advice'): Generator { const base: Partial = { badge: true, tone } const talk: Eyes = tone === 'blocked' ? 'stern' : 'open' if (tone === 'blocked') { yield* hunt(tone) + } else if (tone === 'proposed') { + yield* present(tone) } else { yield S({ ...base, said: text, shown: 0 }) } @@ -223,7 +264,12 @@ export function* speak(text: string, tone: Tone = 'advice'): Generator { while (k < text.length) { k = Math.min(text.length, k + 1.6) f += 1 - yield S({ ...base, eyes: talk, beak: f % 2 ? 'open' : 'shut', said: text, shown: Math.trunc(k) }) + yield S({ ...base, eyes: talk, beak: f % 2 ? 'open' : 'shut', blush: tone === 'proposed', said: text, shown: Math.trunc(k) }) + } + if (tone === 'proposed') { + yield* ask(base, text) + yield S({ ...base, said: text, shown: text.length }) + return } for (let g = 0; g < HOLD_FRAMES; g++) { const eyes: Eyes = g === 30 || g === 31 ? 'shut' : g < 30 ? talk : 'open' @@ -232,6 +278,25 @@ export function* speak(text: string, tone: Tone = 'advice'): Generator { yield S({ ...base, said: text, shown: text.length }) } +/** + * A click: a happy honk and two flaps, blushing, while two pink hearts float + * up off the head; then a pleased squint. Starts and ends on look's first + * frame, standing, so the director can cut it in and out of `look`. + */ +export function* pet(): Generator { + const hearts = (k: number): Overlay[] => + [[HEAD_X - 2, ABOVE_HEAD - 2 * k, '♥', 'R'], [HEAD_X + 3, ABOVE_HEAD - 2 * k, '♥', 'R']] + yield S() + for (let beat = 0; beat < 4; beat++) { + const up = beat % 2 === 0 + yield* hold(S({ body: up ? 'flapU' : 'flapD', beak: up ? 'open' : 'shut', blush: true, overlays: hearts(beat >> 1) }), 3) + } + yield* hold(S({ blush: true, overlays: hearts(1) }), 6) + yield* hold(S({ blush: true }), 6) + yield* hold(S({ eyes: 'shut', blush: true }), 4) + yield S() +} + /** * The speaking position → offscreen: takes off, up and to the right, and * stays gone. The empty beat is the point: without it the goose lands and @@ -246,6 +311,7 @@ export function* leave(): Generator { export const SAMPLE: Readonly> = { advice: 'Rule fired: run only the touched test suites', blocked: 'Blocked: never force-push to a shared branch', + proposed: 'New rule↗ proposed, not active yet: Pin the MCP server when spawning claude -p', } /** The ring the mod walks, for `/goose demo`. */ @@ -260,6 +326,7 @@ export function* cycle(): Generator { yield* rise() yield* speak(SAMPLE.advice) yield* speak(SAMPLE.blocked, 'blocked') + yield* speak(SAMPLE.proposed, 'proposed') yield* leave() } } @@ -268,4 +335,6 @@ export const POSES: Readonly Generator>> = { enter, sleep, wake, look, rise, leave, speak: () => speak(SAMPLE.advice), hunt: () => speak(SAMPLE.blocked, 'blocked'), + propose: () => speak(SAMPLE.proposed, 'proposed'), + pet, } diff --git a/plugins/memhub/companion/animals/hippo/big/script.ts b/plugins/memhub/companion/animals/hippo/big/script.ts index f1f9bce6..965dcd1b 100644 --- a/plugins/memhub/companion/animals/hippo/big/script.ts +++ b/plugins/memhub/companion/animals/hippo/big/script.ts @@ -4,12 +4,15 @@ // cutting them on any frame is safe. import type { Tone } from '../../../animal' +import type { RGB } from '../../../pixels' import type { Eyes, Mouth, SquidPose } from './art' import { SQUID_Y } from './art' -import { DOZE, GONE, LOW, LURK, PEEK, RISEN, type SpawnKind, SX } from './scene' +import { DOZE, GONE, HY, LOW, LURK, PEEK, RISEN, type SpawnKind, SX } from './scene' export type Z = readonly [x: number, y: number, ch: 'z' | 'Z'] export type Squid = readonly [x: number, y: number, pose: SquidPose] +/** A character over the pixels in a colour of its own: a `!`, a `?`, a heart. */ +export type Mark = readonly [x: number, y: number, ch: string, color: RGB] export type Frame = { lift: number @@ -23,6 +26,7 @@ export type Frame = { shown: number tone: Tone spawn: SpawnKind | null + marks: readonly Mark[] } /** The bubble's anchor: column 5, row 10, in canvas pixels. */ @@ -32,7 +36,7 @@ export const BUBBLE_AT = { column: SX, row: 10 } export function S(kw: Partial = {}): Frame { return { lift: GONE, dx: 0, eyes: 'open', mouth: 'shut', ears: false, - zs: [], squid: null, said: null, shown: 0, tone: 'advice', spawn: null, + zs: [], squid: null, said: null, shown: 0, tone: 'advice', spawn: null, marks: [], ...kw, } } @@ -137,12 +141,53 @@ function* intercept(): Generator { yield* hold(S(base), 4) } -/** Type it out, then hold it long enough to read. Ends where it started. */ +const GOLD: RGB = [240, 200, 60] +const PINK: RGB = [240, 124, 150] +/** The head's middle column, and the row just over it at the speaking lift. */ +const HEAD_X = SX + 6 +const OVER_HEAD = HY - RISEN - 2 +/** Frames a proposal stays up once typed: long enough to read it and reach for a button. */ +const ASK_FRAMES = 160 + +/** + * The proposal prelude. Something new has surfaced: the hippo looks both + * ways, then a gold `!` pops over its head in a burst of bubbles while its + * ears wiggle. Starts and ends on the speaking position. + */ +function* present(tone: Tone): Generator { + const T = { lift: RISEN, tone } + yield* hold(S({ ...T, eyes: 'right' }), 4) + yield* hold(S({ ...T, eyes: 'left' }), 4) + for (let i = 0; i < 8; i++) { + yield S({ + ...T, ears: Math.floor(i / 2) % 2 === 1, marks: [[HEAD_X, OVER_HEAD, '!', GOLD]], + spawn: i === 0 ? 'bubbles' : null, + }) + } + yield* hold(S(T), 2) +} + +/** The asking hold: a gold `?` bobbing over the head, the odd blink and glance. */ +function* ask(base: Partial, text: string): Generator { + for (let g = 0; g < ASK_FRAMES; g++) { + const eyes: Eyes = g % 60 === 50 || g % 60 === 51 ? 'shut' : g % 120 >= 80 && g % 120 < 92 ? 'left' : 'open' + const bob = Math.floor(g / 10) % 2 ? OVER_HEAD - 2 : OVER_HEAD + yield S({ ...base, eyes, said: text, shown: text.length, marks: [[HEAD_X, bob, '?', GOLD]] }) + } +} + +/** + * Type it out, then hold it long enough to read. Ends where it started. + * `blocked` has the squid bitten first; `proposed` is presented with a `!` + * and held longer with a `?`, asking for an answer. + */ export function* speak(text: string, tone: Tone = 'advice'): Generator { const base = { lift: RISEN, tone } yield S(base) // neutral, and where it ends up again if (tone === 'blocked') { yield* intercept() // the bite says it first + } else if (tone === 'proposed') { + yield* present(tone) } let k = 0.0 let f = 0 @@ -151,6 +196,11 @@ export function* speak(text: string, tone: Tone = 'advice'): Generator { f += 1 yield S({ ...base, said: text, shown: Math.trunc(k), mouth: f % 2 ? 'open' : 'shut' }) } + if (tone === 'proposed') { + yield* ask(base, text) + yield S({ ...base, said: text, shown: text.length }) + return + } for (let g = 0; g < 60; g++) { // 3 seconds to read it yield S({ @@ -161,6 +211,25 @@ export function* speak(text: string, tone: Tone = 'advice'): Generator { yield* hold(S({ ...base, said: text, shown: text.length }), 2) } +/** + * A click: the ears wiggle, the mouth opens, and a pink heart rises off the + * nostrils in a few bubbles; then a pleased squint. Starts and ends on look's + * first frame, so the director can cut it in and out of `look`. + */ +export function* pet(): Generator { + const rest = S({ lift: DOZE }) + yield rest + for (let i = 0; i < 12; i++) { + yield S({ + lift: DOZE, eyes: 'shut', ears: Math.floor(i / 3) % 2 === 0, mouth: i >= 3 && i < 9 ? 'open' : 'shut', + marks: [[SX + 3, HY - DOZE - 2 - 2 * Math.floor(i / 4), '♥', PINK]], spawn: i === 0 ? 'bubbles' : null, + }) + } + yield* hold(S({ lift: DOZE, eyes: 'shut' }), 8) + yield* hold(rest, 8) + yield rest +} + /** The speaking position → offscreen, ready for `enter` again. */ export function* leave(): Generator { yield* hold(S({ lift: RISEN, eyes: 'open' }), 2) @@ -175,6 +244,7 @@ export function* leave(): Generator { export const SAMPLE: Readonly> = { advice: 'Rule fired: run only the touched test suites', blocked: 'Blocked: never force-push to a shared branch', + proposed: 'New rule↗ proposed, not active yet: Pin the MCP server when spawning claude -p', } /** hippo_band.py's own preview loop: the ring, for `/hippo demo`. */ @@ -189,6 +259,7 @@ export function* cycle(): Generator { yield* rise() yield* speak(SAMPLE.advice) yield* speak(SAMPLE.blocked, 'blocked') + yield* speak(SAMPLE.proposed, 'proposed') yield* leave() } } @@ -197,4 +268,6 @@ export const POSES: Readonly Generator>> = { enter, sleep, wake, look, rise, leave, speak: () => speak(SAMPLE.advice), blocked: () => speak(SAMPLE.blocked, 'blocked'), + propose: () => speak(SAMPLE.proposed, 'proposed'), + pet, } diff --git a/plugins/memhub/companion/animals/hippo/index.ts b/plugins/memhub/companion/animals/hippo/index.ts index 0a11e5e8..0c356069 100644 --- a/plugins/memhub/companion/animals/hippo/index.ts +++ b/plugins/memhub/companion/animals/hippo/index.ts @@ -34,18 +34,22 @@ function buildOf(pixelSize: number, { scene, script }: Parts): Build script.speak(text, tone), leave: script.leave, + pet: script.pet, }, render(st, tick): Painted { if (st.spawn) { particles = particles.concat(scene.spawn(st.spawn, tick)) } particles = scene.stepParticles(particles) - const glyphs: Glyph[] = st.zs.map(([x, y, ch]) => ({ x, y, ch, color: Z_COLOR })) + const glyphs: Glyph[] = [ + ...st.zs.map(([x, y, ch]) => ({ x, y, ch, color: Z_COLOR })), + ...st.marks.map(([x, y, ch, color]) => ({ x, y, ch, color })), + ] return { canvas: scene.renderCanvas(st, tick, particles), glyphs, bubble: st.said - ? { text: st.said, shown: st.shown, tag: 'rule', tone: st.tone } + ? { text: st.said, shown: st.shown, tag: st.tone === 'proposed' ? 'new rule' : 'rule', tone: st.tone } : null, } }, diff --git a/plugins/memhub/companion/animals/hippo/small/script.ts b/plugins/memhub/companion/animals/hippo/small/script.ts index 900b5721..340a1df6 100644 --- a/plugins/memhub/companion/animals/hippo/small/script.ts +++ b/plugins/memhub/companion/animals/hippo/small/script.ts @@ -12,12 +12,15 @@ // cutting them on any frame is safe. import type { Tone } from '../../../animal' +import type { RGB } from '../../../pixels' import type { Eyes, Mouth, SquidPose } from './art' import { SQUID_Y } from './art' -import { DOZE, GONE, LOW, LURK, PEEK, RISEN, type SpawnKind, SX } from './scene' +import { DOZE, GONE, HY, LOW, LURK, PEEK, RISEN, type SpawnKind, SX } from './scene' export type Z = readonly [x: number, y: number, ch: 'z' | 'Z'] export type Squid = readonly [x: number, y: number, pose: SquidPose] +/** A character over the pixels in a colour of its own: a `!`, a `?`, a heart. */ +export type Mark = readonly [x: number, y: number, ch: string, color: RGB] export type Frame = { lift: number @@ -31,6 +34,7 @@ export type Frame = { shown: number tone: Tone spawn: SpawnKind | null + marks: readonly Mark[] } /** The bubble's anchor: column 5, row 10, in canvas pixels. */ @@ -40,7 +44,7 @@ export const BUBBLE_AT = { column: SX, row: 10 } export function S(kw: Partial = {}): Frame { return { lift: GONE, dx: 0, eyes: 'open', mouth: 'shut', ears: false, - zs: [], squid: null, said: null, shown: 0, tone: 'advice', spawn: null, + zs: [], squid: null, said: null, shown: 0, tone: 'advice', spawn: null, marks: [], ...kw, } } @@ -150,12 +154,53 @@ function* intercept(): Generator { yield* hold(S(base), 4) } -/** Type it out, then hold it long enough to read. Ends where it started. */ +const GOLD: RGB = [240, 200, 60] +const PINK: RGB = [240, 124, 150] +/** The head's middle column, and the row just over it at the speaking lift. */ +const HEAD_X = SX + 6 +const OVER_HEAD = HY - RISEN - 2 +/** Frames a proposal stays up once typed: long enough to read it and reach for a button. */ +const ASK_FRAMES = 160 + +/** + * The proposal prelude. Something new has surfaced: the hippo looks both + * ways, then a gold `!` pops over its head in a burst of bubbles while its + * ears wiggle. Starts and ends on the speaking position. + */ +function* present(tone: Tone): Generator { + const T = { lift: RISEN, tone } + yield* hold(S({ ...T, eyes: 'right' }), 4) + yield* hold(S({ ...T, eyes: 'left' }), 4) + for (let i = 0; i < 8; i++) { + yield S({ + ...T, ears: Math.floor(i / 2) % 2 === 1, marks: [[HEAD_X, OVER_HEAD, '!', GOLD]], + spawn: i === 0 ? 'bubbles' : null, + }) + } + yield* hold(S(T), 2) +} + +/** The asking hold: a gold `?` bobbing over the head, the odd blink and glance. */ +function* ask(base: Partial, text: string): Generator { + for (let g = 0; g < ASK_FRAMES; g++) { + const eyes: Eyes = g % 60 === 50 || g % 60 === 51 ? 'shut' : g % 120 >= 80 && g % 120 < 92 ? 'left' : 'open' + const bob = Math.floor(g / 10) % 2 ? OVER_HEAD - 2 : OVER_HEAD + yield S({ ...base, eyes, said: text, shown: text.length, marks: [[HEAD_X, bob, '?', GOLD]] }) + } +} + +/** + * Type it out, then hold it long enough to read. Ends where it started. + * `blocked` has the squid bitten first; `proposed` is presented with a `!` + * and held longer with a `?`, asking for an answer. + */ export function* speak(text: string, tone: Tone = 'advice'): Generator { const base = { lift: RISEN, tone } yield S(base) // neutral, and where it ends up again if (tone === 'blocked') { yield* intercept() // the bite says it first + } else if (tone === 'proposed') { + yield* present(tone) } let k = 0.0 let f = 0 @@ -164,6 +209,11 @@ export function* speak(text: string, tone: Tone = 'advice'): Generator { f += 1 yield S({ ...base, said: text, shown: Math.trunc(k), mouth: f % 2 ? 'open' : 'shut' }) } + if (tone === 'proposed') { + yield* ask(base, text) + yield S({ ...base, said: text, shown: text.length }) + return + } for (let g = 0; g < 60; g++) { // 3 seconds to read it yield S({ @@ -174,6 +224,25 @@ export function* speak(text: string, tone: Tone = 'advice'): Generator { yield* hold(S({ ...base, said: text, shown: text.length }), 2) } +/** + * A click: the ears wiggle, the mouth opens, and a pink heart rises off the + * nostrils in a few bubbles; then a pleased squint. Starts and ends on look's + * first frame, so the director can cut it in and out of `look`. + */ +export function* pet(): Generator { + const rest = S({ lift: DOZE }) + yield rest + for (let i = 0; i < 12; i++) { + yield S({ + lift: DOZE, eyes: 'shut', ears: Math.floor(i / 3) % 2 === 0, mouth: i >= 3 && i < 9 ? 'open' : 'shut', + marks: [[SX + 3, HY - DOZE - 2 - 2 * Math.floor(i / 4), '♥', PINK]], spawn: i === 0 ? 'bubbles' : null, + }) + } + yield* hold(S({ lift: DOZE, eyes: 'shut' }), 8) + yield* hold(rest, 8) + yield rest +} + /** The speaking position → offscreen, ready for `enter` again. */ export function* leave(): Generator { yield* hold(S({ lift: RISEN, eyes: 'open' }), 2) @@ -187,6 +256,7 @@ export function* leave(): Generator { export const SAMPLE: Readonly> = { advice: 'Rule fired: run only the touched test suites', blocked: 'Blocked: never force-push to a shared branch', + proposed: 'New rule↗ proposed, not active yet: Pin the MCP server when spawning claude -p', } /** hippo_band.py's own preview loop: the ring, for `/hippo demo`. */ @@ -201,6 +271,7 @@ export function* cycle(): Generator { yield* rise() yield* speak(SAMPLE.advice) yield* speak(SAMPLE.blocked, 'blocked') + yield* speak(SAMPLE.proposed, 'proposed') yield* leave() } } @@ -209,4 +280,6 @@ export const POSES: Readonly Generator>> = { enter, sleep, wake, look, rise, leave, speak: () => speak(SAMPLE.advice), blocked: () => speak(SAMPLE.blocked, 'blocked'), + propose: () => speak(SAMPLE.proposed, 'proposed'), + pet, } diff --git a/plugins/memhub/companion/animals/index.ts b/plugins/memhub/companion/animals/index.ts index 21304144..ce90425d 100644 --- a/plugins/memhub/companion/animals/index.ts +++ b/plugins/memhub/companion/animals/index.ts @@ -7,9 +7,10 @@ import type { Animal } from '../animal' import { goose } from './goose' import { hippo } from './hippo' import { penguin } from './penguin' +import { shiba } from './shiba' // Gus first: the band shows the first of these to anyone who has not picked // one, and a saved choice wins over it. -export const ANIMALS: readonly Animal[] = [goose as Animal, hippo as Animal, penguin as Animal] +export const ANIMALS: readonly Animal[] = [goose as Animal, hippo as Animal, penguin as Animal, shiba as Animal] export const DEFAULT_ANIMAL = ANIMALS[0]! diff --git a/plugins/memhub/companion/animals/penguin/art.ts b/plugins/memhub/companion/animals/penguin/art.ts index 46a59038..6b028dea 100644 --- a/plugins/memhub/companion/animals/penguin/art.ts +++ b/plugins/memhub/companion/animals/penguin/art.ts @@ -60,18 +60,20 @@ export const FEET: Readonly> = { * Flippers, as [column, row, colour] in STAND's coordinates. Each fills both * rows of a cell, or only the lower one: never the upper row alone. */ -export type LeftFlipper = 'down' | 'out' | 'pocket' | 'phone' | 'ear' +export type LeftFlipper = 'down' | 'out' | 'up' | 'pocket' | 'phone' | 'ear' export const LEFT_FLIPPER: Readonly> = { down: [[1, 6, 'K'], [1, 7, 'K']], out: [[1, 4, 'K'], [1, 5, 'K'], [0, 5, 'K']], + up: [[1, 4, 'K'], [1, 5, 'K'], [0, 2, 'K'], [0, 3, 'K']], pocket: [[1, 6, 'K'], [1, 7, 'K'], [3, 6, 'K'], [3, 7, 'K']], phone: [[1, 4, 'K'], [1, 5, 'K'], [0, 4, 'H'], [0, 5, 'H']], ear: [[1, 4, 'K'], [1, 5, 'K'], [1, 2, 'H'], [1, 3, 'H']], } -export type RightFlipper = 'down' | 'out' | 'point' +export type RightFlipper = 'down' | 'out' | 'up' | 'point' export const RIGHT_FLIPPER: Readonly> = { down: [[8, 6, 'K'], [8, 7, 'K']], out: [[8, 4, 'K'], [8, 5, 'K'], [9, 5, 'K']], + up: [[8, 4, 'K'], [8, 5, 'K'], [9, 2, 'K'], [9, 3, 'K']], point: [[8, 5, 'K'], [9, 5, 'K']], } diff --git a/plugins/memhub/companion/animals/penguin/index.ts b/plugins/memhub/companion/animals/penguin/index.ts index 3f16f0cd..f57a8521 100644 --- a/plugins/memhub/companion/animals/penguin/index.ts +++ b/plugins/memhub/companion/animals/penguin/index.ts @@ -5,7 +5,7 @@ import type { Animal, Build, Glyph, Painted, Tone } from '../../animal' import { PAL } from './art' import { renderCanvas } from './scene' import { H, W } from './scene-consts' -import { BUBBLE_AT, type Frame, POSES, cycle, enter, leave, look, rise, sleep, speak, wake } from './script' +import { BUBBLE_AT, type Frame, POSES, cycle, enter, leave, look, pet, rise, sleep, speak, wake } from './script' const build: Build = { pixelSize: 1, @@ -20,6 +20,7 @@ const build: Build = { rise, speak: (text: string, _n: number, tone: Tone) => speak(text, tone), leave, + pet, }, render(st: Frame, tick: number): Painted { @@ -29,7 +30,7 @@ const build: Build = { canvas: renderCanvas(st, tick), glyphs, bubble: st.said - ? { text: st.said, shown: st.shown, tag: 'rule', tone: st.tone } + ? { text: st.said, shown: st.shown, tag: st.tone === 'proposed' ? 'new rule' : 'rule', tone: st.tone } : null, } }, @@ -40,5 +41,6 @@ const build: Build = { export const penguin: Animal = { name: 'Penguin', + title: 'Penelope the Penguin', builds: [build], } diff --git a/plugins/memhub/companion/animals/penguin/script.ts b/plugins/memhub/companion/animals/penguin/script.ts index 4a2f6880..c946ac92 100644 --- a/plugins/memhub/companion/animals/penguin/script.ts +++ b/plugins/memhub/companion/animals/penguin/script.ts @@ -246,9 +246,56 @@ function* blockedScript(text: string): Generator { yield F(said) } -/** speaking position → the same position. Types out, holds 44 frames. */ +/** The head's middle column at rest, and the row just over it. */ +const HEAD_X = PX + 4 +const OVER_HEAD = FOOT - 10 +/** Frames a proposal stays up once typed: long enough to read it and reach for a button. */ +const ASK_FRAMES = 160 + +/** + * A new rule to present: the penguin looks both ways, throws both flippers + * up under a gold `!` and hops from foot to foot, then says it in the + * speaking position and holds it with a `?` bobbing overhead, asking for an + * answer. Starts and ends on the speaking position. + */ +function* proposedScript(text: string): Generator { + const on = { tone: 'proposed' as const } + yield F(on) + yield* hold(F({ ...on, eyes: 'right' }), 4) + yield* hold(F({ ...on, eyes: 'left' }), 4) + for (let i = 0; i < 8; i++) { + const feet: Feet = i < 6 ? (Math.floor(i / 3) % 2 ? 'b' : 'a') : 'stand' + yield S({ ...on, lf: 'up', rf: 'up', feet, glyphs: [[HEAD_X, OVER_HEAD, '!', 'O']] }) + } + yield* hold(F(on), 2) + yield* typed(text, 'proposed') + const said = { said: text, shown: text.length, tone: 'proposed' as const } + for (let g = 0; g < ASK_FRAMES; g++) { + const eyes: Eyes = g % 60 === 50 || g % 60 === 51 ? 'shut' : g % 120 >= 80 && g % 120 < 92 ? 'left' : 'open' + const bob = Math.floor(g / 10) % 2 ? OVER_HEAD - 2 : OVER_HEAD + yield F({ ...said, eyes, glyphs: [[HEAD_X, bob, '?', 'O']] }) + } + yield F(said) +} + +/** speaking position → the same position. Types out, holds 44 frames; a proposal, longer. */ export function* speak(text: string, tone: Tone = 'advice'): Generator { - yield* tone === 'blocked' ? blockedScript(text) : adviceScript(text) + yield* tone === 'blocked' ? blockedScript(text) : tone === 'proposed' ? proposedScript(text) : adviceScript(text) +} + +/** + * A click: a happy squint and a flutter of both flippers while a pink heart + * rises over the head. Starts and ends on look's first frame, standing. + */ +export function* pet(): Generator { + yield S() + for (let i = 0; i < 12; i++) { + const flap = Math.floor(i / 3) % 2 === 0 ? ('out' as const) : ('down' as const) + yield S({ eyes: 'shut', lf: flap, rf: flap, glyphs: [[HEAD_X, OVER_HEAD - 2 * Math.floor(i / 6), '♥', 'P']] }) + } + yield* hold(S({ eyes: 'shut' }), 8) + yield* hold(S(), 8) + yield S() } /** The speaking position → offscreen, left, ready for `enter` again. */ @@ -263,6 +310,7 @@ export function* leave(): Generator { export const SAMPLE: Readonly> = { advice: 'Rule fired: run only the touched test suites', blocked: 'Blocked: never force-push to a shared branch', + proposed: 'New rule↗ proposed, not active yet: Pin the MCP server when spawning claude -p', } /** The ring the mod walks, for `/penguin demo`. */ @@ -277,6 +325,7 @@ export function* cycle(): Generator { yield* rise() yield* speak(SAMPLE.advice) yield* speak(SAMPLE.blocked, 'blocked') + yield* speak(SAMPLE.proposed, 'proposed') yield* leave() } } @@ -285,4 +334,6 @@ export const POSES: Readonly Generator>> = { enter, sleep, wake, look, rise, leave, speak: () => speak(SAMPLE.advice), blocked: () => speak(SAMPLE.blocked, 'blocked'), + propose: () => speak(SAMPLE.proposed, 'proposed'), + pet, } diff --git a/plugins/memhub/companion/animals/shiba/art.ts b/plugins/memhub/companion/animals/shiba/art.ts new file mode 100644 index 00000000..fb269abc --- /dev/null +++ b/plugins/memhub/companion/animals/shiba/art.ts @@ -0,0 +1,207 @@ +// Layer 1 — art, data only. shiba_band.py's palette and sprites: one +// character a pixel, `.` transparent. Popo stands and lies facing LEFT, toward +// the bubble, and sits facing you; `mirror` gives the right-facing runner. +// +// Row pairs at size 1: every sprite is an odd number of rows tall and stands +// on row 10, so its top is always even and its rows pair as ears|head, +// eyes|nose, chest|belly, paws|grass. No outlines: the only dark pixels (eyes, +// nose, open mouth) always sit under or beside coloured ones. + +import type { Palette } from '../../pixels' +import { FLOOR } from './scene-consts' + +export const PAL: Palette = { + O: [222, 152, 94], // coat + R: [152, 98, 58], // coat shadow, shut eyes, between the legs + C: [235, 198, 151], // cream: muzzle, chest, socks + K: [22, 20, 30], // eyes, nose, open mouth (and the squid's eyes) + b: [246, 248, 255], // bone + S: [217, 119, 87], // the squid, exactly as the goose's + s: [142, 64, 48], // its legs, shut eyes + G: [96, 160, 78], // grass + g: [56, 106, 50], // grass, dark tufts + u: [170, 160, 146], // dust kicked up + z: [190, 190, 232], // the z's (overlay characters) + y: [250, 214, 84], // barks and the squid's fright, "!" + w: [255, 244, 196], // a pleased twinkle, "*" + q: [250, 214, 84], // the proposal's "?", the barks' yellow + h: [240, 124, 150], // a pet's hearts, as the goose's +} + +/** Characters drawn over the pixels, by colour key: single-width, basic plane. */ +export const GLYPH: Readonly> = { z: 'z', y: '!', w: '*', q: '?', h: '♥' } + +export type Body = 'gone' | 'stand' | 'run1' | 'run2' | 'bark' | 'sit' | 'proud' | 'lie' +export type Eyes = 'open' | 'left' | 'right' | 'shut' +export type Mouth = 'shut' | 'open' | 'bone' +export type SquidEyes = 'fwd' | 'left' | 'right' | 'up' | 'shut' +export type BoneSize = 'whole' | 'bitten' | 'nub' + +type Patch = readonly [row: number, col: number, pixels: string] +type Tail = readonly [row: number, col: number, rows: readonly string[]] + +/** Side view, facing left. Front edge (the nose) at column 3. */ +const STAND = [ + '......O.O.........', + '.....OOOO.........', + '...OOOKOO.........', + '...KOOOOOOOOOOOO..', + '....CCCOOOOOOOOR..', + '.....CCCCCOOOORR..', + '.....C.C....C.C...', +] +const STAND_FRONT = 3 +const LEGS: Readonly> = { + stand: '.....C.C....C.C...', + run1: '....C...C..C...C..', + run2: '.......CC...CC....', +} +/** (row, col, pixels) patches; '.' leaves the base. */ +const STAND_MOUTH: Readonly> = { + shut: [], + open: [[4, 4, ' '], [5, 3, 'CC']], // jaw dropped + bone: [], // jaws shut on it +} +const STAND_TAIL: readonly Tail[] = [ + [1, 12, ['.OO.', 'OCCO']], + [1, 12, ['..OO', '.OCO']], + [1, 12, ['OO..', 'OCC.']], +] + +/** Sitting, facing you. Front edge (left cheek) at column 1. */ +const SIT = [ + '..OO....OO....', + '..OOOOOOOO....', + '.O___OO___O...', // ___ the eye slots: one dark pixel each + '.OCCCKKCCCO...', + '..CCC__CCCOO..', // __ the mouth + '..OCC__CCOO...', + '..OCCRRCCOO...', // front legs, shadow between them +] +/** Proud is the same sit: eyes shut, tail held up and still, a twinkle. */ +const SIT_PROUD = SIT +export const PROUD_TAIL = 1 +const SIT_FRONT = 1 +const SIT_EYES: Readonly> = { open: 'OKO', left: 'KOO', right: 'OOK', shut: 'ORO' } +/** Rows 4 and 5, columns 5-6. */ +const SIT_MOUTH: Readonly> = { + shut: ['CC', 'CC'], + open: ['CC', 'KK'], // jaw drops: nose, then mouth + bone: ['CC', 'CC'], // jaws shut on it +} +const SIT_TAIL: readonly Tail[] = [ + [2, 10, ['....', '..OO', '..OC', '.OC.']], + [2, 10, ['..OO', '..OC', '..O.', '.O..']], + [2, 10, ['....', '....', '..OO', 'OOCC']], +] + +/** Lying down, facing left, head up. Front edge (the nose) at column 0. */ +const LIE = [ + '...O.O.........', + '..OOOO.........', + 'OOO_OO.........', // _ the eye + 'KOOOOOOOOOOOOO.', + 'CCCCCOOOOOOOOR.', +] +/** The head turned to you. */ +const LIE_HEAD_FRONT = ['.O..O.', '.OOOO.', 'O_OO_O', 'OCKKCO'] +const LIE_FRONT = 0 +const LIE_EYE: Readonly> = { open: 'K', shut: 'R', left: 'K', right: 'K' } +const LIE_TAIL: readonly Tail[] = [ + [1, 10, ['.OO.', 'OCCO']], + [1, 10, ['..OO', '.OCO']], + [1, 10, ['OO..', 'OCC.']], +] + +/** The squid, as the goose's: 6 x 5, faces left, eyes stamped on. */ +const SQUID = ['.SSSS.', 'SSSSSS', 'SSSSSS', 'SSSSSS'] +const SQUID_EYES: Readonly> = { + fwd: [[2, 1, 'K'], [2, 4, 'K']], + left: [[2, 0, 'K'], [2, 3, 'K']], + right: [[2, 2, 'K'], [2, 5, 'K']], + up: [[1, 1, 'K'], [1, 4, 'K']], + shut: [[2, 1, 's'], [2, 4, 's']], +} +const SQUID_LEGS = ['s.s.s.', '.s.s.s'] +export const SQUID_H = SQUID.length + 1 + +/** + * One bone, the same shape in the mouth and on the ground. Three rows tall + * with its bar on the middle row, so it always sits with its top on an odd + * row and only ever moves a whole cell. Bites shorten it from the right. + */ +export const BONE: Readonly> = { + whole: ['b....b', 'bbbbbb', 'b....b'], + bitten: ['b...', 'bbbb', 'b...'], + nub: ['b.', 'bb', 'b.'], +} +/** Bar on the mouth row (canvas row 8). */ +export const BONE_MOUTH_Y = 7 +/** Bar on row 10, lower knobs in the grass. */ +export const BONE_GROUND_Y = 9 + +/** Overwrite a run of pixels; '.' in `pixels` keeps what is there, ' ' makes it transparent. */ +function patch(rows: string[], r: number, c: number, pixels: string) { + const row = rows[r]! + let out = '' + for (let i = 0; i < pixels.length; i++) { + const ch = pixels[i]! + out += ch === '.' ? row[c + i]! : ch === ' ' ? '.' : ch + } + rows[r] = row.slice(0, c) + out + row.slice(c + pixels.length) +} + +function tail(rows: string[], [r0, c0, px]: Tail) { + px.forEach((p, i) => patch(rows, r0 + i, c0, p)) +} + +const mirror = (rows: readonly string[]) => rows.map(r => [...r].reverse().join('')) + +export type DogState = { body: Body; facing: 'L' | 'R'; eyes: Eyes; mouth: Mouth; head: 'side' | 'front'; tail: number } + +/** + * [rows, front] for the frame's dog: rows are facing-corrected, `front` is the + * column in them that lands on the frame's `x`. Null when offscreen. + */ +export function dogSprite(st: DogState): readonly [readonly string[], number] | null { + const { body } = st + let rows: string[] + let front: number + if (body === 'gone') return null + if (body === 'stand' || body === 'run1' || body === 'run2' || body === 'bark') { + rows = [...STAND] + rows[6] = LEGS[body === 'bark' ? 'stand' : body] + for (const [r, c, px] of STAND_MOUTH[body === 'bark' ? 'open' : st.mouth]) patch(rows, r, c, px) + if (st.eyes === 'shut') patch(rows, 2, 6, 'R') + tail(rows, STAND_TAIL[st.tail]!) + front = STAND_FRONT + } else if (body === 'sit' || body === 'proud') { + rows = [...(body === 'proud' ? SIT_PROUD : SIT)] + rows[2] = rows[2]!.replaceAll('___', SIT_EYES[st.eyes]) + const [m4, m5] = SIT_MOUTH[st.mouth] + rows[4] = rows[4]!.replaceAll('__', m4) + rows[5] = rows[5]!.replaceAll('__', m5) + tail(rows, SIT_TAIL[st.tail]!) + front = SIT_FRONT + } else { + rows = [...LIE] + if (st.head === 'front') { + LIE_HEAD_FRONT.forEach((px, i) => { rows[i] = px + rows[i]!.slice(px.length) }) + rows[2] = rows[2]!.replaceAll('_', st.eyes === 'shut' ? 'R' : 'K') + } else { + rows[2] = rows[2]!.replaceAll('_', LIE_EYE[st.eyes]) + } + tail(rows, LIE_TAIL[st.tail]!) + front = LIE_FRONT + } + // flipped in place: same box, same `front` + return [st.facing === 'R' ? mirror(rows) : rows, front] +} + +export function squidRows(eyes: SquidEyes = 'fwd', legs = 0): string[] { + const rows = SQUID.map(r => [...r]) + for (const [r, c, ch] of SQUID_EYES[eyes]) rows[r]![c] = ch + return [...rows.map(r => r.join('')), SQUID_LEGS[((legs % 2) + 2) % 2]!] +} + +export const spriteTop = (rows: readonly string[]) => FLOOR - rows.length + 1 diff --git a/plugins/memhub/companion/animals/shiba/index.ts b/plugins/memhub/companion/animals/shiba/index.ts new file mode 100644 index 00000000..fbbb0612 --- /dev/null +++ b/plugins/memhub/companion/animals/shiba/index.ts @@ -0,0 +1,46 @@ +// The shiba as an Animal: shiba_band.py bound to the seven poses the director +// asks for. One build, drawn for one cell a pixel — the size the band gives. + +import type { Animal, Build, Glyph, Painted, Tone } from '../../animal' +import { GLYPH, PAL } from './art' +import { renderCanvas } from './scene' +import { H, W } from './scene-consts' +import { BUBBLE_AT, type Frame, POSES, cycle, enter, leave, look, pet, rise, sleep, speak, wake } from './script' + +const build: Build = { + pixelSize: 1, + size: { columns: W, rows: H }, + bubbleAt: { column: BUBBLE_AT.column, row: BUBBLE_AT.row }, + + poses: { + enter, + sleep, + wake, + look, + rise, + speak: (text: string, _n: number, tone: Tone) => speak(text, tone), + leave, + pet, + }, + + render(st: Frame): Painted { + // a glyph is named by its palette key, which is also its colour + const glyphs: Glyph[] = st.glyphs.map(([x, y, key]) => ({ x, y, ch: GLYPH[key]!, color: PAL[key]! })) + return { + canvas: renderCanvas(st), + glyphs, + bubble: st.said + ? { text: st.said, shown: st.shown, tag: st.tone === 'proposed' ? 'new rule' : 'rule', tone: st.tone } + : null, + } + }, + + demo: (only?: string | null) => (only ? POSES[only]!() : cycle()), + demoPoses: Object.keys(POSES), +} + +export const shiba: Animal = { + name: 'Shiba', + title: 'Popo the Shiba', + builds: [build], +} diff --git a/plugins/memhub/companion/animals/shiba/scene-consts.ts b/plugins/memhub/companion/animals/shiba/scene-consts.ts new file mode 100644 index 00000000..734f7ddd --- /dev/null +++ b/plugins/memhub/companion/animals/shiba/scene-consts.ts @@ -0,0 +1,11 @@ +// The scene's fixed geometry, split out so the art and the choreography can +// read it without importing the drawing (which imports the frames it yields). + +export const W = 22 +export const H = 12 +/** The grass row; everything stands on row 10 above it. */ +export const GROUND = 11 +/** The bottom row of every standing sprite. */ +export const FLOOR = 10 +/** The dog's front edge when at home. */ +export const HOME = 6 diff --git a/plugins/memhub/companion/animals/shiba/scene.ts b/plugins/memhub/companion/animals/shiba/scene.ts new file mode 100644 index 00000000..8447513d --- /dev/null +++ b/plugins/memhub/companion/animals/shiba/scene.ts @@ -0,0 +1,47 @@ +// Layer 2 — scene. shiba_band.py's render_canvas: a 22x12 grid of RGB (or +// null for transparent) holding the grass, a squid, the dog, a bone and the +// loose pixels. No terminal code, no clock, no randomness. + +import { type Canvas, canvasOf } from '../../pixels' +import { BONE, dogSprite, PAL, spriteTop, squidRows } from './art' +import { FLOOR, GROUND, H, W } from './scene-consts' +import type { Frame } from './script' + +export { FLOOR, GROUND, H, HOME, W } from './scene-consts' +/** Dark grass blades poking up into row 10. */ +const TUFTS = [1, 4, 9, 13, 18, 20] + +function paint(cv: Canvas, rows: readonly string[], x: number, y: number) { + rows.forEach((row, r) => { + for (let c = 0; c < row.length; c++) { + const ch = row[c]! + if (ch !== '.' && y + r >= 0 && y + r < H && x + c >= 0 && x + c < W) { + cv[y + r]![x + c] = PAL[ch]! + } + } + }) +} + +/** One frame's pixels. `tick` is unused — nothing here moves on its own. */ +export function renderCanvas(st: Frame): Canvas { + const cv = canvasOf(W, H) + for (let x = 0; x < W; x++) cv[GROUND]![x] = x % 7 === 3 ? PAL.g! : PAL.G! + for (const x of TUFTS) cv[FLOOR]![x] = PAL.g! + if (st.squid) { + const [sx, sy, eyes, legs] = st.squid + paint(cv, squidRows(eyes, legs), sx, sy) + } + const sp = dogSprite(st) + if (sp) { + const [rows, front] = sp + paint(cv, rows, st.x - front, spriteTop(rows)) + } + if (st.bone) { + const [bx, by, size] = st.bone + paint(cv, BONE[size], bx, by) + } + for (const [x, y, key] of st.specks) { + if (x >= 0 && x < W && y >= 0 && y < H) cv[y]![x] = PAL[key]! + } + return cv +} diff --git a/plugins/memhub/companion/animals/shiba/script.ts b/plugins/memhub/companion/animals/shiba/script.ts new file mode 100644 index 00000000..57a468f2 --- /dev/null +++ b/plugins/memhub/companion/animals/shiba/script.ts @@ -0,0 +1,364 @@ +// Layer 3 — choreography. shiba_band.py's poses, one frame per 1/20 s, +// ported frame for frame. Finite poses end exactly where the next begins; the +// endless two never move the body, only eyes, head turn, tail and overlays, +// so they can be cut on any frame. + +import type { Tone } from '../../animal' +import { type Body, BONE_GROUND_Y, BONE_MOUTH_Y, type BoneSize, type Eyes, type Mouth, PROUD_TAIL, SQUID_H, type SquidEyes } from './art' +import { FLOOR, HOME, W } from './scene-consts' + +/** A character over the pixels: x, y, and the palette key that names it. */ +export type Glyph = readonly [x: number, y: number, key: string] +/** A loose pixel: x, y, palette key. */ +export type Speck = readonly [x: number, y: number, key: string] +export type Squid = readonly [x: number, y: number, eyes: SquidEyes, legs: number] +export type Bone = readonly [x: number, y: number, size: BoneSize] + +export const FPS = 20 + +export type Frame = { + body: Body + x: number + facing: 'L' | 'R' + eyes: Eyes + mouth: Mouth + head: 'side' | 'front' + tail: number + squid: Squid | null + bone: Bone | null + specks: readonly Speck[] + glyphs: readonly Glyph[] + said: string | null + shown: number + tone: Tone +} + +/** The bubble's anchor: column 5, row 10, in canvas pixels. */ +export const BUBBLE_AT = { column: 5, row: 10 } +/** Frames the finished message stays up (>= 40). */ +const HOLD = 44 +/** Frames a proposal stays up: long enough to read it and reach for a button. */ +const ASK = 8 * FPS +/** One watching cycle in this many carries the glance; the rest only blink. */ +const GLANCE_EVERY = 4 +/** Frames offscreen after leave, before it trots back on. */ +const GONE_FRAMES = 2 * FPS +/** Over the sitting dog's head, between the ears, and the row above them. */ +const HEAD_X = HOME + 4 +const ABOVE_HEAD = 2 +/** Characters revealed per frame. */ +const TYPE_RATE = 1.6 + +/** One frame: plain values only. */ +export function S(kw: Partial = {}): Frame { + return { + body: 'sit', x: HOME, facing: 'L', eyes: 'open', mouth: 'shut', + head: 'side', tail: 0, squid: null, bone: null, specks: [], + glyphs: [], said: null, shown: 0, tone: 'advice', + ...kw, + } +} + +/** The speaking position. */ +const NEUTRAL = () => S() +/** Offscreen, between leave and enter. */ +const GONE = () => S({ body: 'gone', x: W }) +/** Tail phases, a full wag. */ +const WAG = [0, 1, 2, 1] as const +const wag = (i: number) => WAG[((i % 4) + 4) % 4]! + +function* hold(st: Frame, n: number): Generator { + for (let i = 0; i < n; i++) yield { ...st } +} + +/** The bone crosswise in the jaws of a dog standing at x, facing left. */ +const carried = (x: number): Bone => [x - 2, BONE_MOUTH_Y, 'whole'] + +/** Trot from x0 to x1 (exclusive of x1), kicking up dust behind. */ +function* run(x0: number, x1: number, step: number, facing: 'L' | 'R', carry = false, kw: Partial = {}): Generator { + const sign = x1 > x0 ? 1 : -1 + for (let f = 0, x = x0; (x - x1) * sign < 0; f++, x += step * sign) { + const back = x + (facing === 'L' ? 12 : -2) + const dust: Speck[] = f % 2 === 0 ? [[back, FLOOR, 'u']] : [] + yield S({ + body: f % 2 === 0 ? 'run1' : 'run2', x, facing, + tail: 1, specks: dust, bone: carry ? carried(x) : null, ...kw, + }) + } +} + +/** Reveal the text, flapping the mouth between the pair in `flap`. */ +function* typing(text: string, flap: readonly [Mouth, Mouth], kw: Partial = {}): Generator { + let k = 0.0 + for (let f = 0; k < text.length; f++) { + k = Math.min(text.length, k + TYPE_RATE) + yield S({ mouth: flap[f % 2]!, said: text, shown: Math.trunc(k), ...kw }) + } +} + +/** Offscreen → asleep's starting point: trots in from the right, sits. */ +export function* enter(): Generator { + yield GONE() + yield* run(W - 1, HOME, 3, 'L') + yield* hold(S({ body: 'stand' }), 3) + yield* hold(NEUTRAL(), 4) +} + +/** + * Asleep, endless. Settles down first (from sitting, where enter and look + * both leave it), then dozes; every so often it lifts its eyes, glances + * around, looks at you, wags, and dozes off again. + */ +export function* sleep(): Generator { + yield NEUTRAL() + yield* hold(S({ eyes: 'shut' }), 6) // drowsy, still sitting + yield* hold(S({ body: 'lie', eyes: 'open' }), 3) + for (let f = 0; ; f++) { + const p = f % 200 + if (p >= 120 && p < 176) { + // the glance + const q = p - 120 + const head = q >= 16 && q < 36 ? 'front' : 'side' + const eyes: Eyes = [10, 11, 44, 45].includes(q) ? 'shut' : 'open' + yield S({ body: 'lie', eyes, head, tail: wag(Math.floor(q / 3)) }) + } else { + const tail = p >= 40 && p < 72 ? wag(Math.floor(f / 8)) : 0 // a lazy thump + // two z's, rising two rows a step + const zs: Glyph[] = [Math.floor(f / 12) % 3, (Math.floor(f / 12) + 1) % 3].map(ph => [10 + ph, 4 - 2 * ph, 'z']) + yield S({ body: 'lie', eyes: 'shut', tail, glyphs: zs }) + } + } +} + +/** Asleep → awake: eyes, head up, onto its feet, sits. */ +export function* wake(): Generator { + yield* hold(S({ body: 'lie', eyes: 'shut' }), 3) + yield* hold(S({ body: 'lie', eyes: 'open' }), 4) + yield* hold(S({ body: 'lie', eyes: 'open', head: 'front' }), 4) + yield* hold(S({ body: 'stand' }), 3) + yield NEUTRAL() +} + +/** + * Awake, endless: sits facing you, tail going. A blink every cycle, and the + * glance left and right only on one cycle in four, as the goose does: + * watching someone type is mostly stillness, and a glance every few seconds + * reads as a fidget. + */ +export function* look(): Generator { + for (let f = 0; ; f++) { + const p = f % 120 + const glancing = Math.floor(f / 120) % GLANCE_EVERY === 0 + const eyes: Eyes = + glancing && p >= 20 && p < 34 ? 'left' + : glancing && p >= 60 && p < 74 ? 'right' + : p === 95 || p === 96 ? 'shut' + : 'open' + yield S({ eyes, tail: wag(Math.floor(f / 4)) }) + } +} + +/** Awake → the speaking position: a blink, ears up, a quick wag. */ +export function* rise(): Generator { + yield NEUTRAL() + yield* hold(S({ eyes: 'shut' }), 2) + for (const tail of [1, 2, 1, 2, 1, 0]) yield S({ tail }) + yield NEUTRAL() +} + +/** advice: off to the right, back with a bone, drop it, look at you. */ +function* fetch(text: string): Generator { + yield* hold(S({ body: 'stand', facing: 'R' }), 3) + yield* run(HOME, W + 1, 3, 'R') + yield* hold(GONE(), 6) + yield* run(W - 1, HOME, 3, 'L', true, { mouth: 'bone' }) + yield* hold(S({ body: 'stand', mouth: 'bone', bone: carried(HOME) }), 3) + const bx = HOME + 2 // centred under the chin + const held: Bone = [bx, BONE_MOUTH_Y, 'whole'] + const down: Bone = [bx, BONE_GROUND_Y, 'whole'] + for (let f = 0; f < 4; f++) yield S({ mouth: 'bone', bone: held, tail: wag(f) }) + yield S({ mouth: 'open', bone: held, tail: 1 }) // lets go... + yield S({ mouth: 'open', bone: down, tail: 2, specks: [[bx - 1, 10, 'u'], [bx + 6, 10, 'u']] }) // ...thump + for (let f = 0; f < 4; f++) yield S({ bone: down, tail: wag(f) }) + let i = 0 + for (const st of typing(text, ['open', 'shut'], { bone: down, tone: 'advice' })) { + yield { ...st, tail: wag(i++) } + } + for (let f = 0; f < HOLD; f++) { + yield S({ + bone: down, tail: wag(Math.floor(f / 2)), + eyes: f % 24 === 16 || f % 24 === 17 ? 'shut' : 'open', + said: text, shown: text.length, tone: 'advice', + }) + } + // the message is done: it picks the bone back up and eats it in three + // bites, so the scene ends exactly where it began + yield* hold(S({ mouth: 'open', bone: down }), 2) + const next: Record = { whole: 'bitten', bitten: 'nub', nub: null } + for (const size of ['whole', 'bitten', 'nub'] as const) { + yield* hold(S({ mouth: 'bone', bone: [bx, BONE_MOUTH_Y, size] }), 4) + for (let c = 0; c < 2; c++) { + // crunch + const left = next[size] + yield S({ + mouth: 'open', bone: left ? [bx, BONE_MOUTH_Y, left] : null, + specks: [[bx + 4 + c, 8 + 2 * c, 'b']], + }) + } + } + yield* hold(S({ mouth: 'shut', tail: 1 }), 2) + yield* hold(S({ mouth: 'shut', tail: 2 }), 2) +} + +/** The squid's top row on the ground. */ +const SQ_Y = FLOOR - SQUID_H + 1 +/** All the way on screen, at the left edge. */ +const SQ_STOP = 0 +/** The dog stands two columns back to face it. */ +const BARK_X = HOME + 2 + +/** + * blocked: a squid wanders all the way in and lingers; the dog barks, the + * squid jumps in fright and skedaddles, the dog sees it off, then sits proud + * and says it. + */ +function* chase(text: string): Generator { + yield* hold(S({ eyes: 'left' }), 4) // something's coming + yield* hold(S({ body: 'stand', x: BARK_X }), 3) // up on its feet + for (let i = 0, sx = -6; sx <= SQ_STOP; i++, sx++) { + // the squid wanders in + yield S({ body: 'stand', x: BARK_X, squid: [sx, SQ_Y, 'right', i] }) + } + const sq = (eyes: SquidEyes, y = SQ_Y, legs = 0): Squid => [SQ_STOP, y, eyes, legs] + yield* hold(S({ body: 'stand', x: BARK_X, squid: sq('fwd') }), 4) // it lingers + yield* hold(S({ body: 'stand', x: BARK_X, squid: sq('right') }), 6) // eyes the dog + yield* hold(S({ body: 'stand', x: BARK_X, squid: sq('fwd') }), 4) + const bark: Glyph[] = [[BARK_X - 1, 2, 'y']] + const fright: Glyph[] = [[SQ_STOP + 2, 2, 'y']] // over the jumping squid + yield S({ body: 'bark', x: BARK_X, squid: sq('up'), glyphs: bark }) // WOOF + yield* hold(S({ body: 'bark', x: BARK_X, squid: sq('up', SQ_Y - 2), glyphs: [...bark, ...fright] }), 2) // it jumps... + yield* hold(S({ body: 'stand', x: BARK_X, squid: sq('up', SQ_Y - 2), glyphs: fright }), 2) + yield S({ body: 'stand', x: BARK_X, squid: sq('left') }) // ...lands, turns + // and skedaddles, two columns a frame; the dog gives chase + let x = BARK_X + const away = [-1, -3, -5, null] + for (let f = 0; f < away.length; f++) { + const sx = away[f]! + x -= 1 + const loud = f % 2 === 1 + yield S({ + body: loud ? 'bark' : (['run1', 'run2'] as const)[f % 2]!, x, + squid: sx !== null ? [sx, SQ_Y, 'left', f] : null, + glyphs: loud ? [[x - 1, 2, 'y']] : [], tail: 1, + specks: loud ? [] : [[x + 12, FLOOR, 'u']], + }) + } + yield* hold(S({ body: 'bark', x, glyphs: [[x - 1, 2, 'y']] }), 3) + yield* hold(S({ body: 'stand', x }), 3) // ...and stay out + yield* run(x, HOME, 1, 'R') // trots home + yield* hold(S({ body: 'proud', eyes: 'shut', tail: PROUD_TAIL }), 6) // proud + yield* typing(text, ['open', 'shut'], { body: 'proud', eyes: 'shut', tail: PROUD_TAIL, tone: 'blocked' }) + for (let f = 0; f < HOLD; f++) { + const twinkle: Glyph[] = f % 16 < 8 ? [[17, 4, 'w']] : [] // pleased + yield S({ + body: 'proud', eyes: f >= 20 && f < 30 ? 'open' : 'shut', tail: PROUD_TAIL, + said: text, shown: text.length, tone: 'blocked', glyphs: twinkle, + }) + } + yield* hold(S({ eyes: 'shut' }), 2) +} + +/** + * proposed: something new. Ears up, a look both ways, a "!" over its head, + * and it spins round twice for joy; then it says it and waits with a "?" + * bobbing over its head, long enough to reach for a button. + */ +function* present(text: string): Generator { + yield* hold(S({ eyes: 'left' }), 4) + yield* hold(S({ eyes: 'right' }), 4) + yield* hold(S({ tail: 1, glyphs: [[HEAD_X, ABOVE_HEAD, 'y']] }), 8) + for (let i = 0; i < 4; i++) { + // round and round + const twinkle: Glyph[] = [[HEAD_X - 4 + 8 * (i % 2), ABOVE_HEAD, 'w']] + yield* hold(S({ body: 'stand', facing: i % 2 === 0 ? 'R' : 'L', tail: wag(i), glyphs: twinkle }), 3) + } + yield* hold(S({ tail: 1 }), 3) + let i = 0 + for (const st of typing(text, ['open', 'shut'], { tone: 'proposed' })) { + yield { ...st, tail: wag(i++) } + } + for (let g = 0; g < ASK; g++) { + const bob = Math.floor(g / 10) % 2 ? ABOVE_HEAD - 2 : ABOVE_HEAD + const eyes: Eyes = g % 60 === 50 || g % 60 === 51 ? 'shut' : g % 120 >= 80 && g % 120 < 92 ? 'left' : 'open' + yield S({ + eyes, tail: wag(Math.floor(g / 4)), said: text, shown: text.length, + tone: 'proposed', glyphs: [[HEAD_X, bob, 'q']], + }) + } +} + +/** Starts and ends on the speaking position, so it can run back to back. */ +export function* speak(text: string, tone: Tone = 'advice'): Generator { + yield NEUTRAL() + yield* tone === 'blocked' ? chase(text) : tone === 'proposed' ? present(text) : fetch(text) + yield NEUTRAL() +} + +/** + * A click: a happy squint and a pant, tail going hard, while two hearts + * float up off its head. Starts and ends on look's first frame. + */ +export function* pet(): Generator { + const hearts = (k: number): Glyph[] => [[HEAD_X - 3, ABOVE_HEAD - 2 * k, 'h'], [HEAD_X + 3, ABOVE_HEAD - 2 * k, 'h']] + yield NEUTRAL() + for (let f = 0; f < 16; f++) { + yield S({ eyes: 'shut', mouth: Math.floor(f / 4) % 2 === 0 ? 'open' : 'shut', tail: wag(f), glyphs: hearts(Math.floor(f / 8)) }) + } + yield* hold(S({ eyes: 'shut', tail: 1, glyphs: hearts(1) }), 6) + yield* hold(S({ tail: 1 }), 6) + yield NEUTRAL() +} + +/** + * The speaking position → offscreen: a goodbye wag, then off to the right, + * and stays gone a while — without the empty beat it would trot straight + * back on, which reads as a glitch rather than a trip. + */ +export function* leave(): Generator { + yield NEUTRAL() + for (const tail of [1, 2, 1]) yield S({ tail }) // a goodbye wag + yield* hold(S({ body: 'stand', facing: 'R' }), 3) + yield* run(HOME, W + 1, 3, 'R') // last frame: tail tip at col 20 + yield* hold(GONE(), GONE_FRAMES) +} + +export const SAMPLE: Readonly> = { + advice: 'Rule fired: run only the touched test suites', + blocked: 'Blocked: never force-push to a shared branch', + proposed: 'New rule↗ proposed, not active yet: Pin the MCP server when spawning claude -p', +} + +/** The ring the mod walks, for `/shiba demo`. */ +export function* cycle(): Generator { + for (;;) { + yield* enter() + const s = sleep() + for (let i = 0; i < 320; i++) yield s.next().value as Frame + yield* wake() + const lk = look() + for (let i = 0; i < 130; i++) yield lk.next().value as Frame + yield* rise() + yield* speak(SAMPLE.advice) + yield* speak(SAMPLE.blocked, 'blocked') + yield* speak(SAMPLE.proposed, 'proposed') + yield* leave() + } +} + +export const POSES: Readonly Generator>> = { + enter, sleep, wake, look, rise, leave, + advice: () => speak(SAMPLE.advice), + blocked: () => speak(SAMPLE.blocked, 'blocked'), + propose: () => speak(SAMPLE.proposed, 'proposed'), + pet, +} diff --git a/plugins/memhub/companion/hooks.json b/plugins/memhub/companion/hooks.json index 5ff1873f..85c70f54 100644 --- a/plugins/memhub/companion/hooks.json +++ b/plugins/memhub/companion/hooks.json @@ -1,4 +1,6 @@ { - "description": "The MemHub companion above the prompt: one animal (/goose by default, /hippo, /penguin, and any other in animals/), asleep while idle, looking around while Claude answers or you type, and up announcing each MemHub rule that fires — crossly when one blocks a call.", - "modules": ["./register.ts"] + "description": "The MemHub companion above the prompt: one animal a session (/goose, /hippo, /penguin, /shiba, and any other in animals/), picked from the session id unless one is pinned (`/ always`; `random`, `never` and `include` shape the pick), asleep while idle, looking around while Claude answers or you type, up announcing each MemHub rule that fires — crossly when one blocks a call — and, whichever animal it is, hopping up with a new rule the harness proposed, asking Activate / Reject / Later on buttons inside its bubble (1, 2, 3). Clicking the ♥ beside it pets it, and a proposal's rule↗ opens that rule in MemHub Studio.", + "modules": [ + "./register.ts" + ] } diff --git a/plugins/memhub/companion/proposed.ts b/plugins/memhub/companion/proposed.ts new file mode 100644 index 00000000..5abf5ffe --- /dev/null +++ b/plugins/memhub/companion/proposed.ts @@ -0,0 +1,74 @@ +// The rules the MemHub harness filed for review from this session and still +// waiting, as the server lists them (`scripts/rule_decide.py proposed`). The +// server is the only record of a filing (harness-tied-memory-spec §3.4a): the +// harness's fork files through `create_rule` and writes nothing locally. + +export type Proposed = { + title: string + ruleId: string + env: string + /** Where it opens in MemHub Studio, once rule_decide.py has said; '' for nowhere. */ + url?: string +} + +/** rule_decide.py's `proposed` answer; anything malformed is nothing to announce. */ +export function proposedOf(stdout: string): Proposed[] { + let got: unknown + try { + got = JSON.parse(stdout.trim().split('\n').pop() || '{}') + } catch { + return [] + } + const rows = (got as { proposed?: unknown })?.proposed + if (!Array.isArray(rows)) return [] + return rows + .filter((r): r is Record => !!r && typeof r === 'object' && !Array.isArray(r)) + .map(r => ({ title: String(r.title ?? '').trim(), ruleId: String(r.rule_id ?? ''), env: String(r.env ?? '') })) + .filter(p => p.ruleId) +} + +/** + * What the animal says: that a rule was proposed and is not active yet, and + * which one. Its `rule↗` (RULE_WORD) opens the rule in MemHub Studio: a + * pressable word drawn over that word; the buttons go on the row under it. + */ +export function proposalSaid(p: Proposed): string { + return `New ${RULE_WORD} proposed, not active yet: ${p.title || 'an untitled rule'}` +} + +/** + * The word of the proposal that opens the rule in MemHub Studio, with the ↗ + * that says so: a pressable label cannot be underlined until the pointer is + * over it, so the mark is what makes it read as a link. + */ +export const RULE_WORD = 'rule↗' + +/** + * The rule's name, at most `width` cells, cut with … to fit. It is drawn as + * plain text, never Markdown: a terminal without hyperlinks draws a Markdown + * link as its text AND its URL, which does not fit where the name goes. + */ +export function nameOf(p: Proposed, width: number): string { + const name = p.title || 'untitled rule' + return name.length > width ? `${name.slice(0, Math.max(1, width - 1))}…` : name +} + +/** What rule_decide.py answered, as the animal says it back. */ +export type Decision = { outcome: string; msg?: string } + +export function decisionSaid(p: Proposed, action: 'activate' | 'reject', d: Decision): string { + const title = p.title || 'the rule' + switch (d.outcome) { + case 'active': return `Activated: ${title}. It fires for the team from now on.` + case 'dismissed': return `Rejected: ${title}. It will not fire.` + case 'forbidden': + return action === 'activate' + ? `Only a rulebook admin can activate it. It is waiting for one in MemHub Studio: ${title}` + : `You cannot reject this one; a rulebook admin can, in MemHub Studio: ${title}` + case 'decided': return `Someone already decided it: ${title}` + case 'gone': return `That rule is gone from MemHub: ${title}` + case 'wrong_env': return `Filed in ${p.env || 'another'} MemHub; answer it in Studio there: ${title}` + case 'no_key': return `Log in first with /memhub:login, then answer it in MemHub Studio: ${title}` + default: return `Could not reach MemHub (${d.msg || 'error'}); it is still in Studio: ${title}` + } +} diff --git a/plugins/memhub/companion/register.ts b/plugins/memhub/companion/register.ts index a2e4c7c2..4966342b 100644 --- a/plugins/memhub/companion/register.ts +++ b/plugins/memhub/companion/register.ts @@ -2,15 +2,19 @@ import type { EngineInterface, On, Timer } from 'claude-code' import type { Animal, Build, Painted, Tone } from './animal' import { ANIMALS, DEFAULT_ANIMAL } from './animals' +import { type Decision, decisionSaid, nameOf, type Proposed, proposalSaid, proposedOf, RULE_WORD } from './proposed' import { type Fired, firedOf } from './rules' -import { compose, encode, fitBubble, type Layout, layoutOf, SCALES } from './screen' +import { BUB_BG_HEX, compose, encode, fitBubble, footerOf, type Layout, layoutOf, petAt, SCALES, wordAt } from './screen' +import { pick, poolOf, prefsOf, type Prefs, remember, sessionsOf } from './selection' /** * The companion in the band above the prompt: MemHub's face in the session, * there to show what the plugin is doing for it. It sleeps while nothing * happens, looks around while Claude answers or you type, and rises to speak * when a MemHub rule fires, announcing the rule — calmly for an advisory, - * crossly for a call the rule stopped. + * crossly for a call the rule stopped — and when the harness has proposed a + * new rule, presenting it and asking: Activate, Reject or Later, as buttons + * inside its bubble (1, 2, 3 from an empty prompt) until it is answered. * * This is the whole of what watches the session. Which animal does the * sleeping and speaking is the Animal contract's business (see animal.ts): @@ -18,26 +22,51 @@ import { compose, encode, fitBubble, type Layout, layoutOf, SCALES } from './scr * else about it. Every pose plays whole; it cuts in only where the animal is * merely waiting (asleep, looking). * - * `/hippo`, `/goose`, ... each switch to that animal and turn the band on; - * every one also takes `off`, `on`, `auto`, and — where the animal offers a - * demo — `demo` and its own pose names. + * Which animal shows is the session's own (see selection.ts): picked from the + * session id unless someone pinned one, and changed for this session alone by + * `/hippo`, `/goose`, ... — each of which also takes `always` (pin it for + * every new session), `random` (drop the pin), `never` / `include` (leave it + * out of, or put it back in, the random pick), `off`, `on`, `auto`, and — + * where the animal offers a demo — `demo` and its own pose names. + * + * A click on the ♥ beside its ground pets it. The animal itself cannot take + * the click: a Raster takes none, and a surface module laid over it to take + * them froze the pixels under it, while one laid beneath it never got them. A + * proposal's `rule↗` opens the rule in MemHub Studio. */ const FPS = 20 const RASTER_KEY = 'companion' const STORE_ENABLED = 'companion.enabled' -const STORE_ANIMAL = 'companion.animal' +// `companion.animal`, the old global pick, is neither read nor written: most +// of it was `/goose` run to turn the band on, not a choice. Older versions +// still read it, so it is left where it is. +const STORE_PIN = 'companion.pin' +const STORE_NEVER = 'companion.never' +const STORE_SESSIONS = 'companion.sessions' const STORE_SCALE = 'companion.scale' /** How long a keystroke keeps the animal looking, and a finished turn too. */ const LINGER_FRAMES = 3 * FPS -/** Fires waiting to be announced; past this the oldest waiting one drops. */ +/** Announcements waiting their turn; past this the oldest waiting one drops. */ const MAX_QUEUED = 3 +/** The key of the ♥ at the animal's feet that pets it. */ +const PET_KEY = 'companion-pet' +/** How long a click waits for the animal to be free to be petted; then it drops. */ +const PET_PENDING_FRAMES = 5 * FPS +/** The director's own pet, for an animal without one: hearts over `look`. */ +const FALLBACK_PET_FRAMES = 30 +const HEART_RGB: [number, number, number] = [240, 124, 150] +/** How long after a /clear or /resume the frame clock keeps asking for the new session id. */ +const REPICK_FRAMES = 10 * FPS /** A pose the director asks the animal for, plus its own idle `offscreen`. */ -type PoseName = 'offscreen' | 'enter' | 'sleep' | 'wake' | 'look' | 'rise' | 'speak' | 'leave' | 'demo' +type PoseName = 'offscreen' | 'enter' | 'sleep' | 'wake' | 'look' | 'rise' | 'speak' | 'leave' | 'demo' | 'pet' type Segment = { name: PoseName; frames: Iterator } +/** One thing the animal has to say, in the bubble's tone. */ +type Said = { text: string; tone: Tone } + /** What the session wants of the animal right now. */ type Want = 'sleep' | 'look' | 'speak' @@ -47,7 +76,25 @@ type Band = { /** The animal's art for the size being drawn; its poses are the ones running. */ build: Build fires: number - queue: Fired[] + queue: Said[] + /** Proposed rules waiting on the person's answer; the first has the buttons. */ + asks: Proposed[] + /** Rule ids already announced this session: the server lists a proposal until it is decided. */ + announced: Set + /** An answer is on its way to MemHub; the buttons wait for it. */ + isDeciding: boolean + /** The demo is showing a proposal, so it shows the buttons too. */ + hasDemoAsk: boolean + /** What a press in the demo would have done, said in place of doing it. */ + demoNote: string | null + /** The frame the demo's note gives the buttons back. */ + demoNoteUntil: number + /** Where the buttons sat when last drawn (see buttonsAt), so a move redraws. */ + buttonsKey: string + /** Where the proposal's linked `rule` sat when last drawn (see wordOf), likewise. */ + wordKey: string + /** The tone of what the animal is saying now, while a `speak` plays. */ + speakingTone: Tone | null isEnabled: boolean /** A forced pixel size, or null to let the room pick as hippo_half.py does. */ scale: number | null @@ -68,6 +115,30 @@ type Band = { requestId: string | null layout: Layout | null lastCells: string + /** The session the animal was picked for. */ + sessionId: string + /** + * A /clear or /resume moved the process to another session: pick again once + * its id is readable — tried on every prompt edit and turn until then, and + * by the frame clock until `repickUntil`. + */ + needsRepick: boolean + repickUntil: number + isPicking: boolean + /** Bumped by every command that picks an animal, so a pick already in flight yields to it. */ + pickGen: number + /** An animal picked by a command before the engine reported the new session's id: it is that session's. */ + pendingPick: string | null + /** A click waits, until this frame, for the animal to be free to be petted. */ + petUntil: number + /** The frame the director's own pet began, for its hearts. */ + petAt: number + /** Got it: the speech's bubble is hidden and its holds skipped. */ + isHushed: boolean + /** The last frame a hushed speech showed, so a hold's repeats are skipped. */ + hushedFrame: string + /** A page is being opened in the browser; another press waits for it. */ + isOpening: boolean } export function register(on: On) { @@ -76,6 +147,15 @@ export function register(on: On) { build: DEFAULT_ANIMAL.builds[0]!, fires: 0, queue: [], + asks: [], + announced: new Set(), + isDeciding: false, + hasDemoAsk: false, + demoNote: null, + demoNoteUntil: 0, + buttonsKey: '', + wordKey: '', + speakingTone: null, isEnabled: true, scale: null, roomRows: null, @@ -91,6 +171,17 @@ export function register(on: On) { requestId: null, layout: null, lastCells: '', + sessionId: '', + needsRepick: false, + repickUntil: 0, + isPicking: false, + pickGen: 0, + pendingPick: null, + petUntil: 0, + petAt: 0, + isHushed: false, + hushedFrame: '', + isOpening: false, } on('session.start', async ($, e, next) => { @@ -101,8 +192,15 @@ export function register(on: On) { band.isEnabled = (await $.store.get(STORE_ENABLED).catch(() => undefined)) !== false const savedScale = await $.store.get(STORE_SCALE).catch(() => undefined) band.scale = SCALES.includes(savedScale as never) ? (savedScale as number) : null - const saved = await $.store.get(STORE_ANIMAL).catch(() => undefined) - band.animal = ANIMALS.find(a => a.name === saved) ?? DEFAULT_ANIMAL + band.sessionId = await sessionIdOf($) + // The engine has already drawn the band by now — for the fallback animal, + // at no saved scale, enabled — and nothing redraws it on its own: switch + // the art as well as the name, or the fallback walks in and stays until + // the terminal is next resized (a tmux split was how it showed). + const animal = await pickFor($, band.sessionId) + if (animal !== band.animal || buildFor(animal, band.scale) !== band.build) { + switchTo(band, animal) + } for (const animal of ANIMALS) { await $.command .register({ @@ -113,6 +211,9 @@ export function register(on: On) { }) .catch(() => undefined) } + // and redraw whatever the store changed: the animal, the scale's layout, + // or a companion turned off, which draws nothing at all + $.ui.invalidate('ui.render') if (band.isEnabled) { start($, band) } @@ -125,6 +226,9 @@ export function register(on: On) { if (arg === 'off') { band.isEnabled = false stop(band) + // the frame clock stops with the band: a click from before must not + // pet the animal once it is back + band.petUntil = 0 await $.store.set(STORE_ENABLED, false).catch(() => undefined) $.ui.invalidate('ui.render') return { text: `The ${animal.name.toLowerCase()} goes away. /${commandOf(animal)} brings it back.` } @@ -137,29 +241,73 @@ export function register(on: On) { const fits = band.layout ? ` (showing ${band.layout.s})` : '' return { text: `${commandOf(animal)}: pixel size ${band.scale ?? 'auto'}${fits}` } } - const isDemo = arg !== '' && arg !== 'on' && arg !== 'auto' + const key = commandOf(animal) + if (arg === 'random') { + await $.store.delete(STORE_PIN).catch(() => undefined) + return { text: `${key}: new sessions pick their own animal from the session id` } + } + if (arg === 'never' || arg === 'include') { + const prefs = await prefsFor($) + const never = prefs.never.filter(n => n !== key) + if (arg === 'never') { + if (ANIMALS.every(a => a === animal || never.includes(commandOf(a)))) { + return { text: `${key}: it is the last animal in the random pick; include another first` } + } + never.push(key) + if (prefs.pin === key) await $.store.delete(STORE_PIN).catch(() => undefined) + } + await $.store.set(STORE_NEVER, never).catch(() => undefined) + return { text: arg === 'never' ? `${key}: left out of the random pick` : `${key}: back in the random pick` } + } + const isAlways = arg === 'always' + const isDemo = arg !== '' && arg !== 'on' && arg !== 'auto' && !isAlways const demoBuild = buildFor(animal, band.scale) if (isDemo && !(demoBuild.demo && (arg === 'demo' || (demoBuild.demoPoses ?? []).includes(arg)))) { return { text: `usage: /${commandOf(animal)} ${usageOf(animal)}` } } + // this command decides the session's animal: a pick still in flight for + // it yields, and one the engine's new session id (after a /clear or a + // /resume) was waiting for is this one + band.pickGen += 1 + const id = await sessionIdOf($) + let isPending = false + if (id && id !== band.sessionId) { + band.sessionId = id + band.needsRepick = false + band.repickUntil = 0 + } else if (band.needsRepick) { + // typed in the new session before the engine said its id: kept for it, + // and not written under the old session's id + band.pendingPick = key + isPending = true + } const isSwitch = band.animal !== animal || !band.isEnabled band.isEnabled = true band.animal = animal await $.store.set(STORE_ENABLED, true).catch(() => undefined) - await $.store.set(STORE_ANIMAL, animal.name).catch(() => undefined) + if (band.sessionId && !isPending) { + const sessions = sessionsOf(await $.store.get(STORE_SESSIONS).catch(() => undefined)) + await $.store.set(STORE_SESSIONS, remember(sessions, band.sessionId, key)).catch(() => undefined) + } + if (isAlways) { + const prefs = await prefsFor($) + await $.store.set(STORE_PIN, key).catch(() => undefined) + await $.store.set(STORE_NEVER, prefs.never.filter(n => n !== key)).catch(() => undefined) + } if (isDemo) { + // another animal's demo plays in that animal's own art, from the start + band.build = buildFor(animal, band.scale) play(band, arg) } else if (isSwitch) { - // a new animal starts offscreen: it is not standing where the last one stood - band.mode = 'auto' - band.build = buildFor(animal, band.scale) - band.seg = segment(band, 'offscreen') - band.current = { canvas: [] } + switchTo(band, animal) } else if (band.mode !== 'auto') { play(band, 'auto') } start($, band) $.ui.invalidate('ui.render') + if (isAlways) { + return { text: `${key}: every new session starts with ${titleOf(animal).toLowerCase()} (\`/${key} random\` undoes it)` } + } return { text: band.mode === 'auto' @@ -172,6 +320,7 @@ export function register(on: On) { on('prompt.edit', ($, e, next) => { band.activeUntil = band.frame + LINGER_FRAMES nudge(band) + if (band.needsRepick) void repick($, band) return next(e) }) @@ -179,6 +328,20 @@ export function register(on: On) { const result = await next(e) band.isWorking = true nudge(band) + if (band.needsRepick) void repick($, band) + return result + }) + + // A /clear or a /resume goes on under another session id with no + // session.start: that session shows its own animal, picked once the engine + // answers with its id. + on('session.end', async ($, e, next) => { + const result = await next(e) + if (e.reason === 'clear' || e.reason === 'resume') { + band.needsRepick = true + band.repickUntil = band.frame + REPICK_FRAMES + void repick($, band) + } return result }) @@ -195,13 +358,23 @@ export function register(on: On) { // chain, so `next(e)` is what they answered for this call. on('classic.PreToolUse', async ($, e, next) => { const result = await next(e) - announce(band, firedOf(result.additionalContext ?? [], result.deny ?? result.ask)) + announce(band, firedOf(result.additionalContext ?? [], result.deny ?? result.ask).map(saidOfFire)) return result }) on('classic.PostToolUse', async ($, e, next) => { const result = await next(e) - announce(band, firedOf(result.additionalContext ?? [], result.block)) + announce(band, firedOf(result.additionalContext ?? [], result.block).map(saidOfFire)) + return result + }) + + // A rule the harness's background fork filed lands on the server minutes + // after the Stop that launched it, and nothing records it locally. So each + // Stop asks the server, without holding the Stop, and announces what this + // session has not announced yet. + on('classic.Stop', async ($, e, next) => { + const result = await next(e) + if (band.isEnabled && e.session_id) void announceProposed($, band, e.session_id) return result }) @@ -221,7 +394,12 @@ export function register(on: On) { band.roomMax = e.props.maxRows band.roomRows = null } - const room = Math.max(1, Math.min(e.props.maxRows, band.roomRows ?? e.props.maxRows)) + const ask = askOf(band) + // A waiting proposal's buttons sit inside its bubble, where Got it goes. + // Once the animal has said it and gone, they wait on a row of their own + // under it; that row is kept while one waits, so the drawing never jumps. + const extra = band.mode === 'auto' && band.asks.length > 0 ? 1 : 0 + const room = Math.max(1, Math.min(e.props.maxRows, band.roomRows ?? e.props.maxRows) - extra) const build = buildFor(band.animal, band.scale) if (build !== band.build) { // different art, different frames: it starts over rather than cutting @@ -236,19 +414,93 @@ export function register(on: On) { return Text({ dimColor: true, children: `make the terminal a little bigger for the ${name}` }) } const { bodyRows } = e.props.scroll - if (bodyRows > 0 && bodyRows < band.layout.rows && band.roomRows !== bodyRows) { + if (bodyRows > 0 && bodyRows < band.layout.rows + extra && band.roomRows !== bodyRows) { band.roomRows = bodyRows $.ui.invalidate('ui.render') } band.lastCells = cellsOf(band) ?? '' + const raster = Raster({ + key: RASTER_KEY, + columns: band.layout.columns, + rows: band.layout.rows, + cells: band.lastCells, + }) + const { Button } = $.ui.resolve(e) + // opens a rule's page in MemHub Studio. A pressable word rather than a + // link: a terminal without hyperlinks draws a Link or a Markdown link as + // its text and then its URL, which ran down the bubble past its bottom. + const opens = (url: string) => () => void openUrl($, band, url) + // a ♥ just left of where the ground begins, on its row: a click on it pets + // the animal. No hotkey: a plain Button draws one as `p: ♥`. + const pet = petAt(build, band.layout) + const clicks = Box({ + key: 'companion-clicks', position: 'absolute', top: pet.top, left: pet.left, + children: Button({ key: PET_KEY, plain: true, dimColor: true, label: '♥', onPress: () => onClick(band) }), + }) + // a proposal's `rule`, once typed, opens the rule in MemHub Studio: a + // plain Button labelled `rule`, over the word itself, on the bubble's own + // background, underlined under the pointer + const word = wordOf(band) + band.wordKey = keyOfWord(word) + const linked = word + ? [Box({ + key: 'rule-word', position: 'absolute', top: word.at.row, left: word.at.col, width: RULE_WORD.length, + backgroundColor: BUB_BG_HEX, + children: Button({ + key: 'rule-link', plain: true, label: RULE_WORD, hover: { underline: true }, onPress: opens(word.ask.url), + }), + })] + : [] + const at = buttonsAt(band, build, band.layout) + band.buttonsKey = keyOfButtons(at) + if (!ask || !at) { + return Box({ justifyContent: 'flex-end', children: Box({ children: [raster, clicks, ...linked] }) }) + } + const footer = at === 'below' ? null : at + // under the animal, the rule's name opens it (#67's link), as `rule` does + const name = (width: number) => + ask.url + ? Button({ key: 'rule-name', plain: true, label: nameOf(ask, width), hover: { underline: true }, onPress: opens(ask.url) }) + : Text({ children: nameOf(ask, width) }) + const isDemo = ask === DEMO_ASK + const answer = (action: 'activate' | 'reject') => + isDemo ? () => demoPress($, band, action) : () => void decide($, band, ask, action) + // plain, so each reads `1: Activate`: the digit is the key to press + const controls = band.isDeciding + ? [Text({ color: '#968ca5', children: 'asking MemHub…' })] + : isDemo && band.demoNote + ? [Text({ color: '#968ca5', wrap: 'truncate-end', children: band.demoNote })] + : [ + Button({ key: 'rule-activate', hotkey: '1', plain: true, label: 'Activate', onPress: answer('activate') }), + Button({ key: 'rule-reject', hotkey: '2', plain: true, label: 'Reject', onPress: answer('reject') }), + Button({ + key: 'rule-later', hotkey: '3', plain: true, dimColor: true, label: 'Later', + onPress: isDemo ? () => demoPress($, band, 'later') : () => later($, band), + }), + ] + if (footer) { + return Box({ + justifyContent: 'flex-end', + children: Box({ + children: [ + raster, + clicks, + ...linked, + Box({ + position: 'absolute', top: footer.row, left: footer.col, width: footer.width, + backgroundColor: BUB_BG_HEX, gap: 2, children: controls, + }), + ], + }), + }) + } return Box({ - justifyContent: 'flex-end', - children: Raster({ - key: RASTER_KEY, - columns: band.layout.columns, - rows: band.layout.rows, - cells: band.lastCells, - }), + flexDirection: 'column', + alignItems: 'flex-end', + children: [ + Box({ children: [raster, clicks, ...linked] }), + Box({ key: 'rule-waiting', gap: 2, children: [Text({ dimColor: true, children: 'new rule waiting:' }), name(40), ...controls] }), + ], }) }) } @@ -273,23 +525,285 @@ function buildFor(animal: Animal, scale: number | null): Build { } const usageOf = (animal: Animal) => - ['on', 'off', 'auto', 'scale 1-4', + ['on', 'off', 'auto', 'always', 'random', 'never', 'include', 'scale 1-4', ...(animal.builds[0]?.demo ? ['demo', ...(animal.builds[0]?.demoPoses ?? [])] : [])].join('|') function wantOf(band: Band): Want { if (band.queue.length > 0) return 'speak' - return band.isWorking || band.frame < band.activeUntil ? 'look' : 'sleep' + return band.isWorking || band.frame < band.activeUntil || isPetting(band) ? 'look' : 'sleep' +} + +/** A click is waiting to be answered with a pet. */ +const isPetting = (band: Band) => band.frame < band.petUntil + +async function sessionIdOf($: EngineInterface): Promise { + const id = await $.session.id().catch(() => '') + return typeof id === 'string' ? id : '' +} + +async function prefsFor($: EngineInterface): Promise { + return prefsOf( + await $.store.get(STORE_PIN).catch(() => undefined), + await $.store.get(STORE_NEVER).catch(() => undefined), + ) +} + +/** The animal a session shows: its own pick, the pin, or its id's hash (selection.ts). */ +async function pickFor($: EngineInterface, sessionId: string): Promise { + const sessions = sessionsOf(await $.store.get(STORE_SESSIONS).catch(() => undefined)) + const name = pick(ANIMALS.map(a => a.name), sessionId, sessions, await prefsFor($)) + return ANIMALS.find(a => a.name === name) ?? DEFAULT_ANIMAL +} + +/** Another animal: it starts offscreen, not standing where the last one stood. */ +function switchTo(band: Band, animal: Animal) { + band.animal = animal + band.mode = 'auto' + band.build = buildFor(animal, band.scale) + band.seg = segment(band, 'offscreen') + band.current = { canvas: [] } +} + +/** + * After a /clear or /resume: once the engine reports the new session's id, + * pick its animal. Until then (the id not yet changed) it is tried again, by + * the next event, and by the frame clock for a while. A command that picks + * an animal meanwhile wins. + */ +async function repick($: EngineInterface, band: Band) { + if (band.isPicking) return + band.isPicking = true + const gen = band.pickGen + try { + const id = await sessionIdOf($) + if (!id || id === band.sessionId || gen !== band.pickGen) return + band.needsRepick = false + band.repickUntil = 0 + band.sessionId = id + const pending = band.pendingPick + band.pendingPick = null + if (pending) { + const sessions = sessionsOf(await $.store.get(STORE_SESSIONS).catch(() => undefined)) + await $.store.set(STORE_SESSIONS, remember(sessions, id, pending)).catch(() => undefined) + } + const animal = await pickFor($, id) + if (gen !== band.pickGen) return + if (animal !== band.animal) { + switchTo(band, animal) + $.ui.invalidate('ui.render') + } + } finally { + band.isPicking = false + } +} + +/** A click on the ♥: a pet (see pat). */ +function onClick(band: Band) { + if (!band.isEnabled) return + pat(band) +} + +/** + * What a click does to the animal. Asleep, it wakes to be petted; looking, it + * is petted at once; saying a rule, the bubble goes (Got it); presenting a + * proposal, nothing — that waits for its answer. Anywhere else the pet waits + * until the animal is free, for a few seconds. A demo takes no pets. + */ +function pat(band: Band) { + if (band.mode !== 'auto') return + const name = band.seg.name + if (name === 'pet') return + if (name === 'speak') { + if (band.speakingTone !== 'proposed') band.isHushed = true + return + } + band.petUntil = band.frame + PET_PENDING_FRAMES + nudge(band) +} + +/** + * A rule's page in the browser, with the platform's opener; one at a time. + * Where there is none (a remote shell, a container) nothing happens. + */ +async function openUrl($: EngineInterface, band: Band, url: string) { + if (band.isOpening) return + band.isOpening = true + try { + await openWith($, url) + } finally { + band.isOpening = false + } +} + +/** The platform's opener: `open`, else `xdg-open`; where neither works, nothing. */ +async function openWith($: EngineInterface, url: string) { + for (const opener of ['open', 'xdg-open']) { + const run = await $.process.run([opener, url], { timeoutMs: 5_000 }).catch(() => null) + if (run && run.exitCode === 0) return + } +} + +/** + * Where a rule — or, with no id, the rulebook — opens in MemHub Studio, from + * rule_decide.py's `url`: harness_stop.rule_url(), the Stop notice's own + * link, against the install's own MemHub. '' when it has none. + */ +async function studioUrlOf($: EngineInterface, ruleId: string, env: string, timeoutMs: number): Promise { + const root = $.plugin.root.replace(/\/\.claude-plugin\/?$/, '') + const argv = ['python3', `${root}/scripts/rule_decide.py`, 'url', ...(ruleId ? [ruleId] : [])] + const run = await $.process.run(env ? [...argv, '--env', env] : argv, { timeoutMs }) + const got = JSON.parse(run.stdout.trim().split('\n').pop() || '{}') as { url?: unknown } + return typeof got.url === 'string' && /^https:\/\//.test(got.url) ? got.url : '' +} + +const saidOfFire = (fire: Fired): Said => ({ + text: fire.isBlocked ? `Blocked: ${fire.rule}` : `Rule fired: ${fire.rule}`, + tone: fire.isBlocked ? 'blocked' : 'advice', +}) + +async function proposedFor($: EngineInterface, session: string): Promise { + const root = $.plugin.root.replace(/\/\.claude-plugin\/?$/, '') + const argv = ['python3', `${root}/scripts/rule_decide.py`, 'proposed', '--session', session] + const run = await $.process.run(argv, { timeoutMs: 30_000 }).catch(() => null) + return run ? proposedOf(run.stdout) : [] +} + +async function announceProposed($: EngineInterface, band: Band, session: string) { + const fresh = (await proposedFor($, session)).filter(p => !band.announced.has(p.ruleId)) + if (fresh.length === 0) return + for (const p of fresh) band.announced.add(p.ruleId) + announce(band, fresh.map(p => ({ text: proposalSaid(p), tone: 'proposed' as const }))) + band.asks.push(...fresh) + for (const p of fresh) void linkOf($, p) + $.ui.invalidate('ui.render') +} + +/** + * Answer the proposal on the buttons through scripts/rule_decide.py — Studio's + * own PATCH, sent with the plugin's access key — then say how it went. The + * buttons go the moment it is sent, so a second press cannot answer twice. + */ +async function decide($: EngineInterface, band: Band, ask: Proposed, action: 'activate' | 'reject') { + if (band.isDeciding || band.asks[0] !== ask) return + band.isDeciding = true + $.ui.invalidate('ui.render') + let d: Decision = { outcome: 'error' } + try { + const root = $.plugin.root.replace(/\/\.claude-plugin\/?$/, '') + const argv = ['python3', `${root}/scripts/rule_decide.py`, ask.ruleId, action] + const run = await $.process.run(ask.env ? [...argv, '--env', ask.env] : argv, { timeoutMs: 30_000 }) + d = JSON.parse(run.stdout.trim().split('\n').pop() || '{}') as Decision + } catch (err) { + d = { outcome: 'error', msg: err instanceof Error ? err.name : 'failed' } + } finally { + band.isDeciding = false + band.asks = band.asks.filter(a => a !== ask) + } + announce(band, [{ text: decisionSaid(ask, action, d), tone: 'advice' }]) + $.ui.invalidate('ui.render') +} + +/** What the demo's proposal is; it answers nothing, so it needs no id. */ +const DEMO_ASK: Proposed = { title: 'Pin the MCP server when spawning claude -p', ruleId: '', env: '' } + +/** + * The proposal the buttons answer: the first one waiting, following the + * session; or, in `/goose demo` and `/goose propose`, the demo's own while its + * bubble is a proposal — so the demo shows the whole ask, buttons included. + */ +function askOf(band: Band): Proposed | undefined { + if (band.mode === 'auto') return band.asks[0] + return band.hasDemoAsk ? DEMO_ASK : undefined +} + +type ButtonsAt = { row: number; col: number; width: number } | 'below' | null + +/** + * Where a waiting proposal's buttons go: inside its bubble, on the row Got it + * takes, once the text is typed out; on a row under the animal once it has + * said it and is not about to again; and nowhere while it is still being + * presented, so they never show under the animal and then jump into the bubble. + */ +function buttonsAt(band: Band, build: Build, layout: Layout): ButtonsAt { + if (!askOf(band)) return null + const footer = footerOf(band.current, build, layout) + if (footer) return footer + if (band.mode !== 'auto') return null + const presenting = + band.queue.some(q => q.tone === 'proposed') || + (band.seg.name === 'speak' && band.speakingTone === 'proposed') + return presenting ? null : 'below' +} + +/** + * The proposal the bubble is saying now, and where its `rule` is, once typed + * out and when there is a Studio page to link to: in the demo its own, else + * the waiting proposal whose words these are. + */ +function wordOf(band: Band): { ask: Proposed & { url: string }; at: { row: number; col: number } } | null { + const { bubble } = band.current + if (!band.layout || bubble?.tone !== 'proposed') return null + const ask = band.mode === 'auto' + ? band.asks.find(a => fitBubble(proposalSaid(a)) === bubble.text) + : band.hasDemoAsk ? DEMO_ASK : undefined + if (!ask?.url) return null + const at = wordAt(band.current, band.build, band.layout, RULE_WORD) + return at ? { ask: ask as Proposed & { url: string }, at } : null } -function announce(band: Band, fired: readonly Fired[]) { - if (fired.length === 0) return - band.queue.push(...fired) +const keyOfWord = (word: ReturnType) => (word ? `${word.at.row},${word.at.col},${word.ask.url}` : '') + +const keyOfButtons = (at: ButtonsAt) => (at === null ? '' : at === 'below' ? 'below' : `${at.row},${at.col}`) + +/** Short enough for the bubble's footer row, where the buttons were. */ +const DEMO_SAYS: Record<'activate' | 'reject' | 'later', string> = { + activate: 'demo: would turn it on (admins only)', + reject: 'demo: would dismiss it; never fires', + later: 'demo: hides these; waits in Studio', +} +/** How long the demo's note stands in for the buttons. */ +const DEMO_NOTE_FRAMES = 2 * FPS + +/** A press in the demo says what it would do; nothing reaches MemHub. */ +function demoPress($: EngineInterface, band: Band, action: keyof typeof DEMO_SAYS) { + band.demoNote = DEMO_SAYS[action] + band.demoNoteUntil = band.frame + DEMO_NOTE_FRAMES + $.ui.invalidate('ui.render') +} + +/** + * Where the rule opens in MemHub Studio, from rule_decide.py's `url` — which + * is harness_stop.rule_url(), the Stop notice's own link, so the two agree — + * then a redraw, so the name turns into a link. '' when it has none. + */ +async function linkOf($: EngineInterface, ask: Proposed) { + if (ask.url !== undefined) return + ask.url = '' + try { + ask.url = await studioUrlOf($, ask.ruleId, ask.env, 15_000) + } catch { + ask.url = '' + } + $.ui.invalidate('ui.render') +} + +/** Later: the buttons go; the rule stays proposed, in Studio. */ +function later($: EngineInterface, band: Band) { + band.asks.shift() + $.ui.invalidate('ui.render') +} + +function announce(band: Band, said: readonly Said[]) { + if (said.length === 0) return + band.queue.push(...said) band.queue.splice(0, Math.max(0, band.queue.length - MAX_QUEUED)) nudge(band) } function segment(band: Band, name: PoseName): Segment { const { poses } = band.build + band.isHushed = false + band.hushedFrame = '' switch (name) { case 'offscreen': return { name, frames: [][Symbol.iterator]() } case 'enter': return { name, frames: poses.enter() } @@ -299,12 +813,18 @@ function segment(band: Band, name: PoseName): Segment { case 'rise': return { name, frames: poses.rise() } case 'leave': return { name, frames: poses.leave() } case 'demo': return { name, frames: band.build.demo!(band.mode === 'demo' ? null : band.mode) } + case 'pet': { + band.petUntil = 0 + band.petAt = band.frame + // a petted animal stays awake a while, then dozes off as after a turn + band.activeUntil = band.frame + LINGER_FRAMES + return { name, frames: poses.pet?.() ?? lookAround(poses.look(), FALLBACK_PET_FRAMES) } + } case 'speak': { - const fire = band.queue.shift()! + const said = band.queue.shift()! band.fires += 1 - const tone: Tone = fire.isBlocked ? 'blocked' : 'advice' - const said = fitBubble(fire.isBlocked ? `Blocked: ${fire.rule}` : `Rule fired: ${fire.rule}`) - return { name, frames: poses.speak(said, band.fires, tone) } + band.speakingTone = said.tone + return { name, frames: poses.speak(fitBubble(said.text), band.fires, said.tone) } } } } @@ -316,7 +836,8 @@ function after(band: Band, name: PoseName): PoseName { case 'offscreen': return 'enter' // enter and sleep both leave the animal asleep; wake is what opens its eyes case 'enter': return want === 'sleep' ? 'sleep' : 'wake' - case 'wake': return want === 'speak' ? 'rise' : want === 'look' ? 'look' : 'sleep' + case 'wake': return want === 'speak' ? 'rise' : isPetting(band) ? 'pet' : want === 'look' ? 'look' : 'sleep' + case 'pet': return want === 'speak' ? 'rise' : 'look' case 'rise': return want === 'speak' ? 'speak' : 'leave' case 'speak': return want === 'speak' ? 'speak' : 'leave' case 'leave': return 'enter' @@ -335,6 +856,8 @@ function nudge(band: Band) { band.seg = segment(band, 'wake') } else if (name === 'look' && want === 'speak') { band.seg = segment(band, 'rise') + } else if (name === 'look' && isPetting(band)) { + band.seg = segment(band, 'pet') } else if (name === 'look' && want === 'sleep') { band.seg = segment(band, 'sleep') } @@ -348,13 +871,48 @@ function play(band: Band, mode: string) { function advance(band: Band): unknown { for (;;) { const r = band.seg.frames.next() - if (!r.done) { - return r.value + if (r.done) { + band.seg = segment(band, after(band, band.seg.name)) + continue } - band.seg = segment(band, after(band, band.seg.name)) + if (band.isHushed) { + // Got it: the motion plays on, the holds are skipped + const seen = JSON.stringify(r.value) + if (seen === band.hushedFrame) continue + band.hushedFrame = seen + } + return r.value } } +/** + * `n` frames of an endless `look`, the last of them its first again, so the + * look that follows starts where this one ends — the ring's rule for a pet. + */ +function* lookAround(frames: Iterator, n: number): Generator { + const first = frames.next() + if (first.done) return + yield first.value + for (let i = 1; i < n - 1; i++) { + const r = frames.next() + if (r.done) break + yield r.value + } + yield first.value +} + +/** The director's own pet: two hearts rising over the animal, two canvas rows every 8 frames. */ +function withHearts(painted: Painted, build: Build, age: number): Painted { + const last = build.size.columns - 1 + const x = Math.min(last, build.bubbleAt.column + 4) + const y = Math.max(0, build.bubbleAt.row - 6 - 2 * Math.floor(age / 8)) + const hearts = [ + { x, y, ch: '♥', color: HEART_RGB }, + { x: Math.min(last, x + 3), y: Math.min(build.size.rows - 1, y + 2), ch: '♥', color: HEART_RGB }, + ] + return { ...painted, glyphs: [...(painted.glyphs ?? []), ...hearts] } +} + function cellsOf(band: Band): string | null { return band.layout ? encode(compose(band.current, band.build, titleOf(band.animal), band.layout)) @@ -377,7 +935,35 @@ async function tick($: EngineInterface, band: Band) { if (band.frame === band.activeUntil) { nudge(band) } + if (band.needsRepick && band.repickUntil > band.frame && band.frame % 10 === 0) { + void repick($, band) + } band.current = band.build.render(advance(band), band.frame) + if (band.isHushed) { + band.current = { ...band.current, bubble: null } + } + if (band.seg.name === 'pet' && !band.build.poses.pet) { + band.current = withHearts(band.current, band.build, band.frame - band.petAt) + } + // the demo's buttons come and go with its proposal bubble; a blit cannot + // draw them, so a change asks for a render + const hasDemoAsk = band.mode !== 'auto' && band.current.bubble?.tone === 'proposed' + if (hasDemoAsk !== band.hasDemoAsk) { + band.hasDemoAsk = hasDemoAsk + band.demoNote = null + if (hasDemoAsk) void linkOf($, DEMO_ASK) + $.ui.invalidate('ui.render') + } + if (band.demoNote && band.frame >= band.demoNoteUntil) { + band.demoNote = null + $.ui.invalidate('ui.render') + } + // the buttons ride the proposal bubble's footer: when they appear, move or + // go, the tree has to be drawn again, which a blit cannot do + const at = band.layout ? buttonsAt(band, band.build, band.layout) : null + if (keyOfButtons(at) !== band.buttonsKey || keyOfWord(wordOf(band)) !== band.wordKey) { + $.ui.invalidate('ui.render') + } const cells = cellsOf(band) band.frame += 1 const { requestId } = band diff --git a/plugins/memhub/companion/rules.ts b/plugins/memhub/companion/rules.ts index 595cbb74..9c112a7f 100644 --- a/plugins/memhub/companion/rules.ts +++ b/plugins/memhub/companion/rules.ts @@ -7,8 +7,9 @@ // ` (stopped), the rule already in 20 words or fewer — rulebook_hook's // `disclosure_line()`, the same string the terminal shows and the agent is // told to echo. Nothing else counts. The `- **[